diff --git a/atlas/admin.py b/atlas/admin.py index bc53fcd7..041fb750 100755 --- a/atlas/admin.py +++ b/atlas/admin.py @@ -3,6 +3,7 @@ from .models import ( Case, CaseDetail, CaseCollection, + NormalCase, Differential, Series, SeriesImage, @@ -79,6 +80,15 @@ class CaseCollectionAdmin(VersionAdmin): admin.site.register(CaseCollection, CaseCollectionAdmin) + +class NormalCaseAdmin(VersionAdmin): + list_display = ("case", "age_years", "examination", "modality", "added_by", "added_date") + list_filter = ("examination", "modality") + search_fields = ("case__title", "case__pk") + + +admin.site.register(NormalCase, NormalCaseAdmin) + class AtlasAdminForm(ModelForm): class Meta: model = Case diff --git a/atlas/filters.py b/atlas/filters.py index 2af29a4c..0c3f5632 100755 --- a/atlas/filters.py +++ b/atlas/filters.py @@ -11,6 +11,7 @@ from .models import ( Series, Structure, Subspecialty, + NormalCase, ) from django.contrib.auth.models import User from django.db.models import Q @@ -286,6 +287,38 @@ class SubspecialtyFilter(django_filters.FilterSet): ) pass + +class NormalCaseFilter(django_filters.FilterSet): + """FilterSet for the NormalCase model allowing filtering by + examination, modality and nearest-year age ranges. + """ + 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") + + class Meta: + model = NormalCase + fields = ("examination", "modality", "age_years") + + 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 + from generic.models import Examination, Modality + + # Set up the actual querysets for the filters + self.base_filters.get("examination").queryset = Examination.objects.all() + self.base_filters.get("modality").queryset = Modality.objects.all() + + # If not an atlas editor restrict to cases authored by the user or open access cases + if request is not None and not request.user.groups.filter(name="atlas_editor").exists(): + from django.db.models import Q + + if queryset is None: + queryset = NormalCase.objects.select_related("case") + queryset = queryset.filter((Q(case__author__id=request.user.id) | Q(case__open_access=True))) + + super(NormalCaseFilter, self).__init__(data=data, queryset=queryset, prefix=prefix, request=request) + class QuestionSchemaFilter(django_filters.FilterSet): class Meta: model = QuestionSchema diff --git a/atlas/migrations/0080_normalcase.py b/atlas/migrations/0080_normalcase.py new file mode 100644 index 00000000..a949a023 --- /dev/null +++ b/atlas/migrations/0080_normalcase.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.7 on 2025-11-13 21:43 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0079_casecollection_prerequisites'), + ('generic', '0029_add_unique_constraint_supervisor_email'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='NormalCase', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('age_years', models.PositiveSmallIntegerField(blank=True, help_text='Age of the patient at scan in years (rounded to nearest year).', null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(150)])), + ('added_date', models.DateTimeField(default=django.utils.timezone.now)), + ('notes', models.TextField(blank=True, null=True)), + ('added_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='added_normals', to=settings.AUTH_USER_MODEL)), + ('case', models.OneToOneField(help_text='The Case that is marked as a normal example.', on_delete=django.db.models.deletion.CASCADE, related_name='normal_case', to='atlas.case')), + ('examination', models.ForeignKey(blank=True, help_text='Primary examination for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.examination')), + ('modality', models.ForeignKey(blank=True, help_text='Primary modality for this normal case (optional).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='normal_cases', to='generic.modality')), + ], + options={ + 'ordering': ['-added_date'], + }, + ), + ] diff --git a/atlas/models.py b/atlas/models.py index b9226b9c..85ad6d7d 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -611,6 +611,83 @@ class Case(models.Model, AuthorMixin, QuestionMixin): return json.dumps(results) + +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. + """ + case = models.OneToOneField( + Case, + on_delete=models.CASCADE, + related_name="normal_case", + help_text="The Case that is marked as a normal example.", + ) + + age_years = models.PositiveSmallIntegerField( + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(150)], + help_text="Age of the patient at scan in years (rounded to nearest year).", + ) + + examination = models.ForeignKey( + Examination, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="normal_cases", + help_text="Primary examination for this normal case (optional).", + ) + + modality = models.ForeignKey( + Modality, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="normal_cases", + help_text="Primary modality for this normal case (optional).", + ) + + added_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="added_normals", + ) + + added_date = models.DateTimeField(default=timezone.now) + + notes = models.TextField(null=True, blank=True) + + class Meta: + ordering = ["-added_date"] + + def __str__(self): + return f"Normal: {self.case} ({self.age_years or 'unknown'}y)" + + def get_absolute_url(self): + return self.case.get_absolute_url() + + def save(self, *args, **kwargs): + # If examination/modality not specified, try to infer from the first series + try: + if (self.examination is None or self.modality is None) and self.case.series.exists(): + first_series = self.case.series.first() + if self.examination is None and getattr(first_series, 'examination', None) is not None: + self.examination = first_series.examination + if self.modality is None and getattr(first_series, 'modality', None) is not None: + self.modality = first_series.modality + except Exception: + # Be defensive: if anything goes wrong whilst inferring, continue and save as-is + pass + + super().save(*args, **kwargs) + def extract_image_dicom_json_from_ds(ds, url, image_index): to_keep = [ "Columns", diff --git a/atlas/templates/atlas/normals_list.html b/atlas/templates/atlas/normals_list.html new file mode 100644 index 00000000..eac50d37 --- /dev/null +++ b/atlas/templates/atlas/normals_list.html @@ -0,0 +1,45 @@ +{% extends "atlas/base.html" %} +{% load static %} + +{% block content %} +
Age: {{ normal.age_years|default:"unknown" }} years
+Examination: {{ normal.examination }} | Modality: {{ normal.modality }}
+ {% if normal.notes %} +Notes: {{ normal.notes }}
+ {% endif %} +