From 345977e2efacbc16f85f8a2c65916f0179ed027b Mon Sep 17 00:00:00 2001 From: Ross Date: Thu, 13 Nov 2025 21:44:55 +0000 Subject: [PATCH] Add NormalCase model, views, filters, and templates for managing normal cases --- atlas/admin.py | 10 +++ atlas/filters.py | 33 ++++++++ atlas/migrations/0080_normalcase.py | 35 +++++++++ atlas/models.py | 77 +++++++++++++++++++ atlas/templates/atlas/normals_list.html | 45 +++++++++++ .../templates/atlas/partials/_normal_row.html | 11 +++ atlas/urls.py | 1 + atlas/views.py | 19 +++++ 8 files changed, 231 insertions(+) create mode 100644 atlas/migrations/0080_normalcase.py create mode 100644 atlas/templates/atlas/normals_list.html create mode 100644 atlas/templates/atlas/partials/_normal_row.html 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 %} +
+

Normals

+ +
+
+
+ {% for field in filter.form.visible_fields %} +
+ {{ field.label_tag }} + {{ field }} ++
+ {% endfor %} +
+ + Clear +
+
+
+
+ +
+ {% for normal in page_obj.object_list %} + {% include "atlas/partials/_normal_row.html" with normal=normal %} + {% empty %} +
No normal cases found.
+ {% endfor %} +
+ + +
+{% endblock %} diff --git a/atlas/templates/atlas/partials/_normal_row.html b/atlas/templates/atlas/partials/_normal_row.html new file mode 100644 index 00000000..4a470c13 --- /dev/null +++ b/atlas/templates/atlas/partials/_normal_row.html @@ -0,0 +1,11 @@ +
+
+
{{ normal.case.title }}
+ {{ normal.added_date|date:"Y-m-d" }} +
+

Age: {{ normal.age_years|default:"unknown" }} years

+

Examination: {{ normal.examination }} | Modality: {{ normal.modality }}

+ {% if normal.notes %} +

Notes: {{ normal.notes }}

+ {% endif %} +
diff --git a/atlas/urls.py b/atlas/urls.py index 28a63118..8d50e4e9 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -349,6 +349,7 @@ urlpatterns = [ path("categories/", views.categories_list, name="categories_list"), path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"), path("search/cases/", views.case_search_partial, name="case_search_partial"), + path("normals/", views.NormalCaseList.as_view(), name="normals_list"), path("condition/", views.condition_detail, name="condition_detail"), path( "condition//delete", diff --git a/atlas/views.py b/atlas/views.py index bf062f5b..e02c8acc 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -5,6 +5,7 @@ from dal import autocomplete from django.db.models.base import Model as Model from django.db.models.query import QuerySet from django.db.models import Prefetch +from django.db.models import Q from django.shortcuts import render, get_object_or_404, redirect from django import forms from django.utils import timezone @@ -107,6 +108,7 @@ from .models import ( UserReportAnswer, PrerequisiteRequired ) +from .models import NormalCase from .tables import ( CaseCollectionTable, CaseTable, @@ -130,6 +132,7 @@ from .filters import ( SeriesFilter, StructureFilter, SubspecialtyFilter, + NormalCaseFilter, ) from .tasks import push_case_to_cimar_task @@ -288,6 +291,22 @@ class AtlasEditorRequiredMixin(object): return obj raise PermissionDenied("You must be an atlas editor to do this.") # or Http404 + + +class NormalCaseList(LoginRequiredMixin, FilterView): + model = NormalCase + template_name = "atlas/normals_list.html" + filterset_class = NormalCaseFilter + paginate_by = 25 + + def get_queryset(self): + qs = NormalCase.objects.select_related("case", "examination", "modality", "added_by") + # Restrict similar to CaseFilter: non-editors only see authored or open_access + if not self.request.user.groups.filter(name="atlas_editor").exists(): + qs = qs.filter((Q(case__author__id=self.request.user.id) | Q(case__open_access=True))) + + return qs.order_by("-added_date") + @login_required @user_has_case_view_access def case_displaysets(request, pk):