Enhance NormalCaseFilter to support flexible age filtering with unit selection; update normals_list template with detailed help text for improved user guidance

This commit is contained in:
Ross
2026-02-02 11:26:11 +00:00
parent eea3433042
commit 00680c8b9e
3 changed files with 75 additions and 14 deletions
+62 -13
View File
@@ -1,6 +1,7 @@
import django_filters import django_filters
from django import forms
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Div from crispy_forms.layout import Layout, Div, Field
from .models import ( from .models import (
Case, Case,
@@ -355,8 +356,16 @@ class NormalCaseFilter(django_filters.FilterSet):
modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none()) modality = django_filters.ModelChoiceFilter(queryset=Series.objects.none())
# Accept min/max as years in the UI but filter against the canonical # Accept min/max as years in the UI but filter against the canonical
# `age_days` field by converting years -> days. # `age_days` field by converting years -> days.
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age") # Numeric min/max values entered by the user (interpreted via `age_unit`)
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age") min_value = django_filters.NumberFilter(label="Min", method="filter_min_value")
max_value = django_filters.NumberFilter(label="Max", method="filter_max_value")
AGE_UNIT_CHOICES = (
("days", "Days"),
("weeks", "Weeks"),
("months", "Months"),
("years", "Years"),
)
age_unit = django_filters.ChoiceFilter(label="Unit", choices=AGE_UNIT_CHOICES)
class Meta: class Meta:
model = NormalCase model = NormalCase
@@ -406,30 +415,70 @@ class NormalCaseFilter(django_filters.FilterSet):
helper.form_class = "row g-2 align-items-end" helper.form_class = "row g-2 align-items-end"
helper.layout = Layout( helper.layout = Layout(
Div( Div(
Div("examination", css_class="col-md-4"), Div("examination", css_class="col-md-3"),
Div("modality", css_class="col-md-4"), Div("modality", css_class="col-md-3"),
Div("age_days", css_class="col-md-2"), Div(
Div("min_age", css_class="col-md-2"), Field("min_value", wrapper_class="mb-0"),
Div("max_age", css_class="col-md-2"), Field("max_value", wrapper_class="mb-0"),
Field("age_unit", wrapper_class="mb-0"),
css_class="col-md-6 d-flex gap-2 align-items-stretch border rounded p-2 bg-body-tertiary",
),
css_class="row g-2 align-items-end", css_class="row g-2 align-items-end",
) )
) )
# Assign helper to the generated form instance # Assign helper to the generated form instance and tweak widgets
if hasattr(self, 'form') and self.form is not None: if hasattr(self, 'form') and self.form is not None:
# Wider numeric inputs and default unit
try:
form = self.form
if "min_value" in form.fields:
form.fields["min_value"].widget = forms.NumberInput(attrs={"class": "form-control", "min": "0", "step": "1", "style": "width:140px;"})
if "max_value" in form.fields:
form.fields["max_value"].widget = forms.NumberInput(attrs={"class": "form-control", "min": "0", "step": "1", "style": "width:140px;"})
if "age_unit" in form.fields:
form.fields["age_unit"].initial = "days"
form.fields["age_unit"].widget.attrs.update({"class": "form-select", "style": "width:220px;"})
form.helper = helper
except Exception:
# don't crash if form structure differs
self.form.helper = helper self.form.helper = helper
except Exception: except Exception:
pass pass
def filter_min_age(self, queryset, name, value): def _convert_to_days(self, value, unit: str) -> int:
try: try:
days = int(float(value) * 365.25) v = float(value)
except Exception:
raise
if unit == "days":
return int(v)
if unit == "weeks":
return int(v * 7)
if unit == "months":
# approximate month length
return int(v * (365.25 / 12.0))
# default: years
return int(v * 365.25)
def filter_min_value(self, queryset, name, value):
try:
unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
except Exception:
unit = "years"
try:
days = self._convert_to_days(value, unit)
except Exception: except Exception:
return queryset return queryset
return queryset.filter(age_days__gte=days) return queryset.filter(age_days__gte=days)
def filter_max_age(self, queryset, name, value): def filter_max_value(self, queryset, name, value):
try: try:
days = int(float(value) * 365.25) unit = getattr(self.form, "cleaned_data", {}).get("age_unit", "years")
except Exception:
unit = "years"
try:
days = self._convert_to_days(value, unit)
except Exception: except Exception:
return queryset return queryset
return queryset.filter(age_days__lte=days) return queryset.filter(age_days__lte=days)
+11
View File
@@ -1,10 +1,19 @@
{% extends "atlas/base.html" %} {% extends "atlas/base.html" %}
{% load static %} {% load static %}
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load help_tags %}
{% block content %} {% block content %}
<div class="container"> <div class="container">
<h1>Normals</h1> <h1>Normals</h1>
{% help "Help" %}
<p>This page lists all normal cases available in the atlas.</p>
<p>You can use the filter form to narrow down the list based on criteria such as age, modality, or examination type.</p>
<p>Click on a case title to view the case.</p>
<p>See a case that isn't normal? Please add feedback to the case so it can be reviewed.</p>
{% endhelp%}
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-4"> <div class="col-md-4">
@@ -54,3 +63,5 @@
</nav> </nav>
</div> </div>
{% endblock %} {% endblock %}
{% block css %}{% endblock %}
+1
View File
@@ -181,6 +181,7 @@
} }
</style> </style>
{% block css %} {% block css %}
{% endblock %} {% endblock %}