xmas-app 0.9.0__tar.gz → 0.11.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xmas-app
3
- Version: 0.9.0
3
+ Version: 0.11.0
4
4
  Summary: The XLeitstelle model-driven application schema app.
5
5
  License: EUPL-1.2-or-later
6
6
  License-File: LICENSE
@@ -13,10 +13,11 @@ Classifier: License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Requires-Dist: nicegui (>=2.20,<3.0)
16
+ Requires-Dist: pydantic-extra-types[semver]
16
17
  Requires-Dist: pydantic-settings (>=2.0,<3.0)
17
18
  Requires-Dist: xplan-tools (>=1.10.3)
18
19
  Project-URL: Homepage, https://gitlab.opencode.de/xleitstelle/xmas-app
19
- Project-URL: Issues, https://gitlab.opencode.de/xleitstelle/xplanung/xmas-app/-/issues
20
+ Project-URL: Issues, https://gitlab.opencode.de/xleitstelle/xmas-app/-/issues
20
21
  Description-Content-Type: text/markdown
21
22
 
22
23
  # XMAS-App
@@ -106,7 +107,7 @@ pixi shell
106
107
  then run
107
108
 
108
109
  ```shell
109
- xplan-gui
110
+ xmas-app
110
111
  ```
111
112
 
112
113
  ### Optional: batch import test data to the database using xplan-tools
@@ -125,10 +126,11 @@ done
125
126
  (on Windows:):
126
127
  ```cmd
127
128
  for %f in (*.gml) do xplan-tools convert "%f" postgresql://postgres:postgres@127.0.0.1:55432/postgres
128
- cd ```
129
+ ```
130
+
131
+ ## License
129
132
 
130
- ### Notes
133
+ The code in this repository is licensed under the [EUPL-1.2-or-later](https://joinup.ec.europa.eu/collection/eupl)
131
134
 
132
- We set PGGSSENCMODE=disable and PGSSLMODE=disable at runtime to avoid Windows security and file locking issues in local development.
133
- This does not affect production deployments, which can use secure defaults.
135
+ &copy; [XLeitstelle](https://xleitstelle.de), 2025
134
136
 
@@ -85,7 +85,7 @@ pixi shell
85
85
  then run
86
86
 
87
87
  ```shell
88
- xplan-gui
88
+ xmas-app
89
89
  ```
90
90
 
91
91
  ### Optional: batch import test data to the database using xplan-tools
@@ -104,9 +104,10 @@ done
104
104
  (on Windows:):
105
105
  ```cmd
106
106
  for %f in (*.gml) do xplan-tools convert "%f" postgresql://postgres:postgres@127.0.0.1:55432/postgres
107
- cd ```
107
+ ```
108
+
109
+ ## License
108
110
 
109
- ### Notes
111
+ The code in this repository is licensed under the [EUPL-1.2-or-later](https://joinup.ec.europa.eu/collection/eupl)
110
112
 
111
- We set PGGSSENCMODE=disable and PGSSLMODE=disable at runtime to avoid Windows security and file locking issues in local development.
112
- This does not affect production deployments, which can use secure defaults.
113
+ &copy; [XLeitstelle](https://xleitstelle.de), 2025
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmas-app"
3
- version = "0.9.0"
3
+ version = "0.11.0"
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 = [
@@ -15,6 +15,7 @@ license = "EUPL-1.2-or-later"
15
15
  license-files = ["LICENSE"]
16
16
  dependencies = [
17
17
  "pydantic-settings>=2.0,<3.0",
18
+ "pydantic-extra-types[semver]",
18
19
  "xplan-tools>=1.10.3",
19
20
  "nicegui>=2.20,<3.0",
20
21
  # "uvicorn[standard]>=0.29,<0.31"
@@ -25,7 +26,7 @@ requires-python = ">=3.11,<3.14"
25
26
 
26
27
  [project.urls]
27
28
  Homepage = "https://gitlab.opencode.de/xleitstelle/xmas-app"
28
- Issues = "https://gitlab.opencode.de/xleitstelle/xplanung/xmas-app/-/issues"
29
+ Issues = "https://gitlab.opencode.de/xleitstelle/xmas-app/-/issues"
29
30
 
30
31
  [project.scripts]
31
32
  xmas-app = "xmas_app.main:run_server"
@@ -51,6 +52,21 @@ libgdal-core = ">=3.9.1,<4"
51
52
  python = { version = "3.11.*", build = "*_cpython*" }
52
53
  openssl = "3.3.*"
53
54
 
55
+ [tool.pixi.tasks.release]
56
+ args = [
57
+ { "arg" = "version", "default" = "minor" }
58
+ ]
59
+ cmd = """
60
+ pixi workspace version {{version}}
61
+ && pixi lock
62
+ && git add pyproject.toml pixi.lock
63
+ && git commit -m "bump to $(pixi workspace version get)"
64
+ && git tag $(pixi workspace version get)
65
+ && git push
66
+ && git push --tags
67
+ && pixi workspace version get
68
+ """
69
+
54
70
  [dependency-groups]
55
71
  dev = ["pre-commit>=4", "ruff>=0.8"]
56
72
  build = ["poetry>=2.0.0"]
@@ -0,0 +1,26 @@
1
+ from fastapi import Header, HTTPException
2
+ from semver import format_version, parse
3
+
4
+ from xmas_app.settings import settings
5
+
6
+
7
+ async def enforce_plugin_version(
8
+ user_agent: str = Header(...),
9
+ ):
10
+ if not user_agent.startswith(settings.qgis_plugin_name):
11
+ return
12
+ try:
13
+ _, plugin_version = user_agent.split("/")
14
+ client_v = parse(plugin_version)
15
+ except Exception:
16
+ raise HTTPException(
17
+ status_code=400,
18
+ detail=f"Invalid {settings.qgis_plugin_name} user-agent header: '{user_agent}'",
19
+ )
20
+
21
+ if client_v < settings.qgis_plugin_min_version:
22
+ # 426 Upgrade Required is appropriate
23
+ raise HTTPException(
24
+ status_code=426,
25
+ detail=f"Plugin version {settings.qgis_plugin_min_version}+ required, got {format_version(**client_v)}",
26
+ )
@@ -710,7 +710,9 @@ class ModelForm:
710
710
  ui.select(
711
711
  options,
712
712
  label=prop_info["typename"],
713
- on_change=self._set_stylesheetId_and_schriftinhalt,
713
+ on_change=self._set_stylesheetId_and_schriftinhalt
714
+ if self.editable
715
+ else None,
714
716
  multiple=prop_info["list"],
715
717
  clearable=prop_info["nullable"],
716
718
  validation=None
@@ -1,21 +1,9 @@
1
- import os
2
-
3
- import pydantic
4
- import pydantic_core
5
- from nicegui.observables import ObservableSet
6
- from xplan_tools.util import get_geometry_type_from_wkt
7
-
8
- from xmas_app.schema import ErrorDetail, ErrorResponse, SplitPayload, SplitSuccess
9
- from xmas_app.split_service import PlanSplitService, SplitValidationError
10
-
11
- os.environ["PGGSSENCMODE"] = "disable"
12
- os.environ["PGSSLMODE"] = "disable"
13
-
14
1
  import asyncio
15
2
  import importlib
16
3
  import inspect
17
4
  import io
18
5
  import logging
6
+ import os
19
7
  import re
20
8
  from contextlib import asynccontextmanager
21
9
  from functools import partial
@@ -24,10 +12,30 @@ from tempfile import NamedTemporaryFile
24
12
  from typing import Any, Literal
25
13
  from uuid import uuid4
26
14
 
15
+ import pydantic
16
+ import pydantic_core
17
+ from nicegui.observables import ObservableSet
27
18
  from pydantic import ValidationError
28
19
  from starlette.applications import Starlette
20
+ from xplan_tools.util import get_geometry_type_from_wkt
29
21
 
30
- log_dir = Path(__file__).parent / "logs"
22
+ from xmas_app.deps.version_guard import enforce_plugin_version
23
+ from xmas_app.schema import ErrorDetail, ErrorResponse, SplitPayload, SplitSuccess
24
+ from xmas_app.split_service import PlanSplitService, SplitValidationError
25
+
26
+
27
+ def _resolve_log_dir() -> Path:
28
+ # If launched from QGIS plugin
29
+ if os.getenv("XMAS_APP_FROM_PLUGIN") == "1":
30
+ plugin_dir = os.getenv("XMAS_APP_PLUGIN_DIR")
31
+ if plugin_dir:
32
+ return Path(plugin_dir) / "webapp_logs"
33
+
34
+ # default: local repo logs (dev)
35
+ return Path(__file__).parent / "logs"
36
+
37
+
38
+ log_dir = _resolve_log_dir()
31
39
  log_dir.mkdir(exist_ok=True)
32
40
  log_file = log_dir / "xmas_app.log"
33
41
 
@@ -43,20 +51,7 @@ if not logger.handlers:
43
51
  logger.addHandler(fh)
44
52
  logger.debug(f"Writing logs to {log_file}")
45
53
 
46
-
47
- # ui.timer(
48
- # 1.0,
49
- # lambda: (
50
- # print("bindings:", len(binding.bindings)),
51
- # print("a. links:", len(binding.active_links)),
52
- # print("b. props:", len(binding.bindable_properties)),
53
- # print(
54
- # "c. link elements:", "\n".join([str(link) for link in binding.active_links])
55
- # ),
56
- # ),
57
- # )
58
-
59
- from fastapi import APIRouter, HTTPException, Request, status
54
+ from fastapi import APIRouter, Depends, HTTPException, Request, status
60
55
  from nicegui import app, run, ui
61
56
  from nicegui.events import ClickEventArguments, ValueChangeEventArguments
62
57
  from xplan_tools.interface import repo_factory
@@ -133,10 +128,8 @@ async def get_model_for_select(
133
128
  )
134
129
 
135
130
 
136
- @ui.page("/")
137
- def index(
138
- request: Request,
139
- ):
131
+ @ui.page("/", dependencies=[Depends(enforce_plugin_version)])
132
+ def index():
140
133
  """
141
134
  Render the main index page of the XPlan-GUI application.
142
135
  Args:
@@ -157,7 +150,11 @@ def index(
157
150
  )
158
151
 
159
152
 
160
- @ui.page("/plan-tree/{id}", reconnect_timeout=5)
153
+ @ui.page(
154
+ "/plan-tree/{id}",
155
+ reconnect_timeout=5,
156
+ dependencies=[Depends(enforce_plugin_version)],
157
+ )
161
158
  async def plan_tree(
162
159
  request: Request,
163
160
  id: str,
@@ -170,7 +167,7 @@ async def plan_tree(
170
167
  tree.update()
171
168
 
172
169
  app.storage.client["user_agent"] = request.headers.get("user-agent", None)
173
- qgis = app.storage.client["user_agent"] == "QGIS XGeoStd Plugin"
170
+ qgis = app.storage.client["user_agent"].startswith(settings.qgis_plugin_name)
174
171
  if qgis:
175
172
  ui.add_head_html('<script src="qrc:///qtwebchannel/qwebchannel.js"></script>')
176
173
 
@@ -244,23 +241,17 @@ async def plan_tree(
244
241
  if feature.get_geom_wkt():
245
242
  ui.button("Zum Feature springen").on(
246
243
  "click",
247
- lambda e, origin=origin, target=target: highlight_feature(
248
- e, str(origin), str(target)
249
- ),
244
+ lambda e, target=target: highlight_feature(e, str(target)),
250
245
  )
251
246
 
252
247
  ui.button("Feature selektieren").on(
253
248
  "click",
254
- lambda e, origin=origin, target=target: select_feature(
255
- e, str(origin), str(target)
256
- ),
249
+ lambda e, target=target: select_feature(e, str(target)),
257
250
  )
258
251
 
259
252
  ui.button("Attributformular öffnen").on(
260
253
  "click",
261
- lambda e, origin=origin, target=target: show_attribute_form(
262
- e, str(origin), str(target)
263
- ),
254
+ lambda e, target=target: show_attribute_form(e, str(target)),
264
255
  )
265
256
 
266
257
  # ui.button(
@@ -308,12 +299,12 @@ async def plan_tree(
308
299
  return
309
300
  action_dialog.open()
310
301
 
311
- async def highlight_feature(e: ClickEventArguments, origin: str, target: str):
302
+ async def highlight_feature(e: ClickEventArguments, target: str):
312
303
  logger.debug("highlight_feature called")
313
304
  try:
314
305
  logger.info("Sending highlight_feature to QWebChannel handler")
315
306
  ui.run_javascript(f"""new QWebChannel(qt.webChannelTransport, function (channel) {{
316
- channel.objects.handler.highlight_feature("{origin}", "{target}")
307
+ channel.objects.handler.highlight_feature("{target}")
317
308
  }});""")
318
309
  except Exception as ex:
319
310
  logger.error(
@@ -322,12 +313,12 @@ async def plan_tree(
322
313
  finally:
323
314
  e.sender.props(remove="loading")
324
315
 
325
- async def show_attribute_form(e: ClickEventArguments, origin: str, target: str):
316
+ async def show_attribute_form(e: ClickEventArguments, target: str):
326
317
  logger.debug("show_attribute_form called")
327
318
  try:
328
319
  logger.info("Sending show_attribute_form to QWebChannel handler")
329
320
  ui.run_javascript(f"""new QWebChannel(qt.webChannelTransport, function (channel) {{
330
- channel.objects.handler.show_attribute_form("{origin}", "{target}")
321
+ channel.objects.handler.show_attribute_form("{target}")
331
322
  }});""")
332
323
  except Exception as ex:
333
324
  logger.error(
@@ -336,12 +327,12 @@ async def plan_tree(
336
327
  finally:
337
328
  e.sender.props(remove="loading")
338
329
 
339
- async def select_feature(e: ClickEventArguments, origin: str, target: str):
330
+ async def select_feature(e: ClickEventArguments, target: str):
340
331
  logger.debug("highlight_feature called")
341
332
  try:
342
333
  logger.info("Sending select_feature to QWebChannel handler")
343
334
  ui.run_javascript(f"""new QWebChannel(qt.webChannelTransport, function (channel) {{
344
- channel.objects.handler.select_feature("{origin}", "{target}")
335
+ channel.objects.handler.select_feature("{target}")
345
336
  }});""")
346
337
  except Exception as ex:
347
338
  logger.error(f"Exception while select_feature called: {ex}", exc_info=True)
@@ -397,7 +388,7 @@ async def plan_tree(
397
388
  nodes,
398
389
  on_select=show_menu,
399
390
  tick_strategy=None,
400
- ).props("accordion")
391
+ ).props("accordion no-transition")
401
392
 
402
393
  notification.spinner = False
403
394
  notification.type = "positive"
@@ -410,7 +401,7 @@ async def plan_tree(
410
401
  tree_filter.bind_value_to(tree, "filter")
411
402
 
412
403
 
413
- @ui.page("/plans")
404
+ @ui.page("/plans", dependencies=[Depends(enforce_plugin_version)])
414
405
  async def plans(
415
406
  request: Request,
416
407
  appschema: str = settings.appschema,
@@ -434,7 +425,7 @@ async def plans(
434
425
  ui.dropdown_button.default_props("flat square no-caps")
435
426
  ui.select.default_props("square filled dense")
436
427
  app.storage.client["user_agent"] = request.headers.get("user-agent", None)
437
- qgis = app.storage.client["user_agent"] == "QGIS XGeoStd Plugin"
428
+ qgis = app.storage.client["user_agent"].startswith(settings.qgis_plugin_name)
438
429
  if qgis:
439
430
  ui.add_head_html('<script src="qrc:///qtwebchannel/qwebchannel.js"></script>')
440
431
 
@@ -733,7 +724,7 @@ async def plans(
733
724
  new_plan_select.set_value(new_plan_select.options[0])
734
725
 
735
726
 
736
- @ui.page("/feature/{id}")
727
+ @ui.page("/feature/{id}", dependencies=[Depends(enforce_plugin_version)])
737
728
  async def feature(
738
729
  request: Request,
739
730
  id: str,
@@ -865,7 +856,7 @@ async def feature(
865
856
  app.storage.client["parent_id"] = parentId
866
857
  app.storage.client["user_agent"] = request.headers.get("user-agent", None)
867
858
 
868
- qgis = app.storage.client["user_agent"] == "QGIS XGeoStd Plugin"
859
+ qgis = app.storage.client["user_agent"].startswith(settings.qgis_plugin_name)
869
860
 
870
861
  ui.colors(
871
862
  # primary=f"{'#f0f0f0' if qgis else 'rgb(157 157 156)'}",
@@ -964,7 +955,7 @@ async def feature(
964
955
  await add_form(feature_type, feature)
965
956
 
966
957
 
967
- @ui.page("/feature/{id}/associations")
958
+ @ui.page("/feature/{id}/associations", dependencies=[Depends(enforce_plugin_version)])
968
959
  def get_associations(
969
960
  request: Request,
970
961
  id: str,
@@ -1059,7 +1050,7 @@ def get_associations(
1059
1050
 
1060
1051
  app.storage.client["user_agent"] = request.headers.get("user-agent", None)
1061
1052
 
1062
- qgis = app.storage.client["user_agent"] == "QGIS XGeoStd Plugin"
1053
+ qgis = app.storage.client["user_agent"].startswith(settings.qgis_plugin_name)
1063
1054
 
1064
1055
  feature_data = {}
1065
1056
 
@@ -1146,20 +1137,22 @@ def validate_featuretype(
1146
1137
  return "OK"
1147
1138
 
1148
1139
 
1149
- @app.post("/insert-features", status_code=201)
1140
+ @app.post(
1141
+ "/insert-features", status_code=201, dependencies=[Depends(enforce_plugin_version)]
1142
+ )
1150
1143
  async def insert_features(payload: InsertPayload):
1151
1144
  """Insert a number of features."""
1152
1145
  await crud.create(payload)
1153
1146
 
1154
1147
 
1155
- @app.post("/update-features")
1148
+ @app.post("/update-features", dependencies=[Depends(enforce_plugin_version)])
1156
1149
  async def update_features(payload: UpdatePayload):
1157
1150
  """Update a number of features."""
1158
1151
  await crud.update(payload)
1159
1152
 
1160
1153
 
1161
1154
  @app.post(
1162
- "/split_tool",
1155
+ "/split-tool",
1163
1156
  response_model=SplitSuccess,
1164
1157
  status_code=status.HTTP_201_CREATED, # semantically 'created'
1165
1158
  responses={
@@ -1167,6 +1160,7 @@ async def update_features(payload: UpdatePayload):
1167
1160
  422: {"model": ErrorResponse, "description": "Validation Error"},
1168
1161
  500: {"model": ErrorResponse, "description": "Server Error"},
1169
1162
  },
1163
+ dependencies=[Depends(enforce_plugin_version)],
1170
1164
  )
1171
1165
  async def receive_split_plans(payload: SplitPayload) -> SplitSuccess:
1172
1166
  logger.info("Split plans endpoint reached.")
@@ -2,7 +2,9 @@ import os
2
2
  from typing import Any, Dict, List, Literal, Optional, TypedDict
3
3
 
4
4
  from pydantic import HttpUrl
5
+ from pydantic_extra_types.semver import _VersionPydanticAnnotation
5
6
  from pydantic_settings import BaseSettings, SettingsConfigDict
7
+ from semver import Version
6
8
  from xplan_tools.interface.db import DBRepository
7
9
 
8
10
 
@@ -52,6 +54,8 @@ class Settings(BaseSettings):
52
54
  codelist_repo: HttpUrl = HttpUrl(
53
55
  "https://registry.gdi-de.org/codelist/de.xleitstelle.xplanung"
54
56
  )
57
+ qgis_plugin_name: str = "XMAS-Plugin"
58
+ qgis_plugin_min_version: _VersionPydanticAnnotation = Version(major=0, minor=10)
55
59
 
56
60
  model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
57
61
 
File without changes
File without changes
File without changes
File without changes