From 161da980cff873b39adf209879a78531720876ae Mon Sep 17 00:00:00 2001 From: Ross Date: Mon, 8 Jun 2026 10:57:01 +0100 Subject: [PATCH] fix a few things --- atlas/decorators.py | 26 ++- atlas/tasks.py | 15 +- .../atlas/partials/case_search_widget.html | 37 ++++- .../partials/displayset_search_widget.html | 33 +++- .../atlas/partials/finding_search_widget.html | 32 +++- .../atlas/series_reconstruct_advanced.html | 154 ++++++++++++++++++ atlas/templates/atlas/series_viewer.html | 3 + atlas/urls.py | 5 + atlas/views.py | 24 +++ rad/views.py | 3 +- templates/logs_viewer.html | 74 +++++++-- templates/rad/media_cleanup.html | 66 ++++++++ 12 files changed, 436 insertions(+), 36 deletions(-) create mode 100644 atlas/templates/atlas/series_reconstruct_advanced.html diff --git a/atlas/decorators.py b/atlas/decorators.py index 55091d1a..62efd247 100755 --- a/atlas/decorators.py +++ b/atlas/decorators.py @@ -3,8 +3,17 @@ from .models import Case, CaseCollection, Series def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(function): + """Decorator to check if user is author of the series, + an atlas editor, an atlas marker, or if the series is open access. + Used for series-level permissions. + + Requires the decorated view to have a "pk" or "series_id" kwarg to identify the series. + """ def wrap(request, *args, **kwargs): - series = Series.objects.get(pk=kwargs["pk"]) + if "pk" in kwargs: + series = Series.objects.get(pk=kwargs["pk"]) + elif "series_id" in kwargs: + series = Series.objects.get(pk=kwargs["series_id"]) if ( request.user in series.get_author_objects() or series.open_access @@ -23,7 +32,10 @@ def user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access(functi def user_is_author_or_atlas_series_checker(function): def wrap(request, *args, **kwargs): - series = Series.objects.get(pk=kwargs["pk"]) + if "pk" in kwargs: + series = Series.objects.get(pk=kwargs["pk"]) + elif "series_id" in kwargs: + series = Series.objects.get(pk=kwargs["series_id"]) if ( request.user in series.get_author_objects() or request.user.groups.filter(name="atlas_editor").exists() @@ -40,7 +52,10 @@ def user_is_author_or_atlas_series_checker(function): def user_has_case_view_access(function): def wrap(request, *args, **kwargs): - atlas = Case.objects.get(pk=kwargs["pk"]) + if "pk" in kwargs: + atlas = Case.objects.get(pk=kwargs["pk"]) + elif "case_id" in kwargs: + atlas = Case.objects.get(pk=kwargs["case_id"]) # If open access everyone can view if atlas.open_access: @@ -62,7 +77,10 @@ def user_has_case_view_access(function): def user_is_author_or_atlas_editor(function): def wrap(request, *args, **kwargs): - atlas = Case.objects.get(pk=kwargs["pk"]) + if "pk" in kwargs: + atlas = Case.objects.get(pk=kwargs["pk"]) + elif "case_id" in kwargs: + atlas = Case.objects.get(pk=kwargs["case_id"]) if ( request.user in atlas.author.all() or request.user.groups.filter(name="atlas_editor").exists() diff --git a/atlas/tasks.py b/atlas/tasks.py index fe4c9769..266792ec 100644 --- a/atlas/tasks.py +++ b/atlas/tasks.py @@ -405,23 +405,22 @@ def series_reconstruct_task( axis_positions_mm = np.arange(axis_length, dtype=float) * float(axis_spacing) end_pos = axis_positions_mm[-1] if axis_positions_mm.size else 0.0 - start_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float) - if start_positions_mm.size == 0: - start_positions_mm = np.array([0.0], dtype=float) + center_positions_mm = np.arange(0.0, end_pos + 1e-6, output_spacing, dtype=float) + if center_positions_mm.size == 0: + center_positions_mm = np.array([0.0], dtype=float) slabs = [] centers_idx = [] - for start_mm in start_positions_mm: - end_mm = start_mm + slab_thickness - mask = (axis_positions_mm >= (start_mm - 1e-6)) & (axis_positions_mm < (end_mm - 1e-6)) + half_thickness = slab_thickness / 2.0 + for center_mm in center_positions_mm: + mask = np.abs(axis_positions_mm - center_mm) <= (half_thickness + 1e-6) if not mask.any(): - nearest = int(np.argmin(np.abs(axis_positions_mm - start_mm))) + nearest = int(np.argmin(np.abs(axis_positions_mm - center_mm))) idxs = np.array([nearest], dtype=int) else: idxs = np.where(mask)[0].astype(int) slabs.append(idxs) - center_mm = start_mm + (slab_thickness / 2.0) centers_idx.append(float(center_mm / axis_spacing) if axis_spacing > 0 else float(idxs[0])) return slabs, centers_idx diff --git a/atlas/templates/atlas/partials/case_search_widget.html b/atlas/templates/atlas/partials/case_search_widget.html index 69313ad0..3f616dba 100644 --- a/atlas/templates/atlas/partials/case_search_widget.html +++ b/atlas/templates/atlas/partials/case_search_widget.html @@ -1,5 +1,13 @@
{% with input_id=input_id|default:'case-search-input' target_id=target_id|default:'case-search-results' %} + {% if collection %} @@ -13,8 +21,14 @@ hx-include="closest .case-search-widget" hx-target="#{{ target_id }}" hx-trigger="input delay:400ms" + hx-indicator="#{{ input_id }}-loading" autocomplete="off"> +
+ + Searching cases... +
+
{# Render initial results (search + recent) on initial GET; HTMX searches will replace this div with partial results #} {% include 'atlas/partials/case_search_results.html' with cases=cases recent_cases=recent_cases collection=collection show_select_button=show_select_button %} @@ -27,7 +41,14 @@ try { var input = document.getElementById('{{ input_id|default:"case-search-input" }}'); if (!input) return; + var widget = input.closest('.case-search-widget'); var timer = null; + + function setLoading(on) { + if (!widget) return; + widget.classList.toggle('is-loading', !!on); + } + input.addEventListener('input', function(){ if (timer) clearTimeout(timer); timer = setTimeout(function(){ @@ -36,12 +57,13 @@ var url = "{% url 'atlas:case_search' %}?q=" + q + ({% if collection %} "&collection={{ collection.pk }}" {% else %} '' {% endif %}) + ({% if show_select_button %} "&show_select=1" {% else %} '' {% endif %}); var target = '#{{ target_id|default:"case-search-results" }}'; if (window.htmx && typeof window.htmx.ajax === 'function') { - window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' }); + window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"case-search-input" }}-loading' }); } else { + setLoading(true); fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){ var el = document.querySelector(target); if (el) el.innerHTML = html; - }).catch(function(e){ console.error('case search fetch error', e); }); + }).catch(function(e){ console.error('case search fetch error', e); }).finally(function(){ setLoading(false); }); } } catch(e) { console.error(e); } }, 400); @@ -61,6 +83,17 @@ var targetEl = document.getElementById(targetId); if (!container || !targetEl) return; + document.body.addEventListener('htmx:beforeRequest', function (event) { + if (event.target === inputEl) { + container.classList.add('is-loading'); + } + }); + document.body.addEventListener('htmx:afterRequest', function (event) { + if (event.target === inputEl) { + container.classList.remove('is-loading'); + } + }); + // Delegate clicks on result items and dispatch a scoped custom event targetEl.addEventListener('click', function(e){ var item = e.target.closest('.list-group-item'); diff --git a/atlas/templates/atlas/partials/displayset_search_widget.html b/atlas/templates/atlas/partials/displayset_search_widget.html index 5267ad86..62ca9976 100644 --- a/atlas/templates/atlas/partials/displayset_search_widget.html +++ b/atlas/templates/atlas/partials/displayset_search_widget.html @@ -3,6 +3,10 @@ {% endif %} {% with input_id=input_id|default:'displayset-search-input' target_id=target_id|default:'displayset-search-results' %} + +
+ + Searching display sets... +
+
{% if case %}Filtering: {{ case.title|escape }}{% endif %} @@ -31,7 +41,14 @@ try { var input = document.getElementById('{{ input_id|default:"displayset-search-input" }}'); if (!input) return; + var widget = input.closest('.displayset-search-widget'); var timer = null; + + function setLoading(on) { + if (!widget) return; + widget.classList.toggle('is-loading', !!on); + } + input.addEventListener('input', function(){ if (timer) clearTimeout(timer); timer = setTimeout(function(){ @@ -40,16 +57,28 @@ var url = "{% url 'atlas:displayset_search' %}?q=" + q + ({% if case %} "&case={{ case.pk }}" {% else %} '' {% endif %}); var target = '#{{ target_id|default:"displayset-search-results" }}'; if (window.htmx && typeof window.htmx.ajax === 'function') { - window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' }); + window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"displayset-search-input" }}-loading' }); } else { + setLoading(true); fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){ var el = document.querySelector(target); if (el) el.innerHTML = html; - }).catch(function(e){ console.error('displayset search fetch error', e); }); + }).catch(function(e){ console.error('displayset search fetch error', e); }).finally(function(){ setLoading(false); }); } } catch(e) { console.error(e); } }, 400); }); + + document.body.addEventListener('htmx:beforeRequest', function (event) { + if (event.target === input) { + setLoading(true); + } + }); + document.body.addEventListener('htmx:afterRequest', function (event) { + if (event.target === input) { + setLoading(false); + } + }); } catch(e) { console.error('displayset search widget init error', e); } })(); diff --git a/atlas/templates/atlas/partials/finding_search_widget.html b/atlas/templates/atlas/partials/finding_search_widget.html index 2eabf165..66f69578 100644 --- a/atlas/templates/atlas/partials/finding_search_widget.html +++ b/atlas/templates/atlas/partials/finding_search_widget.html @@ -1,5 +1,9 @@
{% with input_id=input_id|default:'finding-search-input' target_id=target_id|default:'finding-search-results' %} + +
+ + Searching findings... +
+
{% if case %}Filtering: {{ case.title|escape }}{% endif %} @@ -28,8 +38,14 @@ try { var input = document.getElementById('{{ input_id|default:"finding-search-input" }}'); if (!input) return; + var widget = input.closest('.finding-search-widget'); var timer = null; + function setLoading(on) { + if (!widget) return; + widget.classList.toggle('is-loading', !!on); + } + // If server provided a case via inclusion, ensure widget sets dataset and nearby textarea dataset var meta = document.getElementById('current-case-meta'); var casePk = meta ? meta.getAttribute('data-current-case') : null; @@ -70,16 +86,28 @@ var url = "{% url 'atlas:finding_search' %}?q=" + q + caseParam; var target = '#{{ target_id|default:"finding-search-results" }}'; if (window.htmx && typeof window.htmx.ajax === 'function') { - window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML' }); + window.htmx.ajax('GET', url, { target: target, swap: 'innerHTML', indicator: '#{{ input_id|default:"finding-search-input" }}-loading' }); } else { + setLoading(true); fetch(url, { credentials: 'same-origin' }).then(function(r){ return r.text(); }).then(function(html){ var el = document.querySelector(target); if (el) el.innerHTML = html; - }).catch(function(e){ console.error('finding search fetch error', e); }); + }).catch(function(e){ console.error('finding search fetch error', e); }).finally(function(){ setLoading(false); }); } } catch(e) { console.error(e); } }, 400); }); + + document.body.addEventListener('htmx:beforeRequest', function (event) { + if (event.target === input) { + setLoading(true); + } + }); + document.body.addEventListener('htmx:afterRequest', function (event) { + if (event.target === input) { + setLoading(false); + } + }); } catch(e) { console.error('finding search widget init error', e); } })(); diff --git a/atlas/templates/atlas/series_reconstruct_advanced.html b/atlas/templates/atlas/series_reconstruct_advanced.html new file mode 100644 index 00000000..cbe232dc --- /dev/null +++ b/atlas/templates/atlas/series_reconstruct_advanced.html @@ -0,0 +1,154 @@ +{% extends 'base.html' %} +{% load static %} + +{% block css %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
+
+
+

Advanced Series Reconstruction

+

Plan reconstruction from interactive DV3D MPR viewports with crosshair-linked navigation.

+
+ + Back to series + +
+ +
+
Workflow: click Apply MPR layout, switch all panes to volume mode if needed, enable crosshairs in the DV3D toolbar, then position the crosshair planes as desired before generating reconstructions.
+
+ +
+ +
+
+
+ {% csrf_token %} + + + +
+
Output planes
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + + + +
+ +
+ + + +
+ + +
+
+
+
+ +
+ +
+
+ + + +{% endblock %} diff --git a/atlas/templates/atlas/series_viewer.html b/atlas/templates/atlas/series_viewer.html index 4690a483..5d4449e1 100755 --- a/atlas/templates/atlas/series_viewer.html +++ b/atlas/templates/atlas/series_viewer.html @@ -416,6 +416,9 @@
+ + Advanced reconstruction page +
diff --git a/atlas/urls.py b/atlas/urls.py index 39c84d75..8238f352 100755 --- a/atlas/urls.py +++ b/atlas/urls.py @@ -570,6 +570,11 @@ urlpatterns = [ # TODO: case context series viewing (so that we can view series in the context of a case) path("series/", views.series_detail, name="series_detail"), path("series//viewer", views.series_viewer, name="series_viewer"), + path( + "series//reconstruct/advanced/", + views.series_reconstruct_advanced, + name="series_reconstruct_advanced", + ), path( "series//truncate///", views.series_truncate_htmx, diff --git a/atlas/views.py b/atlas/views.py index 4c445d5f..25cdecd3 100755 --- a/atlas/views.py +++ b/atlas/views.py @@ -873,6 +873,30 @@ def series_optimize_htmx(request, series_id): return HttpResponse('
Unknown optimize operation.
') +@login_required +@user_is_author_or_atlas_series_checker_or_atlas_marker_or_open_access +def series_reconstruct_advanced(request, series_id): + """Advanced reconstruction planning page using DV3D MPR/crosshair workflow. + + Scope: users with access to the source series. + Functionality: mounts DV3D in an MPR-focused layout, allows manual planning, + and submits reconstruction requests while preserving the existing series tools flow. + """ + series = get_object_or_404(Series, pk=series_id) + + if not series.check_user_can_edit(request.user): + return HttpResponse("Permission denied", status=403) + + image_urls = [f"{REMOTE_URL}{img.image.url}" for img in series.get_images() if getattr(img, "image", None)] + image_ids = [f"wadouri:{url}" for url in image_urls] + + context = { + "series": series, + "image_ids_json": json.dumps(image_ids), + } + return render(request, "atlas/series_reconstruct_advanced.html", context) + + @login_required def series_reconstruct_status_htmx(request, series_id, task_id): """HTMX endpoint returning progress for a queued series reconstruction task.""" diff --git a/rad/views.py b/rad/views.py index 7de52a0e..249d94d5 100644 --- a/rad/views.py +++ b/rad/views.py @@ -594,7 +594,8 @@ def media_cleanup(request): removed_files = set(media_inventory_before) - set(media_inventory_after) output = out.getvalue().strip() - recovered_size = sum(media_inventory_before[path] for path in removed_files) + # Use before/after total delta so reported recovered space matches real disk impact. + recovered_size = max(0, media_size_before - media_size_after) context.update( { "ran": True, diff --git a/templates/logs_viewer.html b/templates/logs_viewer.html index 9627e796..a80fd021 100644 --- a/templates/logs_viewer.html +++ b/templates/logs_viewer.html @@ -26,6 +26,21 @@ border-radius: 0.375rem; padding: 0.75rem; } + .logs-filter-group { + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.02); + } + .logs-filter-group legend { + width: auto; + padding: 0 0.35rem; + margin: 0; + font-size: 0.8rem; + color: var(--bs-secondary-color); + text-transform: uppercase; + letter-spacing: 0.04em; + } {% endblock %} @@ -58,35 +73,59 @@
-
-
+ +
+
-
- {% for level in available_levels %} {% endfor %}
-
- + +
+
+ Date Range +
+
+ + +
+
+ + +
+
+
-
- + +
+
+ Time Range +
+
+ + +
+
+ + +
+
+
-
- -
-
- -
-
+ +
-
+ +
{% if search_query or level_filter %} - Clear + Clear {% endif %}
diff --git a/templates/rad/media_cleanup.html b/templates/rad/media_cleanup.html index 09203466..771f681f 100644 --- a/templates/rad/media_cleanup.html +++ b/templates/rad/media_cleanup.html @@ -15,6 +15,10 @@ pointer-events: none; opacity: 0.7; } + .media-cleanup-modal-spinner { + width: 2.5rem; + height: 2.5rem; + } {% endblock %} @@ -92,6 +96,8 @@ name="action" value="preview" class="btn btn-primary" + data-loading-title="Previewing unused files" + data-loading-message="Scanning media storage for orphaned files." > Preview unused files @@ -101,6 +107,8 @@ value="cleanup" class="btn btn-danger" hx-confirm="Delete unused media files now? This cannot be undone." + data-loading-title="Deleting unused files" + data-loading-message="Removing orphaned media and recalculating disk usage." > Delete unused files @@ -118,4 +126,62 @@ {% include 'rad/partials/_media_cleanup_result.html' %}
+ + + + {% endblock %}