xmas-app 0.12.1__tar.gz → 0.13.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.
- {xmas_app-0.12.1 → xmas_app-0.13.0}/PKG-INFO +1 -1
- {xmas_app-0.12.1 → xmas_app-0.13.0}/pyproject.toml +1 -1
- xmas_app-0.13.0/xmas_app/components/association.py +246 -0
- xmas_app-0.13.0/xmas_app/components/buttons.py +62 -0
- xmas_app-0.13.0/xmas_app/components/label.py +13 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/db.py +92 -9
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/deps/version_guard.py +8 -5
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/form.py +244 -509
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/main.py +63 -132
- xmas_app-0.13.0/xmas_app/models/mappings.py +10 -0
- xmas_app-0.13.0/xmas_app/resources/mappings.toml +3 -0
- xmas_app-0.13.0/xmas_app/services/crud.py +119 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/settings.py +43 -9
- xmas_app-0.13.0/xmas_app/util/__init__.py +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/util/codelist.py +2 -2
- xmas_app-0.12.1/xmas_app/services/crud.py +0 -78
- {xmas_app-0.12.1 → xmas_app-0.13.0}/LICENSE +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/README.md +0 -0
- {xmas_app-0.12.1/xmas_app/util → xmas_app-0.13.0/xmas_app/components}/__init__.py +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/db_uow.py +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/models/crud.py +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/pygeoapi/config.yaml +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/pygeoapi/openapi.yaml +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/pygeoapi/provider.py +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/schema.py +0 -0
- {xmas_app-0.12.1 → xmas_app-0.13.0}/xmas_app/split_service.py +0 -0
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
208
|
+
.selectinload(Refs.feature_inv)
|
|
209
209
|
.selectinload(Feature.refs)
|
|
210
|
-
.selectinload(
|
|
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).
|
|
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
|
|
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
|
|
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 {
|
|
21
|
+
detail=f"Invalid {get_settings().qgis_plugin_name} user-agent header: '{user_agent}'",
|
|
19
22
|
)
|
|
20
23
|
|
|
21
|
-
if client_v <
|
|
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 {
|
|
28
|
+
detail=f"Plugin version {get_settings().qgis_plugin_min_version}+ required, got {format_version(**client_v)}",
|
|
26
29
|
)
|