Files
API_Karte_QGISDemo/vln_karten/api_client.py
T
KAIandClaude Opus 5 d79dff1093 Widmungen und Verkehrszeichen laden, Schilder lokal ablegen
Der Server liefert seit heute die Sammlung widmung-<vkz>-punkt|-linie mit einem Stil, der
die Schilder selbst malt, dazu blattzeichen-<vkz> mit den neuen Zeichen am Weg. Das Plugin
kannte beides nicht -- die Gruppenzuordnung fiel auf "Weitere", und die Seitenleiste
filterte das heraus.

* GRUPPEN kennt jetzt widmung ("Widmungen & Verkehrszeichen", eigener Haken in der
  Seitenleiste) und blattzeichen (laeuft in der Gruppe der Maßnahmenblaetter mit).
* Neues Modul zeichen_katalog.py: holt /v2/zeichen/katalog.json (oeffentlich, wird neben den
  Grafiken abgelegt, damit die Palette auch ohne Netz oeffnet), laedt fehlende Schilder nach
  <Profil>/svg/vln/<code>.svg, traegt den Ordner in die SVG-Suchpfade ein und schreibt im
  Stil das Adressen-Praefix auf "vln/" um. Fehlt eine Datei, bleibt die Serveradresse stehen
  -- so zeichnet QGIS sie weiter aus dem Netz, im Druck und offline stimmt es trotzdem.
* Beim Laden eines Zeichen-Layers werden die Grafiken bereitgestellt, BEVOR der Stil greift;
  sonst zeigt der Ausdruck auf eine Datei, die es lokal noch nicht gibt.
* api_client.roh() holt eine Datei roh und ohne Anmeldung; Fehler geben b"" zurueck, ein
  fehlendes Schild darf nichts abbrechen.

Headless gemessen (QGIS 4.2, offscreen): Katalog 890 Zeichen, drei Grafiken geholt, ein
unbekannter Code sauber als fehlend gemeldet, Suchpfad eingetragen, Stil lokalisiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:17:45 +02:00

252 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""HTTP-Client für die VLN-Manager-API (Karten-Plugin map.php + Stammdaten).
API-Vertrag laut Doku im Wissensdatenbank-Vault
("API.md", "API – Karten (map.php).md", "API – Schnittstellen (Referenz).md",
Stand 2026-06-08):
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 -> Teilnehmergemeinschaften (Verfahren)
GET /maps/<layer>/{vkz} -> GeoJSON FeatureCollection (EPSG:25833)
PUT /maps/<layer>/{vkz} <- GeoJSON (ersetzt den Layer dieser VKZ)
Antwort: {"data": {"vkz", "layer", "art", "speicher_id"}, "status": "ok"}
Layer: umringe, p41, st, kas, we
Auth nach Login über den Header "X-API-Key".
Fehlerformat: RFC 7807 (application/problem+json).
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."""
class AuthError(ApiError):
"""API-Key fehlt, ist abgelaufen oder hat keine Berechtigung (401/403)."""
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_verfahren(self):
"""Liste der Verfahren (TGs) als [{"vkz": ..., "name": ...}, ...],
sortiert nach VKZ. Quelle: GET /tgen."""
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 AuthError(
"Keine Berechtigung für %s %s (HTTP %s)%s"
% (method, path, status, self._error_detail(content))
)
if error != QgsBlockingNetworkRequest.NoError:
raise ApiError(
"%s %s fehlgeschlagen: %s%s"
% (method, path, blocking.errorMessage(), 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))
)
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")