Add patient grouping and selection functionality to user uploads
This commit is contained in:
@@ -2802,6 +2802,7 @@ class UncategorisedDicom(models.Model):
|
|||||||
"SliceThickness",
|
"SliceThickness",
|
||||||
"SeriesNumber",
|
"SeriesNumber",
|
||||||
"StudyDate",
|
"StudyDate",
|
||||||
|
"PatientID",
|
||||||
)
|
)
|
||||||
|
|
||||||
tags = {}
|
tags = {}
|
||||||
|
|||||||
@@ -17,7 +17,97 @@
|
|||||||
|
|
||||||
{% if series_list %}
|
{% if series_list %}
|
||||||
<div id="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 %}
|
{% for study_uid, study in grouped_series %}
|
||||||
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
|
<details class="study-block {% if study.partial_imported_uids %}study-block--partial-import{% endif %}" open>
|
||||||
<summary>
|
<summary>
|
||||||
@@ -233,7 +323,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<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) {
|
document.addEventListener('click', function (e) {
|
||||||
const target = e.target;
|
const target = e.target;
|
||||||
if (target.matches('.select-all-btn')) {
|
if (target.matches('.select-all-btn')) {
|
||||||
@@ -562,6 +652,34 @@
|
|||||||
});
|
});
|
||||||
if (typeof updateSelectionCount === 'function') updateSelectionCount();
|
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
|
// Listen for `case:selected` events dispatched by the case-search widget and perform the import
|
||||||
@@ -884,6 +1002,10 @@
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.select2-container {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
#series-list li {
|
#series-list li {
|
||||||
}
|
}
|
||||||
#series-list .selected {
|
#series-list .selected {
|
||||||
|
|||||||
@@ -56,3 +56,43 @@ def test_user_uploads_partial_import_button_prefers_case_for_hash_matched_series
|
|||||||
content = response.content.decode("utf-8")
|
content = response.content.decode("utf-8")
|
||||||
assert 'class="btn btn-sm btn-warning import-partial-series-btn"' in content
|
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:
|
for d in dicoms:
|
||||||
tags = d.basic_dicom_tags
|
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:
|
if tags is None:
|
||||||
d.save()
|
continue
|
||||||
tags = d.basic_dicom_tags
|
|
||||||
|
|
||||||
series_uid = tags.get("SeriesInstanceUID")
|
series_uid = tags.get("SeriesInstanceUID")
|
||||||
if not series_uid:
|
if not series_uid:
|
||||||
@@ -3569,6 +3575,36 @@ def user_uploads(
|
|||||||
)
|
)
|
||||||
study_data["preferred_case_id"] = sorted_suggestions[0]["case"].pk
|
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
|
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)
|
||||||
@@ -3576,17 +3612,6 @@ def user_uploads(
|
|||||||
case_series_form = CaseForm(user=request.user)
|
case_series_form = CaseForm(user=request.user)
|
||||||
case_series_form.fields["previous_case"].required = False
|
case_series_form.fields["previous_case"].required = False
|
||||||
case_series_form.fields["previous_case"].widget = HiddenInput()
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
@@ -3594,6 +3619,7 @@ def user_uploads(
|
|||||||
{
|
{
|
||||||
"series_list": series_list,
|
"series_list": series_list,
|
||||||
"grouped_series": grouped_series,
|
"grouped_series": grouped_series,
|
||||||
|
"grouped_patients": grouped_patients,
|
||||||
"uploaded_series_uids": uploaded_series_uids,
|
"uploaded_series_uids": uploaded_series_uids,
|
||||||
"case": case,
|
"case": case,
|
||||||
"user": user,
|
"user": user,
|
||||||
|
|||||||
Reference in New Issue
Block a user