"""OGC API Features (``/v2/ogc``) — alle Karten eines Verfahrens als **native, editierbare** QGIS-Layer laden. Seit dem 07.09.2026 bietet die API OGC API Features (Part 1 Core, Part 2 CRS, Part 4 CRUD) unter ``https://api.flurneuordnung-sachsen.de/v2/ogc``. QGIS kann das ohne Plugin: der Datenanbieter ``OAPIF`` liest die Sammlungen, und im Bearbeitungsmodus schickt QGIS jede Änderung als eigene Anfrage (POST/PUT/PATCH/DELETE je Feature) — kein Vollersetzen mehr, kein Geometrie-Splitting, kein Memory-Layer. Dieses Modul zeigt den Weg aus PyQGIS: 1. **Authentifizierung**: nach dem Login (``POST /person/login``, userauth = API-Key) wird eine QGIS-Authentifizierungskonfiguration der Methode ``APIHeader`` angelegt, die den Header ``X-API-Key`` mitschickt. Das Passwort landet nie in QGIS. Alternative ohne Plugin: Methode ``Basic`` mit dem Manager-Login (E-Mail + Passwort) — die API nimmt beides. 2. **Sammlungen** des Verfahrens holen (``GET /ogc/collections?vkz=…``) — die API listet nur, was der Nutzer sehen darf (Vorsitz/Kassenbetreuer/Stellvertreter der TG; Verband alles). 3. Je Sammlung einen ``QgsVectorLayer(uri, titel, "OAPIF")`` in einer Layer-Gruppe anlegen. Sammlungs-IDs: ``-`` (umringe, p41, st, kas, we, ngg, biotop = jüngster Stand wie im Web-GIS), ``vorerhebung--`` (je Vorerhebungsprojekt, schreibbar nur im Status „bearbeitung"), ``massnahmeblatt-`` (je Lage-Geometrie eines Maßnahmenblatts ein Feature, Blatt-Spalten als ``blatt_*``-Attribute; neue Features brauchen ``blatt_uuid`` oder ``blatt_typ`` + ``blatt_name``). """ import os import tempfile from qgis.PyQt.QtCore import QEventLoop, QSettings, QUrl, Qt, QTimer from qgis.PyQt.QtNetwork import QNetworkRequest from qgis.PyQt.QtWidgets import QProgressBar from qgis.core import ( Qgis, QgsApplication, QgsAuthMethodConfig, QgsDataSourceUri, QgsNetworkAccessManager, QgsProject, QgsVectorLayer, ) from qgis.core import QgsBlockingNetworkRequest from .api_client import ApiError, AuthError AUTHCFG_NAME = "VLN Karten (API-Key)" SETTINGS_AUTHCFG = "ogc_authcfg" #: Reihenfolge und Gruppen im Layerbaum (Sammlungs-Präfix -> Gruppenname). GRUPPEN = [ ("vorerhebung", "Vorerhebung"), ("massnahmeblatt", "Maßnahmenblätter"), ("umringe", "Verfahren"), ("p41", "Verfahren"), ("ngg", "Verfahren"), ("st", "Verfahren"), ("kas", "Verfahren"), ("we", "Verfahren"), ("biotop", "Verfahren"), ] def ogc_url(base_url): """Landing Page der OGC API aus der API-Basis (``…/v2`` -> ``…/v2/ogc``).""" return base_url.rstrip("/") + "/ogc" def authcfg_sicherstellen(api_key, settings_group): """Authentifizierungskonfiguration ``APIHeader`` (Header ``X-API-Key``) anlegen oder aktualisieren; ihre ID wird in den Plugin-Einstellungen gemerkt. QGIS verlangt dafür ein Master-Passwort für seine Authentifizierungsdatenbank — beim ersten Mal fragt QGIS danach. Ohne Konfiguration (Abbruch) gibt es ``None`` zurück; dann fällt der Aufrufer auf die Basic-Anmeldung zurück, die QGIS beim ersten 401 selbst abfragt. """ manager = QgsApplication.authManager() if manager is None or manager.isDisabled(): return None settings = QSettings() settings.beginGroup(settings_group) bekannt = settings.value(SETTINGS_AUTHCFG, "") settings.endGroup() cfg = QgsAuthMethodConfig() if bekannt and manager.loadAuthenticationConfig(bekannt, cfg, True) and cfg.isValid(): cfg.setConfigMap({"X-API-Key": api_key}) if not manager.updateAuthenticationConfig(cfg): return None return bekannt cfg = QgsAuthMethodConfig("APIHeader") cfg.setName(AUTHCFG_NAME) cfg.setConfigMap({"X-API-Key": api_key}) if not manager.storeAuthenticationConfig(cfg, True): return None settings.beginGroup(settings_group) settings.setValue(SETTINGS_AUTHCFG, cfg.id()) settings.endGroup() return cfg.id() def collections_laden(client, vkz): """``GET /ogc/collections?vkz=…`` -> Liste ``[{id, title, description, storageCrs, …}]``.""" data = client._request("GET", "/ogc/collections?vkz=%s" % vkz) rows = data.get("collections") if isinstance(data, dict) else None if not isinstance(rows, list): raise ApiError("Antwort von /ogc/collections hat ein unerwartetes Format.") return rows def stil_url(base_url, cid): """Adresse des QGIS-Stils einer Sammlung (QML) auf dem Server.""" return "%s/collections/%s/style.qml" % (ogc_url(base_url), cid) def stil_anwenden(layer, base_url, cid, api_key=None, authcfg=None): """Den Stil der Sammlung vom Server holen und auf den Layer anwenden. Der Server liefert immer etwas: den fuer die Sammlung gespeicherten Stil, sonst die Vorlage der Art, sonst einen erzeugten Standard (kategorisiert nach ``typ``). Schlaegt der Abruf fehl, behaelt der Layer die QGIS-Zufallsfarbe — kein Grund, das Laden abzubrechen. :returns: ``(True, quelle)`` oder ``(False, Meldung)``. """ req = QNetworkRequest(QUrl(stil_url(base_url, cid))) if api_key: req.setRawHeader(b"X-API-Key", api_key.encode("utf-8")) blk = QgsBlockingNetworkRequest() if authcfg: blk.setAuthCfg(authcfg) if blk.get(req) != QgsBlockingNetworkRequest.NoError: return False, blk.errorMessage() antwort = blk.reply() if antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) != 200: return False, "HTTP %s" % antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) quelle = bytes(antwort.rawHeader(b"X-Stil-Quelle")).decode() or "?" pfad = os.path.join(tempfile.gettempdir(), "vln_%s.qml" % cid.replace("*", "_")) with open(pfad, "wb") as fh: fh.write(bytes(antwort.content())) meldung, ok = layer.loadNamedStyle(pfad) if ok: layer.triggerRepaint() return bool(ok), quelle if ok else meldung def stil_speichern(layer, base_url, cid, api_key=None, authcfg=None, vorlage=False): """Den aktuellen Layer-Stil als QML an den Server schicken (PUT …/style.qml). :param vorlage: als Vorlage der Art speichern — gilt dann fuer alle Verfahren. :returns: ``(True, Text)`` oder ``(False, Meldung)``; 403 heisst: Gruppe MODUL_KARTENSTIL fehlt. """ pfad = os.path.join(tempfile.gettempdir(), "vln_upload_%s.qml" % cid.replace("*", "_")) meldung = layer.saveNamedStyle(pfad) if not os.path.exists(pfad): return False, "QGIS konnte den Stil nicht schreiben: %s" % meldung with open(pfad, "rb") as fh: rumpf = fh.read() url = stil_url(base_url, cid) + ("?vorlage=1" if vorlage else "") req = QNetworkRequest(QUrl(url)) req.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/x-qgis-layer-settings") if api_key: req.setRawHeader(b"X-API-Key", api_key.encode("utf-8")) blk = QgsBlockingNetworkRequest() if authcfg: blk.setAuthCfg(authcfg) blk.put(req, rumpf) antwort = blk.reply() code = antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) if code == 200: return True, "%d Zeichen gespeichert%s" % (len(rumpf), " (Vorlage der Art)" if vorlage else "") if code == 403: return False, ("Keine Berechtigung: Stile darf nur speichern, wer in der Gruppe " "MODUL_KARTENSTIL ist (oder Verbandsmitarbeiter).") if code in (401, None): raise AuthError("Anmeldung abgelaufen") return False, "Server antwortete mit HTTP %s: %s" % (code, bytes(antwort.content())[:200].decode("utf-8", "replace")) def layer_uri(base_url, cid, authcfg=None, storage_crs=None, api_key=None): """Die OAPIF-Datenquelle: Landing-URL + Sammlungs-ID (+ Authentifizierung). Ohne ``authcfg`` (keine Authentifizierungs-DB bzw. Master-Passwort abgelehnt) tragen Benutzer ``apikey`` + der API-Key als Passwort die Anmeldung — die API nimmt den Key auch als HTTP-Basic-Passwort. Achtung: dann steht der Key in der Datenquelle und damit in einer gespeicherten Projektdatei; die Authentifizierungs-DB ist der sichere Weg. ``srsname`` auf das Speicher-CRS der Sammlung: EPSG:25833 für die Verfahrens-Layer, CRS84 (= EPSG:4326 lon/lat) für Vorerhebung und Maßnahmenblätter — so rechnet QGIS nichts hin und her, und die Koordinaten bleiben, wie die API sie speichert. """ uri = QgsDataSourceUri() uri.setParam("url", ogc_url(base_url)) uri.setParam("typename", cid) uri.setParam("pagingEnabled", "true") uri.setParam("pageSize", "1000") uri.setParam("restrictToRequestBBOX", "1") if storage_crs and "25833" in storage_crs: uri.setParam("srsname", "EPSG:25833") if authcfg: uri.setAuthConfigId(authcfg) elif api_key: uri.setUsername("apikey") uri.setPassword(api_key) return uri.uri(False) def vorwaermen(base_url, collections, api_key=None, authcfg=None, gleichzeitig=8): """Die Antworten, die der Provider gleich braucht, vorab und **gleichzeitig** holen. Der OAPIF-Provider fragt je Layer nacheinander Landing Page, OpenAPI, Conformance, Sammlung, Schema und (über das Plugin) den Stil ab — bei 27 Sammlungen über 160 Anfragen, jede einzeln und blockierend. Alle diese Antworten tragen serverseitig ``Cache-Control``, liegen also nach einem Vorablauf im Plattencache von QGIS und kosten den Provider danach kein Netz mehr. Gemessen 08.09.2026 (Berbisdorf, 27 Sammlungen): 30,7 s ohne, 14,9 s mit Vorwärmen; das Vorwärmen selbst dauert unter einer Sekunde. Bewusst **kein** paralleles Anlegen der Layer: ein in einem Python-Thread erzeugter OAPIF-Layer stürzt beim ersten ``getFeatures()`` ab (Segfault, gemessen) — der Provider gehört dem Thread, der ihn gebaut hat. Hier laufen nur reine Netzanfragen nebeneinander, über Qts eigene Ereignisschleife im selben Thread. :returns: Anzahl der vorgeladenen Antworten. """ ziele = [ogc_url(base_url), ogc_url(base_url) + "/api", ogc_url(base_url) + "/conformance"] for c in collections or []: cid = str((c or {}).get("id") or "") if cid: ziele.append("%s/collections/%s" % (ogc_url(base_url), cid)) ziele.append("%s/collections/%s/schema" % (ogc_url(base_url), cid)) ziele.append(stil_url(base_url, cid)) verwalter = QgsNetworkAccessManager.instance() geholt = 0 for anfang in range(0, len(ziele), gleichzeitig): haufen = ziele[anfang:anfang + gleichzeitig] schleife = QEventLoop() offen = {"n": len(haufen)} def fertig(_antwort=None): offen["n"] -= 1 if offen["n"] <= 0: schleife.quit() antworten = [] for url in haufen: req = QNetworkRequest(QUrl(url)) if api_key: req.setRawHeader(b"X-API-Key", api_key.encode("utf-8")) if authcfg: QgsApplication.authManager().updateNetworkRequest(req, authcfg) req.setAttribute(QNetworkRequest.Attribute.CacheSaveControlAttribute, True) antwort = verwalter.get(req) antwort.finished.connect(fertig) antworten.append(antwort) schleife.exec() for antwort in antworten: antwort.readAll() # Rumpf lesen, damit er im Cache landet antwort.deleteLater() geholt += 1 return geholt def layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, api_key=None): """Alle Sammlungen als OAPIF-Layer in die Gruppe „VLN " hängen — **schrittweise**. Warum nicht in einer Schleife: der OAPIF-Provider stellt beim Anlegen jedes Layers mehrere **blockierende** Netzanfragen (Landing Page, OpenAPI, Conformance, Collection, Items, Schema, zweimal OPTIONS). Bei rund 30 Sammlungen friert die Oberfläche dabei minutenlang ein und sieht aus wie abgestürzt (Erik, 07.09.2026). Darum wird je Ereignisschleifen-Durchlauf genau EIN Layer angelegt: QGIS bleibt bedienbar, der Fortschritt steht in der Meldungsleiste. :returns: ``QProgressBar``-Träger (die Meldung) — der Aufrufer muss nichts damit tun. """ projekt = QgsProject.instance() wurzel = projekt.layerTreeRoot() gruppe_name = ("VLN %s %s" % (vkz, vkz_name or "")).strip() gruppe = wurzel.findGroup(gruppe_name) or wurzel.insertGroup(0, gruppe_name) untergruppen = {} balken = QProgressBar() balken.setMaximum(len(collections)) balken.setValue(0) balken.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) meldung = iface.messageBar().createMessage("VLN Karten", "Layer werden vorbereitet …") meldung.layout().addWidget(balken) iface.messageBar().pushWidget(meldung, Qgis.Info) # Erst die Antworten gleichzeitig in den Plattencache holen (unter einer Sekunde), dann # bauen — das halbiert die Ladezeit, weil der Provider danach kaum noch ans Netz muss. try: vorwaermen(base_url, collections, api_key=api_key, authcfg=authcfg) except Exception: # noqa: BLE001 pass # Vorwärmen ist Beiwerk meldung.setText("Layer werden geladen …") zustand = {"i": 0, "geladen": 0, "fehler": []} def schritt(): if zustand["i"] >= len(collections): iface.messageBar().popWidget(meldung) text = "%d Layer geladen — zum Bearbeiten den Layer in den Bearbeitungsmodus " \ "schalten; jede gespeicherte Änderung geht sofort an die API." % zustand["geladen"] if zustand["fehler"]: text += " %d Sammlung(en) nicht ladbar: %s" % ( len(zustand["fehler"]), ", ".join(f[0] for f in zustand["fehler"][:3])) iface.messageBar().pushMessage( "VLN Karten", text, level=Qgis.Success if zustand["geladen"] else Qgis.Warning, duration=10) return coll = collections[zustand["i"]] zustand["i"] += 1 cid = str(coll.get("id") or "") gruppen_name = dict(GRUPPEN).get(cid.split("-", 1)[0], "Weitere") if gruppen_name not in untergruppen: untergruppen[gruppen_name] = gruppe.findGroup(gruppen_name) or gruppe.addGroup(gruppen_name) titel = str(coll.get("title") or cid) layer = QgsVectorLayer(layer_uri(base_url, cid, authcfg, coll.get("storageCrs"), api_key), titel, "OAPIF") if layer.isValid(): stil_anwenden(layer, base_url, cid, api_key, authcfg) # Darstellung wie im Web-GIS projekt.addMapLayer(layer, False) untergruppen[gruppen_name].addLayer(layer) zustand["geladen"] += 1 else: zustand["fehler"].append((cid, layer.error().message() if layer.error() else "ungültig")) balken.setValue(zustand["i"]) meldung.setText("Layer werden geladen … %d von %d (%s)" % (zustand["i"], len(collections), titel)) QTimer.singleShot(0, schritt) # zurück in die Ereignisschleife: QGIS bleibt bedienbar QTimer.singleShot(0, schritt) return meldung