From 0c6d33e26ad4e9e2e2e2fec1ebca2d343a2d96f8 Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 2 Mar 2026 10:31:04 +0000 Subject: [PATCH] Enhance user uploads view by grouping series by StudyInstanceUID and adding a partial template for displaying common DICOM tags --- atlas/models.py | 1 + .../templates/atlas/partials/series_tags.html | 17 +++ atlas/templates/atlas/user_uploads.html | 101 ++++++++++++++---- atlas/urls.py | 5 + atlas/views.py | 68 +++++++++++- 5 files changed, 168 insertions(+), 24 deletions(-) create mode 100644 atlas/templates/atlas/partials/series_tags.html diff --git a/atlas/models.py b/atlas/models.py index 0f2596dd..b0812fa9 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -2092,6 +2092,7 @@ class UncategorisedDicom(models.Model): if dataset is None: dataset = pydicom.dcmread(self.image) to_include = ( + "StudyInstanceUID", "StudyDescription", "Modality", "ProtocolName", diff --git a/atlas/templates/atlas/partials/series_tags.html b/atlas/templates/atlas/partials/series_tags.html new file mode 100644 index 00000000..7f315e5e --- /dev/null +++ b/atlas/templates/atlas/partials/series_tags.html @@ -0,0 +1,17 @@ +
+
Series DICOM tags (common across series)
+ {% if common_tags %} + + + {% for k, v in common_tags.items %} + + + + + {% endfor %} + +
{{ k }}{{ v }}
+ {% else %} +
No consistent tags found across images in this series.
+ {% endif %} +
diff --git a/atlas/templates/atlas/user_uploads.html b/atlas/templates/atlas/user_uploads.html index 4c401115..02cc82e4 100644 --- a/atlas/templates/atlas/user_uploads.html +++ b/atlas/templates/atlas/user_uploads.html @@ -14,25 +14,51 @@ {% if series_list %} - +
+ {% if grouped_series %} + {% for study_uid, study in grouped_series %} +
+ + {{ study.description|default:"(no description)" }} + ({{ study_uid }}) — {{ study.series|length }} series + +
    + {% for series, n, tags, date in study.series %} +
  • + +
    Uploaded: {{ date }} — Series: {{ series }} [{{ n }} images]
    +
    {{ tags }}
    + +
    + + Popup +  |  + View tags + +
    +
  • + {% endfor %} +
+
+ {% endfor %} + {% else %} + + {% endif %} +
{% if case %}

Importing into case: {{case}}

@@ -182,10 +208,10 @@ grid-area: 1/1; --c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000); background: - var(--c) 50% 0, - var(--c) 50% 100%, - var(--c) 100% 50%, - var(--c) 0 50%; + var(--c) 50% 0, + var(--c) 50% 100%, + var(--c) 100% 50%, + var(--c) 0 50%; background-size: 12px 12px; animation: l12 1s infinite; } @@ -205,6 +231,35 @@ cursor: pointer; } + .study-block { + margin: 8px 0; + padding: 6px; + border: 1px solid #e9ecef; + border-radius: 6px; + background: #fafafa; + } + + .study-block summary { + font-size: 1rem; + cursor: pointer; + list-style: none; + padding: 4px 6px; + } + + .study-series-list { + list-style: none; + margin: 8px 0 0 0; + padding: 0; + } + + .study-series-list li { + padding: 6px; + border-bottom: 1px solid #eee; + } + + .series-meta { font-weight: 600; } + .series-tags { font-size: 0.9rem; color: #666; } + #series-list li { } #series-list .selected { diff --git a/atlas/urls.py b/atlas/urls.py index a6d94758..764087a1 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -408,6 +408,11 @@ urlpatterns = [ views.series_dicom_json, name="series_dicom_json", ), + path( + "series//tags_partial", + views.series_tags_partial, + name="series_tags_partial", + ), path('series//add-to-case/', views.add_series_to_case, name='add_series_to_case'), path('series///remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'), path('series/bulk-delete/', views.series_bulk_delete, name='series_bulk_delete'), diff --git a/atlas/views.py b/atlas/views.py index 75c75807..cf62fb53 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -1778,6 +1778,23 @@ def user_uploads( series_list.sort(key=lambda l: l[-1]) + # Group series by StudyInstanceUID for better visual grouping in the template + grouped = {} + for series_uid, count, tags, date in series_list: + study_uid = tags.get("StudyInstanceUID") or tags.get("StudyID") or "unknown" + study_desc = tags.get("StudyDescription", "") + study_date = tags.get("StudyDate", date) + if study_uid not in grouped: + grouped[study_uid] = { + "description": study_desc, + "date": study_date, + "series": [], + } + grouped[study_uid]["series"].append((series_uid, count, tags, date)) + + # Convert to a list preserving order sorted by group date + grouped_series = sorted(grouped.items(), key=lambda kv: kv[1].get("date") or "") + case = False if case_id is not None: case = get_object_or_404(Case, pk=case_id) @@ -1785,7 +1802,7 @@ def user_uploads( return render( request, "atlas/user_uploads.html", - {"series_list": series_list, "case": case, "user": user}, + {"series_list": series_list, "grouped_series": grouped_series, "case": case, "user": user}, ) @@ -4782,6 +4799,55 @@ def series_dicom_json(request, pk): return JsonResponse(series.get_ohif_dicom_json()) +def series_tags_partial(request, pk): + # Accept either a numeric PK or a SeriesInstanceUID string + series = None + try: + if str(pk).isdigit(): + series = Series.objects.filter(pk=int(pk)).first() + except Exception: + series = None + + if series is None: + series = Series.objects.filter(series_instance_uid=pk).first() + + if series is None: + return HttpResponse(status=404) + images = list(series.images.all()) + num_images = len(images) + + tag_values = {} + tag_counts = {} + + for img in images: + ds = img.get_dicom_data() + if not ds: + continue + # Iterate elements + for elem in ds: + kw = getattr(elem, "keyword", None) or str(elem.tag) + try: + val = elem.value + except Exception: + val = None + if isinstance(val, pydicom.multival.MultiValue): + sval = ",".join(map(str, val)) + else: + sval = str(val) if val is not None else "" + + tag_counts.setdefault(kw, 0) + tag_counts[kw] += 1 + tag_values.setdefault(kw, set()).add(sval) + + common_tags = {} + for k, vals in tag_values.items(): + if tag_counts.get(k, 0) == num_images and len(vals) == 1: + common_tags[k] = list(vals)[0] + + html = render_to_string("atlas/partials/series_tags.html", {"common_tags": common_tags}, request=request) + return HttpResponse(html) + + @user_is_collection_author_or_atlas_editor def delete_collection_cid_answers(request, exam_id, cid): collection = get_object_or_404(CaseCollection, pk=exam_id)