2026-10-15 — the replacement is complete on both copies. The dental radiograph store runs on
SeaweedFS on the platform and on the dental Client VPS (WO-M4 swapped it in place at port parity, so
neither the tunnel nor the tenant row changed); MinIO is stopped and retained on both, and the primary's
service and digest pin are removed. Verified end to end — the app's own client does put -> read-back ->
presigned GET, and the tenant's 123 referenced objects resolve through the tunnel (6/7 via
migrate_tenant_radiographs --verify, the one miss being the knownplaceholder.png). The last piece is the
agent half: no patch agent is installed on that VPS yet, so itsimage_refreshlist cannot be switched on.
Plan and evidence:
MinIO → SeaweedFS Replacement.
2026-10-14 — the replacement is chosen and underway. SeaweedFS (self-hosted S3) replaces MinIO. It runs
beside MinIO on the primary and the app's seven S3 calls plus a presigned GET are proven against it (WO-M1);
the shared store has been copied and byte-verified (WO-M2, 67/67); the shared-store endpoint config now names
seaweedfs:9000(WO-M3). The live tenant's own images are unaffected — SIAAS was ruled (a):test-client-dental
keeps its own box, and WO-M4 replaces MinIO there (port parity with the 5438 forward). Until WO-M4 lands the
accepted risk is halved, not closed. Plan:
MinIO → SeaweedFS Replacement.
2026-10-13 — the MinIO half of this pipeline is being replaced. The community image has been withdrawn
upstream: Docker Hub's repository API returns 404 forminio/minio,docker manifest inspectfails for
minio/minio:latestand the quay path, and our local copy is the only one left. The container runs on
unchanged (digest-pinned, bind-mounted,unless-stopped), so nothing is broken today — but the image can
never be re-obtained and gets no further security fixes. Decision: switch to an alternative S3-compatible
store, deferred to a dedicated session. Scope, candidates and the migration shape:
MinIO Replacement — Scope.
Status: EXECUTED (2026-08-03). The deferred "MinIO PACS storage wiring for
DentalRadiograph" item is complete, including a fully software-based way to test the physical X-ray connection (no real hardware needed). Verified live end-to-end:storescu→ Orthanc → Django → MinIO →DentalRadiograph→ AI flags.
DentalRadiograph had the model, upload/list views, serializers and a rule-based AI analysis hook (radiograph_ai.py) — but uploads went to local disk (MEDIA_ROOT=/app/media), minio_object_name was never populated, and there was no image view UI. Real X-ray sensors export DICOM, which must reach our sovereign MinIO over the isolated-cloud/SSH-tunnel architecture.
The physical hardware hop is now simulated entirely in software using open-source tools, so the whole pipeline is validated now and only the tunnel/hardware wiring needs re-checking at real site onboarding.
| Decision | Choice |
|---|---|
| Radiograph storage | All-in-MinIO (raw .dcm + viewable PNG) |
| Test DICOM data | Synthetic via pydicom (deterministic, offline, valid UIDs) |
| Orthanc | Deployed as permanent infra (the cloud-side DICOM listener) |
| Patient matching | DICOM PatientID = NHS number; error/log if no match |
| Tool | Role in test |
|---|---|
DCMTK storescu (osimis/dcmtk image) |
Simulates the X-ray sensor (DICOM C-STORE SCU) pushing a .dcm over the network |
Orthanc (jodogne/orthanc) |
Practice-side/cloud DICOM listener (C-STORE SCP); forwards to Django via Lua webhook |
pydicom |
Parse/validate, extract patient metadata, convert pixel_array → PNG |
| MinIO (already running) | Sovereign object storage — bucket dental-radiographs |
| Pillow / numpy | PNG conversion/rendering |
Flow under test: storescu (sensor) → Orthanc (listener) → Django webhook → Celery → pydicom → MinIO (raw/ + processed/) → DentalRadiograph record → radiograph_ai.py.
minio, pydicom, numpy added to requirements.txt (Pillow already present). Images rebuilt for app/celery_worker/celery_beat.config/secrets/env.production + dsp_clinic/settings.py):
MINIO_ENDPOINT=minio:9000, MINIO_ACCESS_KEY=minioadmin, MINIO_SECRET_KEY=MinioDemoPass123!, MINIO_BUCKET=dental-radiographs, MINIO_SECURE=falseORTHANC_URL=http://orthanc:8042, ORTHANC_AET, ORTHANC_USERNAME/PASSWORD (optional)dental/services/minio_client.py (lazy client, ensure_bucket, upload_bytes/file, get_object_bytes, object_exists, presigned_url). Both raw .dcm (raw/{patient_id}/{uid}.dcm) and PNG (processed/{patient_id}/{uid}.png) live in bucket dental-radiographs; DentalRadiograph.image.name holds the PNG object key, minio_object_name the raw key.ensure_minio_bucket (idempotent) + backfill_radiographs_to_minio (migrates legacy local-media placeholders into MinIO; --dry-run supported)./dental/radiographs/<pk>/, /dental/radiographs/<pk>/image/) serve the PNG from MinIO, falling back to the legacy media file.dental/services/dicom_ingest.py:
parse_dicom() — pydicom read with a force=True fallback, defensive on unknown/private tags (Carestream/Planmeca/Sirona proprietary data must not crash celery workers).dicom_to_png() — pixel_array → grayscale PNG; handles MONOCHROME1 (inverted) / MONOCHROME2, 8/16-bit (window/min-max normalisation), RGB/RGBA..dcm + PNG to MinIO; create/update DentalRadiograph; call radiograph_ai.analyze_radiograph.PatientID (= NHS number) → existing PATIENT user. nhs_number is an encrypted field (non-deterministic Fernet), so matching is decrypt-and-compare, optionally narrowed to a practice's appointment patients; unmatched files logged, not silently dropped./dental/api/radiographs/ingest/ — authenticated multipart DICOM upload (dispatches Celery)./dental/webhooks/orthanc/) — OnStoredInstance Lua HttpPost → dispatches Celery task ingest_orthanc_instance which pulls the instance from the Orthanc REST API (deadlock-avoided, see gotchas).process_dicom_radiograph (file-path based) + ingest_orthanc_instance (Orthanc-pull based) for async isolation.seed_dicom_samples management command — synthetic dental .dcm (bitewing/periapical/panoramic), valid numeric UIDs, PatientID = seeded patient NHS numbers, MONOCHROME1/2 × 8/16-bit variety, and one file with private tags for robustness. --ingest also runs the direct ingestion path; requires an active practice.gp_booking_app_gp_booking_network (AET ORTHANC, DICOM 4242, REST 8042), Lua webhook → Django.storescu pushes a sample to Orthanc — exercises the real "sensor sends DICOM over the network" path. (One-off: docker run --rm --network gp_booking_app_gp_booking_network -v $PWD/tmp:/data osimis/dcmtk storescu -aec ORTHANC -aet SENSOR-SIM orthanc 4242 /data/x.dcm)dental/tests.py: pydicom parsing incl. private tags; pixel→PNG (MONOCHROME1/2, 8/16-bit); full ingestion flow (DICOM → DentalRadiograph → MinIO objects → AI flags); patient matching (match + no-match). Note: the repo's Django test-DB harness currently cannot build test databases (pre-existing cross-app BigAutoField migration references dental tables on the shared default DB) — all behaviours are instead covered by the live E2E below.storescu → Orthanc → Lua webhook → Django webhook → Celery ingest_orthanc_instance → pydicom parse → patient match (NHS 4000000001) → MinIO (raw/ 525178 B + processed/ 263058 B) → DentalRadiograph created, is_ai_reviewed=True → image proxy view returns the PNG (HTTP 200, valid PNG magic).orthanc-data volume, gp_booking network; DICOM + REST bound to 127.0.0.1 only for the future socat tunnel).ensure_minio_bucket runs on demand / at startup.minio, bucket dental-radiographs created. Reachable from dental_app at http://minio:9000 (health 200).dental_orthanc (jodogne/orthanc), AET ORTHANC, DICOM 127.0.0.1:4242, REST 127.0.0.1:8042, Lua webhook loaded. On the gp_booking network.dental_app and dental_orthanc share the gp_booking network. The app is reachable by dentalapp (RFC-valid network alias) for the Orthanc webhook — the container name dental_app contains an underscore and is rejected by Django's RFC 1034/1035 host validation.redis to BOTH dental_redis and the GP app's redis (ambiguous). Dental broker/result-backend URLs now use dental_redis explicitly (and the result backend was previously missing its password).HttpPost(url, body, headers) expects headers to be a Lua TABLE, not a content-type string — passing a string crashes Orthanc (Json::LogicError, process terminate). Always pass { ['content-type'] = 'application/json' }.pip install into a running container is lost on restart — deps must be in requirements.txt and the image rebuilt. Each compose service builds its own image (dental_booking_app-app/-celery_worker/-celery_beat), so rebuild all three.docker restart does not re-read env_file — recreate the container (docker compose up -d --force-recreate) to pick up env.production changes.id__in=QuerySet against a model in another database is inlined and runs on the outer query's DB. Materialise to a list() first.radiograph_ai.py is rule-based; the AI-agent hook is optional, gated by practice feature flag ai_radiograph_analysis.manage.py test dental now builds fresh test databases and runs green (12 tests, dental/tests.py declares databases = ['default', 'sector_dental']). Fix commit 23f8490a (GP apps allowed to migrate on the shared default DB; removed the pointless users_customuser.id BigAutoField→AutoField→BigAutoField churn; db_constraint=False on infrastructure→tenancy FKs so sync_apps order is safe).Independent of the ERPNext integration. Executed 2026-08-03 (commit aed01416 on develop).