Compare commits
3
Commits
21de543313
...
8a871d6332
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a871d6332 | ||
|
|
6eeb4cbb1d | ||
|
|
9d57d03cbb |
+21
-3
@@ -164,13 +164,16 @@ class SeriesFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
|
|
||||||
class ConditionFilter(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:
|
class Meta:
|
||||||
model = Condition
|
model = Condition
|
||||||
fields = {
|
fields = {
|
||||||
"name": ["icontains"],
|
"name": ["icontains"],
|
||||||
"primary": ["exact"],
|
|
||||||
"synonym": ["exact"],
|
|
||||||
#"parent": ["exact"],
|
|
||||||
"subspecialty": ["exact"],
|
"subspecialty": ["exact"],
|
||||||
"rcr_curriculum": ["exact"],
|
"rcr_curriculum": ["exact"],
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,21 @@ class ConditionFilter(django_filters.FilterSet):
|
|||||||
)
|
)
|
||||||
pass
|
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 FindingFilter(django_filters.FilterSet):
|
||||||
class Meta:
|
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]"
|
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">
|
||||||
@@ -31,33 +31,9 @@
|
|||||||
|
|
||||||
<dt class="col-sm-4">Synonyms</dt>
|
<dt class="col-sm-4">Synonyms</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
{% for syn in condition.synonym.all %}
|
<div id="synonyms-container">
|
||||||
<a class="badge bg-secondary text-decoration-none text-white me-1" href="{{ syn.get_absolute_url }}">{{ syn }}</a>
|
{% include 'atlas/partials/_synonyms_list.html' %}
|
||||||
{% 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>
|
</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>
|
</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Parent</dt>
|
<dt class="col-sm-4">Parent</dt>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
<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>
|
||||||
|
|||||||
+37
-6
@@ -860,19 +860,50 @@ def condition_detail(request, pk):
|
|||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
other = form.cleaned_data.get("condition")
|
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
|
# If no autocomplete selection, accept a free-text name
|
||||||
synonym_name = request.POST.get("synonym_name")
|
synonym_name = request.POST.get("synonym_name")
|
||||||
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
|
||||||
|
# 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)
|
return redirect("atlas:condition_detail", pk=condition.pk)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user