➡ Fachdatenabruf
➡Versionierung der Fachdaten
➡ Planung der TG-Maßnahmen
➡Kartenerzeugung (NGG und P41)
➡ Erzeugung der Begleitdokumente (Anlagenverzeichnis, MVZ, Maßnahmeblätter)"]
+
+ M1 --> M2
+ M1 --> M3
+```
\ No newline at end of file
diff --git a/functions/__init__.py b/functions/__init__.py
index e69de29..cfbfc4a 100644
--- a/functions/__init__.py
+++ b/functions/__init__.py
@@ -0,0 +1,43 @@
+from .ly_existence_wrapper import layer_exists
+from .ly_geometry_wrapper import (
+ get_layer_geometry_type,
+ get_layer_feature_count,
+)
+from .ly_visibility_wrapper import (
+ is_layer_visible,
+ set_layer_visible,
+)
+from .ly_metadata_wrapper import (
+ get_layer_type,
+ get_layer_crs,
+ get_layer_fields,
+ get_layer_source,
+ is_layer_editable,
+)
+from .ly_style_wrapper import apply_style
+from .dialog_wrapper import ask_yes_no
+
+from .message_wrapper import (
+ _get_message_bar,
+ push_message,
+ error,
+ warning,
+ info,
+ success,
+)
+
+from .os_wrapper import *
+from .qgiscore_wrapper import *
+from .qt_wrapper import *
+from .settings_logic import *
+from .sys_wrapper import *
+from .variable_wrapper import *
+from .qgisui_wrapper import (
+get_main_window,
+add_dock_widget,
+remove_dock_widget,
+find_dock_widgets,
+add_menu,
+remove_menu,
+add_toolbar,
+remove_toolbar)
diff --git a/functions/dialog_wrapper.py b/functions/dialog_wrapper.py
new file mode 100644
index 0000000..3ed9c41
--- /dev/null
+++ b/functions/dialog_wrapper.py
@@ -0,0 +1,62 @@
+"""
+sn_basis/functions/dialog_wrapper.py – Benutzer-Dialoge
+
+Dieser Wrapper kapselt alle Benutzer-Dialoge (z. B. Ja/Nein-Abfragen)
+und sorgt dafür, dass sie sowohl in QGIS als auch im Mock-/Testmodus
+einheitlich funktionieren.
+"""
+
+from typing import Any
+
+# Import der abstrahierten Qt-Klassen aus dem qt_wrapper.
+# QMessageBox, YES und NO sind bereits kompatibel zu Qt5/Qt6
+# und im Mock-Modus durch Dummy-Objekte ersetzt.
+from sn_basis.functions.qt_wrapper import (
+ QMessageBox,
+ YES,
+ NO,
+)
+
+
+# ---------------------------------------------------------
+# Öffentliche API
+# ---------------------------------------------------------
+
+def ask_yes_no(
+ title: str,
+ message: str,
+ default: bool = False,
+ parent: Any = None,
+) -> bool:
+ """
+ Stellt dem Benutzer eine Ja/Nein-Frage.
+
+ - In einer echten QGIS-Umgebung wird ein QMessageBox-Dialog angezeigt.
+ - Im Mock-/Testmodus wird kein Dialog geöffnet, sondern der Default-Wert
+ zurückgegeben, damit Tests ohne UI laufen können.
+
+ :param title: Titel des Dialogs
+ :param message: Nachrichtentext
+ :param default: Rückgabewert im Fehler- oder Mock-Fall
+ :param parent: Optionales Parent-Widget
+ :return: True bei "Ja", False bei "Nein"
+ """
+ try:
+ # Definiert die beiden Buttons, die angezeigt werden sollen.
+ buttons = QMessageBox.Yes | QMessageBox.No
+
+ # Öffnet den Dialog (oder im Mock-Modus: simuliert ihn).
+ result = QMessageBox.question(
+ parent,
+ title,
+ message,
+ buttons,
+ YES if default else NO, # Vorauswahl abhängig vom Default
+ )
+
+ # Gibt True zurück, wenn der Benutzer "Ja" gewählt hat.
+ return result == YES
+
+ except Exception:
+ # Falls Qt nicht verfügbar ist (Mock/CI), wird der Default-Wert genutzt.
+ return default
diff --git a/functions/ly_existence_wrapper.py b/functions/ly_existence_wrapper.py
new file mode 100644
index 0000000..08ded40
--- /dev/null
+++ b/functions/ly_existence_wrapper.py
@@ -0,0 +1,31 @@
+# sn_basis/functions/ly_existence_wrapper.py
+
+def layer_exists(layer) -> bool:
+ """
+ Prüft, ob ein Layer-Objekt existiert (nicht None).
+ """
+ return layer is not None
+
+
+def layer_is_valid(layer) -> bool:
+ """
+ Prüft, ob ein Layer gültig ist (QGIS-konform).
+ """
+ if layer is None:
+ return False
+
+ is_valid = getattr(layer, "isValid", None)
+ if callable(is_valid):
+ try:
+ return bool(is_valid())
+ except Exception:
+ return False
+
+ return False
+
+
+def layer_is_usable(layer) -> bool:
+ """
+ Prüft, ob ein Layer existiert und gültig ist.
+ """
+ return layer_exists(layer) and layer_is_valid(layer)
diff --git a/functions/ly_geometry_wrapper.py b/functions/ly_geometry_wrapper.py
new file mode 100644
index 0000000..03a33de
--- /dev/null
+++ b/functions/ly_geometry_wrapper.py
@@ -0,0 +1,65 @@
+# sn_basis/functions/ly_geometry_wrapper.py
+
+from typing import Optional
+
+
+GEOM_NONE = None
+GEOM_POINT = "Point"
+GEOM_LINE = "LineString"
+GEOM_POLYGON = "Polygon"
+
+
+def get_layer_geometry_type(layer) -> Optional[str]:
+ """
+ Gibt den Geometrietyp eines Layers zurück.
+
+ Rückgabewerte:
+ - "Point"
+ - "LineString"
+ - "Polygon"
+ - None (nicht räumlich / ungültig / unbekannt)
+ """
+ if layer is None:
+ return None
+
+ try:
+ is_spatial = getattr(layer, "isSpatial", None)
+ if callable(is_spatial) and not is_spatial():
+ return None
+
+ gtype = getattr(layer, "geometryType", None)
+ if callable(gtype):
+ value = gtype()
+ if value == 0:
+ return GEOM_POINT
+ if value == 1:
+ return GEOM_LINE
+ if value == 2:
+ return GEOM_POLYGON
+ except Exception:
+ pass
+
+ return None
+
+
+def get_layer_feature_count(layer) -> int:
+ """
+ Gibt die Anzahl der Features eines Layers zurück.
+ """
+ if layer is None:
+ return 0
+
+ try:
+ is_spatial = getattr(layer, "isSpatial", None)
+ if callable(is_spatial) and not is_spatial():
+ return 0
+
+ fc = getattr(layer, "featureCount", None)
+ if callable(fc):
+ value = fc()
+ if isinstance(value, int):
+ return value
+ except Exception:
+ pass
+
+ return 0
diff --git a/functions/ly_metadata_wrapper.py b/functions/ly_metadata_wrapper.py
new file mode 100644
index 0000000..dde7a9c
--- /dev/null
+++ b/functions/ly_metadata_wrapper.py
@@ -0,0 +1,97 @@
+# sn_basis/functions/ly_metadata_wrapper.py
+
+from typing import Optional, List
+
+
+LAYER_TYPE_VECTOR = "vector"
+LAYER_TYPE_TABLE = "table"
+
+
+def get_layer_type(layer) -> Optional[str]:
+ """
+ Gibt den Layer-Typ zurück.
+
+ Rückgabewerte:
+ - "vector"
+ - "table"
+ - None (unbekannt / nicht bestimmbar)
+ """
+ if layer is None:
+ return None
+
+ try:
+ is_spatial = getattr(layer, "isSpatial", None)
+ if callable(is_spatial):
+ return LAYER_TYPE_VECTOR if is_spatial() else LAYER_TYPE_TABLE
+ except Exception:
+ pass
+
+ return None
+
+
+def get_layer_crs(layer) -> Optional[str]:
+ """
+ Gibt das CRS als AuthID zurück (z. B. 'EPSG:25833').
+ """
+ if layer is None:
+ return None
+
+ try:
+ crs = layer.crs()
+ authid = getattr(crs, "authid", None)
+ if callable(authid):
+ value = authid()
+ if isinstance(value, str):
+ return value
+ except Exception:
+ pass
+
+ return None
+
+
+def get_layer_fields(layer) -> List[str]:
+ """
+ Gibt die Feldnamen eines Layers zurück.
+ """
+ if layer is None:
+ return []
+
+ try:
+ return list(layer.fields().names())
+ except Exception:
+ return []
+
+
+
+def get_layer_source(layer) -> Optional[str]:
+ """
+ Gibt die Datenquelle eines Layers zurück.
+ """
+ if layer is None:
+ return None
+
+ try:
+ value = layer.source()
+ if isinstance(value, str) and value:
+ return value
+ except Exception:
+ pass
+
+ return None
+
+
+def is_layer_editable(layer) -> bool:
+ """
+ Prüft, ob ein Layer editierbar ist.
+ """
+ if layer is None:
+ return False
+
+ try:
+ is_editable = getattr(layer, "isEditable", None)
+ if callable(is_editable):
+ return bool(is_editable())
+ except Exception:
+ pass
+
+ return False
diff --git a/functions/ly_style_wrapper.py b/functions/ly_style_wrapper.py
new file mode 100644
index 0000000..3145532
--- /dev/null
+++ b/functions/ly_style_wrapper.py
@@ -0,0 +1,27 @@
+# sn_basis/functions/ly_style_wrapper.py
+
+from sn_basis.functions.ly_existence_wrapper import layer_exists
+from sn_basis.functions.sys_wrapper import (
+ get_plugin_root,
+ join_path,
+ file_exists,
+)
+
+
+def apply_style(layer, style_name: str) -> bool:
+ if not layer_exists(layer):
+ return False
+
+ style_path = join_path(get_plugin_root(), "styles", style_name)
+ if not file_exists(style_path):
+ return False
+
+ try:
+ ok, _ = layer.loadNamedStyle(style_path)
+ if ok:
+ getattr(layer, "triggerRepaint", lambda: None)()
+ return True
+ except Exception:
+ pass
+
+ return False
diff --git a/functions/ly_visibility_wrapper.py b/functions/ly_visibility_wrapper.py
new file mode 100644
index 0000000..ba375bc
--- /dev/null
+++ b/functions/ly_visibility_wrapper.py
@@ -0,0 +1,41 @@
+# sn_basis/functions/ly_visibility_wrapper.py
+
+def is_layer_visible(layer) -> bool:
+ """
+ Prüft, ob ein Layer im Layer-Tree sichtbar ist.
+ """
+ if layer is None:
+ return False
+
+ try:
+ node = getattr(layer, "treeLayer", None)
+ if callable(node):
+ tree_node = node()
+ is_visible = getattr(tree_node, "isVisible", None)
+ if callable(is_visible):
+ return bool(is_visible())
+ except Exception:
+ pass
+
+ return False
+
+
+def set_layer_visible(layer, visible: bool) -> bool:
+ """
+ Setzt die Sichtbarkeit eines Layers im Layer-Tree.
+ """
+ if layer is None:
+ return False
+
+ try:
+ node = getattr(layer, "treeLayer", None)
+ if callable(node):
+ tree_node = node()
+ setter = getattr(tree_node, "setItemVisibilityChecked", None)
+ if callable(setter):
+ setter(bool(visible))
+ return True
+ except Exception:
+ pass
+
+ return False
diff --git a/functions/message_wrapper.py b/functions/message_wrapper.py
new file mode 100644
index 0000000..c6f75f3
--- /dev/null
+++ b/functions/message_wrapper.py
@@ -0,0 +1,84 @@
+"""
+sn_basis/functions/message_wrapper.py – zentrale MessageBar-Abstraktion
+"""
+
+from typing import Any
+
+from sn_basis.functions.qgisui_wrapper import iface
+from sn_basis.functions.qgiscore_wrapper import Qgis
+
+
+# ---------------------------------------------------------
+# Interne Hilfsfunktion
+# ---------------------------------------------------------
+
+def _get_message_bar():
+ """
+ Liefert eine MessageBar-Instanz (QGIS oder Mock).
+ """
+ try:
+ bar = iface.messageBar()
+ if bar is not None:
+ return bar
+ except Exception:
+ pass
+
+ class _MockMessageBar:
+ def pushMessage(self, title, text, level=0, duration=5):
+ return {
+ "title": title,
+ "text": text,
+ "level": level,
+ "duration": duration,
+ }
+
+ return _MockMessageBar()
+
+
+# ---------------------------------------------------------
+# Öffentliche API
+# ---------------------------------------------------------
+
+def push_message(
+ level: int,
+ title: str,
+ text: str,
+ duration: int = 5,
+ parent: Any = None,
+):
+ """
+ Zeigt eine Message in der QGIS-MessageBar an.
+
+ Im Mock-Modus wird ein strukturierter Dict zurückgegeben.
+ """
+ bar = _get_message_bar()
+
+ try:
+ return bar.pushMessage(
+ title,
+ text,
+ level=level,
+ duration=duration,
+ )
+ except Exception:
+ return None
+
+
+def info(title: str, text: str, duration: int = 5):
+ level = Qgis.MessageLevel.Info
+ return push_message(level, title, text, duration)
+
+
+def warning(title: str, text: str, duration: int = 5):
+ level = Qgis.MessageLevel.Warning
+ return push_message(level, title, text, duration)
+
+
+def error(title: str, text: str, duration: int = 5):
+ level = Qgis.MessageLevel.Critical
+ return push_message(level, title, text, duration)
+
+
+def success(title: str, text: str, duration: int = 5):
+ level = Qgis.MessageLevel.Success
+ return push_message(level, title, text, duration)
diff --git a/functions/messages.py b/functions/messages.py
deleted file mode 100644
index fadc330..0000000
--- a/functions/messages.py
+++ /dev/null
@@ -1,44 +0,0 @@
-# sn_basis/functions/messages.py
-
-from typing import Optional
-from qgis.core import Qgis
-from qgis.PyQt.QtWidgets import QWidget
-from qgis.utils import iface
-
-
-def push_message(
- level: Qgis.MessageLevel,
- title: str,
- text: str,
- duration: Optional[int] = 5,
- parent: Optional[QWidget] = None,
-):
- """
- Zeigt eine Meldung in der QGIS-MessageBar.
- - level: Qgis.Success | Qgis.Info | Qgis.Warning | Qgis.Critical
- - title: Überschrift links (kurz halten)
- - text: eigentliche Nachricht
- - duration: Sekunden bis Auto-Ausblendung; None => bleibt sichtbar (mit Close-Button)
- - parent: optionales Eltern-Widget (für Kontext), normalerweise nicht nötig
- Rückgabe: MessageBarItem-Widget (kann später geschlossen/entfernt werden).
- """
- bar = iface.messageBar()
- # QGIS akzeptiert None als "sticky" Meldung
- return bar.pushMessage(title, text, level=level, duration=duration)
-
-
-def success(title: str, text: str, duration: int = 5):
- return push_message(Qgis.Success, title, text, duration)
-
-
-def info(title: str, text: str, duration: int = 5):
- return push_message(Qgis.Info, title, text, duration)
-
-
-def warning(title: str, text: str, duration: int = 5):
- return push_message(Qgis.Warning, title, text, duration)
-
-
-def error(title: str, text: str, duration: Optional[int] = 5):
- # Fehler evtl. länger sichtbar lassen; setze duration=None falls gewünscht
- return push_message(Qgis.Critical, title, text, duration)
diff --git a/functions/os_wrapper.py b/functions/os_wrapper.py
new file mode 100644
index 0000000..6bd6d10
--- /dev/null
+++ b/functions/os_wrapper.py
@@ -0,0 +1,77 @@
+"""
+sn_basis/functions/os_wrapper.py – Betriebssystem-Abstraktion
+"""
+
+from pathlib import Path
+import platform
+from typing import Union
+
+
+# ---------------------------------------------------------
+# OS-Erkennung
+# ---------------------------------------------------------
+
+_SYSTEM = platform.system().lower()
+
+if _SYSTEM.startswith("win"):
+ OS_NAME = "windows"
+elif _SYSTEM.startswith("darwin"):
+ OS_NAME = "macos"
+else:
+ OS_NAME = "linux"
+
+IS_WINDOWS = OS_NAME == "windows"
+IS_LINUX = OS_NAME == "linux"
+IS_MACOS = OS_NAME == "macos"
+
+
+# ---------------------------------------------------------
+# OS-Eigenschaften
+# ---------------------------------------------------------
+
+PATH_SEPARATOR = "\\" if IS_WINDOWS else "/"
+LINE_SEPARATOR = "\r\n" if IS_WINDOWS else "\n"
+
+
+# ---------------------------------------------------------
+# Pfad-Utilities
+# ---------------------------------------------------------
+
+_PathLike = Union[str, Path]
+
+
+def normalize_path(path: _PathLike) -> Path:
+ """
+ Normalisiert einen Pfad OS-unabhängig.
+ """
+ try:
+ return Path(path).expanduser().resolve()
+ except Exception:
+ return Path(path)
+
+
+def get_home_dir() -> Path:
+ """
+ Liefert das Home-Verzeichnis des aktuellen Users.
+ """
+ return Path.home()
+
+
+# ---------------------------------------------------------
+# Dateisystem-Eigenschaften
+# ---------------------------------------------------------
+
+def is_case_sensitive_fs() -> bool:
+ """
+ Gibt zurück, ob das Dateisystem case-sensitiv ist.
+ """
+ # Windows ist immer case-insensitive
+ if IS_WINDOWS:
+ return False
+
+ # macOS meist case-insensitive, aber nicht garantiert
+ if IS_MACOS:
+ return False
+
+ # Linux praktisch immer case-sensitiv
+ return True
diff --git a/functions/qgiscore_wrapper.py b/functions/qgiscore_wrapper.py
new file mode 100644
index 0000000..4a3905e
--- /dev/null
+++ b/functions/qgiscore_wrapper.py
@@ -0,0 +1,378 @@
+"""
+sn_basis/functions/qgiscore_wrapper.py – zentrale QGIS-Core-Abstraktion
+"""
+
+from typing import Type, Any, Optional
+from sn_basis.functions.qt_wrapper import (
+ QUrl,
+ QEventLoop,
+ QNetworkRequest,
+)
+
+# ---------------------------------------------------------
+# QGIS-Symbole (werden dynamisch gesetzt)
+# ---------------------------------------------------------
+
+QgsProject: Type[Any]
+QgsVectorLayer: Type[Any]
+QgsRasterLayer: Type[Any]
+QgsNetworkAccessManager: Type[Any]
+Qgis: Type[Any]
+QgsMapLayerProxyModel: Type[Any]
+QgsVectorFileWriter: Type[Any] # neu: Schreib-API
+
+QGIS_AVAILABLE = False
+
+# ---------------------------------------------------------
+# Versuch: QGIS-Core importieren
+# ---------------------------------------------------------
+
+try:
+ from qgis.core import (
+ QgsProject as _QgsProject,
+ QgsVectorLayer as _QgsVectorLayer,
+ QgsRasterLayer as _QgsRasterLayer,
+ QgsNetworkAccessManager as _QgsNetworkAccessManager,
+ Qgis as _Qgis,
+ QgsMapLayerProxyModel as _QgsMaplLayerProxyModel,
+ QgsVectorFileWriter as _QgsVectorFileWriter,
+ )
+
+ QgsProject = _QgsProject
+ QgsVectorLayer = _QgsVectorLayer
+ QgsRasterLayer = _QgsRasterLayer
+ QgsNetworkAccessManager = _QgsNetworkAccessManager
+ Qgis = _Qgis
+ QgsMapLayerProxyModel = _QgsMaplLayerProxyModel
+ QgsVectorFileWriter = _QgsVectorFileWriter
+
+ QGIS_AVAILABLE = True
+
+# ---------------------------------------------------------
+# Mock-Modus
+# ---------------------------------------------------------
+
+except Exception:
+ QGIS_AVAILABLE = False
+
+ class _MockQgsProject:
+ def __init__(self):
+ self._variables = {}
+
+ @staticmethod
+ def instance() -> "_MockQgsProject":
+ return _MockQgsProject()
+
+ def read(self) -> bool:
+ return True
+
+ QgsProject = _MockQgsProject
+
+ class _MockQgsVectorLayer:
+ def __init__(self, *args, **kwargs):
+ self._valid = True
+
+ def isValid(self) -> bool:
+ return self._valid
+
+ def loadNamedStyle(self, path: str):
+ return True, ""
+
+ def triggerRepaint(self) -> None:
+ pass
+
+ def dataProvider(self):
+ return None
+
+ QgsVectorLayer = _MockQgsVectorLayer
+
+ class _MockQgsNetworkAccessManager:
+ @staticmethod
+ def instance():
+ return _MockQgsNetworkAccessManager()
+
+ def head(self, request: Any):
+ return None
+
+ class _MockQgsRasterLayer:
+ """
+ Minimaler Mock für QgsRasterLayer, ausreichend für Tests und
+ um im Datenabruf ein Raster-Layer-Objekt im pruef_ergebnis kontext mitzugeben.
+ """
+ def __init__(self, source: str, name: str = "Raster", provider: str = "wms"):
+ self.source = source
+ self._name = name
+ self.provider = provider
+ self._valid = True
+
+ def isValid(self) -> bool:
+ return self._valid
+
+ def name(self) -> str:
+ return self._name
+
+ def dataProvider(self):
+ return None
+
+ QgsRasterLayer = _MockQgsRasterLayer
+
+ QgsNetworkAccessManager = _MockQgsNetworkAccessManager
+
+ class _MockQgis:
+ class MessageLevel:
+ Success = 0
+ Info = 1
+ Warning = 2
+ Critical = 3
+
+ Qgis = _MockQgis
+
+ class _MockQgsMapLayerProxyModel:
+ # Layer-Typen (entsprechen QGIS-Konstanten)
+ NoLayer = 0
+ VectorLayer = 1
+ RasterLayer = 2
+ PluginLayer = 3
+ MeshLayer = 4
+ VectorTileLayer = 5
+ PointCloudLayer = 6
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ QgsMapLayerProxyModel = _MockQgsMapLayerProxyModel
+
+ # ---------------------------------------------------------
+ # Mock für QgsVectorFileWriter
+ # ---------------------------------------------------------
+
+ class _MockSaveVectorOptions:
+ """
+ Minimaler Ersatz für QgsVectorFileWriter.SaveVectorOptions.
+ Felder werden als einfache Attribute bereitgestellt.
+ """
+ def __init__(self):
+ self.driverName: str = "GPKG"
+ self.layerName: Optional[str] = None
+ self.fileEncoding: str = "UTF-8"
+ # Action-Konstanten werden symbolisch verwendet
+ self.actionOnExistingFile: Optional[int] = None
+
+ class _MockQgsVectorFileWriter:
+ """
+ Minimaler Mock für QgsVectorFileWriter mit der benötigten API:
+ - SaveVectorOptions (als Klasse)
+ - writeAsVectorFormatV3(layer, path, transformContext, options) -> error_code
+ - NoError (Konstante)
+ - CreateOrOverwriteFile / CreateOrOverwriteLayer (Konstanten)
+ """
+
+ # Fehlerkonstanten (0 = NoError)
+ NoError = 0
+
+ # Action-Konstanten (Werte nur symbolisch)
+ CreateOrOverwriteFile = 1
+ CreateOrOverwriteLayer = 2
+
+ # SaveVectorOptions-Klasse
+ SaveVectorOptions = _MockSaveVectorOptions
+
+ @staticmethod
+ def writeAsVectorFormatV3(layer: Any, path: str, transform_context: Any, options: Any) -> int:
+ """
+ Mock-Schreibfunktion.
+
+ Verhalten im Mock:
+ - Wenn 'layer' None oder options.layerName fehlt, geben wir NoError zurück,
+ aber schreiben nichts (Tests erwarten nur Rückgabecode).
+ - Diese Implementierung versucht nicht, echte Dateien zu schreiben.
+ - Rückgabewert: 0 (NoError) bei Erfolg, sonst eine positive Fehlernummer.
+ """
+ try:
+ # Sehr einfache Validierung: wenn path leer -> Fehler
+ if not path:
+ return 999
+ # Simuliere Erfolg
+ return _MockQgsVectorFileWriter.NoError
+ except Exception:
+ return 999 # generischer Fehlercode
+
+ QgsVectorFileWriter = _MockQgsVectorFileWriter
+
+# ---------------------------------------------------------
+# Netzwerk
+# ---------------------------------------------------------
+
+class NetworkReply:
+ """
+ Minimaler Wrapper für Netzwerkantworten.
+ """
+ def __init__(self, error: int):
+ self.error = error
+
+
+def network_head(url: str) -> NetworkReply | None:
+ """
+ Führt einen HTTP-HEAD-Request aus.
+
+ Rückgabe:
+ - NetworkReply(error=0) → erreichbar
+ - NetworkReply(error!=0) → nicht erreichbar
+ - None → Netzwerk nicht verfügbar / Fehler beim Request
+ """
+
+ if not QGIS_AVAILABLE:
+ return None
+
+ if QUrl is None or QNetworkRequest is None:
+ return None
+
+ try:
+ manager = QgsNetworkAccessManager.instance()
+ request = QNetworkRequest(QUrl(url))
+ reply = manager.head(request)
+
+ # synchron warten (kurz)
+ if QEventLoop is not None:
+ loop = QEventLoop()
+ reply.finished.connect(loop.quit)
+ loop.exec()
+
+ return NetworkReply(error=reply.error())
+ except Exception:
+ return None
+
+# ---------------------------------------------------------
+# Layer-Geometrie / Extent
+# ---------------------------------------------------------
+
+def get_layer_extent(layer: Any) -> Any:
+ """
+ Gibt die Ausdehnung (Extent) eines Layers zurück.
+
+ Diese Funktion kapselt den Zugriff auf ``layer.extent()`` und dient als
+ zentrale Abstraktion für alle Stellen, die die Bounding Box eines Layers
+ benötigen (z.B. für räumliche Filter im Datenabruf).
+
+ Verhalten
+ ---------
+ - Wenn QGIS verfügbar ist und der Layer eine ``extent()``-Methode besitzt,
+ wird deren Rückgabewert zurückgegeben.
+ - Wenn QGIS nicht verfügbar ist oder der Layer keine ``extent()``-Methode
+ hat, wird ``None`` zurückgegeben.
+ """
+ if not QGIS_AVAILABLE or layer is None:
+ return None
+
+ extent_func = getattr(layer, "extent", None)
+ if callable(extent_func):
+ try:
+ return extent_func()
+ except Exception:
+ return None
+
+ return None
+
+# ---------------------------------------------------------
+# Buffer-Layer erzeugen
+# ---------------------------------------------------------
+
+def create_buffer_layer(
+ source_layer: Any,
+ distance_m: float,
+ layer_name: str = "BufferLayer"
+) -> Optional[Any]:
+ """
+ Erzeugt einen Pufferlayer um alle Features eines Quelllayers.
+
+ Diese Funktion dient als zentrale Abstraktion für die Erzeugung eines
+ Pufferlayers in QGIS. Sie wird z.B. im Datenabruf verwendet, wenn der
+ Raumfilter ``"Pufferlayer"`` aktiv ist.
+
+ Verhalten
+ ---------
+ - Wenn QGIS verfügbar ist und der ``source_layer`` gültig ist, wird ein
+ temporärer Vektorlayer erzeugt, der die gepufferten Geometrien enthält.
+ - Der Puffer wird in Metern angegeben.
+ - Der zurückgegebene Layer ist **nicht gespeichert**, sondern ein
+ temporärer Speicherlayer, der anschließend über den UI‑Wrapper ins
+ Projekt geladen werden kann.
+ - Wenn QGIS nicht verfügbar ist oder ein Fehler auftritt, wird ``None``
+ zurückgegeben.
+ """
+ if not QGIS_AVAILABLE:
+ return None
+
+ if source_layer is None or not hasattr(source_layer, "getFeatures"):
+ return None
+
+ try:
+ # Geometrien puffern
+ buffered_geoms = []
+ for feat in source_layer.getFeatures():
+ geom = feat.geometry()
+ if geom is None:
+ continue
+ buf = geom.buffer(distance_m, 8)
+ if buf is not None:
+ buffered_geoms.append(buf)
+
+ if not buffered_geoms:
+ return None
+
+ # Neuen Memory-Layer erzeugen
+ crs = source_layer.crs().authid() if hasattr(source_layer, "crs") else "EPSG:4326"
+ mem_layer = QgsVectorLayer(f"Polygon?crs={crs}", layer_name, "memory")
+
+ prov = mem_layer.dataProvider()
+ prov.addAttributes([])
+ mem_layer.updateFields()
+
+ # Features hinzufügen
+ from qgis.core import QgsFeature
+ for geom in buffered_geoms:
+ f = QgsFeature()
+ f.setGeometry(geom)
+ prov.addFeature(f)
+
+ mem_layer.updateExtents()
+ return mem_layer
+
+ except Exception:
+ return None
+
+#Hilfsfunktion, keine qgiscore-Entsprechung
+
+def layer_exists_in_gpkg(gpkg_path: str, layer_name: str) -> bool:
+ """
+ Prüft, ob ein Layer mit dem Namen `layer_name` in `gpkg_path` existiert.
+ - bevorzugt: SQLite-Abfrage auf gpkg_contents
+ - fallback: kurzer Versuch, mit QgsVectorLayer zu laden (wenn QGIS verfügbar)
+ """
+ import os, sqlite3
+ if not gpkg_path or not layer_name or not os.path.exists(gpkg_path):
+ return False
+
+ # 1) SQLite-Check (schnell)
+ try:
+ conn = sqlite3.connect(gpkg_path)
+ cur = conn.cursor()
+ cur.execute("SELECT COUNT(1) FROM gpkg_contents WHERE table_name = ?", (layer_name,))
+ row = cur.fetchone()
+ conn.close()
+ if row and row[0] > 0:
+ return True
+ except Exception:
+ # falls sqlite fehlschlägt, weiter zum QGIS-Fallback
+ pass
+
+ # 2) QGIS-Fallback: versuche kurz, den Layer zu laden
+ try:
+ if getattr(QgsVectorLayer, "__call__", None) and QGIS_AVAILABLE:
+ uri = f"{gpkg_path}|layername={layer_name}"
+ layer = QgsVectorLayer(uri, layer_name, "ogr")
+ return bool(layer and getattr(layer, "isValid", lambda: False)())
+ except Exception:
+ pass
+
+ return False
diff --git a/functions/qgisui_wrapper.py b/functions/qgisui_wrapper.py
new file mode 100644
index 0000000..7156afa
--- /dev/null
+++ b/functions/qgisui_wrapper.py
@@ -0,0 +1,247 @@
+"""
+sn_basis/functions/qgisui_wrapper.py – zentrale QGIS-UI-Abstraktion
+"""
+
+from __future__ import annotations
+
+from typing import Any, List, Type
+
+
+from sn_basis.functions.qt_wrapper import QDockWidget
+from sn_basis.functions.qgiscore_wrapper import QgsProject, QGIS_AVAILABLE
+
+
+iface: Any
+QGIS_UI_AVAILABLE = False
+
+QgsFileWidget: Type[Any]
+QgsMapLayerComboBox: Type[Any]
+
+
+# ---------------------------------------------------------
+# iface + QGIS-Widgets initialisieren (QGIS oder Mock)
+# ---------------------------------------------------------
+
+try:
+ from qgis.utils import iface as _iface
+ from qgis.gui import (
+ QgsFileWidget as _QgsFileWidget,
+ QgsMapLayerComboBox as _QgsMapLayerComboBox,
+ )
+
+ iface = _iface
+ QgsFileWidget = _QgsFileWidget
+ QgsMapLayerComboBox = _QgsMapLayerComboBox
+ QGIS_UI_AVAILABLE = True
+
+except Exception:
+ QGIS_UI_AVAILABLE = False
+
+ class _MockSignal:
+ def __init__(self):
+ self._callbacks: list[Any] = []
+
+ def connect(self, callback):
+ self._callbacks.append(callback)
+
+ def emit(self, *args, **kwargs):
+ for cb in list(self._callbacks):
+ cb(*args, **kwargs)
+
+ class _MockMessageBar:
+ def pushMessage(self, title, text, level=0, duration=5):
+ return {
+ "title": title,
+ "text": text,
+ "level": level,
+ "duration": duration,
+ }
+
+ class _MockIface:
+ def messageBar(self):
+ return _MockMessageBar()
+
+ def mainWindow(self):
+ return None
+
+ def addDockWidget(self, *args, **kwargs):
+ pass
+
+ def removeDockWidget(self, *args, **kwargs):
+ pass
+
+ def addToolBar(self, *args, **kwargs):
+ pass
+
+ def removeToolBar(self, *args, **kwargs):
+ pass
+
+ iface = _MockIface()
+
+ class _MockQgsFileWidget:
+ GetFile = 0
+
+ def __init__(self, *args, **kwargs):
+ self._path = ""
+ self.fileChanged = _MockSignal()
+
+ def setStorageMode(self, *args, **kwargs):
+ pass
+
+ def setFilter(self, *args, **kwargs):
+ pass
+
+ def setFilePath(self, path: str):
+ self._path = path
+ self.fileChanged.emit(path)
+
+ def filePath(self) -> str:
+ return self._path
+
+ class _MockQgsMapLayerComboBox:
+ def __init__(self, *args, **kwargs):
+ self.layerChanged = _MockSignal()
+ self._layer = None
+ self._count = 0
+
+ def setFilters(self, *args, **kwargs):
+ pass
+
+ def setLayer(self, layer):
+ self._layer = layer
+ self.layerChanged.emit(layer)
+
+ def count(self) -> int:
+ return self._count
+
+ def setCurrentIndex(self, idx: int):
+ pass
+
+ QgsFileWidget = _MockQgsFileWidget
+ QgsMapLayerComboBox = _MockQgsMapLayerComboBox
+
+
+# ---------------------------------------------------------
+# Main Window
+# ---------------------------------------------------------
+
+def get_main_window():
+ try:
+ return iface.mainWindow()
+ except Exception:
+ return None
+
+
+# ---------------------------------------------------------
+# Dock-Handling
+# ---------------------------------------------------------
+
+def add_dock_widget(area, dock: Any) -> None:
+ try:
+ iface.addDockWidget(area, dock)
+ except Exception:
+ pass
+
+
+def remove_dock_widget(dock: Any) -> None:
+ try:
+ iface.removeDockWidget(dock)
+ except Exception:
+ pass
+
+
+def find_dock_widgets() -> List[Any]:
+ main_window = get_main_window()
+ if not main_window:
+ return []
+
+ try:
+ return main_window.findChildren(QDockWidget)
+ except Exception:
+ return []
+
+
+# ---------------------------------------------------------
+# Menü-Handling
+# ---------------------------------------------------------
+
+def add_menu(menu):
+ main_window = iface.mainWindow()
+ if not main_window:
+ return
+
+ # Nur echte Qt-Menüs an Qt übergeben
+ if hasattr(menu, "menuAction"):
+ main_window.menuBar().addMenu(menu)
+
+
+def remove_menu(menu):
+ main_window = iface.mainWindow()
+ if not main_window:
+ return
+
+ if hasattr(menu, "menuAction"):
+ main_window.menuBar().removeAction(menu.menuAction())
+
+
+# ---------------------------------------------------------
+# Toolbar-Handling
+# ---------------------------------------------------------
+
+def add_toolbar(toolbar: Any) -> None:
+ try:
+ iface.addToolBar(toolbar)
+ except Exception:
+ pass
+
+
+def remove_toolbar(toolbar: Any) -> None:
+ try:
+ iface.removeToolBar(toolbar)
+ except Exception:
+ pass
+# ---------------------------------------------------------
+# Layer zum Projekt hinzufügen
+# ---------------------------------------------------------
+
+def add_layer_to_project(layer: Any) -> bool:
+ """
+ Fügt einen Layer dem aktuellen QGIS-Projekt hinzu.
+
+ Diese Funktion kapselt den Zugriff auf ``QgsProject.instance().addMapLayer``
+ und dient als zentrale Abstraktion für alle Stellen, die Layer dynamisch
+ ins Projekt einfügen möchten (z.B. Pufferlayer im Datenabruf).
+
+ Verhalten
+ ---------
+ - Wenn QGIS verfügbar ist und der Layer gültig ist, wird er dem Projekt
+ hinzugefügt und ``True`` zurückgegeben.
+ - Wenn QGIS nicht verfügbar ist oder der Layer ungültig ist, wird
+ ``False`` zurückgegeben.
+ - Im Mock-Modus wird kein Layer hinzugefügt, aber ``True`` zurückgegeben,
+ damit Tests ohne QGIS nicht fehlschlagen.
+
+ Parameters
+ ----------
+ layer:
+ Ein QGIS-Layer (typischerweise ``QgsVectorLayer``), der dem Projekt
+ hinzugefügt werden soll.
+
+ Returns
+ -------
+ bool
+ ``True`` bei Erfolg oder im Mock-Modus, sonst ``False``.
+ """
+ if layer is None:
+ return False
+
+ # Mock-Modus: Erfolg simulieren
+ if not QGIS_AVAILABLE:
+ return True
+
+ try:
+ project = QgsProject.instance()
+ project.addMapLayer(layer)
+ return True
+ except Exception:
+ return False
diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py
new file mode 100644
index 0000000..706c6b0
--- /dev/null
+++ b/functions/qt_wrapper.py
@@ -0,0 +1,580 @@
+"""
+sn_basis/functions/qt_wrapper.py – zentrale Qt-Abstraktion (PyQt5 / PyQt6 / Mock)
+"""
+
+from typing import Optional, Type, Any
+
+# ---------------------------------------------------------
+# Qt-Symbole (werden dynamisch gesetzt)
+# ---------------------------------------------------------
+
+QDockWidget: Type[Any]
+QMessageBox: Type[Any]
+QFileDialog: Type[Any]
+QEventLoop: Type[Any]
+QUrl: Type[Any]
+QNetworkRequest: Type[Any]
+QNetworkReply: Type[Any]
+QCoreApplication: Type[Any]
+
+QWidget: Type[Any]
+QGridLayout: Type[Any]
+QLabel: Type[Any]
+QLineEdit: Type[Any]
+QGroupBox: Type[Any]
+QVBoxLayout: Type[Any]
+QPushButton: Type[Any]
+QAction: Type[Any]
+QMenu: Type[Any]
+QToolBar: Type[Any]
+QActionGroup: Type[Any]
+QTabWidget: type
+QToolButton: Type[Any]
+QSizePolicy: Type[Any]
+Qt: Type[Any]
+QComboBox: Type[Any]
+
+YES: Optional[Any] = None
+NO: Optional[Any] = None
+CANCEL: Optional[Any] = None
+ICON_QUESTION: Optional[Any] = None
+
+QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6
+
+
+def exec_dialog(dialog: Any) -> Any:
+ raise NotImplementedError
+
+
+# ---------------------------------------------------------
+# Versuch: PyQt6
+# ---------------------------------------------------------
+
+try:
+ from qgis.PyQt.QtWidgets import ( # type: ignore
+ QMessageBox as _QMessageBox,# type: ignore
+ QFileDialog as _QFileDialog,# type: ignore
+ QWidget as _QWidget,# type: ignore
+ QGridLayout as _QGridLayout,# type: ignore
+ QLabel as _QLabel,# type: ignore
+ QLineEdit as _QLineEdit,# type: ignore
+ QGroupBox as _QGroupBox,# type: ignore
+ QVBoxLayout as _QVBoxLayout,# type: ignore
+ QPushButton as _QPushButton,# type: ignore
+ QAction as _QAction,
+ QMenu as _QMenu,# type: ignore
+ QToolBar as _QToolBar,# type: ignore
+ QActionGroup as _QActionGroup,# type: ignore
+ QDockWidget as _QDockWidget,# type: ignore
+ QTabWidget as _QTabWidget,# type: ignore
+ QToolButton as _QToolButton,#type:ignore
+ QSizePolicy as _QSizePolicy,#type:ignore
+ QComboBox as _QComboBox,
+
+)
+
+
+
+ from qgis.PyQt.QtCore import ( # type: ignore
+ QEventLoop as _QEventLoop,# type: ignore
+ QUrl as _QUrl,# type: ignore
+ QCoreApplication as _QCoreApplication,# type: ignore
+ Qt as _Qt#type:ignore
+ )
+ from qgis.PyQt.QtNetwork import ( # type: ignore
+ QNetworkRequest as _QNetworkRequest,# type: ignore
+ QNetworkReply as _QNetworkReply,# type: ignore
+ )
+ QT_VERSION = 6
+ QMessageBox = _QMessageBox
+ QFileDialog = _QFileDialog
+ QEventLoop = _QEventLoop
+ QUrl = _QUrl
+ QNetworkRequest = _QNetworkRequest
+ QNetworkReply = _QNetworkReply
+ QCoreApplication = _QCoreApplication
+ Qt=_Qt
+ QDockWidget = _QDockWidget
+ QWidget = _QWidget
+ QGridLayout = _QGridLayout
+ QLabel = _QLabel
+ QLineEdit = _QLineEdit
+ QGroupBox = _QGroupBox
+ QVBoxLayout = _QVBoxLayout
+ QPushButton = _QPushButton
+ QAction = _QAction
+ QMenu = _QMenu
+ QToolBar = _QToolBar
+ QActionGroup = _QActionGroup
+ QTabWidget = _QTabWidget
+ QToolButton=_QToolButton
+ QSizePolicy=_QSizePolicy
+ QComboBox=_QComboBox
+
+ YES = QMessageBox.StandardButton.Yes
+ NO = QMessageBox.StandardButton.No
+ CANCEL = QMessageBox.StandardButton.Cancel
+ ICON_QUESTION = QMessageBox.Icon.Question
+ # ---------------------------------------------------------
+ # Qt6 Enum-Aliase (vereinheitlicht)
+ # ---------------------------------------------------------
+
+ ToolButtonTextBesideIcon = Qt.ToolButtonStyle.ToolButtonTextBesideIcon
+ ArrowDown = Qt.ArrowType.DownArrow
+ ArrowRight = Qt.ArrowType.RightArrow
+ # QSizePolicy Enum-Aliase (Qt6)
+ SizePolicyPreferred = QSizePolicy.Policy.Preferred
+ SizePolicyMaximum = QSizePolicy.Policy.Maximum
+ # ---------------------------------------------------------
+ # QDockWidget Feature-Aliase (Qt6)
+ # ---------------------------------------------------------
+
+ DockWidgetMovable = QDockWidget.DockWidgetFeature.DockWidgetMovable
+ DockWidgetFloatable = QDockWidget.DockWidgetFeature.DockWidgetFloatable
+ DockWidgetClosable = QDockWidget.DockWidgetFeature.DockWidgetClosable
+ # ---------------------------------------------------------
+ # Dock-Area-Aliase (Qt6)
+ # ---------------------------------------------------------
+
+ DockAreaLeft = Qt.DockWidgetArea.LeftDockWidgetArea
+ DockAreaRight = Qt.DockWidgetArea.RightDockWidgetArea
+
+
+
+
+ def exec_dialog(dialog: Any) -> Any:
+ return dialog.exec()
+
+# ---------------------------------------------------------
+# Versuch: PyQt5
+# ---------------------------------------------------------
+
+except Exception:
+ try:
+ from PyQt5.QtWidgets import (# type: ignore
+ QMessageBox as _QMessageBox,
+ QFileDialog as _QFileDialog,
+ QWidget as _QWidget,
+ QGridLayout as _QGridLayout,
+ QLabel as _QLabel,
+ QLineEdit as _QLineEdit,
+ QGroupBox as _QGroupBox,
+ QVBoxLayout as _QVBoxLayout,
+ QPushButton as _QPushButton,
+ QAction as _QAction,
+ QMenu as _QMenu,
+ QToolBar as _QToolBar,
+ QActionGroup as _QActionGroup,
+ QDockWidget as _QDockWidget,
+ QTabWidget as _QTabWidget,
+ QToolButton as _QToolButton,
+ QSizePolicy as _QSizePolicy,
+ QComboBox as _QComboBox,
+ )
+ from PyQt5.QtCore import (# type: ignore
+ QEventLoop as _QEventLoop,
+ QUrl as _QUrl,
+ QCoreApplication as _QCoreApplication,
+ Qt as _Qt,
+ )
+ from PyQt5.QtNetwork import (# type: ignore
+ QNetworkRequest as _QNetworkRequest,
+ QNetworkReply as _QNetworkReply,
+ )
+
+
+ QMessageBox = _QMessageBox
+ QFileDialog = _QFileDialog
+ QEventLoop = _QEventLoop
+ QUrl = _QUrl
+ QNetworkRequest = _QNetworkRequest
+ QNetworkReply = _QNetworkReply
+ QCoreApplication = _QCoreApplication
+ Qt=_Qt
+ QDockWidget = _QDockWidget
+
+
+ QWidget = _QWidget
+ QGridLayout = _QGridLayout
+ QLabel = _QLabel
+ QLineEdit = _QLineEdit
+ QGroupBox = _QGroupBox
+ QVBoxLayout = _QVBoxLayout
+ QPushButton = _QPushButton
+ QAction = _QAction
+ QMenu = _QMenu
+ QToolBar = _QToolBar
+ QActionGroup = _QActionGroup
+ QTabWidget = _QTabWidget
+ QToolButton=_QToolButton
+ QSizePolicy=_QSizePolicy
+ ComboBox=_QComboBox
+
+ YES = QMessageBox.Yes
+ NO = QMessageBox.No
+ CANCEL = QMessageBox.Cancel
+ ICON_QUESTION = QMessageBox.Question
+
+ QT_VERSION = 5
+
+ # then try next backend
+ # ---------------------------------------------------------
+ # Qt5 Enum-Aliase (vereinheitlicht)
+ # ---------------------------------------------------------
+
+ ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon
+ ArrowDown = Qt.DownArrow
+ ArrowRight = Qt.RightArrow
+ # QSizePolicy Enum-Aliase (Qt5)
+ SizePolicyPreferred = QSizePolicy.Preferred
+ SizePolicyMaximum = QSizePolicy.Maximum
+ # ---------------------------------------------------------
+ # QDockWidget Feature-Aliase (Qt5)
+ # ---------------------------------------------------------
+
+ DockWidgetMovable = QDockWidget.DockWidgetMovable
+ DockWidgetFloatable = QDockWidget.DockWidgetFloatable
+ DockWidgetClosable = QDockWidget.DockWidgetClosable
+ # ---------------------------------------------------------
+ # Dock-Area-Aliase (Qt5)
+ # ---------------------------------------------------------
+
+ DockAreaLeft = Qt.LeftDockWidgetArea
+ DockAreaRight = Qt.RightDockWidgetArea
+
+
+ def exec_dialog(dialog: Any) -> Any:
+ return dialog.exec_()
+
+# ---------------------------------------------------------
+# Mock-Modus
+# ---------------------------------------------------------
+
+ except Exception:
+ QT_VERSION = 0
+
+ class FakeEnum(int):
+ def __or__(self, other: int) -> "FakeEnum":
+ return FakeEnum(int(self) | int(other))
+
+ YES = FakeEnum(1)
+ NO = FakeEnum(2)
+ CANCEL = FakeEnum(4)
+ ICON_QUESTION = FakeEnum(8)
+
+ class _MockQMessageBox:
+ Yes = YES
+ No = NO
+ Cancel = CANCEL
+ Question = ICON_QUESTION
+
+ QMessageBox = _MockQMessageBox
+
+ class _MockQFileDialog:
+ @staticmethod
+ def getOpenFileName(*args, **kwargs):
+ return ("", "")
+
+ @staticmethod
+ def getSaveFileName(*args, **kwargs):
+ return ("", "")
+
+ QFileDialog = _MockQFileDialog
+
+ class _MockQEventLoop:
+ def exec(self) -> int:
+ return 0
+
+ def quit(self) -> None:
+ pass
+
+ QEventLoop = _MockQEventLoop
+
+ class _MockQUrl(str):
+ def isValid(self) -> bool:
+ return True
+
+ QUrl = _MockQUrl
+
+ class _MockQNetworkRequest:
+ def __init__(self, url: Any):
+ self.url = url
+
+ QNetworkRequest = _MockQNetworkRequest
+
+ class _MockQNetworkReply:
+ def error(self) -> int:
+ return 0
+
+ def errorString(self) -> str:
+ return ""
+
+ def readAll(self) -> bytes:
+ return b""
+
+ def deleteLater(self) -> None:
+ pass
+
+ QNetworkReply = _MockQNetworkReply
+
+ class _MockWidget:
+ def __init__(self, *args, **kwargs):
+ pass
+
+ class _MockLayout:
+ def __init__(self, *args, **kwargs):
+ self._widgets = []
+
+ def addWidget(self, widget):
+ self._widgets.append(widget)
+
+ def addLayout(self, layout):
+ pass
+
+ def addStretch(self, *args, **kwargs):
+ pass
+
+ def setSpacing(self, *args, **kwargs):
+ pass
+
+ def setContentsMargins(self, *args, **kwargs):
+ pass
+
+
+
+ class _MockLabel:
+ def __init__(self, text: str = ""):
+ self._text = text
+
+ class _MockLineEdit:
+ def __init__(self, *args, **kwargs):
+ self._text = ""
+
+ def text(self) -> str:
+ return self._text
+
+ def setText(self, value: str) -> None:
+ self._text = value
+
+ class _MockButton:
+ def __init__(self, *args, **kwargs):
+ self.clicked = lambda *a, **k: None
+
+ QWidget = _MockWidget
+ QGridLayout = _MockLayout
+ QLabel = _MockLabel
+ QLineEdit = _MockLineEdit
+ QGroupBox = _MockWidget
+ QVBoxLayout = _MockLayout
+ QPushButton = _MockButton
+
+ class _MockQCoreApplication:
+ pass
+
+ QCoreApplication = _MockQCoreApplication
+ class _MockQt:
+ # ToolButtonStyle
+ ToolButtonTextBesideIcon = 0
+
+ # ArrowType
+ ArrowDown = 1
+ ArrowRight = 2
+
+ Qt=_MockQt
+ ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon
+ ArrowDown = Qt.ArrowDown
+ ArrowRight = Qt.ArrowRight
+
+ class _MockQDockWidget(_MockWidget):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._object_name = ""
+
+ def setObjectName(self, name: str) -> None:
+ self._object_name = name
+
+ def objectName(self) -> str:
+ return self._object_name
+
+ def show(self) -> None:
+ pass
+
+ def deleteLater(self) -> None:
+ pass
+ QDockWidget = _MockQDockWidget
+ class _MockAction:
+ def __init__(self, *args, **kwargs):
+ self._checked = False
+ self.triggered = lambda *a, **k: None
+
+ def setToolTip(self, text: str) -> None:
+ pass
+
+ def setCheckable(self, value: bool) -> None:
+ pass
+
+ def setChecked(self, value: bool) -> None:
+ self._checked = value
+
+
+ class _MockMenu:
+ def __init__(self, *args, **kwargs):
+ self._actions = []
+
+ def addAction(self, action):
+ self._actions.append(action)
+
+ def removeAction(self, action):
+ if action in self._actions:
+ self._actions.remove(action)
+
+ def clear(self):
+ self._actions.clear()
+
+ def menuAction(self):
+ return self
+
+
+ class _MockToolBar:
+ def __init__(self, *args, **kwargs):
+ self._actions = []
+
+ def setObjectName(self, name: str) -> None:
+ pass
+
+ def addAction(self, action):
+ self._actions.append(action)
+
+ def removeAction(self, action):
+ if action in self._actions:
+ self._actions.remove(action)
+
+ def clear(self):
+ self._actions.clear()
+
+
+ class _MockActionGroup:
+ def __init__(self, *args, **kwargs):
+ self._actions = []
+
+ def setExclusive(self, value: bool) -> None:
+ pass
+
+ def addAction(self, action):
+ self._actions.append(action)
+ QAction = _MockAction
+ QMenu = _MockMenu
+ QToolBar = _MockToolBar
+ QActionGroup = _MockActionGroup
+
+ class _MockToolButton(_MockWidget):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self._checked = False
+ self.toggled = lambda *a, **k: None
+
+ def setText(self, text: str) -> None:
+ pass
+
+ def setCheckable(self, value: bool) -> None:
+ pass
+
+ def setChecked(self, value: bool) -> None:
+ self._checked = value
+
+ def setToolButtonStyle(self, *args, **kwargs):
+ pass
+
+ def setArrowType(self, *args, **kwargs):
+ pass
+
+ def setStyleSheet(self, *args, **kwargs):
+ pass
+
+ QToolButton=_MockToolButton
+
+ class _MockQSizePolicy:
+ # horizontale Policies
+ Fixed = 0
+ Minimum = 1
+ Maximum = 2
+ Preferred = 3
+ Expanding = 4
+ MinimumExpanding = 5
+ Ignored = 6
+
+ # vertikale Policies (Qt nutzt dieselben Werte)
+ def __init__(self, horizontal=None, vertical=None):
+ self.horizontal = horizontal
+ self.vertical = vertical
+ QSizePolicy=_MockQSizePolicy
+ SizePolicyPreferred = QSizePolicy.Preferred
+ SizePolicyMaximum = QSizePolicy.Maximum
+ DockWidgetMovable = 1
+ DockWidgetFloatable = 2
+ DockWidgetClosable = 4
+ DockAreaLeft = 1
+ DockAreaRight = 2
+
+ def exec_dialog(dialog: Any) -> Any:
+ return YES
+ class _MockTabWidget:
+ def __init__(self, *args, **kwargs):
+ self._tabs = []
+
+ def addTab(self, widget, title: str):
+ self._tabs.append((widget, title))
+ QTabWidget = _MockTabWidget
+
+ # -------------------------
+ # Mock ComboBox Implementation
+ # -------------------------
+ class _MockSignal:
+ def __init__(self):
+ self._slots = []
+
+ def connect(self, cb):
+ self._slots.append(cb)
+
+ def emit(self, value):
+ for s in list(self._slots):
+ try:
+ s(value)
+ except Exception:
+ pass
+
+ class _MockComboBox:
+ def __init__(self, parent=None):
+ self._items = []
+ self._index = -1
+ self.currentTextChanged = _MockSignal()
+
+ def addItem(self, text: str) -> None:
+ self._items.append(text)
+
+ def addItems(self, items):
+ for it in items:
+ self.addItem(it)
+
+ def findText(self, text: str) -> int:
+ try:
+ return self._items.index(text)
+ except ValueError:
+ return -1
+
+ def setCurrentIndex(self, idx: int) -> None:
+ if 0 <= idx < len(self._items):
+ self._index = idx
+ self.currentTextChanged.emit(self.currentText())
+
+ def setCurrentText(self, text: str) -> None:
+ idx = self.findText(text)
+ if idx >= 0:
+ self.setCurrentIndex(idx)
+
+ def currentText(self) -> str:
+ if 0 <= self._index < len(self._items):
+ return self._items[self._index]
+ return ""
+
+ ComboBox = _MockComboBox
diff --git a/functions/settings_logic.py b/functions/settings_logic.py
index 64b209a..73543ce 100644
--- a/functions/settings_logic.py
+++ b/functions/settings_logic.py
@@ -1,37 +1,47 @@
-from qgis.core import QgsProject, QgsExpressionContextUtils
+"""
+sn_basis/functions/settings_logic.py – Logik zum Lesen und Schreiben der Plugin-Einstellungen
+über den zentralen variable_wrapper.
+"""
+
+from sn_basis.functions.variable_wrapper import (
+ get_variable,
+ set_variable,
+)
+
class SettingsLogic:
- def __init__(self):
- self.project = QgsProject.instance()
+ """
+ Verwaltet das Laden und Speichern der Plugin-Einstellungen.
+ Alle Variablen werden als sn_* Projektvariablen gespeichert.
+ """
- # Definition der Variablen-Namen
- self.global_vars = ["amt", "behoerde", "landkreis_user", "sachgebiet"]
- self.project_vars = ["bezeichnung", "verfahrensnummer", "gemeinden", "landkreise_proj"]
+ # Alle Variablen, die gespeichert werden sollen
+ VARIABLEN = [
+ "amt",
+ "behoerde",
+ "landkreis_user",
+ "sachgebiet",
+ "bezeichnung",
+ "verfahrensnummer",
+ "gemeinden",
+ "landkreise_proj",
+ ]
- def save(self, fields: dict):
- """Speichert Felder als globale und projektbezogene Ausdrucksvariablen."""
+ def load(self) -> dict[str, str]:
+ """
+ Lädt alle Variablen aus dem Projekt.
+ Rückgabe: dict mit allen Werten (leere Strings, wenn nicht gesetzt).
+ """
+ daten: dict[str, str] = {}
+ for key in self.VARIABLEN:
+ daten[key] = get_variable(key, scope="project")
+ return daten
- # Globale Variablen
- for key in self.global_vars:
- QgsExpressionContextUtils.setGlobalVariable(f"sn_{key}", fields.get(key, ""))
-
- # Projektvariablen
- for key in self.project_vars:
- QgsExpressionContextUtils.setProjectVariable(self.project, f"sn_{key}", fields.get(key, ""))
-
- print("✅ Ausdrucksvariablen gespeichert.")
-
- def load(self) -> dict:
- """Lädt Werte ausschließlich aus Ausdrucksvariablen (global + projektbezogen)."""
-
- data = {}
-
- # Globale Variablen
- for key in self.global_vars:
- data[key] = QgsExpressionContextUtils.globalScope().variable(f"sn_{key}") or ""
-
- # Projektvariablen
- for key in self.project_vars:
- data[key] = QgsExpressionContextUtils.projectScope(self.project).variable(f"sn_{key}") or ""
-
- return data
+ def save(self, daten: dict[str, str]) -> None:
+ """
+ Speichert alle übergebenen Variablen im Projekt.
+ daten: dict mit key → value
+ """
+ for key, value in daten.items():
+ if key in self.VARIABLEN:
+ set_variable(key, value, scope="project")
diff --git a/functions/styles.py b/functions/styles.py
deleted file mode 100644
index 0723717..0000000
--- a/functions/styles.py
+++ /dev/null
@@ -1,28 +0,0 @@
-# sn_basis/functions/styles.py
-import os
-from qgis.core import QgsVectorLayer
-
-def apply_style(layer: QgsVectorLayer, style_name: str) -> bool:
- """
- Lädt einen QML-Style aus dem styles-Ordner des Plugins und wendet ihn auf den Layer an.
- style_name: Dateiname ohne Pfad, z.B. 'verfahrensgebiet.qml'
- Rückgabe: True bei Erfolg, False sonst
- """
- if not layer or not layer.isValid():
- return False
-
- # Basis-Pfad: sn_basis/styles
- base_dir = os.path.dirname(os.path.dirname(__file__)) # geht von functions/ eins hoch
- style_path = os.path.join(base_dir, "styles", style_name)
-
- if not os.path.exists(style_path):
- print(f"Style-Datei nicht gefunden: {style_path}")
- return False
-
- ok, error_msg = layer.loadNamedStyle(style_path)
- if not ok:
- print(f"Style konnte nicht geladen werden: {error_msg}")
- return False
-
- layer.triggerRepaint()
- return True
diff --git a/functions/sys_wrapper.py b/functions/sys_wrapper.py
new file mode 100644
index 0000000..75b1899
--- /dev/null
+++ b/functions/sys_wrapper.py
@@ -0,0 +1,104 @@
+"""
+sn_basis/functions/sys_wrapper.py – System- und Pfad-Abstraktion
+"""
+
+from pathlib import Path
+from typing import Union
+import sys
+
+
+_PathLike = Union[str, Path]
+
+
+# ---------------------------------------------------------
+# Plugin Root
+# ---------------------------------------------------------
+
+def get_plugin_root() -> Path:
+ """
+ Liefert das Basisverzeichnis des Plugins.
+ """
+ return Path(__file__).resolve().parents[2]
+
+
+# ---------------------------------------------------------
+# Pfad-Utilities
+# ---------------------------------------------------------
+
+def join_path(*parts: _PathLike) -> Path:
+ """
+ Verbindet Pfadbestandteile OS-sicher.
+ """
+ path = Path(parts[0])
+ for part in parts[1:]:
+ path /= part
+ return path
+
+
+def file_exists(path: _PathLike) -> bool:
+ """
+ Prüft, ob eine Datei existiert.
+ """
+ try:
+ return Path(path).exists()
+ except Exception:
+ return False
+
+
+def ensure_dir(path: _PathLike) -> Path:
+ """
+ Stellt sicher, dass ein Verzeichnis existiert.
+ """
+ p = Path(path)
+ p.mkdir(parents=True, exist_ok=True)
+ return p
+
+
+# ---------------------------------------------------------
+# Datei-IO
+# ---------------------------------------------------------
+
+def read_text(path: _PathLike, encoding: str = "utf-8") -> str:
+ """
+ Liest eine Textdatei.
+ """
+ try:
+ return Path(path).read_text(encoding=encoding)
+ except Exception:
+ return ""
+
+
+def write_text(
+ path: _PathLike,
+ content: str,
+ encoding: str = "utf-8",
+) -> bool:
+ """
+ Schreibt eine Textdatei.
+ """
+ try:
+ Path(path).write_text(content, encoding=encoding)
+ return True
+ except Exception:
+ return False
+
+
+
+def add_to_sys_path(path: Union[str, Path]) -> None:
+ """
+ Fügt einen Pfad zu sys.path hinzu, falls er noch nicht enthalten ist.
+ """
+ p = str(path)
+ if p not in sys.path:
+ sys.path.insert(0, p)
+def getattr_safe(obj, attr, default=None):
+ """
+ Sicherer Zugriff auf ein Attribut.
+
+ Gibt das Attribut zurück, wenn es existiert,
+ ansonsten den Default-Wert (None, wenn nicht angegeben).
+ """
+ try:
+ return getattr(obj, attr)
+ except Exception:
+ return default
diff --git a/functions/test.md b/functions/test.md
new file mode 100644
index 0000000..84240dc
--- /dev/null
+++ b/functions/test.md
@@ -0,0 +1,14 @@
+mermaid´´´
+flowchart TD
+ A[Projekt]
+
+ subgraph children[ ]
+ direction TB
+ B[src]
+ C[docs]
+ D[README.md]
+ end
+
+ A --> B
+ A --> C
+ A --> D
diff --git a/functions/variable_utils.py b/functions/variable_utils.py
deleted file mode 100644
index 6ac4af9..0000000
--- a/functions/variable_utils.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from qgis.core import QgsProject, QgsExpressionContextUtils
-
-def get_variable(key: str, scope: str = "project") -> str:
- """
- Liefert den Wert einer sn_* Variable zurück.
- key: Name ohne Präfix, z.B. "verfahrensnummer"
- scope: 'project' oder 'global'
- """
- projekt = QgsProject.instance()
- var_name = f"sn_{key}"
-
- if scope == "project":
- return QgsExpressionContextUtils.projectScope(projekt).variable(var_name) or ""
- elif scope == "global":
- return QgsExpressionContextUtils.globalScope().variable(var_name) or ""
- else:
- raise ValueError("Scope muss 'project' oder 'global' sein.")
-
-
-def set_variable(key: str, value: str, scope: str = "project"):
- """
- Schreibt den Wert einer sn_* Variable.
- key: Name ohne Präfix, z.B. "verfahrensnummer"
- value: Wert, der gespeichert werden soll
- scope: 'project' oder 'global'
- """
- projekt = QgsProject.instance()
- var_name = f"sn_{key}"
-
- if scope == "project":
- QgsExpressionContextUtils.setProjectVariable(projekt, var_name, value)
- elif scope == "global":
- QgsExpressionContextUtils.setGlobalVariable(var_name, value)
- else:
- raise ValueError("Scope muss 'project' oder 'global' sein.")
diff --git a/functions/variable_wrapper.py b/functions/variable_wrapper.py
new file mode 100644
index 0000000..d3f8a2d
--- /dev/null
+++ b/functions/variable_wrapper.py
@@ -0,0 +1,115 @@
+"""
+sn_basis/functions/variable_wrapper.py – QGIS-Variablen-Abstraktion
+"""
+
+from typing import Any
+
+from sn_basis.functions.qgiscore_wrapper import QgsProject
+
+
+# ---------------------------------------------------------
+# Versuch: QgsExpressionContextUtils importieren
+# ---------------------------------------------------------
+
+try:
+ from qgis.core import QgsExpressionContextUtils
+
+ _HAS_QGIS_VARIABLES = True
+
+# ---------------------------------------------------------
+# Mock-Modus
+# ---------------------------------------------------------
+
+except Exception:
+ _HAS_QGIS_VARIABLES = False
+
+ class _MockVariableStore:
+ global_vars: dict[str, str] = {}
+ project_vars: dict[str, str] = {}
+
+ class QgsExpressionContextUtils:
+ @staticmethod
+ def setGlobalVariable(name: str, value: str) -> None:
+ _MockVariableStore.global_vars[name] = value
+
+ @staticmethod
+ def globalScope():
+ class _Scope:
+ def variable(self, name: str) -> str:
+ return _MockVariableStore.global_vars.get(name, "")
+
+ return _Scope()
+
+ @staticmethod
+ def setProjectVariable(project: Any, name: str, value: str) -> None:
+ _MockVariableStore.project_vars[name] = value
+
+ @staticmethod
+ def projectScope(project: Any):
+ class _Scope:
+ def variable(self, name: str) -> str:
+ return _MockVariableStore.project_vars.get(name, "")
+
+ return _Scope()
+
+
+# ---------------------------------------------------------
+# Öffentliche API
+# ---------------------------------------------------------
+
+def get_variable(key: str, scope: str = "project") -> str:
+ """
+ Liest eine QGIS-Variable.
+
+ :param key: Variablenname ohne Prefix
+ :param scope: 'project' oder 'global'
+ """
+ var_name = f"sn_{key}"
+
+ if scope == "project":
+ project = QgsProject.instance()
+ return (
+ QgsExpressionContextUtils
+ .projectScope(project)
+ .variable(var_name)
+ or ""
+ )
+
+ if scope == "global":
+ return (
+ QgsExpressionContextUtils
+ .globalScope()
+ .variable(var_name)
+ or ""
+ )
+
+ raise ValueError("Scope muss 'project' oder 'global' sein.")
+
+
+def set_variable(key: str, value: str, scope: str = "project") -> None:
+ """
+ Setzt eine QGIS-Variable.
+
+ :param key: Variablenname ohne Prefix
+ :param value: Wert
+ :param scope: 'project' oder 'global'
+ """
+ var_name = f"sn_{key}"
+
+ if scope == "project":
+ project = QgsProject.instance()
+ QgsExpressionContextUtils.setProjectVariable(
+ project,
+ var_name,
+ value,
+ )
+ return
+
+ if scope == "global":
+ QgsExpressionContextUtils.setGlobalVariable(
+ var_name,
+ value,
+ )
+ return
+
+ raise ValueError("Scope muss 'project' oder 'global' sein.")
diff --git a/main.py b/main.py
index 8fc2b7f..414820f 100644
--- a/main.py
+++ b/main.py
@@ -1,26 +1,53 @@
-from qgis.PyQt.QtCore import QCoreApplication
+# sn_basis/main.py
+
from qgis.utils import plugins
+
+from sn_basis.functions.qt_wrapper import QCoreApplication
+from sn_basis.functions.sys_wrapper import getattr_safe
from sn_basis.ui.navigation import Navigation
+
class BasisPlugin:
+ """
+ Einstiegspunkt des sn_basis-Plugins.
+ Orchestriert UI und Fachmodule – keine UI-Logik.
+ """
+
def __init__(self, iface):
- self.iface = iface
+ # iface wird von QGIS übergeben, aber nicht direkt verwendet
self.ui = None
- QCoreApplication.instance().aboutToQuit.connect(self.unload)
+
+ # QCoreApplication kann im Mock-Modus None sein
+ if QCoreApplication is not None:
+ app = getattr_safe(QCoreApplication, "instance")
+ if callable(app):
+ instance = app()
+ about_to_quit = getattr_safe(instance, "aboutToQuit")
+ connect = getattr_safe(about_to_quit, "connect")
+ if callable(connect):
+ connect(self.unload)
def initGui(self):
- # Basis-Navigation neu aufbauen
- self.ui = Navigation(self.iface)
-
- # Alle Fachplugins mit "sn_" prüfen und neu initialisieren
+ """
+ Initialisiert die Basis-Navigation und triggert initGui
+ aller abhängigen sn_-Plugins.
+ """
+ self.ui = Navigation()
+ self.ui.init_ui()
for name, plugin in plugins.items():
if name.startswith("sn_") and name != "sn_basis":
try:
- plugin.initGui()
+ init_gui = getattr_safe(plugin, "initGui")
+ if callable(init_gui):
+ init_gui()
except Exception as e:
print(f"Fehler beim Neuinitialisieren von {name}: {e}")
+ self.ui.finalize_menu_and_toolbar()
def unload(self):
+ """
+ Räumt UI-Komponenten sauber auf.
+ """
if self.ui:
self.ui.remove_all()
self.ui = None
diff --git a/modules/DataGrabber.py b/modules/DataGrabber.py
new file mode 100644
index 0000000..b077dee
--- /dev/null
+++ b/modules/DataGrabber.py
@@ -0,0 +1,424 @@
+# sn_basis/modules/DataGrabber.py
+"""
+DataGrabber module
+==================
+
+Leichter Orchestrator, der eine Quelle (Datei, Einzellink, Datenbank)
+analysiert, passende Prüfer aufruft und die Ergebnisse an den
+:class:`sn_basis.modules.Pruefmanager.Pruefmanager` delegiert.
+
+Dieses vereinfachte Modul geht davon aus, dass alle benötigten Prüfer
+und der ExcelImporter vorhanden und importierbar sind. Es enthält
+keine Fallbacks oder defensive Exception-Handling-Pfade für fehlende
+Prüfer-Module — fehlende Komponenten führen zu Import- oder Laufzeitfehlern,
+die bewusst nicht unterdrückt werden.
+"""
+
+from __future__ import annotations
+
+from typing import (
+ Optional,
+ Any,
+ Mapping,
+ Iterable,
+ Dict,
+ Protocol,
+ Literal,
+ Tuple,
+ List,
+)
+from pathlib import Path
+import sqlite3
+
+from sn_basis.modules.Pruefmanager import Pruefmanager
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion
+
+# In dieser vereinfachten Variante werden die Prüfer und der ExcelImporter
+# direkt importiert. Fehlende Module führen zu ImportError (gewollt).
+from sn_basis.modules.Dateipruefer import Dateipruefer
+from sn_basis.modules.linkpruefer import Linkpruefer
+from sn_basis.modules.layerpruefer import Layerpruefer
+from sn_basis.modules.stilpruefer import Stilpruefer
+from sn_basis.modules.excel_importer import ExcelImporter
+
+
+SourceType = Literal["file", "link", "database", "unknown"]
+
+
+class LinklistAdapter(Protocol):
+ """
+ Minimal-Protokoll für Adapter, die Linklisten liefern/normalisieren.
+
+ Implementierende Klassen sollten:
+ - load() -> Iterable[Mapping[str, Any]]
+ - normalize(raw_item) -> Mapping[str, Any]
+ """
+ def load(self) -> Iterable[Mapping[str, Any]]:
+ ...
+ def normalize(self, raw_item: Mapping[str, Any]) -> Mapping[str, Any]:
+ ...
+
+
+class DataGrabber:
+ """
+ DataGrabber orchestriert das Einlesen einer Quelle und die Übergabe an Prüfer.
+
+ Diese vereinfachte Implementierung erwartet, dass alle Prüferklassen und
+ der ExcelImporter vorhanden sind. Es gibt keine defensive Logik für
+ fehlende Komponenten.
+
+ Konstruktor-Parameter
+ --------------------
+ :param pruefmanager: Instanz des Pruefmanagers (verpflichtend).
+ :param datei_pruefer_cls: Klasse des Dateipruefers (Standard: Dateipruefer).
+ :param link_pruefer: Instanz des Linkpruefers.
+ :param layer_pruefer: Instanz des Layerpruefers.
+ :param stil_pruefer: Instanz des Stilpruefers.
+ """
+
+ def __init__(
+ self,
+ pruefmanager: Pruefmanager,
+ *,
+ datei_pruefer_cls=Dateipruefer,
+ link_pruefer: Linkpruefer,
+ layer_pruefer: Layerpruefer,
+ stil_pruefer: Stilpruefer,
+ ) -> None:
+ # Pruefmanager ist verpflichtend
+ self.pruefmanager: Pruefmanager = pruefmanager
+
+ # Dateipruefer-Klasse (wird zur Laufzeit mit einem Pfad instanziert)
+ self._datei_pruefer_cls = datei_pruefer_cls
+
+ # Prüfer-Instanzen (werden direkt verwendet)
+ self.link_pruefer: Linkpruefer = link_pruefer
+ self.layer_pruefer: Layerpruefer = layer_pruefer
+ self.stil_pruefer: Stilpruefer = stil_pruefer
+
+ # Quelle (wird später gesetzt)
+ self.source: Optional[str] = None
+
+ # ------------------------------------------------------------------ #
+ # Source Management
+ # ------------------------------------------------------------------ #
+ def set_source(self, source: str) -> None:
+ """
+ Setzt die Quelle für den DataGrabber.
+
+ Die Quelle ist ein String, der entweder ein lokaler Dateipfad,
+ ein Einzellink (URL/URI) oder ein Pfad zu einer Datenbank/GeoPackage ist.
+ """
+ self.source = source
+
+ def analyze_source(self, source: str) -> SourceType:
+ """
+ Klassifiziert die angegebene Quelle ausschließlich anhand des Dateipruefers.
+
+ Ablauf
+ ------
+ 1. Instanziere den Dateipruefer mit `pfad=source` und `temporaer_erlaubt=False`.
+ 2. Rufe `pruefe()` auf und werte das zurückgegebene :class:`pruef_ergebnis` aus.
+ 3. Bei `ok==True` wird anhand der Dateiendung zwischen "database" (gpkg/sqlite/db)
+ und "file" unterschieden.
+ 4. Bei `ok==False` werden typische Aktionen wie "datei_nicht_gefunden" als "link"
+ interpretiert; bei "falsche_endung" wird anhand der Endung klassifiziert.
+ """
+ dp = self._datei_pruefer_cls(pfad=source, temporaer_erlaubt=False)
+ pe: pruef_ergebnis = dp.pruefe()
+
+ if getattr(pe, "ok", False):
+ suffix = Path(source).suffix.lower()
+ if suffix in (".gpkg", ".sqlite", ".db"):
+ return "database"
+ return "file"
+
+ aktion = getattr(pe, "aktion", None)
+ if aktion in ("datei_nicht_gefunden", "pfad_nicht_gefunden", "kein_dateipfad"):
+ return "link"
+ if aktion == "falsche_endung":
+ lower = source.lower()
+ for db_ext in (".gpkg", ".sqlite", ".db"):
+ if lower.endswith(db_ext):
+ return "database"
+ for file_ext in (".xlsx", ".xls", ".csv"):
+ if lower.endswith(file_ext):
+ return "file"
+ return "unknown"
+
+ # ------------------------------------------------------------------ #
+ # Excel-Verarbeitung
+ #Es werden alle Werte mit gültigem Link übernommen. Die restliche Struktur
+ #wird nicht überprüft, da alle Fachplugins unterschiedliche Strukturen haben können
+ # ------------------------------------------------------------------ #
+ def process_excel_source(
+ self,
+ filepath: str
+ ) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], Any]:
+ """
+ Liest eine Excel-Datei ein und übernimmt ausschließlich die Zeilen,
+ deren Link durch den Linkpruefer als gültig eingestuft wurde.
+
+ Ablauf
+ ------
+ 1. Die Excel-Datei wird mit dem ``ExcelImporter`` eingelesen.
+ Erwartet wird eine Liste von Mappings (z.B. dicts), die jeweils
+ die Linkparameter enthalten.
+
+ 2. Für jede Zeile wird der Wert ``row["Link"]`` extrahiert und durch
+ ``self.link_pruefer.pruefe(...)`` geprüft.
+
+ 3. Das Prüfergebnis wird durch ``self.pruefmanager.verarbeite(...)``
+ geleitet, der UI-Interaktion, Logging und finale Entscheidung übernimmt.
+
+ 4. Nur Zeilen, deren verarbeitete Prüfergebnisse ``ok == True`` liefern,
+ werden in die Ergebnisliste übernommen.
+
+ 5. Wenn mindestens eine Zeile gültig ist, wird ein Dict der Form::
+
+ {"rows": [row1, row2, ...]}
+
+ zurückgegeben.
+ Wenn keine Zeile gültig ist, wird ``None`` zurückgegeben.
+
+ Parameter
+ ---------
+ filepath:
+ Pfad zur Excel-Datei, die eingelesen werden soll.
+
+ Returns
+ -------
+ Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]
+ - ``data``: ``{"rows": [...]} `` wenn gültige Zeilen existieren,
+ sonst ``None``.
+ - ``pruef_ergebnis``: ein zusammenfassendes Prüfergebnis, das
+ den Lesevorgang beschreibt (nicht die Einzelprüfungen).
+
+ Hinweise
+ --------
+ - Diese Methode führt **keine Normalisierung** durch.
+ - Die Verantwortung für die Struktur der Excel-Zeilen liegt beim Fachplugin.
+ - Der Linkpruefer prüft ausschließlich den Wert ``row["Link"]``.
+ """
+
+ # 1) Excel einlesen
+ importer = ExcelImporter(filepath=filepath, pruefmanager=self.pruefmanager)
+ rows = importer.import_xlsx() # erwartet: List[Mapping[str, Any]]
+
+ valid_rows: List[Mapping[str, Any]] = []
+
+ # 2) Jede Zeile einzeln prüfen
+ for row in rows:
+ raw_link = row.get("Link")
+
+ # 2a) Fachliche Prüfung
+ pe = self.link_pruefer.pruefe(raw_link)
+
+ # 2b) Verarbeitung durch den Pruefmanager
+ processed = self.pruefmanager.verarbeite(pe)
+
+ # 2c) Nur gültige Zeilen übernehmen
+ if getattr(processed, "ok", False):
+ valid_rows.append(row)
+
+ # 3) Zusammenfassendes Prüfergebnis erzeugen
+ if valid_rows:
+ pe_ok = pruef_ergebnis(
+ ok=True,
+ meldung=f"{len(valid_rows)} gültige Zeilen aus Excel gelesen",
+ aktion="ok",
+ kontext=filepath,
+ )
+ processed_summary = self.pruefmanager.verarbeite(pe_ok)
+ return {"rows": valid_rows}, processed_summary
+
+ # Keine gültigen Zeilen
+ pe_fail = pruef_ergebnis(
+ ok=False,
+ meldung="Keine gültigen Links in der Excel-Datei gefunden",
+ aktion="read_error",
+ kontext=filepath,
+ )
+ processed_summary = self.pruefmanager.verarbeite(pe_fail)
+ return None, processed_summary
+
+
+ # ------------------------------------------------------------------ #
+ # Einzellink-Verarbeitung
+ # ------------------------------------------------------------------ #
+ def process_single_link(
+ self,
+ link: Mapping[str, Any]
+ ) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], Any]:
+ """
+ Prüft einen einzelnen Link anhand der im Link-Dict enthaltenen Link-URL.
+
+ Ablauf
+ ------
+ 1. Erwartet wird ein Mapping (z.B. dict), das die Linkparameter enthält.
+ Mindestens der Schlüssel ``"Link"`` muss vorhanden sein.
+
+ 2. Der eigentliche Link (z.B. URL) wird aus ``link["Link"]`` extrahiert
+ und an ``self.link_pruefer.pruefe(...)`` übergeben.
+
+ 3. Das Prüfergebnis wird anschließend durch ``self.pruefmanager.verarbeite(...)``
+ geleitet, der UIInteraktion, Logging und finale Entscheidung übernimmt.
+
+ 4. Wenn das verarbeitete Prüfergebnis **nicht OK** ist, wird
+ ``(None, pruef_ergebnis)`` zurückgegeben.
+
+ 5. Wenn das Prüfergebnis **OK** ist, wird das unveränderte LinkDict
+ in der Struktur ``{"rows": [link]}`` zurückgegeben.
+
+ Parameter
+ ---------
+ link:
+ Ein Mapping mit den Linkparametern (z.B. id, Thema, Gruppe, Link,
+ Anbieter, Stildatei). Diese Methode verändert das Mapping nicht.
+
+ Returns
+ -------
+ Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]
+ - ``data``: ``{"rows": [link]}`` wenn gültig, sonst ``None``
+ - ``pruef_ergebnis``: das vom Pruefmanager verarbeitete Ergebnis
+
+ Hinweise
+ --------
+ - Diese Methode führt **keine Normalisierung** durch.
+ - Die Verantwortung für die Struktur des Link-Dicts liegt beim Fachplugin.
+ - Der Linkpruefer prüft ausschließlich den Wert ``link["Link"]``.
+ """
+
+ # 1) Link extrahieren (Fachplugin garantiert, dass "Link" existiert)
+ raw_link = link.get("Link")
+
+ # 2) Fachliche Prüfung durch den Linkpruefer
+ pruef_ergebnis = self.link_pruefer.pruefe(raw_link)
+
+ # 3) Verarbeitung durch den Pruefmanager
+ processed = self.pruefmanager.verarbeite(pruef_ergebnis)
+
+ # 4) Wenn Prüfung nicht OK → keine Daten zurückgeben
+ if not getattr(processed, "ok", False):
+ return None, processed
+
+ # 5) Prüfung OK → unverändertes Link-Dict zurückgeben
+ data = {"rows": [link]}
+
+ return data, processed
+
+
+
+
+ # ------------------------------------------------------------------ #
+ # Datenbank-Verarbeitung
+ # ------------------------------------------------------------------ #
+ #def process_database_table(self, db_path: str, table: Optional[str] = None) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]:
+ #noch nicht implementiert
+ """
+ Liest eine Tabelle aus einer SQLite/GeoPackage-Datei.
+
+ Verhalten
+ ---------
+ 1. Validiert die Datei mit dem Dateipruefer.
+ 2. Falls OK, versucht es, die angegebene Tabelle zu lesen; falls keine Tabelle
+ angegeben ist, wird nach einer typischen Metadaten-Tabelle 'layer_metadaten'
+ gesucht und diese gelesen.
+ 3. Gibt die Zeilen als Liste von Dicts zurück.
+ """
+ dp = self._datei_pruefer_cls(pfad=db_path, temporaer_erlaubt=False)
+ pe = dp.pruefe()
+ processed = self.pruefmanager.verarbeite(pe)
+ if not getattr(processed, "ok", False):
+ return None, processed
+
+ conn = sqlite3.connect(db_path)
+ cur = conn.cursor()
+ if table:
+ cur.execute(f"SELECT * FROM {table}")
+ cols = [d[0] for d in cur.description]
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]
+ else:
+ cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='layer_metadaten'")
+ if cur.fetchone():
+ cur.execute("SELECT * FROM layer_metadaten")
+ cols = [d[0] for d in cur.description]
+ rows = [dict(zip(cols, r)) for r in cur.fetchall()]
+ else:
+ rows = []
+ conn.close()
+
+ pe_ok = pruef_ergebnis(ok=True, meldung="DB gelesen", aktion="ok", kontext=db_path)
+ processed_ok = self.pruefmanager.verarbeite(pe_ok)
+ return {"rows": rows}, processed_ok
+
+ # ------------------------------------------------------------------ #
+ # Hauptlauf / Dispatch
+ # ------------------------------------------------------------------ #
+ def run(self) -> Dict[str, Any]:
+ """
+ Hauptmethode des DataGrabbers.
+
+ Ablauf
+ ------
+ 1. Prüft, ob eine Quelle gesetzt ist.
+ 2. Klassifiziert die Quelle via :meth:`analyze_source`.
+ 3. Dispatch:
+ - file (.xlsx/.xls) -> :meth:`process_excel_source`
+ - link -> :meth:`process_single_link`
+ - database -> :meth:`process_database_table`
+ - unknown -> Fehler
+ 4. Aggregiert geladene Einträge in einem Ergebnis-Dict und gibt dieses zurück.
+
+ Rückgabeformat
+ -------------
+ Ein Dict mit den Schlüsseln:
+ - 'geladen' : Liste der geladenen Themen/Namen
+ - 'fehler' : Mapping Thema -> Fehlermeldung
+ - 'ausserhalb': Liste der als ausserhalb klassifizierten Themen
+ - 'relevant' : Liste der relevanten Themen
+ - 'details' : zusätzliche Detailinformationen (z. B. Anzahl Zeilen)
+ """
+ result: Dict[str, Any] = {"geladen": [], "fehler": {}, "ausserhalb": [], "relevant": [], "details": {}}
+
+ if not self.source:
+ pe = pruef_ergebnis(ok=False, meldung="Keine Quelle gesetzt", aktion="kein_dateipfad", kontext=None)
+ processed = self.pruefmanager.verarbeite(pe)
+ result["fehler"]["source"] = getattr(processed, "meldung", "Keine Quelle")
+ return result
+
+ src_type = self.analyze_source(self.source)
+
+ if src_type == "file":
+ suffix = Path(self.source).suffix.lower()
+ if suffix in (".xlsx", ".xls"):
+ data_dict, pe = self.process_excel_source(self.source)
+ else:
+ pe = pruef_ergebnis(ok=False, meldung="Dateityp nicht unterstützt", aktion="falsche_endung", kontext=self.source)
+ pe = self.pruefmanager.verarbeite(pe)
+ data_dict = None
+
+ elif src_type == "link":
+ data_dict, pe = self.process_single_link(self.source)
+
+ #elif src_type == "database":
+ #data_dict, pe = self.process_database_table(self.source, table=None)
+
+ else:
+ pe = pruef_ergebnis(ok=False, meldung="Quelle unbekannt", aktion="kein_dateipfad", kontext=self.source)
+ pe = self.pruefmanager.verarbeite(pe)
+ data_dict = None
+
+ # Falls Daten vorhanden: fülle result['geladen'] und details
+ if data_dict and "rows" in data_dict:
+ rows = data_dict["rows"]
+ for r in rows:
+ thema = r.get("Inhalt") or r.get("ident") or r.get("Link") or "unbenannt"
+ result["geladen"].append(thema)
+ result["details"]["source_rows"] = len(rows)
+
+ # Falls das letzte pruef_ergebnis einen Fehler enthält, übernehme es
+ if not getattr(pe, "ok", False):
+ result["fehler"]["source"] = getattr(pe, "meldung", "Fehler bei Quelle")
+
+ return result
diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py
new file mode 100644
index 0000000..31e5c25
--- /dev/null
+++ b/modules/Dateipruefer.py
@@ -0,0 +1,134 @@
+"""
+sn_basis/modules/Dateipruefer.py – Prüfung von Dateieingaben für das Plugin.
+Verwendet sys_wrapper und gibt pruef_ergebnis an den Pruefmanager zurück.
+"""
+
+from pathlib import Path
+
+from sn_basis.functions.sys_wrapper import (
+ join_path,
+ file_exists,
+)
+
+from sn_basis.modules.Pruefmanager import pruef_ergebnis
+
+
+class Dateipruefer:
+ """
+ Prüft Dateieingaben und liefert ein pruef_ergebnis zurück.
+ Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager.
+ """
+
+ def __init__(
+ self,
+ pfad: str,
+ basis_pfad: str = "",
+ leereingabe_erlaubt: bool = False,
+ standarddatei: str | None = None,
+ temporaer_erlaubt: bool = False,
+ ):
+ self.pfad = pfad
+ self.basis_pfad = basis_pfad
+ self.leereingabe_erlaubt = leereingabe_erlaubt
+ self.standarddatei = standarddatei
+ self.temporaer_erlaubt = temporaer_erlaubt
+
+ # ---------------------------------------------------------
+ # Hilfsfunktion
+ # ---------------------------------------------------------
+
+ def _pfad(self, relativer_pfad: str) -> Path:
+ """
+ Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis.
+ """
+ return join_path(self.basis_pfad, relativer_pfad)
+
+ # ---------------------------------------------------------
+ # Hauptfunktion
+ # ---------------------------------------------------------
+
+ def pruefe(self) -> pruef_ergebnis:
+ """
+ Prüft eine Dateieingabe und liefert ein pruef_ergebnis zurück.
+ Der Pruefmanager entscheidet später, wie der Nutzer gefragt wird.
+ """
+
+ # -----------------------------------------------------
+ # 1. Fall: Eingabe ist leer
+ # -----------------------------------------------------
+ if not self.pfad:
+ return self._handle_leere_eingabe()
+
+ # -----------------------------------------------------
+ # 2. Fall: Eingabe ist nicht leer → Datei prüfen
+ # -----------------------------------------------------
+ pfad = self._pfad(self.pfad)
+
+ if not file_exists(pfad):
+ return pruef_ergebnis(
+ ok=False,
+ meldung=f"Die Datei '{self.pfad}' wurde nicht gefunden.",
+ aktion="datei_nicht_gefunden",
+ kontext=pfad,
+ )
+
+ # -----------------------------------------------------
+ # 3. Datei existiert → Erfolg
+ # -----------------------------------------------------
+ return pruef_ergebnis(
+ ok=True,
+ meldung="Datei gefunden.",
+ aktion="ok",
+ kontext=pfad,
+ )
+
+ # ---------------------------------------------------------
+ # Behandlung leerer Eingaben
+ # ---------------------------------------------------------
+
+ def _handle_leere_eingabe(self) -> pruef_ergebnis:
+ """
+ Liefert ein pruef_ergebnis für den Fall, dass das Dateifeld leer ist.
+ Der Pruefmanager fragt später den Nutzer.
+ """
+
+ # 1. Leereingabe erlaubt → Nutzer fragen, ob das beabsichtigt war
+ if self.leereingabe_erlaubt:
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Das Dateifeld ist leer. Soll ohne Datei fortgefahren werden?",
+ aktion="leereingabe_erlaubt",
+ kontext=None,
+ )
+
+ # 2. Standarddatei verfügbar → Nutzer fragen, ob sie verwendet werden soll
+ if self.standarddatei:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=(
+ f"Es wurde keine Datei angegeben. "
+ f"Soll die Standarddatei '{self.standarddatei}' verwendet werden?"
+ ),
+ aktion="standarddatei_vorschlagen",
+ kontext=self._pfad(self.standarddatei),
+ )
+
+ # 3. Temporäre Datei erlaubt → Nutzer fragen, ob temporär gearbeitet werden soll
+ if self.temporaer_erlaubt:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=(
+ "Es wurde keine Datei angegeben. "
+ "Sollen temporäre Layer erzeugt werden?"
+ ),
+ aktion="temporaer_erlaubt",
+ kontext=None,
+ )
+
+ # 4. Leereingabe nicht erlaubt → Fehler
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Es wurde keine Datei angegeben.",
+ aktion="leereingabe_nicht_erlaubt",
+ kontext=None,
+ )
diff --git a/modules/Datenabruf.py b/modules/Datenabruf.py
new file mode 100644
index 0000000..7ff9036
--- /dev/null
+++ b/modules/Datenabruf.py
@@ -0,0 +1,405 @@
+# sn_basis/modules/Datenabruf.py
+"""
+Modul ``datenabruf``
+
+Enthält die Klasse :class:`Datenabruf`, die für eine Menge bereits
+validierter Links (aus ``validate_rows``) die Fachdaten abruft und
+aggregierte Prüfergebnisse liefert.
+
+Designprinzipien
+----------------
+- Die BBOX wird serverseitig angewendet: wenn ein Raumfilter aktiv ist,
+ wird die BBOX in die Abruf-URL eingebettet (außer bei WMS).
+- Alle QGIS-Interaktionen laufen über die Wrapper `qgiscore_wrapper` und
+ `qgisui_wrapper`.
+- Fehler werden als kurze Strings zurückgegeben und zentral in `log_fehler`
+ gesammelt; erfolgreiche Aufrufe werden in `log_geladen` protokolliert.
+- Die Methode ist pdoc-kompatibel dokumentiert und bewusst einfach gehalten.
+"""
+
+from typing import Any, Dict, List, Mapping, Optional, Tuple
+
+from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse
+import json
+
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
+from sn_basis.functions import qgiscore_wrapper as qgiscore
+from sn_basis.functions import qgisui_wrapper as qgisui
+from sn_basis.functions import qt_wrapper as qt
+
+DataDict = Dict[str, List[Mapping[str, Any]]]
+
+
+class Datenabruf:
+ """
+ Führt den eigentlichen Fachdatenabruf für eine Menge validierter Links durch.
+
+ Erwartet ein ``DataDict`` der Form ``{"rows": [row1, row2, ...]}``.
+ """
+
+ def __init__(self, pruefmanager: Any) -> None:
+ """
+ Initialisiert eine neue Instanz des Datenabrufs.
+
+ Parameters
+ ----------
+ pruefmanager:
+ Instanz des Pruefmanagers, der :class:`pruef_ergebnis` verarbeitet.
+ """
+ self.pruefmanager = pruefmanager
+
+ # ------------------------------------------------------------------ #
+ # Öffentliche API
+ # ------------------------------------------------------------------ #
+
+ def datenabruf(
+ self,
+ result_dict: DataDict,
+ raumfilter: str,
+ verfahrensgebiet_layer: Any,
+ speicherort: str,
+ pruef_ergebnisse: Optional[List[Any]] = None,
+ ) -> Tuple[Dict[str, Any], List[Any]]:
+ """
+ Ruft für alle Zeilen in ``result_dict["rows"]`` die Fachdaten ab und
+ liefert ein Daten‑Dict sowie die Liste verarbeiteter Pruefergebnisse.
+
+ Logging / Aggregation
+ ---------------------
+ Am Ende enthält das zusammenfassende PruefErgebnis im Kontext:
+ - geladen: dict(dienst -> anzahl geladen)
+ - fehler: dict(dienst -> fehlermeldung)
+ - relevant: dict(dienst -> anzahl relevant)
+ - ausserhalb: dict(dienst -> anzahl geladen, aber ausserhalb)
+ """
+ if pruef_ergebnisse is None:
+ processed_results: List[Any] = []
+ else:
+ processed_results = list(pruef_ergebnisse)
+
+ rows = result_dict.get("rows", [])
+ daten: Dict[str, List[Any]] = {}
+
+ # 1) Räumliche Filtergeometrie bestimmen (BBox oder None)
+ bbox_geom = self._determine_spatial_filter(raumfilter, verfahrensgebiet_layer)
+
+ # Globale Logs über alle Dienste hinweg
+ log_geladen: Dict[str, int] = {}
+ log_fehler: Dict[str, str] = {}
+ log_relevant: Dict[str, int] = {}
+ log_ausserhalb: Dict[str, int] = {}
+
+ # 2) Über alle Zeilen iterieren
+ for row in rows:
+ ident = row.get("ident")
+ link = row.get("Link")
+ provider = row.get("Provider")
+
+ if not ident or not link or not provider:
+ pe = pruef_ergebnis(
+ ok=False,
+ meldung="Ungültige Zeile im Datenabruf (fehlende Pflichtfelder)",
+ aktion="pflichtfelder_fehlen",
+ kontext=row,
+ )
+ processed_results.append(self.pruefmanager.verarbeite(pe))
+ continue
+
+ # Lesbarer Dienstname für Logs
+ thema = row.get("Inhalt") or row.get("Thema") or row.get("Titel") or str(ident)
+
+ # 2a) Provider-spezifische URL zusammenbauen
+ # Wenn Raumfilter aktiv ist, übergeben wir bbox_geom an _build_provider_url,
+ # außer bei WMS (WMS bleibt unverändert).
+ use_bbox = (raumfilter != "ohne") and (str(provider).upper() != "WMS")
+ url = self._build_provider_url(link=link, provider=str(provider), bbox_geom=bbox_geom if use_bbox else None)
+
+ # 2b) Fachdaten abrufen
+ features, error_msg = self._fetch_features(url=url, provider=str(provider))
+
+ # 2c) Logs und Aggregation
+ if error_msg:
+ # Fehler beim Abruf
+ log_fehler[thema] = error_msg
+ pe_err = pruef_ergebnis(
+ ok=False,
+ meldung=f"Fehler beim Abruf von {thema}: {error_msg}",
+ aktion="url_nicht_erreichbar",
+ kontext={"ident": ident, "thema": thema, "url": url, "error": error_msg},
+ )
+ processed_results.append(self.pruefmanager.verarbeite(pe_err))
+ # daten[ident] bleibt nicht gesetzt oder leer
+ daten[str(ident)] = []
+ continue
+
+ # Erfolgreich aufgerufen (auch wenn features == [])
+ anzahl_geladen = len(features)
+ log_geladen[thema] = anzahl_geladen
+
+ # Da die BBOX serverseitig angewendet wurde:
+ # - anzahl_geladen > 0 -> relevant
+ # - anzahl_geladen == 0 -> ausserhalb
+ if anzahl_geladen > 0:
+ log_relevant[thema] = anzahl_geladen
+ daten[str(ident)] = features
+ else:
+ log_ausserhalb[thema] = 0
+ daten[str(ident)] = []
+
+ # 2d) Kurzes Prüfergebnis pro Zeile
+ pe_row = pruef_ergebnis(
+ ok=True,
+ meldung=(
+ f"Datenabruf für ident={ident}: {anzahl_geladen} geladene Objekte"
+ ),
+ aktion="datenabruf",
+ kontext={
+ "ident": ident,
+ "thema": thema,
+ "anzahl_gesamt": anzahl_geladen,
+ "url": url,
+ },
+ )
+ processed_results.append(self.pruefmanager.verarbeite(pe_row))
+
+ # 3) Zusammenfassendes Prüfergebnis (wie alter DataGrabber)
+ summary_kontext = {
+ "geladen": log_geladen,
+ "fehler": log_fehler,
+ "relevant": log_relevant,
+ "ausserhalb": log_ausserhalb,
+ }
+
+ pe_summary = pruef_ergebnis(
+ ok=(len(log_fehler) == 0),
+ meldung=(
+ f"Datenabruf abgeschlossen: {len(log_geladen)} Dienste geladen, "
+ f"{len(log_fehler)} Fehler"
+ ),
+ aktion="datenabruf",
+ kontext=summary_kontext,
+ )
+ processed_results.append(self.pruefmanager.verarbeite(pe_summary))
+
+ daten_dict: Dict[str, Any] = {
+ "speicherort": speicherort,
+ "daten": daten,
+ }
+ return daten_dict, processed_results
+
+ # ------------------------------------------------------------------ #
+ # Hilfsmethoden: räumlicher Filter
+ # ------------------------------------------------------------------ #
+
+ def _determine_spatial_filter(self, raumfilter: str, verfahrensgebiet_layer: Any) -> Optional[Any]:
+ """
+ Bestimmt die räumliche Filtergeometrie (BBox) abhängig vom Raumfilter.
+
+ Returns
+ -------
+ Optional[Any]
+ Eine Geometrie/Extent (z. B. QgsRectangle) oder ``None``.
+ """
+ if raumfilter == "ohne":
+ return None
+
+ if verfahrensgebiet_layer is None:
+ return None
+
+ if raumfilter == "Verfahrensgebiet":
+ return qgiscore.get_layer_extent(verfahrensgebiet_layer)
+
+ if raumfilter == "Pufferlayer":
+ buffer_layer = qgiscore.create_buffer_layer(
+ source_layer=verfahrensgebiet_layer,
+ distance_m=1000.0,
+ layer_name="Verfahrensgebiet_Puffer_1km",
+ )
+ if buffer_layer is not None:
+ qgisui.add_layer_to_project(buffer_layer)
+ return qgiscore.get_layer_extent(buffer_layer)
+
+ return None
+
+ # ------------------------------------------------------------------ #
+ # Hilfsmethoden: Provider-URL und Datenabruf
+ # ------------------------------------------------------------------ #
+
+ def _build_provider_url(self, link: str, provider: str, bbox_geom: Optional[Any]) -> str:
+ """
+ Baut eine Provider-spezifische Abruf-URL. Wenn `bbox_geom` übergeben
+ wird, wird sie in die URL eingebettet (außer bei WMS).
+
+ Erwartet: provider ist gesetzt (z. B. "WFS", "REST", "OGR", "WMS").
+ """
+ provider_norm = (provider or "").upper()
+ base_link = link or ""
+
+ # WMS: niemals BBOX anhängen
+ if provider_norm == "WMS":
+ return base_link
+
+ if bbox_geom is None:
+ return base_link
+
+ # Versuche bbox-String zu erzeugen (nutzt qgiscore.extent_to_bbox_string wenn vorhanden)
+ bbox_str: Optional[str] = None
+ try:
+ extent_to_bbox = getattr(__import__("sn_basis.functions.qgiscore_wrapper", fromlist=["qgiscore_wrapper"]), "extent_to_bbox_string", None)
+ if callable(extent_to_bbox):
+ bbox_str = extent_to_bbox(bbox_geom)
+ else:
+ # Fallback: einfache xmin/ymin/xmax/ymax-Extraktion (duck-typing)
+ if hasattr(bbox_geom, "xmin") and callable(getattr(bbox_geom, "xmin")):
+ bbox_str = f"{bbox_geom.xmin()},{bbox_geom.ymin()},{bbox_geom.xmax()},{bbox_geom.ymax()}"
+ elif isinstance(bbox_geom, (tuple, list)) and len(bbox_geom) == 4:
+ bbox_str = f"{bbox_geom[0]},{bbox_geom[1]},{bbox_geom[2]},{bbox_geom[3]}"
+ else:
+ bbox_str = str(bbox_geom)
+ except Exception:
+ bbox_str = None
+
+ if not bbox_str:
+ return base_link
+
+ parsed = urlparse(base_link)
+ query_params = dict(parse_qsl(parsed.query, keep_blank_values=True))
+
+ if provider_norm == "WFS":
+ query_params.setdefault("BBOX", bbox_str)
+ new_query = urlencode(query_params, doseq=True)
+ rebuilt = parsed._replace(query=new_query)
+ return urlunparse(rebuilt)
+
+ if provider_norm in ("REST", "ARCGIS", "ARCGISFEATURESERVER", "ARCGIS_FEATURESERVER"):
+ query_params.setdefault("geometry", bbox_str)
+ query_params.setdefault("geometryType", "esriGeometryEnvelope")
+ query_params.setdefault("spatialRel", "esriSpatialRelIntersects")
+ query_params.setdefault("f", query_params.get("f", "json"))
+ new_query = urlencode(query_params, doseq=True)
+ rebuilt = parsed._replace(query=new_query)
+ return urlunparse(rebuilt)
+
+ # Default: generischer bbox-Parameter
+ query_params.setdefault("bbox", bbox_str)
+ new_query = urlencode(query_params, doseq=True)
+ rebuilt = parsed._replace(query=new_query)
+ return urlunparse(rebuilt)
+
+ def _fetch_features(self, url: str, provider: str) -> Tuple[List[Any], Optional[str]]:
+ """
+ Führt den eigentlichen Abruf der Fachdaten durch.
+
+ Returns
+ -------
+ Tuple[List[Any], Optional[str]]
+ - features: Liste der geladenen Features (ggf. leer)
+ - error_msg: None bei Erfolg, sonst kurzer Fehlertext
+ """
+ features: List[Any] = []
+ prov = str(provider).upper()
+
+ # WMS: kein Featureabruf; caller behandelt WMS separat (hier defensiv)
+ if prov == "WMS":
+ return [], None
+
+ # OGR / lokale Dateien: versuche QGIS-Layer (wenn QGIS verfügbar)
+ if prov in ("OGR", "GPKG", "SHP", "GEOJSON"):
+ if getattr(qgiscore, "QGIS_AVAILABLE", False):
+ try:
+ layer = qgiscore.QgsVectorLayer(url, "tmp", "ogr")
+ if not layer or not getattr(layer, "isValid", lambda: False)():
+ return [], "Layer ungültig oder konnte nicht geladen werden"
+ for feat in layer.getFeatures():
+ features.append(feat)
+ return features, None
+ except FileNotFoundError:
+ return [], "Lokale Datei nicht gefunden"
+ except Exception as exc:
+ return [], f"Fehler beim Laden der OGR-Quelle: {exc}"
+ else:
+ # Mock: falls GeoJSON-Datei vorhanden, versuche lokale Datei zu lesen
+ try:
+ if url.lower().endswith(".geojson"):
+ with open(url, "r", encoding="utf-8") as fh:
+ data = json.load(fh)
+ if isinstance(data, dict) and data.get("type") == "FeatureCollection":
+ return data.get("features", []), None
+ return [], "Keine QGIS-Umgebung und keine lesbare lokale GeoJSON"
+ except FileNotFoundError:
+ return [], "Lokale Datei nicht gefunden"
+ except Exception as exc:
+ return [], f"Fehler beim Lesen lokaler GeoJSON (Mock): {exc}"
+
+ # HTTP-basierte Dienste (WFS, REST/ArcGIS, generisch)
+ response_text: Optional[str] = None
+ http_error: Optional[str] = None
+
+ # QGIS NetworkAccessManager bevorzugen
+ if getattr(qgiscore, "QGIS_AVAILABLE", False) and getattr(qgiscore, "QgsNetworkAccessManager", None) is not None:
+ try:
+ manager = qgiscore.QgsNetworkAccessManager.instance()
+ QUrl = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QUrl", None)
+ QNetworkRequest = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QNetworkRequest", None)
+ QEventLoop = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QEventLoop", None)
+ if QUrl is not None and QNetworkRequest is not None:
+ req = QNetworkRequest(QUrl(url))
+ reply = manager.get(req)
+ if QEventLoop is not None:
+ loop = QEventLoop()
+ reply.finished.connect(loop.quit)
+ loop.exec()
+ try:
+ raw = reply.readAll()
+ data_bytes = bytes(raw) if hasattr(raw, "__bytes__") else raw
+ response_text = data_bytes.decode("utf-8", errors="replace")
+ except Exception:
+ try:
+ response_text = reply.text()
+ except Exception:
+ response_text = None
+ except Exception as exc:
+ http_error = f"QgsNetworkAccessManager error: {exc}"
+ response_text = None
+
+ # Fallback: requests
+ if response_text is None:
+ try:
+ import requests # lokal import, keine harte Abhängigkeit
+ r = requests.get(url, timeout=30)
+ r.raise_for_status()
+ response_text = r.text
+ except Exception as exc:
+ http_error = f"requests error: {exc}"
+ response_text = None
+
+ if response_text is None:
+ return [], http_error or "keine Antwort vom Server"
+
+ # Versuche JSON/GeoJSON zu parsen
+ try:
+ parsed = json.loads(response_text)
+ if isinstance(parsed, dict) and parsed.get("type") == "FeatureCollection":
+ return parsed.get("features", []), None
+ if isinstance(parsed, dict) and "features" in parsed:
+ return parsed.get("features", []), None
+ # Sonst: gib das gesamte JSON als einzelnes Objekt zurück
+ return [parsed], None
+ except json.JSONDecodeError:
+ # Nicht-JSON-Antwort (z. B. GML). Wenn QGIS verfügbar, versuche GML via temporärer Datei + OGR
+ if getattr(qgiscore, "QGIS_AVAILABLE", False):
+ try:
+ import tempfile
+ with tempfile.NamedTemporaryFile(suffix=".gml", delete=False, mode="w", encoding="utf-8") as fh:
+ fh.write(response_text)
+ tmp_path = fh.name
+ layer = qgiscore.QgsVectorLayer(tmp_path, "tmp_gml", "ogr")
+ if layer and getattr(layer, "isValid", lambda: False)():
+ for feat in layer.getFeatures():
+ features.append(feat)
+ return features, None
+ return [], "GML-Antwort konnte nicht als Layer geladen werden"
+ except Exception as exc:
+ return [], f"Fehler beim Parsen von GML: {exc}"
+ # Wenn alles fehlschlägt:
+ return [], "Antwort konnte nicht als JSON oder GML geparst werden"
diff --git a/modules/Datenbankpruefer.py b/modules/Datenbankpruefer.py
new file mode 100644
index 0000000..5843763
--- /dev/null
+++ b/modules/Datenbankpruefer.py
@@ -0,0 +1 @@
+#Datenbankpruefer.py
\ No newline at end of file
diff --git a/modules/Datenschreiber.py b/modules/Datenschreiber.py
new file mode 100644
index 0000000..143e090
--- /dev/null
+++ b/modules/Datenschreiber.py
@@ -0,0 +1,435 @@
+# sn_basis/modules/Datenschreiber.py
+"""
+Modul Datenschreiber
+
+Enthält die Klasse Datenschreiber mit drei Hauptmethoden:
+
+- schreibe_Daten: schreibt die abgerufenen Daten in die Ziel-GPKG/Dateien,
+ fragt bei vorhandenen Layern nach Überschreiben/Anhängen/Abbrechen und
+ legt Stile in der Datenbank ab.
+- lade_Layer: lädt die erzeugten/aktualisierten Layer ins Projekt und
+ wendet die Vorgabestile an; sortiert abschließend die Layer.
+- schreibe_log: schreibt die verarbeiteten Pruefergebnisse strukturiert in
+ eine Log-Datei im angegebenen Speicherort.
+
+Die Implementierung verwendet die Wrapper-APIs:
+- qgiscore_wrapper als qgiscore
+- qgisui_wrapper als qgisui (nur wenn nötig)
+- qt_wrapper als qt
+
+Wichtig
+------
+Alle Nutzerinteraktionen (z. B. Überschreiben / Anhängen / Abbrechen) werden
+zentral über den Pruefmanager gebündelt. Die Methode `ask_overwrite_append_cancel`
+des Pruefmanagers wird verwendet, damit UI-Interaktionen an einer Stelle
+konsolidiert und testbar sind.
+"""
+
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional
+import os
+import json
+import datetime
+
+from sn_basis.functions import qgiscore_wrapper as qgiscore
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
+
+
+class Datenschreiber:
+ """
+ Schreibt abgerufene Fachdaten in die Zieldatenbank/Dateien und lädt
+ die Layer ins Projekt.
+
+ Konstruktor
+ ----------
+ pruefmanager:
+ Instanz des Pruefmanagers; wird verwendet, um Pruefergebnisse zu
+ verarbeiten und Nutzerinteraktionen zu zentralisieren.
+ gpkg_path:
+ Pfad zur Ziel-GPKG-Datei (oder Verzeichnis). Wenn None, muss der
+ Aufrufer einen Speicherort übergeben.
+ """
+
+ def __init__(self, pruefmanager: Any, gpkg_path: Optional[str] = None) -> None:
+ self.pruefmanager = pruefmanager
+ self.gpkg_path = gpkg_path
+
+ # ------------------------------------------------------------------ #
+ # Schreibe Daten
+ # ------------------------------------------------------------------ #
+ def schreibe_Daten(
+ self,
+ daten_dict: Dict[str, Any],
+ processed_results: List[Any],
+ speicherort: str,
+ ) -> List[Dict[str, Any]]:
+ """
+ Schreibt die abgerufenen Daten in die Zieldatenbank/Dateien.
+
+ Ablauf
+ ------
+ Für jede Zeile (ident) in ``daten_dict["daten"]``:
+ 1. Bestimme Ziel-Layername (z. B. Thema oder ident).
+ 2. Prüfe, ob ein Layer mit diesem Namen bereits existiert (Wrapper).
+ 3. Falls vorhanden, frage den Benutzer (Überschreiben / Anhängen / Abbrechen)
+ über die zentrale Pruefmanager-Methode `ask_overwrite_append_cancel`.
+ 4. Führe die gewählte Operation aus oder schreibe den Layer, wenn er noch nicht existiert.
+ 5. Schreibe ggf. den Stil in die GPKG und setze ihn als Vorgabe.
+ 6. Sammle und gib eine Liste der angelegten/geänderten Layer zurück.
+
+ Returns
+ -------
+ List[Dict[str, Any]]
+ Liste von Dicts mit Informationen zu jedem angelegten/geänderten Layer.
+ """
+ if not speicherort:
+ raise ValueError("Ein gültiger Speicherort (speicherort) muss übergeben werden.")
+
+ # Setze gpkg_path falls noch nicht vorhanden
+ if not self.gpkg_path:
+ self.gpkg_path = speicherort
+
+ results: List[Dict[str, Any]] = []
+ daten_map: Dict[str, List[Any]] = daten_dict.get("daten", {})
+
+ # Iteriere über alle Einträge
+ for ident, features in daten_map.items():
+ # Thema/Name ableiten (falls vorhanden in processed_results oder ident)
+ thema = None
+ for pe in processed_results:
+ try:
+ kontext = getattr(pe, "kontext", None) or {}
+ if kontext and kontext.get("ident") == ident:
+ thema = kontext.get("thema")
+ break
+ except Exception:
+ continue
+ if not thema:
+ thema = str(ident)
+
+ layer_name = thema
+
+ # Prüfe, ob Layer bereits existiert in der Ziel-GPKG
+ layer_exists = False
+ try:
+ layer_exists_fn = getattr(qgiscore, "layer_exists_in_gpkg", None)
+ if callable(layer_exists_fn):
+ layer_exists = layer_exists_fn(self.gpkg_path, layer_name)
+ else:
+ # Fallback: QGIS-Fallback-Check via QgsVectorLayer
+ if getattr(qgiscore, "QgsVectorLayer", None) is not None and qgiscore.QGIS_AVAILABLE:
+ uri = f"{self.gpkg_path}|layername={layer_name}"
+ layer = qgiscore.QgsVectorLayer(uri, layer_name, "ogr")
+ layer_exists = bool(layer and getattr(layer, "isValid", lambda: False)())
+ except Exception:
+ layer_exists = False
+
+ operation = "created"
+
+ if layer_exists:
+ # Zentrale Nutzerabfrage über Pruefmanager
+ # Erwartet Rückgabe: "overwrite" | "append" | "cancel"
+ try:
+ user_choice = self.pruefmanager.ask_overwrite_append_cancel(layer_name)
+ except Exception:
+ # Fallback: overwrite, falls Pruefmanager nicht verfügbar
+ user_choice = "overwrite"
+
+ if user_choice == "cancel":
+ operation = "skipped"
+ results.append({
+ "ident": ident,
+ "thema": thema,
+ "operation": operation,
+ "layer_path": f"{self.gpkg_path}|layername={layer_name}",
+ "feature_count": 0,
+ })
+ continue
+
+ if user_choice == "overwrite":
+ write_err = self._write_layer_to_gpkg(layer_name, features, mode="overwrite")
+ if write_err:
+ pe_err = pruef_ergebnis(
+ ok=False,
+ meldung=f"Fehler beim Überschreiben von {layer_name}: {write_err}",
+ aktion="save_exception",
+ kontext={"ident": ident, "thema": thema, "error": write_err},
+ )
+ self.pruefmanager.verarbeite(pe_err)
+ operation = "skipped"
+ results.append({
+ "ident": ident,
+ "thema": thema,
+ "operation": operation,
+ "layer_path": f"{self.gpkg_path}|layername={layer_name}",
+ "feature_count": 0,
+ })
+ continue
+ else:
+ operation = "overwritten"
+
+ elif user_choice == "append":
+ write_err = self._write_layer_to_gpkg(layer_name, features, mode="append")
+ if write_err:
+ pe_err = pruef_ergebnis(
+ ok=False,
+ meldung=f"Fehler beim Anhängen an {layer_name}: {write_err}",
+ aktion="save_exception",
+ kontext={"ident": ident, "thema": thema, "error": write_err},
+ )
+ self.pruefmanager.verarbeite(pe_err)
+ operation = "skipped"
+ results.append({
+ "ident": ident,
+ "thema": thema,
+ "operation": operation,
+ "layer_path": f"{self.gpkg_path}|layername={layer_name}",
+ "feature_count": 0,
+ })
+ continue
+ else:
+ operation = "appended"
+
+ else:
+ # Layer existiert nicht -> neu anlegen
+ write_err = self._write_layer_to_gpkg(layer_name, features, mode="create")
+ if write_err:
+ pe_err = pruef_ergebnis(
+ ok=False,
+ meldung=f"Fehler beim Erstellen von {layer_name}: {write_err}",
+ aktion="save_exception",
+ kontext={"ident": ident, "thema": thema, "error": write_err},
+ )
+ self.pruefmanager.verarbeite(pe_err)
+ operation = "skipped"
+ results.append({
+ "ident": ident,
+ "thema": thema,
+ "operation": operation,
+ "layer_path": f"{self.gpkg_path}|layername={layer_name}",
+ "feature_count": 0,
+ })
+ continue
+ else:
+ operation = "created"
+
+ # Stilbehandlung (falls in processed_results referenziert)
+ style_written = False
+ style_path = None
+ for pe in processed_results:
+ try:
+ kontext = getattr(pe, "kontext", None) or {}
+ if kontext and kontext.get("ident") == ident:
+ style_path = kontext.get("stildatei") or kontext.get("Stildatei")
+ break
+ except Exception:
+ continue
+
+ if style_path:
+ if not os.path.isabs(style_path):
+ base_dir = os.path.dirname(__file__)
+ style_path = os.path.join(base_dir, style_path)
+ write_style_fn = getattr(qgiscore, "write_style_to_gpkg", None)
+ if callable(write_style_fn):
+ try:
+ write_style_fn(self.gpkg_path, style_path, layer_name)
+ style_written = True
+ except Exception:
+ style_written = False
+
+ feature_count = len(features) if isinstance(features, list) else 0
+
+ results.append({
+ "ident": ident,
+ "thema": thema,
+ "operation": operation,
+ "layer_path": f"{self.gpkg_path}|layername={layer_name}",
+ "feature_count": feature_count,
+ "style_written": style_written,
+ })
+
+ return results
+
+ # ------------------------------------------------------------------ #
+ # Lade Layer ins Projekt
+ # ------------------------------------------------------------------ #
+ def lade_Layer(self, layer_infos: List[Dict[str, Any]]) -> None:
+ """
+ Lädt die in schreibe_Daten erzeugten/aktualisierten Layer ins Projekt
+ und wendet die Vorgabestile an.
+ """
+ loaded_layers = []
+
+ for info in layer_infos:
+ layer_path = info.get("layer_path")
+ thema = info.get("thema")
+ if not layer_path:
+ continue
+
+ try:
+ layer = qgiscore.QgsVectorLayer(layer_path, thema, "ogr")
+ if not layer or not getattr(layer, "isValid", lambda: False)():
+ pe_err = pruef_ergebnis(
+ ok=False,
+ meldung=f"Layer {thema} konnte nicht geladen werden",
+ aktion="layer_nicht_gefunden",
+ kontext={"layer_path": layer_path},
+ )
+ self.pruefmanager.verarbeite(pe_err)
+ continue
+ except Exception as exc:
+ pe_err = pruef_ergebnis(
+ ok=False,
+ meldung=f"Fehler beim Erzeugen des Layers {thema}: {exc}",
+ aktion="layer_nicht_gefunden",
+ kontext={"layer_path": layer_path, "error": str(exc)},
+ )
+ self.pruefmanager.verarbeite(pe_err)
+ continue
+
+ try:
+ apply_style_fn = getattr(qgiscore, "apply_default_style_from_gpkg", None)
+ if callable(apply_style_fn):
+ apply_style_fn(self.gpkg_path, layer)
+ except Exception:
+ pe_warn = pruef_ergebnis(
+ ok=True,
+ meldung=f"Style konnte für {thema} nicht automatisch angewendet werden",
+ aktion="stil_not_implemented",
+ kontext={"thema": thema},
+ )
+ self.pruefmanager.verarbeite(pe_warn)
+
+ try:
+ # qgisui wrapper wird hier nicht direkt für die Abfrage verwendet;
+ # qgisui.add_layer_to_project sollte aber vorhanden sein.
+ from sn_basis.functions import qgisui_wrapper as qgisui
+ add_fn = getattr(qgisui, "add_layer_to_project", None)
+ if callable(add_fn):
+ add_fn(layer)
+ else:
+ # Fallback: falls wrapper nicht vorhanden, versuche QGIS-API direkt
+ if getattr(qgiscore, "QgsProject", None) is not None and qgiscore.QGIS_AVAILABLE:
+ qgiscore.QgsProject.instance().addMapLayer(layer)
+ loaded_layers.append(layer)
+ except Exception:
+ pe_err = pruef_ergebnis(
+ ok=False,
+ meldung=f"Layer {thema} konnte nicht ins Projekt geladen werden",
+ aktion="layer_nicht_gefunden",
+ kontext={"thema": thema},
+ )
+ self.pruefmanager.verarbeite(pe_err)
+ continue
+
+ # Sortiere Layer im Projekt nach ID (Wrapper-Funktion bevorzugt)
+ sort_fn = getattr(qgiscore, "sort_layers_by_id", None)
+ if callable(sort_fn):
+ try:
+ sort_fn()
+ except Exception:
+ pass
+
+ # ------------------------------------------------------------------ #
+ # Schreibe Log
+ # ------------------------------------------------------------------ #
+ def schreibe_log(self, processed_results: List[Any], speicherort: str) -> str:
+ """
+ Schreibt die verarbeiteten Pruefergebnisse strukturiert in eine Log-Datei.
+ """
+ if not speicherort:
+ raise ValueError("Ein gültiger Speicherort muss übergeben werden.")
+
+ log_dir = speicherort
+ os.makedirs(log_dir, exist_ok=True)
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
+ log_path = os.path.join(log_dir, f"datenabruf_log_{timestamp}.json")
+
+ serializable: List[Dict[str, Any]] = []
+ for pe in processed_results:
+ try:
+ entry = {}
+ entry["ok"] = getattr(pe, "ok", None) if hasattr(pe, "ok") else None
+ entry["meldung"] = getattr(pe, "meldung", None) if hasattr(pe, "meldung") else None
+ kontext = getattr(pe, "kontext", None) if hasattr(pe, "kontext") else None
+ entry["kontext"] = kontext
+ serializable.append(entry)
+ except Exception:
+ serializable.append({"raw": str(pe)})
+
+ with open(log_path, "w", encoding="utf-8") as fh:
+ json.dump(serializable, fh, ensure_ascii=False, indent=2)
+
+ pe_log = pruef_ergebnis(
+ ok=True,
+ meldung=f"Log geschrieben: {os.path.basename(log_path)}",
+ aktion="standarddatei_vorschlagen",
+ kontext={"log_path": log_path},
+ )
+ self.pruefmanager.verarbeite(pe_log)
+
+ return log_path
+
+ # ------------------------------------------------------------------ #
+ # Hilfsfunktionen intern
+ # ------------------------------------------------------------------ #
+ def _write_layer_to_gpkg(self, layer_name: str, features: List[Any], mode: str = "create") -> Optional[str]:
+ """
+ Interne Hilfsfunktion zum Schreiben eines Layers in das GPKG.
+
+ Erwartete qgiscore-Funktion:
+ qgiscore.write_features_to_gpkg(gpkg_path, layer_name, features, mode)
+ """
+ write_fn = getattr(qgiscore, "write_features_to_gpkg", None)
+ if callable(write_fn):
+ try:
+ write_fn(self.gpkg_path, layer_name, features, mode)
+ return None
+ except Exception as exc:
+ return str(exc)
+
+ # Fallback: Verwende QgsVectorFileWriter, falls QGIS verfügbar
+ if getattr(qgiscore, "QGIS_AVAILABLE", False) and getattr(qgiscore, "QgsVectorFileWriter", None) is not None:
+ try:
+ # Minimaler Fallback: erwarte, dass 'features' eine Liste von QgsFeature ist
+ if not features:
+ # Erstelle leeren Layer-Eintrag (GPKG erlaubt leere Layer)
+ # Hier vereinfachen wir: writeAsVectorFormatV3 benötigt ein Layer-Objekt.
+ return None
+
+ # Versuche, ein Memory-Layer aus dem ersten Feature zu ermitteln
+ first = features[0]
+ mem_layer = None
+ if hasattr(first, "fields") and hasattr(first, "geometry"):
+ # Wenn Features QgsFeature sind, versuchen wir, das zugehörige Layer zu nutzen
+ try:
+ mem_layer = first.layer() if hasattr(first, "layer") else None
+ except Exception:
+ mem_layer = None
+
+ if mem_layer is None:
+ return "Keine Feld-/Geometrie-Informationen zum Schreiben vorhanden"
+
+ opts = qgiscore.QgsVectorFileWriter.SaveVectorOptions()
+ opts.driverName = "GPKG"
+ opts.layerName = layer_name
+ opts.fileEncoding = "UTF-8"
+ if mode == "overwrite":
+ opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteFile
+ else:
+ opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteLayer
+
+ err = qgiscore.QgsVectorFileWriter.writeAsVectorFormatV3(
+ mem_layer,
+ self.gpkg_path,
+ qgiscore.QgsProject.instance().transformContext(),
+ opts
+ )
+ if err != qgiscore.QgsVectorFileWriter.NoError:
+ return f"Fehler beim Schreiben (Code {err})"
+ return None
+ except Exception as exc:
+ return str(exc)
+
+ return "Keine Schreib-Funktion verfügbar (Wrapper nicht implementiert)"
diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py
new file mode 100644
index 0000000..12d8669
--- /dev/null
+++ b/modules/Pruefmanager.py
@@ -0,0 +1,276 @@
+from __future__ import annotations
+from typing import Optional, Any
+
+from sn_basis.functions import (
+ ask_yes_no,
+ info,
+ warning,
+ error,
+ set_layer_visible,
+)
+
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion
+
+
+class Pruefmanager:
+ """
+ Zentrale Verarbeitung von pruef_ergebnis-Objekten.
+
+ Erwartete öffentliche API (verwendet von Core-Komponenten wie DataGrabber):
+ - report_error(thema, meldung, *, aktion: Optional[PruefAktion]=None, kontext=None) -> None
+ - request_decision(pruef_res) -> str
+ - report_summary(summary: dict) -> None
+ - verarbeite(ergebnis: pruef_ergebnis) -> pruef_ergebnis
+ """
+
+ def __init__(self, ui_modus: str = "qgis", parent: Optional[Any] = None):
+ self.ui_modus = ui_modus
+ self.parent = parent
+
+ # ---------------------------------------------------------------------
+ # Basis-API: Meldungen / Zusammenfassungen
+ # ---------------------------------------------------------------------
+ def report_error(self, thema: str, meldung: str, *, aktion: Optional[PruefAktion] = None, kontext: Optional[Any] = None) -> None:
+ """
+ Einheitliche Meldung für Fehler/Warnungen aus dem Core.
+ Keine Rückgabe; dient als zentraler Hook für Logging/UI.
+ """
+ critical_actions = {
+ "netzwerkfehler",
+ "pruefe_exception",
+ "save_exception",
+ "layer_create_failed",
+ "read_error",
+ "open_error",
+ }
+ warn_actions = {
+ "datei_nicht_gefunden",
+ "pfad_nicht_gefunden",
+ "url_nicht_erreichbar",
+ "falsche_endung",
+ "kein_header",
+ "kein_arbeitsblatt",
+ }
+
+ if aktion in critical_actions:
+ error(thema, meldung)
+ return
+
+ if aktion in warn_actions:
+ warning(thema, meldung)
+ return
+
+ # Default: informative Warnung
+ warning(thema, meldung)
+
+ def report_summary(self, summary: dict) -> None:
+ geladen = summary.get("geladen", [])
+ fehler = summary.get("fehler", {})
+ ausserhalb = summary.get("ausserhalb", [])
+ relevant = summary.get("relevant", [])
+
+ message = (
+ f"Geladene Dienste: {len(geladen)}\n"
+ f"Relevante Dienste: {len(relevant)}\n"
+ f"Dienste ausserhalb: {len(ausserhalb)}\n"
+ f"Fehler: {len(fehler)}"
+ )
+
+ info("DataGrabber Zusammenfassung", message)
+
+ # ---------------------------------------------------------------------
+ # Entscheidungs-API
+ # ---------------------------------------------------------------------
+ def request_decision(self, pruef_res: Any) -> str:
+ """
+ Synchronously request a decision from the user (or return a default in headless mode).
+
+ Returns one of:
+ - "abort"
+ - "continue"
+ - "temporaer_erzeugen"
+ - "ignore"
+ """
+ aktion = getattr(pruef_res, "aktion", None)
+ meldung = getattr(pruef_res, "meldung", str(pruef_res))
+
+ interactive_actions = {
+ "leereingabe_erlaubt",
+ "standarddatei_vorschlagen",
+ "temporaer_erlaubt",
+ "layer_unsichtbar",
+ }
+
+ if aktion in interactive_actions:
+ if self.ui_modus == "qgis":
+ title_map = {
+ "leereingabe_erlaubt": "Ohne Eingabe fortfahren",
+ "standarddatei_vorschlagen": "Standarddatei verwenden",
+ "temporaer_erlaubt": "Temporäre Datei erzeugen",
+ "layer_unsichtbar": "Layer einblenden",
+ }
+ title = title_map.get(aktion, "Entscheidung erforderlich")
+ try:
+ yes = ask_yes_no(title, meldung, default=False, parent=self.parent)
+ except Exception:
+ return "abort"
+ if yes:
+ if aktion == "temporaer_erlaubt":
+ return "temporaer_erzeugen"
+ return "continue"
+ return "abort"
+
+ if self.ui_modus == "headless":
+ return "abort"
+
+ informational_actions = {
+ "leer",
+ "datei_nicht_gefunden",
+ "pfad_nicht_gefunden",
+ "url_nicht_erreichbar",
+ "netzwerkfehler",
+ "falscher_geotyp",
+ "layer_leer",
+ "falscher_layertyp",
+ "falsches_crs",
+ "felder_fehlen",
+ "datenquelle_unerwartet",
+ "layer_nicht_editierbar",
+ "kein_header",
+ "kein_arbeitsblatt",
+ "read_error",
+ "open_error",
+ "pflichtfelder_fehlen",
+ }
+ if aktion in informational_actions:
+ return "abort"
+
+ return "abort"
+
+ # ---------------------------------------------------------------------
+ # Höhere Abstraktion: verarbeite
+ # ---------------------------------------------------------------------
+ def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis:
+ """
+ Verarbeitet ein pruef_ergebnis-Objekt und führt ggf. Nutzerinteraktion durch.
+ Liefert ein ggf. modifiziertes pruef_ergebnis zurück.
+ """
+ if ergebnis.ok:
+ return ergebnis
+
+ aktion = ergebnis.aktion
+ kontext = ergebnis.kontext
+ meldung = ergebnis.meldung
+
+ # Zentrale Meldung
+ self.report_error(aktion or "pruefung", meldung or "", aktion=aktion, kontext=kontext)
+
+ # Interaktive Entscheidungen
+ if aktion in ("leereingabe_erlaubt", "standarddatei_vorschlagen", "temporaer_erlaubt", "layer_unsichtbar"):
+ decision = self.request_decision(ergebnis)
+ if decision == "temporaer_erzeugen":
+ return pruef_ergebnis(ok=True, meldung="Temporäre Datei soll erzeugt werden.", aktion="temporaer_erzeugen", kontext=None)
+ if decision == "continue":
+ return pruef_ergebnis(ok=True, meldung="Fortgefahren.", aktion="ok", kontext=kontext)
+ return ergebnis # abort / unverändert
+
+ # Spezielle Excel/Importer-Fälle: klare Meldungen, keine interaktive Entscheidung
+ if aktion == "kein_header":
+ warning("Excel-Import", meldung or "")
+ return ergebnis
+
+ if aktion == "kein_arbeitsblatt":
+ warning("Excel-Import", meldung or "")
+ return ergebnis
+
+ if aktion in ("read_error", "open_error"):
+ error("Excel-Import", meldung or "")
+ return ergebnis
+
+ if aktion == "datei_nicht_gefunden":
+ warning("Datei nicht gefunden", meldung or "")
+ return ergebnis
+
+ # Spezieller Fall: layer_unsichtbar (falls nicht interaktiv behandelt)
+ if aktion == "layer_unsichtbar":
+ if kontext is not None:
+ try:
+ set_layer_visible(kontext, True)
+ return pruef_ergebnis(ok=True, meldung="Layer wurde eingeblendet.", aktion="ok", kontext=kontext)
+ except Exception:
+ return ergebnis
+ return ergebnis
+
+ # Standard: keine Änderung
+ return ergebnis
+ def ask_overwrite_append_cancel(self, layer_name: str, default: str = "overwrite") -> str:
+ """
+ Zeigt dem Nutzer eine Auswahl für einen bereits existierenden Layer an.
+
+ Rückgabe
+ -------
+ str
+ Einer der Werte: "overwrite", "append", "cancel".
+
+ Verhalten
+ --------
+ - Verwendet bevorzugt die UI-Wrapper-Funktion `qt_wrapper` / `qgisui_wrapper`,
+ falls vorhanden (z. B. ein QMessageBox-Dialog mit drei Buttons).
+ - Im Mock- oder Headless-Modus (kein Qt/QGIS verfügbar) wird der übergebene
+ `default`-Wert zurückgegeben.
+ - Alle Nutzerinteraktionen laufen über diese zentrale Methode, damit das
+ Plugin an einer Stelle gesteuert und ggf. getested werden kann.
+
+ Parameter
+ ---------
+ layer_name:
+ Anzeigename des Layers, der bereits existiert (wird im Dialog angezeigt).
+ default:
+ Rückgabewert im Headless/Mock-Modus oder wenn der Dialog nicht verfügbar ist.
+ Gültige Werte: "overwrite", "append", "cancel". Standard: "overwrite".
+ """
+ # Validierung des Defaults
+ if default not in ("overwrite", "append", "cancel"):
+ default = "overwrite"
+
+ # Versuche, eine UI-Wrapper-Funktion zu verwenden, falls vorhanden
+ try:
+ # qgisui_wrapper kann eine spezialisierte Dialogfunktion bereitstellen
+ from sn_basis.functions import qgisui_wrapper as qgisui
+ ask_fn = getattr(qgisui, "ask_overwrite_append_cancel", None)
+ if callable(ask_fn):
+ # Die Wrapper-Funktion soll genau die drei Strings zurückgeben
+ choice = ask_fn(layer_name)
+ if choice in ("overwrite", "append", "cancel"):
+ return choice
+ except Exception:
+ # Falls Import/Wrapper fehlschlägt, weiter zum Qt-Fallback
+ pass
+
+ # Fallback: direkte Qt-Dialoge über qt_wrapper (wenn verfügbar)
+ try:
+ from sn_basis.functions import qt_wrapper as qt
+ QMessageBox = getattr(qt, "QMessageBox", None)
+ if QMessageBox is not None:
+ # Erzeuge und konfiguriere Dialog
+ msg = QMessageBox()
+ msg.setWindowTitle("Layer bereits vorhanden")
+ msg.setText(f"Der Layer '{layer_name}' existiert bereits. Was möchten Sie tun?")
+ overwrite_btn = msg.addButton("Überschreiben", QMessageBox.AcceptRole)
+ append_btn = msg.addButton("Anhängen", QMessageBox.AcceptRole)
+ cancel_btn = msg.addButton("Abbrechen", QMessageBox.RejectRole)
+ msg.setDefaultButton(overwrite_btn)
+ # Blockierend anzeigen
+ msg.exec_()
+ clicked = msg.clickedButton()
+ if clicked == overwrite_btn:
+ return "overwrite"
+ if clicked == append_btn:
+ return "append"
+ return "cancel"
+ except Exception:
+ # Qt nicht verfügbar oder Fehler beim Dialogaufbau
+ pass
+
+ # Headless / Mock: gib Default zurück
+ return default
diff --git a/modules/__init__py b/modules/__init__py
new file mode 100644
index 0000000..e69de29
diff --git a/modules/excel_importer.py b/modules/excel_importer.py
new file mode 100644
index 0000000..50f8913
--- /dev/null
+++ b/modules/excel_importer.py
@@ -0,0 +1,91 @@
+# sn_plan41/modules/excel_importer.py
+import os
+from typing import Optional, Iterable, Mapping, Any, List, cast
+
+from openpyxl import load_workbook
+from openpyxl.workbook.workbook import Workbook
+from openpyxl.worksheet.worksheet import Worksheet
+
+from sn_basis.modules.Dateipruefer import Dateipruefer
+from sn_basis.modules.Pruefmanager import Pruefmanager
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
+
+
+class ExcelImporter:
+ """
+ Excel-Importer für Linklisten, verwendet Dateipruefer und Pruefmanager zur Meldungsbehandlung.
+
+ - Der Aufrufer übergibt einen konkreten Dateipfad.
+ - Vor dem Öffnen wird der Pfad mit Dateipruefer geprüft.
+ - Link- und Stilprüfungen erfolgen nicht hier, sondern im DataGrabber.
+ - Nach dem Ladevorgang wird die Arbeitsmappe geschlossen, damit die Datei vom OS freigegeben wird.
+ """
+
+ def __init__(self, filepath: str, pruefmanager: Pruefmanager):
+ if not filepath:
+ raise ValueError("ExcelImporter benötigt einen gültigen Dateipfad.")
+ if pruefmanager is None:
+ raise ValueError("ExcelImporter benötigt einen Pruefmanager.")
+ self.filepath = filepath
+ self.pruefmanager = pruefmanager
+
+ def import_xlsx(self) -> List[Mapping[str, Any]]:
+ """
+ Liest die Excel-Datei und gibt eine Liste von Dicts (Zeilen) zurück.
+ Bei Prüf- oder Leseproblemen wird der Pruefmanager zur Verarbeitung des pruef_ergebnis aufgerufen.
+ Im Fehlerfall wird eine leere Liste zurückgegeben.
+ """
+ # 1) Dateiprüfung über Dateipruefer
+ datei_pruefer = Dateipruefer(pfad=self.filepath, temporaer_erlaubt=False)
+ ergebnis: pruef_ergebnis = datei_pruefer.pruefe()
+ ergebnis = self.pruefmanager.verarbeite(ergebnis)
+
+ if not ergebnis.ok:
+ return []
+
+ workbook: Optional[Workbook] = None
+ try:
+ workbook = load_workbook(filename=self.filepath, data_only=True)
+
+ # workbook.active kann typmäßig als Optional angesehen werden; cast/prüfen, damit Pylance weiß, dass sheet ein Worksheet ist
+ sheet = workbook.active
+ if sheet is None:
+ pe = pruef_ergebnis(ok=False, meldung=f"Kein aktives Blatt in der Arbeitsmappe: {self.filepath}", aktion="kein_arbeitsblatt", kontext=self.filepath)
+ self.pruefmanager.verarbeite(pe)
+ return []
+
+ # Typengranularität für den Linter
+ sheet = cast(Worksheet, sheet)
+
+ # Header aus erster Zeile (als Werte)
+ header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None)
+ if not header_row:
+ pe = pruef_ergebnis(ok=False, meldung=f"Excel-Datei enthält keine Header-Zeile: {self.filepath}", aktion="kein_header", kontext=self.filepath)
+ self.pruefmanager.verarbeite(pe)
+ return []
+
+ header = list(header_row)
+ if not header or all(h is None for h in header):
+ pe = pruef_ergebnis(ok=False, meldung=f"Excel-Header ist leer oder ungültig: {self.filepath}", aktion="kein_header", kontext=self.filepath)
+ self.pruefmanager.verarbeite(pe)
+ return []
+
+ ergebnis_list: List[Mapping[str, Any]] = []
+ # Werte-only lesen für Performance und Einfachheit
+ for row in sheet.iter_rows(min_row=2, values_only=True):
+ if row is None:
+ continue
+ # zip stoppt bei kürzerer Länge; das ist beabsichtigt
+ attributes = dict(zip(header, row))
+ ergebnis_list.append(attributes)
+
+ return ergebnis_list
+
+ except Exception as exc:
+ pe = pruef_ergebnis(ok=False, meldung=f"Fehler beim Lesen der Excel-Datei '{self.filepath}': {exc}", aktion="read_error", kontext=self.filepath)
+ self.pruefmanager.verarbeite(pe)
+ return []
+
+ finally:
+ if workbook is not None:
+ workbook.close()
diff --git a/modules/layerpruefer.py b/modules/layerpruefer.py
new file mode 100644
index 0000000..3718a31
--- /dev/null
+++ b/modules/layerpruefer.py
@@ -0,0 +1,182 @@
+"""
+sn_basis/modules/layerpruefer.py – Prüfung von QGIS-Layern.
+Verwendet ausschließlich Wrapper und gibt pruef_ergebnis zurück.
+"""
+from typing import Optional, Any
+from sn_basis.functions import (
+ layer_exists,
+ get_layer_geometry_type,
+ get_layer_feature_count,
+ is_layer_visible,
+ get_layer_type,
+ get_layer_crs,
+ get_layer_fields,
+ get_layer_source,
+ is_layer_editable,
+)
+
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion
+
+
+class Layerpruefer:
+ """
+ Prüft Layer auf Existenz, Sichtbarkeit, Geometrietyp, Objektanzahl,
+ Layertyp, CRS, Felder, Datenquelle und Editierbarkeit.
+ """
+
+ def __init__(
+ self,
+ layer:Optional[Any]=None,
+ erwarteter_geotyp: str | None = None,
+ muss_sichtbar_sein: bool = False,
+ erwarteter_layertyp: str | None = None,
+ erwartetes_crs: str | None = None,
+ erforderliche_felder: list[str] | None = None,
+ erlaubte_datenquellen: list[str] | None = None,
+ muss_editierbar_sein: bool = False,
+ ):
+ self.layer = layer
+ self.erwarteter_geotyp = erwarteter_geotyp
+ self.muss_sichtbar_sein = muss_sichtbar_sein
+ self.erwarteter_layertyp = erwarteter_layertyp
+ self.erwartetes_crs = erwartetes_crs
+ self.erforderliche_felder = erforderliche_felder or []
+ self.erlaubte_datenquellen = erlaubte_datenquellen or []
+ self.muss_editierbar_sein = muss_editierbar_sein
+
+ # ---------------------------------------------------------
+ # Hauptfunktion
+ # ---------------------------------------------------------
+
+ def pruefe(self) -> pruef_ergebnis:
+
+ # -----------------------------------------------------
+ # 1. Existenz
+ # -----------------------------------------------------
+ if not layer_exists(self.layer):
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Der Layer existiert nicht oder wurde nicht geladen.",
+ aktion="layer_nicht_gefunden",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 2. Sichtbarkeit
+ # -----------------------------------------------------
+ sichtbar = is_layer_visible(self.layer)
+ if self.muss_sichtbar_sein and not sichtbar:
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Der Layer ist unsichtbar. Soll er eingeblendet werden?",
+ aktion="layer_unsichtbar",
+ kontext=self.layer, # Layerobjekt als Kontext
+ )
+
+ # -----------------------------------------------------
+ # 3. Layertyp
+ # -----------------------------------------------------
+ layertyp = get_layer_type(self.layer)
+ if self.erwarteter_layertyp and layertyp != self.erwarteter_layertyp:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=(
+ f"Der Layer hat den Typ '{layertyp}', "
+ f"erwartet wurde '{self.erwarteter_layertyp}'."
+ ),
+ aktion="falscher_layertyp",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 4. Geometrietyp
+ # -----------------------------------------------------
+ geotyp = get_layer_geometry_type(self.layer)
+ if self.erwarteter_geotyp and geotyp != self.erwarteter_geotyp:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=(
+ f"Der Layer hat den Geometrietyp '{geotyp}', "
+ f"erwartet wurde '{self.erwarteter_geotyp}'."
+ ),
+ aktion="falscher_geotyp",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 5. Featureanzahl
+ # -----------------------------------------------------
+ anzahl = get_layer_feature_count(self.layer)
+ if anzahl == 0:
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Der Layer enthält keine Objekte.",
+ aktion="layer_leer",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 6. CRS
+ # -----------------------------------------------------
+ crs = get_layer_crs(self.layer)
+ if self.erwartetes_crs and crs != self.erwartetes_crs:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=(
+ f"Der Layer hat das CRS '{crs}', "
+ f"erwartet wurde '{self.erwartetes_crs}'."
+ ),
+ aktion="falsches_crs",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 7. Felder
+ # -----------------------------------------------------
+ felder = get_layer_fields(self.layer)
+ fehlende = [f for f in self.erforderliche_felder if f not in felder]
+
+ if fehlende:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=(
+ "Der Layer enthält nicht alle erforderlichen Felder: "
+ + ", ".join(fehlende)
+ ),
+ aktion="felder_fehlen",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 8. Datenquelle
+ # -----------------------------------------------------
+ quelle = get_layer_source(self.layer)
+ if self.erlaubte_datenquellen and quelle not in self.erlaubte_datenquellen:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=f"Die Datenquelle '{quelle}' ist nicht erlaubt.",
+ aktion="datenquelle_unerwartet",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 9. Editierbarkeit
+ # -----------------------------------------------------
+ editable = is_layer_editable(self.layer)
+ if self.muss_editierbar_sein and not editable:
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Der Layer ist nicht editierbar.",
+ aktion="layer_nicht_editierbar",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 10. Alles OK
+ # -----------------------------------------------------
+ return pruef_ergebnis(
+ ok=True,
+ meldung="Layerprüfung erfolgreich.",
+ aktion="ok",
+ kontext=None,
+ )
diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py
new file mode 100644
index 0000000..a94e863
--- /dev/null
+++ b/modules/linkpruefer.py
@@ -0,0 +1,134 @@
+"""
+sn_basis/modules/linkpruefer.py – Prüfung von URLs und lokalen Links.
+Verwendet Wrapper und gibt pruef_ergebnis an den Pruefmanager zurück.
+"""
+
+from pathlib import Path
+
+from sn_basis.functions import (
+ file_exists,
+ join_path,
+ network_head,
+)
+
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion
+
+
+class Linkpruefer:
+ """
+ Prüft URLs und lokale Pfade.
+ Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager.
+ """
+
+ def __init__(self, basis_pfad: str | None = None):
+ """
+ basis_pfad: optionaler Basisordner für relative Pfade.
+ """
+ self.basis = basis_pfad
+
+ # ---------------------------------------------------------
+ # Hilfsfunktionen
+ # ---------------------------------------------------------
+
+ def _pfad(self, relativer_pfad: str) -> Path:
+ """
+ Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis.
+ """
+ if not self.basis:
+ return Path(relativer_pfad)
+ return join_path(self.basis, relativer_pfad)
+
+ def _ist_url(self, text: str) -> bool:
+ """
+ Einfache URL-Erkennung.
+ """
+ return text.startswith("http://") or text.startswith("https://")
+
+ # ---------------------------------------------------------
+ # Hauptfunktion
+ # ---------------------------------------------------------
+
+ def pruefe(self, eingabe: str) -> pruef_ergebnis:
+ """
+ Prüft einen Link (URL oder lokalen Pfad).
+ Rückgabe: pruef_ergebnis
+ """
+
+ if not eingabe:
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Es wurde kein Link angegeben.",
+ aktion="leer",
+ kontext=None,
+ )
+
+ # -----------------------------------------------------
+ # 1. Fall: URL
+ # -----------------------------------------------------
+ if self._ist_url(eingabe):
+ return self._pruefe_url(eingabe)
+
+ # -----------------------------------------------------
+ # 2. Fall: lokaler Pfad
+ # -----------------------------------------------------
+ return self._pruefe_dateipfad(eingabe)
+
+ # ---------------------------------------------------------
+ # URL‑Prüfung
+ # ---------------------------------------------------------
+
+ def _pruefe_url(self, url: str) -> pruef_ergebnis:
+ """
+ Prüft eine URL über einen HEAD-Request.
+ """
+
+ reply = network_head(url)
+
+ if reply is None:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=f"Die URL '{url}' konnte nicht geprüft werden.",
+ aktion="netzwerkfehler",
+ kontext=url,
+ )
+
+ if reply.error != 0:
+ return pruef_ergebnis(
+ ok=False,
+ meldung=f"Die URL '{url}' ist nicht erreichbar.",
+ aktion="url_nicht_erreichbar",
+ kontext=url,
+ )
+
+ return pruef_ergebnis(
+ ok=True,
+ meldung="URL ist erreichbar.",
+ aktion="ok",
+ kontext=url,
+ )
+
+ # ---------------------------------------------------------
+ # Lokale Datei‑/Pfadprüfung
+ # ---------------------------------------------------------
+
+ def _pruefe_dateipfad(self, eingabe: str) -> pruef_ergebnis:
+ """
+ Prüft einen lokalen Pfad.
+ """
+
+ pfad = self._pfad(eingabe)
+
+ if not file_exists(pfad):
+ return pruef_ergebnis(
+ ok=False,
+ meldung=f"Der Pfad '{eingabe}' wurde nicht gefunden.",
+ aktion="pfad_nicht_gefunden",
+ kontext=pfad,
+ )
+
+ return pruef_ergebnis(
+ ok=True,
+ meldung="Dateipfad ist gültig.",
+ aktion="ok",
+ kontext=pfad,
+ )
diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py
new file mode 100644
index 0000000..af0054d
--- /dev/null
+++ b/modules/pruef_ergebnis.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+from dataclasses import dataclass
+from typing import Any, Optional, Literal
+
+# Erweitertes Literal mit allen erlaubten Aktionen (PruefAktion)
+PruefAktion = Literal[
+ "ok",
+ "leer",
+ "leereingabe_erlaubt",
+ "leereingabe_nicht_erlaubt",
+ "standarddatei_vorschlagen",
+ "temporaer_erlaubt",
+ "temporaer_erzeugen",
+ "datei_nicht_gefunden",
+ "kein_dateipfad",
+ "pfad_nicht_gefunden",
+ "url_nicht_erreichbar",
+ "netzwerkfehler",
+ "layer_nicht_gefunden",
+ "layer_unsichtbar",
+ "falscher_geotyp",
+ "layer_leer",
+ "falscher_layertyp",
+ "falsches_crs",
+ "felder_fehlen",
+ "datenquelle_unerwartet",
+ "layer_nicht_editierbar",
+ "falsche_endung",
+ "pflichtfelder_fehlen",
+ # Excel / Import-spezifische Aktionen
+ "kein_header",
+ "kein_arbeitsblatt",
+ "read_error",
+ "open_error",
+ "datenabruf",
+ # Generische Prüf-/Speicher-Aktionen
+ "pruefe_exception",
+ "save_exception",
+ "save_not_implemented",
+ "stil_not_implemented",
+ "datei_unbekannt",
+ "needs_user_action",
+]
+
+@dataclass
+class pruef_ergebnis:
+ """
+ Einheitliches Ergebnisobjekt für Prüfer.
+ - ok: True wenn Prüfung bestanden
+ - meldung: menschenlesbare Meldung
+ - aktion: maschinenlesbarer Aktionscode (PruefAktion)
+ - kontext: optionaler Zusatzkontext (z. B. Pfad, Layer-Objekt)
+ """
+ ok: bool
+ meldung: Optional[str] = None
+ aktion: Optional[PruefAktion] = None
+ kontext: Optional[Any] = None
+
+ def __init__(self, ok: bool, meldung: Optional[str] = None, aktion: Optional[PruefAktion] = None, kontext: Optional[Any] = None):
+ self.ok = ok
+ self.meldung = meldung
+ self.aktion = aktion
+ self.kontext = kontext
diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py
new file mode 100644
index 0000000..db9312a
--- /dev/null
+++ b/modules/stilpruefer.py
@@ -0,0 +1,75 @@
+"""
+sn_basis/modules/stilpruefer.py – Prüfung von Layerstilen.
+Prüft ausschließlich, ob ein Stilpfad gültig ist.
+Die Anwendung erfolgt später über eine Aktion.
+"""
+
+from pathlib import Path
+
+from sn_basis.functions import file_exists
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
+
+
+class Stilpruefer:
+ """
+ Prüft, ob ein Stilpfad gültig ist und angewendet werden kann.
+ Keine Seiteneffekte, keine QGIS-Aufrufe.
+ """
+
+ def __init__(self):
+ pass
+
+ # ---------------------------------------------------------
+ # Hauptfunktion
+ # ---------------------------------------------------------
+
+ def pruefe(self, stil_pfad: str) -> pruef_ergebnis:
+ """
+ Prüft einen Stilpfad.
+ Rückgabe: pruef_ergebnis
+ """
+
+ # -----------------------------------------------------
+ # 1. Kein Stil angegeben → OK
+ # -----------------------------------------------------
+ if not stil_pfad:
+ return pruef_ergebnis(
+ ok=True,
+ meldung="Kein Stil angegeben.",
+ aktion="ok",
+ kontext=None,
+ )
+
+ pfad = Path(stil_pfad)
+
+ # -----------------------------------------------------
+ # 2. Datei existiert nicht
+ # -----------------------------------------------------
+ if not file_exists(pfad):
+ return pruef_ergebnis(
+ ok=False,
+ meldung=f"Die Stil-Datei '{stil_pfad}' wurde nicht gefunden.",
+ aktion="datei_nicht_gefunden",
+ kontext=pfad,
+ )
+
+ # -----------------------------------------------------
+ # 3. Falsche Endung
+ # -----------------------------------------------------
+ if pfad.suffix.lower() != ".qml":
+ return pruef_ergebnis(
+ ok=False,
+ meldung="Die Stil-Datei muss die Endung '.qml' haben.",
+ aktion="falsche_endung",
+ kontext=pfad,
+ )
+
+ # -----------------------------------------------------
+ # 4. Stil ist gültig → Anwendung später
+ # -----------------------------------------------------
+ return pruef_ergebnis(
+ ok=True,
+ meldung="Stil-Datei ist gültig.",
+ aktion="stil_anwendbar",
+ kontext=pfad,
+ )
diff --git a/styles/GIS_63000F_Objekt_Denkmalschutz.qml b/styles/GIS_63000F_Objekt_Denkmalschutz.qml
new file mode 100644
index 0000000..06bb9e5
--- /dev/null
+++ b/styles/GIS_63000F_Objekt_Denkmalschutz.qml
@@ -0,0 +1,609 @@
+
+
+
+ 1
+ 1
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "gml_id"
+
+
+
+
+
+ 0
+ 0
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+
+
+ 0
+ generatedlayout
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "gml_id"
+
+ 2
+
diff --git a/styles/GIS_Biotope_F.qml b/styles/GIS_Biotope_F.qml
new file mode 100644
index 0000000..ed06272
--- /dev/null
+++ b/styles/GIS_Biotope_F.qml
@@ -0,0 +1,225 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+ 2
+
diff --git a/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml b/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml
new file mode 100644
index 0000000..5e40734
--- /dev/null
+++ b/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml
@@ -0,0 +1,349 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+ 2
+
diff --git a/styles/GIS_LfULG_LSG.qml b/styles/GIS_LfULG_LSG.qml
new file mode 100644
index 0000000..28082ba
--- /dev/null
+++ b/styles/GIS_LfULG_LSG.qml
@@ -0,0 +1,371 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
+ 2
+
diff --git a/styles/verfahrensgebiet.qml b/styles/verfahrensgebiet.qml
index 5504107..474e368 100644
--- a/styles/verfahrensgebiet.qml
+++ b/styles/verfahrensgebiet.qml
@@ -1,25 +1,83 @@
-
-
- 1
- 1
- 1
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -32,285 +90,51 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
- 0
- 1
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- .
-
- 0
- .
-
- 0
- generatedlayout
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- COALESCE( "name", '<NULL>' )
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0
+ 0
2
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..324c4b2
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+#Testordner
\ No newline at end of file
diff --git a/tests/run_tests.py b/tests/run_tests.py
new file mode 100644
index 0000000..7518bd5
--- /dev/null
+++ b/tests/run_tests.py
@@ -0,0 +1,154 @@
+"""
+sn_basis/test/run_tests.py
+
+Zentraler Test-Runner für sn_basis.
+Wrapper-konform, QGIS-unabhängig, CI- und IDE-fähig.
+"""
+
+import unittest
+import datetime
+import inspect
+import os
+import sys
+
+from pathlib import Path
+
+# ---------------------------------------------------------
+# Pre-Bootstrap: Plugin-Root in sys.path eintragen
+# ---------------------------------------------------------
+
+THIS_FILE = Path(__file__).resolve()
+PLUGIN_ROOT = THIS_FILE.parents[2]
+
+if str(PLUGIN_ROOT) not in sys.path:
+ sys.path.insert(0, str(PLUGIN_ROOT))
+
+from sn_basis.functions import (
+ get_plugin_root,
+ add_to_sys_path,
+)
+
+# ---------------------------------------------------------
+# Bootstrap: Plugin-Root in sys.path eintragen
+# ---------------------------------------------------------
+
+def bootstrap():
+ """
+ Simuliert das QGIS-Plugin-Startverhalten:
+ stellt sicher, dass sn_basis importierbar ist.
+ """
+ plugin_root = get_plugin_root()
+ add_to_sys_path(plugin_root)
+
+
+bootstrap()
+
+# ---------------------------------------------------------
+# Farben
+# ---------------------------------------------------------
+
+RED = "\033[91m"
+YELLOW = "\033[93m"
+GREEN = "\033[92m"
+CYAN = "\033[96m"
+MAGENTA = "\033[95m"
+RESET = "\033[0m"
+
+GLOBAL_TEST_COUNTER = 0
+
+# ---------------------------------------------------------
+# Farbige TestResult-Klasse
+# ---------------------------------------------------------
+
+class ColoredTestResult(unittest.TextTestResult):
+
+ _last_test_class: type | None = None
+
+
+ def startTest(self, test):
+ global GLOBAL_TEST_COUNTER
+ GLOBAL_TEST_COUNTER += 1
+ self.stream.write(f"{CYAN}[Test {GLOBAL_TEST_COUNTER}]{RESET}\n")
+ super().startTest(test)
+
+ def startTestClass(self, test):
+ cls = test.__class__
+ file = inspect.getfile(cls)
+ filename = os.path.basename(file)
+
+ self.stream.write(
+ f"\n{MAGENTA}{'=' * 70}\n"
+ f"Starte Testklasse: {filename} → {cls.__name__}\n"
+ f"{'=' * 70}{RESET}\n"
+ )
+
+ def addError(self, test, err):
+ super().addError(test, err)
+ self.stream.write(f"{RED}ERROR{RESET}\n")
+
+ def addFailure(self, test, err):
+ super().addFailure(test, err)
+ self.stream.write(f"{RED}FAILURE{RESET}\n")
+
+ def addSkip(self, test, reason):
+ super().addSkip(test, reason)
+ self.stream.write(f"{YELLOW}SKIPPED{RESET}: {reason}\n")
+
+ def addSuccess(self, test):
+ super().addSuccess(test)
+ self.stream.write(f"{GREEN}OK{RESET}\n")
+
+# ---------------------------------------------------------
+# Farbiger TestRunner
+# ---------------------------------------------------------
+
+class ColoredTestRunner(unittest.TextTestRunner):
+
+ def _makeResult(self):
+ result = ColoredTestResult(
+ self.stream,
+ self.descriptions,
+ self.verbosity,
+ )
+
+ original_start_test = result.startTest
+
+ def patched_start_test(test):
+ if not hasattr(result, "_last_test_class") or \
+ result._last_test_class != test.__class__:
+ result.startTestClass(test)
+ result._last_test_class = test.__class__
+ original_start_test(test)
+
+ result.startTest = patched_start_test
+ return result
+
+
+# ---------------------------------------------------------
+# Testlauf starten
+# ---------------------------------------------------------
+
+def main():
+ print("\n" + "=" * 70)
+ print(
+ f"{CYAN}Testlauf gestartet am: "
+ f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}{RESET}"
+ )
+ print("=" * 70 + "\n")
+
+ loader = unittest.TestLoader()
+
+ suite = loader.discover(
+ start_dir=os.path.dirname(__file__),
+ pattern="test_*.py"
+ )
+
+ runner = ColoredTestRunner(verbosity=2)
+ result = runner.run(suite)
+
+ # Exit-Code für CI / Skripte
+ return 0 if result.wasSuccessful() else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tests/start_osgeo4w_qgis.bat b/tests/start_osgeo4w_qgis.bat
new file mode 100644
index 0000000..a4b0c23
--- /dev/null
+++ b/tests/start_osgeo4w_qgis.bat
@@ -0,0 +1,9 @@
+@echo off
+SET OSGEO4W_ROOT=D:\QGISQT5
+call %OSGEO4W_ROOT%\bin\o4w_env.bat
+set QGIS_PREFIX_PATH=%OSGEO4W_ROOT%\apps\qgis
+set PYTHONPATH=%QGIS_PREFIX_PATH%\python;%PYTHONPATH%
+set PATH=%OSGEO4W_ROOT%\bin;%QGIS_PREFIX_PATH%\bin;%PATH%
+
+REM Neue Eingabeaufforderung starten und Python-Skript ausführen
+start cmd /k "python run_tests.py"
diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py
new file mode 100644
index 0000000..f87d84d
--- /dev/null
+++ b/tests/test_bootstrap.py
@@ -0,0 +1,2 @@
+from sn_basis.functions import sys_wrapper
+sys_wrapper.add_to_sys_path(sys_wrapper.get_plugin_root())
diff --git a/tests/test_dateipruefer.py b/tests/test_dateipruefer.py
new file mode 100644
index 0000000..84cd127
--- /dev/null
+++ b/tests/test_dateipruefer.py
@@ -0,0 +1,104 @@
+# sn_basis/test/test_dateipruefer.py
+
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from sn_basis.modules.Dateipruefer import Dateipruefer
+
+
+class TestDateipruefer(unittest.TestCase):
+
+ # -----------------------------------------------------
+ # 1. Leere Eingabe erlaubt
+ # -----------------------------------------------------
+ def test_leereingabe_erlaubt(self):
+ pruefer = Dateipruefer(
+ pfad="",
+ leereingabe_erlaubt=True
+ )
+
+ result = pruefer.pruefe()
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "leereingabe_erlaubt")
+ self.assertIsNone(result.kontext)
+
+ # -----------------------------------------------------
+ # 2. Leere Eingabe nicht erlaubt
+ # -----------------------------------------------------
+ def test_leereingabe_nicht_erlaubt(self):
+ pruefer = Dateipruefer(
+ pfad="",
+ leereingabe_erlaubt=False
+ )
+
+ result = pruefer.pruefe()
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "leereingabe_nicht_erlaubt")
+ self.assertIsNone(result.kontext)
+
+ # -----------------------------------------------------
+ # 3. Standarddatei vorschlagen
+ # -----------------------------------------------------
+ def test_standarddatei_vorschlagen(self):
+ pruefer = Dateipruefer(
+ pfad="",
+ standarddatei="/tmp/std.txt"
+ )
+
+ result = pruefer.pruefe()
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "standarddatei_vorschlagen")
+ self.assertEqual(result.kontext, Path("/tmp/std.txt"))
+
+ # -----------------------------------------------------
+ # 4. Temporäre Datei erlaubt
+ # -----------------------------------------------------
+ def test_temporaer_erlaubt(self):
+ pruefer = Dateipruefer(
+ pfad="",
+ temporaer_erlaubt=True
+ )
+
+ result = pruefer.pruefe()
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "temporaer_erlaubt")
+ self.assertIsNone(result.kontext)
+
+ # -----------------------------------------------------
+ # 5. Datei existiert nicht
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Dateipruefer.file_exists", return_value=False)
+ def test_datei_nicht_gefunden(self, mock_exists):
+ pruefer = Dateipruefer(
+ pfad="/tmp/nichtvorhanden.txt"
+ )
+
+ result = pruefer.pruefe()
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "datei_nicht_gefunden")
+ self.assertEqual(result.kontext, Path("/tmp/nichtvorhanden.txt"))
+
+ # -----------------------------------------------------
+ # 6. Datei existiert
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Dateipruefer.file_exists", return_value=True)
+ def test_datei_ok(self, mock_exists):
+ pruefer = Dateipruefer(
+ pfad="/tmp/test.txt"
+ )
+
+ result = pruefer.pruefe()
+
+ self.assertTrue(result.ok)
+ self.assertEqual(result.aktion, "ok")
+ self.assertEqual(result.kontext, Path("/tmp/test.txt"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_layerpruefer.py b/tests/test_layerpruefer.py
new file mode 100644
index 0000000..9bff1ad
--- /dev/null
+++ b/tests/test_layerpruefer.py
@@ -0,0 +1,171 @@
+# sn_basis/test/test_layerpruefer.py
+
+import unittest
+
+from sn_basis.modules.layerpruefer import Layerpruefer
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
+
+
+# ---------------------------------------------------------
+# Mock-Layer für Wrapper-Tests
+# ---------------------------------------------------------
+class MockLayer:
+ def __init__(
+ self,
+ exists=True,
+ visible=True,
+ layer_type="vector",
+ geometry_type="Polygon",
+ feature_count=10,
+ crs="EPSG:25833",
+ fields=None,
+ source="/tmp/test.shp",
+ editable=True,
+ ):
+ self.exists = exists
+ self.visible = visible
+ self.layer_type = layer_type
+ self.geometry_type = geometry_type
+ self.feature_count = feature_count
+ self.crs = crs
+ self.fields = fields or []
+ self.source = source
+ self.editable = editable
+
+
+# ---------------------------------------------------------
+# Wrapper-Mocks (monkeypatching)
+# ---------------------------------------------------------
+def mock_layer_exists(layer):
+ return layer is not None and layer.exists
+
+
+def mock_is_layer_visible(layer):
+ return layer.visible
+
+
+def mock_get_layer_type(layer):
+ return layer.layer_type
+
+
+def mock_get_layer_geometry_type(layer):
+ return layer.geometry_type
+
+
+def mock_get_layer_feature_count(layer):
+ return layer.feature_count
+
+
+def mock_get_layer_crs(layer):
+ return layer.crs
+
+
+def mock_get_layer_fields(layer):
+ return layer.fields
+
+
+def mock_get_layer_source(layer):
+ return layer.source
+
+
+def mock_is_layer_editable(layer):
+ return layer.editable
+
+
+# ---------------------------------------------------------
+# Testklasse
+# ---------------------------------------------------------
+class TestLayerpruefer(unittest.TestCase):
+
+ def setUp(self):
+ # Monkeypatching der im Layerpruefer verwendeten Wrapper-Funktionen
+ import sn_basis.modules.layerpruefer as module
+
+ module.layer_exists = mock_layer_exists
+ module.is_layer_visible = mock_is_layer_visible
+ module.get_layer_type = mock_get_layer_type
+ module.get_layer_geometry_type = mock_get_layer_geometry_type
+ module.get_layer_feature_count = mock_get_layer_feature_count
+ module.get_layer_crs = mock_get_layer_crs
+ module.get_layer_fields = mock_get_layer_fields
+ module.get_layer_source = mock_get_layer_source
+ module.is_layer_editable = mock_is_layer_editable
+
+
+ # -----------------------------------------------------
+ # Tests
+ # -----------------------------------------------------
+
+ def test_layer_exists(self):
+ layer = MockLayer(exists=False)
+ pruefer = Layerpruefer(layer)
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "layer_nicht_gefunden")
+
+ def test_layer_unsichtbar(self):
+ layer = MockLayer(visible=False)
+ pruefer = Layerpruefer(layer, muss_sichtbar_sein=True)
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "layer_unsichtbar")
+
+ def test_falscher_layertyp(self):
+ layer = MockLayer(layer_type="raster")
+ pruefer = Layerpruefer(layer, erwarteter_layertyp="vector")
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "falscher_layertyp")
+
+ def test_falscher_geotyp(self):
+ layer = MockLayer(geometry_type="Point")
+ pruefer = Layerpruefer(layer, erwarteter_geotyp="Polygon")
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "falscher_geotyp")
+
+ def test_layer_leer(self):
+ layer = MockLayer(feature_count=0)
+ pruefer = Layerpruefer(layer)
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "layer_leer")
+
+ def test_falsches_crs(self):
+ layer = MockLayer(crs="EPSG:4326")
+ pruefer = Layerpruefer(layer, erwartetes_crs="EPSG:25833")
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "falsches_crs")
+
+ def test_felder_fehlen(self):
+ layer = MockLayer(fields=["id"])
+ pruefer = Layerpruefer(layer, erforderliche_felder=["id", "name"])
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "felder_fehlen")
+
+ def test_datenquelle_unerwartet(self):
+ layer = MockLayer(source="/tmp/test.shp")
+ pruefer = Layerpruefer(layer, erlaubte_datenquellen=["/tmp/allowed.shp"])
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "datenquelle_unerwartet")
+
+ def test_layer_nicht_editierbar(self):
+ layer = MockLayer(editable=False)
+ pruefer = Layerpruefer(layer, muss_editierbar_sein=True)
+ ergebnis = pruefer.pruefe()
+ self.assertFalse(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "layer_nicht_editierbar")
+
+ def test_layer_ok(self):
+ layer = MockLayer()
+ pruefer = Layerpruefer(layer)
+ ergebnis = pruefer.pruefe()
+ self.assertTrue(ergebnis.ok)
+ self.assertEqual(ergebnis.aktion, "ok")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_linkpruefer.py b/tests/test_linkpruefer.py
new file mode 100644
index 0000000..07c0fff
--- /dev/null
+++ b/tests/test_linkpruefer.py
@@ -0,0 +1,79 @@
+# sn_basis/test/test_linkpruefer.py
+
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+from sn_basis.modules.linkpruefer import Linkpruefer
+from sn_basis.functions.qgiscore_wrapper import NetworkReply
+
+
+class TestLinkpruefer(unittest.TestCase):
+
+ # -----------------------------------------------------
+ # 1. Remote-Link erreichbar
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.linkpruefer.network_head")
+ def test_remote_link_ok(self, mock_head):
+ mock_head.return_value = NetworkReply(error=0)
+
+ lp = Linkpruefer()
+ result = lp.pruefe("http://example.com")
+
+ self.assertTrue(result.ok)
+ self.assertEqual(result.aktion, "ok")
+ self.assertEqual(result.kontext, "http://example.com")
+
+ # -----------------------------------------------------
+ # 2. Remote-Link nicht erreichbar
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.linkpruefer.network_head")
+ def test_remote_link_error(self, mock_head):
+ mock_head.return_value = NetworkReply(error=1)
+
+ lp = Linkpruefer()
+ result = lp.pruefe("http://example.com")
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "url_nicht_erreichbar")
+ self.assertEqual(result.kontext, "http://example.com")
+
+ # -----------------------------------------------------
+ # 3. Netzwerkfehler (None)
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.linkpruefer.network_head", return_value=None)
+ def test_remote_link_network_error(self, mock_head):
+ lp = Linkpruefer()
+ result = lp.pruefe("http://example.com")
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "netzwerkfehler")
+ self.assertEqual(result.kontext, "http://example.com")
+
+ # -----------------------------------------------------
+ # 4. Lokaler Pfad existiert nicht
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.linkpruefer.file_exists", return_value=False)
+ def test_local_link_not_found(self, mock_exists):
+ lp = Linkpruefer()
+ result = lp.pruefe("/path/to/missing/file.shp")
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "pfad_nicht_gefunden")
+ self.assertEqual(result.kontext, Path("/path/to/missing/file.shp"))
+
+ # -----------------------------------------------------
+ # 5. Lokaler Pfad existiert
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.linkpruefer.file_exists", return_value=True)
+ def test_local_link_ok(self, mock_exists):
+ lp = Linkpruefer()
+ result = lp.pruefe("/path/to/file.shp")
+
+ self.assertTrue(result.ok)
+ self.assertEqual(result.aktion, "ok")
+ self.assertEqual(result.kontext, Path("/path/to/file.shp"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_pruefmanager.py b/tests/test_pruefmanager.py
new file mode 100644
index 0000000..ef8d95b
--- /dev/null
+++ b/tests/test_pruefmanager.py
@@ -0,0 +1,146 @@
+# sn_basis/test/test_pruefmanager.py
+
+import unittest
+from unittest.mock import patch
+
+from sn_basis.modules.Pruefmanager import Pruefmanager
+from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
+
+
+class TestPruefmanager(unittest.TestCase):
+
+ def setUp(self):
+ self.manager = Pruefmanager()
+
+ # -----------------------------------------------------
+ # 1. OK-Ergebnis → keine Interaktion
+ # -----------------------------------------------------
+ def test_ok(self):
+ ergebnis = pruef_ergebnis(True, "Alles gut", "ok", None)
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertTrue(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "ok")
+
+ # -----------------------------------------------------
+ # 2. Leere Eingabe erlaubt → Nutzer sagt JA
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True)
+ def test_leereingabe_erlaubt_ja(self, mock_ask):
+ ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None)
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertTrue(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "ok")
+
+ # -----------------------------------------------------
+ # 3. Leere Eingabe erlaubt → Nutzer sagt NEIN
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False)
+ def test_leereingabe_erlaubt_nein(self, mock_ask):
+ ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None)
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertFalse(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "leereingabe_erlaubt")
+
+ # -----------------------------------------------------
+ # 4. Standarddatei vorschlagen → Nutzer sagt JA
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True)
+ def test_standarddatei_vorschlagen_ja(self, mock_ask):
+ ergebnis = pruef_ergebnis(
+ False,
+ "Standarddatei verwenden?",
+ "standarddatei_vorschlagen",
+ "/tmp/std.txt",
+ )
+
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertTrue(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "ok")
+ self.assertEqual(entscheidung.kontext, "/tmp/std.txt")
+
+ # -----------------------------------------------------
+ # 5. Standarddatei vorschlagen → Nutzer sagt NEIN
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False)
+ def test_standarddatei_vorschlagen_nein(self, mock_ask):
+ ergebnis = pruef_ergebnis(
+ False,
+ "Standarddatei verwenden?",
+ "standarddatei_vorschlagen",
+ "/tmp/std.txt",
+ )
+
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertFalse(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "standarddatei_vorschlagen")
+
+ # -----------------------------------------------------
+ # 6. Temporäre Datei erzeugen → Nutzer sagt JA
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True)
+ def test_temporaer_erlaubt_ja(self, mock_ask):
+ ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None)
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertTrue(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "temporaer_erzeugen")
+
+ # -----------------------------------------------------
+ # 7. Temporäre Datei erzeugen → Nutzer sagt NEIN
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False)
+ def test_temporaer_erlaubt_nein(self, mock_ask):
+ ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None)
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertFalse(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "temporaer_erlaubt")
+
+ # -----------------------------------------------------
+ # 8. Layer unsichtbar → Nutzer sagt JA
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True)
+ @patch("sn_basis.modules.Pruefmanager.set_layer_visible")
+ def test_layer_unsichtbar_ja(self, mock_set, mock_ask):
+ fake_layer = object()
+ ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer)
+
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ mock_set.assert_called_once_with(fake_layer, True)
+ self.assertTrue(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "ok")
+
+ # -----------------------------------------------------
+ # 9. Layer unsichtbar → Nutzer sagt NEIN
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False)
+ def test_layer_unsichtbar_nein(self, mock_ask):
+ fake_layer = object()
+ ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer)
+
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ self.assertFalse(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "layer_unsichtbar")
+
+ # -----------------------------------------------------
+ # 10. Fehlerhafte Aktion → Fallback
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.Pruefmanager.warning")
+ def test_unbekannte_aktion(self, mock_warn):
+ ergebnis = pruef_ergebnis(False, "???", "unbekannt", None)
+ entscheidung = self.manager.verarbeite(ergebnis)
+
+ mock_warn.assert_called_once()
+ self.assertFalse(entscheidung.ok)
+ self.assertEqual(entscheidung.aktion, "unbekannt")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_qgis.bat b/tests/test_qgis.bat
new file mode 100644
index 0000000..fc9f9bc
--- /dev/null
+++ b/tests/test_qgis.bat
@@ -0,0 +1,52 @@
+@echo off
+setlocal
+echo BATCH WIRD AUSGEFÜHRT
+pause
+
+echo ================================================
+echo Starte Tests in QGIS-Python-Umgebung
+echo ================================================
+
+REM Pfad zur QGIS-Installation
+set QGIS_BIN=D:\OSGeo\bin
+
+REM Prüfen, ob python-qgis.bat existiert
+if not exist "%QGIS_BIN%\python-qgis.bat" (
+ echo.
+ echo [FEHLER] python-qgis.bat wurde nicht gefunden!
+ echo Erwarteter Pfad:
+ echo %QGIS_BIN%\python-qgis.bat
+ echo.
+ echo Bitte korrigiere den Pfad in test_qgis.bat.
+ echo.
+ pause
+ exit /b 1
+)
+
+echo.
+echo [INFO] QGIS-Python gefunden. Starte Tests...
+echo.
+
+"%QGIS_BIN%\python-qgis.bat" -m coverage run run_tests.py
+if errorlevel 1 (
+ echo.
+ echo [FEHLER] Testlauf fehlgeschlagen.
+ echo.
+ pause
+ exit /b 1
+)
+
+echo.
+echo ================================================
+echo Coverage HTML-Bericht wird erzeugt...
+echo ================================================
+
+"%QGIS_BIN%\python-qgis.bat" -m coverage html
+
+echo.
+echo Fertig!
+echo Öffne jetzt: coverage_html\index.html
+echo ================================================
+
+pause
+endlocal
diff --git a/tests/test_settings_logic.py b/tests/test_settings_logic.py
new file mode 100644
index 0000000..6296e9f
--- /dev/null
+++ b/tests/test_settings_logic.py
@@ -0,0 +1,60 @@
+# sn_basis/test/test_settings_logic.py
+
+import unittest
+from unittest.mock import patch
+
+from sn_basis.functions.settings_logic import SettingsLogic
+
+
+class TestSettingsLogic(unittest.TestCase):
+
+ # -----------------------------------------------------
+ # Test: load() liest alle Variablen über get_variable()
+ # -----------------------------------------------------
+ @patch("sn_basis.functions.settings_logic.get_variable")
+ def test_load(self, mock_get):
+ # Mock-Rückgabe für jede Variable
+ mock_get.side_effect = lambda key, scope="project": f"wert_{key}"
+
+ logic = SettingsLogic()
+ daten = logic.load()
+
+ # Alle Variablen müssen enthalten sein
+ for key in SettingsLogic.VARIABLEN:
+ self.assertIn(key, daten)
+ self.assertEqual(daten[key], f"wert_{key}")
+
+ # get_variable muss für jede Variable genau einmal aufgerufen werden
+ self.assertEqual(mock_get.call_count, len(SettingsLogic.VARIABLEN))
+
+ # -----------------------------------------------------
+ # Test: save() ruft set_variable() nur für bekannte Keys auf
+ # -----------------------------------------------------
+ @patch("sn_basis.functions.settings_logic.set_variable")
+ def test_save(self, mock_set):
+ logic = SettingsLogic()
+
+ # Eingabedaten enthalten gültige und ungültige Keys
+ daten = {
+ "amt": "A1",
+ "behoerde": "B1",
+ "unbekannt": "IGNORIEREN",
+ "gemeinden": "G1",
+ }
+
+ logic.save(daten)
+
+ # set_variable muss nur für gültige Keys aufgerufen werden
+ expected_calls = 3 # amt, behoerde, gemeinden
+ self.assertEqual(mock_set.call_count, expected_calls)
+
+ # Prüfen, ob die richtigen Keys gespeichert wurden
+ saved_keys = [call.args[0] for call in mock_set.call_args_list]
+ self.assertIn("amt", saved_keys)
+ self.assertIn("behoerde", saved_keys)
+ self.assertIn("gemeinden", saved_keys)
+ self.assertNotIn("unbekannt", saved_keys)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_stilpruefer.py b/tests/test_stilpruefer.py
new file mode 100644
index 0000000..06c2fca
--- /dev/null
+++ b/tests/test_stilpruefer.py
@@ -0,0 +1,81 @@
+# sn_basis/test/test_stilpruefer.py
+
+import unittest
+import tempfile
+import os
+from pathlib import Path
+from unittest.mock import patch
+
+from sn_basis.modules.stilpruefer import Stilpruefer
+
+
+class TestStilpruefer(unittest.TestCase):
+
+ def setUp(self):
+ self.pruefer = Stilpruefer()
+
+ # -----------------------------------------------------
+ # 1. Keine Datei angegeben
+ # -----------------------------------------------------
+ def test_keine_datei_angegeben(self):
+ result = self.pruefer.pruefe("")
+
+ self.assertTrue(result.ok)
+ self.assertEqual(result.aktion, "ok")
+ self.assertIn("Kein Stil angegeben", result.meldung)
+ self.assertIsNone(result.kontext)
+
+ # -----------------------------------------------------
+ # 2. Datei existiert und ist .qml
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.stilpruefer.file_exists", return_value=True)
+ def test_datei_existiert_mit_qml(self, mock_exists):
+ with tempfile.NamedTemporaryFile(suffix=".qml", delete=False) as tmp:
+ tmp_path = tmp.name
+
+ try:
+ result = self.pruefer.pruefe(tmp_path)
+
+ self.assertTrue(result.ok)
+ self.assertEqual(result.aktion, "stil_anwendbar")
+ self.assertEqual(result.kontext, Path(tmp_path))
+
+ finally:
+ os.remove(tmp_path)
+
+ # -----------------------------------------------------
+ # 3. Datei existiert, aber falsche Endung
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.stilpruefer.file_exists", return_value=True)
+ def test_datei_existiert_falsche_endung(self, mock_exists):
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
+ tmp_path = tmp.name
+
+ try:
+ result = self.pruefer.pruefe(tmp_path)
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "falsche_endung")
+ self.assertIn(".qml", result.meldung)
+ self.assertEqual(result.kontext, Path(tmp_path))
+
+ finally:
+ os.remove(tmp_path)
+
+ # -----------------------------------------------------
+ # 4. Datei existiert nicht
+ # -----------------------------------------------------
+ @patch("sn_basis.modules.stilpruefer.file_exists", return_value=False)
+ def test_datei_existiert_nicht(self, mock_exists):
+ fake_path = "/tmp/nichtvorhanden.qml"
+
+ result = self.pruefer.pruefe(fake_path)
+
+ self.assertFalse(result.ok)
+ self.assertEqual(result.aktion, "datei_nicht_gefunden")
+ self.assertIn("nicht gefunden", result.meldung)
+ self.assertEqual(result.kontext, Path(fake_path))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/ui/base_dockwidget.py b/ui/base_dockwidget.py
index 4184b6d..ededcc9 100644
--- a/ui/base_dockwidget.py
+++ b/ui/base_dockwidget.py
@@ -1,28 +1,111 @@
-from qgis.PyQt.QtWidgets import QDockWidget, QTabWidget
+"""
+sn_basis/ui/base_dockwidget.py
+
+Basis-Dockwidget für alle LNO-Module.
+"""
+
+from sn_basis.functions.qt_wrapper import QDockWidget, QTabWidget
+from sn_basis.functions.message_wrapper import warning, error
+from sn_basis.functions.qt_wrapper import (
+ QDockWidget,
+ QTabWidget,
+ Qt,
+ DockWidgetMovable,
+ DockWidgetFloatable,
+ DockWidgetClosable,
+ DockAreaLeft,
+ DockAreaRight,
+)
+
+
class BaseDockWidget(QDockWidget):
+ """
+ Basis-Dockwidget für alle LNO-Module.
+
+ - Titel wird automatisch aus base_title + subtitle erzeugt
+ - Tabs werden dynamisch aus der Klassenvariable 'tabs' erzeugt
+ - Die zugehörige Toolbar-Action wird beim Schließen zurückgesetzt
+ """
+
base_title = "LNO Sachsen"
- tabs = []
- action = None # Referenz auf die Toolbar-Action
+ tabs = [] # Liste von Tab-Klassen
+ action = None # Referenz auf die Toolbar-Action
def __init__(self, parent=None, subtitle=""):
super().__init__(parent)
+ # -----------------------------------------------------
+ # Dock-Konfiguration (WICHTIG)
+ # -----------------------------------------------------
+ self.setFeatures(
+ DockWidgetMovable
+ | DockWidgetFloatable
+ | DockWidgetClosable
+ )
- # Titel zusammensetzen
- title = self.base_title if not subtitle else f"{self.base_title} | {subtitle}"
- self.setWindowTitle(title)
+ self.setAllowedAreas(
+ DockAreaLeft
+ | DockAreaRight
+ )
- # Dock fixieren (nur schließen erlaubt)
- self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable)
+ # -----------------------------------------------------
+ # Titel setzen
+ # -----------------------------------------------------
+ try:
+ title = (
+ self.base_title
+ if not subtitle
+ else f"{self.base_title} | {subtitle}"
+ )
+ self.setWindowTitle(title)
+ except Exception as e:
+ warning("Titel konnte nicht gesetzt werden", str(e))
- # Tabs hinzufügen
- tab_widget = QTabWidget()
- for tab_class in self.tabs:
- tab_widget.addTab(tab_class(), getattr(tab_class, "tab_title", tab_class.__name__))
- self.setWidget(tab_widget)
+ # -----------------------------------------------------
+ # Tabs erzeugen
+ # -----------------------------------------------------
+ try:
+ tab_widget = QTabWidget()
+
+ for tab_class in self.tabs:
+ try:
+ tab_instance = tab_class()
+ tab_title = getattr(
+ tab_class,
+ "tab_title",
+ tab_class.__name__,
+ )
+ tab_widget.addTab(tab_instance, tab_title)
+ except Exception as e:
+ error(
+ "Tab konnte nicht geladen werden",
+ f"{tab_class}: {e}",
+ )
+
+ self.setWidget(tab_widget)
+
+ except Exception as e:
+ error(
+ "Tab-Widget konnte nicht initialisiert werden",
+ str(e),
+ )
+
+ # ---------------------------------------------------------
+ # Dock schließen
+ # ---------------------------------------------------------
def closeEvent(self, event):
- """Wird aufgerufen, wenn das Dock geschlossen wird."""
- if self.action:
- self.action.setChecked(False) # Toolbar-Button zurücksetzen
+ """
+ Wird aufgerufen, wenn das Dock geschlossen wird.
+ Setzt die zugehörige Toolbar-Action zurück.
+ """
+ try:
+ if self.action:
+ self.action.setChecked(False)
+ except Exception as e:
+ warning(
+ "Toolbar-Status konnte nicht zurückgesetzt werden",
+ str(e),
+ )
+
super().closeEvent(event)
diff --git a/ui/dockmanager.py b/ui/dockmanager.py
index 50bdd34..bcd92fb 100644
--- a/ui/dockmanager.py
+++ b/ui/dockmanager.py
@@ -1,21 +1,85 @@
-from qgis.PyQt.QtCore import Qt
-from qgis.PyQt.QtWidgets import QDockWidget
-from qgis.utils import iface
+"""
+sn_basis/ui/dockmanager.py
+
+Verwaltet das Anzeigen und Ersetzen von DockWidgets.
+Stellt sicher, dass immer nur ein sn_basis-Dock gleichzeitig sichtbar ist.
+"""
+
+from typing import Any, Optional
+
+from sn_basis.functions import (
+ add_dock_widget,
+ remove_dock_widget,
+ find_dock_widgets,
+ warning,
+ error,
+)
+from sn_basis.functions.qt_wrapper import (
+ DockAreaRight,
+)
class DockManager:
- default_area = Qt.DockWidgetArea.RightDockWidgetArea
+ """
+ Verwaltet das Anzeigen und Ersetzen von DockWidgets.
+ """
+
+ dock_prefix = "sn_dock_"
@classmethod
- def show(cls, dock_widget, area=None):
- area = area or cls.default_area
+ def show(cls, dock_widget: Any, area: Optional[Any] = None) -> None:
+ """
+ Zeigt ein DockWidget an und entfernt vorher alle anderen
+ sn_basis-Docks (erkennbar am Prefix 'sn_dock_').
+ """
- # Bestehende Plugin-Docks mit Präfix schließen
- for widget in iface.mainWindow().findChildren(QDockWidget):
- if widget is not dock_widget and widget.objectName().startswith("sn_dock_"):
- iface.removeDockWidget(widget)
- widget.deleteLater()
+ # -----------------------------------------------------
+ # Default-Dock-Area (wrapper-konform)
+ # -----------------------------------------------------
+ if area is None:
+ area = DockAreaRight
- # Neues Dock anzeigen
- iface.addDockWidget(area, dock_widget)
- dock_widget.show()
+ if dock_widget is None:
+ error("Dock konnte nicht angezeigt werden", "Dock-Widget ist None.")
+ return
+
+ try:
+ # -------------------------------------------------
+ # Sicherstellen, dass das Dock einen Namen hat
+ # -------------------------------------------------
+ if not dock_widget.objectName():
+ dock_widget.setObjectName(
+ f"{cls.dock_prefix}{id(dock_widget)}"
+ )
+
+ # -------------------------------------------------
+ # Vorhandene Plugin-Docks entfernen
+ # -------------------------------------------------
+ try:
+ for widget in find_dock_widgets():
+ if (
+ widget is not dock_widget
+ and widget.objectName().startswith(cls.dock_prefix)
+ ):
+ remove_dock_widget(widget)
+ widget.deleteLater()
+ except Exception as e:
+ warning(
+ "Vorherige Docks konnten nicht entfernt werden",
+ str(e),
+ )
+
+ # -------------------------------------------------
+ # Neues Dock anzeigen
+ # -------------------------------------------------
+ try:
+ add_dock_widget(area, dock_widget)
+ dock_widget.show()
+ except Exception as e:
+ error(
+ "Dock konnte nicht angezeigt werden",
+ str(e),
+ )
+
+ except Exception as e:
+ error("DockManager-Fehler", str(e))
diff --git a/ui/navigation.py b/ui/navigation.py
index 44a8895..35bbf45 100644
--- a/ui/navigation.py
+++ b/ui/navigation.py
@@ -1,83 +1,126 @@
-from qgis.PyQt.QtWidgets import QAction, QMenu, QToolBar, QActionGroup
+"""
+sn_basis/ui/navigation.py
+
+Zentrale Navigation (Menü + Toolbar) für sn_basis.
+"""
+
+from typing import Any, List, Tuple
+
+from sn_basis.functions.qt_wrapper import (
+ QAction,
+ QMenu,
+ QToolBar,
+ QActionGroup,
+)
+from sn_basis.functions import (
+ get_main_window,
+ add_toolbar,
+ remove_toolbar,
+ add_menu,
+ remove_menu,
+)
+
class Navigation:
- def __init__(self, iface):
- self.iface = iface
+ def __init__(self):
self.actions = []
-
- # Menü und Toolbar einmalig anlegen
- self.menu = QMenu("LNO Sachsen", iface.mainWindow())
- iface.mainWindow().menuBar().addMenu(self.menu)
+ self.menu = None
+ self.toolbar = None
+ self.plugin_group = None
+
- self.toolbar = QToolBar("LNO Sachsen")
+
+ def init_ui(self):
+ print(">>> Navigation.init_ui() CALLED")
+
+ main_window = get_main_window()
+ if not main_window:
+ return
+ # -----------------------------------------
+ # Vorherige Toolbars entfernen
+ # -----------------------------------------
+ for tb in main_window.findChildren(QToolBar):
+ if tb.objectName() == "LnoSachsenToolbar":
+ remove_toolbar(tb)
+ tb.deleteLater()
+
+ # -----------------------------------------
+ # Menü und Toolbar neu erzeugen
+ # -----------------------------------------
+ self.menu = QMenu("LNO Sachsen", main_window)
+ add_menu(self.menu)
+
+ self.toolbar = QToolBar("LNO Sachsen", main_window)
self.toolbar.setObjectName("LnoSachsenToolbar")
- iface.addToolBar(self.toolbar)
+ add_toolbar(self.toolbar)
- # Gruppe für exklusive Auswahl (nur ein Plugin aktiv)
- self.plugin_group = QActionGroup(iface.mainWindow())
+ test_action = QAction("TEST ACTION", main_window)
+ self.menu.addAction(test_action)
+ self.toolbar.addAction(test_action)
+ self.plugin_group = QActionGroup(main_window)
self.plugin_group.setExclusive(True)
+
+ # -----------------------------------------------------
+ # Actions
+ # -----------------------------------------------------
+
def add_action(self, text, callback, tooltip="", priority=100):
- action = QAction(text, self.iface.mainWindow())
+ if not self.plugin_group:
+ return None
+
+ action = QAction(text, get_main_window())
action.setToolTip(tooltip)
- action.setCheckable(True) # Button kann aktiv sein
+ action.setCheckable(True)
action.triggered.connect(callback)
- # Action in Gruppe aufnehmen
self.plugin_group.addAction(action)
-
- # Action mit Priority speichern
self.actions.append((priority, action))
return action
-
+
def finalize_menu_and_toolbar(self):
- # Sortieren nach Priority
+ if not self.menu or not self.toolbar:
+ return
+
self.actions.sort(key=lambda x: x[0])
- # Menüeinträge
self.menu.clear()
+ self.toolbar.clear()
+
for _, action in self.actions:
self.menu.addAction(action)
-
- # Toolbar-Einträge
- self.toolbar.clear()
- for _, action in self.actions:
self.toolbar.addAction(action)
def set_active_plugin(self, active_action):
- # Alle zurücksetzen, dann aktives Plugin markieren
for _, action in self.actions:
action.setChecked(False)
if active_action:
active_action.setChecked(True)
- def remove_all(self):
- """Alles entfernen beim Entladen des Basisplugins"""
- # Menü entfernen
- if self.menu:
- self.iface.mainWindow().menuBar().removeAction(self.menu.menuAction())
- self.menu = None
-
- # Toolbar entfernen
- if self.toolbar:
- self.iface.mainWindow().removeToolBar(self.toolbar)
- self.toolbar = None
-
- # Actions zurücksetzen
- self.actions.clear()
-
- # Gruppe leeren
- self.plugin_group = None
+ # -----------------------------------------------------
+ # Cleanup
+ # -----------------------------------------------------
def remove_action(self, action):
- """Entfernt eine einzelne Action aus Menü und Toolbar"""
if not action:
return
- # Menüeintrag entfernen
+
if self.menu:
self.menu.removeAction(action)
- # Toolbar-Eintrag entfernen
if self.toolbar:
self.toolbar.removeAction(action)
- # Aus der internen Liste löschen
+
self.actions = [(p, a) for p, a in self.actions if a != action]
+
+ def remove_all(self):
+ if self.menu:
+ remove_menu(self.menu)
+ self.menu = None
+
+ if self.toolbar:
+ remove_toolbar(self.toolbar)
+ self.toolbar = None
+
+ self.actions.clear()
+ self.plugin_group = None
+
diff --git a/ui/tabs/settings_tab.py b/ui/tabs/settings_tab.py
index a8f5de9..fdacd5e 100644
--- a/ui/tabs/settings_tab.py
+++ b/ui/tabs/settings_tab.py
@@ -1,12 +1,18 @@
-from qgis.PyQt.QtWidgets import (
- QWidget, QGridLayout, QLabel, QLineEdit,
- QGroupBox, QVBoxLayout, QPushButton
+#sn_basis/ui/tabs/settings_tab.py
+from sn_basis.functions.qt_wrapper import (
+ QWidget,
+ QGridLayout,
+ QLabel,
+ QLineEdit,
+ QGroupBox,
+ QVBoxLayout,
+ QPushButton,
)
from sn_basis.functions.settings_logic import SettingsLogic
class SettingsTab(QWidget):
- tab_title = "Projekteigenschaften" # Titel für den Tab
+ tab_title = "Projekteigenschaften"
def __init__(self, parent=None):
super().__init__(parent)
@@ -14,58 +20,87 @@ class SettingsTab(QWidget):
main_layout = QVBoxLayout()
+ # -----------------------------
# Definition der Felder
+ # -----------------------------
self.user_fields = {
"amt": "Amt:",
"behoerde": "Behörde:",
"landkreis_user": "Landkreis:",
- "sachgebiet": "Sachgebiet:"
+ "sachgebiet": "Sachgebiet:",
}
+
self.project_fields = {
"bezeichnung": "Bezeichnung:",
"verfahrensnummer": "Verfahrensnummer:",
"gemeinden": "Gemeinde(n):",
- "landkreise_proj": "Landkreis(e):"
+ "landkreise_proj": "Landkreis(e):",
}
- # 🟦 Benutzerspezifische Festlegungen
+ # -----------------------------
+ # Benutzerspezifische Festlegungen
+ # -----------------------------
user_group = QGroupBox("Benutzerspezifische Festlegungen")
user_layout = QGridLayout()
self.user_inputs = {}
+
for row, (key, label) in enumerate(self.user_fields.items()):
- self.user_inputs[key] = QLineEdit()
+ input_widget = QLineEdit()
+ self.user_inputs[key] = input_widget
+
user_layout.addWidget(QLabel(label), row, 0)
- user_layout.addWidget(self.user_inputs[key], row, 1)
+ user_layout.addWidget(input_widget, row, 1)
+
user_group.setLayout(user_layout)
- # 🟨 Projektspezifische Festlegungen
+ # -----------------------------
+ # Projektspezifische Festlegungen
+ # -----------------------------
project_group = QGroupBox("Projektspezifische Festlegungen")
project_layout = QGridLayout()
self.project_inputs = {}
+
for row, (key, label) in enumerate(self.project_fields.items()):
- self.project_inputs[key] = QLineEdit()
+ input_widget = QLineEdit()
+ self.project_inputs[key] = input_widget
+
project_layout.addWidget(QLabel(label), row, 0)
- project_layout.addWidget(self.project_inputs[key], row, 1)
+ project_layout.addWidget(input_widget, row, 1)
+
project_group.setLayout(project_layout)
- # 🟩 Speichern-Button
+ # -----------------------------
+ # Speichern-Button
+ # -----------------------------
save_button = QPushButton("Speichern")
save_button.clicked.connect(self.save_data)
+ # -----------------------------
# Layout zusammenfügen
+ # -----------------------------
main_layout.addWidget(user_group)
main_layout.addWidget(project_group)
main_layout.addStretch()
main_layout.addWidget(save_button)
self.setLayout(main_layout)
+
+ # Daten laden
self.load_data()
+ # ---------------------------------------------------------
+ # Speichern
+ # ---------------------------------------------------------
def save_data(self):
- # Alle Felder zusammenführen
- fields = {key: widget.text() for key, widget in {**self.user_inputs, **self.project_inputs}.items()}
+ fields = {
+ key: widget.text()
+ for key, widget in {**self.user_inputs, **self.project_inputs}.items()
+ }
self.logic.save(fields)
+ # ---------------------------------------------------------
+ # Laden
+ # ---------------------------------------------------------
def load_data(self):
data = self.logic.load()
for key, widget in {**self.user_inputs, **self.project_inputs}.items():