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 @@
+
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 }}
+
+
+
+
+
+ {% 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)