feat: Add support for study instance UID in series and enhance DICOM import functionality
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user