xmas-app 1.0.0.dev1__tar.gz → 1.0.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. xmas_app-1.0.0.dev3/.gitignore +17 -0
  2. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/PKG-INFO +78 -26
  3. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/README.md +65 -11
  4. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/pyproject.toml +37 -27
  5. xmas_app-1.0.0.dev3/version.py +88 -0
  6. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/association.py +16 -13
  7. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/documents.py +57 -23
  8. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/feature_context_menu.py +9 -2
  9. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/art_attribute.py +31 -10
  10. xmas_app-1.0.0.dev3/xmas_app/components/form/associations.py +58 -0
  11. xmas_app-1.0.0.dev3/xmas_app/components/form/bindable.py +133 -0
  12. xmas_app-1.0.0.dev3/xmas_app/components/form/context.py +52 -0
  13. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/__init__.py +1 -3
  14. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/association.py +10 -8
  15. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/basic.py +5 -4
  16. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/datatype.py +21 -18
  17. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/enumeration.py +1 -1
  18. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/measure.py +3 -3
  19. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/temporal.py +2 -1
  20. xmas_app-1.0.0.dev3/xmas_app/components/form/filter_bar.py +121 -0
  21. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/helpers.py +34 -3
  22. xmas_app-1.0.0.dev3/xmas_app/components/form/model_form.py +850 -0
  23. xmas_app-1.0.0.dev3/xmas_app/components/form/multi_edit.py +200 -0
  24. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/tabs.py +7 -5
  25. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/validation.py +17 -4
  26. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/planmigration.py +89 -70
  27. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/plantree.py +320 -70
  28. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/planvalidation.py +13 -7
  29. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/core/auth.py +54 -58
  30. xmas_app-1.0.0.dev3/xmas_app/core/db.py +614 -0
  31. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/core/events.py +1 -0
  32. xmas_app-1.0.0.dev3/xmas_app/core/lifecycle.py +121 -0
  33. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/core/logging.py +9 -1
  34. xmas_app-1.0.0.dev3/xmas_app/core/middleware.py +93 -0
  35. xmas_app-1.0.0.dev3/xmas_app/core/settings.py +142 -0
  36. xmas_app-1.0.0.dev3/xmas_app/core/webchannel.py +190 -0
  37. xmas_app-1.0.0.dev3/xmas_app/deps/__init__.py +89 -0
  38. xmas_app-1.0.0.dev3/xmas_app/deps/plan.py +154 -0
  39. xmas_app-1.0.0.dev3/xmas_app/main.py +470 -0
  40. xmas_app-1.0.0.dev3/xmas_app/models/crud.py +115 -0
  41. xmas_app-1.0.0.dev3/xmas_app/models/editor_context.py +122 -0
  42. xmas_app-1.0.0.dev3/xmas_app/models/errors.py +20 -0
  43. xmas_app-1.0.0.dev3/xmas_app/models/identity.py +39 -0
  44. xmas_app-1.0.0.dev3/xmas_app/models/labels.py +7 -0
  45. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/mappings.py +1 -1
  46. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/migrations/env.py +7 -4
  47. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/migrations/versions/3715fd158e35_add_notify_trigger.py +1 -1
  48. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/migrations/versions/53a0d4789886_docs_table.py +1 -1
  49. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/migrations/versions/5bc8757a75a8_remove_unique_index_from_doc_filename.py +1 -1
  50. xmas_app-1.0.0.dev3/xmas_app/models/migrations/versions/813752acba84_add_authz_schema.py +176 -0
  51. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/migrations/versions/aaf3c696f708_add_mappings.py +1 -1
  52. xmas_app-1.0.0.dev3/xmas_app/models/orm.py +274 -0
  53. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/split.py +0 -11
  54. xmas_app-1.0.0.dev3/xmas_app/models/stylesheets.py +31 -0
  55. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/validation_view.py +5 -5
  56. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/pages/base.py +8 -13
  57. xmas_app-1.0.0.dev3/xmas_app/pages/feature.py +425 -0
  58. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/pages/planmanager.py +379 -179
  59. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/pages/plantools.py +16 -11
  60. xmas_app-1.0.0.dev3/xmas_app/repositories/__init__.py +1 -0
  61. xmas_app-1.0.0.dev3/xmas_app/repositories/authz.py +99 -0
  62. xmas_app-1.0.0.dev3/xmas_app/services/authz_service.py +69 -0
  63. xmas_app-1.0.0.dev3/xmas_app/services/broker.py +137 -0
  64. xmas_app-1.0.0.dev3/xmas_app/services/crud.py +331 -0
  65. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/services/document.py +3 -3
  66. xmas_app-1.0.0.dev3/xmas_app/services/labels.py +27 -0
  67. xmas_app-1.0.0.dev3/xmas_app/services/permissions.py +32 -0
  68. xmas_app-1.0.0.dev3/xmas_app/services/plan.py +35 -0
  69. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/services/split.py +85 -32
  70. xmas_app-1.0.0.dev3/xmas_app/services/stylesheets.py +59 -0
  71. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/util/__init__.py +8 -1
  72. xmas_app-1.0.0.dev3/xmas_app/util/get_models.py +64 -0
  73. xmas_app-1.0.0.dev3/xmas_app/util/gml.py +36 -0
  74. xmas_app-1.0.0.dev3/xmas_app/util/helpers.py +23 -0
  75. xmas_app-1.0.0.dev1/xmas_app/components/form/associations.py +0 -103
  76. xmas_app-1.0.0.dev1/xmas_app/components/form/bindable.py +0 -57
  77. xmas_app-1.0.0.dev1/xmas_app/components/form/context.py +0 -33
  78. xmas_app-1.0.0.dev1/xmas_app/components/form/filter_bar.py +0 -73
  79. xmas_app-1.0.0.dev1/xmas_app/components/form/model_form.py +0 -322
  80. xmas_app-1.0.0.dev1/xmas_app/core/db.py +0 -416
  81. xmas_app-1.0.0.dev1/xmas_app/core/lifecycle.py +0 -83
  82. xmas_app-1.0.0.dev1/xmas_app/core/settings.py +0 -108
  83. xmas_app-1.0.0.dev1/xmas_app/core/webchannel.py +0 -103
  84. xmas_app-1.0.0.dev1/xmas_app/deps/__init__.py +0 -24
  85. xmas_app-1.0.0.dev1/xmas_app/deps/version_guard.py +0 -32
  86. xmas_app-1.0.0.dev1/xmas_app/main.py +0 -366
  87. xmas_app-1.0.0.dev1/xmas_app/models/crud.py +0 -57
  88. xmas_app-1.0.0.dev1/xmas_app/models/orm.py +0 -136
  89. xmas_app-1.0.0.dev1/xmas_app/pages/feature.py +0 -147
  90. xmas_app-1.0.0.dev1/xmas_app/services/broker.py +0 -17
  91. xmas_app-1.0.0.dev1/xmas_app/services/crud.py +0 -213
  92. xmas_app-1.0.0.dev1/xmas_app/util/get_models.py +0 -53
  93. xmas_app-1.0.0.dev1/xmas_app/util/gml.py +0 -38
  94. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/LICENSE +0 -0
  95. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/__init__.py +0 -0
  96. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/__init__.py +0 -0
  97. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/form/fields/codelist.py +0 -0
  98. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/label.py +0 -0
  99. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/components/statuslog.py +0 -0
  100. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/core/__init__.py +0 -0
  101. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/__init__.py +0 -0
  102. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/migrations/script.py.mako +0 -0
  103. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/models/validation_report.py +0 -0
  104. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/pages/__init__.py +0 -0
  105. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/resources/mappings.toml +0 -0
  106. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/services/__init__.py +0 -0
  107. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/services/validation.py +0 -0
  108. {xmas_app-1.0.0.dev1 → xmas_app-1.0.0.dev3}/xmas_app/util/client.py +0 -0
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ build/
4
+ dist/
5
+ *.spec
6
+ .env
7
+ .coverage
8
+ *.log
9
+ *.ipynb*
10
+ notebooks/
11
+ *.Identifier
12
+ .vscode/
13
+ *.qgs~
14
+ .nicegui
15
+ .pixi/tests/data/
16
+ .devcontainer/certs/corporate-root.crt
17
+ *.cer
@@ -1,27 +1,26 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: xmas-app
3
- Version: 1.0.0.dev1
3
+ Version: 1.0.0-dev.3
4
4
  Summary: The XLeitstelle model-driven application schema app.
5
- License: EUPL-1.2-or-later
5
+ Project-URL: Homepage, https://gitlab.opencode.de/xleitstelle/xmas-app
6
+ Project-URL: Issues, https://gitlab.opencode.de/xleitstelle/xmas-app/-/issues
7
+ Author-email: Tobias Kraft <tobias.kraft@gv.hamburg.de>
8
+ License-Expression: EUPL-1.2+
6
9
  License-File: LICENSE
7
- Author: Tobias Kraft
8
- Author-email: tobias.kraft@gv.hamburg.de
9
- Requires-Python: >=3.12,<3.14
10
10
  Classifier: Development Status :: 4 - Beta
11
11
  Classifier: Framework :: Pydantic :: 2
12
12
  Classifier: License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
- Requires-Dist: aiohttp (>=3.13.5)
16
- Requires-Dist: httpx2 (>=2.4.0,<3)
17
- Requires-Dist: nicegui (>=3.12,<4.0)
15
+ Requires-Python: >=3.14
16
+ Requires-Dist: aiohttp>=3.13.5
17
+ Requires-Dist: httpx2<3,>=2.4.0
18
+ Requires-Dist: nicegui<4.0,>=3.12
18
19
  Requires-Dist: pydantic-extra-types[semver]
19
- Requires-Dist: pydantic-settings (>=2.0,<3.0)
20
+ Requires-Dist: pydantic-settings<3.0,>=2.0
20
21
  Requires-Dist: structlog
21
- Requires-Dist: tenacity (>=9.1.4,<10)
22
- Requires-Dist: xplan-tools (==1.24.1)
23
- Project-URL: Homepage, https://gitlab.opencode.de/xleitstelle/xmas-app
24
- Project-URL: Issues, https://gitlab.opencode.de/xleitstelle/xmas-app/-/issues
22
+ Requires-Dist: tenacity<10,>=9.1.4
23
+ Requires-Dist: xmas-core==2.0.0rc12
25
24
  Description-Content-Type: text/markdown
26
25
 
27
26
  # XMAS-App
@@ -30,7 +29,7 @@ Description-Content-Type: text/markdown
30
29
  [![Latest Release](https://gitlab.opencode.de/xleitstelle/xmas-app/-/badges/release.svg)](https://gitlab.opencode.de/xleitstelle/xmas-app/-/releases)
31
30
 
32
31
  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
33
- and [XPlan-Tools](https://gitlab.opencode.de/xleitstelle/xplanung/xplan-tools) for the data model and respective functionality.
32
+ and [xmas-core](https://gitlab.opencode.de/xleitstelle/xmas-core) for the data model and respective functionality.
34
33
 
35
34
  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.
36
35
 
@@ -70,7 +69,11 @@ pixi install
70
69
  ## Running
71
70
 
72
71
  ### Preconditions
73
- A Postgres DB with PostGIS extension and adequate permissions for the used role. If required tables were not previously created with `xplan-tools`, they will be generated on initialization.
72
+ A Postgres DB with PostGIS extension and adequate permissions for the used role. Missing tables are generated on initialization.
73
+
74
+ 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.
75
+
76
+ 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.
74
77
 
75
78
  ### Environment Configuration
76
79
 
@@ -126,18 +129,18 @@ Use the local-mode database settings in `.env` (`PGHOST=127.0.0.1`, `PGPORT=1543
126
129
 
127
130
  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).
128
131
 
129
- To import all `.gml` files at once, extract the archive and use:
132
+ 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.
130
133
 
131
134
  **Linux / macOS / WSL:**
132
135
  ```bash
133
136
  for f in <path>/bp/*.gml; do
134
- xplan-tools convert "$f" postgresql://postgres:postgres@localhost:55432/postgres
137
+ xmas convert "$f" postgresql://postgres:postgres@localhost:55432/postgres
135
138
  done
136
139
  ```
137
140
 
138
141
  **Windows:**
139
142
  ```cmd
140
- for %f in (*.gml) do xplan-tools convert "%f" postgresql://postgres:postgres@127.0.0.1:55432/postgres
143
+ for %f in (*.gml) do xmas convert "%f" postgresql://postgres:postgres@127.0.0.1:55432/postgres
141
144
  ```
142
145
 
143
146
  ## Authentication
@@ -146,24 +149,70 @@ The application supports two authentication modes, controlled by the `AUTH_MODE`
146
149
 
147
150
  ### Static token
148
151
 
149
- Intended for development and testing only. Not suitable for production.
152
+ 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.
150
153
 
151
- Set `AUTH_MODE=static` in `.env`. See `.env.example` for the token format.
154
+ Set `AUTH_MODE=static` in `.env`.
152
155
 
153
156
  Requests must include:
154
157
  ```
155
158
  Authorization: Bearer <token>
156
159
  ```
157
160
 
158
- ### Keycloak JWT (recommended for staging and production)
161
+ 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:
162
+
163
+ ```json
164
+ 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"}}
165
+ ```
166
+
167
+ | Field | Required | Meaning |
168
+ |---|---|---|
169
+ | `user_name` | no | Human-readable label for whoever edits the env var (not consumed by the app) |
170
+ | `user_id` | yes | Subject identifier; stands in for the Keycloak `sub`. Any string, see below. Persisted as `created_by` |
171
+ | `org_id` | no* | UUID of the user's org. A `null` or absent value marks a grant-scoped external invitee |
172
+ | `email` | no | Display / audit only. Not used as an identity key |
173
+ | `role` | no | One of `admin`, `editor`, `viewer`. Absent or `null` is treated as no role, which denies writes |
174
+
175
+ > *`org_id` is optional syntactically, but `null` means the user is treated as
176
+ > an external invitee (grant-scoped only) and will be denied org-scoped plan
177
+ > access. For a normal dev user, set a real org UUID. When `AUTH_MODE=static`,
178
+ > `AUTH_TOKENS` is required, startup fails loudly if it is missing or malformed.
179
+
180
+ `user_id` is not required to be a UUID. A Keycloak `sub` happens to be one, but OIDC
181
+ defines the subject as an opaque string, so the columns storing it are `text` and any
182
+ stable, unique-per-deployment string works here (ADR-0005, Appendix A). Records are
183
+ parsed into `AuthenticatedUser`, the same type the Keycloak path produces.
159
184
 
160
- Validates RS256-signed access tokens issued by a Keycloak realm via the JWKS endpoint.
185
+ See `.env.example` for the full format.
161
186
 
162
- Set `AUTH_MODE=keycloak` in `.env`. See `.env.example` for the required Keycloak variables.
187
+ #### Static tokens in production
188
+
189
+ 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:
190
+
191
+ - No expiry. A token is valid until it is removed from the configuration.
192
+ - No revocation short of editing the configuration and restarting.
193
+ - 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.
194
+ - No rate limiting. Guessing tokens is slowed by nothing but the network.
195
+ - Identity and role are whatever the operator wrote into `AUTH_TOKENS`.
196
+
197
+ 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.
198
+
199
+ - **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))"`.
200
+ - **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.
201
+ - **Storage.** Treat `AUTH_TOKENS` like a password: restrict who can read `.env`, and keep tokens out of version control, tickets and chat.
202
+ - **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.
203
+ - **Rotation.** Edit `AUTH_TOKENS` and restart. That is the whole mechanism; there is no expiry and no revocation list.
204
+
205
+ At startup in prod, the application logs that static mode is active and how many tokens are configured, never the tokens themselves.
206
+
207
+ ### Keycloak JWT (recommended for production)
208
+
209
+ 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.
210
+
211
+ Set `AUTH_MODE=keycloak` in `.env`. See `.env.example` for the required Keycloak variables (`KEYCLOAK_ISSUER`, `KEYCLOAK_AUDIENCE`, `KEYCLOAK_JWKS_URL`).
163
212
 
164
213
  > **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.
165
214
 
166
- 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 and the required audience mapper.
215
+ 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.
167
216
 
168
217
  For Keycloak setup, Identity Provider federation (e.g. Microsoft Entra ID / SSO), and onboarding documentation, see [`keycloak/README.md`](keycloak/README.md).
169
218
 
@@ -185,6 +234,10 @@ Then run:
185
234
  pixi run pytest tests
186
235
  ```
187
236
 
237
+ ## Agentic Coding
238
+
239
+ See `.devcontainer/README.md` for Claude Code setup.
240
+
188
241
  ## Troubleshooting
189
242
 
190
243
  ### PyJWKClientConnectionError
@@ -197,4 +250,3 @@ Make sure proxy settings aren't blocking Keycloak: add `keycloak` to the `NO_PRO
197
250
  The code in this repository is licensed under the [EUPL-1.2-or-later](https://joinup.ec.europa.eu/collection/eupl)
198
251
 
199
252
  &copy; [XLeitstelle](https://xleitstelle.de), 2025
200
-
@@ -4,7 +4,7 @@
4
4
  [![Latest Release](https://gitlab.opencode.de/xleitstelle/xmas-app/-/badges/release.svg)](https://gitlab.opencode.de/xleitstelle/xmas-app/-/releases)
5
5
 
6
6
  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
7
- and [XPlan-Tools](https://gitlab.opencode.de/xleitstelle/xplanung/xplan-tools) for the data model and respective functionality.
7
+ and [xmas-core](https://gitlab.opencode.de/xleitstelle/xmas-core) for the data model and respective functionality.
8
8
 
9
9
  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.
10
10
 
@@ -44,7 +44,11 @@ pixi install
44
44
  ## Running
45
45
 
46
46
  ### Preconditions
47
- A Postgres DB with PostGIS extension and adequate permissions for the used role. If required tables were not previously created with `xplan-tools`, they will be generated on initialization.
47
+ A Postgres DB with PostGIS extension and adequate permissions for the used role. Missing tables are generated on initialization.
48
+
49
+ 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.
50
+
51
+ 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.
48
52
 
49
53
  ### Environment Configuration
50
54
 
@@ -100,18 +104,18 @@ Use the local-mode database settings in `.env` (`PGHOST=127.0.0.1`, `PGPORT=1543
100
104
 
101
105
  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).
102
106
 
103
- To import all `.gml` files at once, extract the archive and use:
107
+ 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.
104
108
 
105
109
  **Linux / macOS / WSL:**
106
110
  ```bash
107
111
  for f in <path>/bp/*.gml; do
108
- xplan-tools convert "$f" postgresql://postgres:postgres@localhost:55432/postgres
112
+ xmas convert "$f" postgresql://postgres:postgres@localhost:55432/postgres
109
113
  done
110
114
  ```
111
115
 
112
116
  **Windows:**
113
117
  ```cmd
114
- for %f in (*.gml) do xplan-tools convert "%f" postgresql://postgres:postgres@127.0.0.1:55432/postgres
118
+ for %f in (*.gml) do xmas convert "%f" postgresql://postgres:postgres@127.0.0.1:55432/postgres
115
119
  ```
116
120
 
117
121
  ## Authentication
@@ -120,24 +124,70 @@ The application supports two authentication modes, controlled by the `AUTH_MODE`
120
124
 
121
125
  ### Static token
122
126
 
123
- Intended for development and testing only. Not suitable for production.
127
+ 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.
124
128
 
125
- Set `AUTH_MODE=static` in `.env`. See `.env.example` for the token format.
129
+ Set `AUTH_MODE=static` in `.env`.
126
130
 
127
131
  Requests must include:
128
132
  ```
129
133
  Authorization: Bearer <token>
130
134
  ```
131
135
 
132
- ### Keycloak JWT (recommended for staging and production)
136
+ 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:
137
+
138
+ ```json
139
+ 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"}}
140
+ ```
141
+
142
+ | Field | Required | Meaning |
143
+ |---|---|---|
144
+ | `user_name` | no | Human-readable label for whoever edits the env var (not consumed by the app) |
145
+ | `user_id` | yes | Subject identifier; stands in for the Keycloak `sub`. Any string, see below. Persisted as `created_by` |
146
+ | `org_id` | no* | UUID of the user's org. A `null` or absent value marks a grant-scoped external invitee |
147
+ | `email` | no | Display / audit only. Not used as an identity key |
148
+ | `role` | no | One of `admin`, `editor`, `viewer`. Absent or `null` is treated as no role, which denies writes |
149
+
150
+ > *`org_id` is optional syntactically, but `null` means the user is treated as
151
+ > an external invitee (grant-scoped only) and will be denied org-scoped plan
152
+ > access. For a normal dev user, set a real org UUID. When `AUTH_MODE=static`,
153
+ > `AUTH_TOKENS` is required, startup fails loudly if it is missing or malformed.
154
+
155
+ `user_id` is not required to be a UUID. A Keycloak `sub` happens to be one, but OIDC
156
+ defines the subject as an opaque string, so the columns storing it are `text` and any
157
+ stable, unique-per-deployment string works here (ADR-0005, Appendix A). Records are
158
+ parsed into `AuthenticatedUser`, the same type the Keycloak path produces.
159
+
160
+ See `.env.example` for the full format.
161
+
162
+ #### Static tokens in production
133
163
 
134
- Validates RS256-signed access tokens issued by a Keycloak realm via the JWKS endpoint.
164
+ 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:
135
165
 
136
- Set `AUTH_MODE=keycloak` in `.env`. See `.env.example` for the required Keycloak variables.
166
+ - No expiry. A token is valid until it is removed from the configuration.
167
+ - No revocation short of editing the configuration and restarting.
168
+ - 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.
169
+ - No rate limiting. Guessing tokens is slowed by nothing but the network.
170
+ - Identity and role are whatever the operator wrote into `AUTH_TOKENS`.
171
+
172
+ 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.
173
+
174
+ - **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))"`.
175
+ - **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.
176
+ - **Storage.** Treat `AUTH_TOKENS` like a password: restrict who can read `.env`, and keep tokens out of version control, tickets and chat.
177
+ - **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.
178
+ - **Rotation.** Edit `AUTH_TOKENS` and restart. That is the whole mechanism; there is no expiry and no revocation list.
179
+
180
+ At startup in prod, the application logs that static mode is active and how many tokens are configured, never the tokens themselves.
181
+
182
+ ### Keycloak JWT (recommended for production)
183
+
184
+ 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.
185
+
186
+ Set `AUTH_MODE=keycloak` in `.env`. See `.env.example` for the required Keycloak variables (`KEYCLOAK_ISSUER`, `KEYCLOAK_AUDIENCE`, `KEYCLOAK_JWKS_URL`).
137
187
 
138
188
  > **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.
139
189
 
140
- 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 and the required audience mapper.
190
+ 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.
141
191
 
142
192
  For Keycloak setup, Identity Provider federation (e.g. Microsoft Entra ID / SSO), and onboarding documentation, see [`keycloak/README.md`](keycloak/README.md).
143
193
 
@@ -159,6 +209,10 @@ Then run:
159
209
  pixi run pytest tests
160
210
  ```
161
211
 
212
+ ## Agentic Coding
213
+
214
+ See `.devcontainer/README.md` for Claude Code setup.
215
+
162
216
  ## Troubleshooting
163
217
 
164
218
  ### PyJWKClientConnectionError
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmas-app"
3
- version = "1.0.0-dev.1"
3
+ dynamic = ["version"]
4
4
  description = "The XLeitstelle model-driven application schema app."
5
5
  authors = [{ name = "Tobias Kraft", email = "tobias.kraft@gv.hamburg.de" }]
6
6
  classifiers = [
@@ -11,19 +11,19 @@ classifiers = [
11
11
  "Programming Language :: Python :: 3",
12
12
  ]
13
13
  readme = "README.md"
14
- license = "EUPL-1.2-or-later"
14
+ license = "EUPL-1.2+"
15
15
  license-files = ["LICENSE"]
16
16
  dependencies = [
17
17
  "pydantic-settings>=2.0,<3.0",
18
18
  "pydantic-extra-types[semver]",
19
- "xplan-tools==1.24.1",
19
+ "xmas-core == 2.0.0rc12",
20
20
  "nicegui>=3.12,<4.0",
21
21
  "aiohttp>=3.13.5",
22
22
  "tenacity>=9.1.4,<10",
23
23
  "httpx2>=2.4.0,<3",
24
24
  "structlog",
25
25
  ]
26
- requires-python = ">=3.12,<3.14"
26
+ requires-python = ">=3.14"
27
27
 
28
28
  [project.urls]
29
29
  Homepage = "https://gitlab.opencode.de/xleitstelle/xmas-app"
@@ -45,21 +45,29 @@ dev = { features = ["dev"], solve-group = "default" }
45
45
  build = { features = ["build"], solve-group = "default" }
46
46
  prod = { features = [], solve-group = "default" }
47
47
  test = { features = ["test"], solve-group = "default" }
48
+ # Union of "dev" and "test", for the devcontainer: it needs both the lint/format
49
+ # tooling and pytest, and "default" (the same union) is off-limits there because
50
+ # Windows PyCharm owns that env in the shared, bind-mounted .pixi folder.
51
+ container = { features = ["dev", "test"], solve-group = "default" }
48
52
 
49
53
  [tool.pixi.dependencies]
54
+ python = "3.14.*"
50
55
  gdal = ">=3.13.0,<4"
51
56
  libgdal-core = ">=3.13.0,<4"
52
- pyjwt = ">=2.12.1,<3"
53
- cryptography = ">=48.0.0,<49"
54
-
55
- [tool.pixi.target.win.dependencies]
56
- winloop = "*"
57
+ pyjwt = ">=2.15.1,<3"
58
+ cryptography = "*"
57
59
 
58
60
  [tool.pixi.target.unix.dependencies]
59
61
  uvloop = "*"
60
62
 
61
63
  [tool.pytest.ini_options]
62
64
  asyncio_mode = "auto"
65
+ addopts = "--tb=short"
66
+ timeout = 180
67
+ timeout_method = "thread"
68
+ # setup/teardown is not charged against the timeout: the session-scoped Postgres
69
+ # container would otherwise bill its image pull to the first integration test
70
+ timeout_func_only = true
63
71
  filterwarnings = [
64
72
  "ignore::DeprecationWarning"
65
73
  ]
@@ -69,30 +77,21 @@ omit = [
69
77
  "xmas_app/models/migrations/*",
70
78
  ]
71
79
 
72
- [tool.pixi.tasks.release]
73
- args = [
74
- { "arg" = "version", "default" = "minor" }
75
- ]
76
- cmd = """
77
- pixi workspace version {{version}}
78
- && git pull
79
- && pixi lock
80
- && git add pyproject.toml pixi.lock
81
- && git commit -S -m "bump to $(pixi workspace version get)"
82
- && git tag -s $(pixi workspace version get) -m "v$(pixi workspace version get)"
83
- && git push
84
- && git push --tags
85
- && pixi workspace version get
86
- """
80
+ [tool.pixi.tasks]
81
+ # tag the next release (push it yourself), parts as in version.py's `bump`: pixi run tag dev
82
+ tag = { cmd = "python version.py {{ parts }}", args = [
83
+ { arg = "parts", default = "patch" },
84
+ ] }
87
85
 
88
86
 
89
87
  [dependency-groups]
90
88
  dev = ["pre-commit>=4", "ruff>=0.8", "cyclonedx-bom>=7"]
91
- build = ["poetry>=2.0.0"]
89
+ build = ["uv"]
92
90
  test = [
93
91
  "pytest",
94
92
  "pytest-asyncio",
95
93
  "pytest-cov",
94
+ "pytest-timeout",
96
95
  "testcontainers"
97
96
  ]
98
97
 
@@ -109,5 +108,16 @@ extend-select = ["I", "D"]
109
108
  convention = "google"
110
109
 
111
110
  [build-system]
112
- requires = ["poetry-core"]
113
- build-backend = "poetry.core.masonry.api"
111
+ requires = ["hatchling>=1.27"]
112
+ build-backend = "hatchling.build"
113
+
114
+ [tool.hatch.version]
115
+ source = "code"
116
+ path = "version.py"
117
+
118
+ # xmas_app has no __init__.py, so hatchling cannot find it by the project name
119
+ [tool.hatch.build.targets.wheel]
120
+ packages = ["xmas_app"]
121
+
122
+ [tool.hatch.build.targets.sdist]
123
+ include = ["xmas_app", "version.py"]
@@ -0,0 +1,88 @@
1
+ """Package version for hatchling (`[tool.hatch.version] source = "code"`).
2
+
3
+ The git tag is the only source. CI_COMMIT_TAG wins: tag pipelines and the Docker build have
4
+ no usable git. Otherwise `git describe`: on a tag that is the tag, past it a local version
5
+ `<tag>+<n>.g<sha>` (sorts after the tag, refused by PyPI); no git or no tag gives 0.0.0.dev0.
6
+ A tag must be semver whose prerelease is also valid PEP 440 (-dev.N/-rc.N): GitLab's
7
+ changelog API and the CI prerelease regexes read it as semver. alpha/beta are left out:
8
+ semver sorts them before dev, PEP 440 after it.
9
+
10
+ `python version.py PART...` (`pixi run tag dev`) tags the commit with the tag after
11
+ the last one, like `uv version --bump`, see `bump`. Here, not in the pixi task:
12
+ its shell carries on after a failed `$(...)`, tagging an empty name.
13
+ """
14
+
15
+ import os
16
+ import re
17
+ import subprocess # nosec B404 - runs git only
18
+ import sys
19
+
20
+ _TAG = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-(dev|rc)\.(\d+))?")
21
+ _PRE = ("dev", "rc")
22
+
23
+
24
+ def _version() -> str:
25
+ """The version of the checkout: CI_COMMIT_TAG, else from `git describe`."""
26
+ if tag := os.environ.get("CI_COMMIT_TAG"):
27
+ if not _TAG.fullmatch(tag):
28
+ raise ValueError(f"tag {tag!r} is not X.Y.Z or X.Y.Z-(dev|rc).N")
29
+ return tag
30
+ try:
31
+ out = subprocess.run( # nosec B603 B607 - fixed argv, git from PATH
32
+ ["git", "describe", "--tags", "--long", "--match", "[0-9]*"],
33
+ capture_output=True,
34
+ text=True,
35
+ check=True,
36
+ cwd=os.path.dirname(os.path.abspath(__file__)),
37
+ ).stdout.strip()
38
+ except OSError, subprocess.CalledProcessError:
39
+ return "0.0.0.dev0"
40
+ tag, n, sha = out.rsplit("-", 2)
41
+ return tag if n == "0" else f"{tag}+{n}.{sha}"
42
+
43
+
44
+ def bump(tag: str, *parts: str) -> str:
45
+ """The tag after `tag`, each part applied in order.
46
+
47
+ major/minor/patch raise that component and drop the prerelease, stable drops it,
48
+ dev/rc count up (dev.1 -> dev.2) or move forward (dev.2 -> rc.1). A prerelease of a
49
+ released tag needs a release part first (`major dev`: 0.18.6 -> 1.0.0-dev.1), else it
50
+ would sort before the tag it follows.
51
+ """
52
+ m = _TAG.fullmatch(tag)
53
+ if not m:
54
+ raise ValueError(f"tag {tag!r} is not X.Y.Z or X.Y.Z-(dev|rc).N")
55
+ major, minor, patch = map(int, m.group(1, 2, 3))
56
+ pre, n = m[4], int(m[5] or 0)
57
+ released = pre is None
58
+ for part in parts:
59
+ match part:
60
+ case "major":
61
+ major, minor, patch, pre, released = major + 1, 0, 0, None, False
62
+ case "minor":
63
+ minor, patch, pre, released = minor + 1, 0, None, False
64
+ case "patch":
65
+ patch, pre, released = patch + 1, None, False
66
+ case "stable":
67
+ pre = None
68
+ case "dev" | "rc" if released:
69
+ raise ValueError(
70
+ f"{tag} is released: bump major, minor or patch before {part}"
71
+ )
72
+ case "dev" | "rc" if part == pre:
73
+ n += 1
74
+ case "dev" | "rc" if pre is None or _PRE.index(part) > _PRE.index(pre):
75
+ pre, n = part, 1
76
+ case _:
77
+ raise ValueError(f"cannot bump {tag} by {part!r}")
78
+ return f"{major}.{minor}.{patch}" + (f"-{pre}.{n}" if pre else "")
79
+
80
+
81
+ __version__ = _version()
82
+
83
+ if __name__ == "__main__":
84
+ tag = bump(__version__.split("+")[0], *sys.argv[1:])
85
+ subprocess.run( # nosec B603 B607 - fixed argv, git from PATH
86
+ ["git", "tag", "-s", tag, "-m", f"v{tag}"], check=True
87
+ )
88
+ print(f"tagged {tag}; release with: git push origin {tag}")
@@ -147,11 +147,13 @@ class AssociationList(ui.button):
147
147
  case "existing":
148
148
  if self.ref_objects is None:
149
149
  self.table_existing.props("loading")
150
- self.ref_objects = await get_ref_objects(
151
- self.refs,
152
- extra_props=self._form._mappings.association_table.extra_property,
153
- repo=self._form.repo,
154
- )
150
+ async with self._form.sessionmaker.begin() as session:
151
+ self.ref_objects = await get_ref_objects(
152
+ self.refs,
153
+ self._form.plan_id,
154
+ extra_props=self._form._mappings.association_table.extra_property,
155
+ session=session,
156
+ )
155
157
  self.table_existing.rows = self.ref_objects
156
158
  self.table_existing.selected = self.ref_objects
157
159
  self.table_existing.props(
@@ -161,14 +163,15 @@ class AssociationList(ui.button):
161
163
  case "candidates":
162
164
  if self.ref_candidates is None:
163
165
  self.table_candidates.props("loading")
164
- self.ref_candidates = await get_ref_candidates(
165
- self.refs,
166
- self._form.plan_id,
167
- self._form.model,
168
- self.rel,
169
- extra_props=self._form._mappings.association_table.extra_property,
170
- repo=self._form.repo,
171
- )
166
+ async with self._form.sessionmaker.begin() as session:
167
+ self.ref_candidates = await get_ref_candidates(
168
+ self.refs,
169
+ self._form.plan_id,
170
+ self._form.model,
171
+ self.rel,
172
+ extra_props=self._form._mappings.association_table.extra_property,
173
+ session=session,
174
+ )
172
175
  self.table_candidates.rows = self.ref_candidates
173
176
  self.table_candidates.props(
174
177
  f":selected-rows-label='(numberOfRows) => `${{ numberOfRows }} von {len(self.ref_candidates)} Referenzen selektiert`'"