diff --git a/atlas/forms.py b/atlas/forms.py
index e0412dc0..89361c03 100755
--- a/atlas/forms.py
+++ b/atlas/forms.py
@@ -44,6 +44,7 @@ from atlas.models import (
UserReportAnswer,
CaseDisplaySet,
)
+from .models import NormalCase
from anatomy.models import Modality
@@ -236,6 +237,28 @@ class CaseCollectionForm(ModelForm):
Submit("submit", "Save Collection", css_class="btn btn-primary"),
)
+
+class NormalCaseForm(ModelForm):
+ class Meta:
+ model = NormalCase
+ fields = ("age_years", "examination", "modality", "notes")
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.helper = FormHelper()
+ self.helper.form_method = "post"
+ self.helper.form_tag = True
+ self.helper.layout = Layout(
+ Div(
+ Field("age_years", wrapper_class="col-md-4"),
+ Field("examination", wrapper_class="col-md-4"),
+ Field("modality", wrapper_class="col-md-4"),
+ css_class="row",
+ ),
+ Field("notes"),
+ Submit("submit", "Save", css_class="btn btn-primary"),
+ )
+
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
widget=SplitDateTimeWidget(
date_attrs={"type": "date", "class": "datepicker"},
@@ -258,14 +281,11 @@ class CaseCollectionForm(ModelForm):
)
def save(self, commit=True):
- # Get the unsaved Case instance
- instance = ModelForm.save(self, False)
-
- # Do we need to save all changes now?
- # if commit:
- instance.save()
- self.save_m2m()
-
+ # Respect the commit flag: return an unsaved instance if commit=False
+ instance = super().save(commit=False)
+ if commit:
+ instance.save()
+ self.save_m2m()
return instance
diff --git a/atlas/templates/atlas/normals_list.html b/atlas/templates/atlas/normals_list.html
index 246f4592..fecf2923 100644
--- a/atlas/templates/atlas/normals_list.html
+++ b/atlas/templates/atlas/normals_list.html
@@ -3,49 +3,49 @@
{% load crispy_forms_tags %}
{% block content %}
-
-
Normals
+
+
Normals
-
-
+ {% else %}
+
No normal cases found.
+ {% endif %}
+
+
-
- {% if page_obj.object_list %}
-
- {% else %}
-
No normal cases found.
- {% endif %}
-
-
-
{% endblock %}
diff --git a/atlas/templates/atlas/partials/_normal_form.html b/atlas/templates/atlas/partials/_normal_form.html
new file mode 100644
index 00000000..4b8757de
--- /dev/null
+++ b/atlas/templates/atlas/partials/_normal_form.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
diff --git a/atlas/templates/atlas/partials/_normal_toggle.html b/atlas/templates/atlas/partials/_normal_toggle.html
index a7c9241f..d7354dd2 100644
--- a/atlas/templates/atlas/partials/_normal_toggle.html
+++ b/atlas/templates/atlas/partials/_normal_toggle.html
@@ -4,7 +4,7 @@
{% if case.normal_case %}
Normal
{% if case.normal_case.age_years %}{{ case.normal_case.age_years }}y{% else %}age unknown{% endif %}
- {% if is_atlas_editor %}
+ {% if can_mark_normal %}
diff --git a/atlas/urls.py b/atlas/urls.py
index 7df4dc41..97be9f60 100755
--- a/atlas/urls.py
+++ b/atlas/urls.py
@@ -456,6 +456,8 @@ urlpatterns = [
# path("verified//", views.verified_detail, name="verified_detail"),
path("case//", views.case_detail, name="case_detail"),
path("case//toggle_normal/", views.toggle_case_normal, name="case_toggle_normal"),
+ path("case//normal_form/", views.case_normal_form, name="case_normal_form"),
+ path("case//create_normal/", views.create_case_normal, name="case_create_normal"),
# path("case//collection-form", views.AddCollectionToCaseView.as_view(), name="case_collection_form"),
path(
"case//collection-form",
diff --git a/atlas/views.py b/atlas/views.py
index 95326269..a2174953 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -457,6 +457,8 @@ def case_detail(request, pk):
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(
request,
@@ -466,6 +468,8 @@ def case_detail(request, pk):
"cimar_sid": request.user.userprofile.cimar_sid,
"can_edit": can_edit,
"is_atlas_editor": is_atlas_editor,
+ "is_case_author": is_case_author,
+ "can_mark_normal": can_mark_normal,
},
)
@@ -481,8 +485,11 @@ def toggle_case_normal(request, pk):
"""
case = get_case_for_case_detail(pk)
- # Permission: atlas editors or superusers
- if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
+ # 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)
@@ -549,14 +556,121 @@ def toggle_case_normal(request, pk):
NormalCase.objects.create(case=case, age_years=age, 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},
+ {"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
+
+ form = NormalCaseForm(initial={
+ "age_years": age,
+ "examination": inferred_examination,
+ "modality": inferred_modality,
+ })
+
+ html = render_to_string("atlas/partials/_normal_form.html", {"form": form, "case": case}, request=request)
+ 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
+ form = NormalCaseForm(request.POST)
+ if form.is_valid():
+ nc = form.save(commit=False)
+ nc.case = case
+ nc.added_by = request.user
+ nc.save()
+
+ # 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 += "".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
@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access
def series_thumbnail_fail(request, pk):