The swiss army knife for USDM study definitions — a FastAPI + HTMX web
application for managing clinical study data expressed in the CDISC
TransCelerate USDM (Unified Study Definitions Model). Imports ICH M11
.docx protocols, validates them against the M11 specification and
against USDM rules (including CDISC CORE), exchanges studies as HL7
FHIR M11 messages, and renders them in a set of comparison and
protocol views.
pip install -r requirements-dev.txt # requirements.txt alone is the deploy set: no AI client, no test tools
cp .env.example .env # or edit the committed .env for local defaults
./dev_server.sh # runs uvicorn on :8000.env ships with single-user defaults (SINGLE_USER=True, no login
required) and mount/ sub-paths so the database, data files, local
files, and CDISC CORE cache all land under ./mount inside the repo.
Single-user mode still needs a SESSION_SECRET (see below).
For the full deployment story — Docker Compose, Docker Run, Docker
Desktop, Fly.io, persistent volumes, and which environment variables
come pre-set inside the published Docker image — see
docs/deployment.md.
SDW uses passwordless email-code login (a short numeric code is
emailed to the user, who enters it to sign in). This replaced the old
Auth0 / OAuth integration — there are no AUTH0_* variables any more
(the legacy AUTH0_SESSION_SECRET name is still accepted as a fallback
for SESSION_SECRET, nothing else).
There are two modes, selected by SINGLE_USER:
- Single-user (
SINGLE_USER=True) — no login screen at all. The app runs as a single built-in user with full (Admin+Transmit) rights. Nothing needs emailing, so no SMTP is required. This is the default for local/desktop/PRISM use. - Multi-user (
SINGLE_USER=False) — email-code login. Users self-register, receive a code by email, and sign in. SMTP must be configured so codes can be sent. Roles live in the user table; any@data4knowledge.dkemail automatically getsAdmin+Transmit.
| Variable | Required | Description |
|---|---|---|
SESSION_SECRET |
always | Signs the session cookie. Set a long random string. (Legacy AUTH0_SESSION_SECRET still works.) |
SMTP_HOST |
multi-user | SMTP server host. If unset, codes are logged to the console instead of emailed (dev mode). |
SMTP_PORT |
multi-user | SMTP port (default 587). |
SMTP_USER |
multi-user | SMTP username. |
SMTP_PASSWORD |
multi-user | SMTP password (keep it a secret / Fly secret). |
SMTP_FROM |
multi-user | From address for the code email (defaults to SMTP_USER). |
REGISTRATION_NOTIFY_EMAIL |
optional | If set, this address is emailed whenever someone self-registers. |
EMAIL_DEV_MODE |
optional | true = log codes instead of emailing. Defaults true when SMTP_HOST is unset. Off/unset in production. |
CODE_LENGTH |
optional | Login code length (default 6). |
CODE_EXPIRY_MINUTES |
optional | Minutes a code stays valid (default 10). |
DEV_LOGIN_CODE |
optional (dev only) | Fixed code used only in dev mode (Playwright). Ignored in production. See playwright_server.sh. |
Production gotcha: if
SINGLE_USER=Falseand noSMTP_HOSTis set (orEMAIL_DEV_MODE=true), codes are only logged, never emailed — and nobody can log in. Always configure SMTP for a real multi-user deploy.
SESSION_SECRET="<a long random string>"
SINGLE_USER=True
FILE_PICKER=os # or 'browser'
# plus the storage paths below (or rely on the Docker defaults)
No SMTP, no user seeding, no registration needed.
SESSION_SECRET="<a long random string>"
SINGLE_USER=False
FILE_PICKER=browser
SMTP_HOST="smtp.example.com"
SMTP_PORT=587
SMTP_USER="no-reply@example.com"
SMTP_PASSWORD="<smtp password>"
SMTP_FROM="no-reply@example.com"
REGISTRATION_NOTIFY_EMAIL="admin@example.com" # optional
Getting the first user in: anyone with an @data4knowledge.dk address
can self-register at /register (they auto-get Admin + Transmit). For
any other first admin, seed one directly:
python -m scripts.seed_user --email you@example.com --name "Your Name" --roles Admin,TransmitAdmins then grant Admin / Transmit to other registered users at
/users/manage.
| Variable | Description |
|---|---|
SINGLE_USER |
True for single-user mode (no login), False for multi-user email-code login |
FILE_PICKER |
browser for standard browser uploads, os for the built-in server-side picker |
MNT_PATH |
Root mount path for persistent storage |
DATABASE_PATH |
Directory where the SQLite database resides |
DATABASE_NAME |
Database filename (e.g. production.db) |
DATAFILE_PATH |
Directory for uploaded/generated data files |
LOCALFILE_PATH |
Directory for local working files within the volume |
CDISC_CORE_CACHE_PATH |
Directory for the CDISC CORE validation cache (rules, JSONata files, XSD schemas, CT packages). Put it on the mounted volume (e.g. /mnt/<name>/core_cache) so a cold cache — which can take several minutes to rebuild — survives container restarts. Leave unset to fall through to the USDM4 platform default (ephemeral in a container). |
ADDRESS_SERVER_URL |
URL for the external address server |
USE_AI |
True to use AI (Claude) in the M11 import — needs requirements-dev.txt (the usdm4_protocol[ai] extra) and ANTHROPIC_API_KEY. Off when unset; the Docker image has no AI client, so an M11 SoA is then logged as not read |
When running via Docker, the Dockerfile pre-sets MNT_PATH,
DATABASE_PATH, DATABASE_NAME, DATAFILE_PATH, LOCALFILE_PATH,
CDISC_CORE_CACHE_PATH, and ADDRESS_SERVER_URL, so only
SESSION_SECRET, SINGLE_USER, FILE_PICKER, and (if multi-user) the
SMTP variables need to be supplied externally.
| Variable | Description |
|---|---|
FHIR_SERVER_URL |
Base URL of the default FHIR server (e.g. an Aidbox instance). When set, users with the Transmit role get a "to default server" entry in the M11 and SoA Transmit menus. Not stored in the database. Unset hides the entry and the routes refuse. |
FHIR_SERVER_USERNAME |
Optional basic-auth username for the default server. |
FHIR_SERVER_PASSWORD |
Optional basic-auth password for the default server. |
Credentials are only ever sent to FHIR_SERVER_URL. FHIR endpoints that users add themselves (Users → Endpoints) are sent no credentials, so they must be open servers (e.g. the public HAPI server).
SDW stores its database, data files, local files, and CDISC CORE
cache under /mount inside the container — a Docker volume must
be mounted there or all data is lost on container stop. There are
two paths:
docker build . -t data4knowledge/sdw:latest
docker compose upcompose.yml declares a named volume sdw_data mounted at /mount.
Compose creates it automatically on first up and reuses it on
subsequent ups — you do not need to run docker volume create.
Use docker compose down to stop while keeping your data;
docker compose down -v wipes the volume.
Set environment variables via a .docker_env file (referenced by
compose.yml). Minimum for single-user mode:
SESSION_SECRET="<a long random string>"
SINGLE_USER=True
FILE_PICKER=browser
For multi-user mode add SINGLE_USER=False plus the SMTP_* variables
(see the Authentication section) so login codes can be emailed.
If you're not using Compose, create the volume yourself first:
docker build . -t data4knowledge/sdw:latest
docker volume create sdw_data
docker run -d \
--mount source=sdw_data,target=/mount \
-p 8000:8000 \
--env-file .env \
data4knowledge/sdw:latestHeads-up: The volume Compose manages and a volume you create manually with
docker volume create sdw_dataare not the same volume — Compose prefixes the name with the project directory. Pick one path per environment and stick with it. Seedocs/deployment.mdfor the full walkthrough and for Docker Desktop / Fly.io / multi-platform publishing.
Two environments, each with its own .toml:
fly_production.toml— appd4k-sdwfly_staging.toml— appd4k-sdw-staging
Config on Fly comes from secrets, not the committed .staging_env /
.production_env files (those are dockerignored and only kept as a
reference for what to set). Set storage paths, the session secret, and —
for multi-user — the SMTP settings:
fly secrets set -a d4k-sdw \
MNT_PATH="/mnt/sdw_data" \
DATABASE_PATH="/mnt/sdw_data/database" \
DATABASE_NAME="production.db" \
DATAFILE_PATH="/mnt/sdw_data/datafiles" \
LOCALFILE_PATH="/mnt/sdw_data/localfiles" \
CDISC_CORE_CACHE_PATH="/mnt/sdw_data/core_cache" \
SESSION_SECRET="<a long random string>" \
SINGLE_USER=False \
FILE_PICKER=browser \
SMTP_HOST="asmtp.dandomain.dk" \
SMTP_PORT=587 \
SMTP_USER="no-reply@d4k.dk" \
SMTP_PASSWORD="<smtp password>" \
SMTP_FROM="no-reply@d4k.dk" \
REGISTRATION_NOTIFY_EMAIL="dih@data4knowledge.dk"Faster alternative: import a whole env file in one atomic restart (pairs the file to the right app — no -a typo can cross-target):
fly secrets import -c fly_production.toml < .production_env
fly secrets import -c fly_staging.toml < .staging_envAlways qualify Fly commands with -c <toml> (or -a <app>) — there is
no plain fly.toml, so nothing auto-detects the target.
Do not set EMAIL_DEV_MODE=true in production — codes would be
logged instead of emailed and logins would fail.
Deploy against the matching .toml:
fly deploy -c fly_production.tomlFull Fly.io walkthrough (volumes, single-machine constraint, staging
vs production) lives in docs/deployment.md.
Two suites, with very different cost profiles:
# Unit tests — fast, safe to run any time
python -m pytest --ignore=tests/playwright
# Playwright end-to-end tests — require a running server + browser
python -m pytest tests/playwrightWhich database/storage a process uses is decided by PYTHON_ENVIRONMENT,
which selects a .{env}_env file (ServiceEnvironment loads it once at
import). Each environment points at its own mount, so dev and test data
are isolated:
| Process | PYTHON_ENVIRONMENT |
Env file | Mount / database |
|---|---|---|---|
Dev server (dev_server.sh) |
development |
.development_env |
mount/ → mount/database/database.db |
Unit tests (conftest.py forces it) |
test |
.test_env |
tests/test_files/mount/ → …/database/test_database.db |
Playwright server (playwright_server.sh) |
test |
.test_env |
same test mount as unit tests |
Because the Playwright server runs in the test environment, the e2e
suite's destructive actions (Delete Database, delete/import flows) hit
the throwaway test database — never the dev database. .test_env
and .development_env are untracked per-machine files; keep their
MNT_PATH aligned with their DATABASE_PATH / DATAFILE_PATH (all
under the same mount) or clean_and_tidy will refuse to run (it guards
against deleting data outside its mount).
Remaining caveat: the Playwright server and pytest both use the
test database, so don't run unit tests while the Playwright server
is up — it can scramble a test run (but no longer touches dev data).
See docs/lessons_learned.md (§ 6) for the
full story of how this used to wipe the dev database.
docs/deployment.md— full deployment guide (Docker Compose, Docker Run, Docker Desktop, Fly.io, volumes, env vars).docs/m11_validation.md— M11 validation UI end-to-end: import-time single-run validator, study-view Validation tab, compare-view per-cell findings, standalone/validate/m11-docxflow, routes, and templates.docs/next_steps.md— running backlog of scoped-but-not-shipped improvements.docs/lessons_learned.md— durable "don't step on this" notes from building SDW features (HTMX patterns, DataFiles gotchas, the external-package cache persistence story for CDISC CORE, etc.).claude.md— project orientation for contributors and AI agents (architecture stance, test philosophy, known issues).
- Unit tests pass (
python -m pytest --ignore=tests/playwright) - Deploy to staging and verify
- Playwright end-to-end tests pass
- Deploy to production and verify
- Build and push the Docker image (tagged with version and
latest) - Tag the release in GitHub
- Check pFDA
- Write GitHub release notes
- Update version and release notes for the next release