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