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:
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
—
|
||||
{% 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 %}
|
||||
—
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if request.user.is_authenticated and can_merge %}
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user