feat: Enhance user uploads with import suggestions for already uploaded DICOM series
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -27,6 +27,28 @@
|
|||||||
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
{% if study.import_suggestions %}
|
||||||
|
<div class="alert alert-info py-2 mt-2 mb-2">
|
||||||
|
<strong>Study already imported:</strong>
|
||||||
|
{% for suggestion in study.import_suggestions %}
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-2 mt-1">
|
||||||
|
<span>
|
||||||
|
Case:
|
||||||
|
<a href="{% url 'atlas:case_detail' suggestion.case.pk %}">{{ suggestion.case }}</a>
|
||||||
|
({{ suggestion.already_imported_count }} series already imported)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline-primary import-missing-series-btn"
|
||||||
|
data-case-id="{{ suggestion.case.pk }}"
|
||||||
|
data-series-uids="{{ suggestion.missing_uids|join:',' }}"
|
||||||
|
>
|
||||||
|
Import remaining {{ suggestion.missing_uids|length }} series
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<ul class="study-series-list">
|
<ul class="study-series-list">
|
||||||
{% for series, n, tags, date in study.series %}
|
{% for series, n, tags, date in study.series %}
|
||||||
{% include 'atlas/partials/_series_item.html' with show_tags_link=True %}
|
{% include 'atlas/partials/_series_item.html' with show_tags_link=True %}
|
||||||
@@ -191,46 +213,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const importButton = document.getElementById('import-dicoms-sequential-button');
|
function markSeriesImported(seriesId) {
|
||||||
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"]'));
|
|
||||||
// Only include checkboxes that are not disabled (i.e., not already imported)
|
|
||||||
const selectableCheckboxes = allCheckboxes.filter(cb => !cb.disabled);
|
|
||||||
const toImport = checkboxes.length
|
|
||||||
? checkboxes.filter(cb => !cb.disabled)
|
|
||||||
: selectableCheckboxes;
|
|
||||||
if (toImport.length === 0) {
|
|
||||||
alert("Please select at least one series to import (not already imported).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const statusDiv = document.getElementById('import-status');
|
|
||||||
if (statusDiv) statusDiv.innerHTML = '';
|
|
||||||
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
|
|
||||||
const csrfToken = '{{ csrf_token }}';
|
|
||||||
{% if case %}
|
|
||||||
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
|
|
||||||
{% else %}
|
|
||||||
const importUrl = "{% url 'api-1:import_dicoms' %}";
|
|
||||||
{% endif %}
|
|
||||||
for (let i = 0; i < toImport.length; i++) {
|
|
||||||
const seriesId = toImport[i].value;
|
|
||||||
statusDiv.innerHTML += `<div id="import-status-${seriesId}">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)}`
|
|
||||||
});
|
|
||||||
const text = await response.text();
|
|
||||||
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: ${text}`;
|
|
||||||
// Mark as imported in the UI and make unselectable
|
|
||||||
const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
|
const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
|
||||||
if (li && !li.classList.contains('imported')) {
|
if (!li || li.classList.contains('imported')) return;
|
||||||
|
|
||||||
li.classList.add('imported');
|
li.classList.add('imported');
|
||||||
if (!li.querySelector('.badge.bg-success')) {
|
if (!li.querySelector('.badge.bg-success')) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
@@ -238,21 +224,80 @@
|
|||||||
badge.textContent = 'Imported';
|
badge.textContent = 'Imported';
|
||||||
li.appendChild(badge);
|
li.appendChild(badge);
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkbox = li.querySelector('input[name="selection"]');
|
const checkbox = li.querySelector('input[name="selection"]');
|
||||||
if (checkbox) {
|
if (checkbox) {
|
||||||
checkbox.disabled = true;
|
checkbox.disabled = true;
|
||||||
checkbox.checked = false;
|
checkbox.checked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _sp = li.querySelector('span');
|
const _sp = li.querySelector('span');
|
||||||
if (_sp) _sp.onclick = null;
|
if (_sp) _sp.onclick = null;
|
||||||
li.style.pointerEvents = "none";
|
li.style.pointerEvents = 'none';
|
||||||
li.style.opacity = "0.7";
|
li.style.opacity = '0.7';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function importSeriesList(selectionList, importUrl, statusPrefix) {
|
||||||
|
const statusDiv = document.getElementById('import-status');
|
||||||
|
if (!statusDiv) return;
|
||||||
|
statusDiv.innerHTML = '';
|
||||||
|
|
||||||
|
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
|
||||||
|
const csrfToken = '{{ csrf_token }}';
|
||||||
|
|
||||||
|
for (let i = 0; i < selectionList.length; i++) {
|
||||||
|
const seriesId = selectionList[i];
|
||||||
|
const statusId = `${statusPrefix}-${seriesId}`;
|
||||||
|
statusDiv.innerHTML += `<div id="${statusId}">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',
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const statusEl = document.getElementById(statusId);
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `Series ${seriesId}: ${text}`;
|
||||||
|
}
|
||||||
|
markSeriesImported(seriesId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: <span style="color:red;">Failed</span>`;
|
const statusEl = document.getElementById(statusId);
|
||||||
|
if (statusEl) {
|
||||||
|
statusEl.innerHTML = `Series ${seriesId}: <span style="color:red;">Failed</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
statusDiv.innerHTML += "<div>All done.</div>";
|
}
|
||||||
|
|
||||||
|
statusDiv.innerHTML += '<div>All done.</div>';
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!toImport.length) {
|
||||||
|
alert('Please select at least one series to import (not already imported).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% if case %}
|
||||||
|
const importUrl = "{% url 'api-1:import_dicoms_case' case.id %}";
|
||||||
|
{% else %}
|
||||||
|
const importUrl = "{% url 'api-1:import_dicoms' %}";
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
await importSeriesList(toImport, importUrl, 'import-status');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +391,29 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('click', async function (e) {
|
||||||
|
const trigger = e.target.closest('.import-missing-series-btn');
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
const casePk = trigger.dataset.caseId;
|
||||||
|
const seriesUids = (trigger.dataset.seriesUids || '')
|
||||||
|
.split(',')
|
||||||
|
.map(v => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (!casePk || !seriesUids.length) {
|
||||||
|
alert('No remaining series found for this study.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
|
||||||
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
|
const importTemplate = "{% url 'api-1:import_dicoms_case' 0 %}";
|
||||||
|
const importUrl = importTemplate.replace(/0\/?$/, casePk);
|
||||||
|
await importSeriesList(seriesUids, importUrl, `import-missing-${casePk}`);
|
||||||
|
});
|
||||||
|
|
||||||
// Row click toggles selection when clicking any non-interactive part of the series card
|
// Row click toggles selection when clicking any non-interactive part of the series card
|
||||||
document.body.addEventListener('click', function (e) {
|
document.body.addEventListener('click', function (e) {
|
||||||
const item = e.target.closest('.series-item');
|
const item = e.target.closest('.series-item');
|
||||||
|
|||||||
+65
-1
@@ -2262,6 +2262,17 @@ def user_uploads(
|
|||||||
user_only: bool = True,
|
user_only: bool = True,
|
||||||
user_pk: int | None = None,
|
user_pk: int | None = None,
|
||||||
):
|
):
|
||||||
|
"""List uploaded (unimported) DICOM series and suggest continuation imports.
|
||||||
|
|
||||||
|
Scope: Authenticated users (or superusers viewing other users' uploads).
|
||||||
|
|
||||||
|
Functionality:
|
||||||
|
- Groups uploaded series by study for easier selection.
|
||||||
|
- Detects when at least one uploaded series from a study is already imported
|
||||||
|
into a case.
|
||||||
|
- Exposes per-study suggestions to import only the remaining uploaded series
|
||||||
|
into that same case.
|
||||||
|
"""
|
||||||
user = request.user
|
user = request.user
|
||||||
if user_only:
|
if user_only:
|
||||||
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
dicoms = UncategorisedDicom.objects.filter(user=request.user)
|
||||||
@@ -2307,12 +2318,60 @@ def user_uploads(
|
|||||||
"description": study_desc,
|
"description": study_desc,
|
||||||
"date": study_date,
|
"date": study_date,
|
||||||
"series": [],
|
"series": [],
|
||||||
|
"import_suggestions": [],
|
||||||
}
|
}
|
||||||
grouped[study_uid]["series"].append((series_uid, count, tags, date))
|
grouped[study_uid]["series"].append((series_uid, count, tags, date))
|
||||||
|
|
||||||
# Convert to a list preserving order sorted by group date
|
# Convert to a list preserving order sorted by group date
|
||||||
grouped_series = sorted(grouped.items(), key=lambda kv: kv[1].get("date") or "")
|
grouped_series = sorted(grouped.items(), key=lambda kv: kv[1].get("date") or "")
|
||||||
|
|
||||||
|
# Build per-study suggestions where some series are already in a case but
|
||||||
|
# additional uploaded series from the same study are still pending import.
|
||||||
|
uploaded_series_uids = [series_uid for series_uid, _, _, _ in series_list]
|
||||||
|
|
||||||
|
if uploaded_series_uids:
|
||||||
|
existing_series = (
|
||||||
|
Series.objects.filter(series_instance_uid__in=uploaded_series_uids)
|
||||||
|
.exclude(case=None)
|
||||||
|
.prefetch_related("case")
|
||||||
|
)
|
||||||
|
|
||||||
|
case_to_series_uids = defaultdict(set)
|
||||||
|
case_lookup = {}
|
||||||
|
|
||||||
|
for existing in existing_series:
|
||||||
|
if not existing.series_instance_uid:
|
||||||
|
continue
|
||||||
|
for case_obj in existing.case.all():
|
||||||
|
if not case_obj.check_user_can_edit(request.user):
|
||||||
|
continue
|
||||||
|
case_lookup[case_obj.pk] = case_obj
|
||||||
|
case_to_series_uids[case_obj.pk].add(existing.series_instance_uid)
|
||||||
|
|
||||||
|
for study_uid, study_data in grouped_series:
|
||||||
|
study_uploaded_uids = {
|
||||||
|
s_uid for s_uid, _, _, _ in study_data.get("series", [])
|
||||||
|
}
|
||||||
|
if not study_uploaded_uids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for case_pk, imported_uids in case_to_series_uids.items():
|
||||||
|
overlap = study_uploaded_uids & imported_uids
|
||||||
|
if not overlap:
|
||||||
|
continue
|
||||||
|
|
||||||
|
missing_uids = sorted(study_uploaded_uids - imported_uids)
|
||||||
|
if not missing_uids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
study_data["import_suggestions"].append(
|
||||||
|
{
|
||||||
|
"case": case_lookup[case_pk],
|
||||||
|
"already_imported_count": len(overlap),
|
||||||
|
"missing_uids": missing_uids,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
case = False
|
case = False
|
||||||
if case_id is not None:
|
if case_id is not None:
|
||||||
case = get_object_or_404(Case, pk=case_id)
|
case = get_object_or_404(Case, pk=case_id)
|
||||||
@@ -2320,7 +2379,12 @@ def user_uploads(
|
|||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/user_uploads.html",
|
"atlas/user_uploads.html",
|
||||||
{"series_list": series_list, "grouped_series": grouped_series, "case": case, "user": user},
|
{
|
||||||
|
"series_list": series_list,
|
||||||
|
"grouped_series": grouped_series,
|
||||||
|
"case": case,
|
||||||
|
"user": user,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user