Enhance case display sets management with HTMX integration and improved UI elements

This commit is contained in:
Ross
2026-04-27 10:48:02 +01:00
parent 854f265623
commit e82b353ad9
2 changed files with 138 additions and 36 deletions
@@ -1,21 +1,49 @@
{# Partial: Case Display Sets (displaysets) #}
{# Buttons adapt at runtime: if an in-page viewer is present (importAnnotations_main_viewer), #}
{# "View" loads the display set into it; otherwise falls back to the modal viewer. #}
{# An Edit button is shown separately when can_edit is truthy. #}
<div>
<h5 class="mt-3">Case Display Sets</h5>
<ul class="list-group">
{% for ds in case.display_sets.all %}
<li class="list-group-item">
<b>Name: {{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted">({{ ds.description }})</span>
{% endif %}
<span>
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}">View Display Set</a>
<button type="button" class="btn btn-secondary btn-sm view-displayset-modal ms-2" data-ds-id="{{ ds.pk }}" data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">View in Modal</button>
</span>
<div>
<li class="list-group-item" id="displayset-row-{{ ds.pk }}">
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div>
<b>{{ ds.name }}</b>
{% if ds.description %}
<span class="text-muted ms-1">({{ ds.description }})</span>
{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0">
{% if ds.viewerstate or ds.annotations %}
<button type="button"
class="btn btn-primary btn-sm ds-view-btn"
data-ds-id="{{ ds.pk }}"
data-modal-url="{% url 'atlas:case_displaysets_modal' ds.pk %}"
data-annotations='{{ ds.annotations|safe }}'
data-viewerstate='{{ ds.viewerstate|safe }}'>
<i class="bi bi-display"></i> View Display Set
</button>
{% endif %}
<button type="button"
class="btn btn-secondary btn-sm view-displayset-modal"
data-ds-id="{{ ds.pk }}"
data-url="{% url 'atlas:case_displaysets_modal' ds.pk %}">
<i class="bi bi-eye"></i> View in Modal
</button>
{% if can_edit %}
<a href="{% url 'atlas:case_displaysets_detail' ds.pk %}?edit=1"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit
</a>
{% endif %}
</div>
</div>
<div class="mt-2">
<strong>Findings:</strong>
{% if ds.findings.all %}
<ul>
<ul class="mb-1">
{% for finding in ds.findings.all %}
<li>{{ finding.get_link }}</li>
{% endfor %}
@@ -27,7 +55,7 @@
<div>
<strong>Structures:</strong>
{% if ds.structures.all %}
<ul>
<ul class="mb-1">
{% for structure in ds.structures.all %}
<li>{{ structure.get_link }}</li>
{% endfor %}
@@ -39,7 +67,7 @@
<div>
<strong>Conditions:</strong>
{% if ds.conditions.all %}
<ul>
<ul class="mb-1">
{% for condition in ds.conditions.all %}
<li>{{ condition.get_link }}</li>
{% endfor %}
@@ -48,25 +76,64 @@
<span class="text-muted">None</span>
{% endif %}
</div>
<div>
<details>
<summary class="small text-muted" style="cursor:pointer;">Show advanced (viewer state & annotations)</summary>
<div>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.viewerstate title='Viewer State' %}
</div>
<div>
{% include 'atlas/partials/json_pretty.html' with json_value=ds.annotations title='Annotations' %}
</div>
</details>
</div>
</li>
{% empty %}
<div class="displayset-empty-state">
<span class="text-muted">No display sets for this case.</span>
<li class="list-group-item text-muted">
No display sets for this case.
{% if can_edit %}
<a class="displayset-empty-link" href="{% url 'atlas:case_displaysets' case.pk %}">Add one now</a>
<a href="{% url 'atlas:case_displaysets' case.pk %}">Add one now</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
<script>
(function () {
/**
* For each "View Display Set" button:
* - If the page has an in-page viewer (importAnnotations_main_viewer available),
* load the display set's state into it and scroll the viewer into view.
* - Otherwise, fall back to the existing modal loader (loadDisplaysetModal).
*/
document.querySelectorAll('.ds-view-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var dsId = btn.dataset.dsId;
var modalUrl = btn.dataset.modalUrl;
var annotations = btn.dataset.annotations;
var viewerstate = btn.dataset.viewerstate;
var hasInPageViewer = (
typeof window.importAnnotations_main_viewer === 'function' &&
typeof window.importViewerState_main_viewer === 'function'
);
if (hasInPageViewer) {
// Ensure viewer UI is visible before applying display set state.
var viewerDetails = document.getElementById('dicom-viewer-details');
if (viewerDetails && viewerDetails.tagName === 'DETAILS') {
viewerDetails.open = true;
}
try { window.importAnnotations_main_viewer(JSON.parse(annotations || '{}')); } catch (e) { console.warn('displayset annotations import error', e); }
try { window.importViewerState_main_viewer(JSON.parse(viewerstate || '{}')); } catch (e) { console.warn('displayset viewerstate import error', e); }
try { window.dispatchEvent(new Event('resize')); } catch (e) {}
if (typeof window.resizeDicomViewers === 'function') {
try { window.resizeDicomViewers(); } catch (e) { console.warn('displayset viewer resize error', e); }
}
// Scroll the viewer into view
var viewer = document.getElementById('main_viewer') || document.querySelector('.dicom-viewer-root');
if (viewer) { viewer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
} else if (typeof window.loadDisplaysetModal === 'function') {
window.loadDisplaysetModal(modalUrl, dsId);
} else {
// Ultimate fallback: navigate to the detail page
window.location.href = modalUrl;
}
});
});
}());
</script>
+43 -8
View File
@@ -484,11 +484,19 @@ class NormalCaseList(LoginRequiredMixin, FilterView):
@login_required
@user_has_case_view_access
def case_displaysets(request, pk):
case = get_object_or_404(Case, pk=pk)
"""Render the dedicated case display set workspace.
This is the full-page management/viewer screen used to review all display sets
for a case and, when permitted, add/edit/delete them inline via HTMX.
Query params:
- ``displayset``: highlight/select a specific display set row.
- ``edit``: when truthy and ``displayset`` is provided, auto-open that row's
inline edit form on page load.
"""
case = get_object_or_404(Case, pk=pk)
can_edit = case.check_user_can_edit(request.user)
# Get displayset id from GET parameter
displayset_id = request.GET.get("displayset")
edit = request.GET.get("edit", False)
selected_displayset = None
@@ -657,6 +665,11 @@ def api_tokens_page(request):
@user_has_case_view_access
@require_http_methods(["GET", "POST"])
def case_displaysets_add(request, pk):
"""Create a display set for a case and return HTMX fragments.
GET returns a blank display set form fragment.
POST validates and creates the display set, then returns the rendered list row.
"""
case = get_object_or_404(Case, pk=pk)
form = CaseDisplaySetForm(request.POST or None)
if request.method == "POST":
@@ -683,11 +696,16 @@ def case_displaysets_add(request, pk):
@login_required
@require_http_methods(["GET", "POST"])
def case_displaysets_edit(request, pk):
"""Edit a single display set via HTMX.
This endpoint is fragment-only and is intended to be called from the dedicated
``case_displaysets`` page inline editor, not linked as a standalone page.
"""
ds = get_object_or_404(CaseDisplaySet, pk=pk)
case = ds.case
if not case.check_user_can_edit(request.user):
return HttpResponse("You do not have permission to delete this display set.")
return HttpResponse("You do not have permission to edit this display set.")
form = CaseDisplaySetForm(request.POST or None, instance=ds)
if request.method == "POST":
if form.is_valid():
@@ -706,17 +724,33 @@ def case_displaysets_edit(request, pk):
html = render_to_string("atlas/partials/displayset_form.html", {"form": form, "case": case}, request=request)
return HttpResponse(html)
@require_http_methods(["GET", "POST"])
@login_required
@require_http_methods(["GET"])
def case_displaysets_detail(request, pk):
"""Redirect to the case display set workspace focused on one display set.
Used as the canonical navigation target from non-management pages.
It redirects to ``case_displaysets`` with ``displayset=<pk>`` and forwards
optional ``edit`` intent (for example ``?edit=1``) so the target page can
auto-open inline editing for permitted users.
"""
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
case = displayset.case
# Redirect to the case_displaysets page with ?displayset=<pk>
return redirect(f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}")
edit = request.GET.get("edit")
redirect_url = f"{reverse('atlas:case_displaysets', args=[case.pk])}?displayset={pk}"
if edit:
redirect_url = f"{redirect_url}&edit={edit}"
return redirect(redirect_url)
@login_required
def case_displaysets_modal(request, pk):
"""Return HTML fragment for a CaseDisplaySet to be shown inside a modal."""
"""Return a display set detail fragment for modal/overlay viewers.
This supports pages that do not have the full ``case_displaysets`` workspace
open but still need to inspect a display set in-place.
"""
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
can_edit = False
try:
@@ -727,11 +761,12 @@ def case_displaysets_modal(request, pk):
def displayset_preview(request, pk):
"""Return a compact HTML fragment summarising a CaseDisplaySet for popups/modals."""
"""Return a compact display set preview fragment for lightweight popups."""
displayset = get_object_or_404(CaseDisplaySet, pk=pk)
return render(request, 'atlas/partials/_displayset_preview.html', {'ds': displayset})
def case_displaysets_delete(request, pk):
"""Delete a display set and return a replacement row fragment/message for HTMX."""
try:
displayset = CaseDisplaySet.objects.get(pk=pk)
except CaseDisplaySet.DoesNotExist: