diff --git a/atlas/migrations/0086_add_canonical.py b/atlas/migrations/0086_add_canonical.py new file mode 100644 index 00000000..393e615e --- /dev/null +++ b/atlas/migrations/0086_add_canonical.py @@ -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), + ] diff --git a/atlas/migrations/0087_alter_condition_canonical.py b/atlas/migrations/0087_alter_condition_canonical.py new file mode 100644 index 00000000..d0714370 --- /dev/null +++ b/atlas/migrations/0087_alter_condition_canonical.py @@ -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'), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 03d63482..03c31d98 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -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: - return self.name - else: + if syns is None: + if getattr(self, "synonym", None) is None or self.synonym.count() == 0: + return self.name 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: - s = self.synonym.filter(primary=True).values_list("name", flat=True) - return ", ".join(s) + 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"{s.name}" 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"{s.name}" for s in syns_qs] + return ", ".join(items) + else: + # render links for synonyms/canonical group + items = [f"{s.name}" for s in syns] + return ", ".join(items) def get_link(self): return format_html("{}", 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() diff --git a/atlas/templates/atlas/condition_detail.html b/atlas/templates/atlas/condition_detail.html index 24b03640..a0fa25d5 100755 --- a/atlas/templates/atlas/condition_detail.html +++ b/atlas/templates/atlas/condition_detail.html @@ -18,7 +18,7 @@
Details
Primary name
-
{{ condition.primary }}
+
{% if condition.canonical %}No (alias of {{ condition.canonical.name }}){% else %}Yes{% endif %}
Subspecialty
diff --git a/atlas/templates/atlas/finding_detail.html b/atlas/templates/atlas/finding_detail.html index c7e502e6..715f06ad 100755 --- a/atlas/templates/atlas/finding_detail.html +++ b/atlas/templates/atlas/finding_detail.html @@ -12,8 +12,8 @@

Name: {{finding.name}}

- Primary name: {{finding.primary}}
- Synonyms: {{finding.synonym.all|join:", "}}
+ Primary name: {% if finding.canonical %}No (alias of {{ finding.canonical.name }}){% else %}Yes{% endif %}
+ Synonyms: {{ finding.get_synonym_link|safe }}
{% if finding.seriesfinding_set.all %}

Associated Cases

diff --git a/atlas/templates/atlas/partials/_synonyms_list.html b/atlas/templates/atlas/partials/_synonyms_list.html index c3a99c84..73e62c50 100644 --- a/atlas/templates/atlas/partials/_synonyms_list.html +++ b/atlas/templates/atlas/partials/_synonyms_list.html @@ -1,13 +1,15 @@ {% comment %}Partial that renders the synonyms list + add form. Used by condition_detail and HTMX responses.{% endcomment %} {% load static %} -{% if condition.synonym.all %} - {% for syn in condition.synonym.all %} - {{ syn }} - {% endfor %} -{% else %} - — -{% endif %} +{% with syns=condition.get_synonyms %} + {% if syns %} + {% for syn in syns %} + {{ syn }} + {% endfor %} + {% else %} + — + {% endif %} +{% endwith %} {% if request.user.is_authenticated and can_merge %}
diff --git a/atlas/templates/atlas/structure_detail.html b/atlas/templates/atlas/structure_detail.html index ee99fe20..75ef1243 100755 --- a/atlas/templates/atlas/structure_detail.html +++ b/atlas/templates/atlas/structure_detail.html @@ -14,8 +14,8 @@

Name: {{structure.name}}

- Primary name: {{structure.primary}}
- Synonyms: {{structure.synonym.all|join:", "}}
+ Primary name: {% if structure.canonical %}No (alias of {{ structure.canonical.name }}){% else %}Yes{% endif %}
+ Synonyms: {{ structure.get_synonym_link|safe }}

Associated Cases