diff --git a/TODO.md b/TODO.md index fe776fb8..1008764d 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ ## Top +- Allow to trigger toasts from HTMX responses using HX-Trigger # see core.js of RADIS + - Before new release -- test job_utils -- Test canceled task/job in test_workers.py @@ -194,4 +196,8 @@ ## RADIS -- Move over to SVG sprites +- In core_layout rename #dialog to #htmx-dialog and #modal to #htmx-modal, also in the core.js and also in all the htmx rendered templates (hx-target="#dialog" to hx-target="#htmx-dialog") +- htmx.on("#htmx-modal", "hidden.bs.modal", () => { +- delete unneeded \_message_modal.html and \_confirm_modal.html +- use HtmxDetails in types.py +- HtmxTemplateView also for RADIS (if needed) diff --git a/adit/batch_query/templates/batch_query/_batch_query_help.html b/adit/batch_query/templates/batch_query/_batch_query_help.html new file mode 100644 index 00000000..5692e3b4 --- /dev/null +++ b/adit/batch_query/templates/batch_query/_batch_query_help.html @@ -0,0 +1,97 @@ +{% load static from static %} +{% load bootstrap_icon from core_extras %} + diff --git a/adit/batch_query/templates/batch_query/_batch_query_help_modal.html b/adit/batch_query/templates/batch_query/_batch_query_help_modal.html deleted file mode 100644 index 3c23a693..00000000 --- a/adit/batch_query/templates/batch_query/_batch_query_help_modal.html +++ /dev/null @@ -1,92 +0,0 @@ -{% extends "core/_message_modal.html" %} -{% load static from static %} -{% load bootstrap_icon from core_extras %} -{% block modal_title %} - Batch Query Help -{% endblock modal_title %} -{% block modal_body %} -

- With a Batch Query you can create a job to find data of multiple studies in a source DICOM / PACS server. Batch - query jobs are put into a queue and will be processed by a worker when the time is right. You will get an Email when - the job is finished (or failed for some reason). -

-

- Each batch query job contains several query tasks that define what studies to search for. The search terms must be - specified in an Excel file (.xlsx). The first row of the Excel file must contain the header with the column titles (see below). - Each of the following rows represent a query task. -

-

- Cave! -
- If PatientID or AccessionNumber contains leading zeros those are relevant as it is not a number but a text - identifier. So make sure that your Excel file does not remove those leading zeros by setting the column type to text or - add a single quote ' as prefix to the text cell itself. -

-

- These are the columns in the batch file to execute your queries: -

-
PatientID
-
- The unique ID of the patient in the PACS. -
-
PatientName
-
- The name of the patient. -
-
PatientBirthDate
-
- The birth date of the patient. -
-
AccessionNumber
-
- The Accession Number (a unique ID) of the study. -
-
From
-
- Only include studies newer than or equal to this date. -
-
Until
-
- Only include studies older than or equal to this date. -
-
Modality
-
- The modality of the study. Multiple modalities to query can be provided as a comma - separated list. -
-
SeriesDescription
-
- Only include series of the study, whose series description match a certain case insensitive regular - expression pattern (see - introduction into using a regular expression and testing your - regular expression). -
-
SeriesNumber
-
- Only include series of the study with the specified series number. Multiple series - numbers can be provided as a comma separated list. -
-
Pseudonym
-
- A pseudonym to pseudonymize the images during a subsequent transfer with Batch Transfer. -
-
-

-

- The patient must be identifiable by either "PatientID" or "PatientName" together with "PatientBirthDate". - The remaining fields are optional and may limit the results for what you really need. -

-

- The result of the batch query can be viewed and downloaded from ADIT. The downloaded Excel file contains more data - then what can be viewed on the website. Each result contains the "PatientID" and "StudyInstanceUID", which is - necessary for a batch transfer job. If a "SeriesDescription" or a "SeriesNumber" was provided, the result will also - contain the "SeriesInstanceUID". This downloaded file can be used for a batch transfer. So a batch query job is in - a preparation step for a batch transfer. -

-
- - {% bootstrap_icon "download" %} - Download a sample batch query file (Excel format). - -
-{% endblock modal_body %} diff --git a/adit/batch_query/templates/batch_query/batch_query_job_form.html b/adit/batch_query/templates/batch_query/batch_query_job_form.html index c5bf8cd3..0f7baf7e 100644 --- a/adit/batch_query/templates/batch_query/batch_query_job_form.html +++ b/adit/batch_query/templates/batch_query/batch_query_job_form.html @@ -5,7 +5,13 @@

New Batch Query Job - {% include "core/_help_button.html" with target="#batch_query_help_modal" only %} +

{% bootstrap_icon "list" %} @@ -17,6 +23,5 @@

{% crispy form %} {% endblock content %} {% block bottom %} - {% include "batch_query/_batch_query_help_modal.html" with modal_id="batch_query_help_modal" %} {% include "batch_query/_batch_file_errors_modal.html" with modal_id="batch_file_errors_modal" %} {% endblock bottom %} diff --git a/adit/batch_query/urls.py b/adit/batch_query/urls.py index 3e2f509a..468573ac 100644 --- a/adit/batch_query/urls.py +++ b/adit/batch_query/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from adit.core.views import HtmxTemplateView + from .views import ( BatchQueryJobCancelView, BatchQueryJobCreateView, @@ -21,6 +23,11 @@ "update-preferences/", BatchQueryUpdatePreferencesView.as_view(), ), + path( + "help/", + HtmxTemplateView.as_view(template_name="batch_query/_batch_query_help.html"), + name="batch_query_help", + ), path( "jobs/", BatchQueryJobListView.as_view(), diff --git a/adit/batch_transfer/templates/batch_transfer/_batch_transfer_help.html b/adit/batch_transfer/templates/batch_transfer/_batch_transfer_help.html new file mode 100644 index 00000000..dd485c50 --- /dev/null +++ b/adit/batch_transfer/templates/batch_transfer/_batch_transfer_help.html @@ -0,0 +1,68 @@ +{% load static from static %} +{% load bootstrap_icon from core_extras %} + diff --git a/adit/batch_transfer/templates/batch_transfer/_batch_transfer_help_modal.html b/adit/batch_transfer/templates/batch_transfer/_batch_transfer_help_modal.html deleted file mode 100644 index 50c549fe..00000000 --- a/adit/batch_transfer/templates/batch_transfer/_batch_transfer_help_modal.html +++ /dev/null @@ -1,63 +0,0 @@ -{% extends "core/_message_modal.html" %} -{% load static from static %} -{% load bootstrap_icon from core_extras %} -{% block modal_title %} - Batch Transfer Help -{% endblock modal_title %} -{% block modal_body %} -

- With this form you can create a new batch transfer job to transfer studies from a source server to a destination. - Batch transfer jobs are put into a queue and will be processed by a worker when the time is right. You will get an - Email when the job is finished (or failed for some reason). -

-

- Each batch transfer job contains several transfer tasks that define what studies to transfer. This data must be - specified in an Excel file (.xlsx). The first row of the Excel file must contain the header with the - column titles. The following rows contain the data that identifies the studies to transfer. -

-

- The required PatientID and StudyInstanceUID can be fetched by doing a "Batch Query". The resulting file of a - batch query can be used for the batch transfer. So a batch query is usually a preparation step for a batch transfer. -

-

- Cave! -
- If PatientID or AccessionNumber contains leading zeros those are relevant as it is not a number but a text - identifier. So make sure that your Excel file does not remove those leading zeros by setting the column type to text or - add a single quote ' as prefix to the text cell itself. -

-

- The following columns must be defined in the batch file: -

-
PatientID
-
- The unique ID of the patient in the PACS. This column is required. -
-
StudyInstanceUID
-
- A unique ID that identifies the study. This column is required. -
-
SeriesInstanceUID
-
- An unique ID that identifies the series. This column is optional to only transfer - specific series of a study. -
-
Pseudonym
-
- A pseudonym to pseudonymize the images during transfer. This field is required - if you don't have the permission to transfer unpseudonymized (the default). -
-
-

-

- The "SeriesInstanceUID" is optional. If provided, only the specified series of the study will be transferred. The - provided pseudonym is optional if you have the permissions to transfer unpseudonymized. It will be set as PatientID - and PatientName. So it is recommended to use cryptic identifier strings (e.g. "XFE3TEW2N"). -

- -{% endblock modal_body %} diff --git a/adit/batch_transfer/templates/batch_transfer/batch_transfer_job_form.html b/adit/batch_transfer/templates/batch_transfer/batch_transfer_job_form.html index 32d9aa96..1b04ebb6 100644 --- a/adit/batch_transfer/templates/batch_transfer/batch_transfer_job_form.html +++ b/adit/batch_transfer/templates/batch_transfer/batch_transfer_job_form.html @@ -5,7 +5,13 @@

New Batch Transfer Job - {% include "core/_help_button.html" with target="#batch_transfer_help_modal" only %} +

{% bootstrap_icon "list" %} @@ -17,6 +23,5 @@

{% crispy form %} {% endblock content %} {% block bottom %} - {% include "batch_transfer/_batch_transfer_help_modal.html" with modal_id="batch_transfer_help_modal" %} {% include "batch_transfer/_batch_file_errors_modal.html" with modal_id="batch_file_errors_modal" %} {% endblock bottom %} diff --git a/adit/batch_transfer/urls.py b/adit/batch_transfer/urls.py index 6f82e513..8a13b5d9 100644 --- a/adit/batch_transfer/urls.py +++ b/adit/batch_transfer/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from adit.core.views import HtmxTemplateView + from .views import ( BatchTransferJobCancelView, BatchTransferJobCreateView, @@ -19,6 +21,11 @@ "update-preferences/", BatchTransferUpdatePreferencesView.as_view(), ), + path( + "help/", + HtmxTemplateView.as_view(template_name="batch_transfer/_batch_transfer_help.html"), + name="batch_transfer_help", + ), path( "jobs/", BatchTransferJobListView.as_view(), diff --git a/adit/core/static/core/core.js b/adit/core/static/core/core.js index 78705026..8cb89544 100644 --- a/adit/core/static/core/core.js +++ b/adit/core/static/core/core.js @@ -23,6 +23,56 @@ ready(function () { for (const tooltip of tooltips) { new bootstrap.Tooltip(tooltip); } + + // Manage the Bootstrap modal when using HTMX + // Based on https://blog.benoitblanchon.fr/django-htmx-modal-form/ + htmx.on("htmx:beforeSwap", (e) => { + // Check if the modal should be static + let staticModal = false; + if (e.detail.target.id == "htmx-dialog" && e.detail.xhr.response) { + const doc = new DOMParser().parseFromString( + e.detail.xhr.response, + "text/html" + ); + if ( + doc.querySelector(".modal-content").hasAttribute("data-dialog-static") + ) { + staticModal = true; + } + } + + const modal = bootstrap.Modal.getOrCreateInstance("#htmx-modal", { + backdrop: staticModal ? "static" : true, + }); + + // An empty response targeting the #dialog does hide the modal + if (e.detail.target.id == "htmx-dialog" && !e.detail.xhr.response) { + modal.hide(); + e.detail.shouldSwap = false; + } + }); + htmx.on("htmx:afterSwap", (e) => { + const modal = bootstrap.Modal.getInstance("#htmx-modal"); + const modalEl = document.getElementById("htmx-modal"); + modalEl.addEventListener("shown.bs.modal", (event) => { + const inputEl = modalEl.querySelector("input:not([type=hidden])"); + if (inputEl) + // @ts-ignore + inputEl.focus(); + }); + + if (e.detail.target.id == "htmx-dialog") { + modal.show(); + } + }); + htmx.on("#htmx-modal", "hidden.bs.modal", () => { + // Reset the dialog after it was closed + document.getElementById("htmx-dialog").innerHTML = ""; + + // Explicitly dispose it that next time it can be recreated + // static or non static dynamically + bootstrap.Modal.getInstance("#htmx-modal").dispose(); + }); }); /** diff --git a/adit/core/templates/core/_confirm_modal.html b/adit/core/templates/core/_confirm_modal.html deleted file mode 100644 index d0374a4b..00000000 --- a/adit/core/templates/core/_confirm_modal.html +++ /dev/null @@ -1,28 +0,0 @@ - diff --git a/adit/core/templates/core/core_layout.html b/adit/core/templates/core/core_layout.html index bedbe136..821914cd 100644 --- a/adit/core/templates/core/core_layout.html +++ b/adit/core/templates/core/core_layout.html @@ -54,6 +54,12 @@ {% include "core/_toasts_panel.html" %} {% block bottom %} {% endblock bottom %} + + {# Vendor Javascript dependencies (except Alpine.js, see below) #} diff --git a/adit/core/types.py b/adit/core/types.py index d860204b..6391228d 100644 --- a/adit/core/types.py +++ b/adit/core/types.py @@ -1,11 +1,16 @@ from typing import Literal, TypedDict from django.http import HttpRequest +from django_htmx.middleware import HtmxDetails from rest_framework.request import Request from adit.accounts.models import User +class HtmxHttpRequest(HttpRequest): + htmx: HtmxDetails + + class AuthenticatedHttpRequest(HttpRequest): user: User diff --git a/adit/core/views.py b/adit/core/views.py index 99d49207..f45cabe0 100644 --- a/adit/core/views.py +++ b/adit/core/views.py @@ -32,7 +32,7 @@ from .models import CoreSettings, DicomJob, DicomTask, QueuedTask from .site import job_stats_collectors from .tasks import broadcast_mail -from .types import AuthenticatedHttpRequest +from .types import AuthenticatedHttpRequest, HtmxHttpRequest from .utils.job_utils import queue_pending_tasks THEME = "theme" @@ -52,6 +52,13 @@ def admin_section(request: HttpRequest) -> HttpResponse: ) +class HtmxTemplateView(TemplateView): + def get(self, request: HtmxHttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: + if not request.htmx: + raise SuspiciousOperation + return super().get(request, *args, **kwargs) + + class BroadcastView(LoginRequiredMixin, UserPassesTestMixin, FormView): template_name = "core/broadcast.html" form_class = BroadcastForm diff --git a/adit/selective_transfer/templates/selective_transfer/_selective_form_help_modal.html b/adit/selective_transfer/templates/selective_transfer/_selective_form_help_modal.html deleted file mode 100644 index 8ef101cf..00000000 --- a/adit/selective_transfer/templates/selective_transfer/_selective_form_help_modal.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends "core/_message_modal.html" %} -{% block modal_title %} - Selective Transfer Help -{% endblock modal_title %} -{% block modal_body %} -

- With the selective transfer form you can search a source server for studies - and transfer them to a destination server or folder. -

-

- You can choose an optional archive password to store the transferred data - in an encrpyted 7z (https://7-zip.org) archive (max. 10 studies). -

-{% endblock modal_body %} diff --git a/adit/selective_transfer/templates/selective_transfer/_selective_transfer_help.html b/adit/selective_transfer/templates/selective_transfer/_selective_transfer_help.html new file mode 100644 index 00000000..a74de143 --- /dev/null +++ b/adit/selective_transfer/templates/selective_transfer/_selective_transfer_help.html @@ -0,0 +1,21 @@ +{% block modal_body %} + +{% endblock modal_body %} diff --git a/adit/selective_transfer/templates/selective_transfer/selective_transfer_job_form.html b/adit/selective_transfer/templates/selective_transfer/selective_transfer_job_form.html index a01f9615..f8d3b498 100644 --- a/adit/selective_transfer/templates/selective_transfer/selective_transfer_job_form.html +++ b/adit/selective_transfer/templates/selective_transfer/selective_transfer_job_form.html @@ -5,7 +5,13 @@
{% endblock content %} -{% block bottom %} - {% include "selective_transfer/_selective_form_help_modal.html" with modal_id="selective_form_help_modal" %} -{% endblock bottom %} diff --git a/adit/selective_transfer/urls.py b/adit/selective_transfer/urls.py index 8dab2365..c67b2aea 100644 --- a/adit/selective_transfer/urls.py +++ b/adit/selective_transfer/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from adit.core.views import HtmxTemplateView + from .views import ( SelectiveTransferJobCancelView, SelectiveTransferJobCreateView, @@ -19,6 +21,11 @@ "update-preferences/", SelectiveTransferUpdatePreferencesView.as_view(), ), + path( + "help/", + HtmxTemplateView.as_view(template_name="selective_transfer/_selective_transfer_help.html"), + name="selective_transfer_help", + ), path( "jobs/", SelectiveTransferJobListView.as_view(), diff --git a/adit/token_authentication/templates/token_authentication/_generate_token_help_modal.html b/adit/token_authentication/templates/token_authentication/_generate_token_help_modal.html deleted file mode 100644 index 77d508b8..00000000 --- a/adit/token_authentication/templates/token_authentication/_generate_token_help_modal.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "core/_message_modal.html" %} -{% block modal_title %} - REST Authentication Token Help -{% endblock modal_title %} -{% block modal_body %} -

- With this feature you can generate an authentication token to authenticate - your third party REST application. -

-

- The token should be included in the request header like this: -
- Authorization: Token 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b -

-{% endblock modal_body %} diff --git a/adit/token_authentication/templates/token_authentication/_token_authentication_help.html b/adit/token_authentication/templates/token_authentication/_token_authentication_help.html new file mode 100644 index 00000000..cae3fe03 --- /dev/null +++ b/adit/token_authentication/templates/token_authentication/_token_authentication_help.html @@ -0,0 +1,22 @@ +{% block modal_body %} + +{% endblock modal_body %} diff --git a/adit/token_authentication/templates/token_authentication/token_dashboard.html b/adit/token_authentication/templates/token_authentication/token_dashboard.html index 774bf7ae..12fb2e3c 100644 --- a/adit/token_authentication/templates/token_authentication/token_dashboard.html +++ b/adit/token_authentication/templates/token_authentication/token_dashboard.html @@ -6,7 +6,13 @@

REST Authentication Tokens - {% include "core/_help_button.html" with target="#generate_token_help_modal" only %} +

{% endblock heading %} @@ -77,6 +83,3 @@
Generate a new token

{% endblock content %} -{% block bottom %} - {% include 'token_authentication/_generate_token_help_modal.html' with modal_id="generate_token_help_modal" %} -{% endblock bottom %} diff --git a/adit/token_authentication/urls.py b/adit/token_authentication/urls.py index 9be66efb..60075291 100644 --- a/adit/token_authentication/urls.py +++ b/adit/token_authentication/urls.py @@ -1,5 +1,7 @@ from django.urls import path +from adit.core.views import HtmxTemplateView + from .views import DeleteTokenView, TestView, TokenDashboardView urlpatterns = [ @@ -8,6 +10,13 @@ TokenDashboardView.as_view(), name="token_dashboard", ), + path( + "help/", + HtmxTemplateView.as_view( + template_name="token_authentication/_token_authentication_help.html" + ), + name="token_authentication_help", + ), path( "/delete-token", DeleteTokenView.as_view(), diff --git a/globals.d.ts b/globals.d.ts new file mode 100644 index 00000000..f5820cff --- /dev/null +++ b/globals.d.ts @@ -0,0 +1,5 @@ +export {}; + +declare global { + var htmx: any; +} diff --git a/jsconfig.json b/jsconfig.json index ce545f5e..1d3ef097 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -3,5 +3,5 @@ "checkJs": true, "target": "ES6" }, - "include": ["./adit/*/static/**/*.js"] + "include": ["./globals.d.ts", "./adit/*/static/**/*.js"] }