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

-
-
-
- {% for field in filter.form.visible_fields %} +
+
+ + {% for field in filter.form.visible_fields %} +
+ {{ field.label_tag }} + {{ field }} +
+ {% endfor %}
- {{ field.label_tag }} - {{ field }} + + Clear
+ +
+
+ + {% if page_obj.object_list %} + +
+ {% 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 %} {% else %} - Normal status only editable by atlas editors + Normal status only editable by atlas editors or case authors {% endif %} {% endif %}
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):