many changes
This commit is contained in:
+70
-2
@@ -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])
|
||||
|
||||
@@ -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
|
||||
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
|
||||
],
|
||||
}
|
||||
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": {},
|
||||
}
|
||||
]
|
||||
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):
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
cache.set(cache_key, {"signature": signature, "groups": ordered_groups}, timeout=60 * 60)
|
||||
except Exception:
|
||||
pass
|
||||
return ordered_groups
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
Reference in New Issue
Block a user