Add patient grouping and selection functionality to user uploads

This commit is contained in:
Ross
2026-06-15 16:48:15 +01:00
parent b5d844e3aa
commit 8003dcd714
4 changed files with 206 additions and 17 deletions
+1
View File
@@ -2802,6 +2802,7 @@ class UncategorisedDicom(models.Model):
"SliceThickness",
"SeriesNumber",
"StudyDate",
"PatientID",
)
tags = {}
+125 -3
View File
@@ -17,7 +17,97 @@
{% if series_list %}
<div id="series-list">
{% if grouped_series %}
{% if grouped_patients %}
{% for patient in grouped_patients %}
<div class="patient-block mb-4 p-3 border rounded" style="background: rgba(255,255,255,0.015); border-color: rgba(255,255,255,0.08) !important;">
<div class="patient-header d-flex flex-wrap align-items-center justify-content-between pb-2 mb-3 border-bottom border-secondary">
<span>
<i class="bi bi-person-badge me-2 text-info"></i>
<strong>Patient ID:</strong> <span class="badge bg-secondary font-monospace">{{ patient.patient_id }}</span>
</span>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary select-all-patient-btn" title="Select all series for this patient">Select Patient Series</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-patient-btn" title="Deselect all series for this patient">Deselect all</button>
</div>
</div>
{% for study_uid, study in patient.studies %}
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
<summary>
<strong>{{ study.description|default:"(no description)" }}</strong>
<small class="text-muted"> ({{ study_uid }}) — {{ study.series|length }} series</small>
<span class="ms-2">
<button type="button" class="btn btn-sm btn-outline-secondary select-all-btn" title="Select all in study">Select all</button>
<button type="button" class="btn btn-sm btn-outline-secondary deselect-all-btn" title="Deselect all in study">Deselect all</button>
</span>
</summary>
{% if study.partial_imported_uids %}
<div class="alert alert-warning py-2 mt-2 mb-2 d-flex flex-wrap align-items-center justify-content-between gap-2">
<div>
<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 %}
{% if study.import_suggestions %}
<div class="alert alert-info py-2 mt-2 mb-2">
<strong>Study already imported:</strong>
{% for suggestion in study.import_suggestions %}
<div class="d-flex flex-wrap align-items-center gap-2 mt-1">
<span>
Case:
<a href="{% url 'atlas:case_detail' suggestion.case.pk %}">{{ suggestion.case }}</a>
({{ suggestion.already_imported_count }} series already imported)
</span>
<button
type="button"
class="btn btn-sm btn-outline-primary import-missing-series-btn"
data-case-id="{{ suggestion.case.pk }}"
data-series-uids="{{ suggestion.missing_uids|join:',' }}"
>
Import remaining {{ suggestion.missing_uids|length }} series
</button>
</div>
{% endfor %}
</div>
{% endif %}
<ul class="study-series-list">
{% for series, n, tags, date in study.series %}
{% if series in study.partial_imported_uids %}
{% 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' import_case_id=study.preferred_case_id %}
{% endif %}
{% else %}
{% if case %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' import_case_id=case.pk %}
{% else %}
{% include 'atlas/partials/_series_item.html' with show_tags_link=True series_is_partial=False series_item_classes='' %}
{% endif %}
{% endif %}
{% endfor %}
</ul>
</details>
{% endfor %}
</div>
{% endfor %}
{% elif grouped_series %}
{% for study_uid, study in grouped_series %}
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
<summary>
@@ -233,7 +323,7 @@
</div>
</div>
<div class="modal fade" id="caseSeriesModal" tabindex="-1" aria-hidden="true">
<div class="modal fade" id="caseSeriesModal" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
@@ -531,7 +621,7 @@
});
}
// Select / Deselect all series within a study block
// Select / Deselect all series within a study block or patient block
document.addEventListener('click', function (e) {
const target = e.target;
if (target.matches('.select-all-btn')) {
@@ -562,6 +652,34 @@
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
if (target.matches('.select-all-patient-btn')) {
const patientBlock = target.closest('.patient-block');
if (!patientBlock) return;
const inputs = patientBlock.querySelectorAll('input[name="selection"]');
inputs.forEach(cb => {
if (!cb.disabled) {
cb.checked = true;
const li = cb.closest('.series-item');
if (li) li.classList.add('selected');
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
if (target.matches('.deselect-all-patient-btn')) {
const patientBlock = target.closest('.patient-block');
if (!patientBlock) return;
const inputs = patientBlock.querySelectorAll('input[name="selection"]');
inputs.forEach(cb => {
if (!cb.disabled) {
cb.checked = false;
const li = cb.closest('.series-item');
if (li) li.classList.remove('selected');
cb.dispatchEvent(new Event('change', { bubbles: true }));
}
});
if (typeof updateSelectionCount === 'function') updateSelectionCount();
}
});
// Listen for `case:selected` events dispatched by the case-search widget and perform the import
@@ -884,6 +1002,10 @@
color: #ffffff;
}
.select2-container {
width: 100% !important;
}
#series-list li {
}
#series-list .selected {
+41 -1
View File
@@ -55,4 +55,44 @@ def test_user_uploads_partial_import_button_prefers_case_for_hash_matched_series
assert response.status_code == 200
content = response.content.decode("utf-8")
assert 'class="btn btn-sm btn-warning import-partial-series-btn"' in content
assert f'data-case-id="{primary_case.pk}"' in content
assert f'data-case-id="{primary_case.pk}"' in content
@pytest.mark.django_db
def test_user_uploads_patient_grouping_and_select2(client, admin_user):
client.force_login(admin_user)
# Create UncategorisedDicom with PatientID tag using bulk_create to avoid reading non-existent file
UncategorisedDicom.objects.bulk_create(
[
UncategorisedDicom(
image="pending-upload-patient.dcm",
user=admin_user,
series_instance_uid="uploaded-series-patient-1",
basic_dicom_tags={
"SeriesInstanceUID": "uploaded-series-patient-1",
"StudyInstanceUID": "study-patient-1",
"StudyDescription": "Patient Study 1",
"SeriesDescription": "Uploaded series",
"Modality": "CT",
"PatientID": "PAT-999-XYZ",
},
)
]
)
response = client.get(reverse("atlas:user_uploads"))
assert response.status_code == 200
content = response.content.decode("utf-8")
# Verify patient grouping is rendered
assert "PAT-999-XYZ" in content
assert "Patient ID:" in content
assert "select-all-patient-btn" in content
# Verify form autocomplete widgets are in context
form = response.context["case_series_form"]
from dal import autocomplete
assert isinstance(form.fields["condition"].widget, autocomplete.ModelSelect2Multiple)
assert isinstance(form.fields["presentation"].widget, autocomplete.ModelSelect2Multiple)
assert isinstance(form.fields["procedures"].widget, autocomplete.ModelSelect2Multiple)
+39 -13
View File
@@ -3343,9 +3343,15 @@ def user_uploads(
for d in dicoms:
tags = d.basic_dicom_tags
if tags is None or "PatientID" not in tags:
try:
d.save()
tags = d.basic_dicom_tags
except Exception:
pass
if tags is None:
d.save()
tags = d.basic_dicom_tags
continue
series_uid = tags.get("SeriesInstanceUID")
if not series_uid:
@@ -3569,6 +3575,36 @@ def user_uploads(
)
study_data["preferred_case_id"] = sorted_suggestions[0]["case"].pk
# Now group the processed studies by PatientID
patient_groups = defaultdict(lambda: {
"studies": [],
"min_date": "99999999",
})
for study_uid, study_data in grouped_series:
patient_id = "Unknown Patient"
if study_data["series"]:
first_series_tags = study_data["series"][0][2]
patient_id = first_series_tags.get("PatientID") or "Unknown Patient"
pg = patient_groups[patient_id]
pg["studies"].append((study_uid, study_data))
study_date = study_data.get("date") or ""
if study_date and study_date < pg["min_date"]:
pg["min_date"] = study_date
grouped_patients = sorted(
[
{
"patient_id": patient_id,
"studies": pg["studies"],
"min_date": pg["min_date"],
}
for patient_id, pg in patient_groups.items()
],
key=lambda x: x["min_date"] or ""
)
case = False
if case_id is not None:
case = get_object_or_404(Case, pk=case_id)
@@ -3576,17 +3612,6 @@ def user_uploads(
case_series_form = CaseForm(user=request.user)
case_series_form.fields["previous_case"].required = False
case_series_form.fields["previous_case"].widget = HiddenInput()
for field_name in ("condition", "presentation", "procedures"):
field = case_series_form.fields.get(field_name)
if field is None:
continue
field.widget = forms.SelectMultiple(
attrs={
"class": "form-select",
"size": 8,
}
)
field.widget.choices = field.choices
return render(
request,
@@ -3594,6 +3619,7 @@ def user_uploads(
{
"series_list": series_list,
"grouped_series": grouped_series,
"grouped_patients": grouped_patients,
"uploaded_series_uids": uploaded_series_uids,
"case": case,
"user": user,