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, Case,
CaseDetail, CaseDetail,
CaseCollection, CaseCollection,
NormalCase,
Differential, Differential,
Series, Series,
SeriesImage, SeriesImage,
@@ -79,6 +80,15 @@ class CaseCollectionAdmin(VersionAdmin):
admin.site.register(CaseCollection, CaseCollectionAdmin) 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 AtlasAdminForm(ModelForm):
class Meta: class Meta:
model = Case model = Case
+33
View File
@@ -11,6 +11,7 @@ from .models import (
Series, Series,
Structure, Structure,
Subspecialty, Subspecialty,
NormalCase,
) )
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
@@ -286,6 +287,38 @@ class SubspecialtyFilter(django_filters.FilterSet):
) )
pass 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 QuestionSchemaFilter(django_filters.FilterSet):
class Meta: class Meta:
model = QuestionSchema 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) 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): def extract_image_dicom_json_from_ds(ds, url, image_index):
to_keep = [ to_keep = [
"Columns", "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/", views.categories_list, name="categories_list"),
path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"), path("categories/search_partial/", views.categories_search_partial, name="categories_search_partial"),
path("search/cases/", views.case_search_partial, name="case_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>", views.condition_detail, name="condition_detail"),
path( path(
"condition/<int:pk>/delete", "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.base import Model as Model
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models import Q
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django import forms from django import forms
from django.utils import timezone from django.utils import timezone
@@ -107,6 +108,7 @@ from .models import (
UserReportAnswer, UserReportAnswer,
PrerequisiteRequired PrerequisiteRequired
) )
from .models import NormalCase
from .tables import ( from .tables import (
CaseCollectionTable, CaseCollectionTable,
CaseTable, CaseTable,
@@ -130,6 +132,7 @@ from .filters import (
SeriesFilter, SeriesFilter,
StructureFilter, StructureFilter,
SubspecialtyFilter, SubspecialtyFilter,
NormalCaseFilter,
) )
from .tasks import push_case_to_cimar_task from .tasks import push_case_to_cimar_task
@@ -288,6 +291,22 @@ class AtlasEditorRequiredMixin(object):
return obj return obj
raise PermissionDenied("You must be an atlas editor to do this.") # or Http404 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 @login_required
@user_has_case_view_access @user_has_case_view_access
def case_displaysets(request, pk): def case_displaysets(request, pk):