diff --git a/README.md b/README.md index 32bd4e7..02b2319 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ jeder Schritt per API gegengeprüft. Stand 07.09.2026: **0 Fehler** mit QGIS 4.2 dafür vom Server braucht (und die API liefert): `service-desc`-Link auf ein OpenAPI-Dokument, Part-5-Schema für Felder/Geometrietyp, `OPTIONS …/items` mit `Allow`, PATCH als JSON Merge Patch. -## Das Beispiel-Plugin (klassischer GeoJSON-Weg) +## Das Beispiel-Plugin Das Plugin macht den kompletten Arbeitsablauf in der QGIS-Oberfläche erlebbar: @@ -231,6 +231,11 @@ erlebbar: Anmeldung, Verfahrensliste aus `GET /tgen` mit Suchfeld, Lade- und Speicherknöpfe, Statuszeile. Wieder einblenden über *Web → VLN Karten → Seitenleiste anzeigen*. - **Verfahrens-Auswahl** gemerkt je QGIS-Projektdatei. +- **Speichern:** über die OGC API schreibt QGIS jede Änderung einzeln zurück (Part 4); + der Knopf **„Änderungen speichern"** schließt die offenen Bearbeitungssitzungen aller + geladenen VLN-Layer auf einmal. Der frühere GeoJSON-Weg (ganze Layer laden und als Ganzes + hochladen) ist am 08.09.2026 entfallen — samt `layer_manager.py` und den beiden + Client-Methoden dafür. - **Ladezeit:** jede Sammlung kostet beim Anlegen rund eine halbe Sekunde — der OAPIF-Provider fragt Sammlung, Schema, Stil, `OPTIONS` und die erste Seite ab, nacheinander und blockierend. Zwei Gegenmittel: `vorwaermen()` holt vorab alle diese Antworten @@ -240,23 +245,12 @@ erlebbar: 1,6 s (3 Layer). **Nicht parallel:** ein in einem Python-Thread gebauter OAPIF-Layer stürzt beim ersten `getFeatures()` ab (Segfault, gemessen) — der Provider gehört dem Thread, der ihn erzeugt hat. -- **Buttons je Layer:** Verfahrensgebiet (`umringe`), Plan 41 (`p41`), - Karte alter Stand (`kas`), Wertermittlung (`we`) — vollständig - geladen über den Listen-Endpunkt mit Paging. Weitere Layer (z. B. - `st`) sind je ein Eintrag mehr im `DATASETS`-Dict in - [vln_karten/plugin.py](vln_karten/plugin.py). -- **„Alle Layer (OGC API) laden“** — der neue Weg (s. o.), editierbar ohne Splitting. -- **„Aktiven Layer hochladen“** vereint alle Teil-Layer des Datensatzes - und schreibt per PUT zurück (mit Sicherheitsabfrage). -- Erneutes Laden **ersetzt** vorhandene Layer nach Rückfrage, statt sie - zu stapeln — sonst würde der nächste Upload alles doppelt senden. | Datei | Zweck | |---|---| | [vln_karten/plugin.py](vln_karten/plugin.py) | Ablaufsteuerung: Anmeldung, Laden, Speichern, Hochladen, `DATASETS`-Registry | | [vln_karten/panel.py](vln_karten/panel.py) | Seitenleiste (Anmeldung, Verfahrensfilter, Knöpfe, Statuszeile) | | [vln_karten/api_client.py](vln_karten/api_client.py) | HTTP-Client: Login, Paging-Loader, PUT, RFC-7807-Fehler | -| [vln_karten/layer_manager.py](vln_karten/layer_manager.py) | GeoJSON ↔ Memory-Layer, Geometrie-Splitting/-Vereinigung | | [vln_karten/ogc_layers.py](vln_karten/ogc_layers.py) | OGC API Features: Auth-Konfiguration (API-Header), Sammlungen → OAPIF-Layer | | [vln_karten/login_dialog.py](vln_karten/login_dialog.py) | Anmeldedialog | | [vln_karten/metadata.txt](vln_karten/metadata.txt) | QGIS-Plugin-Metadaten | diff --git a/tools/headless_panel_test.py b/tools/headless_panel_test.py index c2115c9..85da3ba 100644 --- a/tools/headless_panel_test.py +++ b/tools/headless_panel_test.py @@ -24,7 +24,6 @@ QNetworkProxyFactory.setUseSystemConfiguration(False) from qgis.PyQt.QtCore import QSize from vln_karten.panel import VlnPanel -from vln_karten.plugin import DATASETS from vln_karten.api_client import KartenApiClient ZIEL = sys.argv[1] @@ -39,15 +38,14 @@ def pruefe(bed, text): rueckrufe = {name: (lambda *a, n=name: GERUFEN.append((n, a))) for name in ("login", "logout", "verfahren_gewaehlt", "load_ogc", - "save_style", "load_dataset", "upload")} -panel = VlnPanel(None, callbacks=rueckrufe, - datensaetze=[(k, d["label"]) for k, d in DATASETS.items()]) + "save_edits", "save_style")} +panel = VlnPanel(None, callbacks=rueckrufe) print("== abgemeldet ==") pruefe(panel.login_knopf.isVisible() or not panel.logout_knopf.isVisibleTo(panel), "Anmelde-Knopf sichtbar, Abmelden verborgen") -pruefe(not panel.ogc_knopf.isEnabled() and not panel.combo.isEnabled(), - "Knoepfe und Auswahl gesperrt") +pruefe(not panel.ogc_knopf.isEnabled() and not panel.combo.isEnabled() + and not panel.speichern_knopf.isEnabled(), "Knoepfe und Auswahl gesperrt") print("== angemeldet, echte Verfahrensliste ==") client = KartenApiClient(api_key=os.environ["VLN_TOKEN"]) @@ -78,13 +76,12 @@ pruefe(any(n == "verfahren_gewaehlt" for n, _ in GERUFEN), "Nutzerauswahl meldet panel.waehle_vkz("27027") print("== Knoepfe ==") -panel.ogc_knopf.click(); panel.stil_knopf.click(); panel.upload_knopf.click() -panel.datensatz_knoepfe[0].click() +panel.ogc_knopf.click(); panel.speichern_knopf.click(); panel.stil_knopf.click() namen = [n for n, _ in GERUFEN] -for erwartet in ("load_ogc", "save_style", "upload", "load_dataset"): +for erwartet in ("load_ogc", "save_edits", "save_style"): pruefe(erwartet in namen, "Rueckruf %s" % erwartet) -ds = [a for n, a in GERUFEN if n == "load_dataset"] -pruefe(ds and ds[0][0] == list(DATASETS)[0], "Datensatz-Knopf uebergibt Schluessel: %s" % (ds[0] if ds else "-")) +pruefe(not hasattr(panel, "datensatz_knoepfe") and not hasattr(panel, "upload_knopf"), + "kein GeoJSON-Weg mehr in der Seitenleiste") pruefe(panel.gewaehlte_gruppen() == {"Verfahren", "Vorerhebung", "Maßnahmenblätter"}, "alle Gruppen zunaechst angehakt: %s" % sorted(panel.gewaehlte_gruppen())) panel.gruppen_haken["Verfahren"].setChecked(False) diff --git a/vln_karten/api_client.py b/vln_karten/api_client.py index d06fe6c..82152e6 100644 --- a/vln_karten/api_client.py +++ b/vln_karten/api_client.py @@ -127,35 +127,6 @@ class KartenApiClient: # Karten-Layer (die konkreten Pfade stehen in plugin.DATASETS) # ------------------------------------------------------------------ - def load_layer_complete(self, layer_key, vkz, page_size=2000): - """Lädt ALLE Objekte eines Layers für eine VKZ aus KARTE_OBJEKT. - - Nutzt wie das Web-GIS (0_map, db_p41) den Listen-Endpunkt - /maps/?vkz=…, aber mit limit/offset-Paging, bis keine - weitere Seite mehr kommt — so ist der Abruf auch bei mehr als - 2000 Objekten garantiert vollständig.""" - from urllib.parse import quote - - features = [] - offset = 0 - while True: - data = self._request( - "GET", - "/maps/%s?vkz=%s&limit=%d&offset=%d" - % (quote(str(layer_key)), quote(str(vkz)), page_size, offset), - ) - if not isinstance(data, dict) or data.get("type") != "FeatureCollection": - raise ApiError( - "Antwort von /maps/%s ist keine GeoJSON FeatureCollection." - % layer_key - ) - page = data.get("features", []) - features.extend(page) - if len(page) < page_size: - break - offset += page_size - return {"type": "FeatureCollection", "features": features} - def load_feature_collection(self, path): """Lädt einen Layer als GeoJSON FeatureCollection (dict).""" data = self._request("GET", path) @@ -171,15 +142,6 @@ class KartenApiClient: "Antwort von %s ist keine GeoJSON FeatureCollection." % path ) - def save_feature_collection(self, path, feature_collection): - """Schreibt eine GeoJSON FeatureCollection zurück an die API - (PUT ersetzt den kompletten Layer der jeweiligen VKZ).""" - return self._request("PUT", path, payload=feature_collection) - - # ------------------------------------------------------------------ - # Intern - # ------------------------------------------------------------------ - def _request(self, method, path, payload=None, with_auth=True): if with_auth and not self.is_authenticated: raise AuthError("Nicht angemeldet — bitte zuerst einloggen.") diff --git a/vln_karten/layer_manager.py b/vln_karten/layer_manager.py deleted file mode 100644 index 9d75a35..0000000 --- a/vln_karten/layer_manager.py +++ /dev/null @@ -1,178 +0,0 @@ -"""Konvertierung GeoJSON <-> QGIS-Layer. - -Die API liefert je Layer (z.B. Plan 41) gemischte Geometrietypen in einer -FeatureCollection: Punkte, Linien, Polygone sowie deren Multi-Varianten. -QGIS-Memory-Layer können nur einen Geometrietyp halten — beim Laden wird -deshalb nach Geometrie-Familie (Punkte/Linien/Flächen) in bis zu drei -Layer gesplittet; Single-Geometrien werden zu Multi befördert. - -Beim Hochladen müssen alle Teil-Layer desselben Datensatzes wieder zu -EINER FeatureCollection vereint werden, weil der PUT der API den -kompletten Layer-Bestand der VKZ ersetzt (siehe layers_to_feature_collection -und plugin_layers). - -Welcher Datensatz/welche VKZ zu einem Layer gehört, steht als -Custom-Property am Layer. -""" - -import json - -from qgis.core import ( - QgsJsonExporter, - QgsJsonUtils, - QgsProject, - QgsVectorLayer, -) - -try: # QGIS >= 3.30 und QGIS 4 - from qgis.core import Qgis - _GEOM_POINT = Qgis.GeometryType.Point - _GEOM_LINE = Qgis.GeometryType.Line - _GEOM_POLYGON = Qgis.GeometryType.Polygon -except (ImportError, AttributeError): # ältere QGIS-3-Versionen - from qgis.core import QgsWkbTypes - _GEOM_POINT = QgsWkbTypes.PointGeometry - _GEOM_LINE = QgsWkbTypes.LineGeometry - _GEOM_POLYGON = QgsWkbTypes.PolygonGeometry - -# Die VLN-API liefert Koordinaten unverändert aus der DB (ST_AsGeoJSON): -# ETRS89 / UTM Zone 33 — verifiziert per GET /maps/umringe (Werte wie -# [406924, 5658760] liegen im Raum Dresden, nicht in Grad). -GEOJSON_CRS = "EPSG:25833" - -PROP_DATASET = "vln_karten/dataset" -PROP_VERFAHREN = "vln_karten/verfahren" -PROP_PATH = "vln_karten/api_path" - -# Geometrie-Familie -> (Memory-Provider-Typ, Namenszusatz). -# Reihenfolge = Lade-Reihenfolge: Flächen zuerst, damit Punkte im -# Layerbaum oben landen und nicht verdeckt werden. -_FAMILIES = ( - ("polygon", "MultiPolygon", "Flächen"), - ("line", "MultiLineString", "Linien"), - ("point", "MultiPoint", "Punkte"), - ("none", "None", "ohne Geometrie"), -) - - -def _family(feature): - """Geometrie-Familie eines Features. Unbekannte Typen (z.B. - GeometryCollection) landen bei 'none' — Attribute bleiben erhalten, - die Geometrie ginge beim Hochladen verloren.""" - if not feature.hasGeometry() or feature.geometry().isNull(): - return "none" - gtype = feature.geometry().type() - if gtype == _GEOM_POINT: - return "point" - if gtype == _GEOM_LINE: - return "line" - if gtype == _GEOM_POLYGON: - return "polygon" - return "none" - - -def memory_layer_from_features( - uri_type, name, fields, features, dataset_key, verfahren_nr, api_path, promote=True -): - """Baut einen Memory-Layer aus fertigen QgsFeatures, setzt die - Plugin-Custom-Properties und hängt ihn ins Projekt.""" - uri = uri_type if uri_type == "None" else "%s?crs=%s" % (uri_type, GEOJSON_CRS) - layer = QgsVectorLayer(uri, name, "memory") - if not layer.isValid(): - raise RuntimeError("Memory-Layer '%s' konnte nicht erzeugt werden." % name) - provider = layer.dataProvider() - provider.addAttributes(fields.toList()) - layer.updateFields() - prepared = [] - for feature in features: - geometry = feature.geometry() - if promote and not geometry.isNull() and not geometry.isMultipart(): - geometry.convertToMultiType() - feature.setGeometry(geometry) - prepared.append(feature) - provider.addFeatures(prepared) - layer.updateExtents() - layer.setCustomProperty(PROP_DATASET, dataset_key) - layer.setCustomProperty(PROP_VERFAHREN, verfahren_nr) - layer.setCustomProperty(PROP_PATH, api_path) - return layer - - -def feature_collection_to_layers( - feature_collection, base_name, default_geometry, dataset_key, verfahren_nr, api_path -): - """Erzeugt aus einer GeoJSON FeatureCollection je vorkommender - Geometrie-Familie einen Memory-Layer und hängt sie ins Projekt. - Liefert die Liste der erzeugten Layer.""" - text = json.dumps(feature_collection) - fields = QgsJsonUtils.stringToFields(text) - features = QgsJsonUtils.stringToFeatureList(text, fields) - - groups = {} - for feature in features: - groups.setdefault(_family(feature), []).append(feature) - - layers = [] - multiple = len(groups) > 1 - - def make_layer(uri_type, name, group_features, promote): - layers.append( - memory_layer_from_features( - uri_type, name, fields, group_features, - dataset_key, verfahren_nr, api_path, promote=promote, - ) - ) - - if not groups: - # Leerer Datensatz: ein leerer Layer im Default-Typ, damit der - # Nutzer digitalisieren und hochladen kann. - make_layer(default_geometry, base_name, [], promote=False) - else: - for family, uri_type, suffix in _FAMILIES: - if family not in groups: - continue - name = "%s — %s" % (base_name, suffix) if multiple else base_name - make_layer(uri_type, name, groups[family], promote=(family != "none")) - - QgsProject.instance().addMapLayers(layers) - return layers - - -def plugin_layers(dataset_key, verfahren_nr): - """Alle Layer im Projekt, die zu diesem Datensatz und dieser VKZ - gehören (z.B. die Punkte-/Linien-/Flächen-Teil-Layer von Plan 41).""" - result = [] - for layer in QgsProject.instance().mapLayers().values(): - if ( - isinstance(layer, QgsVectorLayer) - and layer.customProperty(PROP_DATASET) == dataset_key - and str(layer.customProperty(PROP_VERFAHREN)) == str(verfahren_nr) - ): - result.append(layer) - return result - - -def layers_to_feature_collection(layers): - """Vereint die Features mehrerer Layer zu EINER GeoJSON - FeatureCollection (für den PUT, der den Serverbestand ersetzt). - - Keine Transformation nach WGS84: Die API erwartet die Koordinaten im - selben CRS, in dem sie sie liefert (EPSG:25833).""" - merged = [] - for layer in layers: - exporter = QgsJsonExporter(layer) - exporter.setSourceCrs(layer.crs()) - exporter.setTransformGeometries(False) - collection = json.loads( - exporter.exportFeatures(list(layer.getFeatures())) - ) - merged.extend(collection.get("features", [])) - return {"type": "FeatureCollection", "features": merged} - - -def layer_api_path(layer): - """API-Pfad, unter dem dieser Layer geladen wurde — oder None, - wenn der Layer nicht von diesem Plugin stammt.""" - if not isinstance(layer, QgsVectorLayer): - return None - return layer.customProperty(PROP_PATH) or None diff --git a/vln_karten/metadata.txt b/vln_karten/metadata.txt index 1ce4304..fd5f9ad 100644 --- a/vln_karten/metadata.txt +++ b/vln_karten/metadata.txt @@ -3,9 +3,9 @@ name=VLN Karten qgisMinimumVersion=3.22 qgisMaximumVersion=4.99 supportsQt6=True -description=Beispiel: Karten-API des VLN Managers aus QGIS ansteuern — OGC API Features (alle Layer editierbar, Vorerhebung bis Maßnahmenblatt) und der klassische GeoJSON-Weg. +description=Beispiel: Karten-API des VLN Managers aus QGIS ansteuern — OGC API Features, alle Layer editierbar, von der Vorerhebung bis zum Maßnahmenblatt. about=Referenz-Implementierung für die VLN-Manager-Karten-API: Login (mail/password -> userauth-API-Key), vollständiges Laden je VKZ mit Paging (EPSG:25833), Splitten gemischter Geometrietypen in Memory-Layer und vereintes Zurückschreiben per PUT. -version=0.4.0 +version=0.5.0 author=Erik email=phpwelt@gmail.com tracker=https://entwicklung.flurneuordnung-sachsen.de/VLN_SN/API_Karte_QGISDemo/issues diff --git a/vln_karten/panel.py b/vln_karten/panel.py index 364d773..fcb0b0b 100644 --- a/vln_karten/panel.py +++ b/vln_karten/panel.py @@ -10,9 +10,14 @@ umgeschaltet. Aufbau von oben nach unten: 1. Anmeldung — Status + Knopf (Anmelden/Abmelden) 2. Verfahren — Suchfeld (filtert) + Auswahlliste - 3. OGC API — alle Layer laden, Stil speichern (mit Häkchen „für alle Verfahren") - 4. Klassisch — die einzelnen GeoJSON-Datensätze, aktiven Layer hochladen + 3. Layer — Auswahl der Gruppen, laden, Änderungen speichern + 4. Darstellung — Stil speichern (mit Häkchen „für alle Verfahren") 5. Statuszeile + +Der frühere GeoJSON-Weg (Kopie laden, später als Ganzes hochladen) ist am 08.09.2026 +entfallen (Erik: „das Speichern via OGC einbauen, GeoJSON nicht mehr verwenden"). Über +die OGC API schreibt QGIS jede Änderung einzeln zurück; „Änderungen speichern" schließt +nur die offenen Bearbeitungssitzungen. """ from qgis.PyQt.QtCore import Qt @@ -42,7 +47,7 @@ def _klein(widget): class VlnPanel(QDockWidget): """Die Seitenleiste. ``callbacks`` bündelt die Rückrufe ins Plugin.""" - def __init__(self, parent=None, callbacks=None, datensaetze=()): + def __init__(self, parent=None, callbacks=None): super().__init__(PANEL_TITEL, parent) self.setObjectName(PANEL_OBJEKT) self.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea @@ -58,8 +63,8 @@ class VlnPanel(QDockWidget): aussen.addWidget(self._bau_anmeldung()) aussen.addWidget(self._bau_verfahren()) - aussen.addWidget(self._bau_ogc()) - aussen.addWidget(self._bau_klassisch(datensaetze)) + aussen.addWidget(self._bau_layer()) + aussen.addWidget(self._bau_darstellung()) aussen.addStretch(1) aussen.addWidget(self._bau_status()) @@ -104,13 +109,13 @@ class VlnPanel(QDockWidget): lage.addWidget(self.combo) return kasten - def _bau_ogc(self): - kasten = QGroupBox("OGC API (empfohlen)") + def _bau_layer(self): + kasten = QGroupBox("Layer") lage = QVBoxLayout(kasten) lage.setSpacing(6) # Jede Sammlung kostet beim Anlegen rund eine halbe Sekunde (der Provider fragt # Sammlung, Schema, Stil, OPTIONS und die erste Seite ab). Wer nur einen Teil braucht, - # ist mit der Auswahl deutlich schneller: 27 Layer ~16 s, die Vorerhebung allein ~2 s. + # ist mit der Auswahl deutlich schneller: 27 Layer ~14 s, die Vorerhebung allein ~2 s. lage.addWidget(_klein(QLabel("Was geladen wird:"))) self.gruppen_haken = {} for schluessel, beschriftung in (("Verfahren", "Verfahren (Umring, Plan 41, KAS …)"), @@ -121,40 +126,37 @@ class VlnPanel(QDockWidget): lage.addWidget(haken) self.gruppen_haken[schluessel] = haken self.ogc_knopf = QPushButton("Layer laden") - self.ogc_knopf.setToolTip("Die angehakten Sammlungen als editierbare OAPIF-Layer; " - "QGIS schreibt Änderungen direkt zurück") + self.ogc_knopf.setToolTip("Die angehakten Sammlungen als editierbare Layer der OGC API") self.ogc_knopf.clicked.connect(lambda: self._ruf("load_ogc")) - self.stil_knopf = QPushButton("Stil des aktiven Layers speichern") - self.stil_knopf.setToolTip("Braucht die Gruppe MODUL_KARTENSTIL (Verbandsmitarbeiter " - "immer)") - self.stil_knopf.clicked.connect(lambda: self._ruf("save_style")) - self.vorlage_haken = QCheckBox("als Vorlage für alle Verfahren") - self.vorlage_haken.setToolTip("Der Stil gilt dann für diese Art in jedem Verfahren, " - "z. B. für jede Karte alter Stand") - lage.addWidget(self.ogc_knopf) - lage.addWidget(self.stil_knopf) - lage.addWidget(self.vorlage_haken) - return kasten - - def _bau_klassisch(self, datensaetze): - kasten = QGroupBox("Klassisch (GeoJSON)") - lage = QVBoxLayout(kasten) - lage.setSpacing(6) - lage.addWidget(_klein(QLabel("Lädt eine Kopie; Änderungen gehen erst mit " - "„Aktiven Layer hochladen“ zurück."))) - self.datensatz_knoepfe = [] - for schluessel, beschriftung in datensaetze: - knopf = QPushButton(beschriftung) - knopf.clicked.connect(lambda _=False, s=schluessel: self._ruf("load_dataset", s)) - lage.addWidget(knopf) - self.datensatz_knoepfe.append(knopf) linie = QFrame() linie.setFrameShape(QFrame.Shape.HLine) linie.setFrameShadow(QFrame.Shadow.Sunken) + self.speichern_knopf = QPushButton("Änderungen speichern") + self.speichern_knopf.setToolTip("Schließt die offenen Bearbeitungssitzungen aller " + "geladenen VLN-Layer; jede Änderung geht einzeln an " + "die API") + self.speichern_knopf.clicked.connect(lambda: self._ruf("save_edits")) + lage.addWidget(self.ogc_knopf) lage.addWidget(linie) - self.upload_knopf = QPushButton("Aktiven Layer hochladen") - self.upload_knopf.clicked.connect(lambda: self._ruf("upload")) - lage.addWidget(self.upload_knopf) + lage.addWidget(self.speichern_knopf) + lage.addWidget(_klein(QLabel("Bearbeiten wie gewohnt über den Stift; Speichern schreibt " + "jede Änderung sofort zurück."))) + return kasten + + def _bau_darstellung(self): + kasten = QGroupBox("Darstellung") + lage = QVBoxLayout(kasten) + lage.setSpacing(6) + self.stil_knopf = QPushButton("Stil des aktiven Layers speichern") + self.stil_knopf.setToolTip("Den Stil dieses Verfahrens darf jeder speichern, der das " + "Verfahren sieht") + self.stil_knopf.clicked.connect(lambda: self._ruf("save_style")) + self.vorlage_haken = QCheckBox("als Vorlage für alle Verfahren") + self.vorlage_haken.setToolTip("Gilt dann für diese Art in jedem Verfahren, z. B. für " + "jede Karte alter Stand — nur für die Gruppe " + "MODUL_KARTENSTIL") + lage.addWidget(self.stil_knopf) + lage.addWidget(self.vorlage_haken) return kasten def _bau_status(self): @@ -171,9 +173,8 @@ class VlnPanel(QDockWidget): else ("Angemeldet" if angemeldet else "Nicht angemeldet")) self.login_knopf.setVisible(not angemeldet) self.logout_knopf.setVisible(angemeldet) - for widget in (self.filter_feld, self.combo, self.ogc_knopf, self.stil_knopf, - self.vorlage_haken, self.upload_knopf, *self.gruppen_haken.values(), - *self.datensatz_knoepfe): + for widget in (self.filter_feld, self.combo, self.ogc_knopf, self.speichern_knopf, + self.stil_knopf, self.vorlage_haken, *self.gruppen_haken.values()): widget.setEnabled(angemeldet) if not angemeldet: self._stumm = True diff --git a/vln_karten/plugin.py b/vln_karten/plugin.py index f139803..4348b23 100644 --- a/vln_karten/plugin.py +++ b/vln_karten/plugin.py @@ -18,14 +18,6 @@ from qgis.core import Qgis, QgsDataSourceUri, QgsProject from .api_client import ApiError, AuthError, KartenApiClient from .panel import VlnPanel -from .layer_manager import ( - PROP_DATASET, - PROP_VERFAHREN, - feature_collection_to_layers, - layer_api_path, - layers_to_feature_collection, - plugin_layers, -) from .login_dialog import LoginDialog, SETTINGS_GROUP from . import ogc_layers @@ -35,36 +27,6 @@ MENU_TITLE = "&VLN Karten" PROJECT_SCOPE = "vln_karten" PROJECT_KEY_VKZ = "/vkz" -# Karten-Layer der VLN-Manager-API (map.php). Weitere Layer (st, we) -# wären je ein Eintrag mehr. {vkz} = Verfahrenskennzeichen der TG. -DATASETS = { - "umringe": { - "label": "Verfahrensgebiet", - "path": "/maps/umringe/{vkz}", - "geometry": "MultiPolygon", - }, - "p41": { - "label": "Plan 41 (Wege- und Gewässerplan)", - "path": "/maps/p41/{vkz}", - "geometry": "MultiPolygon", - }, - # DB-Art KAS: enthält, was im Web-GIS über "in Datenbank speichern" - # abgelegt wurde — kann je VKZ leer sein, solange dort noch nichts - # gespeichert wurde. - "kas": { - "label": "Karte alter Stand (KAS)", - "path": "/maps/kas/{vkz}", - "geometry": "MultiPolygon", - }, - # DB-Art WE: Wertermittlung (Web-GIS-Modul we.js). - "we": { - "label": "Wertermittlung (WE)", - "path": "/maps/we/{vkz}", - "geometry": "MultiPolygon", - }, -} - - class VlnKartenPlugin: def __init__(self, iface): self.iface = iface @@ -77,9 +39,9 @@ class VlnKartenPlugin: # ------------------------------------------------------------------ def initGui(self): - # Seitenleiste statt Werkzeugleiste (Erik, 08.09.2026). Die OGC-Knöpfe stehen oben: - # sie laden native, editierbare OAPIF-Layer, die QGIS selbst zurückschreibt (Part 4). - # Die GeoJSON-Knöpfe darunter sind der klassische Weg (Kopie laden, später hochladen). + # Seitenleiste statt Werkzeugleiste (Erik, 08.09.2026). Geladen und gespeichert wird + # ausschließlich über die OGC API: die Layer sind nativ editierbar, QGIS schreibt jede + # Änderung einzeln zurück (Part 4). Der frühere GeoJSON-Weg ist entfallen. self.panel = VlnPanel( self.iface.mainWindow(), callbacks={ @@ -87,11 +49,9 @@ class VlnKartenPlugin: "logout": self.run_logout, "verfahren_gewaehlt": self.on_verfahren_changed, "load_ogc": self.run_load_ogc, + "save_edits": self.run_save_edits, "save_style": self.run_save_style, - "load_dataset": self.run_load_dataset, - "upload": self.run_upload_active_layer, }, - datensaetze=[(key, dataset["label"]) for key, dataset in DATASETS.items()], ) self.iface.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.panel) @@ -250,149 +210,6 @@ class VlnKartenPlugin: # Laden & Hochladen # ------------------------------------------------------------------ - def run_load_dataset(self, dataset_key): - if not self._ensure_logged_in(): - return - vkz = self._current_vkz() - if not vkz: - self._show_error( - "Kein Verfahren gewählt", - "Bitte zuerst in der Auswahlliste ein Verfahren wählen.", - ) - return - - dataset = DATASETS[dataset_key] - if not self._confirm_replace_existing(dataset_key, vkz, dataset["label"]): - return - - path = dataset["path"].format(vkz=vkz) - try: - # Vollständig aus KARTE_OBJEKT: Listen-Endpunkt mit Paging - # (wie das Web-GIS, aber ohne 2000er-Limit). - feature_collection = self.client.load_layer_complete(dataset_key, vkz) - layers = feature_collection_to_layers( - feature_collection, - "%s %s" % (dataset["label"], vkz), - dataset["geometry"], - dataset_key, - vkz, - path, - ) - except AuthError: - self._handle_session_expired() - return - except (ApiError, RuntimeError) as exc: - self._show_error("Laden fehlgeschlagen", str(exc)) - return - - total = sum(layer.featureCount() for layer in layers) - if total == 0: - self.iface.messageBar().pushMessage( - "VLN Karten", - "Der Server hat für VKZ %s keine Objekte im Layer " - "'%s' — es wurde ein leerer Layer zum Digitalisieren " - "angelegt." % (vkz, dataset["label"]), - level=Qgis.Warning, - duration=10, - ) - return - - detail = ", ".join( - "%s: %d" % (layer.name(), layer.featureCount()) for layer in layers - ) - self.iface.messageBar().pushMessage( - "VLN Karten", - "%d Layer geladen (%s)." % (len(layers), detail), - level=Qgis.Success, - duration=5, - ) - - def _confirm_replace_existing(self, dataset_key, vkz, label): - """Ersetzt bereits geladene Layer dieses Datensatzes nach - Rückfrage — doppelte Layer-Sätze würden beim Hochladen alle - Objekte dupliziert an den Server schicken.""" - existing = plugin_layers(dataset_key, vkz) - if not existing: - return True - answer = QMessageBox.question( - self.iface.mainWindow(), - "Layer ersetzen", - "%s für VKZ %s ist bereits im Projekt geladen (%s).\n" - "Beim Neuladen werden diese Layer ersetzt — nicht " - "hochgeladene Änderungen gehen verloren. Fortfahren?" - % (label, vkz, ", ".join(layer.name() for layer in existing)), - ) - if answer != QMessageBox.StandardButton.Yes: - return False - QgsProject.instance().removeMapLayers([layer.id() for layer in existing]) - return True - - def run_upload_active_layer(self): - if not self._ensure_logged_in(): - return - - active = self.iface.activeLayer() - path = layer_api_path(active) if active else None - if not path: - self._show_error( - "Hochladen nicht möglich", - "Der aktive Layer wurde nicht über dieses Plugin geladen. " - "Bitte einen über 'VLN Karten' geladenen Layer auswählen.", - ) - return - - # Der PUT ersetzt den kompletten Layer-Bestand der VKZ auf dem - # Server. Deshalb werden immer alle Teil-Layer dieses Datensatzes - # (Punkte/Linien/Flächen) gemeinsam hochgeladen — nur den aktiven - # zu senden, würde die anderen Geometrietypen serverseitig löschen. - dataset_key = active.customProperty(PROP_DATASET) - vkz = active.customProperty(PROP_VERFAHREN) - siblings = plugin_layers(dataset_key, vkz) - - for layer in siblings: - if layer.isEditable() and not layer.commitChanges(): - self._show_error( - "Hochladen nicht möglich", - "Die offene Bearbeitungssitzung von '%s' konnte nicht " - "gespeichert werden." % layer.name(), - ) - return - - dataset = DATASETS.get(dataset_key, {}) - total = sum(layer.featureCount() for layer in siblings) - listing = "\n".join( - "• %s (%d Objekte)" % (layer.name(), layer.featureCount()) - for layer in siblings - ) - answer = QMessageBox.question( - self.iface.mainWindow(), - "Zum Server hochladen", - "%s für VKZ %s übertragen?\n\n%s\n\n" - "Die Layer werden zusammengeführt (%d Objekte); der bisherige " - "Bestand dieser VKZ auf dem Server wird ersetzt." - % (dataset.get("label", dataset_key), vkz, listing, total), - ) - if answer != QMessageBox.StandardButton.Yes: - return - - try: - feature_collection = layers_to_feature_collection(siblings) - self.client.save_feature_collection(path, feature_collection) - except AuthError: - self._handle_session_expired() - return - except ApiError as exc: - self._show_error("Hochladen fehlgeschlagen", str(exc)) - return - - self.iface.messageBar().pushMessage( - "VLN Karten", - "%d Objekte aus %d Layer(n) erfolgreich hochgeladen." - % (total, len(siblings)), - level=Qgis.Success, - duration=5, - ) - def run_load_ogc(self): """Alle Sammlungen des gewählten Verfahrens (Verfahrens-Layer, Vorerhebung, Maßnahmenblätter) als OAPIF-Layer laden — editierbar in QGIS, Rückschreiben je Feature.""" @@ -440,6 +257,37 @@ class VlnKartenPlugin: self.iface, self.client.base_url, vkz, self.panel.aktueller_text(), collections, authcfg, api_key=None if authcfg else self.client.api_key) + def run_save_edits(self): + """Offene Bearbeitungssitzungen aller geladenen VLN-Layer schließen. + + Über die OGC API schreibt QGIS jede Änderung einzeln zurück (Part 4) — dieser Knopf + beendet nur die Bearbeitung, so wie „Layeränderungen speichern" in QGIS, aber für + alle Layer des Verfahrens auf einmal. + """ + layer = [l for l in QgsProject.instance().mapLayers().values() + if getattr(l, "providerType", None) and l.providerType() == "OAPIF"] + offen = [l for l in layer if l.isEditable()] + if not offen: + self.iface.messageBar().pushMessage( + "VLN Karten", "Keine offene Bearbeitung — es gibt nichts zu speichern.", + level=Qgis.Info, duration=5) + return + gespeichert, fehler = [], [] + for l in offen: + if l.commitChanges(): + gespeichert.append(l.name()) + else: + fehler.append("%s: %s" % (l.name(), "; ".join(l.commitErrors())[:120])) + if fehler: + self._show_error("Nicht alles gespeichert", "\n".join(fehler)) + if gespeichert: + self.iface.messageBar().pushMessage( + "VLN Karten", "%d Layer gespeichert (%s)." % (len(gespeichert), + ", ".join(gespeichert[:4])), + level=Qgis.Success, duration=6) + if self.panel is not None: + self.panel.set_status("%d von %d Layern gespeichert." % (len(gespeichert), len(offen))) + def run_save_style(self): """Den Stil des aktiven OGC-Layers auf den Server zurückschreiben.