From 93e24bf855f4a3f53565e41b556bff4fc7918f9c Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 18 May 2026 08:33:46 +0100 Subject: [PATCH] feat(tasks): Add seriesId to stacks in Case model and enhance series viewer with navigation buttons --- atlas/models.py | 4 +- atlas/templates/atlas/case_display_block.html | 220 ++++++++++++++++-- atlas/templates/atlas/series_viewer.html | 18 +- atlas/templates/atlas/task_overview.html | 1 + atlas/views.py | 3 + 5 files changed, 215 insertions(+), 31 deletions(-) diff --git a/atlas/models.py b/atlas/models.py index 0f4afc3e..d48ba047 100644 --- a/atlas/models.py +++ b/atlas/models.py @@ -821,7 +821,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin): for series in case_obj.get_ordered_series(): images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()] name = f"{prefix}: {series}" if prefix else str(series) - stacks.append({"name": name, "imageIds": images}) + stacks.append({"name": name, "imageIds": images, "seriesId": series.pk}) return stacks results = [] @@ -1912,7 +1912,7 @@ class CaseDetail(models.Model): for series in case_obj.get_ordered_series(): images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()] name = f"{prefix}: {series}" if prefix else str(series) - stacks.append({"name": name, "imageIds": images}) + stacks.append({"name": name, "imageIds": images, "seriesId": series.pk}) return stacks results = [] diff --git a/atlas/templates/atlas/case_display_block.html b/atlas/templates/atlas/case_display_block.html index 85dbeaa9..0d3fc77f 100755 --- a/atlas/templates/atlas/case_display_block.html +++ b/atlas/templates/atlas/case_display_block.html @@ -18,10 +18,18 @@ + { - btn.addEventListener('click', function(e) { + + function getViewerApi(baseName, fallbackName) { + const fn = window[`${baseName}_main_viewer`] || (fallbackName ? window[fallbackName] : null); + return typeof fn === 'function' ? fn : null; + } + + function ensureViewerOpen() { + const viewerDetails = document.getElementById('dicom-viewer-details'); + if (viewerDetails && !viewerDetails.hasAttribute('open')) { + viewerDetails.setAttribute('open', 'open'); + viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true })); + } + } + + function flattenNamedStacks(raw) { + const out = []; + if (!Array.isArray(raw)) { + return out; + } + + raw.forEach((item) => { + if (item && typeof item === 'object' && Array.isArray(item.stacks)) { + const caseId = item.caseId; + const studyId = item.studyId; + item.stacks.forEach((stack) => { + if (stack && Array.isArray(stack.imageIds)) { + out.push({ + imageIds: stack.imageIds, + name: stack.name || '', + caseId: stack.caseId || caseId, + studyId: stack.studyId || studyId, + seriesId: stack.seriesId || null, + }); + } + }); + } else if (item && typeof item === 'object' && Array.isArray(item.imageIds)) { + out.push({ + imageIds: item.imageIds, + name: item.name || '', + caseId: item.caseId || null, + studyId: item.studyId || null, + seriesId: item.seriesId || null, + }); + } + }); + + return out; + } + + function getNamedStacksFromMainViewer() { + const mainViewer = document.getElementById('main_viewer'); + if (!mainViewer) { + return []; + } + + const raw = mainViewer.getAttribute('data-named-stacks') || '[]'; + try { + return flattenNamedStacks(JSON.parse(raw)); + } catch (e) { + console.warn('Failed to parse data-named-stacks', e); + return []; + } + } + + function findStackBySeries(stacks, seriesId, seriesName) { + const idNum = Number(seriesId); + let match = stacks.find((stack) => Number(stack.seriesId) === idNum); + if (!match && seriesName) { + match = stacks.find((stack) => String(stack.name || '').trim() === String(seriesName).trim()); + } + return match || null; + } + + async function openSeriesInPrimary(seriesId, seriesName) { + ensureViewerOpen(); + const mainViewer = document.getElementById('main_viewer'); + if (!mainViewer) { + return; + } + + const stacks = getNamedStacksFromMainViewer(); + const target = findStackBySeries(stacks, seriesId, seriesName); + if (!target) { + alert('Series stack not found in this case viewer.'); + return; + } + + const reordered = [target, ...stacks.filter((s) => s !== target)]; + mainViewer.setAttribute('data-named-stacks', JSON.stringify(reordered)); + try { + await mountDicomViewersSafely(); + } catch (e) { + console.warn('Failed to remount viewer', e); + } + } + + async function openSeriesInNewViewport(seriesId, seriesName) { + ensureViewerOpen(); + const stacks = getNamedStacksFromMainViewer(); + const target = findStackBySeries(stacks, seriesId, seriesName); + if (!target) { + alert('Series stack not found in this case viewer.'); + return; + } + + const exportStateFn = getViewerApi('exportViewerState'); + const importStateFn = getViewerApi('importViewerState'); + + if (!exportStateFn || !importStateFn) { + // Fallback: at least add stack to viewer list and auto-fill an empty viewport if available. + const loadAdditionalStackFn = getViewerApi('loadAdditionalStack'); + if (loadAdditionalStackFn) { + await loadAdditionalStackFn(target.imageIds, target.studyId); + } + return; + } + + let state = {}; + try { + state = JSON.parse(exportStateFn() || '{}'); + } catch (e) { + state = {}; + } + + const currentStacks = Array.isArray(state.stacks) ? [...state.stacks] : []; + const toNorm = (ids) => (Array.isArray(ids) ? ids.map((id) => String(id).replace(/^wadouri:/, '')) : []); + const targetNorm = toNorm(target.imageIds); + + let targetIdx = currentStacks.findIndex((stack) => { + const ids = Array.isArray(stack?.i) ? stack.i : []; + const norm = toNorm(ids); + return norm.length === targetNorm.length && norm.every((id, i) => id === targetNorm[i]); + }); + + if (targetIdx < 0) { + currentStacks.push({ i: target.imageIds, u: target.studyId || undefined }); + targetIdx = currentStacks.length - 1; + } + + const grid = state.grid && Number.isFinite(state.grid.rows) && Number.isFinite(state.grid.cols) + ? { rows: Math.max(1, Number(state.grid.rows)), cols: Math.max(1, Number(state.grid.cols)) } + : { rows: 1, cols: 1 }; + + if ((grid.rows * grid.cols) < 2) { + grid.cols = 2; + } + + const numActive = Math.max(2, grid.rows * grid.cols); + const stackIdx = Array.isArray(state.stackIdx) ? [...state.stackIdx] : []; + const modes = Array.isArray(state.modes) ? [...state.modes] : []; + while (stackIdx.length < numActive) { + stackIdx.push(0); + } + while (modes.length < numActive) { + modes.push('stack'); + } + stackIdx[1] = targetIdx; + modes[0] = 'stack'; + modes[1] = 'stack'; + + const nextState = { + ...state, + grid, + stacks: currentStacks, + stackIdx, + modes, + }; + + importStateFn(JSON.stringify(nextState)); + } + + document.querySelectorAll('.series-open-primary-btn').forEach((btn) => { + btn.addEventListener('click', async function (e) { e.preventDefault(); const seriesId = this.getAttribute('data-series-id'); + const seriesName = this.getAttribute('data-series-name') || ''; if (!seriesId) { - alert('Series ID not found'); return; } - - // Focus the viewer details element to open it - const viewerDetails = document.getElementById('dicom-viewer-details'); - if (viewerDetails && !viewerDetails.hasAttribute('open')) { - viewerDetails.setAttribute('open', 'open'); - // Trigger the toggle event to ensure viewer initializes - viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true })); + await openSeriesInPrimary(seriesId, seriesName); + }); + }); + + document.querySelectorAll('.series-open-new-viewport-btn').forEach((btn) => { + btn.addEventListener('click', async function (e) { + e.preventDefault(); + const seriesId = this.getAttribute('data-series-id'); + const seriesName = this.getAttribute('data-series-name') || ''; + if (!seriesId) { + return; } - - // Note: Direct series loading will be implemented via API in the future. - // For now, this just opens and focuses the viewer. - // When the series page viewer API is available, we can load the specific series here: - // window.loadSeriesInViewer(seriesId); + await openSeriesInNewViewport(seriesId, seriesName); }); }); })(); diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html index f41155a8..b7c340cd 100755 --- a/atlas/templates/atlas/series_viewer.html +++ b/atlas/templates/atlas/series_viewer.html @@ -5,6 +5,16 @@
+
+
Series Navigation
+
+ Series details + {% if can_edit %} + Tasks + {% endif %} + +
+

Series: {{ series.modality }} - {{ series.examination }}

@@ -12,14 +22,6 @@
Description: {{ series.description }}
Author: {{ series.get_author_display }}
-
- View series - {% if can_edit %} - - {% else %} - - {% endif %} -
diff --git a/atlas/templates/atlas/task_overview.html b/atlas/templates/atlas/task_overview.html index 393a8a9f..ac40d225 100644 --- a/atlas/templates/atlas/task_overview.html +++ b/atlas/templates/atlas/task_overview.html @@ -82,6 +82,7 @@ Atlas Task Overview +
diff --git a/atlas/views.py b/atlas/views.py index b02c4de4..962be89f 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -936,6 +936,9 @@ def task_overview(request): elif action == "delete_failed": deleted, _ = selected_qs.filter(status="FAILED").delete() messages.success(request, f"Deleted {deleted} failed task record(s).") + elif action == "delete_selected_all": + deleted, _ = selected_qs.delete() + messages.success(request, f"Deleted {deleted} selected task record(s).") else: messages.error(request, "Invalid task action requested.")