diff --git a/atlas/admin.py b/atlas/admin.py index 041fb750..97908782 100755 --- a/atlas/admin.py +++ b/atlas/admin.py @@ -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) diff --git a/atlas/filters.py b/atlas/filters.py index 0c3f5632..f173e7ae 100755 --- a/atlas/filters.py +++ b/atlas/filters.py @@ -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 diff --git a/atlas/forms.py b/atlas/forms.py index 697133a4..8c188ea4 100755 --- a/atlas/forms.py +++ b/atlas/forms.py @@ -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 diff --git a/atlas/migrations/0081_remove_normalcase_age_years_normalcase_age_days.py b/atlas/migrations/0081_remove_normalcase_age_years_normalcase_age_days.py new file mode 100644 index 00000000..14dbe312 --- /dev/null +++ b/atlas/migrations/0081_remove_normalcase_age_years_normalcase_age_days.py @@ -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), + ), + ] diff --git a/atlas/models.py b/atlas/models.py index 85ad6d7d..8d8d0a66 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -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: diff --git a/atlas/templates/atlas/normals_list.html b/atlas/templates/atlas/normals_list.html index 260191a3..37b6eb51 100644 --- a/atlas/templates/atlas/normals_list.html +++ b/atlas/templates/atlas/normals_list.html @@ -32,7 +32,7 @@ {{ normal.added_date|date:"Y-m-d" }}
- 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 %}
diff --git a/atlas/templates/atlas/partials/_normal_row.html b/atlas/templates/atlas/partials/_normal_row.html index 4a470c13..9ff61dba 100644 --- a/atlas/templates/atlas/partials/_normal_row.html +++ b/atlas/templates/atlas/partials/_normal_row.html @@ -3,7 +3,7 @@Age: {{ normal.age_years|default:"unknown" }} years
+Age: {{ normal.display_age }}
Examination: {{ normal.examination }} | Modality: {{ normal.modality }}
{% if normal.notes %}Notes: {{ normal.notes }}
diff --git a/atlas/templates/atlas/partials/_normal_toggle.html b/atlas/templates/atlas/partials/_normal_toggle.html index fab87485..92b60da6 100644 --- a/atlas/templates/atlas/partials/_normal_toggle.html +++ b/atlas/templates/atlas/partials/_normal_toggle.html @@ -3,7 +3,7 @@