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():
|
for series in case_obj.get_ordered_series():
|
||||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
|
images = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images()]
|
||||||
name = f"{prefix}: {series}" if prefix else str(series)
|
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
|
return stacks
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
@@ -1912,7 +1912,7 @@ class CaseDetail(models.Model):
|
|||||||
for series in case_obj.get_ordered_series():
|
for series in case_obj.get_ordered_series():
|
||||||
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
images = [f"{REMOTE_URL}{img.image.url}" for img in series.images.all()]
|
||||||
name = f"{prefix}: {series}" if prefix else str(series)
|
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
|
return stacks
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|||||||
@@ -18,10 +18,18 @@
|
|||||||
</button>
|
</button>
|
||||||
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn btn-outline-info btn-xs btn-sm series-open-viewer-btn"
|
class="btn btn-outline-info btn-xs btn-sm series-open-primary-btn"
|
||||||
title="Open series in viewer"
|
title="Open series in primary viewport"
|
||||||
data-series-id="{{series.pk}}">
|
data-series-id="{{series.pk}}"
|
||||||
<i class="bi bi-eye"></i> View
|
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>
|
</button>
|
||||||
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
|
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
|
||||||
class="btn btn-outline-secondary btn-xs btn-sm"
|
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() {
|
(function initSeriesViewerButtons() {
|
||||||
if (window.__seriesViewerButtonsBound) return;
|
if (window.__seriesViewerButtonsBound) return;
|
||||||
window.__seriesViewerButtonsBound = true;
|
window.__seriesViewerButtonsBound = true;
|
||||||
|
|
||||||
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
|
function getViewerApi(baseName, fallbackName) {
|
||||||
btn.addEventListener('click', function(e) {
|
const fn = window[`${baseName}_main_viewer`] || (fallbackName ? window[fallbackName] : null);
|
||||||
e.preventDefault();
|
return typeof fn === 'function' ? fn : null;
|
||||||
const seriesId = this.getAttribute('data-series-id');
|
|
||||||
if (!seriesId) {
|
|
||||||
alert('Series ID not found');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus the viewer details element to open it
|
function ensureViewerOpen() {
|
||||||
const viewerDetails = document.getElementById('dicom-viewer-details');
|
const viewerDetails = document.getElementById('dicom-viewer-details');
|
||||||
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
|
if (viewerDetails && !viewerDetails.hasAttribute('open')) {
|
||||||
viewerDetails.setAttribute('open', 'open');
|
viewerDetails.setAttribute('open', 'open');
|
||||||
// Trigger the toggle event to ensure viewer initializes
|
|
||||||
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
|
viewerDetails.dispatchEvent(new Event('toggle', { bubbles: true }));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Note: Direct series loading will be implemented via API in the future.
|
function flattenNamedStacks(raw) {
|
||||||
// For now, this just opens and focuses the viewer.
|
const out = [];
|
||||||
// When the series page viewer API is available, we can load the specific series here:
|
if (!Array.isArray(raw)) {
|
||||||
// window.loadSeriesInViewer(seriesId);
|
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) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
await openSeriesInNewViewport(seriesId, seriesName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -5,6 +5,16 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 mb-3">
|
<div class="col-12 mb-3">
|
||||||
<div class="card shadow-sm">
|
<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 class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="card-title mb-1">Series: {{ series.modality }} - {{ series.examination }}</h4>
|
<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">Description: <span class="text-break">{{ series.description }}</span></div>
|
||||||
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
<div class="mt-2 text-muted">Author: {{ series.get_author_display }}</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ Atlas Task Overview
|
|||||||
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
|
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
|
||||||
<option value="delete_completed">Delete selected COMPLETED</option>
|
<option value="delete_completed">Delete selected COMPLETED</option>
|
||||||
<option value="delete_failed">Delete selected FAILED</option>
|
<option value="delete_failed">Delete selected FAILED</option>
|
||||||
|
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
|
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
|
||||||
<div class="form-check ms-2">
|
<div class="form-check ms-2">
|
||||||
|
|||||||
@@ -936,6 +936,9 @@ def task_overview(request):
|
|||||||
elif action == "delete_failed":
|
elif action == "delete_failed":
|
||||||
deleted, _ = selected_qs.filter(status="FAILED").delete()
|
deleted, _ = selected_qs.filter(status="FAILED").delete()
|
||||||
messages.success(request, f"Deleted {deleted} failed task record(s).")
|
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:
|
else:
|
||||||
messages.error(request, "Invalid task action requested.")
|
messages.error(request, "Invalid task action requested.")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user