add a normals feature
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends "atlas/base.html" %}
|
{% extends "atlas/base.html" %}
|
||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
{{ field.label_tag }}
|
{{ field.label_tag }}
|
||||||
{{ field }}
|
{{ field }}
|
||||||
+ </div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<button class="btn btn-primary" type="submit">Filter</button>
|
<button class="btn btn-primary" type="submit">Filter</button>
|
||||||
@@ -22,13 +23,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="list-group">
|
{% if page_obj.object_list %}
|
||||||
{% for normal in page_obj.object_list %}
|
<div class="list-group">
|
||||||
{% include "atlas/partials/_normal_row.html" with normal=normal %}
|
{% for normal in page_obj.object_list %}
|
||||||
{% empty %}
|
<a class="list-group-item list-group-item-action" href="{% url 'atlas:case_detail' normal.case.pk %}">
|
||||||
<div class="list-group-item">No normal cases found.</div>
|
{{ normal.case.title }}{% if normal.age_years %} — {{ normal.age_years }} yrs{% endif %}
|
||||||
{% endfor %}
|
<div class="small text-muted">Added {{ normal.added_date }}</div>
|
||||||
</div>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="list-group-item">No normal cases found.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<nav aria-label="Page navigation">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination mt-3">
|
<ul class="pagination mt-3">
|
||||||
|
|||||||
@@ -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">{% if case.normal_case.age_years %}{{ case.normal_case.age_years }}y{% else %}age unknown{% endif %}</small>
|
||||||
|
{% if is_atlas_editor %}
|
||||||
|
<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 is_atlas_editor %}
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
hx-post="{% url 'atlas:case_toggle_normal' case.pk %}"
|
||||||
|
hx-target="#normal-toggle-block"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
Mark as normal
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">Normal status only editable by atlas editors</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -455,6 +455,7 @@ 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>/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",
|
||||||
|
|||||||
@@ -456,6 +456,8 @@ 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()
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/case_detail.html",
|
"atlas/case_detail.html",
|
||||||
@@ -463,9 +465,98 @@ 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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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 or superusers
|
||||||
|
if not (request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()):
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
|
||||||
|
NormalCase.objects.create(case=case, age_years=age, added_by=request.user)
|
||||||
|
|
||||||
|
# Render the updated fragment
|
||||||
|
is_atlas_editor = request.user.is_superuser or request.user.groups.filter(name="atlas_editor").exists()
|
||||||
|
html = render_to_string(
|
||||||
|
"atlas/partials/_normal_toggle.html",
|
||||||
|
{"case": case, "user": request.user, "is_atlas_editor": is_atlas_editor},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
return HttpResponse(html)
|
||||||
|
|
||||||
@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