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
+6
View File
@@ -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)
@@ -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),
]
+1
View File
@@ -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:
@@ -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
+2
View File
@@ -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/<int:case_id>", views.uploads_import_htmx, name="uploads_import_case_htmx"),
path("uploads/case/<int:case_id>", views.user_uploads, name="user_uploads_case"),
path("uploads/<int:user_pk>/user/case/<int:case_id>", views.other_user_uploads, name="other_user_uploads_case"),
path("uploads/series_id/<str:series_instance_uid>", views.user_uploads_series, name="user_uploads_series"),
+252 -15
View File
@@ -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(
'<div class="alert alert-danger">You do not have permission to import into this case.</div>',
status=403,
)
if not dicoms.exists():
return HttpResponse(
'<div class="alert alert-warning mb-0">No pending uploads were found for the selected series.</div>'
)
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'<div class="alert alert-danger mb-0">Import failed: {escape(str(exc))}</div>',
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")