Add canonical field and update synonym handling in Condition model

- Introduced a new `canonical` ForeignKey field in the Condition model to manage alias relationships.
- Enhanced synonym retrieval methods to prefer canonical names and handle aliases more effectively.
- Updated condition detail templates to reflect canonical status and improved synonym display.
- Implemented migration scripts to establish canonical relationships based on existing synonym links.
This commit is contained in:
Ross
2025-11-20 22:48:03 +00:00
parent 9d57d03cbb
commit 6eeb4cbb1d
8 changed files with 199 additions and 29 deletions
+69
View File
@@ -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'),
),
]
+74 -11
View File
@@ -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"<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()
+1 -1
View File
@@ -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">
+2 -2
View File
@@ -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>
@@ -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 %}
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
{% endfor %}
{% else %}
&mdash;
{% endif %}
{% 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 %}
&mdash;
{% endif %}
{% endwith %}
{% if request.user.is_authenticated and can_merge %}
<div class="mt-2 d-flex gap-2">
+2 -2
View File
@@ -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>
+23 -6
View File
@@ -868,14 +868,31 @@ def condition_detail(request, pk):
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