Add NormalCase model, views, filters, and templates for managing normal cases

This commit is contained in:
Ross
2025-11-13 21:44:55 +00:00
parent 726c7ea401
commit 345977e2ef
8 changed files with 231 additions and 0 deletions
+10
View File
@@ -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
+33
View File
@@ -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
+35
View File
@@ -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'],
},
),
]
+77
View File
@@ -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",
+45
View File
@@ -0,0 +1,45 @@
{% extends "atlas/base.html" %}
{% load static %}
{% block content %}
<div class="container">
<h1>Normals</h1>
<div class="row mb-3">
<div class="col-md-4">
<form method="get" class="form-inline">
{% for field in filter.form.visible_fields %}
<div class="mb-2">
{{ field.label_tag }}
{{ field }}
+ </div>
{% endfor %}
<div class="mb-2">
<button class="btn btn-primary" type="submit">Filter</button>
<a class="btn btn-link" href="?">Clear</a>
</div>
</form>
</div>
</div>
<div class="list-group">
{% for normal in page_obj.object_list %}
{% include "atlas/partials/_normal_row.html" with normal=normal %}
{% empty %}
<div class="list-group-item">No normal cases found.</div>
{% endfor %}
</div>
<nav aria-label="Page navigation">
<ul class="pagination mt-3">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">Previous</a></li>
{% endif %}
<li class="page-item disabled"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">Next</a></li>
{% endif %}
</ul>
</nav>
</div>
{% endblock %}
@@ -0,0 +1,11 @@
<div class="list-group-item">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1"><a href="{{ normal.case.get_absolute_url }}">{{ normal.case.title }}</a></h5>
<small>{{ normal.added_date|date:"Y-m-d" }}</small>
</div>
<p class="mb-1">Age: {{ normal.age_years|default:"unknown" }} years</p>
<p class="mb-1">Examination: {{ normal.examination }} | Modality: {{ normal.modality }}</p>
{% if normal.notes %}
<p class="mb-0">Notes: {{ normal.notes }}</p>
{% endif %}
</div>
+1
View File
@@ -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/<int:pk>", views.condition_detail, name="condition_detail"),
path(
"condition/<int:pk>/delete",
+19
View File
@@ -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):