many changes

This commit is contained in:
Ross
2026-05-28 22:53:04 +01:00
parent 6368f525e1
commit ee4fe2b85b
16 changed files with 848 additions and 148 deletions
+70 -2
View File
@@ -180,6 +180,60 @@ class ConditionForm(ModelForm):
pass
class ConditionRelationshipForm(Form):
parents = ModelMultipleChoiceField(
required=False,
queryset=Condition.objects.none(),
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
)
children = ModelMultipleChoiceField(
required=False,
queryset=Condition.objects.none(),
widget=autocomplete.ModelSelect2Multiple(url="atlas:condition-autocomplete"),
)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop("instance", None)
super().__init__(*args, **kwargs)
condition_qs = Condition.objects.all().order_by("name")
if self.instance is not None and getattr(self.instance, "pk", None):
condition_qs = condition_qs.exclude(pk=self.instance.pk)
self.fields["parents"].queryset = condition_qs
self.fields["children"].queryset = condition_qs
self.helper = FormHelper()
self.helper.form_method = "post"
self.helper.form_tag = False
self.helper.layout = Layout(
Fieldset(
"Condition relationships",
"parents",
"children",
)
)
def save(self, commit=True):
if self.instance is None:
raise ValueError("ConditionRelationshipForm requires an instance")
parents = list(self.cleaned_data.get("parents", []))
children = list(self.cleaned_data.get("children", []))
self.instance.parent.set(parents)
current_children = list(self.instance.child.all())
for child in current_children:
if child not in children:
self.instance.child.remove(child)
for child in children:
if child not in current_children:
self.instance.child.add(child)
return self.instance
class CaseCollectionForm(ModelForm):
class Meta:
model = CaseCollection
@@ -211,6 +265,19 @@ class CaseCollectionForm(ModelForm):
label=label,
)
if "prerequisites" in self.fields:
authored_collections = CaseCollection.objects.filter(author=self.user).distinct().order_by("name")
if getattr(self.instance, "pk", None):
authored_collections = authored_collections.exclude(pk=self.instance.pk)
self.fields["prerequisites"].queryset = authored_collections
self.fields["prerequisites"].widget = autocomplete.ModelSelect2Multiple(
url="atlas:casecollection-prerequisite-autocomplete",
attrs={
"data-placeholder": "Search authored collections...",
"data-minimum-input-length": 0,
},
)
# Identify show_ fields
show_fields = [f for f in self.fields if f.startswith("show_")]
show_pre_fields = [f for f in show_fields if f.endswith("_pre")]
@@ -630,8 +697,9 @@ class CaseForm(ModelForm):
# This is populated if we create the case from an exam/collection
self.collection = None
if kwargs["initial"] is not None and "exams" in kwargs["initial"]:
collections = kwargs["initial"].pop("exams")
initial = kwargs.setdefault("initial", {})
if initial is not None and "exams" in initial:
collections = initial.pop("exams")
logger.debug(collections)
self.collection = get_object_or_404(CaseCollection, pk=collections[0])
+16 -4
View File
@@ -138,7 +138,10 @@ def _series_declared_groups_cache_key(series_id):
def invalidate_series_declared_groups_cache(series_id):
if not series_id:
return
cache.delete(_series_declared_groups_cache_key(series_id))
try:
cache.delete(_series_declared_groups_cache_key(series_id))
except Exception:
pass
def _build_declared_series_groups(series, series_images_with_urls):
@@ -155,7 +158,10 @@ def _build_declared_series_groups(series, series_images_with_urls):
for image_obj, image_url in series_images_with_urls
],
}
cached = cache.get(cache_key)
try:
cached = cache.get(cache_key)
except Exception:
cached = None
if isinstance(cached, dict):
if cached.get("signature") == signature:
return cached.get("groups", [])
@@ -257,7 +263,10 @@ def _build_declared_series_groups(series, series_images_with_urls):
"groupValues": {},
}
]
cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
try:
cache.set(cache_key, {"signature": signature, "groups": result}, timeout=60 * 60)
except Exception:
pass
return result
grouped = {}
@@ -313,7 +322,10 @@ def _build_declared_series_groups(series, series_images_with_urls):
}
)
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
try:
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
except Exception:
pass
return ordered_groups
+4
View File
@@ -173,6 +173,10 @@ def series_downsample_task(context, series_id, user_id, downsample_pct):
series.source_series_instance_uid = source_series_uid
series.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "source_series_instance_uid", "series_instance_uid"])
try:
series.get_total_image_size(refresh=True)
except Exception as exc:
logger.warning("Failed to refresh total_image_size for series {}: {}", series.pk, exc)
cache.set(
progress_key,
@@ -0,0 +1,112 @@
{% extends 'atlas/base.html' %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h3 class="mb-0">Case Admin Overview</h3>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'atlas:case_view' %}">Back to case list</a>
</div>
<div class="row g-3 mb-3">
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Total cases</div>
<div class="fs-4 fw-semibold">{{ totals.total_cases }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Authors with cases</div>
<div class="fs-4 fw-semibold">{{ totals.total_authors }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Open-access cases</div>
<div class="fs-4 fw-semibold">{{ totals.open_access_cases }}</div>
</div>
</div>
</div>
<div class="col-sm-6 col-xl-3">
<div class="card h-100">
<div class="card-body">
<div class="text-muted small">Archived cases</div>
<div class="fs-4 fw-semibold">{{ totals.archived_cases }}</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">Cases per user</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>User</th>
<th class="text-end">Case count</th>
</tr>
</thead>
<tbody>
{% for row in author_rows %}
<tr>
<td>
{% if row.get_full_name %}{{ row.get_full_name }}{% else %}{{ row.username }}{% endif %}
<div class="text-muted small">{{ row.username }}</div>
</td>
<td class="text-end fw-semibold">{{ row.case_count }}</td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-muted">No case authors found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-7 d-flex flex-column gap-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Recently added cases</span>
<button class="btn btn-sm btn-outline-info"
hx-get="{% url 'atlas:case_admin_recent_cases_partial' %}"
hx-target="#admin-recent-cases"
hx-swap="innerHTML">
Load
</button>
</div>
<div id="admin-recent-cases" class="card-body text-muted small">
Click Load to fetch recently added cases.
</div>
</div>
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Largest cases</span>
<button class="btn btn-sm btn-outline-warning"
hx-get="{% url 'atlas:case_admin_largest_cases_partial' %}"
hx-target="#admin-largest-cases"
hx-swap="innerHTML">
Load
</button>
</div>
<div id="admin-largest-cases" class="card-body text-muted small">
Click Load to fetch the largest cases by image footprint.
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -54,6 +54,13 @@
{% endfor %}
</dd>
{% if can_merge %}
<dt class="col-sm-4">Edit relationships</dt>
<dd class="col-sm-8">
{% include 'atlas/partials/_condition_relationships.html' %}
</dd>
{% endif %}
<dt class="col-sm-4">RCR condition</dt>
<dd class="col-sm-8">{% if condition.rcr_curriculum %}Yes{% else %}No{% endif %}</dd>
@@ -0,0 +1,27 @@
{% if largest_rows %}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>Case</th>
<th class="text-end">Series</th>
<th class="text-end">Total size</th>
</tr>
</thead>
<tbody>
{% for row in largest_rows %}
<tr>
<td>
<a href="{% url 'atlas:case_detail' row.case.pk %}">{{ row.case.title }}</a>
<div class="text-muted small">#{{ row.case.pk }}</div>
</td>
<td class="text-end">{{ row.series_count }}</td>
<td class="text-end fw-semibold">{{ row.human_total }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small">No case-size data available.</div>
{% endif %}
@@ -0,0 +1,33 @@
{% if recent_cases %}
<div class="table-responsive">
<table class="table table-sm table-striped mb-0 align-middle">
<thead>
<tr>
<th>Case</th>
<th>Created</th>
<th>Authors</th>
</tr>
</thead>
<tbody>
{% for case in recent_cases %}
<tr>
<td>
<a href="{% url 'atlas:case_detail' case.pk %}">{{ case.title }}</a>
<div class="text-muted small">#{{ case.pk }}</div>
</td>
<td class="small">{{ case.created_date|date:"Y-m-d H:i" }}</td>
<td class="small">
{% for author in case.author.all %}
<span class="badge text-bg-secondary me-1">{{ author.username }}</span>
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-muted small">No recent cases found.</div>
{% endif %}
@@ -0,0 +1,13 @@
<form method="post" class="condition-relationship-form">
{% csrf_token %}
<input type="hidden" name="action" value="update_relationships">
<div class="mb-3">
<label class="form-label" for="id_parents">Parents</label>
{{ relationship_form.parents }}
</div>
<div class="mb-3">
<label class="form-label" for="id_children">Children</label>
{{ relationship_form.children }}
</div>
<button class="btn btn-sm btn-primary" type="submit">Save relationships</button>
</form>
@@ -1,6 +1,6 @@
<li class="series-item {% if series_item_classes %}{{ series_item_classes }}{% endif %}">
<div class="series-left">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
<input class="hidden" type="checkbox" name="selection" value="{{ series }}"{% if tags.StudyInstanceUID %} data-study-uid="{{ tags.StudyInstanceUID }}"{% elif tags.StudyID %} data-study-uid="{{ tags.StudyID }}"{% endif %}>
<h5 class="series-title">
{{ tags.SeriesDescription|default:series }}
{% if series_is_partial %}
@@ -66,12 +66,13 @@
var item = e.target.closest('.list-group-item');
if (!item || !targetEl.contains(item)) return;
// Explicit select action button should always select the row.
if (e.target.closest('.case-select-btn')) {
var selectBtn = e.target.closest('.case-select-btn');
if (selectBtn) {
e.preventDefault();
} else {
// Ignore other interactive elements inside the item (links, buttons, forms, inputs)
if (e.target.closest('a,button,form,input')) return;
// Ignore all non-select interactions so rows are passive unless
// explicit selection controls are present.
return;
}
var casePk = item.getAttribute('data-case-pk');
+11 -4
View File
@@ -216,10 +216,17 @@
<div class="card-header">Actions</div>
<div class="card-body d-flex flex-column gap-2">
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_anonymise_dicom' pk=series.pk %}">Anonymise dicoms</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">Order by slice location</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">Order by instance number</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">Order by SeriesInstanceUID</a>
<a class="btn btn-outline-primary btn-sm" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">Order by uploaded filename</a>
<div class="dropdown">
<button class="btn btn-outline-primary btn-sm dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
Order Images
</button>
<ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom' pk=series.pk %}">By slice location</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom_instance' pk=series.pk %}">By instance number</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_dicom_SeriesInstanceUID' pk=series.pk %}">By SeriesInstanceUID</a></li>
<li><a class="dropdown-item" href="{% url 'atlas:series_order_upload_filename' pk=series.pk %}">By uploaded filename</a></li>
</ul>
</div>
<div hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-trigger="load"
hx-swap="outerHTML">
+92 -1
View File
@@ -1,5 +1,6 @@
{% extends 'atlas/base.html' %}
{% load case_widgets %}
{% load crispy_forms_tags %}
{% block content %}
<h2>Uploaded dicoms</h2>
@@ -173,6 +174,7 @@
class="btn btn-primary"
>Import {% if case %}into case{% endif %}</button>
<button id="import-into-case-button" type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#caseSelectModal">Import into case…</button>
<button id="import-case-series-button" type="button" class="btn btn-outline-success" data-bs-toggle="modal" data-bs-target="#caseSeriesModal" disabled>Import as case series…</button>
</div>
<div id="import-status" class="mt-3"></div>
@@ -231,6 +233,30 @@
</div>
</div>
<div class="modal fade" id="caseSeriesModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Import as linked case series</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="case-series-form">
{% csrf_token %}
<div class="modal-body">
<div class="alert alert-info small">
This creates one case per selected study, copies the same case details to each case, and links them with <em>previous case</em>.
</div>
{% crispy case_series_form %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Create linked cases</button>
</div>
</form>
</div>
</div>
</div>
<script>
const importCaseId = {% if case %}{{ case.pk }}{% else %}null{% endif %};
@@ -238,15 +264,18 @@
function updateSelectionCount() {
const all = Array.from(document.querySelectorAll('input[name="selection"]'));
const selected = all.filter(cb => cb.checked && !cb.disabled).length;
const selectedStudies = new Set(all.filter(cb => cb.checked && !cb.disabled).map(cb => cb.dataset.studyUid).filter(Boolean));
const el = document.getElementById('selected-count');
if (el) el.textContent = selected;
if (el) el.textContent = `${selected} series across ${selectedStudies.size} studies`;
// disable action buttons if none selected
const deleteBtn = document.getElementById('delete-uploads-button');
const importBtn = document.getElementById('import-dicoms-sequential-button');
const importIntoCaseBtn = document.getElementById('import-into-case-button');
const importCaseSeriesBtn = document.getElementById('import-case-series-button');
if (deleteBtn) deleteBtn.disabled = selected === 0;
if (importBtn) importBtn.disabled = selected === 0;
if (importIntoCaseBtn) importIntoCaseBtn.disabled = selected === 0;
if (importCaseSeriesBtn) importCaseSeriesBtn.disabled = selected === 0 || selectedStudies.size < 2;
}
function setImportButtonsDisabled(disabled) {
@@ -304,6 +333,21 @@
return selectedSeries.length ? selectedSeries : selectableSeries;
}
function getSelectedSeriesUids() {
return Array.from(document.querySelectorAll('input[name="selection"]:checked'))
.filter(cb => !cb.disabled)
.map(cb => cb.value);
}
function getSelectedStudyUids() {
return new Set(
Array.from(document.querySelectorAll('input[name="selection"]:checked'))
.filter(cb => !cb.disabled)
.map(cb => cb.dataset.studyUid)
.filter(Boolean)
);
}
async function runImport(selectionList, caseId) {
const statusDiv = document.getElementById('import-status');
if (!statusDiv) return;
@@ -420,6 +464,53 @@
});
}
const caseSeriesForm = document.getElementById('case-series-form');
if (caseSeriesForm) {
caseSeriesForm.addEventListener('submit', async function (event) {
event.preventDefault();
const selectedSeries = getSelectedSeriesUids();
const selectedStudies = getSelectedStudyUids();
if (!selectedSeries.length || selectedStudies.size < 2) {
alert('Select series from at least two studies before creating a linked case series.');
return;
}
const formData = new FormData(caseSeriesForm);
formData.delete('previous_case');
selectedSeries.forEach((seriesUid) => formData.append('selection', seriesUid));
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
if (orderSeries) {
formData.set('order-series', orderSeries);
}
setImportButtonsDisabled(true);
try {
const response = await fetch("{% url 'atlas:uploads_import_case_series_htmx' %}", {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]')?.value || '',
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
const html = await response.text();
appendImportStatus(html);
if (response.ok) {
selectedSeries.forEach((seriesUid) => markSeriesImported(seriesUid));
const modalEl = document.getElementById('caseSeriesModal');
if (modalEl && window.bootstrap && bootstrap.Modal) {
bootstrap.Modal.getInstance(modalEl)?.hide();
}
}
} catch (error) {
appendImportStatus(`<div class="alert alert-danger mb-0">Linked case import failed. ${error?.message || ''}</div>`);
} finally {
setImportButtonsDisabled(false);
updateSelectionCount();
}
});
}
// Select / Deselect all series within a study block
document.addEventListener('click', function (e) {
const target = e.target;
+18 -1
View File
@@ -2,7 +2,7 @@ from django.urls import path, include
from django.views.generic import RedirectView, TemplateView
from atlas.models import Condition, Finding, Presentation, Structure, Subspecialty, Procedure
from atlas.models import CaseCollection, Condition, Finding, Presentation, Structure, Subspecialty, Procedure
from . import views
app_name = "atlas"
@@ -39,6 +39,17 @@ urlpatterns = [
path("author/<int:pk>/", views.author_detail, name="author_detail"),
path("author/", views.author_list, name="author_list"),
path("case/", views.CaseView.as_view(), name="case_view"),
path("case/admin-overview", views.case_admin_overview, name="case_admin_overview"),
path(
"case/admin-overview/recent",
views.case_admin_recent_cases_partial,
name="case_admin_recent_cases_partial",
),
path(
"case/admin-overview/largest",
views.case_admin_largest_cases_partial,
name="case_admin_largest_cases_partial",
),
path("collection/<int:collection_id>/add_case", views.add_case_to_collection, name="add_case_to_collection"),
path("collection/case_search", views.case_search, name="case_search"),
path("collection/", views.CollectionView.as_view(), name="collection_view"),
@@ -51,6 +62,7 @@ urlpatterns = [
path("uploads/new", views.new_uploads, name="new_uploads"),
path("uploads/import", views.uploads_import_htmx, name="uploads_import_htmx"),
path("uploads/import/case/<int:case_id>", views.uploads_import_htmx, name="uploads_import_case_htmx"),
path("uploads/import/case-series", views.uploads_import_case_series_htmx, name="uploads_import_case_series_htmx"),
path("uploads/case/<int:case_id>", views.user_uploads, name="user_uploads_case"),
path("uploads/<int:user_pk>/user/case/<int:case_id>", views.other_user_uploads, name="other_user_uploads_case"),
path("uploads/series_id/<str:series_instance_uid>", views.user_uploads_series, name="user_uploads_series"),
@@ -751,6 +763,11 @@ urlpatterns = [
views.ProcedureAutocomplete.as_view(model=Procedure, create_field="name"),
name="procedure-autocomplete",
),
path(
"casecollection-prerequisite-autocomplete",
views.CaseCollectionPrerequisiteAutocomplete.as_view(model=CaseCollection),
name="casecollection-prerequisite-autocomplete",
),
path(
"subspecialty-autocomplete",
views.SubspecialtyAutocomplete.as_view(model=Subspecialty, create_field="name"),
+259
View File
@@ -57,6 +57,7 @@ from django.urls.exceptions import NoReverseMatch
from django.http import Http404, JsonResponse
from django.http import HttpResponseRedirect, HttpResponse
from django.contrib.contenttypes.models import ContentType
from django.forms.widgets import HiddenInput
import zipfile
import os
from django.core.files.base import ContentFile
@@ -85,6 +86,7 @@ from .forms import (
CidReportAnswerMarkForm,
ConditionAutocompleteForm,
ConditionForm,
ConditionRelationshipForm,
ExamGroupsForm,
FindingForm,
JsonAnswerForm,
@@ -648,6 +650,10 @@ def series_optimize_htmx(request, series_id):
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
try:
series.get_total_image_size(refresh=True)
except Exception:
pass
new_total = int(series.get_image_count())
payload = (
@@ -678,6 +684,10 @@ def series_optimize_htmx(request, series_id):
if removed:
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
try:
series.get_total_image_size(refresh=True)
except Exception:
pass
return HttpResponse(
f'<div class="alert alert-success mb-0">Empty DICOM removal complete. Removed <strong>{len(removed)}</strong> empty images.</div>'
@@ -2357,6 +2367,7 @@ def condition_detail(request, pk):
# form used to add a synonym (can post either an existing condition via autocomplete
# or provide a plain name in 'synonym_name')
synonym_form = ConditionAutocompleteForm()
relationship_form = ConditionRelationshipForm(instance=condition)
can_merge = False
@@ -2366,6 +2377,27 @@ def condition_detail(request, pk):
):
can_merge = True
if request.method == "POST" and request.POST.get("action") == "update_relationships":
if not can_merge:
return HttpResponse("Forbidden", status=403)
relationship_form = ConditionRelationshipForm(
request.POST, instance=condition
)
if relationship_form.is_valid():
relationship_form.save()
if request.htmx:
html = render_to_string(
"atlas/partials/_condition_relationships.html",
{
"condition": condition,
"relationship_form": relationship_form,
},
request=request,
)
return HttpResponse(html)
return redirect("atlas:condition_detail", pk=condition.pk)
# Handle POST to add a synonym
if request.method == "POST" and request.POST.get("action") == "add_synonym":
# only allow staff/editors
@@ -2436,6 +2468,7 @@ def condition_detail(request, pk):
"condition": condition,
"form": merge_form,
"synonym_form": synonym_form,
"relationship_form": relationship_form,
"can_merge": can_merge,
},
)
@@ -3415,6 +3448,10 @@ def user_uploads(
if case_id is not None:
case = get_object_or_404(Case, pk=case_id)
case_series_form = CaseForm(user=request.user)
case_series_form.fields["previous_case"].required = False
case_series_form.fields["previous_case"].widget = HiddenInput()
return render(
request,
"atlas/user_uploads.html",
@@ -3424,6 +3461,7 @@ def user_uploads(
"uploaded_series_uids": uploaded_series_uids,
"case": case,
"user": user,
"case_series_form": case_series_form,
},
)
@@ -3503,6 +3541,134 @@ def uploads_import_htmx(request, case_id: int | None = None):
return response
@login_required
@require_POST
def uploads_import_case_series_htmx(request):
"""Import selected uploaded studies into a linked chain of cases.
Scope: Authenticated Atlas users.
Functionality:
- Groups the selected uploaded series by study.
- Creates one case per selected study using the provided CaseForm values.
- Links each newly created case to the previous one with previous_case.
- Imports the selected series for each study into its corresponding case.
"""
selected_uids = [uid.strip() for uid in request.POST.getlist("selection") if uid.strip()]
if not selected_uids:
return HttpResponse(
'<div class="alert alert-warning mb-0">Select at least one series before creating a case chain.</div>',
status=400,
)
form = CaseForm(request.POST, user=request.user)
if not form.is_valid():
return HttpResponse(
'<div class="alert alert-danger mb-0">Please correct the case details before importing.</div>',
status=400,
)
dicoms = (
UncategorisedDicom.objects.filter(user=request.user, series_instance_uid__in=selected_uids)
.select_related("user")
)
if not dicoms.exists():
return HttpResponse(
'<div class="alert alert-warning mb-0">No pending uploads were found for the selected series.</div>',
status=404,
)
series_to_study = {}
for dicom in dicoms.iterator():
tags = dicom.basic_dicom_tags or {}
series_uid = tags.get("SeriesInstanceUID")
if not series_uid:
continue
study_uid = tags.get("StudyInstanceUID") or tags.get("StudyID") or "unknown"
series_to_study.setdefault(series_uid, study_uid)
study_groups = defaultdict(list)
ordered_study_uids = []
seen_study_uids = set()
for series_uid in selected_uids:
study_uid = series_to_study.get(series_uid)
if not study_uid:
continue
study_groups[study_uid].append(series_uid)
if study_uid not in seen_study_uids:
seen_study_uids.add(study_uid)
ordered_study_uids.append(study_uid)
if not study_groups:
return HttpResponse(
'<div class="alert alert-warning mb-0">The selected uploads did not contain any study metadata.</div>',
status=400,
)
base_case = form.save(commit=False)
case_field_names = {
field.name
for field in Case._meta.concrete_fields
if field.name not in {"id", "previous_case"}
}
m2m_field_names = ["subspecialty", "condition", "presentation", "procedures", "pathological_process"]
created_cases = []
import_results = []
previous_case = None
try:
from .api import import_dicoms_helper
with transaction.atomic():
for study_uid in ordered_study_uids:
case = Case()
for field_name in case_field_names:
setattr(case, field_name, getattr(base_case, field_name))
case.previous_case = previous_case
case.save()
case.author.add(request.user)
for field_name in m2m_field_names:
getattr(case, field_name).set(form.cleaned_data.get(field_name, []))
study_series_uids = study_groups[study_uid]
study_dicoms = dicoms.filter(series_instance_uid__in=study_series_uids)
imported_rows = import_dicoms_helper(request, case_id=case.pk, dicoms=study_dicoms)
created_cases.append(case)
import_results.append((case, study_uid, imported_rows))
previous_case = case
except Exception as exc:
return HttpResponse(
f'<div class="alert alert-danger mb-0">Linked import failed: {escape(str(exc))}</div>',
status=500,
)
created_case_links = "".join(
f'<li><a href="{case.get_absolute_url()}">{escape(str(case))}</a> <span class="text-muted">({escape(study_uid)})</span></li>'
for case, study_uid, _ in import_results
)
imported_series_count = sum(len(rows) for _, _, rows in import_results)
html = (
'<div class="alert alert-success mb-3">'
f'Created {len(created_cases)} linked case{ "s" if len(created_cases) != 1 else "" } and imported {imported_series_count} series.'
'</div>'
'<ul class="mb-0">'
f"{created_case_links}"
'</ul>'
)
response = HttpResponse(html)
response["HX-Trigger"] = json.dumps(
{
"atlas:uploadsImported": {
"imported_count": imported_series_count,
"created_case_count": len(created_cases),
}
}
)
return response
class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
model = Case
success_url = reverse_lazy("atlas:case_view")
@@ -4422,6 +4588,83 @@ class CaseView(LoginRequiredMixin, UserConfigurablePaginationMixin, SingleTableM
filterset_class = CaseFilter
@staff_member_required
def case_admin_overview(request):
"""Admin overview for case ownership and storage-heavy case monitoring.
Scope: staff users.
Functionality: shows per-author case counts and summary metrics, with recent
and largest case lists loaded on demand via HTMX partials.
"""
author_rows = (
User.objects.filter(atlas_authored_cases__isnull=False)
.annotate(case_count=Count("atlas_authored_cases", distinct=True))
.order_by("-case_count", "username")
)
totals = {
"total_cases": Case.objects.count(),
"total_authors": author_rows.count(),
"open_access_cases": Case.objects.filter(open_access=True).count(),
"archived_cases": Case.objects.filter(archive=True).count(),
}
return render(
request,
"atlas/case_admin_overview.html",
{
"author_rows": author_rows,
"totals": totals,
},
)
@staff_member_required
def case_admin_recent_cases_partial(request):
"""HTMX partial for recently added cases in the admin overview."""
recent_cases = Case.objects.prefetch_related("author").order_by("-created_date")[:30]
return render(
request,
"atlas/partials/_case_admin_recent_cases.html",
{"recent_cases": recent_cases},
)
@staff_member_required
def case_admin_largest_cases_partial(request):
"""HTMX partial for largest cases by total series image size."""
def human_size(num_bytes):
n = float(num_bytes or 0)
for unit in ("B", "KB", "MB", "GB", "TB"):
if n < 1024.0:
return f"{n:.1f}{unit}"
n /= 1024.0
return f"{n:.1f}PB"
rows = []
for case in Case.objects.prefetch_related("series__images").all():
try:
total = case.get_total_series_images_size()
except Exception:
total = 0
rows.append(
{
"case": case,
"total": total,
"human_total": human_size(total),
"series_count": case.series.count(),
}
)
rows.sort(key=lambda row: row["total"], reverse=True)
return render(
request,
"atlas/partials/_case_admin_largest_cases.html",
{"largest_rows": rows[:30]},
)
@staff_member_required
def cases_by_size(request):
"""Admin view: list cases ordered by total images size to find large cases.
@@ -5197,6 +5440,22 @@ class ProcedureAutocomplete(autocomplete.Select2QuerySetView):
return qs
class CaseCollectionPrerequisiteAutocomplete(autocomplete.Select2QuerySetView):
def get_queryset(self):
if not self.request.user.is_authenticated:
return CaseCollection.objects.none()
qs = CaseCollection.objects.filter(author=self.request.user).distinct().order_by("name")
if self.q:
try:
qs = qs.filter(name__icontains=self.q)
except FieldError:
return CaseCollection.objects.none()
return qs
class ConditionAutocompleteRCRCurriculum(autocomplete.Select2QuerySetView):
def get_queryset(self):
# Don't forget to filter out results depending on the visitor !