Files
API_Karte_QGISDemo/vln_karten/api_client.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

303 lines
12 KiB
Python

"""HTTP-Client für die VLN-Manager-API.
Basis-URL: https://api.flurneuordnung-sachsen.de/v2
POST /person/login
Body: {"mail": "...", "password": "..."}
Antwort: {"data": {"userauth": "<url_token>", "id": ...}}
Das userauth-Token ist der API-Key (= PERSON.url_token).
GET /tgen/auswahl -> {"verfahren": [{vkz, name, zugriff}], "aemter": [{vkz, name}]}
Eigene Verfahren ("voll") und — als oFB — die Verfahren der eigenen Landkreise
("lesen"), dazu die Landkreise selbst (Manager seit 06.10.2026; Rückfall: GET /tgen).
GET /ogc/collections?vkz=… -> Sammlungen (s. ogc_layers.py), ``vln:schreibbar`` je Sammlung
GET /linkliste?vkz=… -> Geodienste eines Verfahrens oder Landkreises (s. linkliste.py)
Auth über den Header "X-API-Key". Fehlerformat: RFC 7807 (application/problem+json).
401 = nicht (mehr) angemeldet (AuthError), 403 = angemeldet, aber nicht berechtigt
(KeinZugriff) — nur 401 führt zum Abmelden.
Es wird QgsBlockingNetworkRequest verwendet, damit Proxy- und
Zertifikatseinstellungen aus QGIS automatisch greifen.
"""
import json
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.core import QgsBlockingNetworkRequest
# Fest verdrahtete Produktiv-API des VLN Managers.
DEFAULT_BASE_URL = "https://api.flurneuordnung-sachsen.de/v2"
class ApiError(Exception):
"""Fehler bei der Kommunikation mit der Karten-API.
:ivar status: HTTP-Status, wenn der Server geantwortet hat (sonst ``None``).
"""
def __init__(self, nachricht="", status=None):
super().__init__(nachricht)
self.status = status
class AuthError(ApiError):
"""Nicht (mehr) angemeldet: API-Key fehlt oder ist abgelaufen (401) — dann neu anmelden."""
class KeinZugriff(ApiError):
"""Angemeldet, aber für diese Aktion nicht berechtigt (403).
Kein Grund abzumelden: eine oFB liest z. B. die Verfahren ihres Landkreises, darf dort aber
nicht schreiben. Bis 0.6.0 meldete jedes 403 ab und verwarf den gespeicherten API-Key.
"""
def fehler_fuer_status(status, text):
"""Die passende Ausnahme für einen HTTP-Fehlerstatus.
:returns: ``AuthError`` (401), ``KeinZugriff`` (403), sonst ``ApiError`` — mit ``status``.
"""
if status == 401:
return AuthError(text, status)
if status == 403:
return KeinZugriff(text, status)
return ApiError(text, status)
class KartenApiClient:
def __init__(self, base_url=DEFAULT_BASE_URL, api_key=None, mail=None):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.mail = mail
@property
def is_authenticated(self):
return bool(self.api_key)
def login(self, mail, password):
"""Meldet den Benutzer an. Die API liefert das userauth-Token
(= API-Key), das für alle weiteren Requests verwendet wird."""
data = self._request(
"POST",
"/person/login",
payload={"mail": mail, "password": password},
with_auth=False,
)
payload = data.get("data") if isinstance(data, dict) else None
if not isinstance(payload, dict):
payload = data if isinstance(data, dict) else {}
api_key = payload.get("userauth")
if not api_key:
raise ApiError(
"Login-Antwort enthielt kein userauth-Token. "
"Erhaltene Antwort: %s" % json.dumps(data)[:300]
)
self.api_key = api_key
self.mail = mail
return api_key
def logout(self):
self.api_key = None
self.mail = None
# ------------------------------------------------------------------
# Verfahren (Teilnehmergemeinschaften)
# ------------------------------------------------------------------
def get_auswahl(self):
"""Verfahren und Landkreise zur Auswahl — ``GET /tgen/auswahl``.
:returns: ``{"verfahren": [{vkz, name, zugriff}], "aemter": [{vkz, name}]}`` mit ``vkz``
als Text und ``zugriff`` ``"voll"`` oder ``"lesen"`` (eine oFB liest die Verfahren
ihres Landkreises). Ein älterer Server kennt die Route nicht (400/404) — dann
Rückfall auf :meth:`get_verfahren`, alles ``"voll"``, keine Landkreise.
"""
try:
data = self._request("GET", "/tgen/auswahl")
except ApiError as exc:
if getattr(exc, "status", None) in (400, 404):
return {"verfahren": [dict(v, zugriff="voll") for v in self.get_verfahren()],
"aemter": []}
raise
if not isinstance(data, dict) or not isinstance(data.get("verfahren"), list):
raise ApiError("Antwort von /tgen/auswahl hat ein unerwartetes Format: %s"
% json.dumps(data)[:200])
verfahren = [{"vkz": str(v.get("vkz")), "name": str(v.get("name") or ""),
"zugriff": "lesen" if v.get("zugriff") == "lesen" else "voll"}
for v in data["verfahren"] if isinstance(v, dict) and v.get("vkz")]
aemter = [{"vkz": str(a.get("vkz")), "name": str(a.get("name") or "")}
for a in (data.get("aemter") or []) if isinstance(a, dict) and a.get("vkz")]
return {"verfahren": verfahren, "aemter": aemter}
def get_verfahren(self):
"""Liste der Verfahren (TGs) als [{"vkz": ..., "name": ...}, ...],
sortiert nach VKZ. Quelle: GET /tgen (Rückfall für ältere Server)."""
data = self._request("GET", "/tgen")
rows = None
if isinstance(data, list):
rows = data
elif isinstance(data, dict):
for key in ("data", "rows"):
value = data.get(key)
if isinstance(value, list):
rows = value
break
if isinstance(value, dict) and isinstance(value.get("rows"), list):
rows = value["rows"]
break
if rows is None:
raise ApiError(
"Antwort von /tgen hat ein unerwartetes Format: %s"
% json.dumps(data)[:200]
)
verfahren = []
for row in rows:
if not isinstance(row, dict):
continue
vkz = row.get("vkz") or row.get("VKZ")
name = (
row.get("kurzname")
or row.get("Kurzname")
or row.get("name")
or row.get("Name")
or ""
)
if vkz:
verfahren.append({"vkz": str(vkz), "name": str(name)})
verfahren.sort(key=lambda v: v["vkz"])
return verfahren
# ------------------------------------------------------------------
# Karten-Layer (die konkreten Pfade stehen in plugin.DATASETS)
# ------------------------------------------------------------------
def load_feature_collection(self, path):
"""Lädt einen Layer als GeoJSON FeatureCollection (dict)."""
data = self._request("GET", path)
# Die Karten-Endpunkte liefern die FeatureCollection direkt,
# andere Routen verpacken sie in {"data": ...}.
if isinstance(data, dict):
if data.get("type") == "FeatureCollection":
return data
inner = data.get("data")
if isinstance(inner, dict) and inner.get("type") == "FeatureCollection":
return inner
raise ApiError(
"Antwort von %s ist keine GeoJSON FeatureCollection." % path
)
def roh(self, path, with_auth=False):
"""Eine Datei roh holen (Bytes) — fuer die Schildgrafiken und den Katalog.
Anders als :meth:`_request` wird hier nichts als JSON gelesen und keine Anmeldung
verlangt: ``/v2/zeichen/...`` ist oeffentlich, damit der Stil auch ohne Schluessel
zeichnet. Fehler geben ``b""`` zurueck — ein fehlendes Schild darf nichts abbrechen.
"""
from qgis.PyQt.QtCore import QUrl
from qgis.PyQt.QtNetwork import QNetworkRequest
request = QNetworkRequest(QUrl(self.base_url + path))
if with_auth and self.api_key:
request.setRawHeader(b"X-API-Key", self.api_key.encode("utf-8"))
blocking = QgsBlockingNetworkRequest()
if blocking.get(request) != QgsBlockingNetworkRequest.NoError:
return b""
antwort = blocking.reply()
status = antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
if status is not None and int(status) >= 400:
return b""
return bytes(antwort.content())
def _request(self, method, path, payload=None, with_auth=True, frisch=False):
"""Ein Aufruf gegen die v2-API.
:param frisch: den Zwischenspeicher übergehen. Die Linkliste antwortet mit
``Cache-Control: private, max-age=60``, damit ein Ladelauf nicht alles neu holt.
Wer aber gerade im Manager einen Dienst eingetragen hat und dann in QGIS auf
„Dienste holen“ klickt, bekäme sonst bis zu eine Minute lang die alte Liste
(gemessen 11.09.2026). Bei einer ausdrücklichen Nutzeraktion also immer frisch.
"""
if with_auth and not self.is_authenticated:
raise AuthError("Nicht angemeldet — bitte zuerst einloggen.")
request = QNetworkRequest(QUrl(self.base_url + path))
request.setHeader(
QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json"
)
request.setRawHeader(b"Accept", b"application/json")
if with_auth:
request.setRawHeader(b"X-API-Key", self.api_key.encode("utf-8"))
body = b""
if payload is not None:
body = json.dumps(payload).encode("utf-8")
# Der Zwischenspeicher wird über forceRefresh gesteuert, NICHT über
# CacheLoadControlAttribute am Request: QgsBlockingNetworkRequest setzt das Attribut
# selbst und überschreibt dabei, was man vorher gesetzt hat (gemessen 11.09.2026 —
# ein AlwaysNetwork am Request blieb wirkungslos).
blocking = QgsBlockingNetworkRequest()
if method == "GET":
error = blocking.get(request, frisch)
elif method == "POST":
error = blocking.post(request, body, frisch)
elif method == "PUT":
error = blocking.put(request, body)
else:
raise ApiError("Nicht unterstützte HTTP-Methode: %s" % method)
reply = blocking.reply()
content = bytes(reply.content())
status = reply.attribute(
QNetworkRequest.Attribute.HttpStatusCodeAttribute
)
status = int(status) if status is not None else None
if status in (401, 403):
raise fehler_fuer_status(
status,
"Keine Berechtigung für %s %s (HTTP %s)%s"
% (method, path, status, self._error_detail(content)),
)
if status is not None and status >= 400:
raise ApiError(
"%s %s lieferte HTTP %s%s"
% (method, path, status, self._error_detail(content)),
status,
)
if error != QgsBlockingNetworkRequest.NoError:
raise ApiError(
"%s %s fehlgeschlagen: %s%s"
% (method, path, blocking.errorMessage(), self._error_detail(content)),
status,
)
if not content:
return None
try:
return json.loads(content.decode("utf-8"))
except ValueError:
raise ApiError(
"Antwort von %s ist kein gültiges JSON." % path
)
@staticmethod
def _error_detail(content):
"""Liest title/detail aus einer RFC-7807-Fehlerantwort."""
if not content:
return ""
try:
problem = json.loads(content.decode("utf-8"))
parts = [
str(problem[k]) for k in ("title", "detail") if problem.get(k)
]
if parts:
return " — " + ": ".join(parts)
except (ValueError, AttributeError):
pass
return " — " + content[:300].decode("utf-8", "replace")