feat: Add support for study instance UID in series and enhance DICOM import functionality

This commit is contained in:
Ross
2026-05-11 10:05:39 +01:00
parent c1e351c7db
commit aaa3ee6313
8 changed files with 528 additions and 128 deletions
@@ -0,0 +1,26 @@
{% if imported_count %}
<div class="alert alert-success mb-2">
<strong>Import complete.</strong>
Imported {{ imported_count }} series{% if requested_count %} from {{ requested_count }} requested{% endif %}
{% if target_case %}
into case <a href="{% url 'atlas:case_detail' target_case.pk %}">{{ target_case }}</a>
{% endif %}.
</div>
<div class="list-group">
{% for row in imported_rows %}
<div class="list-group-item">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<div>
<strong>{{ row.series.description|default:'Imported series' }}</strong>
{% if row.series.series_instance_uid %}
<div class="small text-muted">UID: {{ row.series.series_instance_uid }}</div>
{% endif %}
</div>
<div>{{ row.link|safe }}</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="alert alert-warning mb-0">No new series were imported.</div>
{% endif %}
+113 -23
View File
@@ -1,34 +1,124 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Search uploaded DICOMs by pixel-data hash</h2>
<p class="text-muted">Admin-only tool for searching uncategorised uploads and imported Atlas images by <code>image_blake3_hash</code>.</p>
<h2>Search Uploaded And Imported DICOMs</h2>
<p class="text-muted">Admin-only tool for searching pending uploads and imported Atlas data by hash, series UID, or study UID.</p>
<form method="get" class="mb-3">
<div class="input-group">
<input
type="text"
class="form-control"
name="hash"
value="{{ hash_query }}"
placeholder="Enter full or partial pixel-data hash"
autocomplete="off"
spellcheck="false"
>
<button type="submit" class="btn btn-primary">Search</button>
<div class="row g-2">
<div class="col-lg-4">
<label class="form-label mb-1" for="hash-query-input">Hash</label>
<input
id="hash-query-input"
type="text"
class="form-control"
name="hash"
value="{{ hash_query }}"
placeholder="Pixel-data hash (partial OK)"
autocomplete="off"
spellcheck="false"
>
</div>
<div class="col-lg-4">
<label class="form-label mb-1" for="series-query-input">Series UID</label>
<input
id="series-query-input"
type="text"
class="form-control"
name="series"
value="{{ series_query }}"
placeholder="SeriesInstanceUID"
autocomplete="off"
spellcheck="false"
>
</div>
<div class="col-lg-4">
<label class="form-label mb-1" for="study-query-input">Study UID</label>
<input
id="study-query-input"
type="text"
class="form-control"
name="study"
value="{{ study_query }}"
placeholder="StudyInstanceUID"
autocomplete="off"
spellcheck="false"
>
</div>
</div>
<small class="form-text text-muted">Search is case-insensitive and supports partial matches.</small>
<div class="d-flex align-items-center gap-2 mt-2">
<button type="submit" class="btn btn-primary">Search</button>
<a href="{% url 'atlas:uploads_hash_search' %}" class="btn btn-outline-secondary">Clear</a>
</div>
<small class="form-text text-muted">All searches are case-insensitive and support partial matches. Study UID matching now uses indexed Atlas series data.</small>
</form>
{% if hash_query %}
{% if hash_query or series_query or study_query %}
<p>
Found
<strong>{{ uploads_result_count }}</strong> pending upload{{ uploads_result_count|pluralize }}
and
<strong>{{ imported_result_count }}</strong> imported image{{ imported_result_count|pluralize }}
for <code>{{ hash_query }}</code>.
and
<strong>{{ matched_series_result_count }}</strong> existing Atlas series.
</p>
<h4>Existing Atlas series</h4>
{% if matched_series_page_obj.object_list %}
<div class="table-responsive">
<table class="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Series</th>
<th>Series UID</th>
<th>Cases</th>
<th>Links</th>
</tr>
</thead>
<tbody>
{% for series in matched_series_page_obj.object_list %}
<tr>
<td>{{ series }}</td>
<td><code>{{ series.series_instance_uid|default:'-' }}</code></td>
<td>{{ series.case.count }}</td>
<td>
<a href="{% url 'atlas:series_detail' series.pk %}">View series</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if matched_series_page_obj.paginator.num_pages > 1 %}
<nav aria-label="Matched series pagination">
<ul class="pagination">
{% if matched_series_page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&series_page={{ matched_series_page_obj.previous_page_number }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
{% endif %}
<li class="page-item disabled">
<span class="page-link">Page {{ matched_series_page_obj.number }} of {{ matched_series_page_obj.paginator.num_pages }}</span>
</li>
{% if matched_series_page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&series_page={{ matched_series_page_obj.next_page_number }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-warning">No existing Atlas series matched the current search.</div>
{% endif %}
<h4>Pending uploads</h4>
{% if uploads_page_obj.object_list %}
<div class="table-responsive">
@@ -67,7 +157,7 @@
<ul class="pagination">
{% if uploads_page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.previous_page_number }}&imported_page={{ imported_page_obj.number }}">Previous</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.previous_page_number }}&imported_page={{ imported_page_obj.number }}&series_page={{ matched_series_page_obj.number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
@@ -79,7 +169,7 @@
{% if uploads_page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.next_page_number }}&imported_page={{ imported_page_obj.number }}">Next</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.next_page_number }}&imported_page={{ imported_page_obj.number }}&series_page={{ matched_series_page_obj.number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
@@ -88,7 +178,7 @@
</nav>
{% endif %}
{% else %}
<div class="alert alert-warning">No pending uploads matched that hash.</div>
<div class="alert alert-warning">No pending uploads matched the current search.</div>
{% endif %}
<h4 class="mt-4">Imported Atlas images</h4>
@@ -141,7 +231,7 @@
<ul class="pagination">
{% if imported_page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.previous_page_number }}">Previous</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.previous_page_number }}&series_page={{ matched_series_page_obj.number }}">Previous</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Previous</span></li>
@@ -153,7 +243,7 @@
{% if imported_page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?hash={{ hash_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.next_page_number }}">Next</a>
<a class="page-link" href="?hash={{ hash_query|urlencode }}&series={{ series_query|urlencode }}&study={{ study_query|urlencode }}&uploads_page={{ uploads_page_obj.number }}&imported_page={{ imported_page_obj.next_page_number }}&series_page={{ matched_series_page_obj.number }}">Next</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link">Next</span></li>
@@ -162,9 +252,9 @@
</nav>
{% endif %}
{% else %}
<div class="alert alert-warning">No imported Atlas images matched that hash.</div>
<div class="alert alert-warning">No imported Atlas images matched the current search.</div>
{% endif %}
{% else %}
<div class="alert alert-info">Enter a hash value to search uploads.</div>
<div class="alert alert-info">Enter a hash, series UID, or study UID to search uploads and existing imports.</div>
{% endif %}
{% endblock %}
+63 -90
View File
@@ -33,11 +33,20 @@
<strong>Partial import detected:</strong>
{{ study.partial_imported_count }} series in this study already exist.
The highlighted rows can finish importing the remaining images.
{% if study.matched_existing_series %}
<div class="small mt-1">
Existing series in Atlas:
{% for existing_series in study.matched_existing_series %}
<a href="{% url 'atlas:series_detail' existing_series.pk %}">{{ existing_series }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<button
type="button"
class="btn btn-sm btn-warning import-partial-series-btn"
data-series-uids="{{ study.partial_imported_uids|join:',' }}"
{% if study.preferred_case_id %}data-case-id="{{ study.preferred_case_id }}"{% endif %}
>Finish these imports</button>
</div>
{% endif %}
@@ -69,7 +78,7 @@
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=True series_item_classes='series-item--partial-import' import_case_id=study.preferred_case_id %}
{% endif %}
{% else %}
{% if case %}
@@ -281,30 +290,57 @@
function getImportUrl(caseId) {
const targetCaseId = caseId || importCaseId;
if (targetCaseId) {
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
const importTemplate = "{% url 'atlas:uploads_import_case_htmx' 0 %}";
return importTemplate.replace(/0\/?$/, targetCaseId);
}
return "{% url 'api-1:import_dicoms' %}";
return "{% url 'atlas:uploads_import_htmx' %}";
}
function formatImportResponse(data) {
if (!Array.isArray(data) || !data.length) {
return '<div class="alert alert-success mb-0">No new series were imported.</div>';
function getSelectedOrAllSeries() {
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
const selectableSeries = allCheckboxes.filter(cb => !cb.disabled).map(cb => cb.value);
const selectedSeries = checkboxes.filter(cb => !cb.disabled).map(cb => cb.value);
return selectedSeries.length ? selectedSeries : selectableSeries;
}
async function runImport(selectionList, caseId) {
const statusDiv = document.getElementById('import-status');
if (!statusDiv) return;
if (!selectionList.length) {
alert('Please select at least one series to import.');
return;
}
const links = data.map((item) => {
const series = Array.isArray(item) ? item[0] : null;
const link = Array.isArray(item) ? item[1] : '';
const uid = series && series.series_instance_uid ? series.series_instance_uid : '';
return `<li>${link || 'Imported series'}${uid ? ` <small class="text-muted">(${uid})</small>` : ''}</li>`;
}).join('');
statusDiv.innerHTML = '';
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
return `
<div class="alert alert-success mb-0">
<strong>Imported ${data.length} series.</strong>
<ul class="mb-0 mt-2">${links}</ul>
</div>
`;
setImportButtonsDisabled(true);
setImportProgress(0, 1, `Importing ${selectionList.length} series. Do not leave this page.`);
try {
await htmx.ajax('POST', getImportUrl(caseId), {
target: '#import-status',
swap: 'innerHTML',
values: {
selection: selectionList,
'order-series': orderSeries,
},
});
selectionList.forEach((seriesId) => {
markSeriesImported(seriesId);
});
setImportProgress(1, 1, `${selectionList.length} series import request completed.`);
} catch (error) {
appendImportStatus(`<div class="alert alert-danger mb-0">Import failed. ${error?.message || ''}</div>`);
} finally {
setTimeout(function () {
setImportProgress(0, 0);
}, 1800);
setImportButtonsDisabled(false);
updateSelectionCount();
}
}
// Keep count up-to-date on checkbox changes and attribute changes
@@ -371,80 +407,16 @@
li.style.opacity = '0.7';
}
async function importSeriesList(selectionList, importUrl, statusPrefix) {
const statusDiv = document.getElementById('import-status');
if (!statusDiv) return;
statusDiv.innerHTML = '';
setImportButtonsDisabled(true);
setImportProgress(0, selectionList.length, 'Starting import');
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
const csrfToken = '{{ csrf_token }}';
let importedCount = 0;
let failedCount = 0;
for (let i = 0; i < selectionList.length; i++) {
const seriesId = selectionList[i];
const statusId = `${statusPrefix}-${seriesId}`;
appendImportStatus(`<div id="${statusId}" class="small text-muted">Importing series ${seriesId}...</div>`);
try {
const response = await fetch(importUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
},
body: `selection=${encodeURIComponent(seriesId)}&order-series=${encodeURIComponent(orderSeries)}`,
credentials: 'same-origin',
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText || `HTTP ${response.status}`);
}
const contentType = response.headers.get('content-type') || '';
const payload = contentType.includes('application/json') ? await response.json() : await response.text();
const statusEl = document.getElementById(statusId);
if (statusEl) {
statusEl.innerHTML = `<span class="text-success">Series ${seriesId} imported.</span> ${formatImportResponse(payload)}`;
}
markSeriesImported(seriesId);
importedCount += 1;
} catch (e) {
const statusEl = document.getElementById(statusId);
if (statusEl) {
statusEl.innerHTML = `Series ${seriesId}: <span class="text-danger">Failed</span>`;
}
failedCount += 1;
}
setImportProgress(i + 1, selectionList.length, `${importedCount} imported, ${failedCount} failed`);
}
appendImportStatus(`<div class="alert alert-info mb-0">All done. ${importedCount} imported, ${failedCount} failed.</div>`);
setImportProgress(selectionList.length, selectionList.length, 'Import complete');
setTimeout(function () {
setImportProgress(0, 0);
}, 1800);
setImportButtonsDisabled(false);
updateSelectionCount();
}
const importButton = document.getElementById('import-dicoms-sequential-button');
if (importButton) {
importButton.addEventListener('click', async function() {
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
const selectableSeries = allCheckboxes.filter(cb => !cb.disabled).map(cb => cb.value);
const selectedSeries = checkboxes.filter(cb => !cb.disabled).map(cb => cb.value);
const toImport = selectedSeries.length ? selectedSeries : selectableSeries;
const toImport = getSelectedOrAllSeries();
if (!toImport.length) {
alert('Please select at least one series to import (not already imported).');
return;
}
const importUrl = getImportUrl();
await importSeriesList(toImport, importUrl, 'import-status');
await runImport(toImport, importCaseId);
});
}
@@ -505,13 +477,12 @@
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
if (!window.confirm(confirmMsg)) return;
const importUrl = getImportUrl(casePk);
// Close modal and show result in import-status
const modalEl = document.getElementById('caseSelectModal');
if (modalEl && window.bootstrap && bootstrap.Modal) {
bootstrap.Modal.getInstance(modalEl)?.hide();
}
await importSeriesList(selectionList, importUrl, `import-case-${casePk}`);
await runImport(selectionList, casePk);
} catch (err) {
console.error('Import handler error', err);
alert('Import failed — check console for details.');
@@ -536,8 +507,7 @@
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
if (!window.confirm(confirmMsg)) return;
const importUrl = getImportUrl(casePk);
await importSeriesList(seriesUids, importUrl, `import-missing-${casePk}`);
await runImport(seriesUids, casePk);
});
document.body.addEventListener('click', async function (e) {
@@ -555,11 +525,14 @@
}
const casePk = trigger.dataset.caseId || importCaseId;
if (!casePk) {
alert('Could not determine a target case for this partial import. Use an "Import remaining" button linked to a case or open this page in a case context.');
return;
}
const confirmMsg = `Finish importing ${seriesUids.length} series${casePk ? ` into case ${casePk}` : ''}?`;
if (!window.confirm(confirmMsg)) return;
const importUrl = getImportUrl(casePk);
await importSeriesList(seriesUids, importUrl, `import-partial-${casePk || 'orphan'}`);
await runImport(seriesUids, casePk);
});
// Row click toggles selection when clicking any non-interactive part of the series card