diff --git a/atlas/templates/atlas/user_uploads.html b/atlas/templates/atlas/user_uploads.html
index 602bf8c6..6018a9ae 100644
--- a/atlas/templates/atlas/user_uploads.html
+++ b/atlas/templates/atlas/user_uploads.html
@@ -27,6 +27,28 @@
+ {% if study.import_suggestions %}
+
+
Study already imported:
+ {% for suggestion in study.import_suggestions %}
+
+
+ Case:
+ {{ suggestion.case }}
+ ({{ suggestion.already_imported_count }} series already imported)
+
+
+
+ {% endfor %}
+
+ {% endif %}
{% for series, n, tags, date in study.series %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True %}
@@ -191,68 +213,91 @@
}
});
+ function markSeriesImported(seriesId) {
+ const li = document.querySelector(`#series-list input[value="${seriesId}"]`)?.closest('li');
+ if (!li || li.classList.contains('imported')) return;
+
+ li.classList.add('imported');
+ if (!li.querySelector('.badge.bg-success')) {
+ const badge = document.createElement('span');
+ badge.className = 'badge bg-success ms-2';
+ badge.textContent = 'Imported';
+ li.appendChild(badge);
+ }
+
+ const checkbox = li.querySelector('input[name="selection"]');
+ if (checkbox) {
+ checkbox.disabled = true;
+ checkbox.checked = false;
+ }
+
+ const _sp = li.querySelector('span');
+ if (_sp) _sp.onclick = null;
+ li.style.pointerEvents = 'none';
+ 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 += `Importing series ${seriesId}...
`;
+ 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) {
+ const statusEl = document.getElementById(statusId);
+ if (statusEl) {
+ statusEl.innerHTML = `Series ${seriesId}: Failed`;
+ }
+ }
+ }
+
+ statusDiv.innerHTML += 'All done.
';
+ 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"]'));
- // 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).");
+ 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;
}
- 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 += `Importing series ${seriesId}...
`;
- 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');
- if (li && !li.classList.contains('imported')) {
- li.classList.add('imported');
- if (!li.querySelector('.badge.bg-success')) {
- const badge = document.createElement('span');
- badge.className = 'badge bg-success ms-2';
- badge.textContent = 'Imported';
- li.appendChild(badge);
- }
- const checkbox = li.querySelector('input[name="selection"]');
- if (checkbox) {
- checkbox.disabled = true;
- checkbox.checked = false;
- }
- const _sp = li.querySelector('span');
- if (_sp) _sp.onclick = null;
- li.style.pointerEvents = "none";
- li.style.opacity = "0.7";
- }
- } catch (e) {
- document.getElementById(`import-status-${seriesId}`).innerHTML = `Series ${seriesId}: Failed`;
- }
- }
- statusDiv.innerHTML += "All done.
";
+
+ 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
document.body.addEventListener('click', function (e) {
const item = e.target.closest('.series-item');
diff --git a/atlas/views.py b/atlas/views.py
index 4990a020..80bca48c 100755
--- a/atlas/views.py
+++ b/atlas/views.py
@@ -2262,6 +2262,17 @@ def user_uploads(
user_only: bool = True,
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
if user_only:
dicoms = UncategorisedDicom.objects.filter(user=request.user)
@@ -2307,12 +2318,60 @@ def user_uploads(
"description": study_desc,
"date": study_date,
"series": [],
+ "import_suggestions": [],
}
grouped[study_uid]["series"].append((series_uid, count, tags, date))
# Convert to a list preserving order sorted by group date
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
if case_id is not None:
case = get_object_or_404(Case, pk=case_id)
@@ -2320,7 +2379,12 @@ def user_uploads(
return render(
request,
"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,
+ },
)