- 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>
372 lines
17 KiB
Python
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
|