VLN-API integriert

This commit is contained in:
2026-06-19 11:47:15 +02:00
parent 18573cb3be
commit 16d6474bb9
8 changed files with 3259 additions and 9 deletions
+248
View File
@@ -0,0 +1,248 @@
"""HTTP-Client für die VLN-Manager-API.
API-Vertrag (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 sn_basis.functions.qt_wrapper import QUrl, QNetworkRequest
from sn_basis.functions.qgiscore_wrapper 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
# ------------------------------------------------------------------
def load_layer_complete(self, layer_key, vkz, page_size=2000):
"""Lädt ALLE Objekte eines Layers für eine VKZ aus KARTE_OBJEKT.
Nutzt den Listen-Endpunkt /maps/<layer>?vkz=… mit limit/offset-Paging,
bis keine weitere Seite mehr kommt."""
from urllib.parse import quote
features = []
offset = 0
while True:
data = self._request(
"GET",
"/maps/%s?vkz=%s&limit=%d&offset=%d"
% (quote(str(layer_key)), quote(str(vkz)), page_size, offset),
)
if not isinstance(data, dict) or data.get("type") != "FeatureCollection":
raise ApiError(
"Antwort von /maps/%s ist keine GeoJSON FeatureCollection."
% layer_key
)
page = data.get("features", [])
features.extend(page)
if len(page) < page_size:
break
offset += page_size
return {"type": "FeatureCollection", "features": features}
def load_feature_collection(self, path):
"""Lädt einen Layer als GeoJSON FeatureCollection (dict)."""
data = self._request("GET", path)
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 save_feature_collection(self, path, feature_collection):
"""Schreibt eine GeoJSON FeatureCollection zurück an die API
(PUT ersetzt den kompletten Layer der jeweiligen VKZ)."""
return self._request("PUT", path, payload=feature_collection)
# ------------------------------------------------------------------
# Intern
# ------------------------------------------------------------------
def _request(self, method, path, payload=None, with_auth=True):
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")
blocking = QgsBlockingNetworkRequest()
if method == "GET":
error = blocking.get(request)
elif method == "POST":
error = blocking.post(request, body)
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")
+378
View File
@@ -0,0 +1,378 @@
"""
sn_plan41/modules/vln_api_logic.py – Fachlogik für die VLN-API-Integration in Tab A.
Kapselt:
- Anmeldung / Abmeldung (KartenApiClient aus vln_karten)
- Persistierung des API-Keys in QSettings
- Verfahrensliste laden (GET /tgen)
- Persistierung der VKZ-Auswahl in der Projektdatei
- Plan 41 laden (2 Layer + Stile aus sn_plan41/assets/)
- Aktiven Layer hochladen
Voraussetzung: Das Plugin `vln_karten` muss installiert sein.
Wenn nicht vorhanden, liefert :attr:`is_available` False; die UI
kann dann einen entsprechenden Hinweis anzeigen.
Sitzungsablauf bei AuthError:
- :meth:`handle_session_expired` aufrufen → API-Key löschen + logout
- In der UI danach :meth:`_update_vln_ui_state` aufrufen und
QMessageBox.warning anzeigen. Kein automatischer Login-Dialog.
"""
from __future__ import annotations
from typing import Optional
from sn_basis.functions.qt_wrapper import QSettings
from sn_basis.functions.qgiscore_wrapper import QgsProject
from sn_basis.functions.sys_wrapper import get_plugin_root, join_path
from sn_basis.functions.ly_style_wrapper import apply_style_from_path
# Lokale API-Module — fallenübrig, sobald QGIS verfügbar ist.
# In der reinen Python-Testumgebung (kein qgis-Paket) schlagen die
# qgis-Imports in den Untermodulen fehl; daher der try/except-Guard.
try:
from sn_plan41.modules.vln_api_client import KartenApiClient, ApiError, AuthError
from sn_plan41.modules.vln_layer_manager import (
feature_collection_to_layers,
plugin_layers,
layers_to_feature_collection,
layer_api_path,
PROP_DATASET,
PROP_VERFAHREN,
)
VLN_KARTEN_AVAILABLE = True
except (ImportError, ModuleNotFoundError):
KartenApiClient = None # type: ignore[assignment,misc]
ApiError = Exception # type: ignore[assignment,misc]
AuthError = Exception # type: ignore[assignment,misc]
VLN_KARTEN_AVAILABLE = False
# Geometrietyp-Konstanten (QGIS intern: 1 = Linie, 2 = Fläche)
try:
from qgis.core import Qgis as _Qgis
_GEOM_LINE = _Qgis.GeometryType.Line
_GEOM_POLYGON = _Qgis.GeometryType.Polygon
except (ImportError, AttributeError):
try:
from qgis.core import QgsWkbTypes as _QgsWkbTypes
_GEOM_LINE = _QgsWkbTypes.LineGeometry
_GEOM_POLYGON = _QgsWkbTypes.PolygonGeometry
except (ImportError, AttributeError):
_GEOM_LINE = 1
_GEOM_POLYGON = 2
# Konstanten
SETTINGS_GROUP = "vln_karten"
PROJECT_SCOPE = "vln_karten"
PROJECT_KEY_VKZ = "/vkz"
P41_DATASET_KEY = "p41"
P41_LABEL = "Plan 41 (Wege- und Gewässerplan)"
P41_PATH_TEMPLATE = "/maps/p41/{vkz}"
P41_GEOMETRY = "MultiPolygon"
STYLE_FLAECHE = "QGIS_P41_API_Layer_flaeche.qml"
STYLE_LINIE = "QGIS_P41_API_Layer_Linie.qml"
class VlnApiLogic:
"""
Kapselt die VLN-API-Fachlogik für den Plan41-Tab.
Alle Methoden, die Netzwerkkommunikation erfordern, können
``ApiError`` oder ``AuthError`` werfen – die UI ist für die
Fehlerdarstellung zuständig.
"""
def __init__(self, pruefmanager=None) -> None:
self.pruefmanager = pruefmanager
self._client: Optional[object] = None # KartenApiClient | None
# API-Client aus gespeicherten Credentials initialisieren
if VLN_KARTEN_AVAILABLE:
api_key, mail = self.load_stored_credentials()
if api_key:
self._client = KartenApiClient(api_key=api_key, mail=mail)
# ------------------------------------------------------------------
# Verfügbarkeit
# ------------------------------------------------------------------
@staticmethod
def is_available() -> bool:
"""True, wenn die lokalen API-Module geladen werden konnten
(erfordert eine QGIS-Laufzeitumgebung)."""
return VLN_KARTEN_AVAILABLE
# ------------------------------------------------------------------
# Authentifizierungsstatus
# ------------------------------------------------------------------
@property
def is_authenticated(self) -> bool:
"""True, wenn ein gültiger API-Key vorhanden ist."""
if self._client is None:
return False
return bool(getattr(self._client, "is_authenticated", False))
@property
def mail(self) -> Optional[str]:
"""E-Mail-Adresse des angemeldeten Benutzers oder None."""
if self._client is None:
return None
return getattr(self._client, "mail", None)
# ------------------------------------------------------------------
# QSettings: API-Key persistieren
# ------------------------------------------------------------------
def load_stored_credentials(self) -> tuple:
"""Liest API-Key und Mail aus QSettings.
Gibt ``(api_key, mail)`` zurück – leere Strings wenn nicht vorhanden.
"""
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
api_key = settings.value("api_key", "")
mail = settings.value("mail", "")
settings.endGroup()
return api_key or None, mail or None
def save_api_key(self, api_key: str, mail: str) -> None:
"""Speichert API-Key und Mail in QSettings."""
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
settings.setValue("api_key", api_key)
settings.setValue("mail", mail or "")
settings.endGroup()
def clear_api_key(self) -> None:
"""Entfernt API-Key aus QSettings."""
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
settings.remove("api_key")
settings.endGroup()
# ------------------------------------------------------------------
# Login / Logout
# ------------------------------------------------------------------
def login(self, mail: str, password: str) -> None:
"""
Meldet den Benutzer an und speichert den API-Key.
:raises ApiError: Bei ungültigen Zugangsdaten oder Netzwerkproblem.
:raises RuntimeError: Wenn QGIS nicht verfügbar ist (kein qgis-Paket).
"""
if not VLN_KARTEN_AVAILABLE:
raise RuntimeError(
"VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)."
)
client = KartenApiClient()
client.login(mail, password) # wirft ApiError bei Fehler
self._client = client
self.save_api_key(client.api_key, mail)
def logout(self) -> None:
"""Meldet den Benutzer ab (lokal, kein API-Aufruf)."""
if self._client is not None:
getattr(self._client, "logout", lambda: None)()
self._client = None
def handle_session_expired(self) -> None:
"""
Behandelt einen abgelaufenen API-Key:
entfernt ihn aus QSettings und setzt den Client zurück.
"""
self.clear_api_key()
self.logout()
# ------------------------------------------------------------------
# Verfahrensliste
# ------------------------------------------------------------------
def get_verfahren(self) -> list:
"""
Lädt alle Verfahren (TGs) vom Server.
:returns: Sortierte Liste von ``{"vkz": ..., "name": ...}``-Dicts.
:raises AuthError: Wenn nicht angemeldet oder Session abgelaufen.
:raises ApiError: Bei sonstigem Netzwerkfehler.
"""
if self._client is None:
raise AuthError("Nicht angemeldet.")
return self._client.get_verfahren()
# ------------------------------------------------------------------
# VKZ-Persistenz in Projektdatei
# ------------------------------------------------------------------
def load_stored_vkz(self) -> Optional[str]:
"""Liest die zuletzt gewählte VKZ aus der Projektdatei."""
try:
vkz, _ok = QgsProject.instance().readEntry(
PROJECT_SCOPE, PROJECT_KEY_VKZ, ""
)
return vkz if vkz else None
except Exception:
return None
def save_vkz(self, vkz: Optional[str]) -> None:
"""Schreibt die gewählte VKZ in die Projektdatei."""
if not vkz:
return
try:
QgsProject.instance().writeEntry(PROJECT_SCOPE, PROJECT_KEY_VKZ, vkz)
except Exception:
pass
# ------------------------------------------------------------------
# Plan 41 laden
# ------------------------------------------------------------------
def get_p41_layers(self, vkz: str) -> list:
"""Gibt alle bereits geladenen P41-Layer für diese VKZ zurück."""
return plugin_layers(P41_DATASET_KEY, vkz)
def remove_p41_layers(self, vkz: str) -> None:
"""Entfernt alle P41-Layer dieser VKZ aus dem Projekt."""
existing = plugin_layers(P41_DATASET_KEY, vkz)
if existing:
QgsProject.instance().removeMapLayers([l.id() for l in existing])
def load_p41(self, vkz: str) -> list:
"""
Lädt den Plan-41-Datensatz für eine VKZ vom Server und legt
Memory-Layer im Projekt an. Auf die entstandenen Layer werden
die passenden QML-Stile aus ``sn_plan41/assets/`` angewendet.
:returns: Liste der erzeugten ``QgsVectorLayer``.
:raises AuthError: Wenn nicht angemeldet oder Session abgelaufen.
:raises ApiError: Bei sonstigem Netzwerkfehler.
:raises RuntimeError: Wenn QGIS nicht verfügbar ist.
"""
if not VLN_KARTEN_AVAILABLE:
raise RuntimeError(
"VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)."
)
if self._client is None:
raise AuthError("Nicht angemeldet.")
path = P41_PATH_TEMPLATE.format(vkz=vkz)
feature_collection = self._client.load_layer_complete(
P41_DATASET_KEY, vkz
)
base_name = "%s %s" % (P41_LABEL, vkz)
layers = feature_collection_to_layers(
feature_collection,
base_name,
P41_GEOMETRY,
P41_DATASET_KEY,
vkz,
path,
)
self._apply_p41_styles(layers)
return layers
def _apply_p41_styles(self, layers: list) -> None:
"""Wendet den passenden QML-Stil auf jeden Layer an."""
assets_dir = join_path(get_plugin_root(), "sn_plan41", "assets")
for layer in layers:
try:
gtype = layer.geometryType()
except Exception:
continue
if gtype == _GEOM_POLYGON:
style_file = STYLE_FLAECHE
elif gtype == _GEOM_LINE:
style_file = STYLE_LINIE
else:
continue # Punkt- und None-Layer erhalten keinen Stil
style_path = join_path(assets_dir, style_file)
apply_style_from_path(layer, style_path)
# ------------------------------------------------------------------
# Upload
# ------------------------------------------------------------------
def upload_active_layer(self, active_layer) -> tuple:
"""
Lädt alle Teil-Layer des aktiven Layers zum Server hoch.
:param active_layer: Aktiver ``QgsVectorLayer`` (aus ``iface.activeLayer()``).
:returns: ``(success: bool, message: str)``
:raises AuthError: Wenn Session abgelaufen.
:raises ApiError: Bei Netzwerkfehler.
"""
if not VLN_KARTEN_AVAILABLE:
return False, (
"VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)."
)
if self._client is None:
raise AuthError("Nicht angemeldet.")
path = layer_api_path(active_layer) if active_layer else None
if not path:
return (
False,
"Der aktive Layer wurde nicht über 'vln_karten' geladen. "
"Bitte einen Plan41-Layer aus dem Projekt auswählen.",
)
dataset_key = active_layer.customProperty(PROP_DATASET)
vkz = active_layer.customProperty(PROP_VERFAHREN)
siblings = plugin_layers(dataset_key, vkz)
for layer in siblings:
if layer.isEditable() and not layer.commitChanges():
return (
False,
"Die Bearbeitungssitzung von '%s' konnte nicht "
"gespeichert werden." % layer.name(),
)
feature_collection = layers_to_feature_collection(siblings)
self._client.save_feature_collection(path, feature_collection)
total = sum(layer.featureCount() for layer in siblings)
return (
True,
"%d Objekte aus %d Layer(n) erfolgreich hochgeladen."
% (total, len(siblings)),
)
def upload_summary(self, active_layer) -> Optional[dict]:
"""
Gibt eine Zusammenfassung der zu hochladenden Layer zurück
(für den Bestätigungs-Dialog in der UI), ohne den Upload
tatsächlich auszuführen.
:returns: Dict mit ``dataset_label``, ``vkz``, ``siblings``
(Layer-Liste), ``total`` (Objektanzahl), ``listing`` (str)
oder ``None`` wenn kein gültiger Layer.
"""
if not VLN_KARTEN_AVAILABLE or active_layer is None:
return None
path = layer_api_path(active_layer)
if not path:
return None
dataset_key = active_layer.customProperty(PROP_DATASET)
vkz = active_layer.customProperty(PROP_VERFAHREN)
siblings = plugin_layers(dataset_key, vkz)
total = sum(l.featureCount() for l in siblings)
listing = "\n".join(
"• %s (%d Objekte)" % (l.name(), l.featureCount())
for l in siblings
)
return {
"dataset_label": P41_LABEL if dataset_key == P41_DATASET_KEY else dataset_key,
"vkz": vkz,
"siblings": siblings,
"total": total,
"listing": listing,
}
+177
View File
@@ -0,0 +1,177 @@
"""Konvertierung GeoJSON <-> QGIS-Layer für die VLN-API.
Lokale Kopie des Layer-Managers aus dem vln_karten-Plugin — sn_plan41
benötigt kein separat installiertes vln_karten-Plugin mehr.
Die API liefert je Layer (z.B. Plan 41) gemischte Geometrietypen in einer
FeatureCollection: Punkte, Linien, Polygone sowie deren Multi-Varianten.
QGIS-Memory-Layer können nur einen Geometrietyp halten — beim Laden wird
deshalb nach Geometrie-Familie (Punkte/Linien/Flächen) in bis zu drei
Layer gesplittet; Single-Geometrien werden zu Multi befördert.
Beim Hochladen müssen alle Teil-Layer desselben Datensatzes wieder zu
EINER FeatureCollection vereint werden, weil der PUT der API den
kompletten Layer-Bestand der VKZ ersetzt.
Custom-Properties an Layern (kompatibel mit vln_karten, falls beide
Plugins gleichzeitig installiert sind):
vln_karten/dataset — z.B. "p41"
vln_karten/verfahren — VKZ-Nummer
vln_karten/api_path — API-Pfad für Upload
"""
import json
from sn_basis.functions.qgiscore_wrapper import (
QgsJsonExporter,
QgsJsonUtils,
QgsProject,
QgsVectorLayer,
GEOM_POINT,
GEOM_LINE,
GEOM_POLYGON,
)
_GEOM_POINT = GEOM_POINT
_GEOM_LINE = GEOM_LINE
_GEOM_POLYGON = GEOM_POLYGON
# Die VLN-API liefert Koordinaten in ETRS89 / UTM Zone 33 (EPSG:25833).
GEOJSON_CRS = "EPSG:25833"
# Custom-Property-Schlüssel — identisch mit vln_karten für Kompatibilität.
PROP_DATASET = "vln_karten/dataset"
PROP_VERFAHREN = "vln_karten/verfahren"
PROP_PATH = "vln_karten/api_path"
# Geometrie-Familie → (Memory-Provider-Typ, Namenszusatz).
# Reihenfolge = Lade-Reihenfolge: Flächen zuerst, damit Punkte im
# Layerbaum oben landen und nicht verdeckt werden.
_FAMILIES = (
("polygon", "MultiPolygon", "Flächen"),
("line", "MultiLineString", "Linien"),
("point", "MultiPoint", "Punkte"),
("none", "None", "ohne Geometrie"),
)
def _family(feature):
"""Geometrie-Familie eines Features. Unbekannte Typen (z.B.
GeometryCollection) landen bei 'none'."""
if not feature.hasGeometry() or feature.geometry().isNull():
return "none"
gtype = feature.geometry().type()
if gtype == _GEOM_POINT:
return "point"
if gtype == _GEOM_LINE:
return "line"
if gtype == _GEOM_POLYGON:
return "polygon"
return "none"
def memory_layer_from_features(
uri_type, name, fields, features, dataset_key, verfahren_nr, api_path, promote=True
):
"""Baut einen Memory-Layer aus fertigen QgsFeatures, setzt die
Plugin-Custom-Properties und hängt ihn ins Projekt."""
uri = uri_type if uri_type == "None" else "%s?crs=%s" % (uri_type, GEOJSON_CRS)
layer = QgsVectorLayer(uri, name, "memory")
if not layer.isValid():
raise RuntimeError("Memory-Layer '%s' konnte nicht erzeugt werden." % name)
provider = layer.dataProvider()
provider.addAttributes(fields.toList())
layer.updateFields()
prepared = []
for feature in features:
geometry = feature.geometry()
if promote and not geometry.isNull() and not geometry.isMultipart():
geometry.convertToMultiType()
feature.setGeometry(geometry)
prepared.append(feature)
provider.addFeatures(prepared)
layer.updateExtents()
layer.setCustomProperty(PROP_DATASET, dataset_key)
layer.setCustomProperty(PROP_VERFAHREN, verfahren_nr)
layer.setCustomProperty(PROP_PATH, api_path)
return layer
def feature_collection_to_layers(
feature_collection, base_name, default_geometry, dataset_key, verfahren_nr, api_path
):
"""Erzeugt aus einer GeoJSON FeatureCollection je vorkommender
Geometrie-Familie einen Memory-Layer und hängt sie ins Projekt.
Liefert die Liste der erzeugten Layer."""
text = json.dumps(feature_collection)
fields = QgsJsonUtils.stringToFields(text)
features = QgsJsonUtils.stringToFeatureList(text, fields)
groups = {}
for feature in features:
groups.setdefault(_family(feature), []).append(feature)
layers = []
multiple = len(groups) > 1
def make_layer(uri_type, name, group_features, promote):
layers.append(
memory_layer_from_features(
uri_type, name, fields, group_features,
dataset_key, verfahren_nr, api_path, promote=promote,
)
)
if not groups:
# Leerer Datensatz: ein leerer Layer im Default-Typ, damit der
# Nutzer digitalisieren und hochladen kann.
make_layer(default_geometry, base_name, [], promote=False)
else:
for family, uri_type, suffix in _FAMILIES:
if family not in groups:
continue
name = "%s — %s" % (base_name, suffix) if multiple else base_name
make_layer(uri_type, name, groups[family], promote=(family != "none"))
QgsProject.instance().addMapLayers(layers)
return layers
def plugin_layers(dataset_key, verfahren_nr):
"""Alle Layer im Projekt, die zu diesem Datensatz und dieser VKZ
gehören (z.B. die Punkte-/Linien-/Flächen-Teil-Layer von Plan 41)."""
result = []
for layer in QgsProject.instance().mapLayers().values():
if (
isinstance(layer, QgsVectorLayer)
and layer.customProperty(PROP_DATASET) == dataset_key
and str(layer.customProperty(PROP_VERFAHREN)) == str(verfahren_nr)
):
result.append(layer)
return result
def layers_to_feature_collection(layers):
"""Vereint die Features mehrerer Layer zu EINER GeoJSON
FeatureCollection (für den PUT, der den Serverbestand ersetzt).
Keine Transformation nach WGS84: Die API erwartet die Koordinaten im
selben CRS, in dem sie sie liefert (EPSG:25833)."""
merged = []
for layer in layers:
exporter = QgsJsonExporter(layer)
exporter.setSourceCrs(layer.crs())
exporter.setTransformGeometries(False)
collection = json.loads(
exporter.exportFeatures(list(layer.getFeatures()))
)
merged.extend(collection.get("features", []))
return {"type": "FeatureCollection", "features": merged}
def layer_api_path(layer):
"""API-Pfad, unter dem dieser Layer geladen wurde — oder None,
wenn der Layer nicht von diesem Plugin stammt."""
if not isinstance(layer, QgsVectorLayer):
return None
return layer.customProperty(PROP_PATH) or None