This document covers the development environment setup, repository structure, CI/CD pipeline, and deployment configuration for the MedCover application.
For architectural decisions behind these choices, see architecture.md (AD09, AD10, Deployment Model).
How MedCover is developed and deployed together with MemberBase (member directory and single sign-on) is in MemberBase.
MedCover/
├── .github/
│ ├── dependabot.yml # Weekly dependency update PRs (pip + GitHub Actions)
│ └── workflows/
│ └── ci.yml # Run lint + tests + pip-audit on every PR and push
│
├── app/
│ ├── __init__.py # Flask app factory: create_app(); CSP headers; custom filters
│ ├── config.py # Config classes: DevelopmentConfig, ProductionConfig
│ ├── extensions.py # Flask extensions (db, migrate, mail, login_manager, csrf)
│ ├── utils.py # Shared helpers: require_permission, audit, diff_changes, …
│ ├── queries.py # Reusable DB queries (active_master_events_list, …)
│ ├── mail.py # Email sending helpers (outbox-backed)
│ ├── scheduler_tasks.py # Task implementations called by scheduler/main.py
│ ├── work_report_generator.py# Výkaz práce XLSX generator
│ ├── models/ # SQLAlchemy models (one file per domain entity)
│ │ ├── __init__.py # Imports all models so Alembic auto-detects them
│ │ ├── user.py # UserAccount, has_permission(), has_any_permission()
│ │ ├── role.py # Role enum, ALL_PERMISSIONS, ROLE_PERMISSIONS
│ │ ├── event.py # Event, EventSpot, EventStatus, EventTemplate
│ │ ├── master_event.py # MasterEvent (hierarchy for yearly reporting)
│ │ ├── assignment.py # Assignment (user ↔ spot)
│ │ ├── equipment.py # EquipmentType, EquipmentItem, plans, assignments
│ │ ├── qualification.py # Qualification, UserQualification (credentials)
│ │ ├── audit.py # AuditLogEntry
│ │ ├── settings.py # AppSettings (SMTP, setup flag, Fernet-encrypted creds)
│ │ ├── invite.py # Invite (invite-only registration tokens)
│ │ ├── outbox.py # OutboxEmail (queued emails, retry logic, batched-notification fields)
│ │ ├── digest.py # DigestSchedule, DigestBlock, DigestMetricSnapshot (admin digest email)
│ │ ├── debriefing.py # DebriefingRecord, DebriefingQuestion
│ │ └── feedback.py # UserFeedback
│ ├── routes/ # Flask blueprints (one per feature area)
│ │ ├── __init__.py
│ │ ├── auth.py # Login, logout, password reset, registration
│ │ ├── setup.py # First-run setup wizard
│ │ ├── admin.py # Dashboard, audit log, permissions overview
│ │ ├── admin_digest.py # Weekly digest subscription management
│ │ ├── app_settings.py # SMTP & app settings (admin)
│ │ ├── backup.py # DB backup/restore (admin)
│ │ ├── users.py # User management, invites, credentials
│ │ ├── master_events.py # Master Event CRUD
│ │ ├── events.py # Event CRUD, lifecycle, spot assignment, calendar feed
│ │ ├── assignments.py # Assignment claim/release
│ │ ├── templates.py # Event template CRUD
│ │ ├── qualifications.py # Qualification (credential type) CRUD
│ │ ├── equipment.py # Equipment types, items, issuance, event plans
│ │ ├── import_events.py # Bulk event import from paste
│ │ ├── reports.py # Reports (staffing, statistics, glossary)
│ │ ├── debriefing.py # Post-event debriefing forms
│ │ ├── work_report.py # Výkaz práce (monthly work-report XLSX)
│ │ ├── feedback.py # User feedback submission
│ │ ├── main.py # Dashboard, health check
│ │ └── dev.py # Dev-only routes (disabled in production)
│ ├── templates/ # Jinja2 HTML templates
│ │ ├── base.html # Base layout with nav, CSP-safe JS config
│ │ ├── macros/ # Reusable macros (help_icon, pagination, …)
│ │ ├── auth/
│ │ ├── events/
│ │ ├── equipment/
│ │ └── …
│ ├── static/
│ │ ├── css/main.css # Custom utility classes (no inline styles — CSP)
│ │ ├── js/ # FullCalendar, per-page JS modules
│ │ └── img/
│ └── email/ # Email templates (Jinja2, plain-text + HTML)
│
├── scheduler/
│ └── main.py # Background task runner (schedule library)
│ # Tasks: event auto-transitions, reminder emails,
│ # digest emails, work-report cleanup
│
├── migrations/ # Flask-Migrate (Alembic) migration scripts
│ └── versions/
│
├── tests/
│ ├── conftest.py # Fixtures: app, DB, client per role; AppSettings seed
│ ├── test_auth.py
│ ├── test_events.py
│ ├── test_assignments.py
│ ├── test_equipment.py
│ ├── test_admin.py
│ ├── test_admin_digest.py
│ ├── test_debriefing.py
│ ├── test_import_events.py
│ ├── test_master_events.py
│ ├── test_qualifications.py
│ ├── test_reports.py
│ ├── test_templates.py
│ ├── test_users.py
│ ├── test_work_report.py
│ └── …
│
├── scripts/
│ ├── seed_dev.py # Populates DB with realistic mock data for local dev
│ ├── compile_requirements.sh # Recompiles .in → .txt in a Linux container (deterministic hashes)
│ └── e2e-entrypoint.sh # Docker entrypoint for E2E web container
│
├── e2e_tests/ # Playwright browser tests (NOT run by default pytest)
│ ├── conftest.py # Fixtures: base_url, logged_in_page
│ ├── test_login_flow.py
│ ├── test_create_event.py
│ └── test_smoke_navigation.py
│
├── Dockerfile # Single image for both web and scheduler containers
├── docker-compose.yml # Local dev: web + scheduler + MSSQL (hot reload)
├── docker-compose.e2e.yml # E2E tests: db-e2e + web-e2e + playwright runner
├── .env.example # Template for required env vars — COMMIT THIS
├── .env # Actual secrets — NEVER COMMIT (in .gitignore)
├── .dockerignore
├── requirements.txt # Production dependencies (compiled from .in files)
├── requirements-dev.txt # Dev/test extras (compiled from .in files)
├── requirements-telemetry.txt # Azure Monitor/OpenTelemetry deps — image only
├── requirements-e2e.txt # E2E test deps: pytest-playwright
├── Makefile # Shortcuts: make e2e, make test
├── tox.ini # tox envs: py314 (unit), e2e (playwright)
├── architecture.md
└── DEVOPS.md # This file
Two containers share a single Docker image; they run different commands:
| Container | Dev command (docker-compose) | Prod command (Dockerfile CMD) | Purpose |
|---|---|---|---|
web |
flask run --host=0.0.0.0 --debug |
gunicorn -w 2 -b 0.0.0.0:${PORT:-5000} "app:create_app()" |
Serves the Flask web application |
scheduler |
python scheduler/main.py |
python scheduler/main.py |
Background tasks: auto-transitions, reminders, digests, file cleanup |
Both containers share the same codebase and connect to the same MSSQL database via DATABASE_URL.
The web container uses docker-entrypoint.sh which runs flask db upgrade + flask verify-schema before starting.
The scheduler container uses docker-entrypoint-scheduler.sh (no migrations) and waits for web to be healthy before starting.
- Docker Desktop (or Docker Engine + Docker Compose)
- Git
git clone https://github.com/CzechRedCrossFM/MedCover.git
cd MedCover
cp .env.example .env # Fill in your local secrets
docker compose up --build # Starts web + scheduler + MSSQLThe app will be available at http://localhost:5000.
docker compose exec web python scripts/seed_dev.pyThis creates realistic test users, credentials, master events, events, assignments, and equipment. Running it multiple times is safe (idempotent).
# Create a new migration after model changes
docker compose exec web flask db migrate -m "describe the change"
# Apply pending migrations
docker compose exec web flask db upgradeTests run on the host in a local Python 3.14 virtualenv. The application
image (Dockerfile) installs only requirements.txt plus
requirements-telemetry.txt (production), so the
running web/scheduler containers do not contain pytest/tox — running
the suite inside them does not work. CI follows the same host-based approach
(see .github/workflows/ci.yml).
Because the only database driver is now pyodbc, the host needs the
Microsoft ODBC Driver 18 and unixODBC installed once (pyodbc links against
libodbc.so.2 at runtime — pip alone is not enough):
# Debian/Ubuntu — install the ODBC driver + unixODBC (one-time, needs sudo)
curl -fsSL https://packages.microsoft.com/keys/microsoft.asc \
| sudo gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg
curl -fsSL https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list \
| sudo tee /etc/apt/sources.list.d/mssql-release.list
sudo apt-get update
sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev
# macOS: brew install msodbcsql18 unixodbcThen create a venv, install dev dependencies, and run the suite:
python3.14 -m venv .venv && source .venv/bin/activate
pip install --require-hashes -r requirements-dev.txt
# Set TEST_DATABASE_URL to use an existing MSSQL DB (the dev db works fine —
# the suite uses an isolated medcover_test database), or leave it unset to let
# testcontainers auto-spin an MSSQL 2022 Express container.
export TEST_DATABASE_URL="mssql+pyodbc://SA:DevPassword123!@localhost:1433/medcover_test?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes"
pytest # or: tox -e py314 (mirrors CI)End-to-end tests use real browsers (Chromium, Firefox, WebKit) driven by Playwright to test rendered pages, JS validation, form submission, and navigation. Everything runs in containers (Docker or Podman) — nothing is installed on the host.
Container runtime: The Makefile auto-detects podman or docker (preferring
Podman). Override with make e2e CONTAINER_ENGINE=docker if needed. For tox,
set CONTAINER_ENGINE=docker tox -e e2e.
Architecture: docker-compose.e2e.yml spins up three containers:
| Container | Image | Purpose |
|---|---|---|
db-e2e |
mcr.microsoft.com/mssql/server:2022-latest |
Fresh MSSQL on tmpfs (destroyed after each run) |
web-e2e |
App Dockerfile | Runs migrations, seeds data (seed_dev.py), serves Flask |
e2e |
mcr.microsoft.com/playwright/python |
Runs Playwright tests against http://web-e2e:5000 |
How to run:
# Using Make (recommended)
make e2e
# Or using tox
tox -e e2e
# Or directly with Docker Compose
docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit --exit-code-from e2e
docker compose -f docker-compose.e2e.yml down -vCleanup after a failed run:
make e2e-down
# or: docker compose -f docker-compose.e2e.yml down -vTest files live in e2e_tests/ (separate from tests/) and are never
included in the regular pytest or CI runs.
HTML report: After each run an HTML report with screenshots is saved to
e2e-report/report.html. To view it, run:
make e2e-report
# Opens http://localhost:9323/report.html (Ctrl+C to stop)Note: Opening
report.htmldirectly as afile://URL will fail due to browser security restrictions. Always usemake e2e-reportto serve it via HTTP.
First run pulls the Playwright Docker image (~1.5 GB) and builds the app image. Subsequent runs are faster thanks to Docker layer caching.
Adding new E2E tests: create a test_*.py file in e2e_tests/. Use the
logged_in_page fixture from e2e_tests/conftest.py for tests that need an
authenticated session (logs in as the admin dev user automatically).
The embedded summary below reflects the actual file. Key points:
webusesflask run --debug(hot reload) in dev; production uses gunicorn viaCMDin the Dockerfile- Both containers mount
.:/appso local code changes reflect immediately - Both containers store DB backups in the bundled Azurite blob emulator — see Backup storage below
- Both containers have healthchecks; the scheduler checks a heartbeat file written every ~5 s
dbuses MSSQL 2022 Express (mcr.microsoft.com/mssql/server:2022-latest) with Czech collation and RCSI enabled
services:
web:
build:
context: .
args:
GIT_COMMIT: ${GIT_COMMIT:-dev}
command: flask run --host=0.0.0.0 --debug
restart: unless-stopped
volumes:
- .:/app # Hot reload: local code changes reflect immediately
env_file: .env
environment:
BACKUP_STORAGE_CONNECTION_STRING: "...;BlobEndpoint=http://azurite:10000/devstoreaccount1;"
ports:
- "5000:5000"
depends_on:
db:
condition: service_healthy
db-init:
condition: service_completed_successfully
scheduler:
build:
context: .
args:
GIT_COMMIT: ${GIT_COMMIT:-dev}
entrypoint: ["/docker-entrypoint-scheduler.sh"]
command: python scheduler/main.py
restart: unless-stopped
volumes:
- .:/app
env_file: .env
environment:
BACKUP_STORAGE_CONNECTION_STRING: "...;BlobEndpoint=http://azurite:10000/devstoreaccount1;"
depends_on:
web:
condition: service_healthy
azurite:
image: mcr.microsoft.com/azure-storage/azurite
command: azurite-blob --blobHost 0.0.0.0 --location /data --skipApiVersionCheck
ports:
- "10000:10000"
volumes:
- azurite_data:/data
db:
image: mcr.microsoft.com/mssql/server:2022-latest
restart: unless-stopped
environment:
ACCEPT_EULA: "Y"
MSSQL_PID: "Express"
MSSQL_SA_PASSWORD: "DevPassword123!"
MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8"
ports:
- "1433:1433"
volumes:
- mssql_data:/var/opt/mssql
# One-shot initializer — the MSSQL image has no auto-init directory, so this
# creates the dev database (Czech collation + RCSI) and the app login/user
# once the db is healthy, then exits. web waits for it to complete.
db-init:
image: mcr.microsoft.com/mssql/server:2022-latest
depends_on:
db:
condition: service_healthy
environment:
MSSQL_HOST: db
MSSQL_SA_PASSWORD: "DevPassword123!"
volumes:
- ./mssql-init:/mssql-init:ro
entrypoint: ["/bin/bash", "/mssql-init/setup.sh"]
restart: "no"
volumes:
mssql_data:
azurite_data:DB backup archives (medcover_backup_<UTC timestamp>_UTC.zip) are stored as
blobs, so the web app and the scheduler — separate containers — see the same
backups without a shared volume, and backups survive container restarts.
app/backup.py picks the target from the environment; exactly one of these
must be set (production refuses to start otherwise):
| Variable | Used by | Auth |
|---|---|---|
BACKUP_CONTAINER_URL (+ AZURE_CLIENT_ID) |
Azure prod, self-hosted prod | Entra ID via DefaultAzureCredential; the storage account has shared-key access disabled |
BACKUP_STORAGE_CONNECTION_STRING |
Dev, e2e, CI, tests | Azurite development account |
- Azure prod: the storage account, container, Storage Blob Data
Contributor role assignment for the identity and both variables are
provisioned by the
medcover-infrarepo. The app never creates the container there. Soft delete makes pruned backups recoverable. - Self-hosted prod (
docker-compose.prod.yml): the managed identity only works on an Azure VM with that identity attached. On any other host, giveDefaultAzureCredentiala service principal instead (AZURE_TENANT_ID,AZURE_CLIENT_ID,AZURE_CLIENT_SECRET) with the same role. - Dev (
docker-compose.yml): theazuriteservice, data in theazurite_datavolume (survivesdocker compose down; wiped only bydocker compose down -v). In connection-string mode the app creates the container (BACKUP_CONTAINER_NAME, defaultbackups) on first use. Port 10000 is published so Azure Storage Explorer can browse it. - e2e: in-memory
azurite-e2e. CI: Azurite started by testcontainers (see below).pytestwithoutBACKUP_STORAGE_CONNECTION_STRINGstarts one through testcontainers; every test gets its own blob container.
The legacy app_settings.backup_dir column is no longer used; it stays in
the database for rollout compatibility and will be dropped in a later
migration. migrations/env.py excludes it from autogenerate so the drop
never slips into an unrelated migration — remove that filter together with
the drop migration.
Upgrading from the file-based backups: archives already on the old
/backups share are not migrated automatically and disappear from the admin
list after the upgrade. Copy the ones worth keeping into the container
beforehand, e.g.
azcopy copy '/backups/medcover_backup_*.zip' '<BACKUP_CONTAINER_URL>'.
MemberBase („Evidence členů“)
is the member directory that MedCover will use for identities, passwords,
roles and qualifications: OpenLDAP holds the data, Keycloak is the only
login (OIDC, TOTP, passkeys), and the MemberBase app administers it.
MedCover does not use it yet. This section is MedCover's side of how the two
repositories are developed and deployed together; MemberBase's side,
including the exact contract between the apps, is in
MemberBase's DEVOPS.md.
| Repository | Compose project | Services | Owns |
|---|---|---|---|
| MedCover (this repo) | medcover |
web, scheduler, db, azurite |
MedCover code; its OIDC login and directory sync settings |
| MemberBase | memberbase |
openldap, keycloak, keycloak-db, mailpit, memberbase, memberbase-jobs |
Directory schema and access rules, Keycloak realm and login theme, the MemberBase app |
| Infrastructure (private) | — (Bicep) | the production stack | Host names, secrets, pinned image versions, Azure resources |
Rules:
- No cross-repository paths. MedCover never builds, mounts or reads files from a MemberBase checkout, and the other way round. Checkouts can live anywhere.
- Each service is defined once, in the repository that owns it. MedCover's compose files never define directory, Keycloak or MemberBase services.
- Dev stacks connect over one Docker network,
crc-dev, created by the MemberBase stack. Production connects the same services inside one Azure Container Apps environment. - Production runs images, never source. MemberBase publishes its own
images to
ghcr.io/czechredcrossfm/; the infrastructure repository pins which versions run. - Real deployment details stay out of both public repositories: host
names, the directory base DN, secrets and Azure resource names belong in
untracked
.envfiles or the infrastructure repository.
A MedCover-only contributor needs none of this: docker compose up works as
described above. To run both apps:
- In a MemberBase checkout,
cp .env.example .env && docker compose up --build -d. This starts the directory, Keycloak (http://localhost:8180), Mailpit (http://localhost:8025) and MemberBase (http://localhost:5100), and creates the networkcrc-devand the volumememberbase-ldap-certs(the directory's CA certificate). - In this repository, enable the MemberBase overlay in
.env:COMPOSE_FILE=docker-compose.yml:docker-compose.memberbase.yml
docker-compose.memberbase.ymlonly attacheswebandschedulertocrc-dev, mountsmemberbase-ldap-certsread-only and sets the OIDC and directory variables. It defines no services of its own. docker compose up -d. MedCover reaches Keycloak athttp://keycloak:8080and the directory atldaps://openldap:1636; the browser uses port 8180 of the dev host for the login pages.
The dev secrets shared by both apps (the medcover OIDC client secret and
the medcover-sync directory password) have the same defaults in both
.env.example files, so they match without editing.
Stop MedCover before taking the MemberBase stack down; otherwise Docker
cannot remove the crc-dev network. Ports don't overlap: MedCover keeps 5000
and 1433; MemberBase uses 5100, 8180 and 8025.
- Unit tests never need MemberBase: OIDC login uses a test helper that logs a seeded local user in, and the directory is mocked.
- Tests that need the real directory or Keycloak (the directory sync, OIDC login, back-channel logout, Playwright E2E across both apps) run against published MemberBase images at a version pinned in this repository, never against a MemberBase checkout. Dependabot proposes version bumps.
- CI in this repository never checks out MemberBase, and MemberBase's CI never checks out this repository.
The contract (service names, OIDC client and claims, the sync account's
attributes, GET /api/roles, shared secrets) is listed in MemberBase's
DEVOPS.md. When it changes:
- MemberBase changes first, backward-compatibly (add before remove), and is released.
- MedCover follows and bumps its pinned MemberBase version in the same PR.
- Production deploys MemberBase first, then MedCover.
- MemberBase removes the old form in a later release.
MemberBase runs in the same Container Apps environment as MedCover:
openldap (internal only, one replica, Azure Files share), keycloak
(database keycloak on the existing Azure SQL server), memberbase and a
Container Apps job for its 15-minute tasks. MedCover's web and scheduler
reach keycloak and openldap over the environment's internal network. The
deployment is defined in the infrastructure repository; MemberBase's
DEVOPS.md describes its storage, backups and update procedure.
An earlier branch put the MemberBase services into this repository's
docker-compose.yml under a compose profile, built from a sibling checkout.
That approach is replaced by the layout above. Remaining steps:
- MemberBase: its own stack with
keycloak-db, thecrc-devnetwork and thememberbase-ldap-certsvolume; published images. - MedCover:
docker-compose.memberbase.ymland the matching.env.exampleentries, added together with the OIDC login. - Dev instance: remove the MemberBase containers from the
medcoverproject and run them as thememberbaseproject.
FROM python:3.14-slim
WORKDIR /app
COPY requirements.txt requirements-telemetry.txt ./
RUN pip install --no-cache-dir --require-hashes -r requirements.txt \
&& pip install --no-cache-dir --require-hashes -r requirements-telemetry.txt
COPY . .
# Embed git commit hash at build time:
# docker build --build-arg GIT_COMMIT=$(git rev-parse --short HEAD) .
ARG GIT_COMMIT=dev
ENV GIT_COMMIT=${GIT_COMMIT}
COPY docker-entrypoint.sh /docker-entrypoint.sh
COPY docker-entrypoint-scheduler.sh /docker-entrypoint-scheduler.sh
RUN chmod +x /docker-entrypoint.sh /docker-entrypoint-scheduler.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["sh", "-c", "gunicorn -w 2 -b 0.0.0.0:${PORT:-5000} \"app:create_app()\""]docker-entrypoint.sh runs flask db upgrade then flask verify-schema on every container start before handing off to the CMD process. If verify-schema detects missing tables/columns the container exits immediately rather than serving broken traffic.
docker-entrypoint-scheduler.sh is a lightweight entrypoint for the scheduler — it skips migrations and schema verification (the scheduler waits for web to be healthy first).
Dependencies are compiled with uv pip compile (.in → .txt compilation with hashes).
| File | Purpose |
|---|---|
requirements.in |
Top-level production dependencies |
requirements-dev.in |
Dev/test extras (extends production) |
requirements-e2e.in |
Playwright E2E test deps |
requirements-telemetry.in |
Azure Monitor exporter — installed in the image only, never locally or in CI |
requirements.txt |
Compiled lock file with hashes (committed) |
requirements-dev.txt |
Compiled dev lock file with hashes (committed) |
requirements-e2e.txt |
Compiled E2E lock file with hashes (committed) |
requirements-telemetry.txt |
Compiled telemetry lock file with hashes (committed); compiled with requirements.txt as a constraint so shared packages cannot drift to a second version |
- Edit the relevant
.infile (add/bump the package). - Run the compile script:
This uses Podman (or Docker) to compile inside a
./scripts/compile_requirements.sh
python:3.14-slimLinux/amd64 container. Production and E2E compile in parallel; dev and telemetry compile in parallel after production finishes. The cache is discarded when the container exits. - Review the diff:
git diff requirements*.txt - Commit both the
.inand.txtfiles together.
Why a container? Dependency resolution can vary by platform and Python version. The container keeps compilation aligned with CI and production.
Dependabot submits weekly PRs for pip and GitHub Actions dependency updates (configured in .github/dependabot.yml). Review and merge these regularly.
Copy .env.example to .env for local development. Never commit .env.
| Variable | Description | Example |
|---|---|---|
FLASK_ENV |
development or production |
development |
SECRET_KEY |
Flask session secret — generate a strong random value | openssl rand -hex 32 |
DATABASE_URL |
MSSQL connection string | mssql+pyodbc://medcover:Dev_Password1!@db:1433/medcover_dev?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes |
APPLICATIONINSIGHTS_CONNECTION_STRING |
Optional. When set, the app exports OpenTelemetry traces/metrics to Azure Monitor. Unset (local, CI) means no telemetry. When AZURE_CLIENT_ID is also set (and AZURE_CLIENT_SECRET is not), ingestion authenticates with that managed identity (Entra ID) instead of the instrumentation key; the identity needs the Monitoring Metrics Publisher role on the Application Insights resource. The chosen auth mode is logged at startup. |
supplied by the hosting environment |
Email / SMTP: SMTP credentials are configured through the web UI setup wizard on first run and stored Fernet-encrypted in the
app_settingsdatabase table. NoMAIL_*environment variables are required.
MedCover runs on Azure Container Apps (France Central) with Azure SQL Database as the managed database service. The CI/CD pipeline (.github/workflows/deploy-azure.yml) builds and deploys on every version tag push.
See azure-setup-guide.md in the medcover-infra repo for the full Azure provisioning guide.
- Docker image: A single
Dockerfilebuilds an image usable for bothwebandschedulercontainers. - Database migrations: Run automatically via
docker-entrypoint.sh(flask db upgrade) onwebcontainer start. The scheduler uses a lightweight entrypoint without migrations. - First-run setup wizard: After the web service is live, navigate to the app URL. The wizard appears on first visit — configure the application name, admin account, and SMTP settings there.
- Production compose file:
docker-compose.prod.ymlis available for self-hosted deployments (e.g. the zerver home-lab test server).
The migration history is a single MSSQL-native baseline
(migrations/versions/*_mssql_baseline_schema.py, down_revision = None), so
no manual bootstrap is needed. The web container's entrypoint runs
flask db upgrade on every start, which creates the full schema on a fresh
Azure SQL database and applies any later migrations on subsequent deploys.
Historical note: earlier revisions carried 45 PostgreSQL-era migrations that could not run on MSSQL and required a manual
stamp head+ autogenerate bootstrap. Those were squashed into the single baseline in PR #381; the manual step is gone.
Tag a version → GitHub Actions builds image → deploys to both Container Apps automatically:
git tag v1.2.3
git push origin v1.2.3MedCover uses mypy 2.0 for static type checking. All production code in app/ and scheduler/ is annotated and must pass mypy on every commit.
source .venv/bin/activate
mypy app/ scheduler/A clean run prints Success: no issues found in N source files.
mypy is configured in pyproject.toml under [tool.mypy]:
disallow_untyped_defs = true— hard requirement: every function must have full parameter and return type annotationscheck_untyped_defs = true— bodies of annotated functions are fully type-checkedignore_missing_imports = true— suppresses errors for third-party packages without stubs (Flask, SQLAlchemy, etc.)exclude— migrations, tests, htmlcov, and .venv are excluded
| Override | Reason |
|---|---|
app.models.* — disables name-defined, misc, assignment |
db.Model base class is not resolvable without full SQLAlchemy stubs; db.relationship() returns RelationshipProperty[Any] at the type level |
app.routes.* — disables union-attr, return-value, attr-defined |
Flask's redirect() returns werkzeug.wrappers.Response (not flask.wrappers.Response); current_user is a LocalProxy without union narrowing |
scripts.* — ignore_errors = true |
Seed scripts are not production code |
mypy runs automatically on every commit via .pre-commit-config.yaml:
- repo: local
hooks:
- id: mypy
name: mypy
entry: .venv/bin/mypy app/ scheduler/
language: system
pass_filenames: false
always_run: trueIt runs before pytest. A commit is rejected if mypy reports any errors.
SQLAlchemy models use the old-style db.Column() syntax (not Mapped[]-style declarative). To avoid converting models (which risks bugs), the pattern is:
- Add
# type: ignore[misc]to the class definition line:class Event(db.Model): # type: ignore[misc] - Annotate relationship attributes with
Mapped[list[X]]orMapped[X | None]when they are iterated or accessed — only the attribute declaration, not thedb.relationship(...)call - Import forward references under
TYPE_CHECKINGto avoid circular imports at runtime
PR opened / updated
↓
GitHub Actions: ci.yml
├── lint job: pre-commit (flake8, mypy, pyupgrade, whitespace)
├── test job: MSSQL 2022 service → pytest --cov
└── audit job: pip-audit → check dependencies for known CVEs
↓
Review, approve, merge
Dependabot submits weekly PRs for pip and github-actions dependency updates (configured in .github/dependabot.yml).
Tag a version to trigger the deploy workflow (deploy-azure.yml) which builds the Docker image and deploys to Azure Container Apps.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit hooks
run: pre-commit run --all-files
test:
runs-on: ubuntu-latest
services:
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
env:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: "CiPassword123!"
MSSQL_PID: "Express"
MSSQL_COLLATION: "Czech_100_CI_AS_SC_UTF8"
ports:
- 1433:1433
options: >-
--health-cmd "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P 'CiPassword123!' -C -Q 'SELECT 1' -b"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
TEST_DATABASE_URL: "mssql+pyodbc://SA:CiPassword123!@localhost:1433/medcover_test?driver=ODBC+Driver+18+for+SQL+Server&Encrypt=no&TrustServerCertificate=yes"
FLASK_ENV: testing
SECRET_KEY: ci-test-secret-not-real
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install ODBC Driver for SQL Server
run: |
sudo find /etc/apt/sources.list.d/ -name "*.list" -exec grep -l "packages.microsoft.com" {} \; | xargs sudo rm -f
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/ubuntu/$(lsb_release -rs)/prod $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/mssql-release.list
sudo apt-get update -q
sudo ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 unixodbc-dev
- name: Create test database
run: |
pip install pyodbc
python - << 'EOF'
import pyodbc, time
conn_str = "DRIVER={ODBC Driver 18 for SQL Server};SERVER=localhost,1433;DATABASE=master;UID=SA;PWD=CiPassword123!;Encrypt=no;TrustServerCertificate=yes"
for _ in range(30):
try:
conn = pyodbc.connect(conn_str); conn.autocommit = True; break
except Exception:
time.sleep(2)
c = conn.cursor()
c.execute("IF NOT EXISTS (SELECT 1 FROM sys.databases WHERE name='medcover_test') CREATE DATABASE medcover_test COLLATE Czech_100_CI_AS_SC_UTF8")
c.execute("ALTER DATABASE medcover_test SET READ_COMMITTED_SNAPSHOT ON")
conn.close()
print("medcover_test ready")
EOF
- name: Install dependencies
run: pip install --require-hashes -r requirements-dev.txt
- name: Run tests with coverage
run: pytest --cov=app --cov-report=term-missing --cov-report=xml
- name: Upload coverage report
uses: actions/upload-artifact@v7
if: always()
with:
name: coverage-report
path: htmlcov/
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install pip-audit
run: pip install pip-audit
- name: Audit dependencies for known vulnerabilities
run: pip-audit -r requirements.txtThis project uses Semantic Versioning (MAJOR.MINOR.PATCH).
| Bump | When |
|---|---|
PATCH |
Bug fixes, small UI tweaks, no new features |
MINOR |
New features, backwards-compatible |
MAJOR |
Breaking changes or a major milestone (e.g. production launch) |
| File | Purpose |
|---|---|
VERSION |
Single source of truth — one line, e.g. 0.9.1 |
CHANGELOG.md |
English, Keep a Changelog format — for developers and GitHub |
app/templates/main/changelog.html |
Czech Změny ve verzích — rendered in the app at /changelog for all logged-in users |
Both are available in app.config and in Jinja2 templates as config.APP_VERSION / config.GIT_COMMIT:
| Key | Value | Purpose |
|---|---|---|
APP_VERSION |
0.9.0 (from VERSION file) |
Human-readable semantic version; shown in admin dashboard; stored in UserFeedback.app_version |
GIT_COMMIT |
abc1234 (from Docker build arg) |
Exact commit; used for static file cache-busting in app/__init__.py; shown in admin dashboard as a GitHub link |
GIT_COMMIT defaults to "dev" outside Docker (local dev, tests).
1. Create a feature branch (or use the last feature branch for the release)
2. Update VERSION
echo "0.9.1" > VERSION
3. Update CHANGELOG.md (English)
- Move items from [Unreleased] into a new [0.9.1] - YYYY-MM-DD section
- Keep the [Unreleased] section at the top (empty for now)
- Update the compare URLs at the bottom
4. Update app/templates/main/changelog.html (Czech)
- Add a new card for version 0.9.1 above the previous release card
- Keep the "Chystané změny" card at the top (empty)
5. Commit:
git add VERSION CHANGELOG.md app/templates/main/changelog.html
git commit -m "chore: release v0.9.1"
6. Open PR, merge to main
7. Tag the merge commit on main:
git checkout main && git pull
git tag v0.9.1
git push origin v0.9.1
Both the English CHANGELOG.md and the Czech changelog.html must be updated together on every release.
Different audiences, different content:
| File | Audience | What to include |
|---|---|---|
CHANGELOG.md |
Developers, GitHub | Everything: features, bug fixes, security changes, infra, refactors, migrations |
changelog.html |
End users (Czech) | Only changes that affect the user's workflow or are visible in the UI |
Czech changelog rules — include only if the user would notice or care:
- New features and screens they can interact with
- Changes to existing workflows (e.g. a form field added/removed, a step changed)
- Bug fixes that were visibly wrong to the user
- New or changed automatic emails they receive
Never include in the Czech changelog:
- Security hardening (CSRF, CSP, TLS, encryption algorithms) — implement silently
- Performance optimisations, caching, query improvements
- Refactors, code cleanup, constant extractions
- Database migrations, Alembic, infrastructure changes
- Developer tooling, CI, test additions
- Internal admin features invisible to regular members (audit log internals, outbox traceability)
- Version bumps, changelog metadata itself
The authoritative source of truth for email notification types is
NOTIFICATION_CATALOG in app/mail.py. The admin UI at /admin/notifications/ renders
this list and exposes per-type toggles stored in AppSettings.
Whenever you add, rename, remove, or change the recipients/trigger of any send_*
function in app/mail.py, you MUST:
- Update or add the corresponding entry in
NOTIFICATION_CATALOG(same file). - If the new notification should be togglable: add a
notify_<code>boolean column toAppSettings(model + Alembic migration, defaultTrue) and setsettings_fieldin the catalog entry accordingly. - Call
_is_notify_enabled("notify_<code>")at the top of the newsend_*function. - Pass
notification_type="<code>"to_enqueue(). - Update
CHANGELOG.mdandapp/templates/main/changelog.html.
Failure to update the catalog means the admin page will be out of sync with the actual behaviour of the application.
Toggle fields are stored in AppSettings (each
maps 1:1 to a notify_<code> column, per the rule above):
| Field | Controls |
|---|---|
notify_assignment |
send_assignment_confirmed, send_assignment_released |
notify_event_published |
send_event_published |
notify_assignments_opened |
send_assignments_opened |
notify_event_cancelled |
send_event_cancelled |
notify_event_archived |
send_event_archived |
notify_event_unarchived |
send_event_unarchived |
notify_event_changed |
send_event_changed |
notify_unfilled_reminder |
send_unfilled_spots_reminder (scheduler) |
notify_debriefing |
send_debriefing_invitation |
notify_external_opportunities |
send_external_opportunity (scheduler, every 5 minutes) |
External opportunity mail uses the existing outbox to batch newly available events per recipient.
Auth-related notifications (account_activated, invite/password reset (auth), admin_digest)
are always-on and cannot be toggled.
This project uses Flask-Migrate (Alembic wrapper for Flask-SQLAlchemy).
# After changing a model:
flask db migrate -m "add preferred_calendar_view to user"
# Before committing: review the generated migration in migrations/versions/
# Then apply:
flask db upgrade
# Rollback one step:
flask db downgradeMigrations run automatically on web container start via docker-entrypoint.sh (flask db upgrade). The scheduler container skips migrations and waits for web to be healthy.
The migration history is a single MSSQL baseline (*_mssql_baseline_schema.py,
down_revision = None). That squash was a one-time bootstrap. Once any
long-lived database (dev on zerver, staging, or prod) has been created from a
baseline, never re-squash or rewrite that baseline — always add forward
migrations for schema changes instead:
flask db migrate -m "add color to event" # new revision, down_revision = current headWhy: re-squashing rewrites the baseline's revision id and folds new columns
into it. An existing DB then (1) has an alembic_version pointing at a revision
that no longer exists (Can't locate revision identified by '<old>'), and
(2) is missing any columns the new baseline added — and because a single
baseline has no incremental step, flask db upgrade can never add them. The web
container fails its flask verify-schema health check and the deploy aborts.
This exact failure hit the zerver dev deploy of feat/mssql-support (the
re-squash that "included color" left dev DBs without event.color).
This is not a dev-only risk — production is more exposed. The mechanism is
environment-independent: it triggers for any database stamped at the old
baseline, including staging and prod. In fact, a re-squash that produces a
schema identical to the old one still breaks prod, because the squash mints
a new revision id and the dangling-alembic_version failure (1) fires
regardless. Prod is the worst place for it to land:
- The auto-run entrypoint (
flask db upgrade→flask verify-schema) aborts on deploy, so the new version never becomes healthy — a failed/blocked production release, possibly mid-rollout. - There is no disposable-volume escape hatch with real data: recovery is the
manual path only — stamp
alembic_versionto head, then hand-writeALTER TABLEs to reconcile every divergence with the baseline, verifying withflask verify-schema. Error-prone under release pressure.
CI / pre-commit guard. scripts/check_migrations.py enforces this
mechanically and runs both in the CI lint job and as a pre-commit hook
(triggered when any migrations/versions/*.py changes). It fails the build if
the baseline (root) revision id ever changes from the frozen
EXPECTED_BASELINE_REVISION, if there is more than one root, or if history has
more than one head. The guard is a forcing function, not an absolute ban —
bumping EXPECTED_BASELINE_REVISION is allowed, but only as the documented step
4 of the procedure below, so a re-baseline can't merge by accident.
A re-squash is allowed when migration history has grown unwieldy — but it must be done so that every durable DB survives it. The two rules that make it safe:
- It must be schema-neutral. The new baseline must reproduce the current
head schema exactly — do not fold any new or changed columns into it. Any
real schema change ships separately as a normal forward migration, either
before or after the squash, never baked into it. (The
event.colorincident happened because a schema change rode along inside the squash.) - Every durable DB must be re-stamped to the new baseline id during the
deploy window — because the entrypoint's
flask db upgradereads the old, now-deleted revision and aborts before anything else runs.
Steps:
- Snapshot the current schema of a representative up-to-date DB (dev is
fine):
flask verify-schemashould pass on it first, so you know it matches today's head. - Squash the history into a single new baseline whose
down_revision = None. Apply it to a fresh, empty DB and runflask verify-schemaagainst it — it must report the same objects as the snapshot in step 1. If anything differs, the squash is not schema-neutral; fix it before proceeding. - Record the new baseline revision id (call it
NEW_ID). - Bump the guard: set
EXPECTED_BASELINE_REVISION = "<NEW_ID>"inscripts/check_migrations.pyin the same commit as the squash. CI now passes; the diff is the audit trail. - Re-stamp every durable DB (dev on zerver, staging, prod) in the deploy
window, before the new app image starts its auto-
upgrade. Since the old code can't reachNEW_ID, do it with a plain SQL update againstalembic_version:(Equivalent toUPDATE alembic_version SET version_num = '<NEW_ID>';
flask db stamp <NEW_ID>once you can run the new code with auto-upgrade disabled. Take a DB backup first for prod.) - Deploy the new image normally.
flask db upgradenow findsNEW_IDas head and is a no-op;flask verify-schemapasses; the container goes healthy. - Verify each environment: container healthy +
flask verify-schemaOK.
If step 2 ever shows a schema difference, stop — that is exactly the failure this whole section exists to prevent.
Recovering a DB already stranded by an unsanctioned past re-squash: stamp
alembic_version to the current head, then ALTER TABLE in the missing columns
to match the baseline (confirm with flask verify-schema) — or, for dev only,
if the data is disposable, drop the DB volume so the baseline applies fresh.
The app sets a CSP header in all non-dev environments via @app.after_request in app/__init__.py. Both script-src and style-src use a per-request nonce; neither allows 'unsafe-inline'.
default-src 'self';
script-src 'self' https://cdn.jsdelivr.net 'nonce-<PER_REQUEST>';
style-src 'self' https://cdn.jsdelivr.net 'nonce-<PER_REQUEST>';
font-src 'self' https://cdn.jsdelivr.net data:;
img-src 'self' data:;
connect-src 'self' https://cdn.jsdelivr.net;
The nonce is 16 random hex bytes generated in before_request and exposed to templates as g.csp_nonce. It changes on every request.
Every inline <script> and <style> block in a browser-facing template must carry nonce="{{ g.csp_nonce }}". Without the nonce the block is blocked silently by the browser. The regression is guarded by tests/test_csp_headers.py, which walks every non-email template and fails on any inline style="…" attribute or unnonced <style> block.
Inline style="…" attributes are forbidden entirely in browser templates. Nonces do not apply to attribute-scoped styles (a browser-level limitation; style-src-attr has poor Safari support), so any styling that would previously have used an inline attribute must:
- move to a class in
app/static/css/main.css(add a utility if none fits), or - be applied at runtime from a
data-*attribute via a JS property setter (e.g.el.style.backgroundColor = el.dataset.tmBg). JS property setters are not covered bystyle-srcand remain CSP-safe. Seeapp/static/js/table-manager.js(applyTmBg) for the canonical pattern.
Third-party libraries that inject <style> at runtime:
- FullCalendar v6 consults
<meta name="csp-nonce">inbase.htmland applies the nonce automatically — no extra work required. - Flatpickr (and any similar lib that appends unnonced
<style>elements) is covered by a small shim inbase.htmlthat wrapsdocument.createElementand auto-nonces every<style>element it creates. The shim runs before any third-party JS loads, so late<style>insertions still validate.
Why not 'unsafe-inline': OWASP, MDN, and Google's web.dev CSP guidance all mark 'unsafe-inline' in style-src as an anti-pattern — it enables UI-redressing attacks (fake overlays, hidden form fields) and, in some browsers, limited data exfiltration via CSS attribute selectors. See PR that closed issue #234.
Why https:// is explicit: The scheme-free cdn.jsdelivr.net form is interpreted as the current page's scheme. Over HTTP it works, but the app is served over HTTPS in production, and an HTTP CDN resource would be blocked as mixed content. Always use https://cdn.jsdelivr.net in the CSP.
MSSQL Server is a significantly heavier container than the PostgreSQL it replaced (approx. 1.5 GB image). On WSL2, allow extra time for the container to start and become healthy. The MSSQL health check retries up to 10 times with 10 s intervals, giving 100 s total — this is sufficient in practice.
If the container fails to start, check available memory: MSSQL Express requires at least 1 GB RAM. The compose file caps it at 512 MB buffer pool via MSSQL_MEMORY_LIMIT_MB; the OS-level limit should be at least 1.5 GB.
Historical note: The dev stack originally used PostgreSQL 17. PostgreSQL was removed and replaced with MSSQL in PR #381.
scripts/seed_dev.py creates a realistic synthetic dataset. Safe to run multiple times — idempotent.
Dev accounts (password: devpassword, email format: dev.<role>@medcover.local):
| Role | Description | |
|---|---|---|
| Admin | dev.admin@medcover.local |
Full system access |
| Coordinator | dev.coordinator@medcover.local |
Create/manage events |
| Member | dev.member@medcover.local |
Join events, submit debriefings |
| Viewer | dev.viewer@medcover.local |
Read-only access |
| Debrief Manager | dev.debrief@medcover.local |
View/manage confidential debriefing records |
| Inactive | dev.inactive@medcover.local |
Registered but not yet activated |
Also seeded (synthetic, fictional):
- All Roles (synced to
ROLE_PERMISSIONSinrole.py), qualification hierarchy, 29 equipment types - 5 extra members (
@medcover.local, same password) so every qualification is held by someone, 4 master events, 3 templates - ~200 rule-based (condition-staffed) events spread around the run date (about a third in the past, the rest up to ~8 months ahead) with assignments, equipment and debriefings; dates are computed from today, so the data stays current whenever it is generated
- AppSettings (id=1, setup_complete=True,
dev_email_blockon)
Reference data is get-or-create on every run. The event dataset is generated only when the database has no events, so re-running never duplicates or shifts anything. To regenerate it, recreate the database.
After changing role permissions in role.py, re-run the seeder to sync:
docker compose exec web python scripts/seed_dev.pyOr on the test server:
ssh <user>@<host> "cd /path/to/MedCover && docker compose exec web python scripts/seed_dev.py"To start from a clean database (and drop any other copies of old data), recreate the stack with fresh volumes from the checkout the DEV containers are bound to:
docker compose down -v
docker compose up --build -d
docker compose exec web python scripts/seed_dev.pyGenerated monthly work-report files are stored in the Flask instance/ directory:
instance/
work_report/
<user-uuid>/
<year>-<MM>.xlsx (e.g. 2026-05.xlsx) — the user's own reports
<other-user-uuid>/
<year>-<MM>.xlsx — reports the user generated for someone else
- Reports are private to whoever generated them: a coordinator's report for a member lives under the coordinator's directory, so neither sees (or overwrites) the other's file. Generating the same month again overwrites only your own copy.
- Files are automatically deleted after 1 day by the
cleanup_work_reportscheduler task (runs hourly in theschedulercontainer). - Do not commit these files — the
instance/directory is gitignored. - The
holidaysPython package (Czech locale) is used to detect Czech public holidays for correct cell colouring. It is declared inrequirements.txt.
| Secret | Where stored |
|---|---|
.env local secrets |
Local only — in .gitignore, never committed |
.env.prod production secrets |
Production server only — never committed |
| GitHub Actions secrets | GitHub repo → Settings → Secrets and variables → Actions |
The .env.example file is committed and documents every required variable with a description but no real values.
All user-facing labels, filters, buttons, and page section titles must include a help icon whenever the concept or behaviour might not be immediately obvious to a new user.
Macro: help_icon(text, title="Nápověda") in app/templates/macros/help.html
{% from 'macros/help.html' import help_icon %}
{# On a form label #}
<label class="form-label">Název {{ help_icon("Celý název akce, jak se zobrazí v přehledech.") }}</label>
{# On a page title #}
<h2 class="mb-0">Akce {{ help_icon("Vysvětlení konceptu...", "Nadpis nápovědy") }}</h2>
{# On a section header inside a card #}
<span class="fw-semibold">Moje akce {{ help_icon("Akce, na které jste přihlášeni...") }}</span>The icon renders as a small ⓘ button that opens a Bootstrap popover on click/tap (works on
both desktop and mobile). Popovers are auto-initialized in app-init.js.
When to add a help icon:
- Every form field label that describes a non-trivial concept
- Page
<h2>titles for main sections (Akce, Nadřazené akce, Vybavení, …) - Dashboard section headings
- Filter controls that aren't self-explanatory
- Buttons with non-obvious side effects (e.g. status transitions)
Text guidelines:
- Write in Czech (all UI text is Czech)
- Be concise but complete — explain why, not just what
- For multi-line content use
\n•bullet points within the string - Keep under ~300 characters so the popover stays readable on mobile
Do not add a help icon to:
- Self-explanatory fields like "E-mail" or "Datum"
- Action buttons where the label is already fully descriptive ("Uložit", "Zrušit")
Bootstrap is loaded via CDN — no npm or build pipeline required.
| Asset | Version | CDN |
|---|---|---|
bootstrap.min.css |
5.3.8 | jsDelivr |
bootstrap.bundle.min.js |
5.3.8 | jsDelivr (includes Popper) |
SRI hashes in app/templates/base.html were generated directly from jsDelivr at the time of setup:
CSS sha384: sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB
JS sha384: FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI
When upgrading Bootstrap, regenerate the hashes:
curl -s "https://cdn.jsdelivr.net/npm/bootstrap@VERSION/dist/css/bootstrap.min.css" \
| openssl dgst -sha384 -binary | openssl base64 -A
curl -s "https://cdn.jsdelivr.net/npm/bootstrap@VERSION/dist/js/bootstrap.bundle.min.js" \
| openssl dgst -sha384 -binary | openssl base64 -AThen update the integrity attributes in base.html.
Converts a UTC datetime to Europe/Prague local time.
{{ event.start_datetime | localdt }} {# default: "23.04.2025 14:00" #}
{{ event.start_datetime | localdt("%d.%m.%Y") }} {# date only #}Czech locale uses a comma as the decimal separator, not a dot. All decimal numbers displayed in templates must use this filter.
{{ value | cznum }} {# 1 decimal place → "3,5" #}
{{ value | cznum(2) }} {# 2 decimal places → "3,50" #}- Registered in
app/__init__.pyalongsidelocaldt. - Never use
"%.1f"|format(x)— that produces an English dot separator. - Handles
Nonegracefully (returns—).