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
+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: