many changes
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user