Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a871d6332 | ||
|
|
6eeb4cbb1d | ||
|
|
9d57d03cbb |
+21
-3
@@ -164,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
|
||||
|
||||
|
||||
class ConditionFilter(django_filters.FilterSet):
|
||||
# Replace legacy `primary` filter with `is_canonical` and provide a
|
||||
# `synonym` filter which will filter by the canonical group of the
|
||||
# selected condition.
|
||||
is_canonical = django_filters.BooleanFilter(method="filter_is_canonical", label="Canonical")
|
||||
synonym = django_filters.ModelChoiceFilter(queryset=Condition.objects.all(), method="filter_synonym", label="Synonym group")
|
||||
|
||||
class Meta:
|
||||
model = Condition
|
||||
fields = {
|
||||
"name": ["icontains"],
|
||||
"primary": ["exact"],
|
||||
"synonym": ["exact"],
|
||||
#"parent": ["exact"],
|
||||
"subspecialty": ["exact"],
|
||||
"rcr_curriculum": ["exact"],
|
||||
}
|
||||
@@ -189,6 +192,21 @@ class ConditionFilter(django_filters.FilterSet):
|
||||
)
|
||||
pass
|
||||
|
||||
def filter_is_canonical(self, queryset, name, value):
|
||||
if value in (True, "True", "true", 1, "1"):
|
||||
return queryset.filter(canonical__isnull=True)
|
||||
if value in (False, "False", "false", 0, "0"):
|
||||
return queryset.filter(canonical__isnull=False)
|
||||
return queryset
|
||||
|
||||
def filter_synonym(self, queryset, name, value):
|
||||
# value is a Condition instance selected in the filter. Return all
|
||||
# Conditions that belong to the same canonical group as `value`.
|
||||
if not value:
|
||||
return queryset
|
||||
master = value.canonical if value.canonical else value
|
||||
return queryset.filter(Q(canonical=master) | Q(pk=master.pk))
|
||||
|
||||
|
||||
class FindingFilter(django_filters.FilterSet):
|
||||
class Meta:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def forwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
# Build connected components using existing M2M 'synonym' links
|
||||
visited = set()
|
||||
|
||||
for cond in Condition.objects.all():
|
||||
if cond.pk in visited:
|
||||
continue
|
||||
# BFS/DFS to collect component
|
||||
stack = [cond]
|
||||
comp = []
|
||||
while stack:
|
||||
c = stack.pop()
|
||||
if c.pk in visited:
|
||||
continue
|
||||
visited.add(c.pk)
|
||||
comp.append(c)
|
||||
# access existing M2M relations
|
||||
for s in c.synonym.all():
|
||||
if s.pk not in visited:
|
||||
stack.append(s)
|
||||
|
||||
# choose canonical: prefer primary==True else lowest pk
|
||||
canon = None
|
||||
for c in comp:
|
||||
if getattr(c, "primary", False):
|
||||
canon = c
|
||||
break
|
||||
if not canon:
|
||||
canon = min(comp, key=lambda x: x.pk)
|
||||
|
||||
# set canonical for all non-canonical members
|
||||
for c in comp:
|
||||
if c.pk != canon.pk:
|
||||
c.canonical_id = canon.pk
|
||||
c.save()
|
||||
|
||||
|
||||
def backwards(apps, schema_editor):
|
||||
Condition = apps.get_model("atlas", "Condition")
|
||||
for c in Condition.objects.all():
|
||||
if c.canonical_id:
|
||||
c.canonical_id = None
|
||||
c.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("atlas", "0085_rename_case_detail_caseprior_casedetail_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="condition",
|
||||
name="canonical",
|
||||
field=models.ForeignKey(
|
||||
related_name="aliases",
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
to="atlas.condition",
|
||||
blank=True,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(forwards, backwards),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-20 22:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('atlas', '0086_add_canonical'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='condition',
|
||||
name='canonical',
|
||||
field=models.ForeignKey(blank=True, help_text='If set, this Condition is an alias and points to the canonical Condition.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='aliases', to='atlas.condition'),
|
||||
),
|
||||
]
|
||||
+71
-8
@@ -116,30 +116,66 @@ class SynMixin(object):
|
||||
return f"{self.name} [syn]"
|
||||
|
||||
def get_old_str(self) -> str:
|
||||
# Prefer using a model-specific get_synonyms (e.g. Condition.get_synonyms)
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
|
||||
if self.primary:
|
||||
if self.synonym.count() == 0:
|
||||
if syns is None:
|
||||
if getattr(self, "synonym", None) is None or self.synonym.count() == 0:
|
||||
return self.name
|
||||
else:
|
||||
synonyms = ",".join([i.name for i in self.synonym.all()])
|
||||
return f"{self.name} ({synonyms})"
|
||||
else:
|
||||
if not syns:
|
||||
return self.name
|
||||
synonyms = ",".join([i.name for i in syns])
|
||||
return f"{self.name} ({synonyms})"
|
||||
else:
|
||||
return f"{self.name} [syn]"
|
||||
|
||||
def get_synonym(self):
|
||||
if self.primary:
|
||||
# Return a readable synonym string. For models that implement
|
||||
# get_synonyms (Condition with canonical/aliases), use that.
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
|
||||
if getattr(self, "primary", False):
|
||||
return "[Primary]"
|
||||
else:
|
||||
if syns is None:
|
||||
s = self.synonym.filter(primary=True).values_list("name", flat=True)
|
||||
return ", ".join(s)
|
||||
else:
|
||||
# prefer primary names among synonyms if present
|
||||
primary_names = [i.name for i in syns if getattr(i, "primary", False)]
|
||||
if primary_names:
|
||||
return ", ".join(primary_names)
|
||||
return ", ".join([i.name for i in syns])
|
||||
|
||||
def get_synonym_link(self):
|
||||
if self.primary:
|
||||
try:
|
||||
syns = self.get_synonyms()
|
||||
except Exception:
|
||||
syns = None
|
||||
|
||||
if getattr(self, "primary", False):
|
||||
return "[Primary]"
|
||||
else:
|
||||
syns = self.synonym.filter(primary=True)
|
||||
return ", ".join(
|
||||
[f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
||||
)
|
||||
if syns is None:
|
||||
# fall back to all M2M synonyms (not just primary) for models
|
||||
# that still use the old synonym field (e.g. Finding, Structure)
|
||||
syns_qs = self.synonym.all()
|
||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns_qs]
|
||||
return ", ".join(items)
|
||||
else:
|
||||
# render links for synonyms/canonical group
|
||||
items = [f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
||||
return ", ".join(items)
|
||||
|
||||
def get_link(self):
|
||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||
@@ -163,6 +199,16 @@ class Condition(SynMixin, models.Model):
|
||||
blank=True,
|
||||
help_text="Use if a direct synonym for the condition exists, e.g. 'Wegener granulomatosis' and 'Granulomatosis with Polyangitis'.",
|
||||
)
|
||||
# New canonical/alias field (Option B): if set, this Condition is an alias
|
||||
# and points to the canonical/master Condition.
|
||||
canonical = models.ForeignKey(
|
||||
"self",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="aliases",
|
||||
help_text="If set, this Condition is an alias and points to the canonical Condition.",
|
||||
)
|
||||
parent = models.ManyToManyField(
|
||||
"self",
|
||||
blank=True,
|
||||
@@ -198,6 +244,23 @@ class Condition(SynMixin, models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
|
||||
|
||||
@property
|
||||
def canonical_condition(self):
|
||||
"""Return the canonical/master condition for this Condition (self if none)."""
|
||||
return self.canonical if self.canonical else self
|
||||
|
||||
def get_synonyms(self):
|
||||
"""Return other Conditions that are synonyms/aliases for this concept.
|
||||
|
||||
Behaviour:
|
||||
- If this Condition is an alias (canonical set), return the canonical and
|
||||
any other aliases (excluding self).
|
||||
- If this Condition is canonical, return all aliases (and exclude self).
|
||||
"""
|
||||
master = self.canonical_condition
|
||||
qs = Condition.objects.filter(models.Q(canonical=master) | models.Q(pk=master.pk)).exclude(pk=self.pk)
|
||||
return qs
|
||||
|
||||
def get_children(self):
|
||||
return self.child.all()
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<h5 class="mb-2">Details</h5>
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Primary name</dt>
|
||||
<dd class="col-sm-8">{{ condition.primary }}</dd>
|
||||
<dd class="col-sm-8">{% if condition.canonical %}No (alias of <a href="{{ condition.canonical.get_absolute_url }}">{{ condition.canonical.name }}</a>){% else %}Yes{% endif %}</dd>
|
||||
|
||||
<dt class="col-sm-4">Subspecialty</dt>
|
||||
<dd class="col-sm-8">
|
||||
@@ -31,33 +31,9 @@
|
||||
|
||||
<dt class="col-sm-4">Synonyms</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for syn in condition.synonym.all %}
|
||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
{% if request.user.is_authenticated and can_merge %}
|
||||
<div class="mt-2">
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#addSynonymCollapse" role="button" aria-expanded="false" aria-controls="addSynonymCollapse">Add synonym</a>
|
||||
<div id="synonyms-container">
|
||||
{% include 'atlas/partials/_synonyms_list.html' %}
|
||||
</div>
|
||||
<div class="collapse mt-2" id="addSynonymCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_synonym" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Select existing condition</label>
|
||||
{{ synonym_form.condition }}
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Or create new synonym name</label>
|
||||
<input class="form-control" type="text" name="synonym_name" placeholder="e.g. Granulomatosis with Polyangiitis" />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Parent</dt>
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{finding.name}}</h3>
|
||||
Primary name: {{finding.primary}}<br />
|
||||
Synonyms: {{finding.synonym.all|join:", "}}<br />
|
||||
Primary name: {% if finding.canonical %}No (alias of <a href="{{ finding.canonical.get_absolute_url }}">{{ finding.canonical.name }}</a>){% else %}Yes{% endif %}<br />
|
||||
Synonyms: {{ finding.get_synonym_link|safe }}<br />
|
||||
</div>
|
||||
{% if finding.seriesfinding_set.all %}
|
||||
<h4>Associated Cases</h4>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{% comment %}Partial that renders the synonyms list + add form. Used by condition_detail and HTMX responses.{% endcomment %}
|
||||
{% load static %}
|
||||
|
||||
{% with syns=condition.get_synonyms %}
|
||||
{% if syns %}
|
||||
{% for syn in syns %}
|
||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if request.user.is_authenticated and can_merge %}
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<!-- Button to add an existing condition as a synonym -->
|
||||
<a class="btn btn-sm btn-outline-primary" data-bs-toggle="collapse" href="#addSynonymCollapse" role="button" aria-expanded="false" aria-controls="addSynonymCollapse">Add synonym</a>
|
||||
|
||||
<!-- Button to create a new condition and add it as a synonym -->
|
||||
<a class="btn btn-sm btn-outline-success" data-bs-toggle="collapse" href="#createSynonymCollapse" role="button" aria-expanded="false" aria-controls="createSynonymCollapse">Create synonym</a>
|
||||
</div>
|
||||
|
||||
<!-- Collapse: add existing condition -->
|
||||
<div class="collapse mt-2" id="addSynonymCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST"
|
||||
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
|
||||
hx-target="#synonyms-container" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_synonym" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Select existing condition</label>
|
||||
{{ synonym_form.condition }}
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" type="submit">Add</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapse: create new condition -->
|
||||
<div class="collapse mt-2" id="createSynonymCollapse">
|
||||
<div class="card card-body">
|
||||
<form method="POST"
|
||||
hx-post="{% url 'atlas:condition_detail' condition.pk %}"
|
||||
hx-target="#synonyms-container" hx-swap="innerHTML">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="add_synonym" />
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Create new synonym name</label>
|
||||
<input class="form-control" type="text" name="synonym_name" placeholder="e.g. Granulomatosis with Polyangitis" required />
|
||||
</div>
|
||||
<button class="btn btn-success btn-sm" type="submit">Create</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -14,8 +14,8 @@
|
||||
</div>
|
||||
<div>
|
||||
<h3>Name: {{structure.name}}</h3>
|
||||
Primary name: {{structure.primary}}<br />
|
||||
Synonyms: {{structure.synonym.all|join:", "}}<br />
|
||||
Primary name: {% if structure.canonical %}No (alias of <a href="{{ structure.canonical.get_absolute_url }}">{{ structure.canonical.name }}</a>){% else %}Yes{% endif %}<br />
|
||||
Synonyms: {{ structure.get_synonym_link|safe }}<br />
|
||||
</div>
|
||||
<h4>Associated Cases</h4>
|
||||
<ul>
|
||||
|
||||
+37
-6
@@ -860,19 +860,50 @@ def condition_detail(request, pk):
|
||||
if form.is_valid():
|
||||
other = form.cleaned_data.get("condition")
|
||||
|
||||
# keep the form (possibly with errors) to render back in HTMX responses
|
||||
synonym_form = form
|
||||
|
||||
# If no autocomplete selection, accept a free-text name
|
||||
synonym_name = request.POST.get("synonym_name")
|
||||
if not other and synonym_name:
|
||||
other, created = Condition.objects.get_or_create(name=synonym_name)
|
||||
if created:
|
||||
# mark created synonym as non-primary
|
||||
other.primary = False
|
||||
other.save()
|
||||
# created condition: treat as alias by setting canonical below
|
||||
pass
|
||||
|
||||
if other and other != condition:
|
||||
# add mutual synonym relationship
|
||||
condition.synonym.add(other)
|
||||
other.synonym.add(condition)
|
||||
# Option B: treat synonym as alias/canonical relationship.
|
||||
# Determine canonical/master for the current condition
|
||||
master = condition.canonical if condition.canonical else condition
|
||||
|
||||
# If `other` belongs to a different canonical group, move its group
|
||||
# to point at our master. If `other` has no canonical, set it.
|
||||
if other.canonical:
|
||||
# move the whole other group (canonical + aliases) to our master
|
||||
group_master = other.canonical
|
||||
members = Condition.objects.filter(Q(canonical=group_master) | Q(pk=group_master.pk))
|
||||
for m in members:
|
||||
if m.pk != master.pk:
|
||||
m.canonical = master
|
||||
m.save()
|
||||
else:
|
||||
# other is standalone; make it an alias of our master
|
||||
other.canonical = master
|
||||
other.save()
|
||||
|
||||
# Master is indicated by having no `canonical` pointer. Ensure
|
||||
# it remains canonical (no save required unless changed elsewhere).
|
||||
|
||||
# If this was an HTMX request return the updated synonyms partial so the
|
||||
# page can be updated without a full redirect. Otherwise perform the
|
||||
# existing redirect back to the detail page.
|
||||
if request.htmx:
|
||||
html = render_to_string(
|
||||
"atlas/partials/_synonyms_list.html",
|
||||
{"condition": condition, "synonym_form": synonym_form, "can_merge": can_merge},
|
||||
request=request,
|
||||
)
|
||||
return HttpResponse(html)
|
||||
|
||||
return redirect("atlas:condition_detail", pk=condition.pk)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user