Enhance user uploads view by grouping series by StudyInstanceUID and adding a partial template for displaying common DICOM tags
This commit is contained in:
@@ -2092,6 +2092,7 @@ class UncategorisedDicom(models.Model):
|
|||||||
if dataset is None:
|
if dataset is None:
|
||||||
dataset = pydicom.dcmread(self.image)
|
dataset = pydicom.dcmread(self.image)
|
||||||
to_include = (
|
to_include = (
|
||||||
|
"StudyInstanceUID",
|
||||||
"StudyDescription",
|
"StudyDescription",
|
||||||
"Modality",
|
"Modality",
|
||||||
"ProtocolName",
|
"ProtocolName",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<div class="series-tags-fragment">
|
||||||
|
<h6>Series DICOM tags (common across series)</h6>
|
||||||
|
{% if common_tags %}
|
||||||
|
<table class="table table-sm">
|
||||||
|
<tbody>
|
||||||
|
{% for k, v in common_tags.items %}
|
||||||
|
<tr>
|
||||||
|
<th style="width:35%">{{ k }}</th>
|
||||||
|
<td>{{ v }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-muted">No consistent tags found across images in this series.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -14,25 +14,51 @@
|
|||||||
|
|
||||||
|
|
||||||
{% if series_list %}
|
{% if series_list %}
|
||||||
<ul id="series-list">
|
<div id="series-list">
|
||||||
{% for series, n, tags, date in series_list %}
|
{% if grouped_series %}
|
||||||
<li>
|
{% for study_uid, study in grouped_series %}
|
||||||
<span _="
|
<details class="study-block" open>
|
||||||
on click toggle .selected on me.closest('li')
|
<summary>
|
||||||
then set i to the first <input/> in me
|
<strong>{{ study.description|default:"(no description)" }}</strong>
|
||||||
then set i.checked to not i.checked" >
|
<small class="text-muted"> ({{ study_uid }}) — {{ study.series|length }} series</small>
|
||||||
Uploaded: {{date}}<br/>
|
</summary>
|
||||||
Series: {{series}} [{{n}} images] <br/>
|
<ul class="study-series-list">
|
||||||
{{tags}}
|
{% for series, n, tags, date in study.series %}
|
||||||
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
|
<li>
|
||||||
</span>
|
<span _="on click toggle .selected on me.closest('li') then set i to the first <input/> in me then set i.checked to not i.checked">
|
||||||
<span class="series-block-popup-link">
|
<div class="series-meta">Uploaded: {{ date }} — Series: {{ series }} [{{ n }} images]</div>
|
||||||
<a href="#"
|
<div class="series-tags">{{ tags }}</div>
|
||||||
onclick="return window.create_popup_window('/atlas/uploads/series_id/{{series}}', 'Series')">Popup</a>
|
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
|
||||||
</span>
|
</span>
|
||||||
</li>
|
<span class="series-block-popup-link">
|
||||||
{% endfor %}
|
<a href="#" onclick="return window.create_popup_window('/atlas/uploads/series_id/{{series}}', 'Series')">Popup</a>
|
||||||
</ul>
|
|
|
||||||
|
<a hx-get="{% url 'atlas:series_tags_partial' series %}" hx-target="[id='series-tags-{{ series }}']" hx-swap="innerHTML">View tags</a>
|
||||||
|
</span>
|
||||||
|
<div id="series-tags-{{ series }}" class="series-tags-placeholder mt-2"></div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<ul>
|
||||||
|
{% for series, n, tags, date in series_list %}
|
||||||
|
<li>
|
||||||
|
<span _="on click toggle .selected on me.closest('li') then set i to the first <input/> in me then set i.checked to not i.checked">
|
||||||
|
Uploaded: {{date}}<br/>
|
||||||
|
Series: {{series}} [{{n}} images] <br/>
|
||||||
|
{{tags}}
|
||||||
|
<input class="hidden" type="checkbox" name="selection" value="{{ series }}">
|
||||||
|
</span>
|
||||||
|
<span class="series-block-popup-link">
|
||||||
|
<a href="#" onclick="return window.create_popup_window('/atlas/uploads/series_id/{{series}}', 'Series')">Popup</a>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if case %}
|
{% if case %}
|
||||||
<p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
|
<p>Importing into case: <a href='{% url "atlas:case_detail" case.pk %}'>{{case}}</a></p>
|
||||||
@@ -182,10 +208,10 @@
|
|||||||
grid-area: 1/1;
|
grid-area: 1/1;
|
||||||
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
|
--c:no-repeat radial-gradient(farthest-side,#25b09b 92%,#0000);
|
||||||
background:
|
background:
|
||||||
var(--c) 50% 0,
|
var(--c) 50% 0,
|
||||||
var(--c) 50% 100%,
|
var(--c) 50% 100%,
|
||||||
var(--c) 100% 50%,
|
var(--c) 100% 50%,
|
||||||
var(--c) 0 50%;
|
var(--c) 0 50%;
|
||||||
background-size: 12px 12px;
|
background-size: 12px 12px;
|
||||||
animation: l12 1s infinite;
|
animation: l12 1s infinite;
|
||||||
}
|
}
|
||||||
@@ -205,6 +231,35 @@
|
|||||||
cursor: pointer;
|
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 li {
|
||||||
}
|
}
|
||||||
#series-list .selected {
|
#series-list .selected {
|
||||||
|
|||||||
@@ -408,6 +408,11 @@ urlpatterns = [
|
|||||||
views.series_dicom_json,
|
views.series_dicom_json,
|
||||||
name="series_dicom_json",
|
name="series_dicom_json",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"series/<str:pk>/tags_partial",
|
||||||
|
views.series_tags_partial,
|
||||||
|
name="series_tags_partial",
|
||||||
|
),
|
||||||
path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'),
|
path('series/<int:series_pk>/add-to-case/', views.add_series_to_case, name='add_series_to_case'),
|
||||||
path('series/<int:series_pk>/<int:case_pk>/remove-from-case/', views.remove_series_from_case, name='remove_series_from_case'),
|
path('series/<int:series_pk>/<int:case_pk>/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'),
|
path('series/bulk-delete/', views.series_bulk_delete, name='series_bulk_delete'),
|
||||||
|
|||||||
+67
-1
@@ -1778,6 +1778,23 @@ def user_uploads(
|
|||||||
|
|
||||||
series_list.sort(key=lambda l: l[-1])
|
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
|
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)
|
||||||
@@ -1785,7 +1802,7 @@ def user_uploads(
|
|||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"atlas/user_uploads.html",
|
"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())
|
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
|
@user_is_collection_author_or_atlas_editor
|
||||||
def delete_collection_cid_answers(request, exam_id, cid):
|
def delete_collection_cid_answers(request, exam_id, cid):
|
||||||
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
collection = get_object_or_404(CaseCollection, pk=exam_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user