Compare commits

...
31 Commits
Author SHA1 Message Date
Ross 064f039a24 up dv3d 2026-05-18 17:26:49 +01:00
Ross c0845287da update dv3d 2026-05-18 17:15:02 +01:00
Ross 32c6dc6f65 update dv3d 2026-05-18 16:59:12 +01:00
Ross b9f511f636 update dv3d 2026-05-18 16:41:01 +01:00
Ross dcc347793d update dv3d 2026-05-18 16:37:12 +01:00
Ross 63ea4cdf23 Add downsample series functionality and related UI updates
- Implemented asynchronous downsample task for series with progress tracking.
- Added new URL endpoint for downsample status.
- Updated Series model to include source_series_instance_uid.
- Enhanced case display template with new buttons for series actions.
- Added password reset functionality in user profile with appropriate alerts.
- Created migration for new source_series_instance_uid field in Series model.
2026-05-18 16:20:38 +01:00
Ross c55797636a Add functionality to reset stale RUNNING tasks to READY in task overview 2026-05-18 14:15:21 +01:00
Ross e016175d2b Refactor code structure for improved readability and maintainability 2026-05-18 14:03:15 +01:00
Ross 682bde82b9 update d3vd 2026-05-18 13:46:33 +01:00
Ross 53109e7cb2 Remove forced remount sequence for thumbnail stabilization in viewer 2026-05-18 13:13:26 +01:00
Ross 3065a279b1 Refactor code structure for improved readability and maintainability 2026-05-18 13:12:02 +01:00
Ross 2f8b61a4be Add forced remount sequence for thumbnail stabilization in viewer 2026-05-18 13:08:32 +01:00
Ross 2b10f2e73c Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 13:03:24 +01:00
Ross 20e24808fc debug thumbs 2026-05-18 12:58:02 +01:00
Ross 714857e3d2 Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 12:54:28 +01:00
Ross 7019ac2ef8 debug thums 2026-05-18 12:53:35 +01:00
Ross 16fea039d7 update dv3d 2026-05-18 12:48:13 +01:00
Ross fc38776ce2 Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 12:39:48 +01:00
Ross d132625419 Refactor code structure for improved readability and maintainability 2026-05-18 12:32:36 +01:00
Ross 5614961980 Refactor code structure for improved readability and maintainability 2026-05-18 12:17:44 +01:00
Ross f3af6d47ee Refactor code structure for improved readability and maintainability 2026-05-18 11:55:40 +01:00
Ross b09fbee2c9 Implement feature X to enhance user experience and fix bug Y in module Z 2026-05-18 11:36:01 +01:00
Ross 4a104be71f Refactor code structure for improved readability and maintainability 2026-05-18 11:33:59 +01:00
Ross dbf0fc262c style(templates): Refactor HTML structure for improved readability and consistency 2026-05-18 11:16:26 +01:00
Ross 026350f9d4 feat(templates): Add content container class for improved layout consistency 2026-05-18 11:16:20 +01:00
Ross b34970ed7f style(case_display_block): Refactor series panel layout and improve styling 2026-05-18 11:00:43 +01:00
Ross 867d92bff9 feat(series): Enhance series block with interactive features and improved styling 2026-05-18 10:50:53 +01:00
Ross 4d1423134a Add django-tasks and django-tasks-db to requirements
- Updated requirements.txt to include django-tasks and django-tasks-db for task management.
2026-05-18 10:38:14 +01:00
Ross 410b71f07f fix(models): Add type hint for series variable in get_series_blocks method 2026-05-18 10:05:54 +01:00
Ross e51e8358bb fix(workspace): Correctly add uploader path to workspace configuration 2026-05-18 09:50:28 +01:00
Ross 5f69cdcaf9 fix(workspace): Correctly add dv3d path to workspace configuration 2026-05-18 09:49:44 +01:00
28 changed files with 2852 additions and 1625 deletions
+22
View File
@@ -32,6 +32,8 @@ from reversion.admin import VersionAdmin
from django.forms.widgets import RadioSelect from django.forms.widgets import RadioSelect
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from datetime import timedelta
from tinymce.widgets import TinyMCE from tinymce.widgets import TinyMCE
@@ -226,6 +228,24 @@ if DBTaskResult is not None:
msg += f" Skipped {blocked} running task(s)." msg += f" Skipped {blocked} running task(s)."
self.message_user(request, msg, level=messages.SUCCESS) self.message_user(request, msg, level=messages.SUCCESS)
@admin.action(description="Reset stale RUNNING tasks to READY (>30 min)")
def reset_stale_running_to_ready(self, request, queryset):
cutoff = timezone.now() - timedelta(minutes=30)
updated = queryset.filter(status="RUNNING", started_at__lt=cutoff).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} stale RUNNING task(s) to READY (older than 30 minutes).",
level=messages.SUCCESS,
)
@admin.action(description="Delete selected COMPLETED tasks") @admin.action(description="Delete selected COMPLETED tasks")
def delete_selected_completed(self, request, queryset): def delete_selected_completed(self, request, queryset):
completed_qs = queryset.filter(status="SUCCESSFUL") completed_qs = queryset.filter(status="SUCCESSFUL")
@@ -278,6 +298,7 @@ if DBTaskResult is not None:
admin_cls.actions = ( admin_cls.actions = (
"reset_failed_to_ready", "reset_failed_to_ready",
"reset_selected_to_ready", "reset_selected_to_ready",
"reset_stale_running_to_ready",
"delete_selected_completed", "delete_selected_completed",
"delete_selected_failed", "delete_selected_failed",
) )
@@ -339,6 +360,7 @@ if DBTaskResult is not None:
admin_cls.traceback_pretty = traceback_pretty admin_cls.traceback_pretty = traceback_pretty
admin_cls.reset_failed_to_ready = reset_failed_to_ready admin_cls.reset_failed_to_ready = reset_failed_to_ready
admin_cls.reset_selected_to_ready = reset_selected_to_ready admin_cls.reset_selected_to_ready = reset_selected_to_ready
admin_cls.reset_stale_running_to_ready = reset_stale_running_to_ready
admin_cls.delete_selected_completed = delete_selected_completed admin_cls.delete_selected_completed = delete_selected_completed
admin_cls.delete_selected_failed = delete_selected_failed admin_cls.delete_selected_failed = delete_selected_failed
admin_cls.has_add_permission = has_add_permission admin_cls.has_add_permission = has_add_permission
@@ -0,0 +1,16 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("atlas", "0103_casecollection_auto_apply_question_template_and_more"),
]
operations = [
migrations.AddField(
model_name="series",
name="source_series_instance_uid",
field=models.CharField(blank=True, db_index=True, max_length=255, null=True),
),
]
+29 -10
View File
@@ -617,20 +617,37 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
cimar_uuid = models.CharField(max_length=255, null=True, blank=True) cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
def get_ordered_series_details(self): def get_ordered_series_details(self):
# If the series are already prefetched, use them directly cached = getattr(self, "_ordered_series_details_cache", None)
if hasattr(self, '_prefetched_objects_cache') and 'series' in self._prefetched_objects_cache: if cached is not None:
# Get the through model and sort by sort_order return cached
through_model = self.series.through
# Build a mapping from series pk to through instance prefetched = getattr(self, "_prefetched_objects_cache", {})
through_objs = list(through_model.objects.filter(case=self).select_related('series')) if "seriesdetail_set" in prefetched:
through_objs.sort(key=lambda x: x.sort_order) through_objs = sorted(
list(prefetched["seriesdetail_set"]),
key=lambda item: item.sort_order,
)
self._ordered_series_details_cache = through_objs
return through_objs return through_objs
# Otherwise, query as before
return self.series.through.objects.filter(case=self).select_related('series').order_by('sort_order') through_objs = list(
self.series.through.objects
.filter(case=self)
.select_related("series")
.order_by("sort_order")
)
self._ordered_series_details_cache = through_objs
return through_objs
def get_ordered_series(self): def get_ordered_series(self):
"""Returns the series in the case in order of the SeriesDetail sort_order""" """Returns the series in the case in order of the SeriesDetail sort_order"""
return [sd.series for sd in self.get_ordered_series_details()] cached = getattr(self, "_ordered_series_cache", None)
if cached is not None:
return cached
ordered = [sd.series for sd in self.get_ordered_series_details()]
self._ordered_series_cache = ordered
return ordered
def get_app_name(self): def get_app_name(self):
return "atlas" return "atlas"
@@ -670,6 +687,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
def get_series_blocks(self): def get_series_blocks(self):
html = "" html = ""
s: Series
for s in self.series.all(): for s in self.series.all():
html += s.get_block() html += s.get_block()
@@ -1114,6 +1132,7 @@ class Series(SeriesBase):
) )
series_instance_uid = models.CharField(max_length=255, blank=True, null=True) series_instance_uid = models.CharField(max_length=255, blank=True, null=True)
source_series_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True) study_instance_uid = models.CharField(max_length=255, blank=True, null=True, db_index=True)
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack") # findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
+101 -2
View File
@@ -92,6 +92,106 @@ from django.core.cache import cache
import copy import copy
import io import io
@task(takes_context=True)
def series_downsample_task(context, series_id, user_id, downsample_pct):
"""Downsample a series asynchronously while preserving hash continuity."""
from loguru import logger
from atlas import views as atlas_views
progress_key = f"series_downsample_progress:{context.task_result.id}"
cache.set(
progress_key,
{"current": 0, "total": 0, "message": "Preparing downsample..."},
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())
total_images = len(images)
replacement_series_uid = generate_uid()
source_series_uid = series.series_instance_uid
updated_images = []
cache.set(
progress_key,
{"current": 0, "total": total_images, "message": "Downsample started"},
timeout=60 * 60,
)
for idx, image in enumerate(images, start=1):
ds = atlas_views._read_series_image_dataset(image)
if ds is None:
cache.set(
progress_key,
{"current": idx, "total": total_images, "message": f"Skipping unreadable image ({idx}/{total_images})"},
timeout=60 * 60,
)
continue
try:
original_hash = image.image_blake3_hash
ds_out = atlas_views._downsample_dicom_dataset(
ds,
int(downsample_pct),
series_instance_uid=replacement_series_uid,
source_series_instance_uid=source_series_uid,
series_description=series.description,
)
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,
)
image.image_blake3_hash = original_hash
image.save(update_fields=["image", "image_blake3_hash"])
updated_images.append(image.pk)
except Exception as exc:
logger.warning("Downsample failed for image {}: {}", image.pk, exc)
cache.set(
progress_key,
{
"current": idx,
"total": total_images,
"message": f"Downsampling image {idx}/{total_images}",
},
timeout=60 * 60,
)
if updated_images:
series.modified = Series.SeriesModifiedChocies.RE
series.source_series_instance_uid = source_series_uid
series.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "source_series_instance_uid", "series_instance_uid"])
cache.set(
progress_key,
{
"current": total_images,
"total": total_images,
"message": "Downsample complete",
},
timeout=10 * 60,
)
return {
"series_id": series.pk,
"downsample_pct": int(downsample_pct),
"updated_images": len(updated_images),
"series_instance_uid": replacement_series_uid,
"source_series_instance_uid": source_series_uid,
}
@task @task
def push_case_to_cimar_task(case_id): def push_case_to_cimar_task(case_id):
"""Sends an email when the feedback form has been submitted.""" """Sends an email when the feedback form has been submitted."""
@@ -389,8 +489,6 @@ def series_reconstruct_task(
for plane_norm, cfg in plane_builders.items(): 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 = 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"])
for idx in range(int(cfg["count"])): for idx in range(int(cfg["count"])):
arr2d = cfg["slice_fn"](idx) arr2d = cfg["slice_fn"](idx)
@@ -403,6 +501,7 @@ def series_reconstruct_task(
atlas_views._stamp_derived_dataset( atlas_views._stamp_derived_dataset(
ds_new, ds_new,
series_instance_uid=recon_series.series_instance_uid, series_instance_uid=recon_series.series_instance_uid,
source_series_instance_uid=series.series_instance_uid,
series_description=recon_series.description or f"Recon {plane_norm.title()}", series_description=recon_series.description or f"Recon {plane_norm.title()}",
derivation_description=( derivation_description=(
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; " f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
@@ -102,52 +102,52 @@
{% endif %} {% endif %}
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %} {% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
<div id="viewer-panel" class="mb-3"> <div id="viewer-panel" class="mb-3">
{% if collection.show_built_in_viewer %} {% 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"> <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> <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"> <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> <i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
</button> </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"> <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> <i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>
</button> </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>
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer"> <div id="viewer-moveable-home">
<div class="viewer-resize-grip-inner"></div> <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> </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> </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 %} {% endif %}
@@ -351,6 +351,7 @@
</aside> </aside>
<aside id="viewerSidebar" class="viewer-sidebar" aria-label="Viewer sidebar"> <aside id="viewerSidebar" class="viewer-sidebar" aria-label="Viewer sidebar">
<div id="viewer-sidebar-resize-handle" class="viewer-sidebar-resize-handle" title="Drag to resize sidebar" aria-hidden="true"></div>
<div class="viewer-sidebar-header"> <div class="viewer-sidebar-header">
<span class="fw-semibold">Viewer</span> <span class="fw-semibold">Viewer</span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="viewer-sidebar-close-btn">Close</button> <button type="button" class="btn btn-sm btn-outline-secondary" id="viewer-sidebar-close-btn">Close</button>
@@ -545,6 +546,18 @@
overflow: auto; overflow: auto;
} }
.viewer-sidebar-resize-handle {
position: absolute;
left: 0;
top: 0;
width: 10px;
height: 100%;
cursor: ew-resize;
z-index: 3;
background: linear-gradient(to right, rgba(100,181,246,0.28), rgba(100,181,246,0));
touch-action: none;
}
.viewer-sidebar.open { .viewer-sidebar.open {
transform: translateX(0); transform: translateX(0);
} }
@@ -975,207 +988,244 @@
<script type="text/javascript"> <script type="text/javascript">
(function () { (function () {
function initViewerControls() { function initViewerControls() {
var COLL_PK = '{{ collection.pk }}'; var COLL_PK = '{{ collection.pk }}';
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK; var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK; var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state'; var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK; var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK;
var panel = document.getElementById('viewer-panel'); var panel = document.getElementById('viewer-panel');
var moveableHome = document.getElementById('viewer-moveable-home'); var moveableHome = document.getElementById('viewer-moveable-home');
var moveable = document.getElementById('viewer-moveable'); var moveable = document.getElementById('viewer-moveable');
var container = document.getElementById('viewer-container'); var container = document.getElementById('viewer-container');
var resizeHandle = document.getElementById('viewer-resize-handle'); var resizeHandle = document.getElementById('viewer-resize-handle');
var sidebarEl = document.getElementById('viewerSidebar'); var sidebarEl = document.getElementById('viewerSidebar');
var sidebarBody = document.getElementById('viewerSidebarBody'); var sidebarBody = document.getElementById('viewerSidebarBody');
var sidebarBtn = document.getElementById('viewer-sidebar-btn'); var sidebarBtn = document.getElementById('viewer-sidebar-btn');
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn'); var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
var popoutBtn = document.getElementById('viewer-popout-btn'); var sidebarResizeHandle = document.getElementById('viewer-sidebar-resize-handle');
var mainViewerEl = document.getElementById('main_viewer'); var popoutBtn = document.getElementById('viewer-popout-btn');
var rootStyle = document.documentElement.style; var mainViewerEl = document.getElementById('main_viewer');
var rootStyle = document.documentElement.style;
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) { if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
return; 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 (panel.dataset.viewerControlsBound === '1') {
if (!sidebarEl) return; 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); panel.dataset.viewerControlsBound = '1';
}
if (sidebarBtn) {
sidebarBtn.addEventListener('click', function () { 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);
}
if (sidebarResizeHandle && sidebarEl) {
var sidebarResizing = false;
var sidebarStartX = 0;
var sidebarStartWidth = 0;
var onSidebarPointerMove = function (event) {
if (!sidebarResizing) return;
var delta = sidebarStartX - event.clientX;
var nextWidth = sidebarStartWidth + delta;
applySidebarWidth(nextWidth);
};
var stopSidebarResize = function () {
if (!sidebarResizing) return;
sidebarResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
var width = Math.round(sidebarEl.getBoundingClientRect().width);
localStorage.setItem(SIDEBAR_W_KEY, width);
};
sidebarResizeHandle.addEventListener('pointerdown', function (event) {
event.preventDefault();
sidebarResizing = true;
sidebarStartX = event.clientX;
sidebarStartWidth = sidebarEl.getBoundingClientRect().width;
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
});
window.addEventListener('pointermove', onSidebarPointerMove);
window.addEventListener('pointerup', stopSidebarResize);
window.addEventListener('pointercancel', stopSidebarResize);
}
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')) { if (sidebarEl && sidebarEl.classList.contains('open')) {
closeViewerSidebar(); closeViewerSidebar();
} else {
openViewerSidebar();
} }
}); var payload = getViewerPayload();
} broadcastViewerState(payload);
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
if (sidebarCloseBtn) { setTimeout(function () { broadcastViewerState(payload); }, 700);
sidebarCloseBtn.addEventListener('click', closeViewerSidebar); localStorage.setItem(MODE_KEY, 'popout');
} showPopoutPlaceholder();
if (popoutBtn) {
var bc = null; popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
try { bc = new BroadcastChannel('viva_load'); } catch (e) {} popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
}
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() { function closePopout() {
var existing = panel.querySelector('.viewer-popout-placeholder'); hidePopoutPlaceholder();
if (existing) return; localStorage.removeItem(MODE_KEY);
var placeholder = document.createElement('div'); if (popoutBtn) {
placeholder.className = 'alert alert-secondary small py-2 mb-0 mt-1 d-flex align-items-center gap-2 viewer-popout-placeholder'; popoutBtn.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop&nbsp;out</span>';
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>'; popoutBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
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) { 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.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'); 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');
}
} }
+14 -13
View File
@@ -1,22 +1,23 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid"> <div class="container-fluid">
<div class="d-flex align-items-start justify-content-between mb-3"> <div class="d-flex align-items-start justify-content-between mb-3">
<h2 class="mb-0">Collections</h2> <h2 class="mb-0">Collections</h2>
{% if request.user.is_staff %} {% if request.user.is_staff %}
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a> <a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
{% endif %} {% endif %}
</div>
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div> </div>
<p class="mb-3">Manage and browse case collections. Use filters to narrow results.</p>
{% render_table table %}
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
</div>
{% endblock %} {% endblock %}
+1
View File
@@ -1,5 +1,6 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %} {% block content %}
<h2>Resources</h2> <h2>Resources</h2>
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
+22 -22
View File
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %} {% block content %}
@@ -33,42 +34,41 @@
{% render_table table %} {% render_table table %}
</div> </div>
<script> <script>
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) { document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
// Remove any previously added hidden inputs // Remove any previously added hidden inputs
document.querySelectorAll('#bulk-delete-form input[name="series_ids"]').forEach(el => el.remove()); document.querySelectorAll('#bulk-delete-form input[name="series_ids"]').forEach(el => el.remove());
// Find all checked checkboxes // Find all checked checkboxes
const checked = document.querySelectorAll('input[name="selection"]:checked'); const checked = document.querySelectorAll('input[name="selection"]:checked');
if (checked.length === 0) { if (checked.length === 0) {
alert("Please select at least one series to delete."); alert("Please select at least one series to delete.");
e.preventDefault(); e.preventDefault();
return false; return false;
} }
// Add hidden inputs for each checked series // Add hidden inputs for each checked series
checked.forEach(cb => { checked.forEach(cb => {
const hidden = document.createElement('input'); const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.type = 'hidden';
hidden.name = 'series_ids'; hidden.name = 'series_ids';
hidden.value = cb.value; hidden.value = cb.value;
this.appendChild(hidden); this.appendChild(hidden);
}); });
}); });
</script> </script>
<div id="exam-options"></div> <div id="exam-options"></div>
{% endblock %} {% endblock %}
{% block js %} {% block js %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
}); });
</script> </script>
{% endblock %} {% endblock %}
{% block css %} {% block css %}
<style> <style>
</style> </style>
{% endblock css %} {% endblock css %}
+203 -189
View File
@@ -2,200 +2,214 @@
{% load static %} {% load static %}
{% load django_htmx %} {% load django_htmx %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block title %} {% block title %}
Atlas Task Overview Atlas Task Overview
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<div class="container-fluid py-3 px-3"> <div class="container-fluid py-3 px-3">
<style> <style>
.task-overview-table { .task-overview-table {
min-width: 1650px; 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>
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
</select>
<button type="submit" class="btn btn-sm btn-primary">Apply to selected</button>
<div class="form-check ms-2">
<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 () { .task-overview-table th,
rows.forEach((cb) => { .task-overview-table td {
cb.checked = selectAll.checked; 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="reset_stale_running">Reset stale RUNNING to READY</option>
<option value="delete_completed">Delete selected COMPLETED</option>
<option value="delete_failed">Delete selected FAILED</option>
<option value="delete_selected_all">Delete ALL selected (including RUNNING)</option>
</select>
<input
class="form-control form-control-sm"
type="number"
name="stale_minutes"
min="1"
step="1"
value="30"
style="max-width: 150px;"
title="Stale cutoff in minutes for reset stale RUNNING action"
aria-label="Stale minutes"
>
<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>
</script> </div>
</div>
{% endblock content %} {% endblock content %}
@@ -1,5 +1,7 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %} {% block content %}
<h2>Search Uploaded And Imported DICOMs</h2> <h2>Search Uploaded And Imported DICOMs</h2>
<p class="text-muted">Admin-only tool for searching pending uploads and imported Atlas data by hash, series UID, or study UID.</p> <p class="text-muted">Admin-only tool for searching pending uploads and imported Atlas data by hash, series UID, or study UID.</p>
+1
View File
@@ -1,6 +1,7 @@
{% extends 'atlas/base.html' %} {% extends 'atlas/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
+5
View File
@@ -576,6 +576,11 @@ urlpatterns = [
views.series_reconstruct_status_htmx, views.series_reconstruct_status_htmx,
name="series_reconstruct_status", name="series_reconstruct_status",
), ),
path(
"series/<int:series_id>/downsample/status/<str:task_id>/",
views.series_downsample_status_htmx,
name="series_downsample_status",
),
path("series/<int:pk>/images/", views.series_images_partial, name="series_images"), 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:pk>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"), path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
+211 -55
View File
@@ -4,6 +4,7 @@ import csv
import random import random
import copy import copy
import io import io
from datetime import timedelta
from typing import Any from typing import Any
from dal import autocomplete from dal import autocomplete
from django.db.models.base import Model as Model from django.db.models.base import Model as Model
@@ -25,6 +26,7 @@ from django.template.defaultfilters import filesizeformat
# from django.contrib.auth.models import User # from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib import messages
from django.core.exceptions import ( from django.core.exceptions import (
FieldError, FieldError,
ObjectDoesNotExist, ObjectDoesNotExist,
@@ -163,7 +165,7 @@ from .filters import (
NormalCaseFilter, NormalCaseFilter,
) )
from .tasks import push_case_to_cimar_task, series_reconstruct_task from .tasks import push_case_to_cimar_task, series_downsample_task, series_reconstruct_task
try: try:
from django_tasks import TaskResultStatus from django_tasks import TaskResultStatus
except ImportError: except ImportError:
@@ -311,6 +313,7 @@ def _stamp_derived_dataset(
ds, ds,
*, *,
series_instance_uid=None, series_instance_uid=None,
source_series_instance_uid=None,
series_description=None, series_description=None,
derivation_description=None, derivation_description=None,
image_type=None, image_type=None,
@@ -320,6 +323,8 @@ def _stamp_derived_dataset(
now = timezone.now() now = timezone.now()
date_str = now.strftime("%Y%m%d") date_str = now.strftime("%Y%m%d")
time_str = now.strftime("%H%M%S.%f")[:13] time_str = now.strftime("%H%M%S.%f")[:13]
source_sop_instance_uid = getattr(ds, "SOPInstanceUID", None)
source_sop_class_uid = getattr(ds, "SOPClassUID", None)
if regenerate_sop_instance_uid: if regenerate_sop_instance_uid:
ds.SOPInstanceUID = generate_uid() ds.SOPInstanceUID = generate_uid()
@@ -333,6 +338,18 @@ def _stamp_derived_dataset(
if derivation_description: if derivation_description:
ds.DerivationDescription = derivation_description ds.DerivationDescription = derivation_description
if source_series_instance_uid:
series_ref = pydicom.dataset.Dataset()
series_ref.SeriesInstanceUID = str(source_series_instance_uid)
ds.ReferencedSeriesSequence = pydicom.sequence.Sequence([series_ref])
if source_sop_instance_uid:
source_ref = pydicom.dataset.Dataset()
if source_sop_class_uid:
source_ref.ReferencedSOPClassUID = str(source_sop_class_uid)
source_ref.ReferencedSOPInstanceUID = str(source_sop_instance_uid)
ds.SourceImageSequence = pydicom.sequence.Sequence([source_ref])
ds.ContentDate = date_str ds.ContentDate = date_str
ds.ContentTime = time_str ds.ContentTime = time_str
ds.InstanceCreationDate = date_str ds.InstanceCreationDate = date_str
@@ -345,7 +362,14 @@ def _stamp_derived_dataset(
return ds return ds
def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, series_description=None): def _downsample_dicom_dataset(
ds,
reduction_pct,
*,
series_instance_uid=None,
source_series_instance_uid=None,
series_description=None,
):
if reduction_pct <= 0 or reduction_pct >= 100: if reduction_pct <= 0 or reduction_pct >= 100:
raise ValueError("Downsample percentage must be between 1 and 99") raise ValueError("Downsample percentage must be between 1 and 99")
@@ -363,6 +387,7 @@ def _downsample_dicom_dataset(ds, reduction_pct, *, series_instance_uid=None, se
_stamp_derived_dataset( _stamp_derived_dataset(
ds, ds,
series_instance_uid=series_instance_uid, series_instance_uid=series_instance_uid,
source_series_instance_uid=source_series_instance_uid,
series_description=series_description, series_description=series_description,
derivation_description=derivation_description, derivation_description=derivation_description,
image_type=["DERIVED", "SECONDARY"], image_type=["DERIVED", "SECONDARY"],
@@ -397,6 +422,7 @@ def _create_series_derivative(source_series, description_suffix):
derived.pk = None derived.pk = None
derived.id = None derived.id = None
derived.series_instance_uid = generate_uid() derived.series_instance_uid = generate_uid()
derived.source_series_instance_uid = source_series.series_instance_uid
derived.description = f"{source_series.description or 'Series'} [{description_suffix}]" derived.description = f"{source_series.description or 'Series'} [{description_suffix}]"
derived.modified = Series.SeriesModifiedChocies.RE derived.modified = Series.SeriesModifiedChocies.RE
derived.save() derived.save()
@@ -673,6 +699,7 @@ def series_optimize_htmx(request, series_id):
ds, ds,
downsample_pct, downsample_pct,
series_instance_uid=preview_series.series_instance_uid, series_instance_uid=preview_series.series_instance_uid,
source_series_instance_uid=series.series_instance_uid,
series_description=preview_series.description, series_description=preview_series.description,
) )
except Exception as exc: except Exception as exc:
@@ -712,44 +739,22 @@ def series_optimize_htmx(request, series_id):
) )
) )
downsampled = [] task_result = series_downsample_task.enqueue(
replacement_series_uid = generate_uid() series_id=series.pk,
for image in full_series_images: user_id=request.user.pk,
ds = _read_series_image_dataset(image) downsample_pct=downsample_pct,
if ds is None: )
continue
try:
original_hash = image.image_blake3_hash
ds_out = _downsample_dicom_dataset(
ds,
downsample_pct,
series_instance_uid=replacement_series_uid,
series_description=series.description,
)
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.series_instance_uid = replacement_series_uid
series.save(update_fields=["modified", "series_instance_uid"])
return HttpResponse( 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>' (
'<div class="alert alert-info mb-2">'
'Downsample queued. This runs in the background to avoid request timeout.'
'</div>'
f'<div id="downsample-task-status" hx-get="{reverse("atlas:series_downsample_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 downsample task...</span></div></div>'
)
) )
if operation == "reconstruct": if operation == "reconstruct":
@@ -879,10 +884,74 @@ def series_reconstruct_status_htmx(request, series_id, task_id):
) )
@login_required
def series_downsample_status_htmx(request, series_id, task_id):
"""HTMX endpoint returning progress for a queued series downsample task."""
series = get_object_or_404(Series, pk=series_id)
target_id = (request.GET.get("target_id") or "downsample-task-status").strip() or "downsample-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_downsample_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_downsample_status", kwargs={"series_id": series.pk, "task_id": task_id})
progress = cache.get(f"series_downsample_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", "Downsample 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} images processed</div>'
'</div>'
)
)
if state == TaskResultStatus.SUCCESSFUL:
payload = task_result.return_value or {}
updated = int(payload.get("updated_images", 0) or 0)
downsample_pct = escape(str(payload.get("downsample_pct", "")))
return HttpResponse(
f'<div id="{target_id}" class="alert alert-success mb-0">Downsample complete ({downsample_pct}%). Updated <strong>{updated}</strong> images while preserving original hashes.</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">Downsample failed: {err}</div>'
)
return HttpResponse(
f'<div id="{target_id}" class="alert alert-secondary mb-0">Task state: {escape(str(state))}</div>'
)
@login_required @login_required
@user_is_atlas_editor @user_is_atlas_editor
def task_overview(request): def task_overview(request):
"""Atlas admin-style task overview page with active/completed/failed task summaries.""" """Atlas editor task control page for inspecting and recovering queued/background tasks."""
if DBTaskResult is None: if DBTaskResult is None:
return render( return render(
request, request,
@@ -930,6 +999,27 @@ def task_overview(request):
if blocked: if blocked:
msg += f" Skipped {blocked} running task(s)." msg += f" Skipped {blocked} running task(s)."
messages.success(request, msg) messages.success(request, msg)
elif action == "reset_stale_running":
stale_minutes_raw = (request.POST.get("stale_minutes") or "30").strip()
try:
stale_minutes = max(int(stale_minutes_raw), 1)
except ValueError:
stale_minutes = 30
cutoff = timezone.now() - timedelta(minutes=stale_minutes)
updated = selected_qs.filter(status="RUNNING", started_at__lt=cutoff).update(
status="READY",
started_at=None,
finished_at=None,
worker_ids="",
exception_class_path="",
traceback="",
return_value=None,
)
messages.success(
request,
f"Reset {updated} stale RUNNING task(s) to READY (older than {stale_minutes} minute(s)).",
)
elif action == "delete_completed": elif action == "delete_completed":
deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete() deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete()
messages.success(request, f"Deleted {deleted} completed task record(s).") messages.success(request, f"Deleted {deleted} completed task record(s).")
@@ -1535,11 +1625,13 @@ def case_displaysets_delete(request, pk):
def get_case_for_case_detail(pk: int) -> Case: def get_case_for_case_detail(pk: int) -> Case:
"""Return a case with eager-loaded relations used by the case detail page."""
try: try:
case = ( case = (
Case.objects Case.objects
.select_related() # Add any FK fields if needed .select_related() # Add any FK fields if needed
.prefetch_related( .prefetch_related(
Prefetch("author", queryset=User.objects.all()),
Prefetch( Prefetch(
"seriesdetail_set", "seriesdetail_set",
queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related( queryset=SeriesDetail.objects.select_related("series").order_by("sort_order").prefetch_related(
@@ -1581,9 +1673,26 @@ def get_case_for_case_detail(pk: int) -> Case:
@login_required @login_required
@user_has_case_view_access @user_has_case_view_access
def case_detail(request, pk): def case_detail(request, pk):
"""Render the Atlas case detail page with precomputed series and move targets."""
case = get_case_for_case_detail(pk) case = get_case_for_case_detail(pk)
can_edit = case.check_user_can_edit(request.user) can_edit = case.check_user_can_edit(request.user)
move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case) move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
ordered_series = list(getattr(case, "ordered_series", case.get_ordered_series()))
for series in ordered_series:
prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get("images")
if prefetched_images is not None:
series.visible_image_count = len(prefetched_images)
else:
series.visible_image_count = series.images.filter(removed=False).count()
move_series_recent_cases = list(
get_cases_available_to_user(request.user)
.exclude(pk=case.pk)
.filter(author=request.user)
.order_by("-created_date")
.prefetch_related("author", "condition", "presentation", "subspecialty")[:5]
)
return render( return render(
@@ -1594,6 +1703,8 @@ def case_detail(request, pk):
"cimar_sid": request.user.userprofile.cimar_sid, "cimar_sid": request.user.userprofile.cimar_sid,
"can_edit": can_edit, "can_edit": can_edit,
"move_series_form": move_series_form, "move_series_form": move_series_form,
"move_series_recent_cases": move_series_recent_cases,
"ordered_series": ordered_series,
# Provide helper lists for resource search filters # Provide helper lists for resource search filters
"site_queryset": Site.objects.all(), "site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(), "subspecialty_queryset": Subspecialty.objects.all(),
@@ -7845,6 +7956,22 @@ def collection_case_view(request, pk, case_number=None, case_id=None):
casedetail = CaseDetail.objects.get(case=case, collection=collection) casedetail = CaseDetail.objects.get(case=case, collection=collection)
series_list = case.series.all().prefetch_related("images", "examination", "plane") series_list = case.series.all().prefetch_related("images", "examination", "plane")
ordered_series = list(getattr(case, "ordered_series", case.get_ordered_series()))
for series in ordered_series:
prefetched_images = getattr(series, "_prefetched_objects_cache", {}).get("images")
if prefetched_images is not None:
series.visible_image_count = len(prefetched_images)
else:
series.visible_image_count = series.images.filter(removed=False).count()
move_series_form = MoveSelectedSeriesForm(user=request.user, source_case=case)
move_series_recent_cases = list(
get_cases_available_to_user(request.user)
.exclude(pk=case.pk)
.filter(author=request.user)
.order_by("-created_date")
.prefetch_related("author", "condition", "presentation", "subspecialty")[:5]
)
previous = collection.get_previous_case(casedetail.case) previous = collection.get_previous_case(casedetail.case)
next = collection.get_next_case(casedetail.case) next = collection.get_next_case(casedetail.case)
@@ -7860,12 +7987,17 @@ def collection_case_view(request, pk, case_number=None, case_id=None):
"case": case, "case": case,
"casedetail": casedetail, "casedetail": casedetail,
"series_list": series_list, "series_list": series_list,
"ordered_series": ordered_series,
"case_number": case_number, "case_number": case_number,
"previous": previous, "previous": previous,
"next": next, "next": next,
"can_edit": can_edit, "can_edit": can_edit,
"move_series_form": move_series_form,
"move_series_recent_cases": move_series_recent_cases,
"answer": answer, "answer": answer,
"cimar_sid": request.user.userprofile.cimar_sid, "cimar_sid": request.user.userprofile.cimar_sid,
"site_queryset": Site.objects.all(),
"subspecialty_queryset": Subspecialty.objects.all(),
}, },
) )
@@ -9014,21 +9146,28 @@ def remove_case_from_collection(request, case_pk, collection_pk):
return HttpResponse(f"Case removed from collection: {collection.name}") return HttpResponse(f"Case removed from collection: {collection.name}")
@login_required
@require_POST @require_POST
def remove_selected_series_from_case(request, case_pk): def remove_selected_series_from_case(request, case_pk):
ids = request.POST.getlist('series-ids') ids = request.POST.getlist('series-ids') or request.POST.getlist('series_ids')
case = Case.objects.get(pk=case_pk) case = get_object_or_404(Case, pk=case_pk)
if not case.check_user_can_edit(request.user): if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to edit this case.", status=403) return HttpResponse("You do not have permission to edit this case.", status=403)
if not ids: if not ids:
return HttpResponse("No series selected for removal.") return HttpResponse("No series selected for removal.", status=400)
linked_series_ids = list(
Series.objects.filter(pk__in=ids, case=case).values_list("pk", flat=True).distinct()
)
if not linked_series_ids:
return HttpResponse("No valid series selected for removal.", status=400)
# Remove the association (for a through model) # Remove the association (for a through model)
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete() SeriesDetail.objects.filter(case=case, series_id__in=linked_series_ids).delete()
return HttpResponse(f"Series removed from case.") return HttpResponse(f"Removed {len(linked_series_ids)} series from this case.")
@login_required @login_required
@@ -9225,18 +9364,35 @@ def seriesfinding_migration_status(request):
"findings_status": findings_status "findings_status": findings_status
}) })
@login_required
@require_POST
def series_bulk_delete(request): def series_bulk_delete(request):
if request.method == "POST": ids = request.POST.getlist("series_ids") or request.POST.getlist("series-ids")
ids = request.POST.getlist("series_ids") if not ids:
# Only delete series the user has permission to delete response = HttpResponse("No series selected to delete.", status=400)
series_to_delete = Series.objects.filter(pk__in=ids) return response if request.htmx else redirect("atlas:series_view")
deleted_ids = []
for series in series_to_delete: series_to_delete = list(Series.objects.filter(pk__in=ids).distinct())
if series.check_user_can_edit(request.user): if not series_to_delete:
series.delete() response = HttpResponse("No valid series selected to delete.", status=400)
deleted_ids.append(series.pk) return response if request.htmx else redirect("atlas:series_view")
# Optionally, you could add a message here about which were deleted
return redirect('atlas:series_view') forbidden_series = [series.pk for series in series_to_delete if not series.check_user_can_edit(request.user)]
if forbidden_series:
response = HttpResponse(
f"You do not have permission to delete {len(forbidden_series)} selected series.",
status=403,
)
return response if request.htmx else redirect("atlas:series_view")
deleted_count = len(series_to_delete)
for series in series_to_delete:
series.delete()
response = HttpResponse(f"Deleted {deleted_count} series.")
if request.htmx:
return response
return redirect(request.META.get("HTTP_REFERER") or reverse("atlas:series_view"))
@user_is_collection_author_or_atlas_editor @user_is_collection_author_or_atlas_editor
def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None): def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None):
+74 -73
View File
@@ -2,6 +2,7 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
@@ -46,78 +47,78 @@
<details> <details>
<summary>Edit CID exams</summary> <summary>Edit CID exams</summary>
<p>This should be done via the exams pages one via group management.</p> <p>This should be done via the exams pages one via group management.</p>
<p>Select exams below to add user(s) to (or edit existing user(s)).</p> <p>Select exams below to add user(s) to (or edit existing user(s)).</p>
<div class="exam-selectors"> <div class="exam-selectors">
<span> <span>
Physics Exams<br /> Physics Exams<br />
<select id="physics-exams" multiple="multiple"> <select id="physics-exams" multiple="multiple">
{% for name, id in physics_exams %} {% for name, id in physics_exams %}
<option value="{{id}}">{{name}}</option> <option value="{{id}}">{{name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
<span> <span>
Rapid Exams<br /> Rapid Exams<br />
<select id="rapid-exams" multiple="multiple"> <select id="rapid-exams" multiple="multiple">
{% for name, id in rapid_exams %} {% for name, id in rapid_exams %}
<option value="{{id}}">{{name}}</option> <option value="{{id}}">{{name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
<span> <span>
Sba Exams<br /> Sba Exams<br />
<select id="sbas-exams" multiple="multiple"> <select id="sbas-exams" multiple="multiple">
{% for name, id in sba_exams %} {% for name, id in sba_exams %}
<option value="{{id}}">{{name}}</option> <option value="{{id}}">{{name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
<span> <span>
Long Exams<br /> Long Exams<br />
<select id="longs-exams" multiple="multiple"> <select id="longs-exams" multiple="multiple">
{% for name, id in longs_exams %} {% for name, id in longs_exams %}
<option value="{{id}}">{{name}}</option> <option value="{{id}}">{{name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
<span> <span>
Anatomy Exams<br /> Anatomy Exams<br />
<select id="anatomy-exams" multiple="multiple"> <select id="anatomy-exams" multiple="multiple">
{% for name, id in anatomy_exams %} {% for name, id in anatomy_exams %}
<option value="{{id}}">{{name}}</option> <option value="{{id}}">{{name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
<span> <span>
Shorts Exams<br /> Shorts Exams<br />
<select id="shorts-exams" multiple="multiple"> <select id="shorts-exams" multiple="multiple">
{% for name, id in shorts_exams %} {% for name, id in shorts_exams %}
<option value="{{id}}">{{name}}</option> <option value="{{id}}">{{name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
<span> <span>
CaseCollection Exams<br /> CaseCollection Exams<br />
<select id="casecollection-exams" multiple="multiple"> <select id="casecollection-exams" multiple="multiple">
{% for name, id in casecollection_exams %} {% for name, id in casecollection_exams %}
<option value="{{id}}">{{name}}</option> <option value="{{id}}">{{name}}</option>
{% endfor %} {% endfor %}
</select> </select>
</span> </span>
</div> </div>
<button id="add-exams-selected-cids">Add selected exams</button> <button id="add-exams-selected-cids">Add selected exams</button>
<button id="change-exams-selected-cids">Change selected exams</button> <button id="change-exams-selected-cids">Change selected exams</button>
<button id="clear-exams-selected-cids">Clear selected exams</button> <button id="clear-exams-selected-cids">Clear selected exams</button>
</details> </details>
</details> </details>
@@ -182,20 +183,20 @@
}) })
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly // $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
.done(function (data) { .done(function (data) {
console.log(data); console.log(data);
if (data.status == "success") { if (data.status == "success") {
toastr.info('Added/Updated'); toastr.info('Added/Updated');
location.reload(); location.reload();
} else { } else {
toastr.error(data.details) toastr.error(data.details)
} }
// show some message according to the response. // show some message according to the response.
// For eg. A message box showing that the status has been changed // For eg. A message box showing that the status has been changed
}) })
.always(function () { .always(function () {
console.log('[Done]'); console.log('[Done]');
}) })
} }
+28 -27
View File
@@ -2,6 +2,7 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block css %} {% block css %}
{% endblock %} {% endblock %}
@@ -15,41 +16,41 @@
<div id="htmx-info"></div> <div id="htmx-info"></div>
<div id="htmx-options"></div> <div id="htmx-options"></div>
<button id="bulk-delete-supervisors-button" <button id="bulk-delete-supervisors-button"
title="Deletes the supervisors of all selected users" title="Deletes the supervisors of all selected users"
hx-post="{% url 'generic:users_bulk_delete_supervisors' %}" hx-post="{% url 'generic:users_bulk_delete_supervisors' %}"
hx-include="[name='selection']" hx-include="[name='selection']"
hx-confirm="This will delete supervisors from all selected users, are you sure you wish to continue?" hx-confirm="This will delete supervisors from all selected users, are you sure you wish to continue?"
hx-target="#htmx-info" hx-target="#htmx-info"
_='on click put "" into #htmx-options' _='on click put "" into #htmx-options'
>Delete supervisors</button> >Delete supervisors</button>
<button id="bulk-edit-grade-button" <button id="bulk-edit-grade-button"
hx-post="{% url 'generic:users_bulk_edit' %}" hx-post="{% url 'generic:users_bulk_edit' %}"
hx-include="[name='selection']" hx-include="[name='selection']"
hx-target="#htmx-options" hx-target="#htmx-options"
title="Changes the selected users grade." title="Changes the selected users grade."
>Edit grade</button> >Edit grade</button>
<button id="bulk-add-group-button" <button id="bulk-add-group-button"
hx-post="{% url 'generic:users_bulk_edit' %}" hx-post="{% url 'generic:users_bulk_edit' %}"
hx-include="[name='selection']" hx-include="[name='selection']"
hx-target="#htmx-options" hx-target="#htmx-options"
title="Adds the selected user to a group." title="Adds the selected user to a group."
>Add group</button> >Add group</button>
<button id="bulk-remove-group-button" <button id="bulk-remove-group-button"
hx-post="{% url 'generic:users_bulk_edit' %}" hx-post="{% url 'generic:users_bulk_edit' %}"
hx-include="[name='selection']" hx-include="[name='selection']"
hx-target="#htmx-options" hx-target="#htmx-options"
title="Removes the selected user from a group." title="Removes the selected user from a group."
>Remove group</button> >Remove group</button>
<button id="bulk-deactivate-user-button" <button id="bulk-deactivate-user-button"
hx-post="{% url 'generic:users_bulk_edit' %}" hx-post="{% url 'generic:users_bulk_edit' %}"
hx-include="[name='selection']" hx-include="[name='selection']"
hx-target="#htmx-options" hx-target="#htmx-options"
hx-confirm="This will deactivate all selected users, are you sure you wish to continue?" hx-confirm="This will deactivate all selected users, are you sure you wish to continue?"
title="Deactivates the selected users" title="Deactivates the selected users"
>Deactivate</button> >Deactivate</button>
</details> </details>
+26 -10
View File
@@ -5855,15 +5855,31 @@ def create_user(request, context=None, trainee: bool = False):
if form.is_valid(): if form.is_valid():
try: try:
username = form.cleaned_data["username"].strip().lower()
existing_user = User.objects.filter(
Q(username__iexact=username) | Q(email__iexact=username)
).first()
user_dict = { user_dict = {
"username": request.POST["username"], "username": username,
"first_name": request.POST["first_name"], "first_name": form.cleaned_data["first_name"],
"last_name": request.POST["last_name"], "last_name": form.cleaned_data["last_name"],
"email": request.POST["username"], "email": username,
"password": secrets.token_hex(nbytes=16), "password": secrets.token_hex(nbytes=16),
} }
new_user = User.objects.create_user(**user_dict) with transaction.atomic():
if existing_user is not None:
existing_user.username = user_dict["username"]
existing_user.email = user_dict["email"]
existing_user.first_name = user_dict["first_name"]
existing_user.last_name = user_dict["last_name"]
existing_user.save(
update_fields=["username", "email", "first_name", "last_name"]
)
new_user = existing_user
else:
new_user = User.objects.create_user(**user_dict)
except Exception as error: except Exception as error:
errors = f"Unable to create account: {error}" errors = f"Unable to create account: {error}"
return render( return render(
@@ -5873,15 +5889,15 @@ def create_user(request, context=None, trainee: bool = False):
) )
try: try:
user_profile = UserProfile.objects.get(user=new_user) user_profile, _ = UserProfile.objects.get_or_create(user=new_user)
user_profile.peninsula_trainee = trainee user_profile.peninsula_trainee = trainee
if request.POST["grade"]: if form.cleaned_data.get("grade"):
grade = UserGrades.objects.get(pk=request.POST["grade"]) grade = form.cleaned_data["grade"]
user_profile.grade = grade user_profile.grade = grade
if "supervisor" in request.POST and request.POST["supervisor"]: if trainee and form.cleaned_data.get("supervisor"):
supervisor = Supervisor.objects.get(pk=request.POST["supervisor"]) supervisor = form.cleaned_data["supervisor"]
user_profile.supervisor = supervisor user_profile.supervisor = supervisor
user_profile.save() user_profile.save()
+6
View File
@@ -3,6 +3,12 @@
{ {
"name": "rad", "name": "rad",
"path": "." "path": "."
},
{
"path": "../../dv3d"
},
{
"path": "../../uploader"
} }
], ],
"settings": { "settings": {
+219 -219
View File
File diff suppressed because one or more lines are too long
+51
View File
@@ -1,5 +1,6 @@
from django.urls import reverse from django.urls import reverse
from django.core import mail
from rich.pretty import pprint from rich.pretty import pprint
@@ -89,3 +90,53 @@ def test_profile_access_and_management(db, client, create_users, create_cid_mana
assert BeautifulSoup(update_profile_response.content, "html.parser").find(id="id_registration_number")["value"] assert BeautifulSoup(update_profile_response.content, "html.parser").find(id="id_registration_number")["value"]
@pytest.mark.django_db
def test_create_user_reuses_existing_account(client, create_cid_manager, django_user_model):
existing_user = django_user_model.objects.create_user(
username="existing@example.com",
email="existing@example.com",
password="password123",
first_name="Old",
last_name="Name",
)
client.force_login(create_cid_manager)
response = client.post(
reverse("create_user"),
{
"username": "existing@example.com",
"first_name": "Updated",
"last_name": "User",
"grade": "",
},
)
assert response.status_code == 302
assert response.url == reverse("accounts_list")
existing_user.refresh_from_db()
assert existing_user.first_name == "Updated"
assert existing_user.last_name == "User"
@pytest.mark.django_db
def test_cid_manager_can_send_password_reset_from_account_profile(client, create_cid_manager, django_user_model):
managed_user = django_user_model.objects.create_user(
username="managed-user",
email="managed@example.com",
password="password123",
)
client.force_login(create_cid_manager)
response = client.post(
reverse("account_send_password_reset", kwargs={"slug": managed_user.username}),
{"next": reverse("account_profile", kwargs={"slug": managed_user.username})},
)
assert response.status_code == 302
assert "password_reset_action=sent" in response.url
assert len(mail.outbox) == 1
assert mail.outbox[0].to == ["managed@example.com"]
+5
View File
@@ -107,6 +107,11 @@ urlpatterns = [
views.account_set_supervisor, views.account_set_supervisor,
name="account_set_supervisor", name="account_set_supervisor",
), ),
path(
"accounts/profile/<str:slug>/send-password-reset/",
views.account_send_password_reset,
name="account_send_password_reset",
),
#path("", TemplateView.as_view(template_name="index.html"), name="home"), #path("", TemplateView.as_view(template_name="index.html"), name="home"),
path("", views.index, name="home"), path("", views.index, name="home"),
path("cid/results/<int:cid>/", views.cid_results, name="cid_results"), path("cid/results/<int:cid>/", views.cid_results, name="cid_results"),
+35
View File
@@ -30,6 +30,7 @@ from django.utils.safestring import mark_safe
# from django.contrib.auth.models import User # from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib.auth.decorators import login_required, user_passes_test
from django.contrib.auth.forms import PasswordResetForm
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@@ -167,6 +168,7 @@ def profile(request):
"is_cid_user_manager": is_cid_user_manager, "is_cid_user_manager": is_cid_user_manager,
"supervisor_account": supervisor_account, "supervisor_account": supervisor_account,
"supervisor_action": request.GET.get("supervisor_action", ""), "supervisor_action": request.GET.get("supervisor_action", ""),
"password_reset_action": request.GET.get("password_reset_action", ""),
}, },
) )
@@ -216,6 +218,7 @@ def account_profile(request, slug):
"is_cid_user_manager": is_cid_user_manager, "is_cid_user_manager": is_cid_user_manager,
"supervisor_account": supervisor_account, "supervisor_account": supervisor_account,
"supervisor_action": request.GET.get("supervisor_action", ""), "supervisor_action": request.GET.get("supervisor_action", ""),
"password_reset_action": request.GET.get("password_reset_action", ""),
}, },
) )
@@ -257,6 +260,38 @@ def account_set_supervisor(request, slug):
return redirect(f"{next_url}?supervisor_action=created") return redirect(f"{next_url}?supervisor_action=created")
@require_POST
@user_is_cid_user_manager
def account_send_password_reset(request, slug):
"""Send a password reset email for an existing user from their profile page."""
user = get_object_or_404(User, username=slug)
next_url = request.POST.get("next") or reverse("account_profile", kwargs={"slug": user.username})
email = (user.email or user.username or "").strip()
if not email:
return redirect(f"{next_url}?password_reset_action=no_email")
if not user.is_active:
return redirect(f"{next_url}?password_reset_action=inactive")
form = PasswordResetForm({"email": email})
if not form.is_valid():
return redirect(f"{next_url}?password_reset_action=invalid")
try:
form.save(
request=request,
use_https=request.is_secure(),
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
)
except Exception:
logger.exception("Failed to send password reset email for user {}", user.pk)
return redirect(f"{next_url}?password_reset_action=error")
return redirect(f"{next_url}?password_reset_action=sent")
@user_passes_test(lambda u: u.is_superuser) @user_passes_test(lambda u: u.is_superuser)
def logs_view(request): def logs_view(request):
"""Display production logs from log.txt with search filtering. """Display production logs from log.txt with search filtering.
+3 -1
View File
@@ -61,4 +61,6 @@ pygments
celery[redis] celery[redis]
redis redis
pywatchman pywatchman
numpy==2.4.0 numpy==2.4.0
django-tasks
django-tasks-db
+13 -6
View File
@@ -127,8 +127,16 @@
max-width: 100%; max-width: 100%;
} }
div.content.container { .content.content-read {
max-width: 100%; max-width: 1280px;
margin-left: auto;
margin-right: auto;
}
.content.content-wide {
max-width: min(98vw, 2200px);
margin-left: auto;
margin-right: auto;
} }
@media (max-width: 1210px) { @media (max-width: 1210px) {
@@ -138,10 +146,9 @@
padding-right: .5rem !important; padding-right: .5rem !important;
} }
div.content.container { .content {
padding-left: .5rem !important; padding-left: .5rem !important;
padding-right: .5rem !important; padding-right: .5rem !important;
max-width: 100% !important;
} }
} }
@@ -152,7 +159,7 @@
padding-right: .75rem !important; padding-right: .75rem !important;
} }
div.content.container { .content {
padding-left: .75rem !important; padding-left: .75rem !important;
padding-right: .75rem !important; padding-right: .75rem !important;
} }
@@ -358,7 +365,7 @@
</div> </div>
</div> </div>
</nav> </nav>
<div class="content container"> <div class="content {% block content_container_class %}container content-read{% endblock %}">
{% block navigation %} {% block navigation %}
{% endblock %} {% endblock %}
<div class="row clear-both"> <div class="row clear-both">
+20 -2
View File
@@ -104,13 +104,31 @@
<div class="alert alert-danger py-2" role="alert">Cannot link supervisor account: email is already linked to another user.</div> <div class="alert alert-danger py-2" role="alert">Cannot link supervisor account: email is already linked to another user.</div>
{% endif %} {% endif %}
<a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}">Change password</a> {% if password_reset_action == 'sent' %}
<div class="alert alert-success py-2" role="alert">Password reset email sent.</div>
{% elif password_reset_action == 'no_email' %}
<div class="alert alert-warning py-2" role="alert">Cannot send password reset: user has no email address.</div>
{% elif password_reset_action == 'inactive' %}
<div class="alert alert-warning py-2" role="alert">Cannot send password reset: user account is inactive.</div>
{% elif password_reset_action == 'invalid' %}
<div class="alert alert-danger py-2" role="alert">Cannot send password reset: email address is invalid.</div>
{% elif password_reset_action == 'error' %}
<div class="alert alert-danger py-2" role="alert">Password reset email could not be sent.</div>
{% endif %}
<a class="btn btn-outline-primary d-block mb-2" href="{% url 'password_change' %}" id="password-change">Change password</a>
{% if request.user|has_group:"cid_user_manager" %} {% if request.user|has_group:"cid_user_manager" %}
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a> <a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
{% endif %} {% endif %}
<a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}">Edit profile</a> <a class="btn btn-primary d-block mb-2" href="{% url 'account_profile_update' user.username %}" id="update-profile-link">Edit profile</a>
{% if request.user.is_superuser or is_cid_user_manager %} {% if request.user.is_superuser or is_cid_user_manager %}
<form method="post" action="{% url 'account_send_password_reset' user.username %}" class="mb-2">
{% csrf_token %}
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button type="submit" class="btn btn-outline-warning d-block w-100">Send password reset link</button>
</form>
{% if not supervisor_account %} {% if not supervisor_account %}
<form method="post" action="{% url 'account_set_supervisor' user.username %}" class="mb-2"> <form method="post" action="{% url 'account_set_supervisor' user.username %}" class="mb-2">
{% csrf_token %} {% csrf_token %}
+12 -10
View File
@@ -3,6 +3,8 @@
{% load crispy_forms_tags %} {% load crispy_forms_tags %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
@@ -11,15 +13,15 @@
<div class="mb-2 mb-md-0"> <div class="mb-2 mb-md-0">
<div class="btn-toolbar" role="toolbar"> <div class="btn-toolbar" role="toolbar">
{% if user.is_superuser %} {% if user.is_superuser %}
<div class="btn-group me-2" role="group"> <div class="btn-group me-2" role="group">
<button id="delete-selected-btn" class="btn btn-danger btn-sm" <button id="delete-selected-btn" class="btn btn-danger btn-sm"
hx-post="{% url 'generic:bulk_delete_questions' %}" hx-post="{% url 'generic:bulk_delete_questions' %}"
hx-include="[name='selection']:checked" hx-include="[name='selection']:checked"
hx-vals='{"app":"{{ app_name|escapejs }}"}' hx-vals='{"app":"{{ app_name|escapejs }}"}'
hx-target="#action-result" hx-target="#action-result"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button> hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button>
</div> </div>
{% endif %} {% endif %}
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button id="button-select-add-exam" class="btn btn-outline-primary btn-sm" <button id="button-select-add-exam" class="btn btn-outline-primary btn-sm"
@@ -39,7 +41,7 @@
</div> </div>
{% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %} {% include "generic/partials/filter_bar.html" with filter=filter app_name=app_name collapse_id="bottom-filter-body" %}
<!-- Minibar: small visible strip when panel is collapsed --> <!-- Minibar: small visible strip when panel is collapsed -->
{% endblock %} {% endblock %}