Compare commits
31 Commits
93e24bf855
...
064f039a24
| Author | SHA1 | Date | |
|---|---|---|---|
| 064f039a24 | |||
| c0845287da | |||
| 32c6dc6f65 | |||
| b9f511f636 | |||
| dcc347793d | |||
| 63ea4cdf23 | |||
| c55797636a | |||
| e016175d2b | |||
| 682bde82b9 | |||
| 53109e7cb2 | |||
| 3065a279b1 | |||
| 2f8b61a4be | |||
| 2b10f2e73c | |||
| 20e24808fc | |||
| 714857e3d2 | |||
| 7019ac2ef8 | |||
| 16fea039d7 | |||
| fc38776ce2 | |||
| d132625419 | |||
| 5614961980 | |||
| f3af6d47ee | |||
| b09fbee2c9 | |||
| 4a104be71f | |||
| dbf0fc262c | |||
| 026350f9d4 | |||
| b34970ed7f | |||
| 867d92bff9 | |||
| 4d1423134a | |||
| 410b71f07f | |||
| e51e8358bb | |||
| 5f69cdcaf9 |
@@ -32,6 +32,8 @@ from reversion.admin import VersionAdmin
|
||||
from django.forms.widgets import RadioSelect
|
||||
from django.utils.html import format_html
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
@@ -226,6 +228,24 @@ if DBTaskResult is not None:
|
||||
msg += f" Skipped {blocked} running task(s)."
|
||||
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")
|
||||
def delete_selected_completed(self, request, queryset):
|
||||
completed_qs = queryset.filter(status="SUCCESSFUL")
|
||||
@@ -278,6 +298,7 @@ if DBTaskResult is not None:
|
||||
admin_cls.actions = (
|
||||
"reset_failed_to_ready",
|
||||
"reset_selected_to_ready",
|
||||
"reset_stale_running_to_ready",
|
||||
"delete_selected_completed",
|
||||
"delete_selected_failed",
|
||||
)
|
||||
@@ -339,6 +360,7 @@ if DBTaskResult is not None:
|
||||
admin_cls.traceback_pretty = traceback_pretty
|
||||
admin_cls.reset_failed_to_ready = reset_failed_to_ready
|
||||
admin_cls.reset_selected_to_ready = reset_selected_to_ready
|
||||
admin_cls.reset_stale_running_to_ready = reset_stale_running_to_ready
|
||||
admin_cls.delete_selected_completed = delete_selected_completed
|
||||
admin_cls.delete_selected_failed = delete_selected_failed
|
||||
admin_cls.has_add_permission = has_add_permission
|
||||
|
||||
@@ -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
@@ -617,20 +617,37 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
cimar_uuid = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
def get_ordered_series_details(self):
|
||||
# If the series are already prefetched, use them directly
|
||||
if hasattr(self, '_prefetched_objects_cache') and 'series' in self._prefetched_objects_cache:
|
||||
# Get the through model and sort by sort_order
|
||||
through_model = self.series.through
|
||||
# Build a mapping from series pk to through instance
|
||||
through_objs = list(through_model.objects.filter(case=self).select_related('series'))
|
||||
through_objs.sort(key=lambda x: x.sort_order)
|
||||
cached = getattr(self, "_ordered_series_details_cache", None)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
prefetched = getattr(self, "_prefetched_objects_cache", {})
|
||||
if "seriesdetail_set" in prefetched:
|
||||
through_objs = sorted(
|
||||
list(prefetched["seriesdetail_set"]),
|
||||
key=lambda item: item.sort_order,
|
||||
)
|
||||
self._ordered_series_details_cache = 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):
|
||||
"""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):
|
||||
return "atlas"
|
||||
@@ -670,6 +687,7 @@ class Case(models.Model, AuthorMixin, QuestionMixin):
|
||||
|
||||
def get_series_blocks(self):
|
||||
html = ""
|
||||
s: Series
|
||||
for s in self.series.all():
|
||||
html += s.get_block()
|
||||
|
||||
@@ -1114,6 +1132,7 @@ class Series(SeriesBase):
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
# findings = models.TextField(null=True, blank=True, help_text="Findings on the series / stack")
|
||||
|
||||
+101
-2
@@ -92,6 +92,106 @@ from django.core.cache import cache
|
||||
import copy
|
||||
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
|
||||
def push_case_to_cimar_task(case_id):
|
||||
"""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():
|
||||
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"])):
|
||||
arr2d = cfg["slice_fn"](idx)
|
||||
@@ -403,6 +501,7 @@ def series_reconstruct_task(
|
||||
atlas_views._stamp_derived_dataset(
|
||||
ds_new,
|
||||
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()}",
|
||||
derivation_description=(
|
||||
f"{plane_norm.title()} reconstruction; mode={recon_thickness_mode}; "
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -102,52 +102,52 @@
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer or collection.show_ohif_viewer_link or collection.show_built_in_viewer %}
|
||||
<div id="viewer-panel" class="mb-3">
|
||||
{% if collection.show_built_in_viewer %}
|
||||
<div class="d-flex align-items-center gap-2 py-1 px-2 mb-1 rounded bg-body-secondary">
|
||||
<span class="text-muted fw-semibold small me-auto">Viewer</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-sidebar-btn" title="Move viewer to sidebar">
|
||||
<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-popout-btn" title="Open viewer in a separate window">
|
||||
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop out</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="viewer-moveable-home">
|
||||
<div id="viewer-moveable">
|
||||
<div id="viewer-container" class="viewer-resizable-container">
|
||||
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
||||
data-auto-cache-stack="false"
|
||||
data-named-stacks='{{casedetail.get_case_named_stacks}}'
|
||||
></div>
|
||||
<div id="viewer-panel" class="mb-3">
|
||||
{% if collection.show_built_in_viewer %}
|
||||
<div class="d-flex align-items-center gap-2 py-1 px-2 mb-1 rounded bg-body-secondary">
|
||||
<span class="text-muted fw-semibold small me-auto">Viewer</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-sidebar-btn" title="Move viewer to sidebar">
|
||||
<i class="bi bi-layout-sidebar-reverse"></i> <span class="d-none d-sm-inline">Sidebar</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="viewer-popout-btn" title="Open viewer in a separate window">
|
||||
<i class="bi bi-box-arrow-up-right"></i> <span class="d-none d-sm-inline">Pop out</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
|
||||
<div class="viewer-resize-grip-inner"></div>
|
||||
<div id="viewer-moveable-home">
|
||||
<div id="viewer-moveable">
|
||||
<div id="viewer-container" class="viewer-resizable-container">
|
||||
<div id="main_viewer" class="dicom-viewer-root viewer-frame-standard"
|
||||
data-auto-cache-stack="false"
|
||||
data-named-stacks='{{casedetail.get_case_named_stacks}}'
|
||||
></div>
|
||||
</div>
|
||||
<div id="viewer-resize-handle" class="viewer-resize-handle" title="Drag to resize viewer">
|
||||
<div class="viewer-resize-grip-inner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer %}
|
||||
<div{% if collection.show_built_in_viewer %} class="mt-3"{% endif %}>
|
||||
{% if question_completed %}
|
||||
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
||||
{% else %}
|
||||
<iframe id="viewer" class="viewer-frame-ohif" src="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}"></iframe>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer_link %}
|
||||
<div class="mt-2">
|
||||
{% if question_completed %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json_review' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_review_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% else %}
|
||||
<a target="_blank" href="/ohif/viewer/dicomjson?url=https://www.penracourses.org.uk{% if case_number %}{% url 'atlas:collection_case_dicom_json' collection.pk case_number %}{% else %}{% url 'atlas:collection_case_dicom_json_legacy' collection.pk case.pk %}{% endif %}" title="Click to open the case in the advanced viewer. This will open in a new tab/window."><button class="viewer"><i class="bi bi-collection"></i> Launch advanced viewer.</button></a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if collection.show_ohif_viewer %}
|
||||
<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 %}
|
||||
|
||||
|
||||
@@ -351,6 +351,7 @@
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
<span class="fw-semibold">Viewer</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="viewer-sidebar-close-btn">Close</button>
|
||||
@@ -545,6 +546,18 @@
|
||||
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 {
|
||||
transform: translateX(0);
|
||||
}
|
||||
@@ -975,207 +988,244 @@
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
function initViewerControls() {
|
||||
var COLL_PK = '{{ collection.pk }}';
|
||||
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
|
||||
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
|
||||
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
|
||||
var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK;
|
||||
var COLL_PK = '{{ collection.pk }}';
|
||||
var HEIGHT_KEY = 'rad_viewer_h_' + COLL_PK;
|
||||
var MODE_KEY = 'rad_viewer_mode_' + COLL_PK;
|
||||
var POPOUT_STATE_KEY = 'rad_local_viewer_popout_state';
|
||||
var SIDEBAR_W_KEY = 'rad_viewer_w_' + COLL_PK;
|
||||
|
||||
var panel = document.getElementById('viewer-panel');
|
||||
var moveableHome = document.getElementById('viewer-moveable-home');
|
||||
var moveable = document.getElementById('viewer-moveable');
|
||||
var container = document.getElementById('viewer-container');
|
||||
var resizeHandle = document.getElementById('viewer-resize-handle');
|
||||
var sidebarEl = document.getElementById('viewerSidebar');
|
||||
var sidebarBody = document.getElementById('viewerSidebarBody');
|
||||
var sidebarBtn = document.getElementById('viewer-sidebar-btn');
|
||||
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
|
||||
var popoutBtn = document.getElementById('viewer-popout-btn');
|
||||
var mainViewerEl = document.getElementById('main_viewer');
|
||||
var rootStyle = document.documentElement.style;
|
||||
var panel = document.getElementById('viewer-panel');
|
||||
var moveableHome = document.getElementById('viewer-moveable-home');
|
||||
var moveable = document.getElementById('viewer-moveable');
|
||||
var container = document.getElementById('viewer-container');
|
||||
var resizeHandle = document.getElementById('viewer-resize-handle');
|
||||
var sidebarEl = document.getElementById('viewerSidebar');
|
||||
var sidebarBody = document.getElementById('viewerSidebarBody');
|
||||
var sidebarBtn = document.getElementById('viewer-sidebar-btn');
|
||||
var sidebarCloseBtn = document.getElementById('viewer-sidebar-close-btn');
|
||||
var sidebarResizeHandle = document.getElementById('viewer-sidebar-resize-handle');
|
||||
var popoutBtn = document.getElementById('viewer-popout-btn');
|
||||
var mainViewerEl = document.getElementById('main_viewer');
|
||||
var rootStyle = document.documentElement.style;
|
||||
|
||||
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panel.dataset.viewerControlsBound === '1') {
|
||||
return;
|
||||
}
|
||||
panel.dataset.viewerControlsBound = '1';
|
||||
|
||||
var savedH = localStorage.getItem(HEIGHT_KEY);
|
||||
if (savedH && !isNaN(parseInt(savedH, 10))) {
|
||||
container.style.height = parseInt(savedH, 10) + 'px';
|
||||
}
|
||||
|
||||
function applySidebarWidth(px) {
|
||||
if (!sidebarEl || !px || isNaN(px)) return;
|
||||
var clamped = Math.max(360, Math.min(window.innerWidth * 0.95, Math.round(px)));
|
||||
sidebarEl.style.width = clamped + 'px';
|
||||
rootStyle.setProperty('--viewer-sidebar-width', clamped + 'px');
|
||||
}
|
||||
|
||||
var savedW = localStorage.getItem(SIDEBAR_W_KEY);
|
||||
if (savedW && !isNaN(parseInt(savedW, 10))) {
|
||||
applySidebarWidth(parseInt(savedW, 10));
|
||||
} else if (sidebarEl) {
|
||||
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
|
||||
}
|
||||
|
||||
if (sidebarEl && 'ResizeObserver' in window) {
|
||||
var sidebarResizeObserver = new ResizeObserver(function () {
|
||||
var width = Math.round(sidebarEl.getBoundingClientRect().width);
|
||||
rootStyle.setProperty('--viewer-sidebar-width', width + 'px');
|
||||
localStorage.setItem(SIDEBAR_W_KEY, width);
|
||||
});
|
||||
sidebarResizeObserver.observe(sidebarEl);
|
||||
}
|
||||
|
||||
var persistHeight = function () {
|
||||
localStorage.setItem(HEIGHT_KEY, Math.round(container.getBoundingClientRect().height));
|
||||
};
|
||||
|
||||
if ('ResizeObserver' in window) {
|
||||
var ro = new ResizeObserver(function () {
|
||||
persistHeight();
|
||||
});
|
||||
ro.observe(container);
|
||||
} else {
|
||||
window.addEventListener('mouseup', persistHeight);
|
||||
}
|
||||
|
||||
if (resizeHandle) {
|
||||
resizeHandle.addEventListener('pointerdown', function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
function openViewerSidebar() {
|
||||
if (!sidebarEl || !sidebarBody) return;
|
||||
sidebarBody.appendChild(moveable);
|
||||
sidebarEl.classList.add('open');
|
||||
document.body.classList.add('viewer-sidebar-open');
|
||||
rootStyle.setProperty('--viewer-sidebar-width', Math.round(sidebarEl.getBoundingClientRect().width) + 'px');
|
||||
if (sidebarBtn) {
|
||||
sidebarBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Close sidebar</span>';
|
||||
sidebarBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||
if (!panel || !moveableHome || !moveable || !container || !mainViewerEl) {
|
||||
return;
|
||||
}
|
||||
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');
|
||||
if (panel.dataset.viewerControlsBound === '1') {
|
||||
return;
|
||||
}
|
||||
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')) {
|
||||
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) {}
|
||||
var payload = getViewerPayload();
|
||||
broadcastViewerState(payload);
|
||||
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
|
||||
setTimeout(function () { broadcastViewerState(payload); }, 700);
|
||||
localStorage.setItem(MODE_KEY, 'popout');
|
||||
showPopoutPlaceholder();
|
||||
if (popoutBtn) {
|
||||
popoutBtn.innerHTML = '<i class="bi bi-x-lg"></i> <span class="d-none d-sm-inline">Bring back</span>';
|
||||
popoutBtn.classList.replace('btn-outline-secondary', 'btn-secondary');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showPopoutPlaceholder() {
|
||||
var existing = panel.querySelector('.viewer-popout-placeholder');
|
||||
if (existing) return;
|
||||
var placeholder = document.createElement('div');
|
||||
placeholder.className = 'alert alert-secondary small py-2 mb-0 mt-1 d-flex align-items-center gap-2 viewer-popout-placeholder';
|
||||
placeholder.innerHTML = '<i class="bi bi-box-arrow-up-right"></i> Viewer is open in a separate window. <button type="button" class="btn btn-sm btn-outline-secondary ms-auto" id="viewer-popout-bring-back">Bring back</button>';
|
||||
panel.insertBefore(placeholder, moveableHome);
|
||||
moveableHome.style.display = 'none';
|
||||
document.getElementById('viewer-popout-bring-back').addEventListener('click', closePopout);
|
||||
}
|
||||
|
||||
function hidePopoutPlaceholder() {
|
||||
var placeholder = panel.querySelector('.viewer-popout-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
moveableHome.style.display = '';
|
||||
}
|
||||
|
||||
function openPopout() {
|
||||
if (sidebarEl && sidebarEl.classList.contains('open')) {
|
||||
closeViewerSidebar();
|
||||
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 out</span>';
|
||||
popoutBtn.classList.replace('btn-secondary', 'btn-outline-secondary');
|
||||
}
|
||||
}
|
||||
var payload = getViewerPayload();
|
||||
broadcastViewerState(payload);
|
||||
window.open('{% url "atlas:collection_viewer_local" %}', 'collectionTakeViewer', 'width=1200,height=900,noopener');
|
||||
setTimeout(function () { broadcastViewerState(payload); }, 700);
|
||||
localStorage.setItem(MODE_KEY, 'popout');
|
||||
showPopoutPlaceholder();
|
||||
|
||||
if (popoutBtn) {
|
||||
popoutBtn.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');
|
||||
}
|
||||
}
|
||||
|
||||
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 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');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||
<h2 class="mb-0">Collections</h2>
|
||||
{% if request.user.is_staff %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
{% endif %}
|
||||
<div class="container-fluid">
|
||||
<div class="d-flex align-items-start justify-content-between mb-3">
|
||||
<h2 class="mb-0">Collections</h2>
|
||||
{% if request.user.is_staff %}
|
||||
<a class="btn btn-sm btn-primary" href="{% url 'atlas:exam_create' %}">Create collection</a>
|
||||
{% 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>
|
||||
|
||||
<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 %}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<h2>Resources</h2>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -33,42 +34,41 @@
|
||||
{% render_table table %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
|
||||
<script>
|
||||
document.getElementById('bulk-delete-form').addEventListener('submit', function(e) {
|
||||
// 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
|
||||
const checked = document.querySelectorAll('input[name="selection"]:checked');
|
||||
if (checked.length === 0) {
|
||||
alert("Please select at least one series to delete.");
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
const checked = document.querySelectorAll('input[name="selection"]:checked');
|
||||
if (checked.length === 0) {
|
||||
alert("Please select at least one series to delete.");
|
||||
e.preventDefault();
|
||||
return false;
|
||||
}
|
||||
// Add hidden inputs for each checked series
|
||||
checked.forEach(cb => {
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'series_ids';
|
||||
hidden.value = cb.value;
|
||||
this.appendChild(hidden);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
checked.forEach(cb => {
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = 'series_ids';
|
||||
hidden.value = cb.value;
|
||||
this.appendChild(hidden);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div id="exam-options"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
</style>
|
||||
</style>
|
||||
{% endblock css %}
|
||||
|
||||
@@ -2,200 +2,214 @@
|
||||
{% load static %}
|
||||
{% load django_htmx %}
|
||||
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
|
||||
{% block title %}
|
||||
Atlas Task Overview
|
||||
Atlas Task Overview
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-3 px-3">
|
||||
<style>
|
||||
.task-overview-table {
|
||||
min-width: 1650px;
|
||||
}
|
||||
|
||||
.task-overview-table th,
|
||||
.task-overview-table td {
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.task-overview-table .task-col,
|
||||
.task-overview-table .generated-col,
|
||||
.task-overview-table .error-col,
|
||||
.task-overview-table .actions-col {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.task-overview-table .generated-col {
|
||||
min-width: 220px;
|
||||
}
|
||||
</style>
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<h2 class="h4 mb-0">Atlas Task Overview</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a class="btn btn-outline-secondary btn-sm" href="?status=all">All</a>
|
||||
<a class="btn btn-outline-warning btn-sm" href="?status=active">Active</a>
|
||||
<a class="btn btn-outline-success btn-sm" href="?status=completed">Completed</a>
|
||||
<a class="btn btn-outline-danger btn-sm" href="?status=failed">Failed</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if backend_missing %}
|
||||
<div class="alert alert-warning">
|
||||
django_tasks_db backend is not available in this environment. Install and migrate it to see task records.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card border-warning">
|
||||
<div class="card-body py-2">
|
||||
<div class="small text-muted">Active</div>
|
||||
<div class="h4 mb-0 text-warning">{{ counts.active }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-success">
|
||||
<div class="card-body py-2">
|
||||
<div class="small text-muted">Completed</div>
|
||||
<div class="h4 mb-0 text-success">{{ counts.completed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card border-danger">
|
||||
<div class="card-body py-2">
|
||||
<div class="small text-muted">Failed</div>
|
||||
<div class="h4 mb-0 text-danger">{{ counts.failed }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="mb-3">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<select class="form-select form-select-sm" name="task_action" style="max-width: 320px;">
|
||||
<option value="">Choose task action...</option>
|
||||
<option value="reset_failed">Reset FAILED to READY</option>
|
||||
<option value="reset_selected">Reset selected to READY (skip RUNNING)</option>
|
||||
<option value="delete_completed">Delete selected COMPLETED</option>
|
||||
<option value="delete_failed">Delete selected FAILED</option>
|
||||
<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;
|
||||
<div class="container-fluid py-3 px-3">
|
||||
<style>
|
||||
.task-overview-table {
|
||||
min-width: 1650px;
|
||||
}
|
||||
|
||||
selectAll.addEventListener('change', function () {
|
||||
rows.forEach((cb) => {
|
||||
cb.checked = selectAll.checked;
|
||||
.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="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>
|
||||
</div>
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% extends 'atlas/base.html' %}
|
||||
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -576,6 +576,11 @@ urlpatterns = [
|
||||
views.series_reconstruct_status_htmx,
|
||||
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>/authors", views.SeriesAuthorUpdate.as_view(), name="series_authors"),
|
||||
path("series/<int:series_id>/finding/related", views.series_finding_related, name="series_finding_related"),
|
||||
|
||||
+211
-55
@@ -4,6 +4,7 @@ import csv
|
||||
import random
|
||||
import copy
|
||||
import io
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from dal import autocomplete
|
||||
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.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import (
|
||||
FieldError,
|
||||
ObjectDoesNotExist,
|
||||
@@ -163,7 +165,7 @@ from .filters import (
|
||||
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:
|
||||
from django_tasks import TaskResultStatus
|
||||
except ImportError:
|
||||
@@ -311,6 +313,7 @@ def _stamp_derived_dataset(
|
||||
ds,
|
||||
*,
|
||||
series_instance_uid=None,
|
||||
source_series_instance_uid=None,
|
||||
series_description=None,
|
||||
derivation_description=None,
|
||||
image_type=None,
|
||||
@@ -320,6 +323,8 @@ def _stamp_derived_dataset(
|
||||
now = timezone.now()
|
||||
date_str = now.strftime("%Y%m%d")
|
||||
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:
|
||||
ds.SOPInstanceUID = generate_uid()
|
||||
@@ -333,6 +338,18 @@ def _stamp_derived_dataset(
|
||||
if 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.ContentTime = time_str
|
||||
ds.InstanceCreationDate = date_str
|
||||
@@ -345,7 +362,14 @@ def _stamp_derived_dataset(
|
||||
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:
|
||||
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(
|
||||
ds,
|
||||
series_instance_uid=series_instance_uid,
|
||||
source_series_instance_uid=source_series_instance_uid,
|
||||
series_description=series_description,
|
||||
derivation_description=derivation_description,
|
||||
image_type=["DERIVED", "SECONDARY"],
|
||||
@@ -397,6 +422,7 @@ def _create_series_derivative(source_series, description_suffix):
|
||||
derived.pk = None
|
||||
derived.id = None
|
||||
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.modified = Series.SeriesModifiedChocies.RE
|
||||
derived.save()
|
||||
@@ -673,6 +699,7 @@ def series_optimize_htmx(request, series_id):
|
||||
ds,
|
||||
downsample_pct,
|
||||
series_instance_uid=preview_series.series_instance_uid,
|
||||
source_series_instance_uid=series.series_instance_uid,
|
||||
series_description=preview_series.description,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -712,44 +739,22 @@ def series_optimize_htmx(request, series_id):
|
||||
)
|
||||
)
|
||||
|
||||
downsampled = []
|
||||
replacement_series_uid = generate_uid()
|
||||
for image in full_series_images:
|
||||
ds = _read_series_image_dataset(image)
|
||||
if ds is None:
|
||||
continue
|
||||
|
||||
try:
|
||||
original_hash = image.image_blake3_hash
|
||||
ds_out = _downsample_dicom_dataset(
|
||||
ds,
|
||||
downsample_pct,
|
||||
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"])
|
||||
task_result = series_downsample_task.enqueue(
|
||||
series_id=series.pk,
|
||||
user_id=request.user.pk,
|
||||
downsample_pct=downsample_pct,
|
||||
)
|
||||
|
||||
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":
|
||||
@@ -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
|
||||
@user_is_atlas_editor
|
||||
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:
|
||||
return render(
|
||||
request,
|
||||
@@ -930,6 +999,27 @@ def task_overview(request):
|
||||
if blocked:
|
||||
msg += f" Skipped {blocked} running task(s)."
|
||||
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":
|
||||
deleted, _ = selected_qs.filter(status="SUCCESSFUL").delete()
|
||||
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:
|
||||
"""Return a case with eager-loaded relations used by the case detail page."""
|
||||
try:
|
||||
case = (
|
||||
Case.objects
|
||||
.select_related() # Add any FK fields if needed
|
||||
.prefetch_related(
|
||||
Prefetch("author", queryset=User.objects.all()),
|
||||
Prefetch(
|
||||
"seriesdetail_set",
|
||||
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
|
||||
@user_has_case_view_access
|
||||
def case_detail(request, pk):
|
||||
"""Render the Atlas case detail page with precomputed series and move targets."""
|
||||
case = get_case_for_case_detail(pk)
|
||||
can_edit = case.check_user_can_edit(request.user)
|
||||
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(
|
||||
@@ -1594,6 +1703,8 @@ def case_detail(request, pk):
|
||||
"cimar_sid": request.user.userprofile.cimar_sid,
|
||||
"can_edit": can_edit,
|
||||
"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
|
||||
"site_queryset": Site.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)
|
||||
|
||||
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)
|
||||
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,
|
||||
"casedetail": casedetail,
|
||||
"series_list": series_list,
|
||||
"ordered_series": ordered_series,
|
||||
"case_number": case_number,
|
||||
"previous": previous,
|
||||
"next": next,
|
||||
"can_edit": can_edit,
|
||||
"move_series_form": move_series_form,
|
||||
"move_series_recent_cases": move_series_recent_cases,
|
||||
"answer": answer,
|
||||
"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}")
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def remove_selected_series_from_case(request, case_pk):
|
||||
ids = request.POST.getlist('series-ids')
|
||||
case = Case.objects.get(pk=case_pk)
|
||||
ids = request.POST.getlist('series-ids') or request.POST.getlist('series_ids')
|
||||
case = get_object_or_404(Case, pk=case_pk)
|
||||
|
||||
if not case.check_user_can_edit(request.user):
|
||||
return HttpResponse("You do not have permission to edit this case.", status=403)
|
||||
|
||||
|
||||
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)
|
||||
SeriesDetail.objects.filter(case=case, series_id__in=ids).delete()
|
||||
return HttpResponse(f"Series removed from case.")
|
||||
SeriesDetail.objects.filter(case=case, series_id__in=linked_series_ids).delete()
|
||||
return HttpResponse(f"Removed {len(linked_series_ids)} series from this case.")
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -9225,18 +9364,35 @@ def seriesfinding_migration_status(request):
|
||||
"findings_status": findings_status
|
||||
})
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def series_bulk_delete(request):
|
||||
if request.method == "POST":
|
||||
ids = request.POST.getlist("series_ids")
|
||||
# Only delete series the user has permission to delete
|
||||
series_to_delete = Series.objects.filter(pk__in=ids)
|
||||
deleted_ids = []
|
||||
for series in series_to_delete:
|
||||
if series.check_user_can_edit(request.user):
|
||||
series.delete()
|
||||
deleted_ids.append(series.pk)
|
||||
# Optionally, you could add a message here about which were deleted
|
||||
return redirect('atlas:series_view')
|
||||
ids = request.POST.getlist("series_ids") or request.POST.getlist("series-ids")
|
||||
if not ids:
|
||||
response = HttpResponse("No series selected to delete.", status=400)
|
||||
return response if request.htmx else redirect("atlas:series_view")
|
||||
|
||||
series_to_delete = list(Series.objects.filter(pk__in=ids).distinct())
|
||||
if not series_to_delete:
|
||||
response = HttpResponse("No valid series selected to delete.", status=400)
|
||||
return response if request.htmx else 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
|
||||
def collection_case_displaysetup(request, collection_id, case_number=None, case_id=None):
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -46,78 +47,78 @@
|
||||
<details>
|
||||
<summary>Edit CID exams</summary>
|
||||
<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>
|
||||
<div class="exam-selectors">
|
||||
<span>
|
||||
Physics Exams<br />
|
||||
<select id="physics-exams" multiple="multiple">
|
||||
{% for name, id in physics_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
<p>Select exams below to add user(s) to (or edit existing user(s)).</p>
|
||||
<div class="exam-selectors">
|
||||
<span>
|
||||
Physics Exams<br />
|
||||
<select id="physics-exams" multiple="multiple">
|
||||
{% for name, id in physics_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Rapid Exams<br />
|
||||
<select id="rapid-exams" multiple="multiple">
|
||||
{% for name, id in rapid_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Rapid Exams<br />
|
||||
<select id="rapid-exams" multiple="multiple">
|
||||
{% for name, id in rapid_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Sba Exams<br />
|
||||
<select id="sbas-exams" multiple="multiple">
|
||||
{% for name, id in sba_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Sba Exams<br />
|
||||
<select id="sbas-exams" multiple="multiple">
|
||||
{% for name, id in sba_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Long Exams<br />
|
||||
<select id="longs-exams" multiple="multiple">
|
||||
{% for name, id in longs_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Long Exams<br />
|
||||
<select id="longs-exams" multiple="multiple">
|
||||
{% for name, id in longs_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Anatomy Exams<br />
|
||||
<select id="anatomy-exams" multiple="multiple">
|
||||
{% for name, id in anatomy_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Anatomy Exams<br />
|
||||
<select id="anatomy-exams" multiple="multiple">
|
||||
{% for name, id in anatomy_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Shorts Exams<br />
|
||||
<select id="shorts-exams" multiple="multiple">
|
||||
{% for name, id in shorts_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
Shorts Exams<br />
|
||||
<select id="shorts-exams" multiple="multiple">
|
||||
{% for name, id in shorts_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
CaseCollection Exams<br />
|
||||
<select id="casecollection-exams" multiple="multiple">
|
||||
{% for name, id in casecollection_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
CaseCollection Exams<br />
|
||||
<select id="casecollection-exams" multiple="multiple">
|
||||
{% for name, id in casecollection_exams %}
|
||||
<option value="{{id}}">{{name}}</option>
|
||||
{% endfor %}
|
||||
|
||||
</select>
|
||||
</span>
|
||||
</select>
|
||||
</span>
|
||||
|
||||
|
||||
</div>
|
||||
<button id="add-exams-selected-cids">Add selected exams</button>
|
||||
<button id="change-exams-selected-cids">Change selected exams</button>
|
||||
<button id="clear-exams-selected-cids">Clear selected exams</button>
|
||||
|
||||
</div>
|
||||
<button id="add-exams-selected-cids">Add selected exams</button>
|
||||
<button id="change-exams-selected-cids">Change selected exams</button>
|
||||
<button id="clear-exams-selected-cids">Clear selected exams</button>
|
||||
|
||||
</details>
|
||||
</details>
|
||||
|
||||
@@ -182,20 +183,20 @@
|
||||
})
|
||||
// $.ajax().done(), $.ajax().fail(), $ajax().always() are upto you. Add/change accordingly
|
||||
.done(function (data) {
|
||||
console.log(data);
|
||||
console.log(data);
|
||||
|
||||
if (data.status == "success") {
|
||||
toastr.info('Added/Updated');
|
||||
location.reload();
|
||||
} else {
|
||||
toastr.error(data.details)
|
||||
}
|
||||
if (data.status == "success") {
|
||||
toastr.info('Added/Updated');
|
||||
location.reload();
|
||||
} else {
|
||||
toastr.error(data.details)
|
||||
}
|
||||
// show some message according to the response.
|
||||
// For eg. A message box showing that the status has been changed
|
||||
})
|
||||
})
|
||||
.always(function () {
|
||||
console.log('[Done]');
|
||||
})
|
||||
console.log('[Done]');
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
{% load crispy_forms_tags %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
{% block css %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -15,41 +16,41 @@
|
||||
<div id="htmx-info"></div>
|
||||
<div id="htmx-options"></div>
|
||||
<button id="bulk-delete-supervisors-button"
|
||||
title="Deletes the supervisors of all selected users"
|
||||
hx-post="{% url 'generic:users_bulk_delete_supervisors' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-confirm="This will delete supervisors from all selected users, are you sure you wish to continue?"
|
||||
hx-target="#htmx-info"
|
||||
_='on click put "" into #htmx-options'
|
||||
title="Deletes the supervisors of all selected users"
|
||||
hx-post="{% url 'generic:users_bulk_delete_supervisors' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-confirm="This will delete supervisors from all selected users, are you sure you wish to continue?"
|
||||
hx-target="#htmx-info"
|
||||
_='on click put "" into #htmx-options'
|
||||
>Delete supervisors</button>
|
||||
<button id="bulk-edit-grade-button"
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
title="Changes the selected users grade."
|
||||
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
title="Changes the selected users grade."
|
||||
|
||||
>Edit grade</button>
|
||||
<button id="bulk-add-group-button"
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
title="Adds the selected user to a group."
|
||||
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
title="Adds the selected user to a group."
|
||||
|
||||
>Add group</button>
|
||||
<button id="bulk-remove-group-button"
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
title="Removes the selected user from a group."
|
||||
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
title="Removes the selected user from a group."
|
||||
|
||||
>Remove group</button>
|
||||
<button id="bulk-deactivate-user-button"
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
hx-confirm="This will deactivate all selected users, are you sure you wish to continue?"
|
||||
title="Deactivates the selected users"
|
||||
|
||||
hx-post="{% url 'generic:users_bulk_edit' %}"
|
||||
hx-include="[name='selection']"
|
||||
hx-target="#htmx-options"
|
||||
hx-confirm="This will deactivate all selected users, are you sure you wish to continue?"
|
||||
title="Deactivates the selected users"
|
||||
|
||||
>Deactivate</button>
|
||||
</details>
|
||||
|
||||
|
||||
+26
-10
@@ -5855,15 +5855,31 @@ def create_user(request, context=None, trainee: bool = False):
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
username = form.cleaned_data["username"].strip().lower()
|
||||
existing_user = User.objects.filter(
|
||||
Q(username__iexact=username) | Q(email__iexact=username)
|
||||
).first()
|
||||
|
||||
user_dict = {
|
||||
"username": request.POST["username"],
|
||||
"first_name": request.POST["first_name"],
|
||||
"last_name": request.POST["last_name"],
|
||||
"email": request.POST["username"],
|
||||
"username": username,
|
||||
"first_name": form.cleaned_data["first_name"],
|
||||
"last_name": form.cleaned_data["last_name"],
|
||||
"email": username,
|
||||
"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:
|
||||
errors = f"Unable to create account: {error}"
|
||||
return render(
|
||||
@@ -5873,15 +5889,15 @@ def create_user(request, context=None, trainee: bool = False):
|
||||
)
|
||||
|
||||
try:
|
||||
user_profile = UserProfile.objects.get(user=new_user)
|
||||
user_profile, _ = UserProfile.objects.get_or_create(user=new_user)
|
||||
user_profile.peninsula_trainee = trainee
|
||||
|
||||
if request.POST["grade"]:
|
||||
grade = UserGrades.objects.get(pk=request.POST["grade"])
|
||||
if form.cleaned_data.get("grade"):
|
||||
grade = form.cleaned_data["grade"]
|
||||
user_profile.grade = grade
|
||||
|
||||
if "supervisor" in request.POST and request.POST["supervisor"]:
|
||||
supervisor = Supervisor.objects.get(pk=request.POST["supervisor"])
|
||||
if trainee and form.cleaned_data.get("supervisor"):
|
||||
supervisor = form.cleaned_data["supervisor"]
|
||||
user_profile.supervisor = supervisor
|
||||
|
||||
user_profile.save()
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
{
|
||||
"name": "rad",
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "../../dv3d"
|
||||
},
|
||||
{
|
||||
"path": "../../uploader"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
|
||||
+219
-219
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
||||
|
||||
from django.urls import reverse
|
||||
from django.core import mail
|
||||
|
||||
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"]
|
||||
|
||||
|
||||
@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"]
|
||||
|
||||
|
||||
|
||||
@@ -107,6 +107,11 @@ urlpatterns = [
|
||||
views.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("", views.index, name="home"),
|
||||
path("cid/results/<int:cid>/", views.cid_results, name="cid_results"),
|
||||
|
||||
@@ -30,6 +30,7 @@ from django.utils.safestring import mark_safe
|
||||
|
||||
# from django.contrib.auth.models import User
|
||||
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.contenttypes.models import ContentType
|
||||
@@ -167,6 +168,7 @@ def profile(request):
|
||||
"is_cid_user_manager": is_cid_user_manager,
|
||||
"supervisor_account": supervisor_account,
|
||||
"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,
|
||||
"supervisor_account": supervisor_account,
|
||||
"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")
|
||||
|
||||
|
||||
@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)
|
||||
def logs_view(request):
|
||||
"""Display production logs from log.txt with search filtering.
|
||||
|
||||
+3
-1
@@ -61,4 +61,6 @@ pygments
|
||||
celery[redis]
|
||||
redis
|
||||
pywatchman
|
||||
numpy==2.4.0
|
||||
numpy==2.4.0
|
||||
django-tasks
|
||||
django-tasks-db
|
||||
+13
-6
@@ -127,8 +127,16 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
div.content.container {
|
||||
max-width: 100%;
|
||||
.content.content-read {
|
||||
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) {
|
||||
@@ -138,10 +146,9 @@
|
||||
padding-right: .5rem !important;
|
||||
}
|
||||
|
||||
div.content.container {
|
||||
.content {
|
||||
padding-left: .5rem !important;
|
||||
padding-right: .5rem !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +159,7 @@
|
||||
padding-right: .75rem !important;
|
||||
}
|
||||
|
||||
div.content.container {
|
||||
.content {
|
||||
padding-left: .75rem !important;
|
||||
padding-right: .75rem !important;
|
||||
}
|
||||
@@ -358,7 +365,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="content container">
|
||||
<div class="content {% block content_container_class %}container content-read{% endblock %}">
|
||||
{% block navigation %}
|
||||
{% endblock %}
|
||||
<div class="row clear-both">
|
||||
|
||||
+20
-2
@@ -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>
|
||||
{% 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" %}
|
||||
<a class="btn btn-outline-secondary d-block mb-2" href="{% url 'account_update' user.username %}">Update user details</a>
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
<form method="post" action="{% url 'account_set_supervisor' user.username %}" class="mb-2">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
{% load crispy_forms_tags %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block content_container_class %}container-fluid content-wide px-3{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row mb-3">
|
||||
@@ -11,15 +13,15 @@
|
||||
<div class="mb-2 mb-md-0">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
{% if user.is_superuser %}
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button id="delete-selected-btn" class="btn btn-danger btn-sm"
|
||||
hx-post="{% url 'generic:bulk_delete_questions' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-vals='{"app":"{{ app_name|escapejs }}"}'
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button>
|
||||
</div>
|
||||
<div class="btn-group me-2" role="group">
|
||||
<button id="delete-selected-btn" class="btn btn-danger btn-sm"
|
||||
hx-post="{% url 'generic:bulk_delete_questions' %}"
|
||||
hx-include="[name='selection']:checked"
|
||||
hx-vals='{"app":"{{ app_name|escapejs }}"}'
|
||||
hx-target="#action-result"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to delete the selected questions? This action cannot be undone.">Delete selected</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="btn-group" role="group">
|
||||
<button id="button-select-add-exam" class="btn btn-outline-primary btn-sm"
|
||||
@@ -39,7 +41,7 @@
|
||||
</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 -->
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user