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)