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
@@ -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;