Add NormalCase model, views, filters, and templates for managing normal cases
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user