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
|
for d in dicoms.iterator(): # Use iterator() to avoid loading all at once
|
||||||
tags = d.basic_dicom_tags
|
tags = d.basic_dicom_tags
|
||||||
series_uid = tags["SeriesInstanceUID"]
|
series_uid = tags["SeriesInstanceUID"]
|
||||||
|
study_uid = tags.get("StudyInstanceUID")
|
||||||
|
|
||||||
if series_uid not in series_map:
|
if series_uid not in series_map:
|
||||||
# Get or create Series and related objects as needed
|
# 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={
|
defaults={
|
||||||
"modality": modality,
|
"modality": modality,
|
||||||
"description": tags.get("SeriesDescription", ""),
|
"description": tags.get("SeriesDescription", ""),
|
||||||
|
"study_instance_uid": study_uid,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if created and tags.get("StudyDescription"):
|
if created and tags.get("StudyDescription"):
|
||||||
@@ -250,6 +252,10 @@ def import_dicoms_helper(request, case_id: int | None = None, dicoms=None):
|
|||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
series = Series.objects.get(series_instance_uid=series_uid)
|
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
|
# Add author and case if needed
|
||||||
# Attach author to the series
|
# Attach author to the series
|
||||||
series.author.add(request.user)
|
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)
|
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")
|
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
|
||||||
def __str__(self) -> str:
|
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' %}
|
{% extends 'atlas/base.html' %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Search uploaded DICOMs by pixel-data hash</h2>
|
<h2>Search Uploaded And Imported DICOMs</h2>
|
||||||
<p class="text-muted">Admin-only tool for searching uncategorised uploads and imported Atlas images by <code>image_blake3_hash</code>.</p>
|
<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">
|
<form method="get" class="mb-3">
|
||||||
<div class="input-group">
|
<div class="row g-2">
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<label class="form-label mb-1" for="hash-query-input">Hash</label>
|
||||||
<input
|
<input
|
||||||
|
id="hash-query-input"
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
name="hash"
|
name="hash"
|
||||||
value="{{ hash_query }}"
|
value="{{ hash_query }}"
|
||||||
placeholder="Enter full or partial pixel-data hash"
|
placeholder="Pixel-data hash (partial OK)"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
>
|
>
|
||||||
<button type="submit" class="btn btn-primary">Search</button>
|
|
||||||
</div>
|
</div>
|
||||||
<small class="form-text text-muted">Search is case-insensitive and supports partial matches.</small>
|
<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>
|
||||||
|
<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>
|
</form>
|
||||||
|
|
||||||
{% if hash_query %}
|
{% if hash_query or series_query or study_query %}
|
||||||
<p>
|
<p>
|
||||||
Found
|
Found
|
||||||
<strong>{{ uploads_result_count }}</strong> pending upload{{ uploads_result_count|pluralize }}
|
<strong>{{ uploads_result_count }}</strong> pending upload{{ uploads_result_count|pluralize }}
|
||||||
and
|
and
|
||||||
<strong>{{ imported_result_count }}</strong> imported image{{ imported_result_count|pluralize }}
|
<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>
|
</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>
|
<h4>Pending uploads</h4>
|
||||||
{% if uploads_page_obj.object_list %}
|
{% if uploads_page_obj.object_list %}
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@@ -67,7 +157,7 @@
|
|||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% if uploads_page_obj.has_previous %}
|
{% if uploads_page_obj.has_previous %}
|
||||||
<li class="page-item">
|
<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>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
||||||
@@ -79,7 +169,7 @@
|
|||||||
|
|
||||||
{% if uploads_page_obj.has_next %}
|
{% if uploads_page_obj.has_next %}
|
||||||
<li class="page-item">
|
<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>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
||||||
@@ -88,7 +178,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
||||||
<h4 class="mt-4">Imported Atlas images</h4>
|
<h4 class="mt-4">Imported Atlas images</h4>
|
||||||
@@ -141,7 +231,7 @@
|
|||||||
<ul class="pagination">
|
<ul class="pagination">
|
||||||
{% if imported_page_obj.has_previous %}
|
{% if imported_page_obj.has_previous %}
|
||||||
<li class="page-item">
|
<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>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
||||||
@@ -153,7 +243,7 @@
|
|||||||
|
|
||||||
{% if imported_page_obj.has_next %}
|
{% if imported_page_obj.has_next %}
|
||||||
<li class="page-item">
|
<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>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
||||||
@@ -162,9 +252,9 @@
|
|||||||
</nav>
|
</nav>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -33,11 +33,20 @@
|
|||||||
<strong>Partial import detected:</strong>
|
<strong>Partial import detected:</strong>
|
||||||
{{ study.partial_imported_count }} series in this study already exist.
|
{{ study.partial_imported_count }} series in this study already exist.
|
||||||
The highlighted rows can finish importing the remaining images.
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-warning import-partial-series-btn"
|
class="btn btn-sm btn-warning import-partial-series-btn"
|
||||||
data-series-uids="{{ study.partial_imported_uids|join:',' }}"
|
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>
|
>Finish these imports</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -69,7 +78,7 @@
|
|||||||
{% if case %}
|
{% 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 %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if case %}
|
{% if case %}
|
||||||
@@ -281,30 +290,57 @@
|
|||||||
function getImportUrl(caseId) {
|
function getImportUrl(caseId) {
|
||||||
const targetCaseId = caseId || importCaseId;
|
const targetCaseId = caseId || importCaseId;
|
||||||
if (targetCaseId) {
|
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 importTemplate.replace(/0\/?$/, targetCaseId);
|
||||||
}
|
}
|
||||||
return "{% url 'api-1:import_dicoms' %}";
|
return "{% url 'atlas:uploads_import_htmx' %}";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatImportResponse(data) {
|
function getSelectedOrAllSeries() {
|
||||||
if (!Array.isArray(data) || !data.length) {
|
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
|
||||||
return '<div class="alert alert-success mb-0">No new series were imported.</div>';
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
const links = data.map((item) => {
|
async function runImport(selectionList, caseId) {
|
||||||
const series = Array.isArray(item) ? item[0] : null;
|
const statusDiv = document.getElementById('import-status');
|
||||||
const link = Array.isArray(item) ? item[1] : '';
|
if (!statusDiv) return;
|
||||||
const uid = series && series.series_instance_uid ? series.series_instance_uid : '';
|
if (!selectionList.length) {
|
||||||
return `<li>${link || 'Imported series'}${uid ? ` <small class="text-muted">(${uid})</small>` : ''}</li>`;
|
alert('Please select at least one series to import.');
|
||||||
}).join('');
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return `
|
statusDiv.innerHTML = '';
|
||||||
<div class="alert alert-success mb-0">
|
const orderSeries = (document.querySelector('input[name="order-series"]:checked') || {}).value || '';
|
||||||
<strong>Imported ${data.length} series.</strong>
|
|
||||||
<ul class="mb-0 mt-2">${links}</ul>
|
setImportButtonsDisabled(true);
|
||||||
</div>
|
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
|
// Keep count up-to-date on checkbox changes and attribute changes
|
||||||
@@ -371,80 +407,16 @@
|
|||||||
li.style.opacity = '0.7';
|
li.style.opacity = '0.7';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importSeriesList(selectionList, importUrl, statusPrefix) {
|
|
||||||
const statusDiv = document.getElementById('import-status');
|
|
||||||
if (!statusDiv) return;
|
|
||||||
|
|
||||||
statusDiv.innerHTML = '';
|
|
||||||
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');
|
const importButton = document.getElementById('import-dicoms-sequential-button');
|
||||||
if (importButton) {
|
if (importButton) {
|
||||||
importButton.addEventListener('click', async function() {
|
importButton.addEventListener('click', async function() {
|
||||||
const checkboxes = Array.from(document.querySelectorAll('input[name="selection"]:checked'));
|
const toImport = getSelectedOrAllSeries();
|
||||||
const allCheckboxes = Array.from(document.querySelectorAll('input[name="selection"]'));
|
|
||||||
const selectableSeries = allCheckboxes.filter(cb => !cb.disabled).map(cb => cb.value);
|
|
||||||
const selectedSeries = checkboxes.filter(cb => !cb.disabled).map(cb => cb.value);
|
|
||||||
const toImport = selectedSeries.length ? selectedSeries : selectableSeries;
|
|
||||||
|
|
||||||
if (!toImport.length) {
|
if (!toImport.length) {
|
||||||
alert('Please select at least one series to import (not already imported).');
|
alert('Please select at least one series to import (not already imported).');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await runImport(toImport, importCaseId);
|
||||||
const importUrl = getImportUrl();
|
|
||||||
await importSeriesList(toImport, importUrl, 'import-status');
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,13 +477,12 @@
|
|||||||
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
|
const confirmMsg = `Import ${selectionList.length} series into case "${caseTitle}" (ID ${casePk})?`;
|
||||||
if (!window.confirm(confirmMsg)) return;
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
const importUrl = getImportUrl(casePk);
|
|
||||||
// Close modal and show result in import-status
|
// Close modal and show result in import-status
|
||||||
const modalEl = document.getElementById('caseSelectModal');
|
const modalEl = document.getElementById('caseSelectModal');
|
||||||
if (modalEl && window.bootstrap && bootstrap.Modal) {
|
if (modalEl && window.bootstrap && bootstrap.Modal) {
|
||||||
bootstrap.Modal.getInstance(modalEl)?.hide();
|
bootstrap.Modal.getInstance(modalEl)?.hide();
|
||||||
}
|
}
|
||||||
await importSeriesList(selectionList, importUrl, `import-case-${casePk}`);
|
await runImport(selectionList, casePk);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Import handler error', err);
|
console.error('Import handler error', err);
|
||||||
alert('Import failed — check console for details.');
|
alert('Import failed — check console for details.');
|
||||||
@@ -536,8 +507,7 @@
|
|||||||
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
|
const confirmMsg = `Import ${seriesUids.length} remaining series into case ${casePk}?`;
|
||||||
if (!window.confirm(confirmMsg)) return;
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
const importUrl = getImportUrl(casePk);
|
await runImport(seriesUids, casePk);
|
||||||
await importSeriesList(seriesUids, importUrl, `import-missing-${casePk}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.addEventListener('click', async function (e) {
|
document.body.addEventListener('click', async function (e) {
|
||||||
@@ -555,11 +525,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const casePk = trigger.dataset.caseId || importCaseId;
|
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}` : ''}?`;
|
const confirmMsg = `Finish importing ${seriesUids.length} series${casePk ? ` into case ${casePk}` : ''}?`;
|
||||||
if (!window.confirm(confirmMsg)) return;
|
if (!window.confirm(confirmMsg)) return;
|
||||||
|
|
||||||
const importUrl = getImportUrl(casePk);
|
await runImport(seriesUids, casePk);
|
||||||
await importSeriesList(seriesUids, importUrl, `import-partial-${casePk || 'orphan'}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Row click toggles selection when clicking any non-interactive part of the series card
|
// Row click toggles selection when clicking any non-interactive part of the series card
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ urlpatterns = [
|
|||||||
path("uploads/all", views.all_uploads, name="all_uploads"),
|
path("uploads/all", views.all_uploads, name="all_uploads"),
|
||||||
path("uploads/hash-search", views.uploads_hash_search, name="uploads_hash_search"),
|
path("uploads/hash-search", views.uploads_hash_search, name="uploads_hash_search"),
|
||||||
path("uploads/new", views.new_uploads, name="new_uploads"),
|
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/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/<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"),
|
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
|
@staff_member_required
|
||||||
def uploads_hash_search(request):
|
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()
|
uploads_qs = UncategorisedDicom.objects.none()
|
||||||
imported_qs = SeriesImage.objects.none()
|
imported_qs = SeriesImage.objects.none()
|
||||||
|
matched_series_qs = Series.objects.none()
|
||||||
|
|
||||||
if query:
|
has_query = bool(hash_query or series_query or study_query)
|
||||||
uploads_qs = UncategorisedDicom.objects.filter(
|
|
||||||
image_blake3_hash__icontains=query
|
if has_query:
|
||||||
).select_related("user", "series").order_by("-created_date")
|
uploads_filters = Q()
|
||||||
imported_qs = SeriesImage.objects.filter(
|
imported_filters = Q()
|
||||||
image_blake3_hash__icontains=query
|
matched_series_filters = Q()
|
||||||
).select_related("series").order_by("-pk")
|
|
||||||
|
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_paginator = Paginator(uploads_qs, 50)
|
||||||
uploads_page_number = request.GET.get("uploads_page")
|
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_number = request.GET.get("imported_page")
|
||||||
imported_page_obj = imported_paginator.get_page(imported_page_number)
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/uploads_hash_search.html",
|
"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_page_obj": uploads_page_obj,
|
||||||
"uploads_result_count": uploads_paginator.count,
|
"uploads_result_count": uploads_paginator.count,
|
||||||
"imported_page_obj": imported_page_obj,
|
"imported_page_obj": imported_page_obj,
|
||||||
"imported_result_count": imported_paginator.count,
|
"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()
|
dicoms = UncategorisedDicom.objects.filter()
|
||||||
|
|
||||||
data = defaultdict(list)
|
data = defaultdict(list)
|
||||||
|
series_hashes = defaultdict(set)
|
||||||
for d in dicoms:
|
for d in dicoms:
|
||||||
tags = d.basic_dicom_tags
|
tags = d.basic_dicom_tags
|
||||||
|
|
||||||
@@ -2290,7 +2341,13 @@ def user_uploads(
|
|||||||
d.save()
|
d.save()
|
||||||
tags = d.basic_dicom_tags
|
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 = []
|
series_list = []
|
||||||
for series in data:
|
for series in data:
|
||||||
@@ -2319,6 +2376,8 @@ def user_uploads(
|
|||||||
"date": study_date,
|
"date": study_date,
|
||||||
"series": [],
|
"series": [],
|
||||||
"import_suggestions": [],
|
"import_suggestions": [],
|
||||||
|
"partial_imported_uids": [],
|
||||||
|
"matched_existing_series": [],
|
||||||
}
|
}
|
||||||
grouped[study_uid]["series"].append((series_uid, count, tags, date))
|
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
|
# Build per-study suggestions where some series are already in a case but
|
||||||
# additional uploaded series from the same study are still pending import.
|
# additional uploaded series from the same study are still pending import.
|
||||||
uploaded_series_uids = [series_uid for series_uid, _, _, _ in series_list]
|
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:
|
if uploaded_series_uids:
|
||||||
existing_series = (
|
existing_series = (
|
||||||
@@ -2336,18 +2415,69 @@ def user_uploads(
|
|||||||
.prefetch_related("case")
|
.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_to_series_uids = defaultdict(set)
|
||||||
case_lookup = {}
|
case_lookup = {}
|
||||||
|
|
||||||
for existing in existing_series:
|
for existing in existing_series:
|
||||||
if not existing.series_instance_uid:
|
if not existing.series_instance_uid:
|
||||||
continue
|
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():
|
for case_obj in existing.case.all():
|
||||||
if not case_obj.check_user_can_edit(request.user):
|
if not case_obj.check_user_can_edit(request.user):
|
||||||
continue
|
continue
|
||||||
case_lookup[case_obj.pk] = case_obj
|
case_lookup[case_obj.pk] = case_obj
|
||||||
case_to_series_uids[case_obj.pk].add(existing.series_instance_uid)
|
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:
|
for study_uid, study_data in grouped_series:
|
||||||
study_uploaded_uids = {
|
study_uploaded_uids = {
|
||||||
@@ -2356,18 +2486,40 @@ def user_uploads(
|
|||||||
if not study_uploaded_uids:
|
if not study_uploaded_uids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
case_candidates_for_study = set()
|
||||||
|
|
||||||
study_partial_imported_uids = sorted(
|
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:
|
if study_partial_imported_uids:
|
||||||
study_data["partial_imported_uids"] = study_partial_imported_uids
|
study_data["partial_imported_uids"] = study_partial_imported_uids
|
||||||
study_data["partial_imported_count"] = len(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
|
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
|
continue
|
||||||
|
|
||||||
|
case_candidates_for_study.add(case_pk)
|
||||||
|
|
||||||
missing_uids = sorted(study_uploaded_uids - imported_uids)
|
missing_uids = sorted(study_uploaded_uids - imported_uids)
|
||||||
if not missing_uids:
|
if not missing_uids:
|
||||||
continue
|
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
|
case = False
|
||||||
if case_id is not None:
|
if case_id is not None:
|
||||||
case = get_object_or_404(Case, pk=case_id)
|
case = get_object_or_404(Case, pk=case_id)
|
||||||
@@ -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):
|
class CaseDelete(RevisionMixin, AuthorOrCheckerRequiredMixin, DeleteView):
|
||||||
model = Case
|
model = Case
|
||||||
success_url = reverse_lazy("atlas:case_view")
|
success_url = reverse_lazy("atlas:case_view")
|
||||||
|
|||||||
Reference in New Issue
Block a user