DICOMweb resource management: remove a study or series by DICOM UID

Implements the plugin half (§§2-5) of #57 (closed).

The companion Sonador-side change is oak-tree/medical-imaging/sonador!96. These two must land together — without the classification change, these endpoints return 403 for every non-superuser.

What this adds

DELETE {dicomweb_root}/studies/{StudyInstanceUID}/manage
DELETE {dicomweb_root}/series/{SeriesInstanceUID}/manage

Each resolves the DICOM UID to a Sonador cache resource, confirms it exists, and answers 307 with Location set to that resource's own Orthanc API URL on the public FQDN, where the delete is performed.

Removal was previously reachable only by Orthanc public ID (SonadorStudyResourceView / SonadorSeriesResourceView). A DICOMweb client — including the OHIF frontend — holds StudyInstanceUID/SeriesInstanceUID, not Orthanc IDs, so there was no path to it at all.

Files

File Change
sonador_orthanc/web/manage.py new — ManageBaseView, StudyDICOMManageView, SeriesDICOMManageView
sonador_orthanc/web/dicomweb.py new init_manage_endpoints()
sonador-plugin.py call it from orthanc_cache_onstart
sonador_orthanc/web/resource.py fix the _execute_resource_request format-string defect

The decisions worth reviewing

307, not 302. RFC 7231 §6.4.3 permits a user agent to rewrite the method to GET on a 302, and browsers do so inconsistently for non-GET methods. A DELETE rewritten to GET lands on SonadorStudyResourceView.get and returns 200 with study JSON having deleted nothing — a silent no-op that reads as success, which is the worst available failure mode for a destructive operation. 307 is already the repository convention for exactly this reason (RedirectView.forward_status_code in orthanc-sonador-common). Exposed as a class attribute so it stays settable through as_view, which rejects any keyword that is not already a class attribute.

No get() handler. dispatch() resolves handlers by method name, so adding one would turn GET .../manage into a success rather than a 405, and would trigger the head = get aliasing in OrthancBaseView.setup. Without one, _allowed_methods() reports ['DELETE', 'OPTIONS'].

The redirect target is resource_url. It yields studies/{orthanc-id} / series/{orthanc-id} — the endpoint that implements DELETE, and the same property the client's own delete() uses.

No permission logic in the views. Authorization stays with the orthanc-authorization plugin consulting Sonador. The views verify existence only. No new permission is introduced — remove already exists at patient, study and series granularity.

manage is deliberately a generic management namespace rather than a remove/delete route, so a later anonymize/modify/reindex operation extends the same views instead of needing a second naming decision.

Also fixed

SonadorResourceBaseView._execute_resource_request carried a live format-string defect: the generic except Exception branch formatted emsg_404 (a single-%s template) with a two-item (uid, err) tuple, so any non-404 failure raised TypeError: not all arguments converted during string formatting from inside the handler instead of returning the intended 500 JSON. The emsg_500 parameter the callers supply was never used. This path was previously reachable only by an operator holding an Orthanc ID; manage makes it reachable from a browser, so it is in scope here (FR-8).

The 404 branch's fallback was also parenthesized — % binds tighter than or, so emsg_404 % uid or '(none)' applied the fallback to the formatted message rather than to the UID.

Verification

Full results are recorded on the issue. Confirmed against the running dev stack (Orthanc 26.6.1, public origin http://devsite99.oak-tree.us:8842):

  • manage is claimed by nothing in Orthanc core or the DICOMweb plugin — every method returned the core dispatcher's "Unknown resource" 404 before registration (V-1).
  • Both callbacks register at boot; the plugin starts cleanly.
  • Study and series removal work end to end against synthetic studies staged and removed by the harness itself: 307 on the un-followed response with the correct Location, resource still present until the redirect is followed, gone afterwards, child series removed with a study, parent study pruned when its last series goes.
  • GET .../manage405. POST/PUT → 405; PATCH → 400 from Orthanc core before dispatch.
  • Unknown UID → 404 with a well-formed JSON body and no TypeError from http404_resource_not_found (AR-5).
  • The existing archive endpoints still return their redirect for studies and series — unaffected.
  • CORS at the ingress already permits DELETE, and the preflight is answered by nginx before proxy_pass, bypassing the authorization plugin (V-2).

Two findings from verification that belong to the consuming frontend rather than to this MR, both written up in full on the issue:

  • A denied request carries no CORS headers (nginx add_header is not always), so a 403 surfaces to a browser as an opaque network error rather than a readable status.
  • The redirect is same-origin only while the registered public scheme/host/port match the origin the client used. Where they diverge, fetch/requests drop Authorization at the origin boundary and the second hop 403s — reproduced deliberately. The ?token= credential carrier sidesteps it.

Not in this MR

  • ftests/tests_manage.py (§5.7) — handed to a separate test agent; this repository has no test suite and no CI test stage.
  • Any OHIF frontend work — oak-tree/medical-imaging/imaging-development-env#64.
  • Audit logging of the delete — oak-tree/medical-imaging/imaging-development-env#78.
  • Instance- and patient-level removal, soft delete, bulk removal.
  • Propagating the upstream Orthanc status code through SonadorResourceBaseView.
Edited by Sonador Claude

Merge request reports

Loading