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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+71
-8
@@ -116,30 +116,66 @@ class SynMixin(object):
|
|||||||
return f"{self.name} [syn]"
|
return f"{self.name} [syn]"
|
||||||
|
|
||||||
def get_old_str(self) -> str:
|
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.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
|
return self.name
|
||||||
else:
|
|
||||||
synonyms = ",".join([i.name for i in self.synonym.all()])
|
synonyms = ",".join([i.name for i in self.synonym.all()])
|
||||||
return f"{self.name} ({synonyms})"
|
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:
|
else:
|
||||||
return f"{self.name} [syn]"
|
return f"{self.name} [syn]"
|
||||||
|
|
||||||
def get_synonym(self):
|
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]"
|
return "[Primary]"
|
||||||
else:
|
else:
|
||||||
|
if syns is None:
|
||||||
s = self.synonym.filter(primary=True).values_list("name", flat=True)
|
s = self.synonym.filter(primary=True).values_list("name", flat=True)
|
||||||
return ", ".join(s)
|
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):
|
def get_synonym_link(self):
|
||||||
if self.primary:
|
try:
|
||||||
|
syns = self.get_synonyms()
|
||||||
|
except Exception:
|
||||||
|
syns = None
|
||||||
|
|
||||||
|
if getattr(self, "primary", False):
|
||||||
return "[Primary]"
|
return "[Primary]"
|
||||||
else:
|
else:
|
||||||
syns = self.synonym.filter(primary=True)
|
if syns is None:
|
||||||
return ", ".join(
|
# fall back to all M2M synonyms (not just primary) for models
|
||||||
[f"<a href='{s.get_absolute_url()}'>{s.name}</a>" for s in syns]
|
# 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):
|
def get_link(self):
|
||||||
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
return format_html("<a href='{}'>{}</a>", self.get_absolute_url(), self.name)
|
||||||
@@ -163,6 +199,16 @@ class Condition(SynMixin, models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Use if a direct synonym for the condition exists, e.g. 'Wegener granulomatosis' and 'Granulomatosis with Polyangitis'.",
|
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(
|
parent = models.ManyToManyField(
|
||||||
"self",
|
"self",
|
||||||
blank=True,
|
blank=True,
|
||||||
@@ -198,6 +244,23 @@ class Condition(SynMixin, models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("atlas:condition_detail", kwargs={"pk": self.pk})
|
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):
|
def get_children(self):
|
||||||
return self.child.all()
|
return self.child.all()
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<h5 class="mb-2">Details</h5>
|
<h5 class="mb-2">Details</h5>
|
||||||
<dl class="row">
|
<dl class="row">
|
||||||
<dt class="col-sm-4">Primary name</dt>
|
<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>
|
<dt class="col-sm-4">Subspecialty</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Name: {{finding.name}}</h3>
|
<h3>Name: {{finding.name}}</h3>
|
||||||
Primary name: {{finding.primary}}<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.synonym.all|join:", "}}<br />
|
Synonyms: {{ finding.get_synonym_link|safe }}<br />
|
||||||
</div>
|
</div>
|
||||||
{% if finding.seriesfinding_set.all %}
|
{% if finding.seriesfinding_set.all %}
|
||||||
<h4>Associated Cases</h4>
|
<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 %}
|
{% comment %}Partial that renders the synonyms list + add form. Used by condition_detail and HTMX responses.{% endcomment %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
|
||||||
{% if condition.synonym.all %}
|
{% with syns=condition.get_synonyms %}
|
||||||
{% for syn in condition.synonym.all %}
|
{% 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>
|
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
—
|
—
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% if request.user.is_authenticated and can_merge %}
|
{% if request.user.is_authenticated and can_merge %}
|
||||||
<div class="mt-2 d-flex gap-2">
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
|||||||
@@ -14,8 +14,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3>Name: {{structure.name}}</h3>
|
<h3>Name: {{structure.name}}</h3>
|
||||||
Primary name: {{structure.primary}}<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.synonym.all|join:", "}}<br />
|
Synonyms: {{ structure.get_synonym_link|safe }}<br />
|
||||||
</div>
|
</div>
|
||||||
<h4>Associated Cases</h4>
|
<h4>Associated Cases</h4>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
+23
-6
@@ -868,14 +868,31 @@ def condition_detail(request, pk):
|
|||||||
if not other and synonym_name:
|
if not other and synonym_name:
|
||||||
other, created = Condition.objects.get_or_create(name=synonym_name)
|
other, created = Condition.objects.get_or_create(name=synonym_name)
|
||||||
if created:
|
if created:
|
||||||
# mark created synonym as non-primary
|
# created condition: treat as alias by setting canonical below
|
||||||
other.primary = False
|
pass
|
||||||
other.save()
|
|
||||||
|
|
||||||
if other and other != condition:
|
if other and other != condition:
|
||||||
# add mutual synonym relationship
|
# Option B: treat synonym as alias/canonical relationship.
|
||||||
condition.synonym.add(other)
|
# Determine canonical/master for the current condition
|
||||||
other.synonym.add(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
|
# If this was an HTMX request return the updated synonyms partial so the
|
||||||
# page can be updated without a full redirect. Otherwise perform the
|
# page can be updated without a full redirect. Otherwise perform the
|
||||||
|
|||||||
Reference in New Issue
Block a user