Compare commits

...
10 Commits
16 changed files with 951 additions and 193 deletions
+17
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,22 @@ class CaseCollectionAdmin(VersionAdmin):
admin.site.register(CaseCollection, CaseCollectionAdmin) admin.site.register(CaseCollection, CaseCollectionAdmin)
class NormalCaseAdmin(VersionAdmin):
list_display = ("case", "display_age", "examination", "modality", "added_by", "added_date")
list_filter = ("examination", "modality")
search_fields = ("case__title", "case__pk")
def display_age(self, obj):
try:
return obj.display_age()
except Exception:
return ""
display_age.short_description = "Age"
admin.site.register(NormalCase, NormalCaseAdmin)
class AtlasAdminForm(ModelForm): class AtlasAdminForm(ModelForm):
class Meta: class Meta:
model = Case model = Case
+49
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,54 @@ 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())
# Accept min/max as years in the UI but filter against the canonical
# `age_days` field by converting years -> days.
min_age = django_filters.NumberFilter(label="Min age", method="filter_min_age")
max_age = django_filters.NumberFilter(label="Max age", method="filter_max_age")
class Meta:
model = NormalCase
fields = ("examination", "modality", "age_days")
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)
def filter_min_age(self, queryset, name, value):
try:
days = int(float(value) * 365.25)
except Exception:
return queryset
return queryset.filter(age_days__gte=days)
def filter_max_age(self, queryset, name, value):
try:
days = int(float(value) * 365.25)
except Exception:
return queryset
return queryset.filter(age_days__lte=days)
class QuestionSchemaFilter(django_filters.FilterSet): class QuestionSchemaFilter(django_filters.FilterSet):
class Meta: class Meta:
model = QuestionSchema model = QuestionSchema
+43 -22
View File
@@ -44,6 +44,7 @@ from atlas.models import (
UserReportAnswer, UserReportAnswer,
CaseDisplaySet, CaseDisplaySet,
) )
from .models import NormalCase
from anatomy.models import Modality from anatomy.models import Modality
@@ -236,33 +237,53 @@ class CaseCollectionForm(ModelForm):
Submit("submit", "Save Collection", css_class="btn btn-primary"), Submit("submit", "Save Collection", css_class="btn btn-primary"),
) )
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
widget=SplitDateTimeWidget( class NormalCaseForm(ModelForm):
date_attrs={"type": "date", "class": "datepicker"}, # Keep non-model input fields for age entry so users can provide a
time_attrs={"type": "time", "class": "timepicker"}, # value + unit (e.g. 6 months) which will be converted to canonical
date_format="%Y-%m-%d", # days on save. The model now stores only `age_days`.
time_format="%H:%M", age_value = forms.IntegerField(required=False, min_value=0)
age_unit = forms.ChoiceField(required=False, choices=NormalCase.AGE_UNIT_CHOICES)
class Meta:
model = NormalCase
fields = ("examination", "modality", "notes")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
# Do not render a <form> tag from crispy here because the template
# already provides the surrounding form element (we inject this
# fragment into a modal that contains the form). Rendering a second
# <form> would nest forms and cause fields to be outside the
# submitted form (HTMX would only send the outer form's fields).
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Div(
Field("age_value", wrapper_class="col-md-3"),
Field("age_unit", wrapper_class="col-md-3"),
Field("examination", wrapper_class="col-md-3"),
Field("modality", wrapper_class="col-md-3"),
css_class="row",
), ),
required=False, Field("notes"),
help_text="The date the exam is due to start (time is optional)", Submit("submit", "Save", css_class="btn btn-primary"),
)
self.fields["end_date"] = SplitDateTimeFieldDefaultTimeEnd(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
time_attrs={"type": "time", "class": "timepicker"},
date_format="%Y-%m-%d",
time_format="%H:%M",
),
required=False,
help_text="The date the exam is due to start (time is optional)",
) )
def save(self, commit=True): def save(self, commit=True):
# Get the unsaved Case instance # Respect commit flag. Compute canonical `age_days` from the
instance = ModelForm.save(self, False) # provided age_value/age_unit and set it on the instance.
instance = super().save(commit=False)
age_value = self.cleaned_data.get("age_value")
age_unit = self.cleaned_data.get("age_unit")
if age_value is not None and age_unit:
instance.age_days = NormalCase.to_days(age_value, age_unit)
else:
# If the user left fields blank, clear any previous value.
instance.age_days = None
# Do we need to save all changes now? if commit:
# if commit:
instance.save() instance.save()
self.save_m2m() self.save_m2m()
+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'],
},
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-11-13 22:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('atlas', '0080_normalcase'),
]
operations = [
migrations.RemoveField(
model_name='normalcase',
name='age_years',
),
migrations.AddField(
model_name='normalcase',
name='age_days',
field=models.PositiveIntegerField(blank=True, help_text='Patient age at scan in days (canonical).', null=True),
),
]
+149
View File
@@ -611,6 +611,155 @@ 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.
This version stores a canonical `age_days` integer (patient age in days).
We keep the examination/modality metadata and authored fields. The
age fields previously stored (`age_years`, `age_value`, `age_unit`) have
been removed in favour of the single canonical `age_days` value.
"""
case = models.OneToOneField(
Case,
on_delete=models.CASCADE,
related_name="normal_case",
help_text="The Case that is marked as a normal example.",
)
# Canonical internal representation: patient age in days
age_days = models.PositiveIntegerField(
null=True,
blank=True,
help_text="Patient age at scan in days (canonical).",
)
# Helpful constants for converting/displaying ages. These are not
# stored directly on the model but are used by forms/views/templates.
AGE_UNIT_YEARS = "years"
AGE_UNIT_MONTHS = "months"
AGE_UNIT_WEEKS = "weeks"
AGE_UNIT_DAYS = "days"
AGE_UNIT_CHOICES = [
(AGE_UNIT_YEARS, "years"),
(AGE_UNIT_MONTHS, "months"),
(AGE_UNIT_WEEKS, "weeks"),
(AGE_UNIT_DAYS, "days"),
]
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.display_age()})"
def get_absolute_url(self):
return self.case.get_absolute_url()
@staticmethod
def to_days(value: int | float, unit: str) -> int | None:
"""Convert a numeric value + unit into integer days (rounded).
Examples:
to_days(2, 'years') -> ~730
to_days(6, 'months') -> ~183
"""
try:
if value is None or unit is None:
return None
v = float(value)
if unit == NormalCase.AGE_UNIT_YEARS:
return int(round(v * 365.25))
if unit == NormalCase.AGE_UNIT_MONTHS:
return int(round(v * 30.4375))
if unit == NormalCase.AGE_UNIT_WEEKS:
return int(round(v * 7))
if unit == NormalCase.AGE_UNIT_DAYS:
return int(round(v))
except Exception:
return None
@staticmethod
def days_to_value_unit(days: int | None) -> tuple[int | None, str | None]:
"""Convert canonical days into a (value, unit) tuple for display.
Prefer years for ages >= 365 days, months for ages >= 30 days,
weeks for >=7 days, else days.
"""
if days is None:
return (None, None)
try:
d = int(days)
if d >= 365:
yrs = int(round(d / 365.25))
return (yrs, NormalCase.AGE_UNIT_YEARS)
if d >= 30:
months = int(round(d / 30.4375))
return (months, NormalCase.AGE_UNIT_MONTHS)
if d >= 7:
weeks = int(round(d / 7))
return (weeks, NormalCase.AGE_UNIT_WEEKS)
return (d, NormalCase.AGE_UNIT_DAYS)
except Exception:
return (None, None)
def display_age(self) -> str:
"""Return a human friendly age string derived from age_days."""
if self.age_days is None:
return "age unknown"
val, unit = NormalCase.days_to_value_unit(self.age_days)
if val is None or unit is None:
return "age unknown"
# abbreviate years to 'y' for backwards compatibility where helpful
if unit == NormalCase.AGE_UNIT_YEARS:
return f"{val}y"
return f"{val} {unit}"
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",
+4
View File
@@ -46,6 +46,9 @@
<li> <li>
<a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a> <a class="dropdown-item" href="{% url 'atlas:case_create' %}"><i class="bi bi-plus-square me-1" aria-hidden="true"></i>Create Case</a>
</li> </li>
<li>
<a class="dropdown-item" href="{% url 'atlas:normals_list' %}"><i class="bi bi-file-earmark-check me-1" aria-hidden="true"></i>Normals</a>
</li>
</ul> </ul>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
@@ -69,6 +72,7 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a> <a class="nav-link" href="{% url 'atlas:resource_view' %}" title="Resources"><i class="bi bi-folder2-open me-1" aria-hidden="true"></i>Resources</a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a> <a class="nav-link" href="{% url 'atlas:user_uploads' %}" title="View unimported uploads"><i class="bi bi-upload me-1" aria-hidden="true"></i>Uploads</a>
</li> </li>
@@ -147,6 +147,10 @@
{# Diagnostic certainty as a badge #} {# Diagnostic certainty as a badge #}
Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span> Diagnostic certainty: <span class="badge bg-info text-dark ms-2">{{ case.get_diagnostic_certainty_display }}</span>
{# Normal toggle HTMX block #}
<div class="mt-1">
{% include "atlas/partials/_normal_toggle.html" %}
</div>
</div> </div>
</div> </div>
+61
View File
@@ -0,0 +1,61 @@
{% extends "atlas/base.html" %}
{% load static %}
{% load crispy_forms_tags %}
{% 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>
{% if page_obj.object_list %}
<div class="list-group">
{% for normal in page_obj.object_list %}
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{ normal.case.title }}</h5>
<small class="text-muted">{{ normal.added_date|date:"Y-m-d" }}</small>
</div>
<p class="mb-1">
Age: {{ normal.display_age }}
{% if normal.examination %} • Exam: {{ normal.examination }}{% endif %}
{% if normal.modality %} • Modality: {{ normal.modality }}{% endif %}
</p>
{% if normal.notes %}
<p class="mb-0 text-muted">{{ normal.notes }}</p>
{% endif %}
</a>
{% endfor %}
</div>
{% else %}
<div class="list-group-item">No normal cases found.</div>
{% endif %}
<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,37 @@
<div id="normal-modal-{{ case.pk }}" class="modal modal-blur fade" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<form method="post" hx-post="{% url 'atlas:case_create_normal' case.pk %}" hx-target="#normal-toggle-block" hx-swap="outerHTML">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">Mark case {{ case.pk }} as Normal</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{% load crispy_forms_tags %}
{{ form.media }}
{% crispy form %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Normal</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Show the modal after HTMX injects it
(function(){
try {
var el = document.getElementById('normal-modal-{{ case.pk }}');
if(el){
var m = new bootstrap.Modal(el);
m.show();
}
} catch(e) {
console.error('Failed to show normal modal', e);
}
})();
</script>
@@ -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.display_age }}</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>
@@ -0,0 +1,28 @@
{% load static %}
<div id="normal-toggle-block">
{% if case.normal_case %}
<span class="badge bg-success">Normal</span>
<small class="text-muted">{{ case.normal_case.display_age }}</small>
{% if can_mark_normal %}
<button class="btn btn-sm btn-outline-danger ms-2"
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
hx-target="#normal-toggle-block"
hx-swap="outerHTML"
hx-confirm="Unmark this case as normal?">
Unmark normal
</button>
{% endif %}
{% else %}
{% if can_mark_normal %}
<button class="btn btn-sm btn-outline-primary"
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
hx-target="body"
hx-swap="beforeend">
Mark as normal
</button>
{% else %}
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
{% endif %}
{% endif %}
</div>
+4
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",
@@ -454,6 +455,9 @@ urlpatterns = [
# path("unchecked/", views.unchecked_list, name="unchecked_list"), # path("unchecked/", views.unchecked_list, name="unchecked_list"),
# path("verified/<int:pk>/", views.verified_detail, name="verified_detail"), # path("verified/<int:pk>/", views.verified_detail, name="verified_detail"),
path("case/<int:pk>/", views.case_detail, name="case_detail"), path("case/<int:pk>/", views.case_detail, name="case_detail"),
path("case/<int:pk>/toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
path("case/<int:pk>/normal_form/", views.case_normal_form, name="case_normal_form"),
path("case/<int:pk>/create_normal/", views.create_case_normal, name="case_create_normal"),
# path("case/<int:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"), # path("case/<int:pk>/collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
path( path(
"case/<int:case_id>/collection-form", "case/<int:case_id>/collection-form",
+279
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):
@@ -437,6 +456,10 @@ def case_detail(request, pk):
case = get_case_for_case_detail(pk) case = get_case_for_case_detail(pk)
can_edit = case.check_user_can_edit(request.user) can_edit = case.check_user_can_edit(request.user)
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
return render( return render(
request, request,
"atlas/case_detail.html", "atlas/case_detail.html",
@@ -444,9 +467,265 @@ def case_detail(request, pk):
"case": case, "case": case,
"cimar_sid": request.user.userprofile.cimar_sid, "cimar_sid": request.user.userprofile.cimar_sid,
"can_edit": can_edit, "can_edit": can_edit,
"is_atlas_editor": is_atlas_editor,
"is_case_author": is_case_author,
"can_mark_normal": can_mark_normal,
}, },
) )
@login_required
@require_POST
def toggle_case_normal(request, pk):
"""HTMX endpoint to toggle the NormalCase mark for a Case.
- Only users in the 'atlas_editor' group or superusers may toggle.
- On create, attempt to auto-extract age_years from DICOM metadata.
- Returns an HTML fragment that replaces the toggle block.
"""
case = get_case_for_case_detail(pk)
# Permission: atlas editors, case authors, or superusers
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
if not can_mark_normal:
return HttpResponse(status=403)
# If already marked normal, unmark (delete the NormalCase)
try:
normal = case.normal_case
except Exception:
normal = None
if normal is not None:
normal.delete()
# Remove any cached relation on the case and reload so the template
# sees the updated state (no normal_case).
try:
if hasattr(case, "normal_case"):
delattr(case, "normal_case")
except Exception:
pass
case = get_case_for_case_detail(pk)
else:
# Attempt to auto-extract age from DICOM
age = None
try:
from datetime import datetime
import re
# Iterate series then images to find first usable DICOM
for series in case.series.all():
img = series.images.filter(removed=False).first()
if not img:
continue
ds = img.get_dicom_data()
if not ds:
continue
# Try PatientAge (format often like '045Y' or '45Y')
try:
pa = getattr(ds, "PatientAge", None)
except Exception:
pa = None
if pa:
s = str(pa)
m = re.search(r"(\d+)", s)
if m:
age = int(m.group(1))
break
# Try birthdate + studydate
try:
pbd = getattr(ds, "PatientBirthDate", None)
sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None)
except Exception:
pbd = None
sdate = None
if pbd and sdate:
try:
bd = datetime.strptime(str(pbd), "%Y%m%d")
sd = datetime.strptime(str(sdate), "%Y%m%d")
years = round((sd - bd).days / 365.25)
age = int(years)
break
except Exception:
pass
except Exception:
age = None
# Create NormalCase
from .models import NormalCase
# Convert inferred integer years -> canonical days (approx)
if age is not None:
age_days = NormalCase.to_days(age, NormalCase.AGE_UNIT_YEARS)
else:
age_days = None
NormalCase.objects.create(case=case, age_days=age_days, added_by=request.user)
# Render the updated fragment
# Recompute flags for the fragment render
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
html = render_to_string(
"atlas/partials/_normal_toggle.html",
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal},
request=request,
)
return HttpResponse(html)
@login_required
def case_normal_form(request, pk):
"""Return an HTMX modal with a NormalCaseForm prepopulated with auto-extracted values."""
case = get_case_for_case_detail(pk)
# Permission check: allow atlas editors, case authors, or superusers
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
if not can_mark_normal:
return HttpResponse(status=403)
# Extract defaults
age = None
inferred_examination = None
inferred_modality = None
try:
for series in case.series.all():
img = series.images.filter(removed=False).first()
if not img:
continue
ds = img.get_dicom_data()
if not ds:
continue
pa = getattr(ds, "PatientAge", None)
if pa:
import re
m = re.search(r"(\d+)", str(pa))
if m:
age = int(m.group(1))
break
pbd = getattr(ds, "PatientBirthDate", None)
sdate = getattr(ds, "StudyDate", None) or getattr(ds, "AcquisitionDate", None) or getattr(ds, "SeriesDate", None)
if pbd and sdate:
from datetime import datetime
try:
bd = datetime.strptime(str(pbd), "%Y%m%d")
sd = datetime.strptime(str(sdate), "%Y%m%d")
years = round((sd - bd).days / 365.25)
age = int(years)
break
except Exception:
pass
# Try inferring exam/modality from first series
first_series = case.series.first()
if first_series is not None:
inferred_examination = getattr(first_series, "examination", None)
inferred_modality = getattr(first_series, "modality", None)
except Exception:
pass
# Build form with initial values
from .forms import NormalCaseForm
# Map inferred age (years) into age_value/unit defaults. If we inferred
# a fractional year (from birthdate), prefer months for ages < 2 years.
from .models import NormalCase
inferred_age_value = None
inferred_age_unit = None
try:
if age is not None:
# age currently holds integer years; for small ages prefer months
if age < 2:
inferred_age_value = int(age * 12)
inferred_age_unit = NormalCase.AGE_UNIT_MONTHS
else:
inferred_age_value = int(age)
inferred_age_unit = NormalCase.AGE_UNIT_YEARS
except Exception:
inferred_age_value = None
inferred_age_unit = None
form = NormalCaseForm(initial={
"age_value": inferred_age_value,
"age_unit": inferred_age_unit,
"examination": inferred_examination,
"modality": inferred_modality,
})
# Log the rendered form HTML so we can verify inputs/names reach the client
from loguru import logger
try:
form_html = form.as_p()
logger.debug("case_normal_form rendered form HTML: {}", form_html)
except Exception as e:
logger.exception("Failed to render form.as_p(): {}", e)
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
logger.debug("case_normal_form returning HTML length: {}", len(html))
return HttpResponse(html)
@login_required
@require_POST
def create_case_normal(request, pk):
case = get_case_for_case_detail(pk)
# Permission check: allow atlas editors, case authors, or superusers
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
if not can_mark_normal:
return HttpResponse(status=403)
from .forms import NormalCaseForm
from loguru import logger
form = NormalCaseForm(request.POST)
logger.debug("create_case_normal POST data: {}", dict(request.POST))
if form.is_valid():
logger.debug("NormalCaseForm.cleaned_data: {}", form.cleaned_data)
nc = form.save(commit=False)
nc.case = case
nc.added_by = request.user
nc.save()
# Ensure the case reflects the newly created NormalCase when rendering
try:
if hasattr(case, "normal_case"):
delattr(case, "normal_case")
except Exception:
pass
case = get_case_for_case_detail(pk)
# Return the updated toggle fragment and close modal via inline script
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
is_case_author = case.author.filter(id=request.user.id).exists()
can_mark_normal = is_atlas_editor or is_case_author or request.user.is_superuser
toggle_html = render_to_string(
"atlas/partials/_normal_toggle.html",
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor, "is_case_author": is_case_author, "can_mark_normal": can_mark_normal},
request=request,
)
toggle_html += "<script>var m = bootstrap.Modal.getOrCreateInstance(document.getElementById('normal-modal-{}')); if(m) m.hide();</script>".format(case.pk)
return HttpResponse(toggle_html)
else:
# On form errors, re-render the modal with errors
html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
return HttpResponse(html, status=400)
@login_required @login_required
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access @user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail_fail(request, pk): def series_thumbnail_fail(request, pk):
+52 -15
View File
@@ -556,20 +556,16 @@ class UserUserGroupModelChoiceField(ModelMultipleChoiceField):
return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]" return f"{obj.username} ({obj.userprofile.grade}) [{obj.email}]"
class UserUserGroupForm(ModelForm): class UserSearchSelectMultipleWidget:
class UserSearchSelectMultiple(object): """Reusable lightweight widget renderer for a searchable multi-user selector.
"""
Lightweight widget renderer for a searchable multi-user selector. Usage: instantiate with (name, value) where value is an iterable of user ids
Renders a hidden <select multiple> containing selected user ids and and call .render() to get the HTML fragment. This mirrors the previous
a search box with results. Uses a small inline script to fetch nested widget but is available module-wide for reuse.
results from the `generic:user_search_widget` endpoint and add/remove
users from the selection.
This keeps implementation simple and avoids external JS dependencies.
""" """
def __init__(self, name, value): def __init__(self, name, value):
self.name = name self.name = name
# value is a list of selected user ids
self.value = value or [] self.value = value or []
def render(self): def render(self):
@@ -578,13 +574,11 @@ class UserUserGroupForm(ModelForm):
results_id = f"user_search_results_{self.name}" results_id = f"user_search_results_{self.name}"
selected_list_id = f"selected_users_{self.name}" selected_list_id = f"selected_users_{self.name}"
# Build initial options for selected users
users = User.objects.filter(pk__in=self.value) if self.value else [] users = User.objects.filter(pk__in=self.value) if self.value else []
options_html = "" options_html = ""
selected_html = "" selected_html = ""
for u in users: for u in users:
options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>' options_html += f'<option value="{u.pk}" selected>{u.get_full_name() or u.username} - {u.email}</option>'
# include grade display in the selected list if available
grade_text = "" grade_text = ""
try: try:
up = getattr(u, "userprofile", None) up = getattr(u, "userprofile", None)
@@ -605,10 +599,10 @@ class UserUserGroupForm(ModelForm):
url = reverse("generic:user_search_widget") url = reverse("generic:user_search_widget")
# Build a small grade filter select
grades_options = '<option value="">All grades</option>' grades_options = '<option value="">All grades</option>'
try: try:
from generic.models import UserGrades from generic.models import UserGrades
grades_qs = UserGrades.objects.all() grades_qs = UserGrades.objects.all()
for g in grades_qs: for g in grades_qs:
grades_options += f'<option value="{g.pk}">{g.name}</option>' grades_options += f'<option value="{g.pk}">{g.name}</option>'
@@ -622,13 +616,39 @@ class UserUserGroupForm(ModelForm):
</select> </select>
<div class="mb-2"> <div class="mb-2">
<div class="d-flex gap-2"> <div class="d-flex gap-2 align-items-start">
<input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" /> <input type="search" id="{search_id}" class="form-control form-control-sm" placeholder="Search users by name, username or email" />
<select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px"> <select id="grade_filter_{self.name}" class="form-select form-select-sm" style="max-width:180px">
{grades_options} {grades_options}
</select> </select>
<!-- Help button for advanced search features -->
<button type="button" id="user_search_help_btn_{self.name}" class="btn btn-sm btn-outline-secondary" aria-expanded="false" aria-controls="user_search_help_panel_{self.name}" title="Advanced search help">?</button>
</div> </div>
<div id="{results_id}" class="mt-2"></div> <div id="{results_id}" class="mt-2"></div>
<!-- Collapsible help panel (hidden by default) -->
<div id="user_search_help_panel_{self.name}" class="card card-body mt-2 d-none" style="font-size:.9rem;">
<strong>Advanced search tips</strong>
<ul class="mb-0 mt-1">
<li>Basic: type any part of a user's first name, last name, username or email (case-insensitive substring match). Example: <code>smith</code> or <code>joe@example.com</code>.</li>
<li>Wildcards: use <code>*</code> or <code>%</code> as the query to return many users (use carefully). Wildcard queries return up to 50 results; normal queries return up to 10.</li>
<li>Field-specific searches: prefix with <code>field:term</code> to restrict the search. Supported fields:
<ul class="mb-0 mt-1">
<li><code>name:</code> or <code>full_name:</code> matches first OR last name (e.g. <code>name:smith</code>).</li>
<li><code>first:</code> or <code>first_name:</code> matches first name.</li>
<li><code>last:</code> or <code>last_name:</code> matches last name.</li>
<li><code>email:</code> matches email address.</li>
<li><code>username:</code> matches username.</li>
<li><code>id:</code> or <code>pk:</code> match by numeric user id (e.g. <code>id:123</code>).</li>
<li><code>grade:</code> match by grade name or grade id (e.g. <code>grade:ST3</code> or <code>grade:2</code>).</li>
</ul>
</li>
<li>Grade filter: use the grade dropdown next to the search box to narrow results by trainee grade in addition to your query.</li>
<li>To add users, click the <em>Add</em> button beside a result. If an <em>Add all results</em> button appears, it will add all currently visible results.</li>
<li>If you expect many matches, narrow your query or use a field-specific search to improve relevance and performance.</li>
</ul>
</div>
</div> </div>
<div> <div>
@@ -645,6 +665,8 @@ class UserUserGroupForm(ModelForm):
const select = document.getElementById('{field_id}'); const select = document.getElementById('{field_id}');
const selectedList = document.getElementById('{selected_list_id}'); const selectedList = document.getElementById('{selected_list_id}');
const gradeSelect = document.getElementById('grade_filter_{self.name}'); const gradeSelect = document.getElementById('grade_filter_{self.name}');
const helpBtn = document.getElementById('user_search_help_btn_{self.name}');
const helpPanel = document.getElementById('user_search_help_panel_{self.name}');
let timeout = null; let timeout = null;
function fetchResults(q) {{ function fetchResults(q) {{
@@ -670,6 +692,16 @@ class UserUserGroupForm(ModelForm):
timeout = setTimeout(() => fetchResults(search.value), 50); timeout = setTimeout(() => fetchResults(search.value), 50);
}}); }});
// Toggle help panel visibility
if (helpBtn && helpPanel) {{
helpBtn.addEventListener('click', function(e) {{
e.preventDefault();
helpPanel.classList.toggle('d-none');
const expanded = !helpPanel.classList.contains('d-none');
helpBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}});
}}
// fieldName for this widget instance // fieldName for this widget instance
const widgetFieldName = '{self.name}'; const widgetFieldName = '{self.name}';
@@ -738,6 +770,11 @@ class UserUserGroupForm(ModelForm):
return mark_safe(html) return mark_safe(html)
class UserUserGroupForm(ModelForm):
# Use module-level UserSearchSelectMultipleWidget for rendering the users field
users = UserUserGroupModelChoiceField( users = UserUserGroupModelChoiceField(
required=False, required=False,
queryset=User.objects.all(), queryset=User.objects.all(),
@@ -770,7 +807,7 @@ class UserUserGroupForm(ModelForm):
# store attrs so template helpers can inspect them # store attrs so template helpers can inspect them
self.attrs = attrs or {} self.attrs = attrs or {}
value = value or [] value = value or []
widget = UserUserGroupForm.UserSearchSelectMultiple(name, value) widget = UserSearchSelectMultipleWidget(name, value)
return widget.render() return widget.render()
def value_from_datadict(self, data, files, name): def value_from_datadict(self, data, files, name):
@@ -15,7 +15,7 @@
</div> </div>
<div> <div>
<span class="badge bg-primary">Answer: {{ answer }}</span> <span class="badge bg-primary">Answer: {{ answer }}</span>
<span class="ms-2">{{ feedback }}</span> <span class="ms-2 fst-italic opacity-75">{{ feedback }}</span>
</div> </div>
</li> </li>
{% endfor %} {% endfor %}