2026-09-07 09:47:55 +02:00
|
|
|
"""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: ``<layer>-<vkz>`` (umringe, p41, st, kas, we, ngg, biotop = jüngster Stand wie
|
|
|
|
|
im Web-GIS), ``vorerhebung-<vkz>-<key>`` (je Vorerhebungsprojekt, schreibbar nur im Status
|
|
|
|
|
„bearbeitung"), ``massnahmeblatt-<vkz>`` (je Lage-Geometrie eines Maßnahmenblatts ein Feature,
|
|
|
|
|
Blatt-Spalten als ``blatt_*``-Attribute; neue Features brauchen ``blatt_uuid`` oder
|
|
|
|
|
``blatt_typ`` + ``blatt_name``).
|
|
|
|
|
"""
|
|
|
|
|
|
2026-09-07 21:07:54 +02:00
|
|
|
import os
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
from qgis.PyQt.QtCore import QSettings, QUrl, Qt, QTimer
|
|
|
|
|
from qgis.PyQt.QtNetwork import QNetworkRequest
|
|
|
|
|
from qgis.PyQt.QtWidgets import QProgressBar
|
2026-09-07 09:47:55 +02:00
|
|
|
from qgis.core import (
|
|
|
|
|
Qgis,
|
|
|
|
|
QgsApplication,
|
|
|
|
|
QgsAuthMethodConfig,
|
|
|
|
|
QgsDataSourceUri,
|
|
|
|
|
QgsProject,
|
|
|
|
|
QgsVectorLayer,
|
|
|
|
|
)
|
|
|
|
|
|
2026-09-07 21:07:54 +02:00
|
|
|
from qgis.core import QgsBlockingNetworkRequest
|
|
|
|
|
|
|
|
|
|
from .api_client import ApiError, AuthError
|
2026-09-07 09:47:55 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 21:07:54 +02:00
|
|
|
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"))
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 18:32:35 +02:00
|
|
|
def layer_uri(base_url, cid, authcfg=None, storage_crs=None, api_key=None):
|
2026-09-07 09:47:55 +02:00
|
|
|
"""Die OAPIF-Datenquelle: Landing-URL + Sammlungs-ID (+ Authentifizierung).
|
|
|
|
|
|
2026-09-07 18:32:35 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-09-07 09:47:55 +02:00
|
|
|
``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)
|
2026-09-07 18:32:35 +02:00
|
|
|
elif api_key:
|
|
|
|
|
uri.setUsername("apikey")
|
|
|
|
|
uri.setPassword(api_key)
|
2026-09-07 09:47:55 +02:00
|
|
|
return uri.uri(False)
|
|
|
|
|
|
|
|
|
|
|
2026-09-07 18:32:35 +02:00
|
|
|
def layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, api_key=None):
|
2026-09-07 21:07:54 +02:00
|
|
|
"""Alle Sammlungen als OAPIF-Layer in die Gruppe „VLN <Verfahren>" hängen — **schrittweise**.
|
2026-09-07 09:47:55 +02:00
|
|
|
|
2026-09-07 21:07:54 +02:00
|
|
|
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.
|
2026-09-07 09:47:55 +02:00
|
|
|
"""
|
|
|
|
|
projekt = QgsProject.instance()
|
|
|
|
|
wurzel = projekt.layerTreeRoot()
|
2026-09-07 21:07:54 +02:00
|
|
|
gruppe_name = ("VLN %s %s" % (vkz, vkz_name or "")).strip()
|
|
|
|
|
gruppe = wurzel.findGroup(gruppe_name) or wurzel.insertGroup(0, gruppe_name)
|
2026-09-07 09:47:55 +02:00
|
|
|
untergruppen = {}
|
2026-09-07 21:07:54 +02:00
|
|
|
|
|
|
|
|
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 geladen …")
|
|
|
|
|
meldung.layout().addWidget(balken)
|
|
|
|
|
iface.messageBar().pushWidget(meldung, Qgis.Info)
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-07 09:47:55 +02:00
|
|
|
cid = str(coll.get("id") or "")
|
2026-09-07 21:07:54 +02:00
|
|
|
gruppen_name = dict(GRUPPEN).get(cid.split("-", 1)[0], "Weitere")
|
2026-09-07 09:47:55 +02:00
|
|
|
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)
|
2026-09-07 21:07:54 +02:00
|
|
|
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
|