Compare commits

..
20 Commits
Author SHA1 Message Date
Ross ff00efecf7 feat(tasks): Enhance series_reconstruct_task with additional metadata and improved slice handling 2026-05-17 22:29:23 +01:00
Ross a9946961f1 fix(tasks): Update _reduce_slab function to accept reduce_axis parameter for flexibility in reconstruction 2026-05-17 22:20:22 +01:00
Ross d5e473d692 style(tasks): Improve task overview page layout and styling for better readability 2026-05-17 22:08:07 +01:00
Ross 75e3bca503 feat(tasks): Implement task action functionality in task overview page 2026-05-17 21:53:30 +01:00
Ross 1775b11e40 fix(tasks): Correct ImagePositionPatient assignment in series_reconstruct_task 2026-05-17 21:44:09 +01:00
Ross d77c5149f0 feat(worker): Enhance db_worker script for better error handling and add systemd service example 2026-05-17 21:36:46 +01:00
Ross c2d8376e22 feat(worker): Add db_worker service with automatic restart and logging
feat(task_overview): Display generated series in task overview
docs(README): Update documentation for background task workers and troubleshooting
2026-05-17 21:13:40 +01:00
Ross ab9847e22e feat(tasks): Add task overview page with active, completed, and failed task summaries 2026-05-17 14:30:38 +01:00
Ross bbf7542451 feat(settings): Update task backend to use django-tasks-db and add QUEUES configuration 2026-05-17 14:00:04 +01:00
Ross e6a20bb851 feat(tasks): Integrate django-tasks for task management and progress tracking 2026-05-17 12:41:17 +01:00
Ross 7ed0a8f378 feat(reconstruction): Add asynchronous series reconstruction task with progress tracking 2026-05-17 11:32:48 +01:00
Ross c8d05818a4 feat(reconstruction): Enhance volume reconstruction with geometry extraction and thickness merge options 2026-05-17 11:19:18 +01:00
Ross b6716c9ae1 feat(viewer): Refactor truncate viewer API integration and update modal handling 2026-05-17 11:04:18 +01:00
Ross e368d0c7d1 feat(series): Add functions for inferring native spacings and resampling volumes for optimization 2026-05-16 22:42:49 +01:00
Ross b9cbfd20c1 feat(optimization): Implement series optimization features including downsampling, empty DICOM removal, and reconstruction options 2026-05-16 22:30:55 +01:00
Ross 5ce337c7c4 feat(series): Enhance series truncation with options for removing empty DICOMs and downsampling pixel data 2026-05-16 22:02:50 +01:00
Ross f4d990676a feat(viewer): Enhance sidebar functionality with adjustable width and responsive behavior 2026-05-15 20:28:05 +01:00
Ross 4f61e43b8c feat(viewer): Refactor DICOM viewer structure and update responsive styles 2026-05-15 20:19:12 +01:00
Ross c947cba5a7 feat(viewer): Enhance viewer functionality with sidebar and popout support 2026-05-15 20:11:31 +01:00
Ross 0cf655325c feat: Enhance collection review and overview templates with improved styling and conditional content display 2026-05-15 20:04:13 +01:00
24 changed files with 3036 additions and 191 deletions
+63
View File
@@ -11,6 +11,69 @@ Running locally (development)
COMPOSE_ENV=dev docker compose -f docker/docker-compose.prod.yml -f docker/docker-compose.dev.yml up -d --build
```
Background task workers (automatic)
- The compose stack now includes a dedicated `worker` service which runs:
```sh
python manage.py db_worker
```
- This means workers start automatically with `docker compose up` and restart on failure.
- To inspect worker logs:
```sh
docker compose logs -f worker
```
- To scale workers (for higher throughput):
```sh
docker compose up -d --scale worker=2
```
Troubleshooting `SIGKILL` when running manually
- A `SIGKILL` on `db_worker` is usually the kernel OOM killer (out-of-memory), not a Django exception.
- Check kernel OOM events:
```sh
dmesg -T | grep -i -E "killed process|out of memory|oom"
```
- If OOM is confirmed, prefer running worker inside compose (managed restart + container limits), reduce concurrency/parallel services, or add memory/swap.
Manual worker startup (non-Docker production)
- If you are not yet running production in Docker, start the worker using the restart wrapper script:
```sh
source .venv/bin/activate
DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh
```
- This script restarts `db_worker` if it exits unexpectedly.
- To run it in the background and keep logs:
```sh
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
```
- In fish shell, follow with `disown` so the shell does not keep the job attached:
```sh
nohup env DJANGO_SETTINGS_MODULE=rad.settings ./scripts/run-db-worker.sh > logs/db_worker.log 2>&1 &
disown
```
- For server reliability, prefer running the same script under `systemd` (auto-start on reboot, restart-on-failure).
- A starter unit file is provided at `scripts/db-worker.service.example`.
- To verify it is still running:
```sh
ps aux | grep "manage.py db_worker" | grep -v grep
tail -f logs/db_worker.log
```
- By default the development nginx ports are set in `.env.dev` to avoid colliding with a host nginx. The defaults now are:
- `NGINX_HTTP_PORT=8080`
- `NGINX_HTTPS_PORT=8444`
+190
View File
@@ -1,4 +1,5 @@
from django.contrib import admin
from django.contrib import messages
from .models import (
Case,
CaseDetail,
@@ -29,12 +30,19 @@ from django.forms import ModelForm
from reversion.admin import VersionAdmin
from django.forms.widgets import RadioSelect
from django.utils.html import format_html
from django.urls import reverse
from tinymce.widgets import TinyMCE
# from generic.models import Examination, Sign, Site, Condition
from generic.models import Examination
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
# Register your models here.
# admin.site.register(Examination)
# admin.site.register(Site)
@@ -152,3 +160,185 @@ class APITokenAdmin(admin.ModelAdmin):
admin.site.register(APIToken, APITokenAdmin)
if DBTaskResult is not None:
try:
# Ensure package admin registration has run before we customize it.
import django_tasks_db.admin # noqa: F401
except Exception:
pass
def traceback_link(self, obj):
if not obj.traceback:
return "-"
url = reverse(
f"admin:{obj._meta.app_label}_{obj._meta.model_name}_change",
args=[obj.pk],
)
return format_html('<a href="{}">View full traceback</a>', url)
traceback_link.short_description = "Traceback"
def traceback_pretty(self, obj):
if not obj.traceback:
return ""
return format_html(
'<pre style="white-space: pre-wrap; margin: 0;">{}</pre>',
obj.traceback,
)
traceback_pretty.short_description = "Traceback"
@admin.action(description="Reset FAILED tasks to READY")
def reset_failed_to_ready(self, request, queryset):
failed_qs = queryset.filter(status="FAILED")
updated = failed_qs.update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
self.message_user(
request,
f"Reset {updated} failed task(s) to READY.",
level=messages.SUCCESS,
)
@admin.action(description="Reset selected tasks to READY (except RUNNING)")
def reset_selected_to_ready(self, request, queryset):
blocked = queryset.filter(status="RUNNING").count()
reset_qs = queryset.exclude(status="RUNNING")
updated = reset_qs.update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
msg = f"Reset {updated} task(s) to READY."
if blocked:
msg += f" Skipped {blocked} running task(s)."
self.message_user(request, msg, level=messages.SUCCESS)
@admin.action(description="Delete selected COMPLETED tasks")
def delete_selected_completed(self, request, queryset):
completed_qs = queryset.filter(status="SUCCESSFUL")
deleted, _ = completed_qs.delete()
self.message_user(
request,
f"Deleted {deleted} completed task record(s).",
level=messages.SUCCESS,
)
@admin.action(description="Delete selected FAILED tasks")
def delete_selected_failed(self, request, queryset):
failed_qs = queryset.filter(status="FAILED")
deleted, _ = failed_qs.delete()
self.message_user(
request,
f"Deleted {deleted} failed task record(s).",
level=messages.SUCCESS,
)
def has_add_permission(self, request):
return False
task_admin = admin.site._registry.get(DBTaskResult)
if task_admin is not None:
admin_cls = task_admin.__class__
admin_cls.list_display = (
"id",
"task_path",
"status",
"queue_name",
"priority",
"enqueued_at",
"started_at",
"finished_at",
"worker_ids",
"exception_class_path",
"traceback_link",
)
admin_cls.list_filter = ("status", "queue_name", "task_path", "backend_name")
admin_cls.search_fields = (
"id",
"task_path",
"queue_name",
"worker_ids",
"exception_class_path",
"traceback",
)
admin_cls.ordering = ("-enqueued_at",)
admin_cls.actions = (
"reset_failed_to_ready",
"reset_selected_to_ready",
"delete_selected_completed",
"delete_selected_failed",
)
admin_cls.readonly_fields = (
"id",
"status",
"enqueued_at",
"started_at",
"finished_at",
"args_kwargs",
"priority",
"task_path",
"worker_ids",
"queue_name",
"backend_name",
"run_after",
"return_value",
"exception_class_path",
"traceback_pretty",
)
admin_cls.fieldsets = (
(
"Task",
{
"fields": (
"id",
"task_path",
"status",
"queue_name",
"priority",
"backend_name",
)
},
),
(
"Timing",
{
"fields": (
"enqueued_at",
"run_after",
"started_at",
"finished_at",
"worker_ids",
)
},
),
(
"Payload",
{"fields": ("args_kwargs", "return_value")},
),
(
"Error",
{
"fields": ("exception_class_path", "traceback_pretty"),
},
),
)
admin_cls.traceback_link = traceback_link
admin_cls.traceback_pretty = traceback_pretty
admin_cls.reset_failed_to_ready = reset_failed_to_ready
admin_cls.reset_selected_to_ready = reset_selected_to_ready
admin_cls.delete_selected_completed = delete_selected_completed
admin_cls.delete_selected_failed = delete_selected_failed
admin_cls.has_add_permission = has_add_permission
+442 -4
View File
@@ -2,14 +2,98 @@ from time import sleep
from django.core.mail import send_mail
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from celery import shared_task
from atlas.models import Case
from enum import Enum
try:
from django_tasks import task
HAS_DJANGO_TASKS = True
except ImportError:
from celery import shared_task
from celery.result import AsyncResult
HAS_DJANGO_TASKS = False
class _CompatTaskResultStatus(Enum):
READY = "READY"
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"
class _CompatError:
def __init__(self, traceback):
self.traceback = traceback
class _CompatTaskResult:
def __init__(self, async_result):
self._async_result = async_result
@property
def id(self):
return self._async_result.id
@property
def status(self):
state = self._async_result.state
if state in ("PENDING",):
return _CompatTaskResultStatus.READY
if state in ("STARTED", "RETRY"):
return _CompatTaskResultStatus.RUNNING
if state == "SUCCESS":
return _CompatTaskResultStatus.SUCCESSFUL
if state == "FAILURE":
return _CompatTaskResultStatus.FAILED
return _CompatTaskResultStatus.RUNNING
def refresh(self):
return self
@property
def return_value(self):
return self._async_result.result
@property
def errors(self):
if self._async_result.state == "FAILURE" and self._async_result.traceback:
return [_CompatError(self._async_result.traceback)]
return []
def task(func=None, *, takes_context=False, **kwargs):
def decorator(inner_func):
if takes_context:
@shared_task(bind=True)
def wrapped(self, *args, **inner_kwargs):
class _Ctx:
class _TaskResultRef:
id = self.request.id
task_result = _TaskResultRef()
attempt = 1
return inner_func(_Ctx(), *args, **inner_kwargs)
else:
wrapped = shared_task(inner_func)
wrapped.enqueue = wrapped.delay
wrapped.get_result = lambda result_id: _CompatTaskResult(AsyncResult(result_id))
return wrapped
if func is not None:
return decorator(func)
return decorator
from atlas.models import Case, Series, SeriesImage
from generic.models import CimarCase
from rad.settings import REMOTE_URL, CIMAR_USERNAME, CIMAR_PASSWORD
from helpers.cimar import CimarAPI, NotFoundError
from pydicom.uid import generate_uid
from datetime import datetime
from django.contrib.auth.models import User
from django.core.files.base import ContentFile
from django.core.cache import cache
import copy
import io
@shared_task()
@task
def push_case_to_cimar_task(case_id):
"""Sends an email when the feedback form has been submitted."""
case = get_object_or_404(Case, pk=case_id)
@@ -49,4 +133,358 @@ def push_case_to_cimar_task(case_id):
cimar_case.refresh_study()
return 10
return 10
@task(takes_context=True)
def series_reconstruct_task(
context,
series_id,
user_id,
recon_planes,
slice_thickness_val=None,
slice_spacing_val=None,
recon_thickness_mode="mean",
):
"""Generate reconstructions asynchronously for a series with progress updates."""
import numpy as np
from loguru import logger
from atlas import views as atlas_views
progress_key = f"series_reconstruct_progress:{context.task_result.id}"
cache.set(
progress_key,
{"current": 0, "total": 0, "message": "Preparing reconstruction..."},
timeout=60 * 60,
)
series = get_object_or_404(Series, pk=series_id)
user = get_object_or_404(User, pk=user_id)
if not series.check_user_can_edit(user):
raise PermissionError("Permission denied")
images = list(series.get_images())
dicom_items = []
for image in images:
ds = atlas_views._read_series_image_dataset(image)
if ds is None:
continue
try:
arr = ds.pixel_array
if arr.ndim != 2:
continue
dicom_items.append((image, ds, arr))
except Exception:
continue
if len(dicom_items) < 2:
raise ValueError("Need at least 2 valid DICOM images in series for reconstruction")
base_shape = dicom_items[0][2].shape
dicom_items = [item for item in dicom_items if item[2].shape == base_shape]
if len(dicom_items) < 2:
raise ValueError("Not enough consistently-sized slices for reconstruction")
geom = atlas_views._extract_recon_geometry(dicom_items)
sorted_items = geom["sorted_items"]
volume = np.stack([item[2] for item in sorted_items], axis=0)
template_ds = sorted_items[0][1]
origin_ipp = geom["origin_ipp"]
row_dir = geom["row_dir"]
col_dir = geom["col_dir"]
normal_dir = geom["normal_dir"]
native_row_spacing = geom["row_spacing"]
native_col_spacing = geom["col_spacing"]
native_z_spacing = geom["native_z_spacing"]
requested_slice_spacing = float(slice_spacing_val) if slice_spacing_val is not None else None
requested_slab_thickness = float(slice_thickness_val) if slice_thickness_val is not None else None
# Determine source acquisition plane from normal vector.
dominant_normal_axis = int(np.argmax(np.abs(normal_dir)))
source_plane = {0: "sagittal", 1: "coronal", 2: "axial"}.get(dominant_normal_axis, "unknown")
# Reorient the reconstructed volume into patient axes order [z, y, x] so
# generated plane names are anatomically correct regardless of source plane.
# NOTE: DICOM stores ImageOrientationPatient as:
# - first triplet: direction across columns (x-axis in pixel grid)
# - second triplet: direction across rows (y-axis in pixel grid)
# In _extract_recon_geometry names are historically row_dir/col_dir, so we
# map axis spacing/indexing explicitly to avoid directional mixups.
source_axis_dirs = [normal_dir, col_dir, row_dir]
source_axis_spacings = [float(native_z_spacing), float(native_row_spacing), float(native_col_spacing)]
source_axis_sizes = [int(volume.shape[0]), int(volume.shape[1]), int(volume.shape[2])]
source_for_patient_axis = {}
sign_for_patient_axis = {}
patient_axes_claimed = set()
for src_axis, vec in enumerate(source_axis_dirs):
patient_axis = int(np.argmax(np.abs(vec)))
if patient_axis in patient_axes_claimed:
raise ValueError("Could not infer unique source orientation axes for reconstruction")
patient_axes_claimed.add(patient_axis)
source_for_patient_axis[patient_axis] = src_axis
sign_for_patient_axis[patient_axis] = 1 if float(vec[patient_axis]) >= 0 else -1
src_x = source_for_patient_axis[0]
src_y = source_for_patient_axis[1]
src_z = source_for_patient_axis[2]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
vol_zyx = np.transpose(volume, (src_z, src_y, src_x))
if sign_for_patient_axis[2] < 0:
vol_zyx = np.flip(vol_zyx, axis=0)
if sign_for_patient_axis[1] < 0:
vol_zyx = np.flip(vol_zyx, axis=1)
if sign_for_patient_axis[0] < 0:
vol_zyx = np.flip(vol_zyx, axis=2)
def source_indices_from_patient_zyx(iz, iy, ix):
src_indices = [0, 0, 0]
z_index = float(iz)
y_index = float(iy)
x_index = float(ix)
if sign_for_patient_axis[2] < 0:
z_index = source_axis_sizes[src_z] - 1 - z_index
if sign_for_patient_axis[1] < 0:
y_index = source_axis_sizes[src_y] - 1 - y_index
if sign_for_patient_axis[0] < 0:
x_index = source_axis_sizes[src_x] - 1 - x_index
src_indices[src_z] = z_index
src_indices[src_y] = y_index
src_indices[src_x] = x_index
return src_indices
def position_from_patient_zyx(iz, iy, ix):
src_k, src_r, src_c = source_indices_from_patient_zyx(iz, iy, ix)
pos = (
np.asarray(origin_ipp, dtype=float)
+ (np.asarray(normal_dir, dtype=float) * (float(src_k) * float(native_z_spacing)))
+ (np.asarray(col_dir, dtype=float) * (float(src_r) * float(native_row_spacing)))
+ (np.asarray(row_dir, dtype=float) * (float(src_c) * float(native_col_spacing)))
)
return [float(pos[0]), float(pos[1]), float(pos[2])]
spacing_x = source_axis_spacings[src_x]
spacing_y = source_axis_spacings[src_y]
spacing_z = source_axis_spacings[src_z]
def _reduce_slab(slab, reduce_axis=0):
if recon_thickness_mode == "max":
out = np.max(slab, axis=reduce_axis)
elif recon_thickness_mode == "min":
out = np.min(slab, axis=reduce_axis)
else:
out = np.mean(slab, axis=reduce_axis)
if np.issubdtype(volume.dtype, np.integer):
info = np.iinfo(volume.dtype)
out = np.clip(np.rint(out), info.min, info.max).astype(volume.dtype)
else:
out = out.astype(volume.dtype, copy=False)
return out
def _slice_indices_for_axis(axis_length, axis_spacing, output_spacing, slab_thickness):
if output_spacing <= 0:
raise ValueError("Slice spacing must be greater than 0")
if slab_thickness <= 0:
raise ValueError("Slice thickness must be greater than 0")
axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing)
end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0
start_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float)
if start_positions_mm.size == 0:
start_positions_mm = np.array([0.0], dtype=float)
slabs = []
centers_idx = []
for start_mm in start_positions_mm:
end_mm = start_mm + slab_thickness
mask = (axis_positions_mm >= (start_mm - 1e-6)) & (axis_positions_mm < (end_mm - 1e-6))
if not mask.any():
nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm)))
idxs = np.array([nearest], dtype=int)
else:
idxs = np.where(mask)[0].astype(int)
slabs.append(idxs)
center_mm = start_mm + (slab_thickness / 2.0)
centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0]))
return slabs, centers_idx
plane_builders = {}
for plane in recon_planes:
plane_norm = plane.lower()
if plane_norm == "axial":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_z)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[0]), float(spacing_z), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: _reduce_slab(vol_zyx[slabs[idx], :, :]),
"pixel_spacing_out": [float(spacing_y), float(spacing_x)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
"plane_normal_out": [0.0, 0.0, 1.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(centers[idx], 0.0, 0.0),
}
elif plane_norm == "coronal":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_y)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[1]), float(spacing_y), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, slabs[idx], :], reduce_axis=1)),
"pixel_spacing_out": [float(spacing_z), float(spacing_x)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [1.0, 0.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [0.0, 1.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), centers[idx], 0.0),
}
elif plane_norm == "sagittal":
plane_spacing = float(requested_slice_spacing) if requested_slice_spacing is not None else float(spacing_x)
plane_thickness = float(requested_slab_thickness) if requested_slab_thickness is not None else float(plane_spacing)
slab_indices, slab_centers_idx = _slice_indices_for_axis(
int(vol_zyx.shape[2]), float(spacing_x), plane_spacing, plane_thickness
)
plane_builders[plane_norm] = {
"count": int(len(slab_indices)),
"slice_fn": lambda idx, slabs=slab_indices: np.flipud(_reduce_slab(vol_zyx[:, :, slabs[idx]], reduce_axis=2)),
"pixel_spacing_out": [float(spacing_z), float(spacing_y)],
"spacing_between_slices_out": float(plane_spacing),
"slice_thickness_out": float(plane_thickness),
"image_orientation_out": [0.0, 1.0, 0.0, 0.0, 0.0, -1.0],
"plane_normal_out": [1.0, 0.0, 0.0],
"position_fn": lambda idx, centers=slab_centers_idx: position_from_patient_zyx(float(vol_zyx.shape[0] - 1), 0.0, centers[idx]),
}
if not plane_builders:
raise ValueError("No valid reconstruction planes selected")
total_slices = sum(v["count"] for v in plane_builders.values())
cache.set(
progress_key,
{"current": 0, "total": total_slices, "message": "Reconstruction started"},
timeout=60 * 60,
)
processed = 0
created_series = []
for plane_norm, cfg in plane_builders.items():
recon_series = atlas_views._create_series_derivative(series, f"Recon {plane_norm.title()} ({source_plane.title()} src)")
recon_series.series_instance_uid = generate_uid()
recon_series.save(update_fields=["series_instance_uid"])
now = datetime.utcnow()
date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S.%f")[:13]
for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx)
ds_new = copy.deepcopy(template_ds)
arr2d = np.asarray(arr2d, dtype=dicom_items[0][2].dtype)
ds_new.Rows = int(arr2d.shape[0])
ds_new.Columns = int(arr2d.shape[1])
ds_new.InstanceNumber = idx + 1
ds_new.SOPInstanceUID = generate_uid()
ds_new.SeriesInstanceUID = recon_series.series_instance_uid
ds_new.SeriesDescription = recon_series.description or f"Recon {plane_norm.title()}"
ds_new.ImageType = ["DERIVED", "SECONDARY", "MPR"]
ds_new.DerivationDescription = (
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
f"thickness={float(cfg['slice_thickness_out']):.3f}mm; spacing={float(cfg['spacing_between_slices_out']):.3f}mm"
)
ds_new.ContentDate = date_str
ds_new.ContentTime = time_str
ds_new.InstanceCreationDate = date_str
ds_new.InstanceCreationTime = time_str
ds_new.PixelData = arr2d.tobytes()
ds_new.PixelSpacing = [float(cfg["pixel_spacing_out"][0]), float(cfg["pixel_spacing_out"][1])]
ds_new.ImageOrientationPatient = cfg["image_orientation_out"]
ipp = cfg["position_fn"](idx)
ds_new.ImagePositionPatient = ipp
ds_new.SliceThickness = float(cfg["slice_thickness_out"])
ds_new.SpacingBetweenSlices = float(cfg["spacing_between_slices_out"])
try:
normal = np.asarray(cfg["plane_normal_out"], dtype=float)
ds_new.SliceLocation = float(np.dot(np.asarray(ipp, dtype=float), normal))
except Exception:
pass
out_io = io.BytesIO()
ds_new.save_as(out_io, write_like_original=False)
out_io.seek(0)
recon_image = SeriesImage(
series=recon_series,
position=idx + 1,
upload_filename=f"recon_{plane_norm}_{idx + 1}.dcm",
)
recon_image.image.save(
f"recon_{plane_norm}_{recon_series.pk}_{idx + 1}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
recon_image.save()
processed += 1
cache.set(
progress_key,
{
"current": processed,
"total": total_slices,
"message": f"Generating {plane_norm} reconstruction ({processed}/{total_slices})",
},
timeout=60 * 60,
)
created_series.append(
{
"id": recon_series.pk,
"url": recon_series.get_absolute_url(),
"description": recon_series.description or str(recon_series.pk),
}
)
logger.info(
"Reconstruction task complete for series {} with {} outputs",
series.pk,
len(created_series),
)
cache.set(
progress_key,
{
"current": total_slices,
"total": total_slices,
"message": "Reconstruction complete",
},
timeout=10 * 60,
)
return {
"series_id": series.pk,
"created_series": created_series,
"target_spacing": float(requested_slice_spacing) if requested_slice_spacing is not None else None,
"slab_thickness": float(requested_slab_thickness) if requested_slab_thickness is not None else None,
"mode": recon_thickness_mode,
"source_plane": source_plane,
}
+110 -13
View File
@@ -2,24 +2,40 @@
{% partialdef case-series %}
{% for series in case.get_ordered_series %}
<span class="series-block" id="series-block-{{ series.pk }}">
<span class="series-block series-block-interactive" id="series-block-{{ series.pk }}" data-series-id="{{series.pk}}">
<span>
<span class="series-block-series-number">Series {{ forloop.counter }}:</span><br>
<a href="{% url 'atlas:series_detail' pk=series.pk %}">
<a href="{% url 'atlas:series_detail' pk=series.pk %}" class="series-block-title-link">
{{series.get_block}}
</a>
<br>
<button type="button"
class="btn btn-outline-primary btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</button>
<input type="checkbox" class="hide" name="series-ids" value="{{series.pk}}">
<span class="series-block-popup-link">
<a href="#"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">Popup</a>
</span>
<div class="series-block-actions mt-2 small">
<button type="button"
class="btn btn-outline-primary btn-xs btn-sm select-series-btn"
style="display: none;"
data-series-id="{{series.pk}}">
Select
</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
</button>
<a href="{% url 'atlas:series_detail' pk=series.pk %}"
class="btn btn-outline-secondary btn-xs btn-sm"
title="Open series page">
<i class="bi bi-box-arrow-up-right"></i> Page
</a>
<span class="series-block-popup-link ms-1">
<a href="#"
class="btn btn-outline-secondary btn-xs btn-sm"
onclick="return window.create_popup_window('/atlas/series/{{series.pk}}', 'Series')">
<i class="bi bi-window"></i> Popup
</a>
</span>
</div>
</span>
</span>
{% endfor %}
@@ -162,6 +178,57 @@
border: 1px solid rgba(0,0,0,0.06);
text-align: left;
}
/* Enhanced series block with interactive features */
.series-block-interactive {
transition: all 0.2s ease;
cursor: pointer;
}
.series-block-interactive:hover {
box-shadow: 0 2px 8px rgba(13, 110, 253, 0.15);
border-color: rgba(13, 110, 253, 0.3);
}
.series-block-actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.5rem;
}
.series-block-actions .btn {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
.series-block-actions .btn-xs {
min-width: auto;
}
/* Also support dark theme when site sets a theme attribute/class */
.atlas[data-bs-theme="dark"] .series-block,
[data-bs-theme="dark"] .series-block,
body[data-bs-theme="dark"] .series-block,
body.bg-dark .series-block,
.bg-dark .series-block,
.dark .series-block,
.theme-dark .series-block {
background: #071018 !important;
border-color: rgba(255,255,255,0.03) !important;
color: #e6eef8 !important;
}
.atlas[data-bs-theme="dark"] .series-block-interactive:hover,
[data-bs-theme="dark"] .series-block-interactive:hover,
body[data-bs-theme="dark"] .series-block-interactive:hover,
body.bg-dark .series-block-interactive:hover,
.bg-dark .series-block-interactive:hover,
.dark .series-block-interactive:hover,
.theme-dark .series-block-interactive:hover {
border-color: rgba(158, 197, 255, 0.3) !important;
box-shadow: 0 2px 8px rgba(158, 197, 255, 0.1) !important;
}
/* Also support dark theme when site sets a theme attribute/class */
.atlas[data-bs-theme="dark"] .series-block,
[data-bs-theme="dark"] .series-block,
@@ -1272,6 +1339,36 @@
}
})();
// Series viewer button handler: open series in viewer or navigate to series page
(function initSeriesViewerButtons() {
if (window.__seriesViewerButtonsBound) return;
window.__seriesViewerButtonsBound = true;
document.querySelectorAll('.series-open-viewer-btn').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const seriesId = this.getAttribute('data-series-id');
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 }));
}
// 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);
});
});
})();
});
@@ -101,39 +101,56 @@
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div>
{% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
<div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %}
<div class="d-flex align-items-center gap-2 py-1 px-2 mb-1 rounded bg-body-secondary">
<span class="text-muted fw-semibold small me-auto">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-sidebar-btn" title="Move viewer to sidebar">
<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-popout-btn" title="Open viewer in a separate window">
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>
</button>
</div>
<div id="viewer-moveable-home">
<div id="viewer-moveable">
<div id="viewer-container" class="viewer-resizable-container">
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</div>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
<div class="viewer-resize-grip-inner"></div>
</div>
</div>
</div>
{% endif %}
{% if collection.show_ohif_viewer %}
<div{% if collection.show_built_in_viewer %} class="mt-3"{% endif %}>
{% if question_completed %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% else %}
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div class="mt-2">
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
{% if collection.show_ohif_viewer_link %}
<div>
{% if question_completed %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% else %}
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
{% endif %}
</div>
{% endif %}
{% if collection.show_built_in_viewer %}
<details id="dicom-viewer-details" open>
<summary>Viewer</summary>
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
data-auto-cache-stack="false"
data-named-stacks='{{casedetail.get_case_named_stacks}}'
></div>
</details>
{% comment %} <div class="pre-whitespace multi-image-block">
<details open>
<summary>
@@ -165,8 +182,6 @@
</div>
</div> {% endcomment %}
{% else %}
{% endif %}
{% if show_discussion and case.discussion %}
<details class="card mb-3">
@@ -335,6 +350,14 @@
</div>
</aside>
<aside id="viewerSidebar" class="viewer-sidebar" aria-label="Viewer sidebar">
<div class="viewer-sidebar-header">
<span class="fw-semibold">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="viewer-sidebar-close-btn">Close</button>
</div>
<div id="viewerSidebarBody" class="viewer-sidebar-body"></div>
</aside>
<style>
label {
vertical-align: top;
@@ -443,6 +466,132 @@
overflow: auto;
flex: 1 1 auto;
}
.viewer-resizable-container {
position: relative;
overflow: hidden;
height: clamp(560px, 78vh, 920px);
min-height: 320px;
max-height: 85vh;
resize: vertical;
}
.viewer-resizable-container .dicom-viewer-root {
width: 100% !important;
height: 100% !important;
min-height: 0;
max-height: none !important;
}
#viewer-container {
width: 100%;
}
#viewer-container #main_viewer {
box-sizing: border-box;
}
@media (max-width: 991.98px) {
.viewer-resizable-container {
height: clamp(360px, 60vh, 640px);
min-height: 280px;
}
}
.viewer-resize-handle {
height: 10px;
background: var(--bs-secondary-bg);
border: 1px solid var(--bs-border-color);
border-top: none;
border-radius: 0 0 4px 4px;
cursor: ns-resize;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
touch-action: none;
display: none;
}
.viewer-resize-handle:hover {
background: var(--bs-tertiary-bg);
}
.viewer-resize-grip-inner {
width: 40px;
height: 3px;
background: var(--bs-secondary-color);
border-radius: 2px;
opacity: 0.4;
}
.viewer-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(850px, 95vw);
min-width: 360px;
max-width: 95vw;
height: 100vh;
background: var(--bs-body-bg);
border-left: 1px solid var(--bs-border-color);
box-shadow: -8px 0 18px rgba(0, 0, 0, 0.35);
z-index: 1045;
transform: translateX(100%);
transition: transform 0.2s ease-in-out;
display: flex;
flex-direction: column;
resize: horizontal;
overflow: auto;
}
.viewer-sidebar.open {
transform: translateX(0);
}
.viewer-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--bs-border-color);
background: var(--bs-secondary-bg);
flex-shrink: 0;
}
.viewer-sidebar-body {
flex: 1 1 auto;
overflow: hidden;
display: flex;
flex-direction: column;
}
.viewer-sidebar-body #viewer-moveable {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.viewer-sidebar-body #viewer-container {
flex: 1 1 auto;
height: auto !important;
}
.viewer-sidebar-body .viewer-resize-handle {
display: none;
}
body.viewer-sidebar-open {
margin-right: var(--viewer-sidebar-width, min(850px, 95vw));
transition: margin-right 0.2s ease-in-out;
}
@media (max-width: 991.98px) {
body.viewer-sidebar-open {
margin-right: 0;
}
}
</style>
{% endblock %}
@@ -823,4 +972,219 @@
</script>
<script type="text/javascript">
(function () {
function initViewerControls() {
var COLL_PK = '{{ collection.pk }}';
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK;
var panel = document.getElementById('viewer-panel');
var moveableHome = document.getElementById('viewer-moveable-home');
var moveable = document.getElementById('viewer-moveable');
var container = document.getElementById('viewer-container');
var resizeHandle = document.getElementById('viewer-resize-handle');
var sidebarEl = document.getElementById('viewerSidebar');
var sidebarBody = document.getElementById('viewerSidebarBody');
var sidebarBtn = document.getElementById('viewer-sidebar-btn');
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
var popoutBtn = document.getElementById('viewer-popout-btn');
var mainViewerEl = document.getElementById('main_viewer');
var rootStyle = document.documentElement.style;
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
return;
}
if (panel.dataset.viewerControlsBound === '1') {
return;
}
panel.dataset.viewerControlsBound = '1';
var savedH = localStorage.getItem(HEIGHT_KEY);
if (savedH && !isNaN(parseInt(savedH, 10))) {
container.style.height = parseInt(savedH, 10) + 'px';
}
function applySidebarWidth(px) {
if (!sidebarEl || !px || isNaN(px)) return;
var clamped = Math.max(360, Math.min(window.innerWidth * 0.95, Math.round(px)));
sidebarEl.style.width = clamped + 'px';
rootStyle.setProperty('--viewer-sidebar-width', clamped + 'px');
}
var savedW = localStorage.getItem(SIDEBAR_W_KEY);
if (savedW && !isNaN(parseInt(savedW, 10))) {
applySidebarWidth(parseInt(savedW, 10));
} else if (sidebarEl) {
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
}
if (sidebarEl && 'ResizeObserver' in window) {
var sidebarResizeObserver = new ResizeObserver(function () {
var width = Math.round(sidebarEl.getBoundingClientRect().width);
rootStyle.setProperty('--viewer-sidebar-width', width + 'px');
localStorage.setItem(SIDEBAR_W_KEY, width);
});
sidebarResizeObserver.observe(sidebarEl);
}
var persistHeight = function () {
localStorage.setItem(HEIGHT_KEY, Math.round(container.getBoundingClientRect().height));
};
if ('ResizeObserver' in window) {
var ro = new ResizeObserver(function () {
persistHeight();
});
ro.observe(container);
} else {
window.addEventListener('mouseup', persistHeight);
}
if (resizeHandle) {
resizeHandle.addEventListener('pointerdown', function (e) {
e.preventDefault();
});
}
function openViewerSidebar() {
if (!sidebarEl || !sidebarBody) return;
sidebarBody.appendChild(moveable);
sidebarEl.classList.add('open');
document.body.classList.add('viewer-sidebar-open');
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Close sidebar</span>';
sidebarBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
localStorage.setItem(MODE_KEY, 'sidebar');
}
function closeViewerSidebar() {
if (!sidebarEl) return;
moveableHome.appendChild(moveable);
sidebarEl.classList.remove('open');
document.body.classList.remove('viewer-sidebar-open');
if (sidebarBtn) {
sidebarBtn.innerHTML = '<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>';
sidebarBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
localStorage.removeItem(MODE_KEY);
}
if (sidebarBtn) {
sidebarBtn.addEventListener('click', function () {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
} else {
openViewerSidebar();
}
});
}
if (sidebarCloseBtn) {
sidebarCloseBtn.addEventListener('click', closeViewerSidebar);
}
var bc = null;
try { bc = new BroadcastChannel('viva_load'); } catch (e) {}
function getViewerPayload() {
var namedStacks = mainViewerEl.getAttribute('data-named-stacks');
var images = mainViewerEl.getAttribute('data-images');
var viewerState = window.exportViewerState_main_viewer ? window.exportViewerState_main_viewer() : '';
var annotations = window.exportAnnotations_main_viewer ? window.exportAnnotations_main_viewer() : '';
return {
type: 'open',
namedStacks: namedStacks || '',
images: images || '',
viewerstate: viewerState || '',
annotations: annotations || ''
};
}
function broadcastViewerState(payload) {
if (!payload) return;
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
if (bc) {
try { bc.postMessage(payload); } catch (e) {}
}
}
function showPopoutPlaceholder() {
var existing = panel.querySelector('.viewer-popout-placeholder');
if (existing) return;
var placeholder = document.createElement('div');
placeholder.className = 'alert alert-secondary small py-2 mb-0 mt-1 d-flex align-items-center gap-2 viewer-popout-placeholder';
placeholder.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Viewer is open in a separate window. <button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="viewer-popout-bring-back">Bring back</button>';
panel.insertBefore(placeholder, moveableHome);
moveableHome.style.display = 'none';
document.getElementById('viewer-popout-bring-back').addEventListener('click', closePopout);
}
function hidePopoutPlaceholder() {
var placeholder = panel.querySelector('.viewer-popout-placeholder');
if (placeholder) placeholder.remove();
moveableHome.style.display = '';
}
function openPopout() {
if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar();
}
var payload = getViewerPayload();
broadcastViewerState(payload);
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
setTimeout(function () { broadcastViewerState(payload); }, 700);
localStorage.setItem(MODE_KEY, 'popout');
showPopoutPlaceholder();
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
function closePopout() {
hidePopoutPlaceholder();
localStorage.removeItem(MODE_KEY);
if (popoutBtn) {
popoutBtn.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>';
popoutBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
}
}
if (popoutBtn) {
popoutBtn.addEventListener('click', function () {
if (localStorage.getItem(MODE_KEY) === 'popout') {
closePopout();
} else {
openPopout();
}
});
}
var savedMode = localStorage.getItem(MODE_KEY);
if (savedMode === 'sidebar') {
openViewerSidebar();
} else if (savedMode === 'popout' && popoutBtn) {
showPopoutPlaceholder();
setTimeout(function () { broadcastViewerState(getViewerPayload()); }, 300);
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initViewerControls);
} else {
initViewerControls();
}
})();
</script>
{% endblock js %}
@@ -1,47 +1,143 @@
{% extends 'atlas/base.html' %}
{% block content %}
<h2>Collection: {{exam}}</h2>
<style>
.take-start-hero {
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, rgba(13, 110, 253, 0.22), rgba(25, 135, 84, 0.2));
border-radius: .75rem;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
.take-start-card {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
border-radius: .75rem;
padding: 1rem;
}
.take-start-meta {
display: grid;
grid-template-columns: 9rem 1fr;
gap: .35rem .75rem;
}
.take-start-meta dt {
font-weight: 600;
color: var(--bs-secondary-color);
margin: 0;
}
.take-start-meta dd {
margin: 0;
}
</style>
<section class="take-start-hero">
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-between">
<h2 class="mb-0">Review: {{ collection.name }}</h2>
<span class="badge bg-light text-dark">Review Collection</span>
</div>
<p class="mb-0 mt-2 text-body-secondary">Open the collection and browse completed cases.</p>
</section>
{% if collection.start_screen_content %}
<div class="card mb-3">
<div class="card-body">
{{ collection.start_screen_content|safe }}
</div>
</div>
<section class="take-start-card mb-3">
{{ collection.start_screen_content|safe }}
</section>
{% endif %}
{% if collection.start_screen_resources.exists %}
<div class="card mb-3">
<div class="card-body">
<h6>Resources</h6>
{% for resource in collection.start_screen_resources.all %}
<div class="small mb-1">{{ resource.get_display }}</div>
{% if start_screen_resources %}
<section class="take-start-card mb-3">
<h6 class="mb-2">Resources</h6>
<div class="d-grid gap-2">
{% for resource in start_screen_resources %}
<div class="small">{{ resource.get_display }}</div>
{% endfor %}
</div>
</div>
</section>
{% endif %}
{% comment %} <ul>
{% for case in collection.cases.all %}
<li><a href="{% url 'atlas:collection_case_view_take_user' pk=collection.id case_number=forloop.counter0 %}">Case {{forloop.counter}}</a></li>
{% endfor %}
</ul> {% endcomment %}
{% if request.user.is_authenticated and valid_user %}
<section class="take-start-card">
<dl class="take-start-meta mb-3">
<dt>User</dt>
<dd>{{ request.user }}</dd>
User: {{request.user}}<br/>
{% if cid_exam %}
<dt>Started</dt>
<dd>{{ cid_exam.start_time }}</dd>
{% if cid_exam %}
Started: {{cid_exam.start_time}} <br/>
{% if cid_exam.completed %}
Ended: {{cid_exam.end_time}} <br/>
{% if cid_exam.completed %}
<dt>Ended</dt>
<dd>{{ cid_exam.end_time }}</dd>
{% endif %}
{% endif %}
</dl>
<div class="d-flex flex-wrap gap-2">
<a class="btn btn-primary"
href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
Browse
</a>
{% if cid_exam.completed %}
<a class="btn btn-outline-secondary"
href="{% url 'atlas:collection_take_overview_user' pk=collection.pk %}">Overview</a>
{% endif %}
</div>
</section>
{% if collection.show_results_sharing_information and sharing_summary %}
<section class="take-start-card mt-3">
<div class="fw-semibold mb-2 small text-body-secondary text-uppercase" style="letter-spacing:.05em;">Who can see your results</div>
<ul class="list-unstyled mb-0 small">
<li class="mb-1">
<span class="badge bg-secondary me-1">You</span>
Always visible to you.
</li>
{% for u in sharing_summary.always_visible_to %}
<li class="mb-1">
<span class="badge bg-secondary me-1">Author/Marker</span>
{{ u.get_full_name|default:u.username }}
{% if u.email %}<span class="text-muted">&lt;{{ u.email }}&gt;</span>{% endif %}
</li>
{% endfor %}
{% if sharing_summary.supervisor_always %}
<li class="mb-1">
<span class="badge bg-info text-dark me-1">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
(always shared - collection setting)
{% else %}
Your supervisor, if one is linked to your profile (collection setting always on).
{% endif %}
</li>
{% elif sharing_summary.supervisor_opt_in %}
<li class="mb-1">
<span class="badge bg-outline-secondary me-1" style="border:1px solid var(--bs-secondary);color:var(--bs-secondary);">Supervisor</span>
{% if sharing_summary.supervisor %}
{{ sharing_summary.supervisor.get_full_name|default:sharing_summary.supervisor.username }}
- you can choose to share with them after starting.
{% else %}
You have a supervisor linked but they do not have a login account yet.
{% endif %}
</li>
{% endif %}
{% if not sharing_summary.always_visible_to and not sharing_summary.supervisor_always and not sharing_summary.supervisor_opt_in %}
<li class="text-muted fst-italic">Only you can see your results for this collection.</li>
{% endif %}
</ul>
</section>
{% endif %}
{% else %}
<section class="take-start-card">
<p class="mb-0">
Please sign in to access this review collection.
<a href="{% url 'login' %}?next={% url 'atlas:collection_take_start' pk=collection.pk %}">Log in here</a>.
</p>
</section>
{% endif %}
<a href="{% url 'atlas:collection_case_view_take_user' pk=collection.pk case_number=0 %}">
{% if cid_exam.completed %}
<button>Review</button>
{% else %}
<button>Start</button>
{% endif %}
</a>
{% endblock %}
@@ -57,7 +57,7 @@
<div class="d-flex flex-wrap justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">
Case {{ forloop.counter }}{% if collection.show_title_post %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %}
Case {{ forloop.counter }}{% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}: {{ question.case.title|default:question.case.pk|truncatechars:70 }}{% endif %}
{% if answer and answer.completed or cid_user_exam.completed %}
<span class="badge bg-success-subtle text-success-emphasis ms-2">Completed</span>
{% elif answer %}
@@ -79,7 +79,7 @@
{% endif %}
{% endif %}
</div>
{% if collection.show_title_post %}
{% if collection.show_title_post and answer and answer.completed or collection.show_title_post and cid_user_exam.completed %}
<div class="small text-muted">
{{ question.case }}
</div>
@@ -5,9 +5,11 @@
class="btn btn-outline-warning btn-sm"
hx-get="{% url 'atlas:series_split_by_tag' pk=series.pk %}?analyze=1"
hx-target="#series-split-by-tag-panel"
hx-swap="outerHTML">
hx-swap="outerHTML"
hx-indicator="#series-split-analysis-indicator">
Analyze split options
</button>
<span id="series-split-analysis-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
</div>
{% if analyze_requested %}
@@ -16,7 +18,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#split-by-tag-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#series-split-submit-indicator">
{% csrf_token %}
<div class="input-group input-group-sm">
<select name="dicom_tag" class="form-select form-select-sm">
@@ -27,6 +30,7 @@
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
</div>
</form>
<span id="series-split-submit-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator" role="status" aria-hidden="true"></span>
<p class="small text-muted mb-0">Only tags that can produce more than one output series are shown.</p>
{% else %}
<div class="alert alert-secondary mb-0 small">
@@ -11,9 +11,11 @@
class="btn btn-outline-info btn-sm"
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-panel"
hx-swap="outerHTML">
hx-swap="outerHTML"
hx-indicator="#series-tag-consistency-indicator">
Re-run analysis
</button>
<span id="series-tag-consistency-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
{% if analyze_requested %}
<span class="small text-muted">Scanned {{ image_count }} image{{ image_count|pluralize }}</span>
{% endif %}
@@ -57,7 +59,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -99,7 +102,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -143,7 +147,8 @@
action="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-post="{% url 'atlas:series_split_by_tag' pk=series.pk %}"
hx-target="#tag-consistency-split-result"
hx-swap="innerHTML">
hx-swap="innerHTML"
hx-indicator="#tag-consistency-split-indicator">
{% csrf_token %}
<input type="hidden" name="dicom_tag" value="{{ row.tag_name }}">
<button type="submit" class="btn btn-outline-warning btn-sm">Split</button>
@@ -168,6 +173,7 @@
</div>
<div class="modal-footer">
<span id="tag-consistency-split-indicator" class="spinner-border spinner-border-sm text-warning htmx-indicator me-auto" role="status" aria-hidden="true"></span>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
@@ -1,6 +1,17 @@
<div class="alert alert-success">
<strong>Truncate complete.</strong>
<div class="small mt-1">Removed {{ images_removed|length }} images from series {{ series.pk }}.</div>
<div class="small mt-2">
<div>Removed {{ images_removed|length }} images from series {{ series.pk }}.</div>
{% if images_downsampled %}
<div class="mt-1">Downsampled {{ images_downsampled|length }} images (pixel data reduced by 50%).</div>
{% endif %}
{% if remove_empty_dicoms %}
<div class="mt-1">✓ Empty DICOM removal applied</div>
{% endif %}
{% if downsample_pixels %}
<div class="mt-1">✓ Pixel downsampling applied (original hash preserved)</div>
{% endif %}
</div>
</div>
{% if images_removed %}
<details class="mt-2">
@@ -8,3 +19,9 @@
<div class="small mt-1">{{ images_removed|join:", " }}</div>
</details>
{% endif %}
{% if images_downsampled %}
<details class="mt-2">
<summary class="small">Show downsampled image IDs</summary>
<div class="small mt-1">{{ images_downsampled|join:", " }}</div>
</details>
{% endif %}
+559 -70
View File
@@ -30,7 +30,11 @@
<div class="card">
<div class="card-body p-2">
{% with image_url_array_and_count=series.get_image_url_array_and_count %}
<div id="root" class="dicom-viewer-root w-100 viewer-frame-standard" data-images="{{ image_url_array_and_count.0 }}" data-auto-cache-stack=false>
<div id="root"
class="dicom-viewer-root w-100 viewer-frame-standard series-viewer-resizable"
style="height: clamp(640px, 78vh, 1200px); min-height: 640px; resize: vertical; overflow: auto;"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack=false>
</div>
{% endwith %}
</div>
@@ -198,43 +202,36 @@
hx-get="{% url 'atlas:series_tag_consistency' pk=series.pk %}?analyze=1"
hx-target="#series-tag-consistency-modal .modal-content"
hx-swap="innerHTML"
hx-indicator="#series-tag-consistency-load-indicator"
data-bs-toggle="modal"
data-bs-target="#series-tag-consistency-modal">
Analyze tag consistency
</button>
<span id="series-tag-consistency-load-indicator" class="spinner-border spinner-border-sm text-info htmx-indicator" role="status" aria-hidden="true"></span>
{% if can_edit %}
<button class="btn btn-outline-warning btn-sm"
data-bs-toggle="modal"
data-bs-target="#truncate-series-modal">
<i class="bi bi-scissors"></i> Truncate series
</button>
<div class="card mt-2">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="small fw-semibold">Background Task Progress</span>
<div class="d-flex gap-2">
<a class="btn btn-outline-info btn-sm" href="{% url 'atlas:task_overview' %}">Task Overview</a>
<button id="clear-recon-monitors-btn" type="button" class="btn btn-outline-secondary btn-sm">Clear</button>
</div>
</div>
<div class="card-body p-2">
<div id="persistent-recon-tasks" class="d-flex flex-column gap-2">
<div id="persistent-recon-empty" class="small text-muted">No tracked reconstruction tasks yet.</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
{% if can_edit %}
<div class="card">
<div class="card-header">Truncate series</div>
<div class="card-body">
<p class="small">Limit the series to the selected bounds (the rest of the images will be deleted). This action is irreversible on site.</p>
<div class="alert alert-warning"><strong>Warning:</strong> If you have reordered the series this may remove the wrong images.</div>
<div class="mb-2">
<label class="form-label">Start</label>
<div class="input-group">
<input id="lower-truncation-bound-input" type="number" class="form-control form-control-sm" value="1">
<button id="set-lower-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
</div>
</div>
<div class="mb-2">
<label class="form-label">End</label>
<div class="input-group">
<input id="upper-truncation-bound-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}">
<button id="set-upper-truncation-bound-button" class="btn btn-outline-secondary btn-sm">Set</button>
</div>
</div>
<div class="d-flex gap-2">
<button id="truncate-test-button" class="btn btn-sm btn-outline-primary">Test truncate</button>
<button id="truncate-button" class="btn btn-sm btn-danger">Truncate series</button>
</div>
<div id="truncate-output" class="mt-2"></div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -252,56 +249,552 @@
</div>
</div>
<!-- Series Optimization Modal -->
{% if can_edit %}
<div id="truncate-series-modal" class="modal modal-blur fade" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Series Optimization</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header fw-semibold">Viewer / Compare Preview</div>
<div class="card-body p-2 d-flex flex-column gap-2">
<div id="truncate_viewer" class="dicom-viewer-root w-100"
style="height: 460px; min-height: 360px; resize: vertical; overflow: auto; background: #222;"
data-images="{{ image_url_array_and_count.0 }}"
data-auto-cache-stack="false">
</div>
<div class="small text-muted">Downsample compare preview loads into this viewer as stack options.</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header fw-semibold">Operations</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Destructive actions cannot be undone.
</div>
<form id="series-optimize-form"
hx-post="{% url 'atlas:series_optimize' series_id=series.pk %}"
hx-target="#series-optimize-feedback"
hx-swap="innerHTML"
hx-indicator="#series-optimize-indicator">
{% csrf_token %}
<input type="hidden" name="start" id="opt-start-hidden" value="1">
<input type="hidden" name="end" id="opt-end-hidden" value="{{ image_url_array_and_count.1 }}">
<div class="mb-3">
<label class="form-label">Start image</label>
<div class="input-group">
<input id="truncate-lower-input" type="number" class="form-control form-control-sm" value="1" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-lower-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">End image</label>
<div class="input-group">
<input id="truncate-upper-input" type="number" class="form-control form-control-sm" value="{{image_url_array_and_count.1}}" min="1" max="{{image_url_array_and_count.1}}">
<button id="truncate-set-upper-btn" class="btn btn-outline-secondary btn-sm" type="button">Set from viewer</button>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button id="truncate-test-modal-btn" class="btn btn-sm btn-outline-primary" type="button">Test preview</button>
<button id="truncate-apply-btn" class="btn btn-sm btn-danger" type="submit" name="operation" value="truncate">Apply truncate</button>
<button class="btn btn-sm btn-outline-warning" type="submit" name="operation" value="remove_empty">Remove empty DICOMs</button>
</div>
<div id="truncate-progress" class="d-none mb-3">
<div class="small text-warning mb-1">Applying truncate. Please wait...</div>
<div class="progress" style="height: 8px;">
<div class="progress-bar progress-bar-striped progress-bar-animated bg-warning" style="width: 100%" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
</div>
</div>
<div class="small text-muted mb-3">Truncate is a dedicated step. Downsample and reconstruction always run on the full active series.</div>
<hr>
<div class="mb-3">
<label for="downsample-pct" class="form-label">Downsample percentage</label>
<select id="downsample-pct" name="downsample_pct" class="form-select form-select-sm">
<option value="25">25% reduction</option>
<option value="40">40% reduction</option>
<option value="50" selected>50% reduction</option>
<option value="60">60% reduction</option>
<option value="75">75% reduction</option>
</select>
<small class="text-muted">Original hash is preserved after downsampling.</small>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<button class="btn btn-sm btn-outline-info" type="submit" name="operation" value="downsample_preview">Preview downsample compare</button>
<button class="btn btn-sm btn-outline-success" type="submit" name="operation" value="downsample">Apply downsample</button>
</div>
<hr>
<div class="mb-2">
<div class="fw-semibold small mb-2">Generate reconstructions</div>
<div class="d-flex flex-wrap gap-3 mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-axial" name="recon_planes" value="axial" checked>
<label class="form-check-label" for="recon-axial">Axial</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-coronal" name="recon_planes" value="coronal" checked>
<label class="form-check-label" for="recon-coronal">Coronal</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="recon-sagittal" name="recon_planes" value="sagittal" checked>
<label class="form-check-label" for="recon-sagittal">Sagittal</label>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label small mb-1" for="recon-slice-thickness">Slice thickness (mm)</label>
<input type="number" step="0.1" min="0.1" id="recon-slice-thickness" name="recon_slice_thickness" class="form-control form-control-sm" placeholder="e.g. 1.0">
</div>
<div class="col-6">
<label class="form-label small mb-1" for="recon-slice-spacing">Slice spacing (mm)</label>
<input type="number" step="0.1" min="0.1" id="recon-slice-spacing" name="recon_slice_spacing" class="form-control form-control-sm" placeholder="e.g. 1.0">
</div>
<div class="col-12">
<label class="form-label small mb-1" for="recon-thickness-mode">Thickness merge mode</label>
<select id="recon-thickness-mode" name="recon_thickness_mode" class="form-select form-select-sm">
<option value="mean" selected>Mean (default)</option>
<option value="max">Max intensity</option>
<option value="min">Min intensity</option>
</select>
<small class="text-muted">Thickness controls slab merge depth. Spacing controls distance between generated slices.</small>
</div>
</div>
<div class="mt-2">
<button class="btn btn-sm btn-outline-primary" type="submit" name="operation" value="reconstruct">Generate recon series</button>
</div>
</div>
</form>
</div>
<div class="card-footer d-flex align-items-center gap-2">
<span id="series-optimize-indicator" class="spinner-border spinner-border-sm text-primary htmx-indicator" role="status" aria-hidden="true"></span>
<small class="text-muted">Processing operations may take time for large stacks.</small>
<button type="button" class="btn btn-secondary btn-sm ms-auto" data-bs-dismiss="modal">Close</button>
</div>
<div class="px-3 pb-3">
<div id="series-optimize-feedback" class="small"></div>
<div id="truncate-modal-output" class="alert alert-info d-none mt-2 mb-0" role="alert"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<script>
$(document).ready(function () {
const apiKey = "root"
const truncateApiKey = "truncate_viewer";
const seriesPk = {{ series.pk }};
const totalImages = {{ image_url_array_and_count.1 }};
const viewerHeightKey = `series_viewer_height_${seriesPk}`;
const truncateViewerHeightKey = `truncate_viewer_height_${seriesPk}`;
const reconTaskStorageKey = `series_recon_tasks_${seriesPk}`;
$("#set-lower-truncation-bound-button").click(() => {
$("#lower-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#set-upper-truncation-bound-button").click(() => {
$("#upper-truncation-bound-input").val(getCurrentStackPosition_root(0) + 1);
});
$("#truncate-test-button").click(() => {
const lower = parseInt($("#lower-truncation-bound-input").val()) - 1;
const upper = parseInt($("#upper-truncation-bound-input").val()) - 1;
const ok = window[`truncateStack_${apiKey}`](lower, upper);
if (ok) {
$("#truncate-output").text("The truncated images are shown above.");
// Restore viewer height from localStorage
function restoreViewerHeight() {
const saved = localStorage.getItem(viewerHeightKey);
if (saved) {
const viewer = document.getElementById('root');
if (viewer) {
viewer.style.height = saved;
}
}
});
$("#truncate-button").click(() => {
lower_bound = parseInt($("#lower-truncation-bound-input").val()) - 1;
upper_bound = parseInt($("#upper-truncation-bound-input").val()) - 1;
}
if (lower_bound >= upper_bound) {
alert("The lower bound must be less than the upper bound.");
// Save viewer height to localStorage when resized
function persistViewerHeight() {
const viewer = document.getElementById('root');
if (viewer) {
const height = window.getComputedStyle(viewer).height;
localStorage.setItem(viewerHeightKey, height);
}
}
// Listen for resize events on the viewer
const viewer = document.getElementById('root');
if (viewer) {
const resizeObserver = new ResizeObserver(() => {
persistViewerHeight();
});
resizeObserver.observe(viewer);
restoreViewerHeight();
requestAnimationFrame(() => {
try {
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
window.dispatchEvent(new Event('resize'));
} catch (e) {
console.warn('Viewer resize dispatch failed', e);
}
});
}
function syncOptimizeBounds() {
const startInput = document.getElementById('truncate-lower-input');
const endInput = document.getElementById('truncate-upper-input');
const startHidden = document.getElementById('opt-start-hidden');
const endHidden = document.getElementById('opt-end-hidden');
if (!startInput || !endInput || !startHidden || !endHidden) {
return;
}
startHidden.value = startInput.value;
endHidden.value = endInput.value;
}
function updateBoundsInputs(startValue, endValue) {
const startInput = document.getElementById('truncate-lower-input');
const endInput = document.getElementById('truncate-upper-input');
if (!startInput || !endInput) {
return;
}
startInput.value = String(startValue);
endInput.value = String(endValue);
startInput.max = String(endValue);
endInput.max = String(endValue);
syncOptimizeBounds();
}
function getStoredReconTasks() {
try {
const raw = localStorage.getItem(reconTaskStorageKey);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
function setStoredReconTasks(items) {
localStorage.setItem(reconTaskStorageKey, JSON.stringify(items));
}
function upsertStoredReconTask(taskId, statusUrl) {
const current = getStoredReconTasks();
const existing = current.find((t) => t.taskId === taskId);
if (existing) {
existing.statusUrl = statusUrl;
existing.addedAt = Date.now();
} else {
current.unshift({ taskId, statusUrl, addedAt: Date.now() });
}
setStoredReconTasks(current.slice(0, 25));
}
function removeStoredReconTask(taskId) {
const filtered = getStoredReconTasks().filter((t) => t.taskId !== taskId);
setStoredReconTasks(filtered);
}
function createPersistentTaskMonitor(taskId, statusUrl) {
const wrap = document.getElementById('persistent-recon-tasks');
const empty = document.getElementById('persistent-recon-empty');
if (!wrap) {
return;
}
if(confirm(`Trucated series. This will leave ${upper_bound - lower_bound + 1} images.`) == true) {
const truncateTemplate = `{% url 'atlas:series_truncate' series.id 0 0 %}`; // e.g. /atlas/series/920/truncate/0/0/
const truncateUrl = truncateTemplate.replace('/0/0/', `/${lower_bound}/${upper_bound}/`);
htmx.ajax("GET", truncateUrl, "#truncate-output")
const cardId = `persistent-recon-card-${taskId}`;
if (document.getElementById(cardId)) {
return;
}
empty?.classList.add('d-none');
})
const card = document.createElement('div');
card.id = cardId;
card.className = 'border rounded p-2 bg-body-tertiary';
const statusId = `persistent-recon-status-${taskId}`;
const statusUrlWithTarget = `${statusUrl}?target_id=${statusId}`;
card.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-1">
<div class="small fw-semibold">Recon task ${taskId}</div>
<button type="button" class="btn btn-outline-secondary btn-sm" data-remove-task-id="${taskId}">Hide</button>
</div>
<div id="${statusId}" hx-get="${statusUrlWithTarget}" hx-trigger="load, every 4s" hx-swap="outerHTML">
<div class="small text-muted">Loading task status...</div>
</div>
`;
wrap.prepend(card);
card.querySelector('[data-remove-task-id]')?.addEventListener('click', () => {
removeStoredReconTask(taskId);
card.remove();
if (!wrap.querySelector('[id^="persistent-recon-card-"]')) {
empty?.classList.remove('d-none');
}
});
if (window.htmx) {
window.htmx.process(card);
}
}
function renderStoredReconTaskMonitors() {
const tasks = getStoredReconTasks();
tasks.forEach((task) => {
if (task?.taskId && task?.statusUrl) {
createPersistentTaskMonitor(task.taskId, task.statusUrl);
}
});
}
function getTruncateViewerElement() {
return document.getElementById(truncateApiKey);
}
function persistTruncateViewerHeight() {
const modalViewer = getTruncateViewerElement();
if (!modalViewer) {
return;
}
const height = window.getComputedStyle(modalViewer).height;
localStorage.setItem(truncateViewerHeightKey, height);
}
function applyTruncateViewerHeight(el) {
if (!el) {
return;
}
const saved = localStorage.getItem(truncateViewerHeightKey);
if (saved) {
el.style.height = saved;
}
}
function observeTruncateViewerResize() {
const modalViewer = getTruncateViewerElement();
if (!modalViewer) {
return;
}
applyTruncateViewerHeight(modalViewer);
const observer = new ResizeObserver(() => {
persistTruncateViewerHeight();
});
observer.observe(modalViewer);
}
// Modal optimization handlers
const truncateModal = document.getElementById('truncate-series-modal');
if (truncateModal) {
truncateModal.addEventListener('show.bs.modal', function () {
observeTruncateViewerResize();
setTimeout(() => {
try {
if (window.mountDicomViewers) {
window.mountDicomViewers();
}
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
} catch (e) {
console.warn('Modal viewer init failed', e);
}
}, 200);
});
function getTruncateViewerApi(baseName) {
const fn = window[`${baseName}_${truncateApiKey}`];
return typeof fn === 'function' ? fn : null;
}
document.getElementById('truncate-set-lower-btn')?.addEventListener('click', (e) => {
e.preventDefault();
const getStackPosition = getTruncateViewerApi('getCurrentStackPosition');
if (getStackPosition) {
const current = getStackPosition(0);
const pos = Number.isFinite(current) ? current + 1 : 1;
document.getElementById('truncate-lower-input').value = pos;
syncOptimizeBounds();
}
});
document.getElementById('truncate-set-upper-btn')?.addEventListener('click', (e) => {
e.preventDefault();
const getStackPosition = getTruncateViewerApi('getCurrentStackPosition');
if (getStackPosition) {
const current = getStackPosition(0);
const pos = Number.isFinite(current) ? current + 1 : 1;
document.getElementById('truncate-upper-input').value = pos;
syncOptimizeBounds();
}
});
document.getElementById('truncate-lower-input')?.addEventListener('input', syncOptimizeBounds);
document.getElementById('truncate-upper-input')?.addEventListener('input', syncOptimizeBounds);
document.getElementById('truncate-test-modal-btn')?.addEventListener('click', (e) => {
e.preventDefault();
const lower = parseInt(document.getElementById('truncate-lower-input').value) - 1;
const upper = parseInt(document.getElementById('truncate-upper-input').value) - 1;
if (lower < 0 || upper >= totalImages || lower > upper) {
alert('Invalid range. Please check your bounds.');
return;
}
const truncateFn = getTruncateViewerApi('truncateStack');
if (truncateFn) {
const ok = truncateFn(lower, upper);
if (ok) {
document.getElementById('truncate-modal-output').innerHTML = `Preview shows images ${lower + 1} to ${upper + 1} (${upper - lower + 1} total).`;
document.getElementById('truncate-modal-output').classList.remove('d-none');
}
} else {
document.getElementById('truncate-modal-output').innerHTML = 'Truncate viewer API not ready yet. Reopen the modal and try again.';
document.getElementById('truncate-modal-output').classList.remove('d-none');
}
});
}
document.body.addEventListener('htmx:configRequest', function (event) {
const form = document.getElementById('series-optimize-form');
if (!form || event.target !== form) {
return;
}
syncOptimizeBounds();
});
document.body.addEventListener('htmx:beforeRequest', function (event) {
const form = document.getElementById('series-optimize-form');
if (!form || event.target !== form) {
return;
}
const op = event.detail?.parameters?.operation || '';
const truncateProgress = document.getElementById('truncate-progress');
const truncateApplyBtn = document.getElementById('truncate-apply-btn');
if (op === 'truncate') {
truncateProgress?.classList.remove('d-none');
if (truncateApplyBtn) {
truncateApplyBtn.disabled = true;
}
}
});
document.body.addEventListener('htmx:afterRequest', function (event) {
const form = document.getElementById('series-optimize-form');
if (!form || event.target !== form) {
return;
}
const truncateProgress = document.getElementById('truncate-progress');
const truncateApplyBtn = document.getElementById('truncate-apply-btn');
truncateProgress?.classList.add('d-none');
if (truncateApplyBtn) {
truncateApplyBtn.disabled = false;
}
});
document.body.addEventListener('htmx:afterSwap', function (event) {
if (event.target && event.target.id === 'series-optimize-feedback') {
const reconPayload = document.getElementById('recon-task-payload');
if (reconPayload) {
const taskId = reconPayload.dataset.taskId;
const statusUrl = reconPayload.dataset.statusUrl;
if (taskId && statusUrl) {
upsertStoredReconTask(taskId, statusUrl);
createPersistentTaskMonitor(taskId, statusUrl);
}
}
const truncatePayload = document.getElementById('truncate-range-payload');
if (truncatePayload) {
const start = parseInt(truncatePayload.dataset.start || '1', 10);
const end = parseInt(truncatePayload.dataset.end || '1', 10);
if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
updateBoundsInputs(start, end);
}
}
const payload = document.getElementById('downsample-compare-payload');
if (!payload) {
return;
}
const namedStacks = payload.dataset.namedStacks;
if (!namedStacks) {
return;
}
const modalViewer = getTruncateViewerElement();
if (!modalViewer) {
return;
}
const existingHeight = window.getComputedStyle(modalViewer).height;
// Recreate the same modal viewer element to force a clean remount with compare stacks.
const fresh = document.createElement('div');
fresh.id = truncateApiKey;
fresh.className = 'dicom-viewer-root w-100';
fresh.style.height = existingHeight;
fresh.style.minHeight = '360px';
fresh.style.resize = 'vertical';
fresh.style.overflow = 'auto';
fresh.style.background = '#222';
fresh.setAttribute('data-auto-cache-stack', 'false');
fresh.setAttribute('data-named-stacks', namedStacks);
modalViewer.replaceWith(fresh);
observeTruncateViewerResize();
try {
if (window.mountDicomViewers) {
window.mountDicomViewers();
}
if (window.resizeDicomViewers) {
window.resizeDicomViewers();
}
} catch (err) {
console.warn('Failed to mount downsample compare viewer', err);
}
}
});
syncOptimizeBounds();
renderStoredReconTaskMonitors();
document.getElementById('clear-recon-monitors-btn')?.addEventListener('click', () => {
setStoredReconTasks([]);
const wrap = document.getElementById('persistent-recon-tasks');
const empty = document.getElementById('persistent-recon-empty');
wrap?.querySelectorAll('[id^="persistent-recon-card-"]')?.forEach((node) => node.remove());
empty?.classList.remove('d-none');
});
$("#add-finding-button").click(() => {
$("#hidden-form").show()
//dicom_element = $(".cornerstone-element").get(0);
//cornerstoneTools.globalImageIdSpecificToolStateManager.clear(dicom_element);
//cornerstone.reset(dicom_element);
////cornerstone.resize(dicom_element, true);
$("#add-finding-button").hide()
$("#clone-finding-button").hide()
//$("#finding-form").empty().append(
// $("#hidden-form form").clone()
//);
});
$("#cancel-add-finding-button").click((e) => {
$("#hidden-form").hide()
@@ -310,11 +803,10 @@
e.preventDefault();
});
$("#reset-viewport-button").click(() => {
resetViewer_root();
});
$(".view-finding-button-3d").each((n, el) => {
$(el).click((e) => {
dicom_element = $(".cornerstone-element").get(0);
@@ -327,16 +819,13 @@
});
});
{% if editing_finding > 0 %}
$("#hidden-form").show()
setTimeout(function() {
$(".view-finding-button-3d[data-findingid={{editing_finding}}]").trigger("click");
}, 1000)
{% endif %}
{% if request.GET.show_finding %}
function show_finding() {
console.log("show finding")
@@ -382,7 +871,7 @@
},
error: function (xhr, errmsg, err) {
console.log(xhr.status + ": " + xhr
.responseText); // provide a bit more info about the error to the console
.responseText);
}
});
});
+200
View File
@@ -0,0 +1,200 @@
{% extends "base.html" %}
{% load static %}
{% load django_htmx %}
{% block title %}
Atlas Task Overview
{% endblock title %}
{% block content %}
<div class="container-fluid py-3 px-3">
<style>
.task-overview-table {
min-width: 1650px;
}
.task-overview-table th,
.task-overview-table td {
white-space: nowrap;
vertical-align: middle;
}
.task-overview-table .task-col,
.task-overview-table .generated-col,
.task-overview-table .error-col,
.task-overview-table .actions-col {
white-space: normal;
}
.task-overview-table .generated-col {
min-width: 220px;
}
</style>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<h2 class="h4 mb-0">Atlas Task Overview</h2>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary btn-sm" href="?status=all">All</a>
<a class="btn btn-outline-warning btn-sm" href="?status=active">Active</a>
<a class="btn btn-outline-success btn-sm" href="?status=completed">Completed</a>
<a class="btn btn-outline-danger btn-sm" href="?status=failed">Failed</a>
</div>
</div>
{% if backend_missing %}
<div class="alert alert-warning">
django_tasks_db backend is not available in this environment. Install and migrate it to see task records.
</div>
{% endif %}
<div class="row g-2 mb-3">
<div class="col-md-4">
<div class="card border-warning">
<div class="card-body py-2">
<div class="small text-muted">Active</div>
<div class="h4 mb-0 text-warning">{{ counts.active }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-success">
<div class="card-body py-2">
<div class="small text-muted">Completed</div>
<div class="h4 mb-0 text-success">{{ counts.completed }}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card border-danger">
<div class="card-body py-2">
<div class="small text-muted">Failed</div>
<div class="h4 mb-0 text-danger">{{ counts.failed }}</div>
</div>
</div>
</div>
</div>
<form method="post" class="mb-3">
{% csrf_token %}
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<select class="form-select form-select-sm" name="task_action" style="max-width: 320px;">
<option value="">Choose task action...</option>
<option value="reset_failed">Reset FAILED to READY</option>
<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>
</select>
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
<div class="form-check ms-2">
<input class="form-check-input" type="checkbox" id="select-all-tasks">
<label class="form-check-label small" for="select-all-tasks">Select all</label>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-striped align-middle task-overview-table">
<thead>
<tr>
<th style="width: 36px;"></th>
<th class="task-col">Task</th>
<th>Status</th>
<th class="generated-col">Generated Series</th>
<th>Queue</th>
<th>Enqueued</th>
<th>Started</th>
<th>Finished</th>
<th>Worker</th>
<th class="error-col">Error</th>
<th class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
{% for task in tasks %}
<tr>
<td>
<input class="form-check-input task-select" type="checkbox" name="task_ids" value="{{ task.id }}">
</td>
<td class="task-col">
<div class="small fw-semibold">{{ task.task_path }}</div>
<div class="small text-muted">{{ task.id }}</div>
</td>
<td>
{% if task.status == "FAILED" %}
<span class="badge bg-danger">{{ task.status }}</span>
{% elif task.status == "SUCCESSFUL" %}
<span class="badge bg-success">{{ task.status }}</span>
{% elif task.status == "RUNNING" or task.status == "READY" %}
<span class="badge bg-warning text-dark">{{ task.status }}</span>
{% else %}
<span class="badge bg-secondary">{{ task.status }}</span>
{% endif %}
</td>
<td class="small generated-col">
{% if task.generated_series %}
{% for series in task.generated_series %}
<a class="d-block" target="_blank" href="{{ series.url }}">{{ series.description }}</a>
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>{{ task.queue_name }}</td>
<td class="small">{{ task.enqueued_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.started_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.finished_at|date:"Y-m-d H:i:s" }}</td>
<td class="small">{{ task.worker_ids }}</td>
<td class="small text-danger error-col">{{ task.exception_class_path|default:"" }}</td>
<td class="small actions-col">
{% if task.traceback %}
<button
class="btn btn-outline-danger btn-sm"
type="button"
data-bs-toggle="collapse"
data-bs-target="#traceback-{{ task.id }}"
aria-expanded="false"
aria-controls="traceback-{{ task.id }}"
>
View full traceback
</button>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% if task.traceback %}
<tr class="collapse" id="traceback-{{ task.id }}">
<td></td>
<td colspan="10">
<div class="border rounded bg-dark-subtle p-2">
<div class="small fw-semibold mb-1">Full traceback</div>
<pre class="small mb-0" style="white-space: pre-wrap;">{{ task.traceback }}</pre>
</div>
</td>
</tr>
{% endif %}
{% empty %}
<tr>
<td colspan="11" class="text-muted">No tasks found for this filter.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
<script>
(function () {
const selectAll = document.getElementById('select-all-tasks');
const rows = Array.from(document.querySelectorAll('.task-select'));
if (!selectAll || !rows.length) {
return;
}
selectAll.addEventListener('change', function () {
rows.forEach((cb) => {
cb.checked = selectAll.checked;
});
});
})();
</script>
</div>
{% endblock content %}
+81 -23
View File
@@ -86,6 +86,7 @@
document.title = "Case: {{current_case.title}}";
console.log('ready');
const bc = new BroadcastChannel("viva_load");
const POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
// Centralized hide helper and delegated close handling for local viewer
function hideResourceLocal(){ const disp=document.getElementById('resourceDisplay'); const inner=document.getElementById('resourceInner'); if(disp) disp.style.display='none'; if(inner) inner.innerHTML=''; }
@@ -114,6 +115,83 @@
} catch (err) { console.warn('Viewer initialization deferred/fallback:', err); }
}
function applyViewerPayload(payload) {
if (!payload || payload.type !== 'open') {
return;
}
var root = document.getElementById('root');
var awaiting = document.getElementById('awaiting-modal');
if (!root) {
return;
}
if (awaiting) {
awaiting.style.display = 'none';
}
if (payload.namedStacks) {
root.dataset.namedStacks = payload.namedStacks;
} else {
delete root.dataset.namedStacks;
}
if (payload.images) {
root.dataset.images = payload.images;
} else {
delete root.dataset.images;
}
if (payload.viewerstate && Object.keys(payload.viewerstate).length > 0) {
root.dataset.viewerstate = payload.viewerstate;
} else {
delete root.dataset.viewerstate;
}
if (payload.annotations && Object.keys(payload.annotations).length > 0) {
root.dataset.annotationjson = payload.annotations;
} else {
delete root.dataset.annotationjson;
}
mountDicomViewersSafely();
}
function persistViewerPayload(payload) {
try {
localStorage.setItem(POPOUT_STATE_KEY, JSON.stringify(payload));
} catch (e) {
console.warn('Could not persist viewer payload', e);
}
}
function readPersistedViewerPayload() {
try {
var saved = localStorage.getItem(POPOUT_STATE_KEY);
return saved ? JSON.parse(saved) : null;
} catch (e) {
console.warn('Could not read persisted viewer payload', e);
return null;
}
}
var savedPayload = readPersistedViewerPayload();
if (savedPayload) {
applyViewerPayload(savedPayload);
}
window.addEventListener('storage', function (event) {
if (event.key !== POPOUT_STATE_KEY || !event.newValue) {
return;
}
try {
applyViewerPayload(JSON.parse(event.newValue));
} catch (e) {
console.warn('Could not parse updated viewer payload', e);
}
});
bc.onmessage = (event) => {
// Ensure any existing overlay is closed on new load
try { hideResourceLocal(); } catch(_) {}
@@ -179,33 +257,13 @@
if (event.data.url && isMediaUrl(event.data.url)) {
// show resource overlay and do not attempt to mount DICOM viewer
if (showResource(event.data.url)) {
persistViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
return;
}
}
// Hide modal when data is loaded
document.getElementById("awaiting-modal").style.display = "none";
document.getElementById("root").dataset.images = event.data.images;
if (
event.data.viewerstate &&
Object.keys(event.data.viewerstate).length > 0
) {
document.getElementById("root").dataset.viewerstate = event.data.viewerstate;
} else {
delete document.getElementById("root").dataset.viewerstate;
}
console.log("Annotations:", event.data.annotations);
if (
event.data.annotations &&
Object.keys(event.data.annotations).length > 0
) {
document.getElementById("root").dataset.annotationjson = event.data.annotations;
} else {
delete document.getElementById("root").dataset.annotationjson;
}
mountDicomViewersSafely();
persistViewerPayload(event.data);
applyViewerPayload(event.data);
bc.postMessage({"type": "open-complete-local"});
}
};
+11
View File
@@ -552,6 +552,7 @@ urlpatterns = [
path("structure/create", views.StructureCreate.as_view(), name="structure_create"),
path("series/<int:pk>/thumbnail/fail", views.series_thumbnail_fail, name="series_thumbnail_fail"),
path("series/<int:pk>/thumbnail", views.series_thumbnail, name="series_thumbnail"),
path("tasks/overview/", views.task_overview, name="task_overview"),
# TODO: case context series viewing (so that we can view series in the context of a case)
path("series/<int:pk>", views.series_detail, name="series_detail"),
path("series/<int:pk>/viewer", views.series_viewer, name="series_viewer"),
@@ -565,6 +566,16 @@ urlpatterns = [
views.series_image_size_htmx,
name="series_image_size",
),
path(
"series/<int:series_id>/optimize/",
views.series_optimize_htmx,
name="series_optimize",
),
path(
"series/<int:series_id>/reconstruct/status/<str:task_id>/",
views.series_reconstruct_status_htmx,
name="series_reconstruct_status",
),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"),
path("series/<int:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
+679 -9
View File
@@ -2,6 +2,8 @@ import json
import pprint
import csv
import random
import copy
import io
from typing import Any
from dal import autocomplete
from django.db.models.base import Model as Model
@@ -161,7 +163,22 @@ from .filters import (
NormalCaseFilter,
)
from .tasks import push_case_to_cimar_task
from .tasks import push_case_to_cimar_task, series_reconstruct_task
try:
from django_tasks import TaskResultStatus
except ImportError:
from enum import Enum
class TaskResultStatus(Enum):
READY = "READY"
RUNNING = "RUNNING"
FAILED = "FAILED"
SUCCESSFUL = "SUCCESSFUL"
try:
from django_tasks_db.models import DBTaskResult
except ImportError:
DBTaskResult = None
from django_tables2 import SingleTableView, SingleTableMixin
from django_filters.views import FilterView
@@ -216,6 +233,8 @@ from loguru import logger
def series_truncate_htmx(request, series_id, start, end):
"""HTMX endpoint to truncate a series' images to the given inclusive
start/end bounds (zero-based). Returns an HTML fragment with the result.
This endpoint only performs truncation for backward compatibility.
"""
series = get_object_or_404(Series, pk=series_id)
@@ -223,15 +242,18 @@ def series_truncate_htmx(request, series_id, start, end):
return HttpResponse("<div class=\"alert alert-danger\">Permission denied</div>")
images_removed = []
for n, image in enumerate(series.get_images()):
if n >= int(start) and n <= int(end):
# keep images within bounds
continue
else:
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
image.removed = True
image.image = None
image.save()
images_removed.append(image.pk)
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
# Recalculate stored series size
try:
@@ -243,12 +265,660 @@ def series_truncate_htmx(request, series_id, start, end):
# Render a simple message fragment for HTMX swap
html = render_to_string(
"atlas/partials/truncate_result.html",
{"images_removed": images_removed, "series": series},
{
"images_removed": images_removed,
"images_downsampled": [],
"series": series,
"remove_empty_dicoms": False,
"downsample_pixels": False,
},
request=request,
)
return HttpResponse(html)
def _read_series_image_dataset(series_image):
if not series_image.image:
return None
try:
series_image.image.open("rb")
series_image.image.seek(0)
return pydicom.dcmread(series_image.image, force=True)
except Exception as exc:
logger.warning("Failed to read DICOM for image {}: {}", series_image.pk, exc)
return None
def _is_empty_dicom(ds):
try:
px = ds.pixel_array
return bool((px == 0).all())
except Exception:
return False
def _resize_array_nearest(arr, target_rows, target_cols):
import numpy as np
rows, cols = arr.shape[0], arr.shape[1]
row_idx = np.clip(np.round(np.linspace(0, rows - 1, target_rows)).astype(int), 0, rows - 1)
col_idx = np.clip(np.round(np.linspace(0, cols - 1, target_cols)).astype(int), 0, cols - 1)
return arr[np.ix_(row_idx, col_idx)]
def _downsample_dicom_dataset(ds, reduction_pct):
if reduction_pct <= 0 or reduction_pct >= 100:
raise ValueError("Downsample percentage must be between 1 and 99")
factor = (100 - reduction_pct) / 100.0
px = ds.pixel_array
if px.ndim != 2:
raise ValueError("Only 2D DICOM slices are supported for this downsample operation")
target_rows = max(1, int(round(px.shape[0] * factor)))
target_cols = max(1, int(round(px.shape[1] * factor)))
resized = _resize_array_nearest(px, target_rows, target_cols)
ds.Rows = int(target_rows)
ds.Columns = int(target_cols)
ds.PixelData = resized.tobytes()
ds.pop("SmallestImagePixelValue", None)
ds.pop("LargestImagePixelValue", None)
return ds
def _create_series_derivative(source_series, description_suffix):
derived = Series.objects.get(pk=source_series.pk)
derived.pk = None
derived.id = None
derived.series_instance_uid = generate_uid()
derived.description = f"{source_series.description or 'Series'} [{description_suffix}]"
derived.modified = Series.SeriesModifiedChocies.RE
derived.save()
derived.case.set(source_series.case.all())
derived.author.set(source_series.author.all())
return derived
def _series_image_urls_json(series):
image_url_array_and_count = series.get_image_url_array_and_count()
try:
urls = json.loads(image_url_array_and_count[0])
except Exception:
urls = []
return urls
def _safe_float(value, default=None):
try:
if value is None:
return default
return float(value)
except Exception:
return default
def _infer_native_spacings(dicom_items):
"""Infer native (row, col, z) spacing in mm from a list of (image, ds, arr)."""
first_ds = dicom_items[0][1]
row_spacing = 1.0
col_spacing = 1.0
if hasattr(first_ds, "PixelSpacing") and first_ds.PixelSpacing and len(first_ds.PixelSpacing) >= 2:
row_spacing = _safe_float(first_ds.PixelSpacing[0], 1.0)
col_spacing = _safe_float(first_ds.PixelSpacing[1], 1.0)
z_spacing = _safe_float(getattr(first_ds, "SpacingBetweenSlices", None), None)
if z_spacing is None:
z_spacing = _safe_float(getattr(first_ds, "SliceThickness", None), None)
if z_spacing is None and len(dicom_items) > 1:
try:
p0 = dicom_items[0][1].ImagePositionPatient
p1 = dicom_items[1][1].ImagePositionPatient
if p0 and p1 and len(p0) == 3 and len(p1) == 3:
import numpy as np
z_spacing = float(np.linalg.norm(np.array(p1, dtype=float) - np.array(p0, dtype=float)))
except Exception:
z_spacing = None
if z_spacing is None or z_spacing <= 0:
z_spacing = 1.0
return float(row_spacing), float(col_spacing), float(z_spacing)
def _extract_recon_geometry(dicom_items):
"""Build sorted geometry context for reconstruction from (image, ds, arr) items."""
import numpy as np
row_spacing, col_spacing, native_z_spacing = _infer_native_spacings(dicom_items)
first_ds = dicom_items[0][1]
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
iop = getattr(first_ds, "ImageOrientationPatient", None)
if iop and len(iop) >= 6:
try:
row_dir = np.array([float(iop[0]), float(iop[1]), float(iop[2])], dtype=float)
col_dir = np.array([float(iop[3]), float(iop[4]), float(iop[5])], dtype=float)
except Exception:
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
if np.linalg.norm(row_dir) == 0 or np.linalg.norm(col_dir) == 0:
row_dir = np.array([1.0, 0.0, 0.0], dtype=float)
col_dir = np.array([0.0, 1.0, 0.0], dtype=float)
row_dir = row_dir / np.linalg.norm(row_dir)
col_dir = col_dir / np.linalg.norm(col_dir)
normal_dir = np.cross(row_dir, col_dir)
if np.linalg.norm(normal_dir) == 0:
normal_dir = np.array([0.0, 0.0, 1.0], dtype=float)
normal_dir = normal_dir / np.linalg.norm(normal_dir)
decorated = []
for idx, item in enumerate(dicom_items):
ds = item[1]
ipp = None
try:
ipp_attr = getattr(ds, "ImagePositionPatient", None)
if ipp_attr and len(ipp_attr) >= 3:
ipp = np.array([float(ipp_attr[0]), float(ipp_attr[1]), float(ipp_attr[2])], dtype=float)
except Exception:
ipp = None
if ipp is None:
ipp = np.array([0.0, 0.0, idx * native_z_spacing], dtype=float)
proj = float(np.dot(ipp, normal_dir))
decorated.append((proj, ipp, item))
decorated.sort(key=lambda x: x[0])
sorted_items = [d[2] for d in decorated]
sorted_ipps = [d[1] for d in decorated]
sorted_proj = np.array([d[0] for d in decorated], dtype=float)
if len(sorted_proj) > 1:
diffs = np.diff(sorted_proj)
valid_diffs = np.abs(diffs[np.abs(diffs) > 1e-6])
if valid_diffs.size > 0:
native_z_spacing = float(np.median(np.abs(valid_diffs)))
if native_z_spacing <= 0:
native_z_spacing = 1.0
origin_ipp = sorted_ipps[0] if sorted_ipps else np.array([0.0, 0.0, 0.0], dtype=float)
source_positions_mm = sorted_proj - sorted_proj[0] if len(sorted_proj) > 0 else np.array([0.0], dtype=float)
return {
"sorted_items": sorted_items,
"source_positions_mm": source_positions_mm,
"origin_ipp": origin_ipp,
"row_dir": row_dir,
"col_dir": col_dir,
"normal_dir": normal_dir,
"row_spacing": float(row_spacing),
"col_spacing": float(col_spacing),
"native_z_spacing": float(native_z_spacing),
}
def _aggregate_volume_along_z(volume, source_positions_mm, target_spacing, slab_thickness, mode):
"""Aggregate a z-volume into slabbed slices using spacing/thickness in mm."""
import numpy as np
if target_spacing <= 0:
raise ValueError("Target spacing must be positive")
if slab_thickness <= 0:
raise ValueError("Slab thickness must be positive")
source_positions_mm = np.asarray(source_positions_mm, dtype=float)
if source_positions_mm.size != volume.shape[0]:
raise ValueError("Source position count must match volume depth")
start_pos = float(source_positions_mm[0])
end_pos = float(source_positions_mm[-1])
centers = np.arange(start_pos, end_pos + (target_spacing * 0.5), target_spacing, dtype=float)
if centers.size == 0:
centers = np.array([start_pos], dtype=float)
half = slab_thickness / 2.0
slabs = []
for center in centers:
mask = np.abs(source_positions_mm - center) <= (half + 1e-6)
if not mask.any():
nearest = int(np.argmin(np.abs(source_positions_mm - center)))
slab = volume[nearest : nearest + 1, :, :]
else:
slab = volume[mask, :, :]
if mode == "max":
out = np.max(slab, axis=0)
elif mode == "min":
out = np.min(slab, axis=0)
else:
out = np.mean(slab, axis=0)
slabs.append(out)
out_volume = np.stack(slabs, axis=0)
if np.issubdtype(volume.dtype, np.integer):
info = np.iinfo(volume.dtype)
out_volume = np.clip(np.rint(out_volume), info.min, info.max).astype(volume.dtype)
else:
out_volume = out_volume.astype(volume.dtype, copy=False)
return out_volume, centers
@login_required
@require_http_methods(["POST"])
def series_optimize_htmx(request, series_id):
"""Run disk optimization operations for a series.
Scope: Series editors only.
Supported operations:
- remove_empty
- downsample
- downsample_preview
- reconstruct
- truncate
"""
series = get_object_or_404(Series, pk=series_id)
if not series.check_user_can_edit(request.user):
return HttpResponse('<div class="alert alert-danger mb-0">Permission denied</div>')
operation = request.POST.get("operation", "").strip()
start = int(request.POST.get("start", "1") or 1) - 1
end = int(request.POST.get("end", str(series.get_image_count())) or series.get_image_count()) - 1
if end < start:
return HttpResponse('<div class="alert alert-danger mb-0">Start must be less than or equal to end.</div>')
images = list(series.get_images())
bounded_images = [img for idx, img in enumerate(images) if idx >= start and idx <= end]
full_series_images = images
if operation == "truncate":
removed = []
for idx, image in enumerate(images):
if idx < start or idx > end:
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
new_total = int(series.get_image_count())
payload = (
f'<div id="truncate-range-payload" data-start="1" data-end="{new_total}" data-total="{new_total}"></div>'
)
return HttpResponse(
(
'<div class="alert alert-success mb-2">'
f'Truncate complete. Removed <strong>{len(removed)}</strong> images. '
f'Updated working range to <strong>1-{new_total}</strong>.'
'</div>'
f'{payload}'
)
)
if operation == "remove_empty":
removed = []
for image in bounded_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
if _is_empty_dicom(ds):
image.removed = True
image.image = None
image.save()
removed.append(image.pk)
if removed:
series.modified = Series.SeriesModifiedChocies.TR
series.save(update_fields=["modified"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Empty DICOM removal complete. Removed <strong>{len(removed)}</strong> empty images.</div>'
)
if operation in ("downsample", "downsample_preview"):
downsample_pct = int(request.POST.get("downsample_pct", "50") or 50)
if operation == "downsample_preview":
preview_series = _create_series_derivative(series, f"Downsample preview {downsample_pct}%")
created = 0
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
except Exception as exc:
logger.warning("Downsample preview failed for image {}: {}", image.pk, exc)
continue
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
new_image = SeriesImage(series=preview_series, position=image.position, upload_filename=image.upload_filename)
new_image.image.save(
f"preview_ds_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
new_image.save()
created += 1
original_urls = _series_image_urls_json(series)
preview_urls = _series_image_urls_json(preview_series)
named_stacks = json.dumps(
{
"Original": original_urls,
f"Preview {downsample_pct}%": preview_urls,
}
)
escaped_named_stacks = escape(named_stacks)
return HttpResponse(
(
'<div class="alert alert-info mb-2">'
f'Downsample preview series created with <strong>{created}</strong> images. '
f'<a class="alert-link" target="_blank" href="{preview_series.get_absolute_url()}">Open preview series</a>.'
'</div>'
f'<div id="downsample-compare-payload" data-named-stacks="{escaped_named_stacks}"></div>'
)
)
downsampled = []
for image in full_series_images:
ds = _read_series_image_dataset(image)
if ds is None:
continue
try:
original_hash = image.image_blake3_hash
ds_out = _downsample_dicom_dataset(ds, downsample_pct)
out_io = io.BytesIO()
ds_out.save_as(out_io, write_like_original=False)
out_io.seek(0)
image.image.save(
f"downsample_{downsample_pct}_{image.pk}.dcm",
ContentFile(out_io.getvalue()),
save=False,
)
# Preserve original hash for duplicate detection continuity.
image.image_blake3_hash = original_hash
image.save(update_fields=["image", "image_blake3_hash"])
downsampled.append(image.pk)
except Exception as exc:
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
if downsampled:
series.modified = Series.SeriesModifiedChocies.RE
series.save(update_fields=["modified"])
return HttpResponse(
f'<div class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{len(downsampled)}</strong> images while preserving original hashes.</div>'
)
if operation == "reconstruct":
recon_planes = request.POST.getlist("recon_planes")
if not recon_planes:
return HttpResponse('<div class="alert alert-danger mb-0">Select at least one reconstruction plane.</div>')
slice_thickness = request.POST.get("recon_slice_thickness", "")
slice_spacing = request.POST.get("recon_slice_spacing", "")
recon_thickness_mode = (request.POST.get("recon_thickness_mode", "mean") or "mean").strip().lower()
if recon_thickness_mode not in ("mean", "max", "min"):
return HttpResponse('<div class="alert alert-danger mb-0">Invalid thickness merge mode.</div>')
try:
slice_thickness_val = float(slice_thickness) if slice_thickness else None
slice_spacing_val = float(slice_spacing) if slice_spacing else None
except ValueError:
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness and spacing must be numeric values.</div>')
if slice_thickness_val is not None and slice_thickness_val <= 0:
return HttpResponse('<div class="alert alert-danger mb-0">Slice thickness must be greater than 0.</div>')
if slice_spacing_val is not None and slice_spacing_val <= 0:
return HttpResponse('<div class="alert alert-danger mb-0">Slice spacing must be greater than 0.</div>')
task_result = series_reconstruct_task.enqueue(
series_id=series.pk,
user_id=request.user.pk,
recon_planes=recon_planes,
slice_thickness_val=slice_thickness_val,
slice_spacing_val=slice_spacing_val,
recon_thickness_mode=recon_thickness_mode,
)
return HttpResponse(
(
'<div class="alert alert-info mb-2">'
'Reconstruction queued. This runs in the background to avoid request timeout.'
'</div>'
f'<div id="recon-task-status" hx-get="{reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id})}" '
'hx-trigger="load, every 2s" hx-swap="outerHTML">'
'<div class="d-flex align-items-center gap-2"><span class="spinner-border spinner-border-sm text-primary" role="status"></span>'
'<span class="small text-muted">Starting reconstruction task...</span></div></div>'
f'<div id="recon-task-payload" data-task-id="{escape(str(task_result.id))}" data-status-url="{escape(reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_result.id}))}"></div>'
)
)
return HttpResponse('<div class="alert alert-danger mb-0">Unknown optimize operation.</div>')
@login_required
def series_reconstruct_status_htmx(request, series_id, task_id):
"""HTMX endpoint returning progress for a queued series reconstruction task."""
series = get_object_or_404(Series, pk=series_id)
target_id = (request.GET.get("target_id") or "recon-task-status").strip() or "recon-task-status"
target_id = escape(target_id)
if not series.check_user_can_edit(request.user):
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Permission denied</div>')
try:
task_result = series_reconstruct_task.get_result(task_id)
except NotImplementedError:
return HttpResponse(
f'<div id="{target_id}" class="alert alert-warning mb-0">Task backend does not support result polling. Configure a backend with result retrieval support.</div>'
)
except Exception:
return HttpResponse(f'<div id="{target_id}" class="alert alert-danger mb-0">Task result not found.</div>')
task_result.refresh()
state = task_result.status
poll_url = reverse("atlas:series_reconstruct_status", kwargs={"series_id": series.pk, "task_id": task_id})
progress = cache.get(f"series_reconstruct_progress:{task_id}") or {}
current = int(progress.get("current", 0) or 0)
total = int(progress.get("total", 0) or 0)
message = escape(str(progress.get("message", "Reconstruction is running...")))
pct = int((current / total) * 100) if total > 0 else 0
if state in (TaskResultStatus.READY, TaskResultStatus.RUNNING):
return HttpResponse(
(
f'<div id="{target_id}" hx-get="{poll_url}?target_id={target_id}" hx-trigger="every 2s" hx-swap="outerHTML">'
f'<div class="small text-muted mb-1">{message}</div>'
'<div class="progress" style="height: 10px;">'
f'<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: {pct}%" '
f'aria-valuenow="{pct}" aria-valuemin="0" aria-valuemax="100"></div>'
'</div>'
f'<div class="small text-muted mt-1">{current}/{total} slices processed</div>'
'</div>'
)
)
if state == TaskResultStatus.SUCCESSFUL:
try:
payload = task_result.return_value if isinstance(task_result.return_value, dict) else {}
except ValueError:
payload = {}
created_series = payload.get("created_series", [])
if created_series:
links = " ".join(
[
f'<a class="alert-link me-2" target="_blank" href="{escape(item.get("url", "#"))}">{escape(item.get("description", str(item.get("id", "series"))))}</a>'
for item in created_series
]
)
return HttpResponse(
(
f'<div id="{target_id}" class="alert alert-success mb-0">'
f'Reconstruction complete. Created <strong>{len(created_series)}</strong> series: {links}'
'</div>'
)
)
return HttpResponse(f'<div id="{target_id}" class="alert alert-warning mb-0">Reconstruction finished but no output series were created.</div>')
if state == TaskResultStatus.FAILED:
err = "Unknown failure"
if task_result.errors:
err = escape(task_result.errors[-1].traceback.splitlines()[-1])
return HttpResponse(
f'<div id="{target_id}" class="alert alert-danger mb-0">Reconstruction failed: {err}</div>'
)
return HttpResponse(
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
)
@login_required
@user_is_atlas_editor
def task_overview(request):
"""Atlas admin-style task overview page with active/completed/failed task summaries."""
if DBTaskResult is None:
return render(
request,
"atlas/task_overview.html",
{
"tasks": [],
"counts": {"active": 0, "completed": 0, "failed": 0},
"backend_missing": True,
},
)
if request.method == "POST":
action = (request.POST.get("task_action") or "").strip()
selected_ids = [task_id for task_id in request.POST.getlist("task_ids") if task_id]
if not selected_ids:
messages.warning(request, "Select at least one task to run an action.")
return redirect(f"{reverse('atlas:task_overview')}?status={(request.GET.get('status') or 'all').strip().lower()}")
selected_qs = DBTaskResult.objects.filter(id__in=selected_ids)
if action == "reset_failed":
updated = selected_qs.filter(status="FAILED").update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
messages.success(request, f"Reset {updated} failed task(s) to READY.")
elif action == "reset_selected":
blocked = selected_qs.filter(status="RUNNING").count()
updated = selected_qs.exclude(status="RUNNING").update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
msg = f"Reset {updated} task(s) to READY."
if blocked:
msg += f" Skipped {blocked} running task(s)."
messages.success(request, msg)
elif action == "delete_completed":
deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete()
messages.success(request, f"Deleted {deleted} completed task record(s).")
elif action == "delete_failed":
deleted, _ = selected_qs.filter(status="FAILED").delete()
messages.success(request, f"Deleted {deleted} failed task record(s).")
else:
messages.error(request, "Invalid task action requested.")
status_q = (request.GET.get("status") or "all").strip().lower()
return redirect(f"{reverse('atlas:task_overview')}?status={status_q}")
selected_status = (request.GET.get("status") or "all").strip().lower()
status_map = {
"active": ["READY", "RUNNING"],
"completed": ["SUCCESSFUL"],
"failed": ["FAILED"],
}
base_qs = DBTaskResult.objects.all().order_by("-enqueued_at")
if selected_status in status_map:
tasks = list(base_qs.filter(status__in=status_map[selected_status])[:300])
else:
tasks = list(base_qs[:300])
for task in tasks:
generated_series = []
payload = task.return_value
if isinstance(payload, str):
try:
payload = json.loads(payload)
except Exception:
payload = None
if isinstance(payload, dict):
for item in payload.get("created_series", []) or []:
if not isinstance(item, dict):
continue
url = item.get("url")
description = item.get("description") or str(item.get("id", "Series"))
if url:
generated_series.append({"url": url, "description": description})
task.generated_series = generated_series
counts = {
"active": DBTaskResult.objects.filter(status__in=["READY", "RUNNING"]).count(),
"completed": DBTaskResult.objects.filter(status="SUCCESSFUL").count(),
"failed": DBTaskResult.objects.filter(status="FAILED").count(),
}
return render(
request,
"atlas/task_overview.html",
{
"tasks": tasks,
"counts": counts,
"selected_status": selected_status,
"backend_missing": False,
},
)
@login_required
def series_image_size_htmx(request, series_id):
"""HTMX endpoint returning the total size of images in a series.
@@ -8222,7 +8892,7 @@ def collection_reset_answers(request, exam_id: int):
def push_case_to_cimar(request, case_id):
push_case_to_cimar_task.delay(case_id=case_id)
push_case_to_cimar_task.enqueue(case_id=case_id)
return HttpResponse("Push started")
case = get_object_or_404(Case, pk=case_id)
+14
View File
@@ -17,6 +17,20 @@ services:
- ../:/usr/src/app:cached
# Mount the app log directory so logs are visible on the host at ./logs
- ../logs:/var/log/rad
worker:
env_file:
- ../.env.dev
volumes:
- ../:/usr/src/app:cached
- ../logs:/var/log/rad
entrypoint:
[
"sh",
"-c",
"python manage.py migrate --noinput && exec python manage.py db_worker",
]
command: []
nginx:
# Development nginx override: mount a simplified config that does not
# reference LetsEncrypt certs so the container can start without real
+17
View File
@@ -30,6 +30,23 @@ services:
- 3459:3459
env_file:
- ./.env.dev.local
worker:
build:
context: ..
dockerfile: rad/Dockerfile
command: python manage.py db_worker
restart: unless-stopped
volumes:
- ../:/usr/src/app
- ../backups:/usr/src/app/backups
- ../media:/usr/src/app/media
- ../static:/usr/src/app/static
env_file:
- ./.env.dev.local
depends_on:
- web
- db
db:
image: postgres:14.2-alpine
volumes:
+24
View File
@@ -35,6 +35,30 @@ services:
- "8000"
command: ["/usr/src/app/entrypoint.sh"]
worker:
build:
context: ..
dockerfile: rad/Dockerfile.prod
restart: always
env_file:
- ../.env.${COMPOSE_ENV:-prod}
environment:
- DJANGO_SETTINGS_MODULE=rad.settings
depends_on:
redis:
condition: service_healthy
web:
condition: service_started
volumes:
- ../deploy/settings_local.py:/usr/src/app/rad/settings_local.py:ro
- ../logs:/var/log/rad:rw
entrypoint:
[
"sh",
"-c",
"python manage.py migrate --noinput && exec python manage.py db_worker",
]
nginx:
image: nginx:stable-alpine
restart: always
+14 -2
View File
@@ -86,8 +86,8 @@ INSTALLED_APPS = [
'django_jsonforms',
'django_svelte_jsoneditor',
'django_psutil_dash',
'django_tasks',
'django_tasks_db',
]
MIDDLEWARE = [
@@ -382,6 +382,18 @@ CIMAR_PASSWORD = ""
CELERY_BROKER_URL = "redis://redis:6379"
CELERY_RESULT_BACKEND = "redis://redis:6379"
# Django 6 task framework settings.
# Use DJANGO_TASK_BACKEND to select your backend implementation.
TASKS = {
"default": {
"BACKEND": os.environ.get(
"DJANGO_TASK_BACKEND",
"django_tasks_db.DatabaseBackend",
),
"QUEUES": ["default"],
}
}
try:
from .settings_local import *
+7
View File
@@ -24,3 +24,10 @@ EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
CACHES["default"] = {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
}
TASKS = {
"default": {
"BACKEND": "django_tasks.backends.immediate.ImmediateBackend",
"QUEUES": ["default"],
}
}
+2
View File
@@ -1,5 +1,7 @@
#Django==3.2.13
Django==6.0.1
django-tasks
django-tasks-db
django_debug_toolbar
django_jquery
django_reversion
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=PENRA django-tasks db_worker
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/home/ross/web/rad
Environment=DJANGO_SETTINGS_MODULE=rad.settings
ExecStart=/home/ross/web/rad/scripts/run-db-worker.sh
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-$ROOT_DIR/.venv/bin/python}"
DJANGO_SETTINGS_MODULE="${DJANGO_SETTINGS_MODULE:-rad.settings}"
RESTART_DELAY="${RESTART_DELAY:-3}"
MAX_RESTARTS="${MAX_RESTARTS:-0}"
if [ ! -x "$PYTHON_BIN" ]; then
echo "Python binary not found or not executable: $PYTHON_BIN" >&2
exit 1
fi
export DJANGO_SETTINGS_MODULE
restart_count=0
echo "Starting persistent db_worker loop"
echo "Root: $ROOT_DIR"
echo "Settings: $DJANGO_SETTINGS_MODULE"
echo "Python: $PYTHON_BIN"
while true; do
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] Launching db_worker"
if "$PYTHON_BIN" "$ROOT_DIR/manage.py" db_worker; then
exit_code=0
else
exit_code=$?
fi
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] db_worker exited with code $exit_code"
if [ "$exit_code" -eq 137 ] || [ "$exit_code" -eq 9 ]; then
echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] db_worker appears to have been SIGKILL'ed (likely OOM)." >&2
fi
if [ "$MAX_RESTARTS" -gt 0 ] && [ "$restart_count" -ge "$MAX_RESTARTS" ]; then
echo "Max restarts reached ($MAX_RESTARTS). Exiting." >&2
exit "$exit_code"
fi
restart_count=$((restart_count + 1))
echo "Restarting in ${RESTART_DELAY}s (restart #$restart_count)"
sleep "$RESTART_DELAY"
done
+5
View File
@@ -333,6 +333,11 @@
<a class="dropdown-item" href="{% url 'media_cleanup' %}">Unused media cleanup</a>
</li>
{% endif %}
{% if request.user.is_superuser or request.user|has_group:"atlas_editor" %}
<li>
<a class="dropdown-item" href="{% url 'atlas:task_overview' %}">Atlas tasks overview</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}