xmas-app 0.12.2__tar.gz → 0.13.1__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 (26) hide show
  1. {xmas_app-0.12.2 → xmas_app-0.13.1}/PKG-INFO +1 -1
  2. {xmas_app-0.12.2 → xmas_app-0.13.1}/pyproject.toml +1 -1
  3. xmas_app-0.13.1/xmas_app/components/association.py +246 -0
  4. xmas_app-0.13.1/xmas_app/components/buttons.py +62 -0
  5. xmas_app-0.13.1/xmas_app/components/label.py +13 -0
  6. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/db.py +92 -9
  7. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/deps/version_guard.py +8 -5
  8. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/form.py +244 -509
  9. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/main.py +63 -132
  10. xmas_app-0.13.1/xmas_app/models/mappings.py +10 -0
  11. xmas_app-0.13.1/xmas_app/resources/mappings.toml +3 -0
  12. xmas_app-0.13.1/xmas_app/services/crud.py +120 -0
  13. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/settings.py +42 -8
  14. xmas_app-0.13.1/xmas_app/util/__init__.py +0 -0
  15. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/util/codelist.py +2 -2
  16. xmas_app-0.12.2/xmas_app/services/crud.py +0 -78
  17. {xmas_app-0.12.2 → xmas_app-0.13.1}/LICENSE +0 -0
  18. {xmas_app-0.12.2 → xmas_app-0.13.1}/README.md +0 -0
  19. {xmas_app-0.12.2/xmas_app/util → xmas_app-0.13.1/xmas_app/components}/__init__.py +0 -0
  20. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/db_uow.py +0 -0
  21. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/models/crud.py +0 -0
  22. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/pygeoapi/config.yaml +0 -0
  23. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/pygeoapi/openapi.yaml +0 -0
  24. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/pygeoapi/provider.py +0 -0
  25. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/schema.py +0 -0
  26. {xmas_app-0.12.2 → xmas_app-0.13.1}/xmas_app/split_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: xmas-app
3
- Version: 0.12.2
3
+ Version: 0.13.1
4
4
  Summary: The XLeitstelle model-driven application schema app.
5
5
  License: EUPL-1.2-or-later
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "xmas-app"
3
- version = "0.12.2"
3
+ version = "0.13.1"
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 = [
@@ -0,0 +1,246 @@
1
+ import logging
2
+
3
+ from nicegui import app, binding, events, run, ui
4
+
5
+ from xmas_app.components.buttons import FeatureInteractionButton
6
+ from xmas_app.db import get_ref_candidates, get_ref_objects
7
+
8
+ logger = logging.getLogger("xmas_app")
9
+
10
+
11
+ class AssociationList(ui.button):
12
+ """Class to view and edit associations with multiplicity > 1.
13
+
14
+ It initializes a button to use in a feature form that opens a dialog.
15
+ The dialog contains two tabs: one for existing and one for potential feature references.
16
+ The (potential) reference features are retreived from the database and rendered in tables.
17
+ A click on a feature (row) opens a dialog with action buttons to zoom to it etc.
18
+ Active references are handled via the tables selection mechanism: selected features are referenced, deselected features are not.
19
+ """
20
+
21
+ refs = binding.BindableProperty()
22
+
23
+ def __init__(self, bind_obj: object, bind_key: str) -> None:
24
+ """Initialize the button element.
25
+
26
+ Args:
27
+ bind_obj: The binding object to bind the reference list to, i.e. the feature.
28
+ bind_key: The property of the binding object to bind to, i.e. the association attribute.
29
+ """
30
+ super().__init__(icon="link")
31
+ self.props("square unelevated outline")
32
+ self.classes("w-full")
33
+ binding.bind(
34
+ self,
35
+ "refs",
36
+ bind_obj,
37
+ bind_key,
38
+ forward=lambda x: None if not x else x,
39
+ backward=lambda x: [] if not x else x,
40
+ )
41
+ self.rel = bind_key
42
+ self.dialog = ui.dialog().props("full-height")
43
+ self.dialog.on("before-show", self._on_dialog_open)
44
+ self.action_dialog = ui.dialog()
45
+ self.tab_panels: ui.tab_panels | None = None
46
+ self.table_existing: ui.table | None = None
47
+ self.table_candidates: ui.table | None = None
48
+ self.ref_objects: list[dict] | None = None
49
+ self.ref_candidates: list[dict] | None = None
50
+ # create a badge on the button showing the number of active references
51
+ with self:
52
+ ui.badge().props("floating").bind_text_from(
53
+ self,
54
+ "refs",
55
+ backward=lambda refs: str(len(refs)),
56
+ )
57
+ with (
58
+ self.dialog,
59
+ ui.card().style("min-width: 800px; max-width: 90vw;"),
60
+ ):
61
+ with ui.icon("live_help", size="1.2rem").classes("absolute top-1 right-1"):
62
+ ui.tooltip(
63
+ """
64
+ Durch Klick auf Features öffnet sich ein Aktionsdialog.
65
+ Durch Selektion bzw. Deselektion von Features (nur im Editiermodus verfügbar)
66
+ wird die entsprechende Referenz hinzugefügt bzw. entfernt.
67
+ """
68
+ ).props("max-width=200px")
69
+ with ui.tabs().classes("w-full pt-2").props("no-caps dense") as tabs:
70
+ ui.tab("existing", "Bestehende Referenzen", "link").classes("w-full")
71
+ ui.tab("candidates", "Mögliche Referenzen", "add_link").classes(
72
+ "w-full"
73
+ )
74
+ self.tab_panels = ui.tab_panels(
75
+ tabs, on_change=self._on_tab_change
76
+ ).classes("w-full")
77
+ with self.tab_panels:
78
+ with ui.tab_panel("existing"):
79
+ self.table_existing = self._create_table()
80
+ with ui.tab_panel("candidates"):
81
+ self.table_candidates = self._create_table()
82
+ self.on_click(self.dialog.open)
83
+
84
+ async def _on_dialog_open(self, _: events.GenericEventArguments):
85
+ """Deferred one-time initialization of table content."""
86
+ if not self.tab_panels.value:
87
+ self.tab_panels.set_value("existing")
88
+
89
+ async def _on_row_click(self, e: events.GenericEventArguments):
90
+ """Opens a dialog with action buttons for the row/feature."""
91
+ try:
92
+ row = e.args[1]
93
+ feature_id = row["id"]
94
+ except Exception as e:
95
+ logger.exception("error on loading feature data from row: %s", e)
96
+ return ui.notify("Fehler beim Abruf der Feature-Daten", type="negative")
97
+ self.action_dialog.clear()
98
+ with self.action_dialog, ui.card():
99
+ ui.label(f"{row['featuretype']} {feature_id[:8]}").classes("text-bold")
100
+ ui.button(
101
+ text="Vorschau öffnen",
102
+ icon="o_preview",
103
+ on_click=lambda: app.storage.client["form"]._preview_association(
104
+ feature_id
105
+ ),
106
+ ).props("flat square no-caps align=left").classes("w-full")
107
+ FeatureInteractionButton(
108
+ "highlight_feature", feature_id, row["geometry_type"] != "nogeom"
109
+ )
110
+ FeatureInteractionButton("select_feature", feature_id)
111
+ FeatureInteractionButton(
112
+ "show_attribute_form", feature_id, app.storage.client["form"].feature.id
113
+ )
114
+ self.action_dialog.open()
115
+
116
+ async def _on_select(self, _: events.TableSelectionEventArguments):
117
+ """Sets active references to the aggregated selected features from both tables."""
118
+ try:
119
+ self.refs = [
120
+ selected["id"]
121
+ for selected in (
122
+ self.table_existing.selected + self.table_candidates.selected
123
+ )
124
+ ]
125
+ except Exception as e:
126
+ logger.exception("Error while updating references: %s", e)
127
+ ui.notify("Fehler bei Aktualisierung der Referenzen", type="negative")
128
+
129
+ async def _on_tab_change(self, e: events.ValueChangeEventArguments):
130
+ """Deferred loading of DB features and table initialisation via tab change event."""
131
+ match e.value:
132
+ case "existing":
133
+ if self.ref_objects is None:
134
+ self.table_existing.props("loading")
135
+ self.ref_objects = await run.io_bound(get_ref_objects, self.refs)
136
+ self.table_existing.update_rows(self.ref_objects)
137
+ self.table_existing.selected = self.ref_objects
138
+ self.table_existing.props(
139
+ f":selected-rows-label='(numberOfRows) => `${{ numberOfRows }} von {len(self.ref_objects)} Referenzen selektiert`'"
140
+ )
141
+ self.table_existing.props(remove="loading")
142
+ case "candidates":
143
+ if self.ref_candidates is None:
144
+ self.table_candidates.props("loading")
145
+ self.ref_candidates = await run.io_bound(
146
+ get_ref_candidates,
147
+ self.refs,
148
+ app.storage.client["plan_id"],
149
+ app.storage.client["form"].model,
150
+ self.rel,
151
+ )
152
+ self.table_candidates.update_rows(self.ref_candidates)
153
+ self.table_candidates.props(
154
+ f":selected-rows-label='(numberOfRows) => `${{ numberOfRows }} von {len(self.ref_candidates)} Referenzen selektiert`'"
155
+ )
156
+ self.table_candidates.props(remove="loading")
157
+
158
+ def _create_table(self) -> ui.table:
159
+ """Creates a table with adequate columns, props and event handlers."""
160
+ columns = [
161
+ {
162
+ "name": "id",
163
+ "label": "Identifikator",
164
+ "field": "id",
165
+ "align": "left",
166
+ "sortable": True,
167
+ },
168
+ {
169
+ "name": "featuretype",
170
+ "label": "Objektart",
171
+ "field": "featuretype",
172
+ "align": "left",
173
+ "sortable": True,
174
+ },
175
+ {
176
+ "name": "updated",
177
+ "label": "letzte Aktualisierung",
178
+ "field": "updated",
179
+ "align": "left",
180
+ "sortable": True,
181
+ },
182
+ {
183
+ "name": "extra_property",
184
+ "label": "Selektionskriterium",
185
+ "field": "extra_property",
186
+ "align": "left",
187
+ "sortable": True,
188
+ "classes": "hidden",
189
+ "headerClasses": "hidden",
190
+ },
191
+ ]
192
+ table = (
193
+ ui.table(
194
+ rows=[],
195
+ columns=columns,
196
+ selection="multiple",
197
+ )
198
+ .props(
199
+ """
200
+ no-data-label='keine passenden Features vorhanden'
201
+ no-results-label='keine passenden Ergebnisse gefunden'
202
+ flat
203
+ square
204
+ dense
205
+ virtual-scroll
206
+ """
207
+ )
208
+ .classes("w-full h-full")
209
+ )
210
+ if form := app.storage.client["form"]:
211
+ # use existing bind method to enable/disable selection
212
+ table.bind_visibility_from(
213
+ form,
214
+ "editable",
215
+ backward=lambda editable, table=table: table.props(
216
+ f"selection={'none' if not editable else 'multiple'}"
217
+ ),
218
+ )
219
+ table.on("row-click", self._on_row_click)
220
+ table.on_select(self._on_select)
221
+ with table.add_slot("top"):
222
+ search = (
223
+ ui.input("Filter")
224
+ .bind_value(table, "filter")
225
+ .tooltip("über alle Spalten nach Ausdruck filtern")
226
+ .props("clearable square dense")
227
+ .classes("w-64")
228
+ )
229
+ with search.add_slot("prepend"):
230
+ ui.icon("filter_list")
231
+ ui.space()
232
+ ui.checkbox(
233
+ "Selektionskriterium",
234
+ on_change=lambda e, table=table: (
235
+ table.columns[-1].update(
236
+ {
237
+ "classes": "" if e.value else "hidden",
238
+ "headerClasses": "" if e.value else "hidden",
239
+ }
240
+ ),
241
+ table.update(),
242
+ ),
243
+ ).tooltip(
244
+ "aktiviert eine zusätzliche Spalte mit einem ggf. definierten Selektionskriterium"
245
+ )
246
+ return table
@@ -0,0 +1,62 @@
1
+ import json
2
+ import logging
3
+ from typing import Literal, get_args
4
+
5
+ from nicegui import events, ui
6
+
7
+ logger = logging.getLogger("xmas_app")
8
+
9
+
10
+ class FeatureInteractionButton(ui.button):
11
+ """Generic Button to interact with QGIS features via QWebchannel."""
12
+
13
+ action_types = Literal["highlight_feature", "show_attribute_form", "select_feature"]
14
+ type_map = {
15
+ "highlight_feature": {"text": "Zum Feature springen", "icon": "o_pageview"},
16
+ "show_attribute_form": {
17
+ "text": "Attributformular öffnen",
18
+ "icon": "list_alt",
19
+ },
20
+ "select_feature": {"text": "Feature selektieren", "icon": "select_all"},
21
+ }
22
+
23
+ def __init__(
24
+ self,
25
+ action_type: action_types,
26
+ target: str,
27
+ source: str | None = None,
28
+ geom: bool = True,
29
+ ):
30
+ """Button initialization.
31
+
32
+ Args:
33
+ type: the specific action to initialize the button for
34
+ target: the ID of the target feature
35
+ source: the ID of the source feature
36
+ geom: whether the feature has a geometry
37
+ Raises:
38
+ ValueError: if the type is unknown
39
+ """
40
+ if action_type not in get_args(self.action_types):
41
+ raise ValueError(f"invalid type: '{action_type}'")
42
+ self.action_type = action_type
43
+ self.source = source
44
+ self.target = target
45
+ super().__init__(**self.type_map[action_type], on_click=self._on_click)
46
+ self.props("flat square no-caps align=left")
47
+ self.classes("w-full")
48
+ if not geom:
49
+ self.disable()
50
+ self.tooltip("Keine Geometrie")
51
+
52
+ async def _on_click(self, _: events.ClickEventArguments):
53
+ try:
54
+ logger.debug("Sending %s request to QWebChannel handler", self.action_type)
55
+ data = json.dumps({"source": self.source, "target": self.target})
56
+ ui.run_javascript(f"""new QWebChannel(qt.webChannelTransport, function (channel) {{
57
+ channel.objects.handler.{self.action_type}({data})
58
+ }});""")
59
+ except Exception as e:
60
+ logger.exception(
61
+ "Exception while %s was called: %s", self.action_type, str(e)
62
+ )
@@ -0,0 +1,13 @@
1
+ from nicegui import ui
2
+
3
+
4
+ class LabelWithDescription(ui.label):
5
+ def __init__(self, text: str, tooltip=str | None):
6
+ super().__init__(text)
7
+ self.classes(
8
+ "underline underline-offset-2 decoration-1 decoration-dotted cursor-help"
9
+ )
10
+ with self:
11
+ ui.tooltip(tooltip or "").classes("text-sm").props(
12
+ "anchor='center middle' self='center left'"
13
+ )
@@ -3,12 +3,13 @@ import logging
3
3
  from nicegui.observables import ObservableSet
4
4
  from sqlalchemy import select, text
5
5
  from sqlalchemy.exc import SQLAlchemyError
6
- from sqlalchemy.orm import selectinload
6
+ from sqlalchemy.orm import aliased, selectinload
7
7
  from xplan_tools.interface.db import DBRepository
8
8
  from xplan_tools.model import model_factory
9
- from xplan_tools.model.orm import Feature
9
+ from xplan_tools.model.base import BaseFeature
10
+ from xplan_tools.model.orm import Feature, Refs
10
11
 
11
- from xmas_app.settings import settings
12
+ from xmas_app.settings import get_mappings, get_settings
12
13
 
13
14
  logger = logging.getLogger(__name__)
14
15
 
@@ -83,7 +84,7 @@ def get_db_plans() -> list[dict]:
83
84
  """
84
85
  results = []
85
86
  try:
86
- with settings.repo.Session() as session:
87
+ with get_settings().repo.Session() as session:
87
88
  stmt = select(Feature).where(Feature.featuretype.regexp_match("^.*Plan$"))
88
89
  try:
89
90
  db_result = session.execute(stmt)
@@ -199,21 +200,20 @@ def get_nodes(
199
200
  icon = "o_text_snippet"
200
201
  return icon
201
202
 
202
- with settings.repo.Session() as session:
203
- Ref = Feature.refs.property.mapper.class_
203
+ with get_settings().repo.Session() as session:
204
204
  stmt = (
205
205
  select(Feature)
206
206
  .options(
207
207
  selectinload(Feature.refs)
208
- .selectinload(Ref.feature_inv)
208
+ .selectinload(Refs.feature_inv)
209
209
  .selectinload(Feature.refs)
210
- .selectinload(Ref.feature_inv)
210
+ .selectinload(Refs.feature_inv)
211
211
  .selectinload(Feature.refs)
212
212
  )
213
213
  .where(Feature.id == id)
214
214
  )
215
215
 
216
- feature = session.execute(stmt).scalars().first()
216
+ feature = session.execute(stmt).scalar_one_or_none()
217
217
  # feature = session.get(Feature, id)
218
218
  if not feature or "Plan" not in feature.featuretype:
219
219
  return
@@ -235,3 +235,86 @@ def get_nodes(
235
235
  feature.id,
236
236
  feature.appschema,
237
237
  )
238
+
239
+
240
+ def _feature_to_table_row(feature: Feature) -> dict:
241
+ extra_property = getattr(
242
+ get_mappings().association_table.extra_properties, feature.appschema
243
+ ).get(feature.featuretype)
244
+ extra_property_label = feature.properties.get(extra_property, "N/A")
245
+ return {
246
+ "id": str(feature.id),
247
+ "featuretype": feature.featuretype,
248
+ "geometry_type": feature.geometry_type,
249
+ "updated": f"{feature.updated_at.strftime('%m.%d.%Y')} {feature.updated_at.strftime('%H:%M:%S')}",
250
+ "extra_property": f"{extra_property}={extra_property_label}"
251
+ if extra_property
252
+ else "N/A",
253
+ }
254
+
255
+
256
+ def _rel_inv_is_list(feature: Feature, rel_inv: str | None) -> bool:
257
+ # if there is no inverse relation, return True
258
+ if not rel_inv:
259
+ return True
260
+ model = model_factory(feature.featuretype, feature.version, feature.appschema)
261
+ return model.get_property_info(rel_inv)["list"]
262
+
263
+
264
+ def get_ref_objects(refs: list[str]) -> list[dict]:
265
+ """Returns a list of data objects for existing feature references to use in table rows."""
266
+ with get_settings().repo.Session() as session:
267
+ stmt = select(Feature).where(Feature.id.in_(refs))
268
+ features = session.execute(stmt).scalars().all()
269
+
270
+ return [_feature_to_table_row(feature) for feature in features]
271
+
272
+
273
+ def get_ref_candidates(
274
+ refs: list[str], plan_id: str, model: BaseFeature, rel: str
275
+ ) -> list[dict]:
276
+ """Returns a list of data objects for potential feature references to use in table rows."""
277
+ prop_info = model.get_property_info(rel)
278
+ typename = prop_info["typename"]
279
+ featuretypes = typename if isinstance(typename, list) else [typename]
280
+ rel_inv = model.get_property_info(rel)["assoc_info"]["reverse"]
281
+
282
+ with get_settings().repo.Session() as session:
283
+ # collect all plan features while applying WHERE clauses to filter on the DB side
284
+ Plan = aliased(Feature, name="plan")
285
+ DirectFeature = aliased(Feature, name="direct_feature")
286
+ IndirectFeature = aliased(Feature, name="indirect_feature")
287
+
288
+ RefPlanDirect = aliased(Refs, name="ref_plan_direct")
289
+ RefDirectIndirect = aliased(Refs, name="ref_direct_indirect")
290
+ direct = (
291
+ select(DirectFeature)
292
+ .select_from(Plan)
293
+ .join(RefPlanDirect, RefPlanDirect.base_id == Plan.id)
294
+ .join(DirectFeature, DirectFeature.id == RefPlanDirect.related_id)
295
+ .where(Plan.id == plan_id)
296
+ .where(DirectFeature.featuretype.in_(featuretypes))
297
+ .where(DirectFeature.id.not_in(refs))
298
+ )
299
+
300
+ indirect = (
301
+ select(IndirectFeature)
302
+ .select_from(Plan)
303
+ .join(RefPlanDirect, RefPlanDirect.base_id == Plan.id)
304
+ .join(DirectFeature, DirectFeature.id == RefPlanDirect.related_id)
305
+ .join(RefDirectIndirect, RefDirectIndirect.base_id == DirectFeature.id)
306
+ .join(IndirectFeature, IndirectFeature.id == RefDirectIndirect.related_id)
307
+ .where(Plan.id == plan_id)
308
+ .where(IndirectFeature.featuretype.in_(featuretypes))
309
+ .where(IndirectFeature.id.not_in(refs))
310
+ )
311
+ stmt = direct.union(indirect)
312
+
313
+ candidates = (
314
+ session.execute(select(Feature).from_statement(stmt)).scalars().all()
315
+ )
316
+ return [
317
+ _feature_to_table_row(feature)
318
+ for feature in candidates
319
+ if _rel_inv_is_list(feature, rel_inv)
320
+ ]
@@ -1,13 +1,16 @@
1
1
  from fastapi import Header, HTTPException
2
2
  from semver import format_version, parse
3
3
 
4
- from xmas_app.settings import settings
4
+ from xmas_app.settings import get_settings
5
5
 
6
6
 
7
7
  async def enforce_plugin_version(
8
8
  user_agent: str = Header(...),
9
9
  ):
10
- if not user_agent.startswith(settings.qgis_plugin_name):
10
+ if (
11
+ not user_agent.startswith(get_settings().qgis_plugin_name)
12
+ or get_settings().app_mode == "dev"
13
+ ):
11
14
  return
12
15
  try:
13
16
  _, plugin_version = user_agent.split("/")
@@ -15,12 +18,12 @@ async def enforce_plugin_version(
15
18
  except Exception:
16
19
  raise HTTPException(
17
20
  status_code=400,
18
- detail=f"Invalid {settings.qgis_plugin_name} user-agent header: '{user_agent}'",
21
+ detail=f"Invalid {get_settings().qgis_plugin_name} user-agent header: '{user_agent}'",
19
22
  )
20
23
 
21
- if client_v < settings.qgis_plugin_min_version:
24
+ if client_v < get_settings().qgis_plugin_min_version:
22
25
  # 426 Upgrade Required is appropriate
23
26
  raise HTTPException(
24
27
  status_code=426,
25
- detail=f"Plugin version {settings.qgis_plugin_min_version}+ required, got {format_version(**client_v)}",
28
+ detail=f"Plugin version {get_settings().qgis_plugin_min_version}+ required, got {format_version(**client_v)}",
26
29
  )