Refactor NormalCase model to use age_days instead of age_years, update forms, filters, and templates accordingly for improved age handling
This commit is contained in:
+8
-1
@@ -82,10 +82,17 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
|
|||||||
|
|
||||||
|
|
||||||
class NormalCaseAdmin(VersionAdmin):
|
class NormalCaseAdmin(VersionAdmin):
|
||||||
list_display = ("case", "age_years", "examination", "modality", "added_by", "added_date")
|
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
|
||||||
list_filter = ("examination", "modality")
|
list_filter = ("examination", "modality")
|
||||||
search_fields = ("case__title", "case__pk")
|
search_fields = ("case__title", "case__pk")
|
||||||
|
|
||||||
|
def display_age(self, obj):
|
||||||
|
try:
|
||||||
|
return obj.display_age()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
display_age.short_description = "Age"
|
||||||
|
|
||||||
|
|
||||||
admin.site.register(NormalCase, NormalCaseAdmin)
|
admin.site.register(NormalCase, NormalCaseAdmin)
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -294,12 +294,14 @@ class NormalCaseFilter(django_filters.FilterSet):
|
|||||||
"""
|
"""
|
||||||
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
examination = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||||
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
|
||||||
min_age = django_filters.NumberFilter(field_name="age_years", lookup_expr="gte", label="Min age")
|
# Accept min/max as years in the UI but filter against the canonical
|
||||||
max_age = django_filters.NumberFilter(field_name="age_years", lookup_expr="lte", label="Max age")
|
# `age_days` field by converting years -> days.
|
||||||
|
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
|
||||||
|
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NormalCase
|
model = NormalCase
|
||||||
fields = ("examination", "modality", "age_years")
|
fields = ("examination", "modality", "age_days")
|
||||||
|
|
||||||
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
def __init__(self, data=None, queryset=None, prefix=None, strict=None, user=None, request=None):
|
||||||
# Use the global Examination and Modality querysets from the Series model
|
# Use the global Examination and Modality querysets from the Series model
|
||||||
@@ -319,6 +321,20 @@ class NormalCaseFilter(django_filters.FilterSet):
|
|||||||
|
|
||||||
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request)
|
||||||
|
|
||||||
|
def filter_min_age(self, queryset, name, value):
|
||||||
|
try:
|
||||||
|
days = int(float(value) * 365.25)
|
||||||
|
except Exception:
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(age_days__gte=days)
|
||||||
|
|
||||||
|
def filter_max_age(self, queryset, name, value):
|
||||||
|
try:
|
||||||
|
days = int(float(value) * 365.25)
|
||||||
|
except Exception:
|
||||||
|
return queryset
|
||||||
|
return queryset.filter(age_days__lte=days)
|
||||||
|
|
||||||
class QuestionSchemaFilter(django_filters.FilterSet):
|
class QuestionSchemaFilter(django_filters.FilterSet):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = QuestionSchema
|
model = QuestionSchema
|
||||||
|
|||||||
+22
-26
@@ -239,9 +239,15 @@ class CaseCollectionForm(ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class NormalCaseForm(ModelForm):
|
class NormalCaseForm(ModelForm):
|
||||||
|
# Keep non-model input fields for age entry so users can provide a
|
||||||
|
# value + unit (e.g. 6 months) which will be converted to canonical
|
||||||
|
# days on save. The model now stores only `age_days`.
|
||||||
|
age_value = forms.IntegerField(required=False, min_value=0)
|
||||||
|
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = NormalCase
|
model = NormalCase
|
||||||
fields = ("age_years", "examination", "modality", "notes")
|
fields = ("examination", "modality", "notes")
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
@@ -255,42 +261,32 @@ class NormalCaseForm(ModelForm):
|
|||||||
self.helper.form_tag = False
|
self.helper.form_tag = False
|
||||||
self.helper.layout = Layout(
|
self.helper.layout = Layout(
|
||||||
Div(
|
Div(
|
||||||
Field("age_years", wrapper_class="col-md-4"),
|
Field("age_value", wrapper_class="col-md-3"),
|
||||||
Field("examination", wrapper_class="col-md-4"),
|
Field("age_unit", wrapper_class="col-md-3"),
|
||||||
Field("modality", wrapper_class="col-md-4"),
|
Field("examination", wrapper_class="col-md-3"),
|
||||||
|
Field("modality", wrapper_class="col-md-3"),
|
||||||
css_class="row",
|
css_class="row",
|
||||||
),
|
),
|
||||||
Field("notes"),
|
Field("notes"),
|
||||||
Submit("submit", "Save", css_class="btn btn-primary"),
|
Submit("submit", "Save", css_class="btn btn-primary"),
|
||||||
)
|
)
|
||||||
|
|
||||||
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
|
||||||
widget=SplitDateTimeWidget(
|
|
||||||
date_attrs={"type": "date", "class": "datepicker"},
|
|
||||||
time_attrs={"type": "time", "class": "timepicker"},
|
|
||||||
date_format="%Y-%m-%d",
|
|
||||||
time_format="%H:%M",
|
|
||||||
),
|
|
||||||
required=False,
|
|
||||||
help_text="The date the exam is due to start (time is optional)",
|
|
||||||
)
|
|
||||||
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
|
|
||||||
widget=SplitDateTimeWidget(
|
|
||||||
date_attrs={"type": "date", "class": "datepicker"},
|
|
||||||
time_attrs={"type": "time", "class": "timepicker"},
|
|
||||||
date_format="%Y-%m-%d",
|
|
||||||
time_format="%H:%M",
|
|
||||||
),
|
|
||||||
required=False,
|
|
||||||
help_text="The date the exam is due to start (time is optional)",
|
|
||||||
)
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
# Respect the commit flag: return an unsaved instance if commit=False
|
# Respect commit flag. Compute canonical `age_days` from the
|
||||||
|
# provided age_value/age_unit and set it on the instance.
|
||||||
instance = super().save(commit=False)
|
instance = super().save(commit=False)
|
||||||
|
age_value = self.cleaned_data.get("age_value")
|
||||||
|
age_unit = self.cleaned_data.get("age_unit")
|
||||||
|
if age_value is not None and age_unit:
|
||||||
|
instance.age_days = NormalCase.to_days(age_value, age_unit)
|
||||||
|
else:
|
||||||
|
# If the user left fields blank, clear any previous value.
|
||||||
|
instance.age_days = None
|
||||||
|
|
||||||
if commit:
|
if commit:
|
||||||
instance.save()
|
instance.save()
|
||||||
self.save_m2m()
|
self.save_m2m()
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-13 22:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('atlas', '0080_normalcase'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='normalcase',
|
||||||
|
name='age_years',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='normalcase',
|
||||||
|
name='age_days',
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
+80
-8
@@ -615,10 +615,10 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
|||||||
class NormalCase(models.Model):
|
class NormalCase(models.Model):
|
||||||
"""Marks a Case as a 'normal' example used in the Atlas normals section.
|
"""Marks a Case as a 'normal' example used in the Atlas normals section.
|
||||||
|
|
||||||
We store a nearest-year age (age_years) and optional primary
|
This version stores a canonical `age_days` integer (patient age in days).
|
||||||
examination/modality to make filtering simple and fast. A OneToOne
|
We keep the examination/modality metadata and authored fields. The
|
||||||
relation keeps normal-specific fields separate from the main Case
|
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
|
||||||
object and is non-destructive.
|
been removed in favour of the single canonical `age_days` value.
|
||||||
"""
|
"""
|
||||||
case = models.OneToOneField(
|
case = models.OneToOneField(
|
||||||
Case,
|
Case,
|
||||||
@@ -627,13 +627,26 @@ class NormalCase(models.Model):
|
|||||||
help_text="The Case that is marked as a normal example.",
|
help_text="The Case that is marked as a normal example.",
|
||||||
)
|
)
|
||||||
|
|
||||||
age_years = models.PositiveSmallIntegerField(
|
# Canonical internal representation: patient age in days
|
||||||
|
age_days = models.PositiveIntegerField(
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
validators=[MinValueValidator(0), MaxValueValidator(150)],
|
help_text="Patient age at scan in days (canonical).",
|
||||||
help_text="Age of the patient at scan in years (rounded to nearest year).",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Helpful constants for converting/displaying ages. These are not
|
||||||
|
# stored directly on the model but are used by forms/views/templates.
|
||||||
|
AGE_UNIT_YEARS = "years"
|
||||||
|
AGE_UNIT_MONTHS = "months"
|
||||||
|
AGE_UNIT_WEEKS = "weeks"
|
||||||
|
AGE_UNIT_DAYS = "days"
|
||||||
|
AGE_UNIT_CHOICES = [
|
||||||
|
(AGE_UNIT_YEARS, "years"),
|
||||||
|
(AGE_UNIT_MONTHS, "months"),
|
||||||
|
(AGE_UNIT_WEEKS, "weeks"),
|
||||||
|
(AGE_UNIT_DAYS, "days"),
|
||||||
|
]
|
||||||
|
|
||||||
examination = models.ForeignKey(
|
examination = models.ForeignKey(
|
||||||
Examination,
|
Examination,
|
||||||
null=True,
|
null=True,
|
||||||
@@ -668,11 +681,70 @@ class NormalCase(models.Model):
|
|||||||
ordering = ["-added_date"]
|
ordering = ["-added_date"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Normal: {self.case} ({self.age_years or 'unknown'}y)"
|
return f"Normal: {self.case} ({self.display_age()})"
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return self.case.get_absolute_url()
|
return self.case.get_absolute_url()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def to_days(value: int | float, unit: str) -> int | None:
|
||||||
|
"""Convert a numeric value + unit into integer days (rounded).
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
to_days(2, 'years') -> ~730
|
||||||
|
to_days(6, 'months') -> ~183
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if value is None or unit is None:
|
||||||
|
return None
|
||||||
|
v = float(value)
|
||||||
|
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||||
|
return int(round(v * 365.25))
|
||||||
|
if unit == NormalCase.AGE_UNIT_MONTHS:
|
||||||
|
return int(round(v * 30.4375))
|
||||||
|
if unit == NormalCase.AGE_UNIT_WEEKS:
|
||||||
|
return int(round(v * 7))
|
||||||
|
if unit == NormalCase.AGE_UNIT_DAYS:
|
||||||
|
return int(round(v))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
|
||||||
|
"""Convert canonical days into a (value, unit) tuple for display.
|
||||||
|
|
||||||
|
Prefer years for ages >= 365 days, months for ages >= 30 days,
|
||||||
|
weeks for >=7 days, else days.
|
||||||
|
"""
|
||||||
|
if days is None:
|
||||||
|
return (None, None)
|
||||||
|
try:
|
||||||
|
d = int(days)
|
||||||
|
if d >= 365:
|
||||||
|
yrs = int(round(d / 365.25))
|
||||||
|
return (yrs, NormalCase.AGE_UNIT_YEARS)
|
||||||
|
if d >= 30:
|
||||||
|
months = int(round(d / 30.4375))
|
||||||
|
return (months, NormalCase.AGE_UNIT_MONTHS)
|
||||||
|
if d >= 7:
|
||||||
|
weeks = int(round(d / 7))
|
||||||
|
return (weeks, NormalCase.AGE_UNIT_WEEKS)
|
||||||
|
return (d, NormalCase.AGE_UNIT_DAYS)
|
||||||
|
except Exception:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
def display_age(self) -> str:
|
||||||
|
"""Return a human friendly age string derived from age_days."""
|
||||||
|
if self.age_days is None:
|
||||||
|
return "age unknown"
|
||||||
|
val, unit = NormalCase.days_to_value_unit(self.age_days)
|
||||||
|
if val is None or unit is None:
|
||||||
|
return "age unknown"
|
||||||
|
# abbreviate years to 'y' for backwards compatibility where helpful
|
||||||
|
if unit == NormalCase.AGE_UNIT_YEARS:
|
||||||
|
return f"{val}y"
|
||||||
|
return f"{val} {unit}"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# If examination/modality not specified, try to infer from the first series
|
# If examination/modality not specified, try to infer from the first series
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1">
|
<p class="mb-1">
|
||||||
Age: {{ normal.age_years|default:"unknown" }} yrs
|
Age: {{ normal.display_age }}
|
||||||
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
|
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
|
||||||
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
|
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<h5 class="mb-1"><a href="{{ normal.case.get_absolute_url }}">{{ normal.case.title }}</a></h5>
|
<h5 class="mb-1"><a href="{{ normal.case.get_absolute_url }}">{{ normal.case.title }}</a></h5>
|
||||||
<small>{{ normal.added_date|date:"Y-m-d" }}</small>
|
<small>{{ normal.added_date|date:"Y-m-d" }}</small>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-1">Age: {{ normal.age_years|default:"unknown" }} years</p>
|
<p class="mb-1">Age: {{ normal.display_age }}</p>
|
||||||
<p class="mb-1">Examination: {{ normal.examination }} | Modality: {{ normal.modality }}</p>
|
<p class="mb-1">Examination: {{ normal.examination }} | Modality: {{ normal.modality }}</p>
|
||||||
{% if normal.notes %}
|
{% if normal.notes %}
|
||||||
<p class="mb-0">Notes: {{ normal.notes }}</p>
|
<p class="mb-0">Notes: {{ normal.notes }}</p>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div id="normal-toggle-block">
|
<div id="normal-toggle-block">
|
||||||
{% if case.normal_case %}
|
{% if case.normal_case %}
|
||||||
<span class="badge bg-success">Normal</span>
|
<span class="badge bg-success">Normal</span>
|
||||||
<small class="text-muted">{% if case.normal_case.age_years %}{{ case.normal_case.age_years }}y{% else %}age unknown{% endif %}</small>
|
<small class="text-muted">{{ case.normal_case.display_age }}</small>
|
||||||
{% if can_mark_normal %}
|
{% if can_mark_normal %}
|
||||||
<button class="btn btn-sm btn-outline-danger ms-2"
|
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||||
|
|||||||
+28
-2
@@ -561,7 +561,13 @@ def toggle_case_normal(request, pk):
|
|||||||
# Create NormalCase
|
# Create NormalCase
|
||||||
from .models import NormalCase
|
from .models import NormalCase
|
||||||
|
|
||||||
NormalCase.objects.create(case=case, age_years=age, added_by=request.user)
|
# Convert inferred integer years -> canonical days (approx)
|
||||||
|
if age is not None:
|
||||||
|
age_days = NormalCase.to_days(age, NormalCase.AGE_UNIT_YEARS)
|
||||||
|
else:
|
||||||
|
age_days = None
|
||||||
|
|
||||||
|
NormalCase.objects.create(case=case, age_days=age_days, added_by=request.user)
|
||||||
|
|
||||||
# Render the updated fragment
|
# Render the updated fragment
|
||||||
# Recompute flags for the fragment render
|
# Recompute flags for the fragment render
|
||||||
@@ -633,8 +639,28 @@ def case_normal_form(request, pk):
|
|||||||
# Build form with initial values
|
# Build form with initial values
|
||||||
from .forms import NormalCaseForm
|
from .forms import NormalCaseForm
|
||||||
|
|
||||||
|
# Map inferred age (years) into age_value/unit defaults. If we inferred
|
||||||
|
# a fractional year (from birthdate), prefer months for ages < 2 years.
|
||||||
|
from .models import NormalCase
|
||||||
|
inferred_age_value = None
|
||||||
|
inferred_age_unit = None
|
||||||
|
try:
|
||||||
|
if age is not None:
|
||||||
|
# age currently holds integer years; for small ages prefer months
|
||||||
|
if age < 2:
|
||||||
|
inferred_age_value = int(age * 12)
|
||||||
|
inferred_age_unit = NormalCase.AGE_UNIT_MONTHS
|
||||||
|
else:
|
||||||
|
inferred_age_value = int(age)
|
||||||
|
inferred_age_unit = NormalCase.AGE_UNIT_YEARS
|
||||||
|
except Exception:
|
||||||
|
inferred_age_value = None
|
||||||
|
inferred_age_unit = None
|
||||||
|
|
||||||
|
|
||||||
form = NormalCaseForm(initial={
|
form = NormalCaseForm(initial={
|
||||||
"age_years": age,
|
"age_value": inferred_age_value,
|
||||||
|
"age_unit": inferred_age_unit,
|
||||||
"examination": inferred_examination,
|
"examination": inferred_examination,
|
||||||
"modality": inferred_modality,
|
"modality": inferred_modality,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user