diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4087545 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[run] +source = modules +omit = + */test/* + */__init__.py + +[report] +show_missing = True +skip_covered = False + +[html] +directory = coverage_html diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0dfa213 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to QGIS (Port 5678)", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis", + "remoteRoot": "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + } + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d8d3a84 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + // OSGeo Python als Interpreter (QGIS 4.0, Qt6) + "python.defaultInterpreterPath": "D:/OSGeo/apps/Python312/python.exe", + + // Pylance: zusätzliche Suchpfade + "python.analysis.extraPaths": [ + "D:/OSGeo/apps/qgis/python", + "D:/OSGeo/apps/Python312/Lib/site-packages", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + ], + + // Autocomplete ebenfalls erweitern + "python.autoComplete.extraPaths": [ + "D:/OSGeo/apps/qgis/python", + "D:/OSGeo/apps/Python312/Lib/site-packages", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + ], + + // Pylance-Modus + "python.analysis.typeCheckingMode": "basic", + "python.analysis.diagnosticMode": "workspace", + + // Tests aktivieren + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true, + "python.testing.unittestArgs": [ + "-v", + "-s", + "./test", + "-p", + "*test*.py" + ] +} diff --git a/__init__.py b/__init__.py index f579213..854d63e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,3 @@ -from .functions.variable_utils import get_variable - def classFactory(iface): from .main import BasisPlugin return BasisPlugin(iface) diff --git a/__pdoc__.py b/__pdoc__.py new file mode 100644 index 0000000..bfecc06 --- /dev/null +++ b/__pdoc__.py @@ -0,0 +1,3 @@ +__pdoc__ = { + "main": False, +} diff --git a/assets/Dateipruefer_flowchart.svg b/assets/Dateipruefer_flowchart.svg new file mode 100644 index 0000000..b9abfe8 --- /dev/null +++ b/assets/Dateipruefer_flowchart.svg @@ -0,0 +1,102 @@ +

Ja

VERBOTEN

NUTZE_STANDARD
ohne plugin_pfad/standard

NUTZE_STANDARD
mit plugin_pfad+standard

TEMPORAER_ERLAUBT

Nein

Ja

Nein

Ja=ABBRECHEN

Ja=ERSETZEN/ANHAENGEN

Nein

Start: Eingabe prüfen

Pfad leer?

leer_modus

Return: erfolgreich=False
Fehler: 'Kein Pfad angegeben'

Return: erfolgreich=False
Fehler: 'Standardpfad/-name fehlen'

Setze Pfad=plugin_pfad+standard

Return: erfolgreich=True
temporär=True

Datei existiert?

vorhandene_datei_entscheidung gesetzt?

Return: erfolgreich=True
entscheidung=None
Fehler: 'Datei existiert bereits – Entscheidung ausstehend'

Return: erfolgreich=False
Fehler: 'Benutzer hat abgebrochen'

Return: erfolgreich=True
entscheidung=...

Return: erfolgreich=True
pfad=...

\ No newline at end of file diff --git a/assets/Linkpruefer_flowchart.svg b/assets/Linkpruefer_flowchart.svg new file mode 100644 index 0000000..7d791de --- /dev/null +++ b/assets/Linkpruefer_flowchart.svg @@ -0,0 +1,3 @@ + + +

Nein

Ja

Ja

Nein

Ja

Ja

Nein

Nein

Ja

Nein

Start Linkprüfung

Ist Link vorhanden?

Fehler: Link fehlt

Prüfergebnis: Fehler zurückgeben

Ist Link Remote http/https?

HEAD-Anfrage mit QgsNetworkAccessManager

Antwort erhalten?

Fehler: Verbindungsfehler

Prüfergebnis: Fehler zurückgeben

HTTP-Statuscode < 200 oder ≥ 400?

Fehler: Link nicht erreichbar

Anbieter klassifizieren

Prüfergebnis zurückgeben

Plausibilitätscheck für lokalen Link

Link sieht ungewöhnlich aus?

Warnung ausgeben

\ No newline at end of file diff --git a/assets/Objektstruktur.txt b/assets/Objektstruktur.txt new file mode 100644 index 0000000..0cf5e72 --- /dev/null +++ b/assets/Objektstruktur.txt @@ -0,0 +1,188 @@ +# Wrapper‑Architektur – Übersicht +Die Wrapper‑Architektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGIS‑Plugin‑Entwicklung. +Sie kapselt sämtliche QGIS‑ und Qt‑Abhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch: + +Mock‑fähige Unit‑Tests ohne QGIS + +PyQt5/6‑Kompatibilität ohne Code‑Änderungen + +saubere Trennung von UI, Logik und Infrastruktur + +stabile APIs, die unabhängig von QGIS‑Versionen bleiben + +klare Erweiterbarkeit für zukünftige Module und Plugins + +Die Wrapper‑Schicht ist das zentrale Bindeglied zwischen der Plugin‑Logik und der QGIS‑/Qt‑Umgebung. + +## Ziele der Wrapper‑Architektur +🎯 1. Entkopplung von QGIS und Qt +Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der Plugin‑Logik. +Stattdessen werden sie über Wrapper‑Module abstrahiert. + +🎯 2. Testbarkeit ohne QGIS +Im Mock‑Modus liefern die Wrapper: + +Dummy‑Objekte + +simulierte Rückgabewerte + +speicherbare Zustände (z. B. Variablen, Layer, Nachrichten) + +Damit laufen Tests in jeder CI‑Umgebung. + +🎯 3. Einheitliche API für alle Plugins +Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen: + +Code +sn_basis.functions.qgiscore_wrapper +sn_basis.functions.qgisui_wrapper +sn_basis.functions.qt_wrapper +sn_basis.functions.variable_wrapper +sn_basis.functions.message_wrapper +sn_basis.functions.dialog_wrapper +🎯 4. Zukunftssicherheit +Ändert sich die QGIS‑ oder Qt‑API, wird nur der Wrapper angepasst, nicht jedes Plugin. + +## Architekturüberblick +Die Wrapper‑Schicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben. + +### 1. qt_wrapper – Qt‑Abstraktion +Kapselt alle Qt‑Widgets, Dialoge und Konstanten: + +QWidget, QDialog, QMessageBox, QToolBar, QMenu, … + +Layouts, Buttons, Labels, LineEdits + +Qt‑Konstanten wie YES, NO, Dock‑Areas + +Mock‑Modus: +Stellt Dummy‑Widgets bereit, die keine UI öffnen. + +### 2. qgiscore_wrapper – QGIS‑Core‑Abstraktion +Abstraktion für: + +QgsProject + +Layer‑Zugriff + +Projekt‑Metadaten + +Pfade, CRS, Feature‑Zugriff + +Mock‑Modus: +Simuliert ein Projekt und Layer‑Container. + +### 3. qgisui_wrapper – QGIS‑UI‑Abstraktion +Kapselt UI‑bezogene QGIS‑Funktionen: + +Zugriff auf iface + +Dock‑Management + +Menü‑ und Toolbar‑Integration + +Hauptfenster‑Zugriff + +Mock‑Modus: +Stellt ein Dummy‑Interface bereit. + +### 4. variable_wrapper – QGIS‑Variablen +Abstraktion für: + +Projektvariablen (projectScope) + +globale Variablen (globalScope) + +Mock‑Speicher für Tests + +Vorteile: + +keine QGIS‑Abhängigkeit in der Logik + +testbare Variablenverwaltung + +einheitliches API + +### 5. message_wrapper – Meldungen & Logging +Einheitliche Schnittstelle für: + +Fehlermeldungen + +Warnungen + +Info‑Meldungen + +Logging + +Mock‑Modus: +Speichert Nachrichten statt sie an QGIS zu senden. + +### 6. dialog_wrapper – Benutzer‑Dialoge +Abstraktion für: + +Ja/Nein‑Dialoge + +spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.) + +Mock‑Modus: +Gibt Default‑Werte zurück, öffnet keine UI. + +### 7. DockManager & Navigation +Diese Module nutzen die Wrapper‑Schicht, um: + +DockWidgets sicher zu verwalten + +Toolbars und Menüs zu erzeugen + +Reload‑sichere UI‑Strukturen aufzubauen + +Sie sind keine Wrapper, sondern Wrapper‑Konsumenten. + +## Designprinzipien +🧱 1. Single Source of Truth +Jede QGIS‑ oder Qt‑Funktionalität wird nur an einer Stelle implementiert. + +🔄 2. Austauschbarkeit +Mock‑Modus und Echtmodus sind vollständig austauschbar. + +🧪 3. Testbarkeit +Jede Funktion kann ohne QGIS getestet werden. + +🧼 4. Saubere Trennung +UI → qt_wrapper + +QGIS‑Core → qgiscore_wrapper + +QGIS‑UI → qgisui_wrapper + +Logik → settings_logic, layer_logic, prüfmanager, … + +🔌 5. Erweiterbarkeit +Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen. + +## Vorteile für Entwickler +Keine QGIS‑Abhängigkeiten in der Logik + +IDE‑freundlich (Pylance, Autocomplete, Typing) + +CI‑fähig (Tests ohne QGIS) + +saubere Architektur + +leichte Wartbarkeit + +klare Dokumentation + +## Fazit +Die Wrapper‑Architektur ist das Herzstück von sn_basis. +Sie ermöglicht eine moderne, modulare und testbare QGIS‑Plugin‑Entwicklung, die unabhängig von QGIS‑Versionen, Qt‑Versionen und Entwicklungsumgebungen funktioniert. + +Sie bildet die Grundlage für: + +stabile APIs + +saubere UI‑Abstraktion + +automatisierte Tests + +nachhaltige Weiterentwicklung \ No newline at end of file diff --git a/assets/Pluginkonzept.md b/assets/Pluginkonzept.md new file mode 100644 index 0000000..b00f2d4 --- /dev/null +++ b/assets/Pluginkonzept.md @@ -0,0 +1,144 @@ +# Wrapper‑Architektur – Übersicht +Die Wrapper‑Architektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGIS‑Plugin‑Entwicklung. +Sie kapselt sämtliche QGIS‑ und Qt‑Abhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch: + +- Mock‑fähige Unit‑Tests ohne QGIS +- PyQt5/6‑Kompatibilität ohne Code‑Änderungen +- saubere Trennung von UI, Logik und Infrastruktur +- stabile APIs, die unabhängig von QGIS‑Versionen bleiben +- klare Erweiterbarkeit für zukünftige Module und Plugins + +Die Wrapper‑Schicht ist das zentrale Bindeglied zwischen der Plugin‑Logik und der QGIS‑/Qt‑Umgebung. + +## Ziele der Wrapper‑Architektur +1. Entkopplung von QGIS und Qt +Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der Plugin‑Logik. +Stattdessen werden sie über Wrapper‑Module abstrahiert. + +2. Testbarkeit ohne QGIS +Im Mock‑Modus liefern die Wrapper: + +- Dummy‑Objekte +- simulierte Rückgabewerte +- speicherbare Zustände (z. B. Variablen, Layer, Nachrichten) + +Damit laufen Tests in jeder CI‑Umgebung. + +3. Einheitliche API für alle Plugins +Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen: + + +- sn_basis.functions.qgiscore_wrapper +- sn_basis.functions.qgisui_wrapper +- sn_basis.functions.qt_wrapper +- sn_basis.functions.variable_wrapper +- sn_basis.functions.message_wrapper +- sn_basis.functions.dialog_wrapper +Aufgrund des Umfangs ist der Wrapper für die Layerbehandlung aufgeteilt: +- ly_existence_wrapper +- ly_geometry_wrapper +- ly_Metadata_wrapper +- ly_style_wrapper +- ly_visibility_wrapper + +4. Zukunftssicherheit +Ändert sich die QGIS‑ oder Qt‑API, wird nur der Wrapper angepasst, nicht jedes Plugin. + +## Architekturüberblick +Die Wrapper‑Schicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben. + +### 1. qt_wrapper – Qt‑Abstraktion +Kapselt alle Qt‑Widgets, Dialoge und Konstanten: + +- QWidget, QDialog, QMessageBox, QToolBar, QMenu, … +- Layouts, Buttons, Labels, LineEdits +- Qt‑Konstanten wie YES, NO, Dock‑Areas + +Mock‑Modus: +Stellt Dummy‑Widgets bereit, die keine UI öffnen. + +### 2. qgiscore_wrapper – QGIS‑Core‑Abstraktion +Abstraktion für: + +- QgsProject +- Layer‑Zugriff +- Projekt‑Metadaten +- Pfade, CRS, Feature‑Zugriff + +Mock‑Modus: +Simuliert ein Projekt und Layer‑Container. + +### 3. qgisui_wrapper – QGIS‑UI‑Abstraktion +Kapselt UI‑bezogene QGIS‑Funktionen: + +- Zugriff auf iface +- Dock‑Management +- Menü‑ und Toolbar‑Integration +- Hauptfenster‑Zugriff + +Mock‑Modus: +Stellt ein Dummy‑Interface bereit. + +### 4. variable_wrapper – QGIS‑Variablen +Abstraktion für: + +- Projektvariablen (projectScope) +- globale Variablen (globalScope) +- Mock‑Speicher für Tests + +Vorteile: + +- keine QGIS‑Abhängigkeit in der Logik +- testbare Variablenverwaltung +- einheitliches API + +### 5. message_wrapper – Meldungen & Logging +Einheitliche Schnittstelle für: + +- Fehlermeldungen +- Warnungen +- Info‑Meldungen +- Logging + +Mock‑Modus: +Speichert Nachrichten statt sie an QGIS zu senden. + +### 6. dialog_wrapper – Benutzer‑Dialoge +Abstraktion für: + +- Ja/Nein‑Dialoge +- spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.) + +Mock‑Modus: +Gibt Default‑Werte zurück, öffnet keine UI. + +### 7. DockManager & Navigation +Diese Module nutzen die Wrapper‑Schicht, um: + +- DockWidgets sicher zu verwalten +- Toolbars und Menüs zu erzeugen +- Reload‑sichere UI‑Strukturen aufzubauen + +Sie sind keine Wrapper, sondern Wrapper‑Konsumenten. Alle Fach-Plugins nutzen den Dockmanager des Basisplugins. + +## Designprinzipien +1. Single Source of Truth +Jede QGIS‑ oder Qt‑Funktionalität wird nur an einer Stelle implementiert. + +2. Austauschbarkeit +Mock‑Modus und Echtmodus sind vollständig austauschbar. + +3. Testbarkeit +Jede Funktion kann ohne QGIS getestet werden. + +4. Saubere Trennung +- UI → qt_wrapper +- QGIS‑Core → qgiscore_wrapper +- QGIS‑UI → qgisui_wrapper +- Logik → settings_logic, layer_logic, prüfmanager, … + +5. Erweiterbarkeit +Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen. + + + diff --git a/assets/Stilpruefer_flowchart.svg b/assets/Stilpruefer_flowchart.svg new file mode 100644 index 0000000..dafc0d0 --- /dev/null +++ b/assets/Stilpruefer_flowchart.svg @@ -0,0 +1 @@ +
Nein
Ja
Nein
Ja
Nein
Ja
Input Stilpfad
Stilpfad vorhanden?
Ergebnis: Erfolg, Stil=None, Warnung 'Kein Stil angegeben'
Datei existiert?
Ergebnis: Fehler 'Stildatei nicht gefunden'
Endet mit .qml?
Ergebnis: Fehler 'Ungültige Dateiendung'
Ergebnis: Erfolg, Stil=pfad
\ No newline at end of file diff --git a/assets/UML_Struktur.png b/assets/UML_Struktur.png new file mode 100644 index 0000000..70fea30 Binary files /dev/null and b/assets/UML_Struktur.png differ diff --git a/assets/datagrabber.jpeg b/assets/datagrabber.jpeg new file mode 100644 index 0000000..e38d84b Binary files /dev/null and b/assets/datagrabber.jpeg differ diff --git a/assets/datagrabber.md b/assets/datagrabber.md new file mode 100644 index 0000000..a3161f6 --- /dev/null +++ b/assets/datagrabber.md @@ -0,0 +1,38 @@ +```mermaid +flowchart TD + subgraph Plugin + P[sn_plan41 Fachplugin] + A[Adapter Plan41LinklistAdapter] + PM[Pruefmanager] + LP[Layerpruefer] + KP[Linkpruefer] + SP[Stilpruefer] + end + + subgraph Core + DG[DataGrabber] + NL[normalized entries] + LL[Layer Loader Provider Dispatch] + SM[Spatial Matcher] + ST[Storage GPKG / PostGIS] + PR[Project QGIS - addMapLayer] + LOG[Log / Ergebnisstruktur] + end + + P -->|gibt Adapter, Prüfer, Pruefmanager| DG + A -->|load liefert Rohdaten| DG + DG -->|adapter.normalize| NL + NL --> DG + DG -->|für jeden Eintrag: _check_link -> KP.check| KP + DG -->|für jeden Eintrag: _check_style -> SP.check| SP + DG -->|prüfe vorhandene Layer| LP + DG -->|lade Layer via provider| LL + LL -->|Features| SM + SM -->|Abgleich| DG + DG -->|speichern| ST + ST --> PR + DG --> PR + DG -->|Ergebnis/Fehler| LOG + LOG --> PM + DG --> PM +``` \ No newline at end of file diff --git a/assets/datagrabber.pdf b/assets/datagrabber.pdf new file mode 100644 index 0000000..f79edb2 Binary files /dev/null and b/assets/datagrabber.pdf differ diff --git a/assets/moduluebersicht.md b/assets/moduluebersicht.md new file mode 100644 index 0000000..4375bab --- /dev/null +++ b/assets/moduluebersicht.md @@ -0,0 +1,9 @@ +```mermaid +graph TD + M1["
sn_basis

➡ Initialisierung der GUI
➡ Exception Handling
➡ Bereitstellung der Stile"] + M2["
sn_verfahrensgebiet

➡ Abruf und Aufbereitung der Gebietsgrenze"
➡ Erstellung neuer Gebietsgrenzen
➡ Grenzpunktextraktion
➡ Grenzpunktprüfung] + M3["
sn_Plan41

➡ 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..8b06c1d 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, ask_overwrite_append_cancel_custom + +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..71bfc48 --- /dev/null +++ b/functions/dialog_wrapper.py @@ -0,0 +1,84 @@ +""" +sn_basis/functions/dialog_wrapper.py – Benutzer-Dialoge (Qt5/6/Mock-kompatibel) +""" +from typing import Any +from typing import Literal, Optional +from sn_basis.functions.qt_wrapper import ( + QMessageBox, YES, NO, CANCEL, QT_VERSION, exec_dialog, ICON_QUESTION, + +) + +def ask_yes_no( + title: str, + message: str, + default: bool = True, + parent: Any = None, +) -> bool: + """ + Stellt Ja/Nein-Frage. Funktioniert in PyQt5/6 UND Mock-Modus. + """ + try: + if QT_VERSION == 0: # Mock-Modus + print(f"🔍 Mock-Modus: ask_yes_no('{title}') → {default}") + return default + + # ✅ KORREKT: Verwende YES/NO-Aliase aus qt_wrapper! + buttons = YES | NO + default_button = YES if default else NO + + result = QMessageBox.question( + parent, title, message, buttons, default_button + ) + + # ✅ int(result) == int(YES) funktioniert Qt5/6/Mock + print(f"DEBUG ask_yes_no: result={result}, YES={YES}, match={int(result) == int(YES)}") + return int(result) == int(YES) + + except Exception as e: + print(f"⚠️ ask_yes_no Fehler: {e}") + return default + + +OverwriteDecision = Optional[Literal["overwrite", "append", "cancel"]] + + +def ask_overwrite_append_cancel_custom( + parent, + title: str, + message: str, +) -> Literal["overwrite", "append", "cancel"]: + """Zeigt Dialog mit benutzerdefinierten Buttons: Überschreiben/Anhängen/Abbrechen. + + Parameters + ---------- + parent : + Eltern-Widget oder None. + title : str + Dialog-Titel. + message : str + Hauptmeldung mit Erklärung. + + Returns + ------- + Literal["overwrite", "append", "cancel"] + Genaue Entscheidung des Nutzers. + """ + msg = QMessageBox(parent) + msg.setIcon(ICON_QUESTION) + msg.setWindowTitle(title) + msg.setText(message) + + # Eigene Buttons mit exakten Texten + overwrite_btn = msg.addButton("Überschreiben", QMessageBox.ButtonRole.AcceptRole) + append_btn = msg.addButton("Anhängen", QMessageBox.ButtonRole.ActionRole) + cancel_btn = msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole) + + exec_dialog(msg) + + clicked = msg.clickedButton() + if clicked == overwrite_btn: + return "overwrite" + elif clicked == append_btn: + return "append" + else: # cancel_btn + return "cancel" 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..ad0221a --- /dev/null +++ b/functions/ly_style_wrapper.py @@ -0,0 +1,48 @@ +# 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 +from sn_basis.modules.stilpruefer import Stilpruefer +from typing import Optional + +def apply_style(layer, style_name: str) -> bool: + """ + Wendet einen Layerstil an, sofern er gültig ist. + + - Validierung erfolgt ausschließlich über Stilpruefer + - Keine eigenen Dateisystem- oder Endungsprüfungen + - Keine Seiteneffekte bei ungültigem Stil + """ + print(">>> apply_style() START") + + if not layer_exists(layer): + return False + + # Stilpfad zusammensetzen + style_path = join_path(get_plugin_root(), "sn_verfahrensgebiet","styles", style_name) + + # Stil prüfen + pruefer = Stilpruefer() + ergebnis = pruefer.pruefe(style_path) + print(">>> Stilprüfung:", ergebnis) + + print( + f"[Stilprüfung] ok={ergebnis.ok} | " + f"aktion={ergebnis.aktion} | " + f"meldung={ergebnis.meldung}" +) + + + if not ergebnis.ok: + return False + + # Stil anwenden + try: + ok, _ = layer.loadNamedStyle(str(ergebnis.kontext)) + 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..2473fbf --- /dev/null +++ b/functions/qgiscore_wrapper.py @@ -0,0 +1,384 @@ +""" +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, + QgsFeature as _QgsFeature, + QgsField as _QgsField, + QgsGeometry as _QgsGeometry, + ) + + QgsProject = _QgsProject + QgsVectorLayer = _QgsVectorLayer + QgsRasterLayer = _QgsRasterLayer + QgsNetworkAccessManager = _QgsNetworkAccessManager + Qgis = _Qgis + QgsMapLayerProxyModel = _QgsMaplLayerProxyModel + QgsVectorFileWriter = _QgsVectorFileWriter + QgsFeature = _QgsFeature + QgsField = _QgsField + QgsGeometry = _QgsGeometry + + 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..d0c325f --- /dev/null +++ b/functions/qt_wrapper.py @@ -0,0 +1,514 @@ +""" +sn_basis/functions/qt_wrapper.py – zentrale Qt-Abstraktion (PyQt6 primär / PyQt5 Fallback / Mock) +""" + +from typing import Optional, Type, Any, Callable + +# Globale Qt-Symbole (werden dynamisch gesetzt) +QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 +YES: Optional[Any] = None +NO: Optional[Any] = None +CANCEL: Optional[Any] = None +ICON_QUESTION: Optional[Any] = None + + +# Qt-Klassen (werden dynamisch gesetzt) +QDockWidget: Type[Any] = object +QMessageBox: Type[Any] = object +QFileDialog: Type[Any] = object +QEventLoop: Type[Any] = object +QUrl: Type[Any] = object +QNetworkRequest: Type[Any] = object +QNetworkReply: Type[Any] = object +QCoreApplication: Type[Any] = object +QWidget: Type[Any] = object +QGridLayout: Type[Any] = object +QLabel: Type[Any] = object +QLineEdit: Type[Any] = object +QGroupBox: Type[Any] = object +QVBoxLayout: Type[Any] = object +QPushButton: Type[Any] = object +QAction: Type[Any] = object +QMenu: Type[Any] = object +QToolBar: Type[Any] = object +QActionGroup: Type[Any] = object +QTabWidget: Type[Any] = object +QToolButton: Type[Any] = object +QSizePolicy: Type[Any] = object +Qt: Type[Any] = object +QComboBox: Type[Any] = object +QHBoxLayout: Type[Any] = object + + +def exec_dialog(dialog: Any) -> Any: + """Führt Dialog modal aus (Qt6: exec(), Qt5: exec_(), Mock: YES)""" + raise NotImplementedError("Qt nicht initialisiert") + +def debug_qt_status() -> None: + """Debug: Zeigt Qt-Status für Troubleshooting.""" + print(f"🔍 QT_VERSION: {QT_VERSION}") + print(f"🔍 QMessageBox Typ: {getattr(QMessageBox, '__name__', type(QMessageBox).__name__)}") + print(f"🔍 YES Wert: {YES} (Typ: {type(YES) if YES is not None else 'None'})") + + if QT_VERSION == 0: + print("❌ MOCK-MODUS AKTIV! Keine Dialoge möglich!") + elif QT_VERSION == 5: + print("✅ PyQt5 geladen (Fallback) – Dialoge sollten funktionieren!") + elif QT_VERSION == 6: + print("✅ PyQt6 geladen (primär) – Dialoge sollten funktionieren!") + else: + print("❓ Unbekannte Qt-Version!") + +# --------------------------- PYQT6 PRIMÄR --------------------------- +try: + from qgis.PyQt.QtWidgets import ( + 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, + QHBoxLayout as _QHBoxLayout, + ) + from qgis.PyQt.QtCore import ( + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + Qt as _Qt, + QVariant as _QVariant + ) + from qgis.PyQt.QtNetwork import ( + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + # ✅ ALLE GLOBALS ZUWEISEN + 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 + QVariant = _QVariant + QHBoxLayout= _QHBoxLayout + # ✅ QT6 ENUMS + YES = QMessageBox.StandardButton.Yes + NO = QMessageBox.StandardButton.No + CANCEL = QMessageBox.StandardButton.Cancel + ICON_QUESTION = QMessageBox.Icon.Question + AcceptRole = QMessageBox.ButtonRole.AcceptRole + ActionRole = QMessageBox.ButtonRole.ActionRole + RejectRole = QMessageBox.ButtonRole.RejectRole + + # Qt6 Enum-Aliase + ToolButtonTextBesideIcon = Qt.ToolButtonStyle.ToolButtonTextBesideIcon + ArrowDown = Qt.ArrowType.DownArrow + ArrowRight = Qt.ArrowType.RightArrow + SizePolicyPreferred = QSizePolicy.Policy.Preferred + SizePolicyMaximum = QSizePolicy.Policy.Maximum + DockWidgetMovable = QDockWidget.DockWidgetFeature.DockWidgetMovable + DockWidgetFloatable = QDockWidget.DockWidgetFeature.DockWidgetFloatable + DockWidgetClosable = QDockWidget.DockWidgetFeature.DockWidgetClosable + DockAreaLeft = Qt.DockWidgetArea.LeftDockWidgetArea + DockAreaRight = Qt.DockWidgetArea.RightDockWidgetArea + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec() + + print(f"✅ qt_wrapper: PyQt6 geladen (QT_VERSION={QT_VERSION})") + +# --------------------------- PYQT5 FALLBACK --------------------------- +except (ImportError, AttributeError): + try: + from PyQt5.QtWidgets import ( + 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, + QHBoxLayout as _QHBoxLayout, + ) + from PyQt5.QtCore import ( + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + Qt as _Qt, + QVariant as _QVariant + ) + from PyQt5.QtNetwork import ( + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + # ✅ ALLE GLOBALS ZUWEISEN + QT_VERSION = 5 + 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 + QVariant = _QVariant + QHBoxLayout = _QHBoxLayout + + # ✅ PYQT5 ENUMS + YES = QMessageBox.Yes + NO = QMessageBox.No + CANCEL = QMessageBox.Cancel + ICON_QUESTION = QMessageBox.Question + AcceptRole = QMessageBox.AcceptRole + ActionRole = QMessageBox.ActionRole + RejectRole = QMessageBox.RejectRole + + + # PyQt5 Enum-Aliase + ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon + ArrowDown = Qt.DownArrow + ArrowRight = Qt.RightArrow + SizePolicyPreferred = QSizePolicy.Preferred + SizePolicyMaximum = QSizePolicy.Maximum + DockWidgetMovable = QDockWidget.DockWidgetMovable + DockWidgetFloatable = QDockWidget.DockWidgetFloatable + DockWidgetClosable = QDockWidget.DockWidgetClosable + DockAreaLeft = Qt.LeftDockWidgetArea + DockAreaRight = Qt.RightDockWidgetArea + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec_() + + print(f"✅ qt_wrapper: PyQt5 Fallback geladen (QT_VERSION={QT_VERSION})") + +# --------------------------- MOCK-MODUS --------------------------- + except Exception: + QT_VERSION = 0 + print("⚠️ qt_wrapper: Mock-Modus aktiviert (QT_VERSION=0)") + + # Fake Enum für Bit-Operationen + class FakeEnum(int): + def __or__(self, other: Any) -> "FakeEnum": + return FakeEnum(int(self) | int(other)) + + YES = FakeEnum(1) + NO = FakeEnum(2) + CANCEL = FakeEnum(4) + ICON_QUESTION = FakeEnum(8) + + # Im Mock-Block von qt_wrapper.py: + class _MockQMessageBox: + Yes = YES + No = NO + Cancel = CANCEL + Question = ICON_QUESTION + AcceptRole = 0 + ActionRole = 3 + RejectRole = 1 + + + @classmethod + def question(cls, parent, title, message, buttons, default_button): + """Mock: Gibt immer default_button zurück""" + print(f"🔍 Mock QMessageBox.question: '{title}' → {default_button}") + return default_button + + 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: 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 + QCoreApplication = object() + + class _MockQt: + ToolButtonTextBesideIcon = 0 + ArrowDown = 1 + ArrowRight = 2 + LeftDockWidgetArea = 1 + RightDockWidgetArea = 2 + + Qt = _MockQt() + ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon + ArrowDown = Qt.ArrowDown + ArrowRight = Qt.ArrowRight + DockAreaLeft = Qt.LeftDockWidgetArea + DockAreaRight = Qt.RightDockWidgetArea + + class _MockQDockWidget(_MockWidget): + def __init__(self, *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): + 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: + Preferred = 3 + Maximum = 2 + + QSizePolicy = _MockQSizePolicy + SizePolicyPreferred = QSizePolicy.Preferred + SizePolicyMaximum = QSizePolicy.Maximum + DockWidgetMovable = 1 + DockWidgetFloatable = 2 + DockWidgetClosable = 4 + + class _MockTabWidget: + def __init__(self, *args, **kwargs): self._tabs = [] + def addTab(self, widget, title: str): self._tabs.append((widget, title)) + + QTabWidget = _MockTabWidget + + class _MockComboBox: + def __init__(self, parent=None): + self._items = [] + self._index = -1 + self.currentTextChanged = type('Signal', (), {'connect': lambda s, cb: None, 'emit': lambda s, v: None})() + def addItem(self, text: str) -> None: self._items.append(text) + def addItems(self, items): [self.addItem(it) for it in items] + def findText(self, text: str) -> int: + return self._items.index(text) if text in self._items else -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: + return self._items[self._index] if 0 <= self._index < len(self._items) else "" + + QComboBox = _MockComboBox + + + # --------------------------- + # Mock für QVariant + # --------------------------- + + class _MockQVariant: + """ + Minimaler Ersatz für QtCore.QVariant. + + Ziel: + - Werte transparent durchreichen + - Typ-Konstanten bereitstellen + - Keine Qt-Abhängigkeiten + """ + + # Typ-Konstanten (symbolisch, Werte egal) + Invalid = 0 + Int = 1 + Double = 2 + String = 3 + Bool = 4 + Date = 5 + DateTime = 6 + + def __init__(self, value: Any = None): + self._value = value + + def value(self) -> Any: + return self._value + + def __repr__(self) -> str: + return f"QVariant({self._value!r})" + + # Optional: automatische Entpackung + def __int__(self): + return int(self._value) + + def __float__(self): + return float(self._value) + + def __str__(self): + return str(self._value) + QVariant = _MockQVariant + + class _MockQHBoxLayout: + 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 + QHBoxLayout = _MockQHBoxLayout + def exec_dialog(dialog: Any) -> Any: + return YES +# --------------------------- TEST --------------------------- +if __name__ == "__main__": + debug_qt_status() 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..d78ce61 --- /dev/null +++ b/modules/DataGrabber.py @@ -0,0 +1,172 @@ +""" +DataGrabber module +================== + +UI‑freier Orchestrator für die Prüfung und Klassifikation von Datenquellen. + +Der DataGrabber: +- klassifiziert die übergebene Quelle (Datei, Dienst, Datenbank, Excel), +- ruft passende Prüfer (Dateipruefer, Linkpruefer, Layerpruefer, Stilpruefer) auf, +- sammelt alle rohen ``pruef_ergebnis``‑Objekte, +- aggregiert diese zu einem zusammenfassenden Ergebnis, +- **löst selbst keinerlei UI‑Interaktion aus**. + +Alle Nutzerinteraktionen (MessageBar, QMessageBox, Logging) erfolgen +ausschließlich über den ``Pruefmanager`` im aufrufenden Kontext (UI / Pipeline). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Tuple, Literal + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.modules.Pruefmanager import Pruefmanager + +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["service", "database", "excel", "unknown"] + +SourceDict = Dict[str, List[Mapping[str, Any]]] + + +class DataGrabber: + """ + Analysiert und prüft Datenquellen für den Fachdatenabruf. + + Der DataGrabber ist **UI‑frei**. Er erzeugt ausschließlich rohe + ``pruef_ergebnis``‑Objekte und überlässt deren Verarbeitung + vollständig dem aufrufenden Code. + """ + + def __init__( + self, + pruefmanager: Pruefmanager, + *, + datei_pruefer_cls: type[Dateipruefer] = Dateipruefer, + link_pruefer: Optional[Linkpruefer] = None, + layer_pruefer: Optional[Layerpruefer] = None, + stil_pruefer: Optional[Stilpruefer] = None, + excel_importer_cls: type[ExcelImporter] = ExcelImporter, + ) -> None: + self.pruefmanager = pruefmanager + self._datei_pruefer_cls = datei_pruefer_cls + self.link_pruefer = link_pruefer + self.layer_pruefer = layer_pruefer + self.stil_pruefer = stil_pruefer + self._excel_importer_cls = excel_importer_cls + + self._source: Optional[str] = None + + # ------------------------------------------------------------------ + # Öffentliche API + # ------------------------------------------------------------------ + def set_source(self, source: str) -> None: + """Setzt die aktuell zu untersuchende Rohquelle.""" + self._source = source + + def analyze_source_type(self, source: str) -> SourceType: + """ + Klassifiziert die Quelle. + + Aktuell Platzhalter – liefert ``"unknown"``. + """ + return "unknown" + + def run(self, source: str) -> Tuple[SourceDict, pruef_ergebnis]: + """ + Führt die vollständige Quellprüfung aus. + + Diese Methode ist **UI‑frei**. Sie gibt rohe Ergebnisse zurück, + die vom Aufrufer über den ``Pruefmanager`` verarbeitet werden. + """ + self.set_source(source) + source_type = self.analyze_source_type(source) + + source_dict: SourceDict = {} + partial_results: List[pruef_ergebnis] = [] + + if source_type == "excel": + source_dict, partial_results = self._process_excel_source(source) + elif source_type == "database": + source_dict, partial_results = self._process_database_source(source) + elif source_type == "service": + source_dict, partial_results = self._process_service_source(source) + else: + partial_results.append( + pruef_ergebnis( + ok=False, + meldung="Quelle konnte nicht klassifiziert werden", + aktion="kein_dateipfad", + kontext={"source": source}, + ) + ) + + summary = self._aggregate_results(source, source_dict, partial_results) + return source_dict, summary + + # ------------------------------------------------------------------ + # Excel‑Quellen + # ------------------------------------------------------------------ + def _process_excel_source( + self, filepath: str + ) -> Tuple[SourceDict, List[pruef_ergebnis]]: + source_dict: SourceDict = {} + results: List[pruef_ergebnis] = [] + return source_dict, results + + # ------------------------------------------------------------------ + # Datenbank‑Quellen + # ------------------------------------------------------------------ + def _process_database_source( + self, db_path: str + ) -> Tuple[SourceDict, List[pruef_ergebnis]]: + source_dict: SourceDict = {} + results: List[pruef_ergebnis] = [] + return source_dict, results + + # ------------------------------------------------------------------ + # Dienst‑Quellen + # ------------------------------------------------------------------ + def _process_service_source( + self, link: str + ) -> Tuple[SourceDict, List[pruef_ergebnis]]: + source_dict: SourceDict = {} + results: List[pruef_ergebnis] = [] + return source_dict, results + + # ------------------------------------------------------------------ + # Aggregation + # ------------------------------------------------------------------ + def _aggregate_results( + self, + source: str, + source_dict: SourceDict, + partial_results: List[pruef_ergebnis], + ) -> pruef_ergebnis: + """ + Aggregiert Einzelprüfungen zu einem Gesamt‑``pruef_ergebnis``. + + **Keine UI‑Interaktion.** + """ + if source_dict: + return pruef_ergebnis( + ok=True, + meldung="Quelle erfolgreich geprüft", + aktion="ok", + kontext={ + "source": source, + "valid_entries": sum(len(v) for v in source_dict.values()), + }, + ) + + return pruef_ergebnis( + ok=False, + meldung="Keine gültigen Einträge in der Quelle gefunden", + aktion="read_error", + kontext={"source": source}, + ) diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py new file mode 100644 index 0000000..1ad1a44 --- /dev/null +++ b/modules/Dateipruefer.py @@ -0,0 +1,208 @@ +""" +sn_basis/modules/Dateipruefer.py + +Erweiterter Dateiprüfer für Verfahrens-DB-Workflows mit vollständiger Unterstützung +der Anforderungen 1-2.e (leerer Pfad, fehlende Datei, bestehende Datei). +""" + +from pathlib import Path +from typing import Optional + +from sn_basis.functions.sys_wrapper import join_path, file_exists +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion + + +class Dateipruefer: + """ + Prüft Dateieingaben für Verfahrens-DB-Workflows und liefert :class:`pruef_ergebnis`. + + **Funktionsweise (deine Anforderungen 1-2.e):** + + +---------------------+------------------------------------------+---------------+ + | **Fall** | **Ergebnis** | **ok** | + +=====================+==========================================+===============+ + | 1. Leerer Pfad | ``temporaer_erlaubt`` | False | + +---------------------+------------------------------------------+---------------+ + | 2.a Leerer Pfad | Pruefmanager fragt → ``temporaer_erzeugen`` | True | + +---------------------+------------------------------------------+---------------+ + | 2.b Datei existiert | ``ok`` | True | + +---------------------+------------------------------------------+---------------+ + | 2.c Ungültiger Pfad | ``datei_nicht_gefunden`` | False | + +---------------------+------------------------------------------+---------------+ + | **2.d Datei fehlt** | **``datei_wird_erzeugt``** | **True** | + +---------------------+------------------------------------------+---------------+ + | **2.e Datei da** | **``datei_existiert``** | **False** | + +---------------------+------------------------------------------+---------------+ + + Der Dateiprüfer führt **keine UI-Interaktion** durch. + Entscheidungen werden ausschließlich vom :class:`Pruefmanager` getroffen. + """ + + def __init__( + self, + pfad: Optional[str], + basis_pfad: str = "", + leereingabe_erlaubt: bool = False, + standarddatei: Optional[str] = None, + temporaer_erlaubt: bool = False, + *, + verfahrens_db_modus: bool = True, # 🆕 Verfahrens-DB-spezifische Logik + ) -> None: + """ + Parameters + ---------- + pfad : Optional[str] + Vom UI gelieferter Dateipfad (kann leer oder Whitespace sein). + basis_pfad : str, optional + Basisverzeichnis für relative Pfade (default: ""). + leereingabe_erlaubt : bool, optional + Ob leere Eingabe grundsätzlich erlaubt ist (default: False). + standarddatei : Optional[str], optional + Optionaler Standardpfad (default: None). + temporaer_erlaubt : bool, optional + Ob bei leerer Eingabe temporäre Layer erlaubt sind (default: False). + verfahrens_db_modus : bool, optional + Aktiviert Verfahrens-DB-spezifische Logik (2.d, 2.e) (default: True). + """ + self.pfad = pfad + self.basis_pfad = basis_pfad + self.leereingabe_erlaubt = leereingabe_erlaubt + self.standarddatei = standarddatei + self.temporaer_erlaubt = temporaer_erlaubt + self.verfahrens_db_modus = verfahrens_db_modus + + # ------------------------------------------------------------------ + # Hilfsfunktionen + # ------------------------------------------------------------------ + def _pfad(self, relativer_pfad: str) -> Path: + """Erzeugt OS-unabhängigen Pfad relativ zum Basisverzeichnis.""" + return join_path(self.basis_pfad, relativer_pfad) + + def _ist_leer(self) -> bool: + """ + Prüft robust, ob Eingabe als „leer" zu behandeln ist. + + Returns + ------- + bool + True bei None, leerem String oder reinem Whitespace. + """ + if self.pfad is None: + return True + if not isinstance(self.pfad, str): + return True + return not self.pfad.strip() + + def _ist_gueltiger_gpkg_pfad(self, pfad: Path) -> bool: + """ + Prüft, ob Pfad für GPKG geeignet ist (Endung + Schreibrechte). + + Returns + ------- + bool + True wenn `.gpkg`-Endung und Verzeichnis beschreibbar. + """ + if not str(pfad).lower().endswith('.gpkg'): + return False + + # Verzeichnis muss beschreibbar sein + return pfad.parent.exists() and pfad.parent.is_dir() + + # ------------------------------------------------------------------ + # Hauptlogik: deine Anforderungen 1-2.e + # ------------------------------------------------------------------ + def pruefe(self) -> pruef_ergebnis: + """ + 🆕 Prüft Dateieingabe gemäß Anforderungen 1-2.e. + + **Workflow:** + 1. **Leere Eingabe** → ``temporaer_erlaubt`` (Pruefmanager fragt) + 2. **Pfad prüfen**: + - **Ungültig** → 2.c ``datei_nicht_gefunden`` + - **Gültig, fehlt** → **2.d** ``datei_wird_erzeugt`` (ok=True!) + - **Gültig, existiert** → **2.e** ``datei_existiert`` (Pruefmanager fragt) + 3. **Datei OK** → 2.b ``ok`` + + Returns + ------- + pruef_ergebnis + Mit korrekter Aktion für jeden Fall. + """ + # 1. 🎯 ANFORDERUNG 1: Leere Eingabe + if self._ist_leer(): + return self._handle_leere_eingabe() + + # 2. Pfad normalisieren + pfad = self._pfad(self.pfad.strip()) + + # 🆕 2.c: Ungültiger GPKG-Pfad? + if not self.verfahrens_db_modus or not self._ist_gueltiger_gpkg_pfad(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Der Pfad '{self.pfad}' ist kein gültiger GPKG-Pfad.", + aktion="datei_nicht_gefunden", + kontext=pfad, + ) + + # 🆕 2.d: Gültiger Pfad, Datei fehlt → DIREKT WEITER (ok=True!) + if not file_exists(pfad): + return pruef_ergebnis( + ok=True, # 🎯 WICHTIG: Pipeline fortsetzen! + meldung=f"Datei '{self.pfad}' wird erzeugt.", + aktion="datei_wird_erzeugt", + kontext=pfad, + ) + + # 🆕 2.e: Datei existiert → Pruefmanager fragt Überschreiben/etc. + return pruef_ergebnis( + ok=False, # 🎯 Pruefmanager soll 4-Optionen-Dialog zeigen + meldung=f"Datei '{self.pfad}' existiert bereits.", + aktion="datei_existiert", + kontext=pfad, + ) + + # 2.b: Wird nicht erreicht (durch 2.e abgefangen) + + # ------------------------------------------------------------------ + # Leere Eingabe (ANFORDERUNG 1, 2.a) + # ------------------------------------------------------------------ + def _handle_leere_eingabe(self) -> pruef_ergebnis: + """ + Behandelt leere Eingaben (Priorität: leereingabe → Standard → temporär → Fehler). + """ + if self.leereingabe_erlaubt: + return pruef_ergebnis( + ok=False, + meldung="Das Dateifeld ist leer. Soll ohne Datei fortgefahren werden?", + aktion="leereingabe_erlaubt", + kontext=None, + ) + + if self.standarddatei: + return pruef_ergebnis( + ok=False, + meldung=( + "Es wurde keine Datei angegeben. " + f"Soll die Standarddatei '{self.standarddatei}' verwendet werden?" + ), + aktion="standarddatei_vorschlagen", + kontext=self._pfad(self.standarddatei), + ) + + 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, + ) + + 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..96bb8b5 --- /dev/null +++ b/modules/Pruefmanager.py @@ -0,0 +1,218 @@ +""" +sn_basis/modules/Pruefmanager.py +""" + +from __future__ import annotations +from typing import Optional, Any + +from sn_basis.functions import ask_yes_no, info, warning, error, ask_overwrite_append_cancel_custom +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion +print("DEBUG: Pruefmanager DATEI GELADEN:", __file__) + +class Pruefmanager: + def __init__(self, ui_modus: str = "qgis", parent: Optional[Any] = None) -> None: + self.ui_modus = ui_modus + self.parent = parent + + # ------------------------------------------------------------------ + # Meldungen / Zusammenfassungen + # ------------------------------------------------------------------ + def report_error( + self, + thema: str, + meldung: str, + *, + aktion: Optional[PruefAktion] = None, + kontext: Optional[Any] = None, + ) -> None: + 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 + 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) + + # ------------------------------------------------------------------ + # VERFAHRENS-DB-spezifische Entscheidungen + # ------------------------------------------------------------------ + def _handle_datei_existiert(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + """Handhabt das Szenario, dass die Ziel-Verfahrens-DB bereits existiert. + + Zeigt einen einzigen Dialog mit drei Optionen an: + - **Überschreiben**: Bestehende Layer ersetzen (entspricht YES) + - **Anhängen**: Neue Layer zur Datei hinzufügen (entspricht NO) + - **Abbrechen**: Vorgang beenden (entspricht CANCEL) + + Parameters + ---------- + ergebnis : pruef_ergebnis + Eingabe-Ergebnis mit Dateipfad im ``kontext``-Attribut. + + Returns + ------- + pruef_ergebnis + Ergebnis mit Aktion: + - ``datei_existiert_ueberschreiben`` + - ``datei_existiert_anhaengen`` + - ``datei_existiert_ueberspringen`` (für Cancel-Fall) + """ + if self.ui_modus != "qgis": + return ergebnis + + pfad = ergebnis.kontext + pfad_str = str(pfad) if pfad else "unbekannt" + + titel = "Verfahrens-DB existiert bereits" + meldung = ( + f"Die Datei '{pfad_str}' existiert bereits.\n\n" + "Was soll geschehen?\n\n" + "• **Überschreiben**: Bestehende Layer ersetzen\n" + "• **Anhängen**: Neue Layer hinzufügen\n" + "• **Abbrechen**: Vorgang beenden" + ) + + # Einzelner Dialog mit drei Optionen + entscheidung = ask_overwrite_append_cancel_custom( + parent=self.parent, + title=titel, + message=meldung + ) + + if entscheidung == "overwrite": + return pruef_ergebnis( + ok=True, + aktion="datei_existiert_ueberschreiben", + kontext=ergebnis.kontext, + ) + elif entscheidung == "append": + return pruef_ergebnis( + ok=True, + aktion="datei_existiert_anhaengen", + kontext=ergebnis.kontext, + ) + else: # cancel + return pruef_ergebnis( + ok=True, + aktion="datei_existiert_ueberspringen", + kontext=ergebnis.kontext, + ) + # ------------------------------------------------------------------ + # Basis-Entscheidungen (KORREKT: → pruef_ergebnis) + # ------------------------------------------------------------------ + def _handle_basic_decision(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + """Basis-Entscheidung für einfache Ja/Nein-Fragen.""" + print(f"DEBUG _handle_basic_decision: aktion='{ergebnis.aktion}', ui_modus='{self.ui_modus}'") + + if self.ui_modus != "qgis": + print("DEBUG: Nicht QGIS → ergebnis unverändert") + return ergebnis + + title_map = { + "leereingabe_erlaubt": "Ohne Eingabe fortfahren", + "standarddatei_vorschlagen": "Standarddatei verwenden", + "temporaer_erlaubt": "Temporäre Layer erzeugen", + "layer_unsichtbar": "Layer einblenden", + } + + title = title_map.get(ergebnis.aktion, "Entscheidung erforderlich") + meldung = ergebnis.meldung or "" + + try: + print(f"DEBUG ask_yes_no: title='{title}', meldung='{meldung[:50]}...'") + yes = ask_yes_no(title, meldung, default=False, parent=self.parent) + print(f"DEBUG ask_yes_no: yes={yes}") + except Exception as e: + print(f"DEBUG ask_yes_no Exception: {e}") + return ergebnis + + if not yes: + print("DEBUG: Nutzer sagte Nein → ok=False") + return ergebnis + + # Nutzer sagte Ja + if ergebnis.aktion == "temporaer_erlaubt": + print("DEBUG: temporaer_erlaubt bestätigt → ok=True") + return pruef_ergebnis( + ok=True, + aktion="temporaer_erlaubt", + kontext=ergebnis.kontext + ) + + print("DEBUG: Andere Aktion bestätigt → ok=True, aktion='ok'") + return pruef_ergebnis( + ok=True, + aktion="ok", + kontext=ergebnis.kontext + ) + + # ------------------------------------------------------------------ + # Hauptlogik: verarbeite() (KORRIGIERT!) + # ------------------------------------------------------------------ + def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + print("🔥 verarbeite() START") + print("DEBUG Pruefmanager:", ergebnis.ok, ergebnis.aktion) + print("DEBUG ergebnis.aktion TYPE:", type(ergebnis.aktion), repr(ergebnis.aktion)) + + # 1. Erfolg → direkt weiter + print("🔍 Schritt 1: Prüfe ergebnis.ok =", ergebnis.ok) + if ergebnis.ok: + print("✅ Schritt 1: ok=True → return") + return ergebnis + + # 2. VERFAHRENS-DB: Bestehende Datei + print("🔍 Schritt 2: Prüfe datei_existiert =", ergebnis.aktion == "datei_existiert") + if ergebnis.aktion == "datei_existiert": + print("✅ Schritt 2: _handle_datei_existiert") + return self._handle_datei_existiert(ergebnis) + + # 3. Basis interaktive Aktionen + print("🔍 Schritt 3: Definiere interactive_actions") + interactive_actions = { + "leereingabe_erlaubt", + "standarddatei_vorschlagen", + "temporaer_erlaubt", + "layer_unsichtbar", + } + print("DEBUG interactive_actions:", repr(interactive_actions)) + print("DEBUG ergebnis.aktion in interactive_actions?", ergebnis.aktion in interactive_actions) + + if ergebnis.aktion in interactive_actions: + print("✅ Schritt 3: Interaktive Aktion → _handle_basic_decision") + decision = self._handle_basic_decision(ergebnis) + print(f"DEBUG: _handle_basic_decision Ergebnis: ok={decision.ok}, aktion='{decision.aktion}'") + return decision + + # 4. Fehler behandeln + print("❌ Schritt 4: FEHLER BEHANDELN") + self.report_error( + thema=ergebnis.aktion or "pruefung", + meldung=ergebnis.meldung or "", + aktion=ergebnis.aktion, + kontext=ergebnis.kontext, + ) + print("🔥 verarbeite() ENDE mit ok=False") + return ergebnis 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..78eb423 --- /dev/null +++ b/modules/pruef_ergebnis.py @@ -0,0 +1,179 @@ +""" +sn_basis/modules/pruef_ergebnis.py + +Erweitertes Ergebnisobjekt für Dateiprüfungen mit Verfahrens-DB-spezifischen Aktionen. +""" + +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Optional, Literal +from pathlib import Path + + +# ============================================================================= +# Erweiterte PruefAktionen für Verfahrens-DB-Workflow +# ============================================================================= + +PruefAktion = Literal[ + # Basis-Aktionen (bestehend) + "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-spezifisch + "layer_nicht_gefunden", + "layer_unsichtbar", + "falscher_geotyp", + "layer_leer", + "falscher_layertyp", + "falsches_crs", + "felder_fehlen", + "datenquelle_unerwartet", + "layer_nicht_editierbar", + + # Dateiendung/Format + "falsche_endung", + "pflichtfelder_fehlen", + + # Excel/Import + "kein_header", + "kein_arbeitsblatt", + "read_error", + "open_error", + "datenabruf", + + # 🆕 VERFAHRENS-DB SPEZIFISCH (deine Anforderungen 2.d, 2.e) + "datei_wird_erzeugt", # 2.d: Pfad gültig, Datei fehlt → weiter + "datei_existiert", # Datei vorhanden → Layer-Entscheidung + "datei_existiert_ueberschreiben", # 2.e: Nutzer wählt "Überschreiben" + "datei_existiert_anhaengen", # 2.e: Nutzer wählt "Anhängen" + "datei_existiert_ueberspringen", # 2.e: Nutzer wählt "Überspringen" + + # Generisch + "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 im Verfahrens-DB-Workflow. + + Attributes + ---------- + ok : bool + True wenn Prüfung bestanden und Pipeline fortgesetzt werden kann. + False signalisiert Fehler oder Nutzerentscheidung erforderlich. + meldung : Optional[str], optional + Menschenlesbare Meldung für UI-Dialoge (BY: Pruefmanager). + aktion : Optional[PruefAktion], optional + Maschinenlesbarer Aktionscode für nachfolgende Pipeline-Schritte. + kontext : Optional[Any], optional + Zusatzkontext: meist `pathlib.Path` für Dateipfade oder Layer-Objekte. + + Verfahrens-DB-spezifische Aktionen: + + +-----------------------------+-------------------------------------------------+ + | Aktion | Bedeutung | + +=============================+=================================================+ + | ``datei_wird_erzeugt`` | 2.d: Neues GPKG wird angelegt (Pfad gültig) | + +-----------------------------+-------------------------------------------------+ + | ``datei_existiert`` | Datei vorhanden → Layer-Überschreibung prüfen | + +-----------------------------+-------------------------------------------------+ + | ``datei_existiert_*`` | 2.e: Nutzerentscheidung für bestehende Datei | + +-----------------------------+-------------------------------------------------+ + """ + + 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, + ) -> None: + """ + Erstellt ein neues Prüfergebnis. + + Parameters + ---------- + ok : bool + True für "weiter mit Pipeline", False für "Entscheidung/Fehler". + meldung : Optional[str] + UI-Text für Nutzerdialoge. + aktion : Optional[PruefAktion] + Maschinenaktion für nachfolgende Verarbeitung. + kontext : Optional[Any] + Typischerweise `pathlib.Path` (Dateipfad) oder `QgsVectorLayer`. + """ + self.ok = ok + self.meldung = meldung + self.aktion = aktion + self.kontext = kontext + + @property + def ist_verfahrens_db_aktion(self) -> bool: + """ + Prüft, ob es sich um eine Verfahrens-DB-spezifische Aktion handelt. + + Returns + ------- + bool + True für ``datei_wird_erzeugt`` oder ``datei_existiert*``. + """ + return self.aktion in { + "datei_wird_erzeugt", + "datei_existiert", + "datei_existiert_ueberschreiben", + "datei_existiert_anhaengen", + "datei_existiert_ueberspringen", + } + + @property + def dateipfad(self) -> Optional[Path]: + """ + Extrahiert den Dateipfad aus dem Kontext (falls vorhanden). + + Returns + ------- + Optional[Path] + `Path`-Objekt oder None. + """ + if isinstance(self.kontext, Path): + return self.kontext + return None + + @property + def erlaubte_persistierung(self) -> bool: + """ + Prüft, ob die Pipeline Daten persistieren darf. + + Returns + ------- + bool + True für ``datei_wird_erzeugt``, ``datei_existiert_ueberschreiben``, + ``datei_existiert_anhaengen``. + """ + return self.aktion in { + "datei_wird_erzeugt", + "datei_existiert_ueberschreiben", + "datei_existiert_anhaengen", + } diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py new file mode 100644 index 0000000..aa12879 --- /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.sys_wrapper 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 - - + + - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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():