Files
API_Karte_QGISDemo/vln_karten/ogc_layers.py
T
KAIandClaude Opus 5.5 a56bf7c397 Fassung 0.7.0: Landkreis-Linkliste, oFB liest nur, Untergruppen, 403 meldet nicht ab
- Auswahl über GET /tgen/auswahl (Manager seit 06.10.2026): oben die eigenen Landkreise
  („… — Linkliste des Landkreises“), dann die Verfahren; als oFB die Verfahren des Landkreises
  mit „(nur lesen)“. Älterer Server ohne die Route: Rückfall auf /tgen.
- Landkreis gewählt: „Liste holen“ lädt die Linkliste des ganzen Landkreises; „Layer laden“
  erklärt, dass es für einen Landkreis keine Verfahrenslayer gibt.
- 401 und 403 getrennt: nur 401 (AuthError) meldet ab, 403 (KeinZugriff) zeigt eine Meldung.
  Bis 0.6.0 verwarf jedes 403 den gespeicherten API-Key.
- OGC-Sammlungen mit vln:schreibbar: false werden schreibgeschützt geladen; Meldungen für Stil
  und Verkehrszeichen nennen „nur lesen“ als Grund.
- Linkliste: Gruppe › Untergruppe › Layer im Ebenenbaum (linkliste.zielgruppe), Untergruppe
  auch in der Dienstliste.
- README: Abschnitt „Geodienste aus der Linkliste“; Tests ohne Netz:
  tools/headless_linkliste_test.py (neu), tools/headless_panel_test.py erweitert — beide grün
  mit QGIS 4.2 offscreen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 22:44:36 +02:00

372 lines
17 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; seit
06.10.2026 auch die oFB des Landkreises, aber nur lesend). ``vln:schreibbar: false`` an einer
Sammlung heißt: der Layer wird schreibgeschützt geladen.
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 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"),
# Die neuen Zeichen am Weg gehoeren zum Blatt, also in dessen Gruppe.
("blattzeichen", "Maßnahmenblätter"),
("widmung", "Widmungen & Verkehrszeichen"),
("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 "?"
roh = bytes(antwort.content()).decode("utf-8", "replace")
# Schildgrafiken aus der lokalen Ablage nehmen, wo sie liegt (s. zeichen_katalog).
try:
from . import zeichen_katalog as zk
roh, _ = zk.stil_lokalisieren(roh, base_url)
except Exception: # noqa: BLE001
pass
pfad = os.path.join(tempfile.gettempdir(), "vln_%s.qml" % cid.replace("*", "_"))
with open(pfad, "w", encoding="utf-8") as fh:
fh.write(roh)
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: im Verfahren nur lesend
(oFB) oder — bei einer Vorlage — die 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: den Stil eines Verfahrens speichert nur, wer dort "
"schreiben darf (nicht bei „nur lesen“); eine Vorlage für alle Verfahren "
"nur die Gruppe MODUL_KARTENSTIL oder der Verband.")
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 _schilder_bereitstellen(client, layer):
"""Die Schildgrafiken der geladenen Zeichen lokal ablegen (best effort).
Ohne lokale Dateien zeichnet QGIS die Schilder aus dem Netz — im Druck und offline
bleibt die Karte dann leer (Erik, 21.09.2026: „lokal ablegen, URL als Rückfall").
"""
try:
from . import zeichen_katalog as zk
codes = zk.codes_im_layer(layer)
if not codes:
return
zk.pfad_registrieren()
zk.sichere_svgs(client, codes)
except Exception: # noqa: BLE001
pass # Beiwerk, kein Abbruch
def layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, api_key=None,
client=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 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():
if coll.get("vln:schreibbar") is False:
# Nur lesen (oFB des Landkreises, Manager seit 06.10.2026): QGIS soll kein
# Bearbeiten anbieten, das erst beim Speichern am Server scheitert.
layer.setReadOnly(True)
# Verkehrszeichen brauchen ihre Grafiken, BEVOR der Stil greift — sonst zeigt der
# Ausdruck auf eine Datei, die es lokal nicht gibt, und QGIS malt ein Fragezeichen.
if cid.startswith(("widmung-", "blattzeichen-")) and client is not None:
_schilder_bereitstellen(client, layer)
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