diff --git a/atlas/api.py b/atlas/api.py index 8782f485..08215f04 100644 --- a/atlas/api.py +++ b/atlas/api.py @@ -220,6 +220,7 @@ def import_dicoms_helper(request, case_id: int | None = None, dicoms=None): for d in dicoms.iterator(): # Use iterator() to avoid loading all at once tags = d.basic_dicom_tags series_uid = tags["SeriesInstanceUID"] + study_uid = tags.get("StudyInstanceUID") if series_uid not in series_map: # Get or create Series and related objects as needed @@ -236,6 +237,7 @@ def import_dicoms_helper(request, case_id: int | None = None, dicoms=None): defaults={ "modality": modality, "description": tags.get("SeriesDescription", ""), + "study_instance_uid": study_uid, } ) if created and tags.get("StudyDescription"): @@ -250,6 +252,10 @@ def import_dicoms_helper(request, case_id: int | None = None, dicoms=None): except IntegrityError: series = Series.objects.get(series_instance_uid=series_uid) + if study_uid and not series.study_instance_uid: + series.study_instance_uid = study_uid + series.save(update_fields=["study_instance_uid"]) + # Add author and case if needed # Attach author to the series series.author.add(request.user) diff --git a/atlas/migrations/0101_series_study_instance_uid.py b/atlas/migrations/0101_series_study_instance_uid.py new file mode 100644 index 00000000..62ee5ea5 --- /dev/null +++ b/atlas/migrations/0101_series_study_instance_uid.py @@ -0,0 +1,65 @@ +# Generated by Django 5.2.7 on 2026-05-11 08:56 + +from django.db import migrations, models +import pydicom + + +def backfill_series_study_instance_uid(apps, schema_editor): + Series = apps.get_model('atlas', 'Series') + SeriesImage = apps.get_model('atlas', 'SeriesImage') + + series_qs = Series.objects.filter(study_instance_uid__isnull=True) + + for series in series_qs.iterator(): + image_obj = ( + SeriesImage.objects.filter(series_id=series.pk, removed=False) + .exclude(image='') + .order_by('pk') + .first() + ) + if image_obj is None: + image_obj = ( + SeriesImage.objects.filter(series_id=series.pk) + .exclude(image='') + .order_by('pk') + .first() + ) + + if image_obj is None or not image_obj.image: + continue + + try: + with image_obj.image.open('rb') as fp: + ds = pydicom.dcmread(fp, stop_before_pixels=True, force=True) + value = ds.get('StudyInstanceUID') + if value is None: + continue + if hasattr(value, 'value'): + value = value.value + study_uid = str(value).strip() + if not study_uid: + continue + + Series.objects.filter(pk=series.pk).update(study_instance_uid=study_uid) + except Exception: + continue + + +def noop_reverse(apps, schema_editor): + return + + +class Migration(migrations.Migration): + + dependencies = [ + ('atlas', '0100_casecollection_show_results_sharing_information'), + ] + + operations = [ + migrations.AddField( + model_name='series', + name='study_instance_uid', + field=models.CharField(blank=True, db_index=True, max_length=255, null=True), + ), + migrations.RunPython(backfill_series_study_instance_uid, noop_reverse), + ] diff --git a/atlas/models.py b/atlas/models.py index 1bb8b3fb..3367accd 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -1114,6 +1114,7 @@ class Series(SeriesBase): ) series_instance_uid = models.CharField(max_length=255, blank=True, null=True) + study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True) # findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack") def __str__(self) -> str: diff --git a/atlas/templates/atlas/partials/_upload_import_result.html b/atlas/templates/atlas/partials/_upload_import_result.html new file mode 100644 index 00000000..8893a0c1 --- /dev/null +++ b/atlas/templates/atlas/partials/_upload_import_result.html @@ -0,0 +1,26 @@ +{% if imported_count %} +
+ Import complete. + Imported {{ imported_count }} series{% if requested_count %} from {{ requested_count }} requested{% endif %} + {% if target_case %} + into case {{ target_case }} + {% endif %}. +
+
+ {% for row in imported_rows %} +
+
+
+ {{ row.series.description|default:'Imported series' }} + {% if row.series.series_instance_uid %} +
UID: {{ row.series.series_instance_uid }}
+ {% endif %} +
+
{{ row.link|safe }}
+
+
+ {% endfor %} +
+{% else %} +
No new series were imported.
+{% endif %} diff --git a/atlas/templates/atlas/uploads_hash_search.html b/atlas/templates/atlas/uploads_hash_search.html index e2decea6..603d469f 100644 --- a/atlas/templates/atlas/uploads_hash_search.html +++ b/atlas/templates/atlas/uploads_hash_search.html @@ -1,34 +1,124 @@ {% extends 'atlas/base.html' %} {% block content %} -

Search uploaded DICOMs by pixel-data hash

-

Admin-only tool for searching uncategorised uploads and imported Atlas images by image_blake3_hash.

+

Search Uploaded And Imported DICOMs

+

Admin-only tool for searching pending uploads and imported Atlas data by hash, series UID, or study UID.

-
- - +
+
+ + +
+
+ + +
+
+ + +
- Search is case-insensitive and supports partial matches. +
+ + Clear +
+ All searches are case-insensitive and support partial matches. Study UID matching now uses indexed Atlas series data. - {% if hash_query %} + {% if hash_query or series_query or study_query %}

Found {{ uploads_result_count }} pending upload{{ uploads_result_count|pluralize }} and {{ imported_result_count }} imported image{{ imported_result_count|pluralize }} - for {{ hash_query }}. + and + {{ matched_series_result_count }} existing Atlas series.

+

Existing Atlas series

+ {% if matched_series_page_obj.object_list %} +
+ + + + + + + + + + + {% for series in matched_series_page_obj.object_list %} + + + + + + + {% endfor %} + +
SeriesSeries UIDCasesLinks
{{ series }}{{ series.series_instance_uid|default:'-' }}{{ series.case.count }} + View series +
+
+ + {% if matched_series_page_obj.paginator.num_pages > 1 %} + + {% endif %} + {% else %} +
No existing Atlas series matched the current search.
+ {% endif %} +

Pending uploads

{% if uploads_page_obj.object_list %}
@@ -67,7 +157,7 @@
    {% if uploads_page_obj.has_previous %}
  • - Previous + Previous
  • {% else %}
  • Previous
  • @@ -79,7 +169,7 @@ {% if uploads_page_obj.has_next %}
  • - Next + Next
  • {% else %}
  • Next
  • @@ -88,7 +178,7 @@ {% endif %} {% else %} -
    No pending uploads matched that hash.
    +
    No pending uploads matched the current search.
    {% endif %}

    Imported Atlas images

    @@ -141,7 +231,7 @@
      {% if imported_page_obj.has_previous %}
    • - Previous + Previous
    • {% else %}
    • Previous
    • @@ -153,7 +243,7 @@ {% if imported_page_obj.has_next %}
    • - Next + Next
    • {% else %}
    • Next
    • @@ -162,9 +252,9 @@ {% endif %} {% else %} -
      No imported Atlas images matched that hash.
      +
      No imported Atlas images matched the current search.
      {% endif %} {% else %} -
      Enter a hash value to search uploads.
      +
      Enter a hash, series UID, or study UID to search uploads and existing imports.
      {% endif %} {% endblock %} diff --git a/atlas/templates/atlas/user_uploads.html b/atlas/templates/atlas/user_uploads.html index 951da79e..9ff8f2fa 100644 --- a/atlas/templates/atlas/user_uploads.html +++ b/atlas/templates/atlas/user_uploads.html @@ -33,11 +33,20 @@ Partial import detected: {{ study.partial_imported_count }} series in this study already exist. The highlighted rows can finish importing the remaining images. + {% if study.matched_existing_series %} +
      + Existing series in Atlas: + {% for existing_series in study.matched_existing_series %} + {{ existing_series }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
      + {% endif %}
{% 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 '
No new series were imported.
'; + 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 `
  • ${link || 'Imported series'}${uid ? ` (${uid})` : ''}
  • `; - }).join(''); + statusDiv.innerHTML = ''; + const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || ''; - return ` -
    - Imported ${data.length} series. - -
    - `; + 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(`
    Import failed. ${error?.message || ''}
    `); + } 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(`
    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', - }); - 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 = `Series ${seriesId} imported. ${formatImportResponse(payload)}`; - } - markSeriesImported(seriesId); - importedCount += 1; - } catch (e) { - const statusEl = document.getElementById(statusId); - if (statusEl) { - statusEl.innerHTML = `Series ${seriesId}: Failed`; - } - failedCount += 1; - } - setImportProgress(i + 1, selectionList.length, `${importedCount} imported, ${failedCount} failed`); - } - - appendImportStatus(`
    All done. ${importedCount} imported, ${failedCount} failed.
    `); - 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 diff --git a/atlas/urls.py b/atlas/urls.py index a2d8a73e..4c6415aa 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -49,6 +49,8 @@ urlpatterns = [ path("uploads/all", views.all_uploads, name="all_uploads"), path("uploads/hash-search", views.uploads_hash_search, name="uploads_hash_search"), path("uploads/new", views.new_uploads, name="new_uploads"), + path("uploads/import", views.uploads_import_htmx, name="uploads_import_htmx"), + path("uploads/import/case/", views.uploads_import_htmx, name="uploads_import_case_htmx"), path("uploads/case/", views.user_uploads, name="user_uploads_case"), path("uploads//user/case/", views.other_user_uploads, name="other_user_uploads_case"), path("uploads/series_id/", views.user_uploads_series, name="user_uploads_series"), diff --git a/atlas/views.py b/atlas/views.py index 1037e9fe..9edbc75c 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -2217,17 +2217,58 @@ def all_uploads(request, case_id: int | None = None): @staff_member_required def uploads_hash_search(request): - query = (request.GET.get("hash") or "").strip().lower() + hash_query = (request.GET.get("hash") or "").strip().lower() + series_query = (request.GET.get("series") or "").strip() + study_query = (request.GET.get("study") or "").strip() + uploads_qs = UncategorisedDicom.objects.none() imported_qs = SeriesImage.objects.none() + matched_series_qs = Series.objects.none() - if query: - uploads_qs = UncategorisedDicom.objects.filter( - image_blake3_hash__icontains=query - ).select_related("user", "series").order_by("-created_date") - imported_qs = SeriesImage.objects.filter( - image_blake3_hash__icontains=query - ).select_related("series").order_by("-pk") + has_query = bool(hash_query or series_query or study_query) + + if has_query: + uploads_filters = Q() + imported_filters = Q() + matched_series_filters = Q() + + if hash_query: + uploads_filters |= Q(image_blake3_hash__icontains=hash_query) + imported_filters |= Q(image_blake3_hash__icontains=hash_query) + + if series_query: + uploads_filters |= Q(series_instance_uid__icontains=series_query) + imported_filters |= Q(series__series_instance_uid__icontains=series_query) + matched_series_filters |= Q(series_instance_uid__icontains=series_query) + + if study_query: + uploads_filters |= Q(basic_dicom_tags__StudyInstanceUID__icontains=study_query) + imported_filters |= Q(series__study_instance_uid__icontains=study_query) + matched_series_filters |= Q(study_instance_uid__icontains=study_query) + + uploads_qs = ( + UncategorisedDicom.objects.filter(uploads_filters) + .select_related("user", "series") + .order_by("-created_date") + .distinct() + ) + + imported_qs = ( + SeriesImage.objects.filter(imported_filters) + .select_related("series") + .order_by("-pk") + .distinct() + ) + + if matched_series_filters.children: + matched_series_qs = ( + Series.objects.filter(matched_series_filters) + .prefetch_related("case") + .order_by("-modified_date") + .distinct() + ) + else: + matched_series_qs = Series.objects.none() uploads_paginator = Paginator(uploads_qs, 50) uploads_page_number = request.GET.get("uploads_page") @@ -2237,15 +2278,24 @@ def uploads_hash_search(request): imported_page_number = request.GET.get("imported_page") imported_page_obj = imported_paginator.get_page(imported_page_number) + matched_series_paginator = Paginator(matched_series_qs, 50) + matched_series_page_number = request.GET.get("series_page") + matched_series_page_obj = matched_series_paginator.get_page(matched_series_page_number) + return render( request, "atlas/uploads_hash_search.html", { - "hash_query": query, + "hash_query": hash_query, + "series_query": series_query, + "study_query": study_query, "uploads_page_obj": uploads_page_obj, "uploads_result_count": uploads_paginator.count, "imported_page_obj": imported_page_obj, "imported_result_count": imported_paginator.count, + "matched_series_page_obj": matched_series_page_obj, + "matched_series_result_count": matched_series_paginator.count, + "series_study_scan_limit": 0, }, ) @@ -2283,6 +2333,7 @@ def user_uploads( dicoms = UncategorisedDicom.objects.filter() data = defaultdict(list) + series_hashes = defaultdict(set) for d in dicoms: tags = d.basic_dicom_tags @@ -2290,7 +2341,13 @@ def user_uploads( d.save() tags = d.basic_dicom_tags - data[tags["SeriesInstanceUID"]].append((tags, d.created_date)) + series_uid = tags.get("SeriesInstanceUID") + if not series_uid: + continue + + data[series_uid].append((tags, d.created_date)) + if d.image_blake3_hash: + series_hashes[series_uid].add(d.image_blake3_hash) series_list = [] for series in data: @@ -2319,6 +2376,8 @@ def user_uploads( "date": study_date, "series": [], "import_suggestions": [], + "partial_imported_uids": [], + "matched_existing_series": [], } grouped[study_uid]["series"].append((series_uid, count, tags, date)) @@ -2328,7 +2387,27 @@ def user_uploads( # 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] - existing_series_by_uid: dict[str, list[Series]] = defaultdict(list) + uploaded_study_uids = { + (tags.get("StudyInstanceUID") or tags.get("StudyID") or "") + for _, _, tags, _ in series_list + if (tags.get("StudyInstanceUID") or tags.get("StudyID")) + } + uploaded_series_uid_to_study_uid = { + series_uid: (tags.get("StudyInstanceUID") or tags.get("StudyID") or "") + for series_uid, _, tags, _ in series_list + } + uploaded_uid_to_existing_series_ids: dict[str, set[int]] = defaultdict(set) + existing_series_lookup: dict[int, Series] = {} + case_to_study_uids = defaultdict(set) + uploaded_hashes = { + image_hash + for hash_set in series_hashes.values() + for image_hash in hash_set + } + hash_to_uploaded_uids = defaultdict(set) + for uploaded_uid, hash_set in series_hashes.items(): + for image_hash in hash_set: + hash_to_uploaded_uids[image_hash].add(uploaded_uid) if uploaded_series_uids: existing_series = ( @@ -2336,18 +2415,69 @@ def user_uploads( .prefetch_related("case") ) + existing_study_series = Series.objects.filter( + study_instance_uid__in=uploaded_study_uids + ).prefetch_related("case") + case_to_series_uids = defaultdict(set) case_lookup = {} for existing in existing_series: if not existing.series_instance_uid: continue - existing_series_by_uid[existing.series_instance_uid].append(existing) + existing_series_lookup[existing.pk] = existing + uploaded_uid_to_existing_series_ids[existing.series_instance_uid].add( + existing.pk + ) 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) + if existing.study_instance_uid: + case_to_study_uids[case_obj.pk].add(existing.study_instance_uid) + + for existing in existing_study_series: + if not existing.study_instance_uid: + continue + + existing_series_lookup[existing.pk] = existing + for uploaded_uid, uploaded_study_uid in uploaded_series_uid_to_study_uid.items(): + if uploaded_study_uid and uploaded_study_uid == existing.study_instance_uid: + uploaded_uid_to_existing_series_ids[uploaded_uid].add(existing.pk) + + 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_study_uids[case_obj.pk].add(existing.study_instance_uid) + + if uploaded_hashes: + imported_hash_matches = ( + SeriesImage.objects.filter(image_blake3_hash__in=uploaded_hashes) + .exclude(series=None) + .select_related("series") + .prefetch_related("series__case") + ) + + for image in imported_hash_matches: + if image.series is None: + continue + + existing_series_lookup[image.series.pk] = image.series + matched_uploaded_uids = hash_to_uploaded_uids.get( + image.image_blake3_hash, set() + ) + for uploaded_uid in matched_uploaded_uids: + uploaded_uid_to_existing_series_ids[uploaded_uid].add(image.series.pk) + for case_obj in image.series.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(uploaded_uid) + study_uid = uploaded_series_uid_to_study_uid.get(uploaded_uid) + if study_uid: + case_to_study_uids[case_obj.pk].add(study_uid) for study_uid, study_data in grouped_series: study_uploaded_uids = { @@ -2356,18 +2486,40 @@ def user_uploads( if not study_uploaded_uids: continue + case_candidates_for_study = set() + study_partial_imported_uids = sorted( - s_uid for s_uid in study_uploaded_uids if existing_series_by_uid.get(s_uid) + s_uid + for s_uid in study_uploaded_uids + if uploaded_uid_to_existing_series_ids.get(s_uid) ) if study_partial_imported_uids: study_data["partial_imported_uids"] = study_partial_imported_uids study_data["partial_imported_count"] = len(study_partial_imported_uids) - for case_pk, imported_uids in case_to_series_uids.items(): + matched_series_ids = set() + for uploaded_uid in study_partial_imported_uids: + matched_series_ids.update( + uploaded_uid_to_existing_series_ids.get(uploaded_uid, set()) + ) + + study_data["matched_existing_series"] = [ + existing_series_lookup[series_id] + for series_id in sorted(matched_series_ids) + if series_id in existing_series_lookup + ] + + related_case_ids = set(case_to_series_uids.keys()) | set(case_to_study_uids.keys()) + for case_pk in related_case_ids: + imported_uids = case_to_series_uids.get(case_pk, set()) overlap = study_uploaded_uids & imported_uids - if not overlap: + case_study_uids = case_to_study_uids.get(case_pk, set()) + + if not overlap and (not study_uid or study_uid not in case_study_uids): continue + case_candidates_for_study.add(case_pk) + missing_uids = sorted(study_uploaded_uids - imported_uids) if not missing_uids: continue @@ -2380,6 +2532,16 @@ def user_uploads( } ) + if len(case_candidates_for_study) == 1: + study_data["preferred_case_id"] = next(iter(case_candidates_for_study)) + elif study_data.get("import_suggestions"): + sorted_suggestions = sorted( + study_data["import_suggestions"], + key=lambda s: s.get("already_imported_count", 0), + reverse=True, + ) + study_data["preferred_case_id"] = sorted_suggestions[0]["case"].pk + case = False if case_id is not None: case = get_object_or_404(Case, pk=case_id) @@ -2397,6 +2559,81 @@ def user_uploads( ) +@login_required +@require_POST +def uploads_import_htmx(request, case_id: int | None = None): + """HTMX endpoint to import uploaded DICOMs with user-facing status output. + + Scope: Authenticated Atlas users. + + Functionality: + - Imports selected uploaded series (or all pending uploads if none selected). + - Optionally associates imported series with a target case. + - Returns an HTML fragment suitable for HTMX swap into the uploads page. + """ + selected_uids = [ + uid.strip() for uid in request.POST.getlist("selection") if uid.strip() + ] + + dicoms = UncategorisedDicom.objects.filter(user=request.user) + if selected_uids: + dicoms = dicoms.filter(series_instance_uid__in=selected_uids) + + target_case = None + if case_id is not None: + target_case = get_object_or_404(Case, pk=case_id) + if not target_case.check_user_can_edit(request.user): + return HttpResponse( + '
    You do not have permission to import into this case.
    ', + status=403, + ) + + if not dicoms.exists(): + return HttpResponse( + '
    No pending uploads were found for the selected series.
    ' + ) + + try: + # Import helper lives in the API module and handles the core create/update logic. + from .api import import_dicoms_helper + + imported = import_dicoms_helper(request, case_id=case_id, dicoms=dicoms) + except Exception as exc: + return HttpResponse( + f'
    Import failed: {escape(str(exc))}
    ', + status=500, + ) + + imported_rows = [ + { + "series": series, + "link": link, + } + for series, link in imported + ] + + html = render_to_string( + "atlas/partials/_upload_import_result.html", + { + "imported_rows": imported_rows, + "imported_count": len(imported_rows), + "requested_count": len(selected_uids) if selected_uids else None, + "target_case": target_case, + }, + request=request, + ) + response = HttpResponse(html) + response["HX-Trigger"] = json.dumps( + { + "atlas:uploadsImported": { + "imported_count": len(imported_rows), + "requested_count": len(selected_uids) if selected_uids else len(imported_rows), + } + } + ) + return response + + class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView): model = Case success_url = reverse_lazy("atlas:case_view")