feat(tasks): Add seriesId to stacks in Case model and enhance series viewer with navigation buttons
This commit is contained in:
+2
-2
@@ -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 = []
|
||||
|
||||
@@ -18,10 +18,18 @@
|
||||
</button>
|
||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||
<button type="button"
|
||||
class="btn btn-outline-info btn-xs btn-sm series-open-viewer-btn"
|
||||
title="Open series in viewer"
|
||||
data-series-id="{{series.pk}}">
|
||||
<i class="bi bi-eye"></i> View
|
||||
class="btn btn-outline-info btn-xs btn-sm series-open-primary-btn"
|
||||
title="Open series in primary viewport"
|
||||
data-series-id="{{series.pk}}"
|
||||
data-series-name="{{ series|escape }}">
|
||||
<i class="bi bi-eye"></i> Open
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-outline-info btn-xs btn-sm series-open-new-viewport-btn"
|
||||
title="Add a viewport and open series"
|
||||
data-series-id="{{series.pk}}"
|
||||
data-series-name="{{ series|escape }}">
|
||||
<i class="bi bi-layout-split"></i> Open + viewport
|
||||
</button>
|
||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
|
||||
class="btn btn-outline-secondary btn-xs btn-sm"
|
||||
@@ -1339,32 +1347,202 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// Series viewer button handler: open series in viewer or navigate to series page
|
||||
// Series viewer button handlers: open series in primary viewport or new viewport.
|
||||
(function initSeriesViewerButtons() {
|
||||
if (window.__seriesViewerButtonsBound) return;
|
||||
window.__seriesViewerButtonsBound = true;
|
||||
|
||||
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
|
||||
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;
|
||||
}
|
||||
await openSeriesInPrimary(seriesId, seriesName);
|
||||
});
|
||||
});
|
||||
|
||||
// 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 }));
|
||||
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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -5,6 +5,16 @@
|
||||
<div class="row">
|
||||
<div class="col-12 mb-3">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<div class="small text-uppercase text-muted fw-semibold">Series Navigation</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">Series details</a>
|
||||
{% if can_edit %}
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:task_overview' %}">Tasks</a>
|
||||
{% endif %}
|
||||
<button id="reset-viewport-button" class="btn btn-outline-secondary btn-sm">Reset viewport</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
||||
<div>
|
||||
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
||||
@@ -12,14 +22,6 @@
|
||||
<div class="mt-2">Description: <span class="text-break">{{ series.description }}</span></div>
|
||||
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
||||
</div>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{% url 'atlas:series_detail' pk=series.pk %}">View series</a>
|
||||
{% if can_edit %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
{% else %}
|
||||
<button id="reset-viewport-button" class="btn btn-secondary btn-sm">Reset viewport</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,7 @@ Atlas Task Overview
|
||||
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
|
||||
<option value="delete_completed">Delete selected COMPLETED</option>
|
||||
<option value="delete_failed">Delete selected FAILED</option>
|
||||
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
|
||||
<div class="form-check ms-2">
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user