Add patient grouping and selection functionality to user uploads
This commit is contained in:
@@ -2802,6 +2802,7 @@ class UncategorisedDicom(models.Model):
|
||||
"SliceThickness",
|
||||
"SeriesNumber",
|
||||
"StudyDate",
|
||||
"PatientID",
|
||||
)
|
||||
|
||||
tags = {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user