Add NormalCase form and related views for managing normal cases
This commit is contained in:
+28
-8
@@ -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,6 +237,28 @@ class CaseCollectionForm(ModelForm):
|
|||||||
Submit("submit", "Save Collection", css_class="btn btn-primary"),
|
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(
|
self.fields["start_date"] = SplitDateTimeFieldDefaultTime(
|
||||||
widget=SplitDateTimeWidget(
|
widget=SplitDateTimeWidget(
|
||||||
date_attrs={"type": "date", "class": "datepicker"},
|
date_attrs={"type": "date", "class": "datepicker"},
|
||||||
@@ -258,14 +281,11 @@ class CaseCollectionForm(ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def save(self, commit=True):
|
def save(self, commit=True):
|
||||||
# Get the unsaved Case instance
|
# Respect the commit flag: return an unsaved instance if commit=False
|
||||||
instance = ModelForm.save(self, False)
|
instance = super().save(commit=False)
|
||||||
|
if commit:
|
||||||
# Do we need to save all changes now?
|
instance.save()
|
||||||
# if commit:
|
self.save_m2m()
|
||||||
instance.save()
|
|
||||||
self.save_m2m()
|
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,49 +3,49 @@
|
|||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Normals</h1>
|
<h1>Normals</h1>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<form method="get" class="form-inline">
|
<form method="get" class="form-inline">
|
||||||
{% for field in filter.form.visible_fields %}
|
{% for field in filter.form.visible_fields %}
|
||||||
|
<div class="mb-2">
|
||||||
|
{{ field.label_tag }}
|
||||||
|
{{ field }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{{ field.label_tag }}
|
<button class="btn btn-primary" type="submit">Filter</button>
|
||||||
{{ field }}
|
<a class="btn btn-link" href="?">Clear</a>
|
||||||
</div>
|
</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 %}">
|
||||||
|
{{ normal.case.title }}{% if normal.age_years %} — {{ normal.age_years }} yrs{% endif %}
|
||||||
|
<div class="small text-muted">Added {{ normal.added_date }}</div>
|
||||||
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="mb-2">
|
</div>
|
||||||
<button class="btn btn-primary" type="submit">Filter</button>
|
{% else %}
|
||||||
<a class="btn btn-link" href="?">Clear</a>
|
<div class="list-group-item">No normal cases found.</div>
|
||||||
</div>
|
{% endif %}
|
||||||
</form>
|
|
||||||
</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>
|
</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 %}">
|
|
||||||
{{ normal.case.title }}{% if normal.age_years %} — {{ normal.age_years }} yrs{% endif %}
|
|
||||||
<div class="small text-muted">Added {{ normal.added_date }}</div>
|
|
||||||
</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 %}
|
{% 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>
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
{% if case.normal_case %}
|
{% if case.normal_case %}
|
||||||
<span class="badge bg-success">Normal</span>
|
<span class="badge bg-success">Normal</span>
|
||||||
<small class="text-muted">{% if case.normal_case.age_years %}{{ case.normal_case.age_years }}y{% else %}age unknown{% endif %}</small>
|
<small class="text-muted">{% if case.normal_case.age_years %}{{ case.normal_case.age_years }}y{% else %}age unknown{% endif %}</small>
|
||||||
{% if is_atlas_editor %}
|
{% if can_mark_normal %}
|
||||||
<button class="btn btn-sm btn-outline-danger ms-2"
|
<button class="btn btn-sm btn-outline-danger ms-2"
|
||||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||||
hx-target="#normal-toggle-block"
|
hx-target="#normal-toggle-block"
|
||||||
@@ -14,15 +14,15 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if is_atlas_editor %}
|
{% if can_mark_normal %}
|
||||||
<button class="btn btn-sm btn-outline-primary"
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
hx-get="{% url 'atlas:case_normal_form' case.pk %}"
|
||||||
hx-target="#normal-toggle-block"
|
hx-target="body"
|
||||||
hx-swap="outerHTML">
|
hx-swap="beforeend">
|
||||||
Mark as normal
|
Mark as normal
|
||||||
</button>
|
</button>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">Normal status only editable by atlas editors</span>
|
<span class="text-muted">Normal status only editable by atlas editors or case authors</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -456,6 +456,8 @@ urlpatterns = [
|
|||||||
# 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>/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",
|
||||||
|
|||||||
+117
-3
@@ -457,6 +457,8 @@ def case_detail(request, 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_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,
|
||||||
@@ -466,6 +468,8 @@ def case_detail(request, pk):
|
|||||||
"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_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)
|
case = get_case_for_case_detail(pk)
|
||||||
|
|
||||||
# Permission: atlas editors or superusers
|
# Permission: atlas editors, case authors, or superusers
|
||||||
if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
|
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)
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
# If already marked normal, unmark (delete the NormalCase)
|
# 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)
|
NormalCase.objects.create(case=case, age_years=age, added_by=request.user)
|
||||||
|
|
||||||
# Render the updated fragment
|
# 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_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(
|
html = render_to_string(
|
||||||
"atlas/partials/_normal_toggle.html",
|
"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,
|
request=request,
|
||||||
)
|
)
|
||||||
return HttpResponse(html)
|
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 += "<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):
|
||||||
|
|||||||
Reference in New Issue
Block a user