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])
|
||||
|
||||
+16
-4
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 !
|
||||
|
||||
Reference in New Issue
Block a user