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:
Ross
2025-11-13 22:34:36 +00:00
parent 8da2dd27da
commit a2e9d5e681
9 changed files with 182 additions and 43 deletions
+8 -1
View File
@@ -82,10 +82,17 @@ admin.site.register(CaseCollection, CaseCollectionAdmin)
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")
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)
+19 -3
View File
@@ -294,12 +294,14 @@ class NormalCaseFilter(django_filters.FilterSet):
"""
examination = 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")
max_age = django_filters.NumberFilter(field_name="age_years", lookup_expr="lte", label="Max age")
# Accept min/max as years in the UI but filter against the canonical
# `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:
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):
# 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)
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 Meta:
model = QuestionSchema
+22 -26
View File
@@ -239,9 +239,15 @@ class CaseCollectionForm(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:
model = NormalCase
fields = ("age_years", "examination", "modality", "notes")
fields = ("examination", "modality", "notes")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -255,42 +261,32 @@ class NormalCaseForm(ModelForm):
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field("age_years", wrapper_class="col-md-4"),
Field("examination", wrapper_class="col-md-4"),
Field("modality", wrapper_class="col-md-4"),
Field("age_value", wrapper_class="col-md-3"),
Field("age_unit", wrapper_class="col-md-3"),
Field("examination", wrapper_class="col-md-3"),
Field("modality", wrapper_class="col-md-3"),
css_class="row",
),
Field("notes"),
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):
# 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)
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:
instance.save()
self.save_m2m()
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
View File
@@ -615,10 +615,10 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
class NormalCase(models.Model):
"""Marks a Case as a 'normal' example used in the Atlas normals section.
We store a nearest-year age (age_years) and optional primary
examination/modality to make filtering simple and fast. A OneToOne
relation keeps normal-specific fields separate from the main Case
object and is non-destructive.
This version stores a canonical `age_days` integer (patient age in days).
We keep the examination/modality metadata and authored fields. The
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
been removed in favour of the single canonical `age_days` value.
"""
case = models.OneToOneField(
Case,
@@ -627,13 +627,26 @@ class NormalCase(models.Model):
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,
blank=True,
validators=[MinValueValidator(0), MaxValueValidator(150)],
help_text="Age of the patient at scan in years (rounded to nearest year).",
help_text="Patient age at scan in days (canonical).",
)
# 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,
null=True,
@@ -668,11 +681,70 @@ class NormalCase(models.Model):
ordering = ["-added_date"]
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):
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):
# If examination/modality not specified, try to infer from the first series
try:
+1 -1
View File
@@ -32,7 +32,7 @@
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
</div>
<p class="mb-1">
Age: {{ normal.age_years|default:"unknown" }} yrs
Age: {{ normal.display_age }}
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
</p>
@@ -3,7 +3,7 @@
<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>
</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>
{% if normal.notes %}
<p class="mb-0">Notes: {{ normal.notes }}</p>
@@ -3,7 +3,7 @@
<div id="normal-toggle-block">
{% if case.normal_case %}
<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 %}
<button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
+28 -2
View File
@@ -561,7 +561,13 @@ def toggle_case_normal(request, pk):
# Create 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
# Recompute flags for the fragment render
@@ -633,8 +639,28 @@ def case_normal_form(request, pk):
# Build form with initial values
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={
"age_years": age,
"age_value": inferred_age_value,
"age_unit": inferred_age_unit,
"examination": inferred_examination,
"modality": inferred_modality,
})