Metadata-Version: 2.5
Name: xmas-app
Version: 1.0.0-dev.2
Summary: The XLeitstelle model-driven application schema app.
Project-URL: Homepage, https://gitlab.opencode.de/xleitstelle/xmas-app
Project-URL: Issues, https://gitlab.opencode.de/xleitstelle/xmas-app/-/issues
Author-email: Tobias Kraft <tobias.kraft@gv.hamburg.de>
License-Expression: EUPL-1.2+
License-File: LICENSE
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Pydantic :: 2
Classifier: License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.14
Requires-Dist: aiohttp>=3.13.5
Requires-Dist: httpx2<3,>=2.4.0
Requires-Dist: nicegui<4.0,>=3.12
Requires-Dist: pydantic-extra-types[semver]
Requires-Dist: pydantic-settings<3.0,>=2.0
Requires-Dist: structlog
Requires-Dist: tenacity<10,>=9.1.4
Requires-Dist: xmas-core==2.0.0rc12
Description-Content-Type: text/markdown

# XMAS-App

[![pipeline status](https://gitlab.opencode.de/xleitstelle/xmas-app/badges/main/pipeline.svg)](https://gitlab.opencode.de/xleitstelle/xmas-app/-/commits/main)
[![Latest Release](https://gitlab.opencode.de/xleitstelle/xmas-app/-/badges/release.svg)](https://gitlab.opencode.de/xleitstelle/xmas-app/-/releases)

The **X**Leistelle **m**odel-driven **a**pplication **s**chema app is a Python web application to edit and create data according to geo-spatial standards of the [XLeistelle](https://xleitstelle.de), e.g. XPlanung, XTrasse. It is based on [NiceGUI](https://nicegui.io/) for a graphical user interface
and [xmas-core](https://gitlab.opencode.de/xleitstelle/xmas-core) for the data model and respective functionality.

While it could be advanced to a standalone application, its current focus is [integration in QGIS](https://gitlab.opencode.de/xleitstelle/xmas-plugin) to provide attribute forms etc.

## Features

* Render attribute forms for features.
* Create and edit features in combination with the corresponding QGIS Plugin.
* Import GML documents into a database.
* Export GML, JSON-FG or GPKG files from a database.
* Delete plans from a database.
* Display and edit relations of plan objects in a tree view.

## Installation

### Container Image
Container images are available in the [registry](https://gitlab.opencode.de/xleitstelle/xmas-app/container_registry). They are built and signed by the pipeline and tagged per release version and per integration branch (`dev-amd64`); there is no `latest` tag. See [Running via Docker Compose](#running-via-docker-compose) for configuration.

### Python >= v3.11

[GDAL](https://gdal.org) and its Python bindings are required, so you need to make sure the GDAL system library and Python package versions match.
QGIS installations come with GDAL and a Python environment that can readily be used to install the app.

Install with `pip`, e.g. via OSGeo4W Shell:
```shell
pip install xmas-app
```

### Pixi
This project uses [Pixi](https://pixi.sh) for package management. To install this repo with a self-contained environment, run

```shell
git clone https://gitlab.opencode.de/xleitstelle/xmas-app.git
cd xmas-app
pixi install
```

## Running

### Preconditions
A Postgres DB with PostGIS extension and adequate permissions for the used role. Missing tables are generated on initialization.

A database created by `xplan-tools` (xmas-core < 2.0) cannot be upgraded: startup reports `SchemaOutdatedError`. Start with a fresh database instead, with the PostGIS extension enabled and `PGDATABASE` pointing at it, and re-import the plans through the plan manager. The app's own tables live in the same schema as the plans, so nothing of them carries over: organisations are created again on first login, plans are attributed to whoever imports them, and attachments have to be uploaded again.

Plans written straight into the database, for example with `xmas convert`, get no authorization row: the app does not list them, and opening one gives a 404.

### Environment Configuration

Copy the example configuration and adjust values as needed:

```shell
cp .env.example .env
```

`.env.example` documents all available variables with inline comments, including database connection, app settings, and authentication. See that file as the canonical reference.

**Important:**

- Do not use the example values in production
- All credentials and tokens must be set explicitly
- The application will fail to start if required variables are missing

### Running via Docker Compose

```shell
docker compose -f compose.yaml up -d
```

This spins up a complete XMAS-App stack including a PostGIS backend and Keycloak. Configure the stack via `.env` — see `.env.example` for the container-mode database settings (`PGHOST=postgres`, `PGPORT=5432`).

#### Optional: testing `ROOT_PATH` sub-path deployments

To verify running the app under a URL sub-path (so several instances can share one domain), start the `reverse-proxy` service via its compose profile:

```shell
ROOT_PATH=/test docker compose -f compose.yaml --profile proxy up -d
```

This adds an nginx container (see `nginx/default.conf.template`) that strips `ROOT_PATH` before forwarding to `xmas-app`, mirroring a real front-facing proxy. Browse `http://localhost:${PROXY_PORT:-8000}/test/` — not `xmas-app`'s own port directly, since `ROOT_PATH` only affects URL generation, not routing; see [`xmas_app/core/settings.py`](xmas_app/core/settings.py) for the setting itself.

### Running Locally (host machine)

First activate the Pixi shell:

```shell
pixi shell
```

Then run:

```shell
xmas-app
```

Use the local-mode database settings in `.env` (`PGHOST=127.0.0.1`, `PGPORT=15432`).

### Optional: batch import test data

Test data can be downloaded from https://gitlab.opencode.de/xleitstelle/xplanung/testdaten, e.g. a ZIP archive with [BP_Plans in v 6.0](https://gitlab.opencode.de/xleitstelle/xplanung/testdaten/-/archive/main/testdaten-main.zip?path=valide/6_0/bp).

To import all `.gml` files at once, extract the archive and use the commands below. They write the plans straight into the database, without the authorization rows the app needs, so the app does not show them; for plans to work with in the app, import them through the plan manager.

**Linux / macOS / WSL:**
```bash
for f in <path>/bp/*.gml; do
  xmas convert "$f" postgresql://postgres:postgres@localhost:55432/postgres
done
```

**Windows:**
```cmd
for %f in (*.gml) do xmas convert "%f" postgresql://postgres:postgres@127.0.0.1:55432/postgres
```

## Authentication

The application supports two authentication modes, controlled by the `AUTH_MODE` environment variable.

### Static token

Maps a bearer token to an identity record the operator writes. It is used for local development, for the `xmas-plugin` CI pipeline (which cannot run the interactive OAuth2/PKCE flow headlessly), and for self-hosted installations that do not run an identity provider. See [Static tokens in production](#static-tokens-in-production) before using it for the last of these.

Set `AUTH_MODE=static` in `.env`.

Requests must include:
```
Authorization: Bearer <token>
```

Each token maps to a full identity record via the `AUTH_TOKENS` environment variable (JSON). The record stands in for the claims a Keycloak access token would otherwise carry:

```json
AUTH_TOKENS={"<generated-token>": {"user_name": "test-user", "user_id": "41a74c7b-dea8-42c2-bd15-9b88012b10d7", "org_id": "25459bec-0f6e-4f3a-a3f4-3b3e6a2a5d11", "email": "test@example.org", "role": "editor"}}
```

| Field | Required | Meaning |
|---|---|---|
| `user_name` | no | Human-readable label for whoever edits the env var (not consumed by the app) |
| `user_id` | yes | Subject identifier; stands in for the Keycloak `sub`. Any string, see below. Persisted as `created_by` |
| `org_id` | no* | UUID of the user's org. A `null` or absent value marks a grant-scoped external invitee |
| `email` | no | Display / audit only. Not used as an identity key |
| `role` | no | One of `admin`, `editor`, `viewer`. Absent or `null` is treated as no role, which denies writes |

> *`org_id` is optional syntactically, but `null` means the user is treated as
> an external invitee (grant-scoped only) and will be denied org-scoped plan
> access. For a normal dev user, set a real org UUID. When `AUTH_MODE=static`,
> `AUTH_TOKENS` is required, startup fails loudly if it is missing or malformed.

`user_id` is not required to be a UUID. A Keycloak `sub` happens to be one, but OIDC
defines the subject as an opaque string, so the columns storing it are `text` and any
stable, unique-per-deployment string works here (ADR-0005, Appendix A). Records are
parsed into `AuthenticatedUser`, the same type the Keycloak path produces.

See `.env.example` for the full format.

#### Static tokens in production

Static mode is supported for self-hosted installations. It is not suitable for an installation reachable from the public internet, because it lacks what that needs:

- No expiry. A token is valid until it is removed from the configuration.
- No revocation short of editing the configuration and restarting.
- No signature. The token is the secret itself, so anyone who sees it in transit, in a log, in a shell history or in a backup of `.env` holds the credential.
- No rate limiting. Guessing tokens is slowed by nothing but the network.
- Identity and role are whatever the operator wrote into `AUTH_TOKENS`.

How an installation is exposed is the operating organisation's decision, and so is acting on the recommendations below (ADR-0007). An installation that has to be reachable from the internet should use Keycloak.

- **Length.** With `APP_MODE=prod`, the default, startup fails if any token is shorter than 32 characters. Generate one with `python -c "import secrets; print(secrets.token_urlsafe(32))"`.
- **One token per person.** Recommended, not enforced. With one shared token, every action is attributed to that token's identity, so the record shows the organisation rather than the person.
- **Storage.** Treat `AUTH_TOKENS` like a password: restrict who can read `.env`, and keep tokens out of version control, tickets and chat.
- **One place.** Set `AUTH_TOKENS` either in the environment or in `.env`, not both. The two are merged, so a token removed from one stays valid while it is still in the other.
- **Rotation.** Edit `AUTH_TOKENS` and restart. That is the whole mechanism; there is no expiry and no revocation list.

At startup in prod, the application logs that static mode is active and how many tokens are configured, never the tokens themselves.

### Keycloak JWT (recommended for production)

Validates RS256-signed access tokens issued by a Keycloak realm via the JWKS endpoint. This is the mode for any installation reachable from the public internet.

Set `AUTH_MODE=keycloak` in `.env`. See `.env.example` for the required Keycloak variables (`KEYCLOAK_ISSUER`, `KEYCLOAK_AUDIENCE`, `KEYCLOAK_JWKS_URL`).

> **Note:** `KEYCLOAK_ISSUER` must match the `iss` claim in the token exactly — including scheme, host, and port. `KEYCLOAK_JWKS_URL` is the address of the Keycloak service as seen from inside the Docker network, which may differ from the issuer URL seen by clients.

The local Keycloak instance is started automatically via `compose.yaml`. A development realm (`realm-xmas-dev.json`) is imported on first start, including a test user, the `xmas-plugin` client for the OAuth2/PKCE flow, and the required audience mapper.

For Keycloak setup, Identity Provider federation (e.g. Microsoft Entra ID / SSO), and onboarding documentation, see [`keycloak/README.md`](keycloak/README.md).

## Running Tests Locally

Integration tests use `testcontainers` to start a temporary PostGIS database.
Make sure Docker is running before executing the tests.

The test database is seeded from a git submodule `./testdaten/` (https://gitlab.opencode.de/xleitstelle/xplanung/testdaten).

Initialize the submodule first:

```shell
git submodule update --init --recursive
```

Then run:
```shell
pixi run pytest tests
```

## Agentic Coding

See `.devcontainer/README.md` for Claude Code setup.

## Troubleshooting

### PyJWKClientConnectionError
The plugin can't reach the app, and the app's container log shows `PyJWKClientConnectionError`.
Verify that the env variables are being passed correctly and that `KEYCLOAK_JWKS_URL` has the right value.
Make sure proxy settings aren't blocking Keycloak: add `keycloak` to the `NO_PROXY` list if not yet set in the compose file.

## License

The code in this repository is licensed under the [EUPL-1.2-or-later](https://joinup.ec.europa.eu/collection/eupl)

&copy; [XLeitstelle](https://xleitstelle.de), 2025
