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 !
+118 -118
View File
File diff suppressed because one or more lines are too long
+61 -12
View File
@@ -1,18 +1,67 @@
whilst the dv3d viewer is rendering an image we should show a muted loading text in the middle of the viewport.
the cine button in the viewer should just be an icon, it would be better placed under the viewports fullscreen icon.
when we update a series image stack we should also recalculate the total image size.
on the seties detail page please group the order by buttons into a nested menu so that they take less space.
on the case details page when a series is opened in the viewer from an external element it still triggers a full set of 206 requests and the viewer does not update until this has happened. we should instead just load the exsiting stack (and not trigger any more requests than is absolutely necessary).
on the collections overview page when adding a case via the add case widget if the item is clicked on (not the add button) we get a dialog that offers to add the caes but results in a 500 error. the add button works as intended. we do not want clicking on a case outside fo the buttons to do anything in the widget (please check this won't break the widget in any other locations).
please add a admin case overview page that displays the number of cases that each user has created and other pertinant information. this should have easy access to a list of recently added cases and a summary of the largest cases that a user has (these may be best loaded as required via htmx partials).
on the condition details pages we should have an easier way to set parents and children
the casecollection form should use a widget for the prerequisites that supports autocomplete searching to allow for easy case selection. the selection options should be restricted to those cases that the user is an author of.
if a user has multiple uploads from the same patient waiting to be imported on the uploads page and more than one study in selected to import we should be able to import them and automatically create a set of cases with appropriate case series relations (by setting the model previous_case field appropriately). the workflow should be, multiple studies are selected -> enable the import into cases series button. clicking this should open a dialog that allows a user to set the fields from the CaseForm that will be duplicated across the series. a button at the bottoms should trigger the creation of the cases and import the series appropriately.
when opening the series detail page we get the following error: web-1 | [28/May/2026 22:24:52] "GET /atlas/series/936 HTTP/1.0" 500 158244
web-1 | /opt/venv/lib/python3.14/site-packages/pydicom/dataset.py:589: UserWarning: Invalid value 'DiffusionGradientDirection' used with the 'in' operator: must be an element tag as a 2-tuple or int, or an element keyword
web-1 | warn_and_log(msg)
we also get the following error when trying to view cases (in dev at least):
MemcacheError at /atlas/case/238/
All servers seem to be down right now
Request Method: GET
Request URL: http://127.0.0.1/atlas/case/238/
Django Version: 6.0.1
Exception Type: MemcacheError
Exception Value:
All servers seem to be down right now
Exception Location: /opt/venv/lib/python3.14/site-packages/pymemcache/client/hash.py, line 182, in _get_client
Raised during: atlas.decorators.case_detail
Python Executable: /opt/venv/bin/python
Python Version: 3.14.5
Python Path:
['/usr/src/app',
'/usr/src/app',
'/usr/local/lib/python314.zip',
'/usr/local/lib/python3.14',
'/usr/local/lib/python3.14/lib-dynload',
'/opt/venv/lib/python3.14/site-packages']
Server time: Thu, 28 May 2026 22:28:21 +0100
Error during template rendering
In template /usr/src/app/atlas/templates/atlas/case_display_block.html, error at line 528
All servers seem to be down right now
518 </div>
519
520 </div>
521
522 <details id="dicom-viewer-details">
523 <summary>Viewer</summary>
524
525 <div id="main_viewer" class="dicom-viewer-root"
526 style="box-sizing: border-box; background: #222; width: 100%; height: 600px;"
527 data-auto-cache-stack="false"
528 data-named-stacks='{{case.get_case_named_stacks}}'
529 ></div>
530 </details>
531 <details class="series-panel" id="case-series-panel" open>
532 <summary class="series-panel-summary">
533 <span class="series-panel-title">Series</span>
534 <span class="series-panel-summary-actions">
535 <span class="series-panel-badge">{{ ordered_series|length }} total</span>
536 <button type="button"
537 class="btn btn-outline-primary btn-sm series-summary-btn"
538 id="series-import-uploads-btn"
we need a link to the case admin overview page in the admin menu.