diff --git a/assets/QGIS_P41_API_Layer_Linie.qml b/assets/QGIS_P41_API_Layer_Linie.qml
new file mode 100644
index 0000000..6815606
--- /dev/null
+++ b/assets/QGIS_P41_API_Layer_Linie.qml
@@ -0,0 +1,1004 @@
+
+
+
+ 1
+ 1
+ 1
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+ generatedlayout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "NAME"
+
+ 1
+
diff --git a/assets/QGIS_P41_API_Layer_flaeche.qml b/assets/QGIS_P41_API_Layer_flaeche.qml
new file mode 100644
index 0000000..5ea684c
--- /dev/null
+++ b/assets/QGIS_P41_API_Layer_flaeche.qml
@@ -0,0 +1,597 @@
+
+
+
+ 1
+ 1
+ 1
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+ generatedlayout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "NAME"
+
+ 2
+
diff --git a/modules/vln_api_client.py b/modules/vln_api_client.py
new file mode 100644
index 0000000..dd6eb7e
--- /dev/null
+++ b/modules/vln_api_client.py
@@ -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": "", "id": ...}}
+ Das userauth-Token ist der API-Key (= PERSON.url_token).
+
+ GET /tgen -> Teilnehmergemeinschaften (Verfahren)
+
+ GET /maps//{vkz} -> GeoJSON FeatureCollection (EPSG:25833)
+ PUT /maps//{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/?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")
diff --git a/modules/vln_api_logic.py b/modules/vln_api_logic.py
new file mode 100644
index 0000000..a396f1b
--- /dev/null
+++ b/modules/vln_api_logic.py
@@ -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,
+ }
diff --git a/modules/vln_layer_manager.py b/modules/vln_layer_manager.py
new file mode 100644
index 0000000..c18b35f
--- /dev/null
+++ b/modules/vln_layer_manager.py
@@ -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
diff --git a/tests/run_tests.py b/tests/run_tests.py
index 42e1cdd..cae8f33 100644
--- a/tests/run_tests.py
+++ b/tests/run_tests.py
@@ -5,6 +5,7 @@ Zentraler Test-Runner für sn_plan41.
Wrapper-konform, QGIS-unabhängig, CI- und IDE-fähig.
"""
+import io
import unittest
import datetime
import inspect
@@ -12,6 +13,19 @@ import os
import sys
from pathlib import Path
+# Stdout auf UTF-8 umstellen, damit Emoji-Print-Statements in
+# qt_wrapper.py (Mock-Modus) auf Windows-Konsolen (CP1252) nicht zu
+# UnicodeEncodeError führen.
+if hasattr(sys.stdout, "reconfigure"):
+ try:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+ except Exception:
+ pass
+elif hasattr(sys.stdout, "buffer"):
+ sys.stdout = io.TextIOWrapper(
+ sys.stdout.buffer, encoding="utf-8", errors="replace"
+ )
+
# ---------------------------------------------------------
# Plugin-Roots bestimmen
diff --git a/tests/test_vln_api_logic.py b/tests/test_vln_api_logic.py
new file mode 100644
index 0000000..142b357
--- /dev/null
+++ b/tests/test_vln_api_logic.py
@@ -0,0 +1,473 @@
+"""
+Tests für sn_plan41/modules/vln_api_logic.py
+
+Alle externen Abhängigkeiten (vln_karten, QSettings, QgsProject, Netzwerk)
+werden mit unittest.mock gemockt, sodass keine QGIS-Laufzeitumgebung nötig ist.
+"""
+
+import unittest
+from typing import Any, Optional
+from unittest.mock import MagicMock, patch, call
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def _make_logic(api_key: Optional[str] = None, mail: Optional[str] = None):
+ """Erzeugt eine VlnApiLogic-Instanz mit gemockten QSettings."""
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
+ mock_qs = MagicMock()
+ mock_qs.value.side_effect = lambda key, default="": (
+ api_key if key == "api_key" else (mail if key == "mail" else default)
+ )
+ mock_qs_cls.return_value = mock_qs
+
+ if api_key and VlnApiLogic.is_available():
+ with patch(
+ "sn_plan41.modules.vln_api_logic.KartenApiClient"
+ ) as mock_client_cls:
+ mock_client = MagicMock()
+ mock_client.is_authenticated = True
+ mock_client.api_key = api_key
+ mock_client.mail = mail
+ mock_client_cls.return_value = mock_client
+ logic = VlnApiLogic()
+ else:
+ logic = VlnApiLogic()
+
+ return logic
+
+
+# ---------------------------------------------------------------------------
+# Tests: Verfügbarkeit
+# ---------------------------------------------------------------------------
+
+
+class TestVlnApiLogicAvailability(unittest.TestCase):
+
+ def test_is_available_reflects_import_state(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+ result = VlnApiLogic.is_available()
+ # Ergebnis hängt davon ab, ob vln_karten installiert ist —
+ # wir prüfen nur, dass ein bool zurückkommt
+ self.assertIsInstance(result, bool)
+
+ def test_is_available_false_when_vln_karten_missing(self):
+ """Wenn VLN_KARTEN_AVAILABLE=False, liefert is_available() False."""
+ import sn_plan41.modules.vln_api_logic as module
+ original = module.VLN_KARTEN_AVAILABLE
+ try:
+ module.VLN_KARTEN_AVAILABLE = False
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+ with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
+ self.assertFalse(VlnApiLogic.is_available())
+ finally:
+ module.VLN_KARTEN_AVAILABLE = original
+
+
+# ---------------------------------------------------------------------------
+# Tests: Credentials-Persistenz
+# ---------------------------------------------------------------------------
+
+
+class TestVlnApiLogicCredentials(unittest.TestCase):
+
+ def _make(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings"):
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
+ return VlnApiLogic()
+
+ def test_save_api_key_calls_qsettings(self):
+ logic = self._make()
+ with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
+ mock_qs = MagicMock()
+ mock_qs_cls.return_value = mock_qs
+ logic.save_api_key("token123", "user@test.de")
+
+ mock_qs.setValue.assert_any_call("api_key", "token123")
+ mock_qs.setValue.assert_any_call("mail", "user@test.de")
+ mock_qs.beginGroup.assert_called_with("vln_karten")
+
+ def test_clear_api_key_calls_remove(self):
+ logic = self._make()
+ with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
+ mock_qs = MagicMock()
+ mock_qs_cls.return_value = mock_qs
+ logic.clear_api_key()
+
+ mock_qs.remove.assert_called_with("api_key")
+
+ def test_load_stored_credentials_returns_api_key_and_mail(self):
+ logic = self._make()
+ with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
+ mock_qs = MagicMock()
+ mock_qs.value.side_effect = lambda key, default="": {
+ "api_key": "mykey",
+ "mail": "user@test.de",
+ }.get(key, default)
+ mock_qs_cls.return_value = mock_qs
+
+ api_key, mail = logic.load_stored_credentials()
+
+ self.assertEqual(api_key, "mykey")
+ self.assertEqual(mail, "user@test.de")
+
+ def test_load_stored_credentials_returns_none_for_empty(self):
+ logic = self._make()
+ with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls:
+ mock_qs = MagicMock()
+ mock_qs.value.return_value = ""
+ mock_qs_cls.return_value = mock_qs
+
+ api_key, mail = logic.load_stored_credentials()
+
+ self.assertIsNone(api_key)
+ self.assertIsNone(mail)
+
+
+# ---------------------------------------------------------------------------
+# Tests: Login / Logout
+# ---------------------------------------------------------------------------
+
+
+class TestVlnApiLogicLogin(unittest.TestCase):
+
+ def _make(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings"):
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
+ return VlnApiLogic()
+
+ def test_login_sets_client_and_saves_key(self):
+ import sn_plan41.modules.vln_api_logic as module
+ if not module.VLN_KARTEN_AVAILABLE:
+ self.skipTest("vln_karten nicht verfügbar")
+
+ logic = self._make()
+
+ mock_client = MagicMock()
+ mock_client.api_key = "newtoken"
+
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client):
+ with patch.object(logic, "save_api_key") as mock_save:
+ logic.login("user@test.de", "secret")
+
+ mock_client.login.assert_called_once_with("user@test.de", "secret")
+ mock_save.assert_called_once_with("newtoken", "user@test.de")
+
+ def test_login_raises_runtime_error_without_qgis_modules(self):
+ import sn_plan41.modules.vln_api_logic as module
+ logic = self._make()
+ with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
+ with self.assertRaises(RuntimeError):
+ logic.login("user@test.de", "secret")
+
+ def test_login_propagates_api_error(self):
+ import sn_plan41.modules.vln_api_logic as module
+ if not module.VLN_KARTEN_AVAILABLE:
+ self.skipTest("vln_karten nicht verfügbar")
+
+ logic = self._make()
+ mock_client = MagicMock()
+ mock_client.login.side_effect = module.ApiError("Ungültige Zugangsdaten")
+
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client):
+ with self.assertRaises(Exception):
+ logic.login("user@test.de", "wrong")
+
+ def test_logout_clears_client(self):
+ logic = self._make()
+ logic._client = MagicMock()
+ logic.logout()
+ self.assertIsNone(logic._client)
+
+ def test_is_authenticated_false_after_logout(self):
+ logic = self._make()
+ logic._client = None
+ self.assertFalse(logic.is_authenticated)
+
+ def test_handle_session_expired_clears_key_and_client(self):
+ logic = self._make()
+ with patch.object(logic, "clear_api_key") as mock_clear:
+ with patch.object(logic, "logout") as mock_logout:
+ logic.handle_session_expired()
+
+ mock_clear.assert_called_once()
+ mock_logout.assert_called_once()
+
+
+# ---------------------------------------------------------------------------
+# Tests: Verfahrensliste
+# ---------------------------------------------------------------------------
+
+
+class TestVlnApiLogicVerfahren(unittest.TestCase):
+
+ def _make_authenticated(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings"):
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
+ logic = VlnApiLogic()
+
+ mock_client = MagicMock()
+ mock_client.is_authenticated = True
+ logic._client = mock_client
+ return logic, mock_client
+
+ def test_get_verfahren_raises_when_not_authenticated(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings"):
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
+ logic = VlnApiLogic()
+ logic._client = None
+
+ with self.assertRaises(Exception):
+ logic.get_verfahren()
+
+ def test_get_verfahren_delegates_to_client(self):
+ logic, mock_client = self._make_authenticated()
+ mock_client.get_verfahren.return_value = [
+ {"vkz": "27010", "name": "Alpha"},
+ {"vkz": "27001", "name": "Beta"},
+ ]
+ result = logic.get_verfahren()
+ mock_client.get_verfahren.assert_called_once()
+ self.assertEqual(len(result), 2)
+
+
+# ---------------------------------------------------------------------------
+# Tests: VKZ-Persistenz
+# ---------------------------------------------------------------------------
+
+
+class TestVlnApiLogicVkz(unittest.TestCase):
+
+ def _make(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings"):
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
+ return VlnApiLogic()
+
+ def test_load_stored_vkz_reads_from_project(self):
+ logic = self._make()
+ mock_project = MagicMock()
+ mock_project.readEntry.return_value = ("27010", True)
+
+ with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
+ mock_qgsproj_cls.instance.return_value = mock_project
+ vkz = logic.load_stored_vkz()
+
+ self.assertEqual(vkz, "27010")
+ mock_project.readEntry.assert_called_once_with("vln_karten", "/vkz", "")
+
+ def test_load_stored_vkz_returns_none_for_empty(self):
+ logic = self._make()
+ mock_project = MagicMock()
+ mock_project.readEntry.return_value = ("", True)
+
+ with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
+ mock_qgsproj_cls.instance.return_value = mock_project
+ vkz = logic.load_stored_vkz()
+
+ self.assertIsNone(vkz)
+
+ def test_save_vkz_writes_to_project(self):
+ logic = self._make()
+ mock_project = MagicMock()
+
+ with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
+ mock_qgsproj_cls.instance.return_value = mock_project
+ logic.save_vkz("27010")
+
+ mock_project.writeEntry.assert_called_once_with("vln_karten", "/vkz", "27010")
+
+ def test_save_vkz_does_nothing_for_none(self):
+ logic = self._make()
+ mock_project = MagicMock()
+
+ with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls:
+ mock_qgsproj_cls.instance.return_value = mock_project
+ logic.save_vkz(None)
+
+ mock_project.writeEntry.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Tests: Plan 41 laden (Stil-Anwendung)
+# ---------------------------------------------------------------------------
+
+
+class TestVlnApiLogicLoadP41(unittest.TestCase):
+
+ def _make(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings"):
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
+ logic = VlnApiLogic()
+
+ mock_client = MagicMock()
+ mock_client.is_authenticated = True
+ mock_client.load_layer_complete.return_value = {
+ "type": "FeatureCollection",
+ "features": [],
+ }
+ logic._client = mock_client
+ return logic
+
+ def test_load_p41_raises_without_vln_karten(self):
+ import sn_plan41.modules.vln_api_logic as module
+ logic = self._make()
+ with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
+ with self.assertRaises(RuntimeError):
+ logic.load_p41("27010")
+
+ def test_load_p41_applies_styles_to_polygon_and_line_layers(self):
+ import sn_plan41.modules.vln_api_logic as module
+ if not module.VLN_KARTEN_AVAILABLE:
+ self.skipTest("vln_karten nicht verfügbar")
+
+ logic = self._make()
+
+ # Zwei Mock-Layer: einer Polygon (2), einer Linie (1)
+ mock_layer_polygon = MagicMock()
+ mock_layer_polygon.geometryType.return_value = module._GEOM_POLYGON
+
+ mock_layer_line = MagicMock()
+ mock_layer_line.geometryType.return_value = module._GEOM_LINE
+
+ with patch(
+ "sn_plan41.modules.vln_api_logic.feature_collection_to_layers",
+ return_value=[mock_layer_polygon, mock_layer_line],
+ ):
+ with patch(
+ "sn_plan41.modules.vln_api_logic.apply_style_from_path"
+ ) as mock_style:
+ with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)):
+ with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"):
+ logic.load_p41("27010")
+
+ # Prüfen, dass der richtige Stil für jeden Geometrietyp verwendet wurde
+ style_calls = [str(c) for c in mock_style.call_args_list]
+ polygon_style_used = any(
+ module.STYLE_FLAECHE in c for c in style_calls
+ )
+ line_style_used = any(
+ module.STYLE_LINIE in c for c in style_calls
+ )
+ self.assertTrue(polygon_style_used, "Flächen-Stil wurde nicht angewendet")
+ self.assertTrue(line_style_used, "Linien-Stil wurde nicht angewendet")
+
+ def test_load_p41_no_style_for_point_layer(self):
+ import sn_plan41.modules.vln_api_logic as module
+ if not module.VLN_KARTEN_AVAILABLE:
+ self.skipTest("vln_karten nicht verfügbar")
+
+ logic = self._make()
+
+ mock_layer_point = MagicMock()
+ mock_layer_point.geometryType.return_value = 0 # Point
+
+ with patch(
+ "sn_plan41.modules.vln_api_logic.feature_collection_to_layers",
+ return_value=[mock_layer_point],
+ ):
+ with patch(
+ "sn_plan41.modules.vln_api_logic.apply_style_from_path"
+ ) as mock_style:
+ with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)):
+ with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"):
+ logic.load_p41("27010")
+
+ mock_style.assert_not_called()
+
+
+# ---------------------------------------------------------------------------
+# Tests: Upload
+# ---------------------------------------------------------------------------
+
+
+class TestVlnApiLogicUpload(unittest.TestCase):
+
+ def _make(self):
+ from sn_plan41.modules.vln_api_logic import VlnApiLogic
+
+ with patch("sn_plan41.modules.vln_api_logic.QSettings"):
+ with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"):
+ logic = VlnApiLogic()
+
+ mock_client = MagicMock()
+ mock_client.is_authenticated = True
+ logic._client = mock_client
+ return logic
+
+ def test_upload_returns_error_for_layer_without_api_path(self):
+ import sn_plan41.modules.vln_api_logic as module
+ if not module.VLN_KARTEN_AVAILABLE:
+ self.skipTest("vln_karten nicht verfügbar")
+
+ logic = self._make()
+ mock_layer = MagicMock()
+
+ with patch(
+ "sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None
+ ):
+ success, message = logic.upload_active_layer(mock_layer)
+
+ self.assertFalse(success)
+ self.assertIn("nicht", message.lower())
+
+ def test_upload_returns_error_for_none_layer(self):
+ import sn_plan41.modules.vln_api_logic as module
+ if not module.VLN_KARTEN_AVAILABLE:
+ self.skipTest("vln_karten nicht verfügbar")
+
+ logic = self._make()
+ with patch(
+ "sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None
+ ):
+ success, message = logic.upload_active_layer(None)
+
+ self.assertFalse(success)
+
+ def test_upload_returns_false_without_qgis_modules(self):
+ import sn_plan41.modules.vln_api_logic as module
+ logic = self._make()
+ with patch.object(module, "VLN_KARTEN_AVAILABLE", False):
+ success, message = logic.upload_active_layer(MagicMock())
+
+ self.assertFalse(success)
+
+ def test_upload_calls_save_feature_collection(self):
+ import sn_plan41.modules.vln_api_logic as module
+ if not module.VLN_KARTEN_AVAILABLE:
+ self.skipTest("vln_karten nicht verfügbar")
+
+ logic = self._make()
+
+ mock_layer = MagicMock()
+ mock_layer.isEditable.return_value = False
+ mock_layer.featureCount.return_value = 5
+
+ fc = {"type": "FeatureCollection", "features": []}
+
+ with patch("sn_plan41.modules.vln_api_logic.layer_api_path", return_value="/maps/p41/27010"):
+ with patch("sn_plan41.modules.vln_api_logic.plugin_layers", return_value=[mock_layer]):
+ with patch("sn_plan41.modules.vln_api_logic.layers_to_feature_collection", return_value=fc):
+ success, message = logic.upload_active_layer(mock_layer)
+
+ logic._client.save_feature_collection.assert_called_once_with("/maps/p41/27010", fc)
+ self.assertTrue(success)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/ui/tab_a_ui.py b/ui/tab_a_ui.py
index a224e77..b9270b4 100644
--- a/ui/tab_a_ui.py
+++ b/ui/tab_a_ui.py
@@ -9,7 +9,9 @@ from typing import Optional
from sn_basis.functions.qt_wrapper import (
QWidget,
QVBoxLayout,
+ QHBoxLayout,
QLabel,
+ QLineEdit,
QPushButton,
QToolButton,
QFileDialog,
@@ -32,7 +34,8 @@ from sn_basis.modules.DataGrabber import DataGrabber
from sn_basis.modules.Dateipruefer import Dateipruefer
from sn_plan41.ui.tab_a_logic import TabALogic
from sn_basis.modules.linkpruefer import Linkpruefer
-from sn_basis.modules.stilpruefer import Stilpruefer
+from sn_basis.modules.stilpruefer import Stilpruefer
+from sn_plan41.modules.vln_api_logic import VlnApiLogic
# Konstanten
RAUMFILTER_VAR = "Raumfilter"
@@ -78,7 +81,11 @@ class TabA(QWidget):
# UI-Referenzen
self._raumfilter_combo: Optional[QComboBox] = None
-
+
+ # VLN-API
+ self.vln_logic: Optional[VlnApiLogic] = None
+ self._vln_restoring: bool = False
+
self._build_ui()
self._restore_state()
@@ -92,14 +99,17 @@ class TabA(QWidget):
self.pruefmanager = pruefmanager
self.data_grabber = data_grabber
self.logic = TabALogic(
- pruefmanager=self.pruefmanager,
- link_pruefer=Linkpruefer(),
- stil_pruefer=Stilpruefer(),
- )
-
- # DataGrabber in die Logik injizieren
+ pruefmanager=self.pruefmanager,
+ link_pruefer=Linkpruefer(),
+ stil_pruefer=Stilpruefer(),
+ )
self.logic.data_grabber = self.data_grabber
+ # VLN-API-Logik initialisieren
+ self.vln_logic = VlnApiLogic(self.pruefmanager)
+ self._update_vln_ui_state()
+ self._restore_vln_state()
+
def _build_ui(self) -> None:
"""Erstellt die komplette UI-Hierarchie mit allen Gruppen."""
main_layout = QVBoxLayout()
@@ -180,7 +190,84 @@ class TabA(QWidget):
self.btn_pipeline.clicked.connect(self._on_load_fachdaten)
main_layout.addWidget(self.btn_pipeline)
-
+ # === VLN API / PLAN 41 ===
+ self._vln_group_button = QToolButton()
+ self._vln_group_button.setText("VLN API / Plan 41")
+ self._vln_group_button.setCheckable(True)
+ self._vln_group_button.setChecked(False)
+ self._vln_group_button.setToolButtonStyle(ToolButtonTextBesideIcon)
+ self._vln_group_button.setArrowType(ArrowRight)
+ self._vln_group_button.setStyleSheet("font-weight: bold; margin-top: 8px;")
+ self._vln_group_button.toggled.connect(self._toggle_vln_group)
+ main_layout.addWidget(self._vln_group_button)
+
+ self._vln_group_content = QWidget()
+ self._vln_group_content.setSizePolicy(SizePolicyPreferred, SizePolicyMaximum)
+ vln_layout = QVBoxLayout()
+ vln_layout.setSpacing(4)
+ vln_layout.setContentsMargins(10, 4, 4, 8)
+
+ # Status
+ self._vln_status_label = QLabel("Nicht angemeldet")
+ self._vln_status_label.setStyleSheet("color: gray; font-style: italic;")
+ vln_layout.addWidget(self._vln_status_label)
+
+ # E-Mail und Passwort
+ vln_layout.addWidget(QLabel("E-Mail:"))
+ self._vln_mail_edit = QLineEdit()
+ self._vln_mail_edit.setPlaceholderText("mail@beispiel.de")
+ vln_layout.addWidget(self._vln_mail_edit)
+
+ vln_layout.addWidget(QLabel("Passwort:"))
+ self._vln_pass_edit = QLineEdit()
+ try:
+ self._vln_pass_edit.setEchoMode(QLineEdit.EchoMode.Password)
+ except AttributeError:
+ self._vln_pass_edit.setEchoMode(QLineEdit.Password)
+ vln_layout.addWidget(self._vln_pass_edit)
+
+ # Login / Logout Buttons
+ btn_row = QHBoxLayout()
+ self._btn_vln_login = QPushButton("Anmelden")
+ self._btn_vln_login.clicked.connect(self._on_vln_login)
+ self._btn_vln_logout = QPushButton("Abmelden")
+ self._btn_vln_logout.clicked.connect(self._on_vln_logout)
+ btn_row.addWidget(self._btn_vln_login)
+ btn_row.addWidget(self._btn_vln_logout)
+ vln_layout.addLayout(btn_row)
+
+ # Verfahren
+ vln_layout.addWidget(QLabel("Verfahren:"))
+ self._vln_verfahren_combo = QComboBox()
+ self._vln_verfahren_combo.setToolTip("Verfahren (Teilnehmergemeinschaft) wählen")
+ self._vln_verfahren_combo.setEnabled(False)
+ self._vln_verfahren_combo.currentIndexChanged.connect(
+ self._on_vln_verfahren_changed
+ )
+ vln_layout.addWidget(self._vln_verfahren_combo)
+
+ # Plan 41 laden
+ self._btn_p41_laden = QPushButton("Plan 41 (Wege- und Gewässerplan) laden")
+ self._btn_p41_laden.setToolTip(
+ "Lädt Flächen- und Linienlayer für das gewählte Verfahren vom Server"
+ )
+ self._btn_p41_laden.setEnabled(False)
+ self._btn_p41_laden.clicked.connect(self._on_p41_laden)
+ vln_layout.addWidget(self._btn_p41_laden)
+
+ # Upload
+ self._btn_upload = QPushButton("Aktiven Layer hochladen")
+ self._btn_upload.setToolTip(
+ "Lädt alle Plan-41-Layer des aktiven Verfahrens zum Server hoch"
+ )
+ self._btn_upload.setEnabled(False)
+ self._btn_upload.clicked.connect(self._on_upload_active)
+ vln_layout.addWidget(self._btn_upload)
+
+ self._vln_group_content.setLayout(vln_layout)
+ self._vln_group_content.setVisible(False)
+ main_layout.addWidget(self._vln_group_content)
+
main_layout.addStretch(1)
self.setLayout(main_layout)
@@ -206,7 +293,279 @@ class TabA(QWidget):
pass
# Raumfilter (schon im _build_ui behandelt)
+ def _restore_vln_state(self) -> None:
+ """Stellt die zuletzt gewählte VKZ aus der Projektdatei wieder her."""
+ if self.vln_logic is None or not self.vln_logic.is_authenticated:
+ return
+ try:
+ self._populate_verfahren_combo(restore_vkz=True)
+ except Exception:
+ pass
+
# === UI CALLBACKS ===
+
+ def _toggle_vln_group(self, checked: bool) -> None:
+ self._vln_group_button.setArrowType(ArrowDown if checked else ArrowRight)
+ self._vln_group_content.setVisible(checked)
+
+ def _update_vln_ui_state(self) -> None:
+ """Passt alle VLN-Controls an den aktuellen Authentifizierungsstatus an."""
+ if self.vln_logic is None:
+ authenticated = False
+ available = False
+ else:
+ authenticated = self.vln_logic.is_authenticated
+ available = self.vln_logic.is_available()
+
+ if not available:
+ self._vln_status_label.setText(
+ "Plugin 'vln_karten' nicht installiert"
+ )
+ self._vln_status_label.setStyleSheet("color: red; font-style: italic;")
+ self._btn_vln_login.setEnabled(False)
+ elif authenticated:
+ mail = (self.vln_logic.mail or "API-Key") if self.vln_logic else ""
+ self._vln_status_label.setText("Angemeldet: %s" % mail)
+ self._vln_status_label.setStyleSheet(
+ "color: green; font-style: italic;"
+ )
+ self._btn_vln_login.setEnabled(False)
+ else:
+ self._vln_status_label.setText("Nicht angemeldet")
+ self._vln_status_label.setStyleSheet("color: gray; font-style: italic;")
+ self._btn_vln_login.setEnabled(True)
+
+ self._btn_vln_logout.setEnabled(authenticated)
+ self._vln_verfahren_combo.setEnabled(authenticated)
+ vkz_chosen = (
+ authenticated
+ and self._vln_verfahren_combo.currentData() is not None
+ )
+ self._btn_p41_laden.setEnabled(vkz_chosen)
+ self._btn_upload.setEnabled(authenticated)
+
+ def _populate_verfahren_combo(self, restore_vkz: bool = False) -> None:
+ """Füllt die Verfahren-Combo und wählt ggf. die gespeicherte VKZ."""
+ if self.vln_logic is None:
+ return
+ verfahren = self.vln_logic.get_verfahren()
+ self._vln_restoring = True
+ self._vln_verfahren_combo.clear()
+ self._vln_verfahren_combo.addItem("— Verfahren wählen —", None)
+ for v in verfahren:
+ label = (
+ "%s — %s" % (v["vkz"], v["name"])
+ if v["name"]
+ else v["vkz"]
+ )
+ self._vln_verfahren_combo.addItem(label, v["vkz"])
+ self._vln_restoring = False
+
+ if restore_vkz:
+ stored_vkz = self.vln_logic.load_stored_vkz()
+ if stored_vkz:
+ idx = self._vln_verfahren_combo.findData(stored_vkz)
+ if idx >= 0:
+ self._vln_verfahren_combo.setCurrentIndex(idx)
+
+ self._vln_verfahren_combo.setEnabled(True)
+ self._update_vln_ui_state()
+
+ def _on_vln_login(self) -> None:
+ """Führt den API-Login durch."""
+ if self.vln_logic is None:
+ return
+ mail = self._vln_mail_edit.text().strip()
+ password = self._vln_pass_edit.text()
+ if not mail or not password:
+ QMessageBox.warning(
+ self,
+ "Anmeldung",
+ "Bitte E-Mail-Adresse und Passwort eingeben.",
+ )
+ return
+ try:
+ self.vln_logic.login(mail, password)
+ except Exception as exc:
+ QMessageBox.warning(self, "Anmeldung fehlgeschlagen", str(exc))
+ return
+
+ self._vln_pass_edit.clear()
+ try:
+ self._populate_verfahren_combo(restore_vkz=True)
+ except Exception as exc:
+ QMessageBox.warning(
+ self,
+ "Verfahrensliste",
+ "Anmeldung erfolgreich, aber Verfahrensliste konnte nicht "
+ "geladen werden: %s" % exc,
+ )
+ self._update_vln_ui_state()
+
+ def _on_vln_logout(self) -> None:
+ """Meldet den Benutzer lokal ab."""
+ if self.vln_logic is None:
+ return
+ self.vln_logic.logout()
+ self._vln_restoring = True
+ self._vln_verfahren_combo.clear()
+ self._vln_verfahren_combo.setEnabled(False)
+ self._vln_restoring = False
+ self._update_vln_ui_state()
+
+ def _on_vln_verfahren_changed(self, _index: int) -> None:
+ """Persistiert die gewählte VKZ."""
+ if self._vln_restoring or self.vln_logic is None:
+ return
+ vkz = self._vln_verfahren_combo.currentData()
+ self.vln_logic.save_vkz(vkz)
+ self._update_vln_ui_state()
+
+ def _on_p41_laden(self) -> None:
+ """Lädt den Plan-41-Datensatz für die gewählte VKZ."""
+ if self.vln_logic is None:
+ return
+ vkz = self._vln_verfahren_combo.currentData()
+ if not vkz:
+ QMessageBox.warning(
+ self, "Plan 41 laden", "Bitte zuerst ein Verfahren wählen."
+ )
+ return
+
+ # Bereits geladene Layer prüfen
+ existing = self.vln_logic.get_p41_layers(vkz)
+ if existing:
+ try:
+ answer = QMessageBox.question(
+ self,
+ "Layer ersetzen",
+ "Plan 41 für VKZ %s ist bereits geladen.\n"
+ "Beim Neuladen werden die bestehenden Layer ersetzt. Fortfahren?"
+ % vkz,
+ )
+ # Qt6: StandardButton.Yes, Qt5: QMessageBox.Yes
+ yes = getattr(
+ getattr(QMessageBox, "StandardButton", QMessageBox),
+ "Yes",
+ QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None,
+ )
+ if answer != yes:
+ return
+ except Exception:
+ pass
+ self.vln_logic.remove_p41_layers(vkz)
+
+ try:
+ layers = self.vln_logic.load_p41(vkz)
+ except Exception as exc:
+ # AuthError: Sitzung abgelaufen
+ try:
+ from sn_plan41.modules.vln_api_client import AuthError as _AuthError
+ if isinstance(exc, _AuthError):
+ self.vln_logic.handle_session_expired()
+ self._on_vln_logout()
+ QMessageBox.warning(
+ self,
+ "Sitzung abgelaufen",
+ "Der API-Key ist nicht mehr gültig — bitte neu anmelden.",
+ )
+ return
+ except ImportError:
+ pass
+ QMessageBox.warning(self, "Laden fehlgeschlagen", str(exc))
+ return
+
+ total = sum(l.featureCount() for l in layers)
+ detail = ", ".join(
+ "%s: %d" % (l.name(), l.featureCount()) for l in layers
+ )
+ if total == 0:
+ QMessageBox.warning(
+ self,
+ "Plan 41 laden",
+ "Keine Objekte vorhanden — ein leerer Layer wurde angelegt.",
+ )
+ else:
+ try:
+ from sn_basis.functions.message_wrapper import info
+ info(
+ "Plan 41 geladen",
+ "%d Layer geladen (%s)." % (len(layers), detail),
+ )
+ except Exception:
+ pass
+
+ def _on_upload_active(self) -> None:
+ """Lädt den aktiven Layer (und Geschwister-Layer) zum Server hoch."""
+ if self.vln_logic is None:
+ return
+
+ # Zusammenfassung für Bestaetigungs-Dialog ermitteln
+ try:
+ from sn_basis.functions.qgisui_wrapper import iface as _iface
+ active_layer = _iface.activeLayer() if _iface else None
+ except Exception:
+ active_layer = None
+
+ summary = self.vln_logic.upload_summary(active_layer)
+ if summary is None:
+ QMessageBox.warning(
+ self,
+ "Hochladen nicht möglich",
+ "Der aktive Layer wurde nicht über 'vln_karten' geladen.",
+ )
+ return
+
+ try:
+ answer = QMessageBox.question(
+ self,
+ "Zum Server hochladen",
+ "%s für VKZ %s übertragen?\n\n%s\n\n"
+ "%d Objekte; der bisherige Bestand dieser VKZ wird ersetzt."
+ % (
+ summary["dataset_label"],
+ summary["vkz"],
+ summary["listing"],
+ summary["total"],
+ ),
+ )
+ yes = getattr(
+ getattr(QMessageBox, "StandardButton", QMessageBox),
+ "Yes",
+ QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None,
+ )
+ if answer != yes:
+ return
+ except Exception:
+ return
+
+ try:
+ success, message = self.vln_logic.upload_active_layer(active_layer)
+ except Exception as exc:
+ try:
+ from sn_plan41.modules.vln_api_client import AuthError as _AuthError
+ if isinstance(exc, _AuthError):
+ self.vln_logic.handle_session_expired()
+ self._on_vln_logout()
+ QMessageBox.warning(
+ self,
+ "Sitzung abgelaufen",
+ "Der API-Key ist nicht mehr gültig — bitte neu anmelden.",
+ )
+ return
+ except ImportError:
+ pass
+ QMessageBox.warning(self, "Hochladen fehlgeschlagen", str(exc))
+ return
+
+ if success:
+ try:
+ from sn_basis.functions.message_wrapper import info
+ info("Upload erfolgreich", message)
+ except Exception:
+ pass
+ else:
+ QMessageBox.warning(self, "Hochladen fehlgeschlagen", message)
def _toggle_group(self, checked: bool) -> None:
"""Zeigt/verbirgt Verfahrens-DB-Gruppe."""
self.group_button.setArrowType(ArrowDown if checked else ArrowRight)