Files
API_Karte_QGISDemo/vln_karten/ogc_layers.py
T
ErikandClaude Opus 4.8 4504f9a34c Stile: beim Laden vom Server anwenden, Knopf zum Zurueckspeichern (Gruppe MODUL_KARTENSTIL)
- ogc_layers.stil_anwenden(): GET …/style.qml, in eine Temp-Datei, loadNamedStyle; scheitert
  der Abruf, bleibt die QGIS-Zufallsfarbe (kein Abbruch des Ladens).
- ogc_layers.stil_speichern(): saveNamedStyle -> PUT; 403 meldet die fehlende Gruppe im Klartext.
- plugin.run_save_style(): Knopf 'Stil des aktiven Layers speichern'; mit Umschalttaste als
  Vorlage der Art (gilt fuer alle Verfahren, z. B. Karte alter Stand).
- README: Abschnitt Stile samt Routen und Berechtigung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 21:07:54 +02:00

268 lines
12 KiB
Python

"""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``).
"""
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
from qgis.core import (
Qgis,
QgsApplication,
QgsAuthMethodConfig,
QgsDataSourceUri,
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 layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, api_key=None):
"""Alle Sammlungen als OAPIF-Layer in die Gruppe „VLN <Verfahren>" 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 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
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