From 8f9cf4c9fba110aba3e7b76ad6836843e8a6c160 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 20 Apr 2026 14:31:49 +0200 Subject: [PATCH 01/13] =?UTF-8?q?pdoc-dockstrings=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 14 ++++++++++++++ main.py | 7 ++++++- modules/listenauswerter.py | 5 ++++- tests/__init__.py | 4 +++- ui/dockwidget.py | 21 ++++++++++++++++++++- ui/tabs/tab_b.py | 15 +++++++++++++++ 6 files changed, 62 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index 40ff784..2a5bea3 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,17 @@ +"""sn_plan41 – QGIS-Plugin für thematische Linklisten und Datenquellen. +Verwaltet Excel-basierte Link-Listen und Fachdatenabruf. +""" + + def classFactory(iface): + """Factory-Funktion für QGIS. + Wird von QGIS aufgerufen, um die Plugin-Instanz zu erstellen. + + Args: + iface: QGIS-Schnittstellenobjekt + + Returns: + Plan41: Instanziiertes Plugin + """ from .main import Plan41 return Plan41(iface) diff --git a/main.py b/main.py index 87ab81e..fd8673f 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ -# sn_plan41/main.py +"""sn_plan41/main.py – Einstiegspunkt des sn_plan41 Plugins. +Orchestriert die UI und den Datenabruf für Thematische Linklisten. +""" from qgis.utils import plugins from sn_basis.ui.dockmanager import DockManager @@ -8,6 +10,9 @@ from sn_basis.modules.Pruefmanager import Pruefmanager class Plan41: + """Einstiegspunkt des sn_plan41-Plugins. + Verwaltet die DockWidget-Initialisierung und den Datenflusss. + """ def __init__(self, iface): self.iface = iface self.pruefmanager=Pruefmanager(ui_modus="qgis") diff --git a/modules/listenauswerter.py b/modules/listenauswerter.py index 4602aa5..86cd495 100644 --- a/modules/listenauswerter.py +++ b/modules/listenauswerter.py @@ -1,4 +1,7 @@ -#sn_plan41/modules/listenauswerter.py +"""sn_plan41/modules/listenauswerter.py – Validierung von Linklisten. +Prüft Zeilen aus DataDict auf Vollständigkeit und Konsistenz. +""" + from typing import Any, Dict, List, Mapping, Optional, Tuple from collections.abc import Mapping as _Mapping # Prüfer-Typen (werden als Instanzen erwartet) diff --git a/tests/__init__.py b/tests/__init__.py index 324c4b2..f0f6299 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,3 @@ -#Testordner \ No newline at end of file +"""sn_plan41/tests – Test-Suite für das sn_plan41 Plugin. +Enthält Unit-Tests und Integrationstests für Tab-Logik und Datengrabber. +""" diff --git a/ui/dockwidget.py b/ui/dockwidget.py index 51388b3..cfafb2c 100644 --- a/ui/dockwidget.py +++ b/ui/dockwidget.py @@ -1,4 +1,7 @@ -#sn_plan41/ui/dockwidget.py +"""sn_plan41/ui/dockwidget.py – Haupt-DockWidget für sn_plan41. +Verwaltet die Tabs für Linkvalidierung, Druck und Einstellungen. +""" + from sn_basis.ui.tabs.settings_tab import SettingsTab from sn_plan41.ui.tab_a_ui import TabA from sn_basis.ui.tabs.print_tab import PrintTab @@ -8,9 +11,25 @@ from sn_basis.functions.message_wrapper import error class DockWidget(BaseDockWidget): + """Haupt-DockWidget für sn_plan41 mit Tab-Verwaltung. + + Attributes: + tabs: Liste von Tab-Klassen (TabA, PrintTab, SettingsTab) + pruefmanager: Prüfmanager-Instanz für Validierung + data_grabber: DataGrabber-Instanz für Fachdatenabruf + """ + tabs = [TabA, PrintTab, SettingsTab] def __init__(self, parent=None, subtitle="", pruefmanager=None, data_grabber=None): + """Initialisiert das DockWidget mit Services und Tabs. + + Args: + parent: Übergeordnetes Widget + subtitle: Untertitel des DockWidgets + pruefmanager: Prüfmanager-Instanz + data_grabber: DataGrabber-Instanz + """ super().__init__(parent, subtitle) # Services als Attribute speichern diff --git a/ui/tabs/tab_b.py b/ui/tabs/tab_b.py index 785b79e..97d115c 100644 --- a/ui/tabs/tab_b.py +++ b/ui/tabs/tab_b.py @@ -1,9 +1,24 @@ +"""sn_plan41/ui/tabs/tab_b.py – Beispiel-Tab für zusätzliche Funktionen. +Ein Tab für zukünftige Erweiterungen. +""" + from qgis.PyQt.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit class TabB(QWidget): + """Beispiel-Tab für sn_plan41. + + Attributes: + tab_title: Anzeigetitel des Tabs ("Tab B") + """ + tab_title = "Tab B" def __init__(self, parent=None): + """Initialisiert den Tab mit Beispielinhalt. + + Args: + parent: Übergeordnetes Widget + """ super().__init__(parent) layout = QVBoxLayout() layout.addWidget(QLabel("Plugin2 – Tab B")) -- 2.54.0 From 600a3c7aff5db9f417887a95225d62fba1b48c34 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 20 Apr 2026 15:36:29 +0200 Subject: [PATCH 02/13] =?UTF-8?q?test=20=C3=BCberarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_listenauswerter.py | 84 ++++++++++++++++++ tests/test_tab_a_logic.py | 156 ++++++++++++++++++---------------- tests/test_tab_a_ui.py | 131 ++++++++++++++++++++-------- 3 files changed, 261 insertions(+), 110 deletions(-) create mode 100644 tests/test_listenauswerter.py diff --git a/tests/test_listenauswerter.py b/tests/test_listenauswerter.py new file mode 100644 index 0000000..1eadfcd --- /dev/null +++ b/tests/test_listenauswerter.py @@ -0,0 +1,84 @@ +import unittest +from typing import Any + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_plan41.modules.listenauswerter import Listenauswerter + + +class _DummyPruefmanager: + def __init__(self) -> None: + self.seen = [] + + def verarbeite(self, ergebnis: Any): + self.seen.append(ergebnis) + return ergebnis + + +class _DummyStilPruefer: + def __init__(self, ok: bool = True) -> None: + self.ok = ok + + def pruefe(self, _path: str) -> pruef_ergebnis: + if self.ok: + return pruef_ergebnis(ok=True, meldung="ok", aktion="ok", kontext=None) + return pruef_ergebnis(ok=False, meldung="stil fehler", aktion="falsche_endung", kontext=None) + + +class TestListenauswerter(unittest.TestCase): + + def test_validate_rows_accepts_valid_row_and_normalizes_provider(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + data = {"rows": [{"ident": "A1", "Inhalt": "Test", "Link": "http://x", "Provider": "wfs"}]} + validated, processed = validator.validate_rows(data) + + self.assertEqual(len(validated["rows"]), 1) + self.assertEqual(validated["rows"][0]["Provider"], "WFS") + self.assertEqual(processed, []) + + def test_validate_rows_rejects_missing_required_fields(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + data = {"rows": [{"ident": "", "Link": "", "Provider": ""}]} + validated, processed = validator.validate_rows(data) + + self.assertEqual(validated["rows"], []) + self.assertEqual(len(processed), 1) + self.assertEqual(processed[0].aktion, "pflichtfelder_fehlen") + + def test_validate_rows_sets_stildatei_none_on_style_failure(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=False)) + + data = { + "rows": [ + { + "ident": "A1", + "Inhalt": "Thema", + "Link": "http://x", + "Provider": "WMS", + "Stildatei": "broken.qml", + } + ] + } + validated, processed = validator.validate_rows(data) + + self.assertEqual(len(validated["rows"]), 1) + self.assertIsNone(validated["rows"][0]["stildatei"]) + self.assertEqual(len(processed), 1) + self.assertEqual(processed[0].aktion, "falsche_endung") + + def test_validate_rows_handles_non_mapping_rows_edge_case(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + validated, processed = validator.validate_rows({"rows": ["invalid", 123, None]}) + + self.assertEqual(validated, {"rows": []}) + self.assertEqual(processed, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tab_a_logic.py b/tests/test_tab_a_logic.py index 94c3bd8..595d4d0 100644 --- a/tests/test_tab_a_logic.py +++ b/tests/test_tab_a_logic.py @@ -1,99 +1,109 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from typing import Any, cast +from unittest.mock import patch -from sn_plan41.ui.tab_a_logic import TabALogic # type: ignore -from sn_basis.functions.variable_wrapper import get_variable # type: ignore -from sn_basis.functions.sys_wrapper import file_exists # type: ignore +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.functions.variable_wrapper import get_variable +from sn_plan41.ui.tab_a_logic import TabALogic + + +class _DummyPruefmanager: + def verarbeite(self, ergebnis): + return ergebnis class TestTabALogic(unittest.TestCase): - # ----------------------------------------------------- - # 1. Verfahrens-DB setzen und laden - # ----------------------------------------------------- - def test_verfahrens_db_set_and_load(self): - logic = TabALogic() + def _make_logic(self) -> TabALogic: + return TabALogic( + pruefmanager=cast(Any, _DummyPruefmanager()), + link_pruefer=cast(Any, object()), + stil_pruefer=cast(Any, object()), + ) - with TemporaryDirectory() as tmp: - db_path = Path(tmp) / "test.gpkg" - db_path.write_text("") + def test_verfahrens_db_set_and_load_roundtrip(self): + logic = self._make_logic() + logic.set_verfahrens_db("C:/tmp/v_db.gpkg") + self.assertEqual(logic.load_verfahrens_db(), "C:/tmp/v_db.gpkg") - logic.set_verfahrens_db(str(db_path)) - - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, str(db_path)) - - loaded = logic.load_verfahrens_db() - self.assertEqual(loaded, str(db_path)) - - # ----------------------------------------------------- - # 2. Verfahrens-DB löschen - # ----------------------------------------------------- def test_verfahrens_db_clear(self): - logic = TabALogic() - + logic = self._make_logic() logic.set_verfahrens_db(None) + self.assertEqual(get_variable("verfahrens_db", scope="project"), "") - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, "") - - # ----------------------------------------------------- - # 3. Neue Verfahrens-DB anlegen - # ----------------------------------------------------- - def test_create_new_verfahrens_db(self): - logic = TabALogic() - - with TemporaryDirectory() as tmp: - db_path = Path(tmp) / "neu.gpkg" - - result = logic.create_new_verfahrens_db(str(db_path)) - - self.assertTrue(result) - self.assertTrue(file_exists(db_path)) - - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, str(db_path)) - - def test_create_new_verfahrens_db_with_none_path(self): - logic = TabALogic() - result = logic.create_new_verfahrens_db(None) - self.assertFalse(result) - - - - # ----------------------------------------------------- - # 4. Linkliste setzen und laden - # ----------------------------------------------------- - def test_linkliste_set_and_load(self): - logic = TabALogic() - + def test_linkliste_set_and_load_existing_path(self): + logic = self._make_logic() with TemporaryDirectory() as tmp: link_path = Path(tmp) / "links.xlsx" - link_path.write_text("dummy") + link_path.write_text("x", encoding="utf-8") logic.set_linkliste(str(link_path)) + self.assertEqual(logic.load_linkliste(), str(link_path)) - stored = get_variable("linkliste", scope="project") - self.assertEqual(stored, str(link_path)) + def test_linkliste_load_returns_none_when_file_missing(self): + logic = self._make_logic() + logic.set_linkliste("C:/tmp/missing.xlsx") + self.assertIsNone(logic.load_linkliste()) - loaded = logic.load_linkliste() - self.assertEqual(loaded, str(link_path)) + @patch("sn_plan41.ui.tab_a_logic.get_plugin_root", return_value="C:/plugins") + @patch("sn_plan41.ui.tab_a_logic.join_path", return_value="C:/plugins/sn_plan41/assets/Linkliste.xlsx") + def test_resolve_linkliste_empty_uses_standard_and_persists(self, _mock_join_path, _mock_root): + logic = self._make_logic() + resolved = logic._resolve_linkliste(None) - # ----------------------------------------------------- - # 5. Linkliste löschen - # ----------------------------------------------------- - def test_linkliste_clear(self): - logic = TabALogic() + self.assertEqual(resolved, "C:/plugins/sn_plan41/assets/Linkliste.xlsx") + self.assertEqual(get_variable("linkliste", scope="project"), "C:/plugins/sn_plan41/assets/Linkliste.xlsx") - logic.set_linkliste(None) + @patch("sn_plan41.ui.tab_a_logic.Dateipruefer") + def test_resolve_linkliste_returns_none_when_pruefmanager_rejects(self, mock_dateipruefer): + logic = self._make_logic() + mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis( + ok=False, + meldung="Ungültig", + aktion="datei_nicht_gefunden", + kontext=None, + ) - stored = get_variable("linkliste", scope="project") - self.assertEqual(stored, "") + resolved = logic._resolve_linkliste("C:/tmp/missing.xlsx") + self.assertIsNone(resolved) - # ----------------------------------------------------- - # 6. Layer-ID speichern - # ----------------------------------------------------- + def test_resolve_raumfilter_without_filter_returns_none(self): + logic = self._make_logic() + result = logic._resolve_raumfilter("ohne", "C:/tmp/v_db.gpkg") + self.assertIsNone(result) + + @patch.object(TabALogic, "_get_verfahrensgebiet_layer", return_value="layer_obj") + def test_resolve_raumfilter_verfahrensgebiet_uses_layer_lookup(self, mock_get_layer): + logic = self._make_logic() + result = logic._resolve_raumfilter("Verfahrensgebiet", "C:/tmp/v_db.gpkg") + + self.assertEqual(result, "layer_obj") + mock_get_layer.assert_called_once() + + def test_extract_datenabruf_error_maps_merges_reason_maps(self): + logic = self._make_logic() + results = [ + pruef_ergebnis( + ok=False, + meldung="Head failed", + aktion="url_nicht_erreichbar", + kontext={"ident": "A1", "thema": "Dienst A", "error": "Timeout"}, + ), + pruef_ergebnis( + ok=False, + meldung="Fetch failed", + aktion="datenabruf", + kontext={"fehler": {"Dienst B": "HTTP 500", "Dienst A": "ignored"}}, + ), + ] + + merged = logic._extract_datenabruf_error_maps(results) + + self.assertEqual(merged["by_ident"]["A1"], "Timeout") + self.assertEqual(merged["by_thema"]["Dienst A"], "Timeout") + self.assertEqual(merged["by_thema"]["Dienst B"], "HTTP 500") if __name__ == "__main__": diff --git a/tests/test_tab_a_ui.py b/tests/test_tab_a_ui.py index 4911949..1fa7022 100644 --- a/tests/test_tab_a_ui.py +++ b/tests/test_tab_a_ui.py @@ -1,56 +1,113 @@ -""" -Smoke-Tests für TabA UI (sn_plan41/ui/tab_a_ui.py) - -Ziel: -- UI kann erstellt werden -- Callbacks crashen nicht -- Keine Qt-Verhaltensprüfung -""" - import unittest +from unittest.mock import patch -from sn_plan41.ui.tab_a_ui import TabA #type:ignore +from sn_basis.functions.variable_wrapper import get_variable +from sn_plan41.ui.tab_a_ui import TabA + + +class _DummySignal: + def connect(self, _callback): + return None + + +class _DummyToggleButton: + def __init__(self): + self.arrow = None + self.style = "" + + def setArrowType(self, arrow): + self.arrow = arrow + + def setStyleSheet(self, style): + self.style = style + + +class _DummyWidget: + def __init__(self): + self.visible = None + + def setVisible(self, visible): + self.visible = visible + + +class _DummyFileWidget: + def __init__(self): + self._path = "" + + def setFilePath(self, path): + self._path = path + + def filePath(self): + return self._path + + +class _DummyCombo: + def __init__(self, value="Pufferlayer"): + self._value = value + + def currentText(self): + return self._value + + +class _DummyPruefmanager: + pass + + +class _DummyDataGrabber: + pass class TestTabAUI(unittest.TestCase): - # ----------------------------------------------------- - # 1. UI kann erstellt werden - # ----------------------------------------------------- - def test_tab_a_ui_can_be_created(self): - tab = TabA(parent=None,build_ui=False) + def _make_tab_without_qt(self): + tab = TabA.__new__(TabA) + tab.group_button = _DummyToggleButton() + tab.group_content = _DummyWidget() + tab.optional_button = _DummyToggleButton() + tab.optional_content = _DummyWidget() + tab.file_widget = _DummyFileWidget() + tab.linkliste_widget = _DummyFileWidget() + tab._raumfilter_combo = _DummyCombo("ohne") + tab.verfahrens_db = None + tab.lokale_linkliste = None + tab.logic = None + tab.pruefmanager = None + tab.data_grabber = None + return tab - self.assertIsNotNone(tab) - self.assertEqual(tab.tab_title, "Daten") - - # ----------------------------------------------------- - # 2. Toggle-Callbacks crashen nicht - # ----------------------------------------------------- - def test_tab_a_toggle_callbacks_do_not_crash(self): - tab = TabA(parent=None,build_ui=False) + def test_tab_a_callbacks_can_be_called_without_crash(self): + tab = self._make_tab_without_qt() tab._toggle_group(True) tab._toggle_group(False) - tab._toggle_optional(True) tab._toggle_optional(False) - - # ----------------------------------------------------- - # 3. Datei-Callbacks akzeptieren leere Eingaben - # ----------------------------------------------------- - def test_tab_a_file_callbacks_accept_empty_input(self): - tab = TabA(parent=None,build_ui=False) - tab._on_verfahrens_db_changed("") tab._on_linkliste_changed("") - # ----------------------------------------------------- - # 4. Layer-Callback akzeptiert None - # ----------------------------------------------------- - def test_tab_a_layer_callback_accepts_none(self): - tab = TabA(parent=None,build_ui=False) + self.assertEqual(tab.group_content.visible, False) + self.assertEqual(tab.optional_content.visible, False) - tab._on_layer_changed(None) + def test_tab_a_raumfilter_callback_persists_value(self): + tab = self._make_tab_without_qt() + + tab._on_raumfilter_changed("ohne") + self.assertEqual(get_variable("Raumfilter", scope="project"), "ohne") + + @patch("sn_plan41.ui.tab_a_ui.TabALogic") + def test_set_services_initializes_logic_and_injects_data_grabber(self, mock_logic_cls): + tab = self._make_tab_without_qt() + mock_logic = mock_logic_cls.return_value + + pm = _DummyPruefmanager() + dg = _DummyDataGrabber() + + tab.set_services(pruefmanager=pm, data_grabber=dg) + + self.assertIs(tab.pruefmanager, pm) + self.assertIs(tab.data_grabber, dg) + self.assertIs(tab.logic, mock_logic) + self.assertIs(tab.logic.data_grabber, dg) if __name__ == "__main__": -- 2.54.0 From b2ee167132cdf9746d86cff5f19323c5e43be302 Mon Sep 17 00:00:00 2001 From: Daniel Helbig Date: Wed, 22 Apr 2026 09:03:54 +0200 Subject: [PATCH 03/13] =?UTF-8?q?QgsmaxVersion=20eingef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- metadata.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metadata.txt b/metadata.txt index c9ed787..a545b37 100644 --- a/metadata.txt +++ b/metadata.txt @@ -6,7 +6,7 @@ author=Daniel Helbig, Michael Otto homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Plan41 tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Plan41/issues repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_Test_Action/src/branch/unstable/ - +QGISMaximumVersion=4.99 qgisMinimumVersion=3.40 experimental=true deprecated=false -- 2.54.0 From cc2d5c6200ea3b38a2af99a889c86bdcba8760bc Mon Sep 17 00:00:00 2001 From: release-bot Date: Wed, 22 Apr 2026 07:07:17 +0000 Subject: [PATCH 04/13] Release 26.4.2-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 6fee27b..25dd481 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.4.2-unstable: +--- Version 26.04.1-unstable: --- Version 26.3.3-unstable: diff --git a/metadata.txt b/metadata.txt index a545b37..201d692 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.04.1-unstable +version=26.4.2-unstable name=LNO Sachsen | Plan41 description=Plugin zum Erzeugen der Pläne nach §38 und §41 author=Daniel Helbig, Michael Otto -- 2.54.0 From 10615b31f4dec5f23f45d65c8d0aceea4c279e02 Mon Sep 17 00:00:00 2001 From: release-bot Date: Wed, 22 Apr 2026 07:24:17 +0000 Subject: [PATCH 05/13] Release 26.4.3-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 84adbba..d80bedb 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.4.3-unstable: +--- Version 26.4.2-testing: --- Version 26.04.1-unstable: diff --git a/metadata.txt b/metadata.txt index 201d692..f5e877c 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.4.2-unstable +version=26.4.3-unstable name=LNO Sachsen | Plan41 description=Plugin zum Erzeugen der Pläne nach §38 und §41 author=Daniel Helbig, Michael Otto -- 2.54.0 From 16d6474bb96fb2918b365fa9c5d12c1a783a9a94 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 19 Jun 2026 11:47:15 +0200 Subject: [PATCH 06/13] VLN-API integriert --- assets/QGIS_P41_API_Layer_Linie.qml | 1004 +++++++++++++++++++++++++ assets/QGIS_P41_API_Layer_flaeche.qml | 597 +++++++++++++++ modules/vln_api_client.py | 248 ++++++ modules/vln_api_logic.py | 378 ++++++++++ modules/vln_layer_manager.py | 177 +++++ tests/run_tests.py | 14 + tests/test_vln_api_logic.py | 473 ++++++++++++ ui/tab_a_ui.py | 377 +++++++++- 8 files changed, 3259 insertions(+), 9 deletions(-) create mode 100644 assets/QGIS_P41_API_Layer_Linie.qml create mode 100644 assets/QGIS_P41_API_Layer_flaeche.qml create mode 100644 modules/vln_api_client.py create mode 100644 modules/vln_api_logic.py create mode 100644 modules/vln_layer_manager.py create mode 100644 tests/test_vln_api_logic.py diff --git a/assets/QGIS_P41_API_Layer_Linie.qml b/assets/QGIS_P41_API_Layer_Linie.qml new file mode 100644 index 0000000..6815606 --- /dev/null +++ b/assets/QGIS_P41_API_Layer_Linie.qml @@ -0,0 +1,1004 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "NAME" + + 1 + diff --git a/assets/QGIS_P41_API_Layer_flaeche.qml b/assets/QGIS_P41_API_Layer_flaeche.qml new file mode 100644 index 0000000..5ea684c --- /dev/null +++ b/assets/QGIS_P41_API_Layer_flaeche.qml @@ -0,0 +1,597 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "NAME" + + 2 + diff --git a/modules/vln_api_client.py b/modules/vln_api_client.py new file mode 100644 index 0000000..dd6eb7e --- /dev/null +++ b/modules/vln_api_client.py @@ -0,0 +1,248 @@ +"""HTTP-Client für die VLN-Manager-API. + +API-Vertrag (Stand 2026-06-08): + + Basis-URL: https://api.flurneuordnung-sachsen.de/v2 + + POST /person/login + Body: {"mail": "...", "password": "..."} + Antwort: {"data": {"userauth": "", "id": ...}} + Das userauth-Token ist der API-Key (= PERSON.url_token). + + GET /tgen -> Teilnehmergemeinschaften (Verfahren) + + GET /maps//{vkz} -> GeoJSON FeatureCollection (EPSG:25833) + PUT /maps//{vkz} <- GeoJSON (ersetzt den Layer dieser VKZ) + Antwort: {"data": {"vkz", "layer", "art", "speicher_id"}, "status": "ok"} + + Layer: umringe, p41, st, kas, we + Auth nach Login über den Header "X-API-Key". + Fehlerformat: RFC 7807 (application/problem+json). + +Es wird QgsBlockingNetworkRequest verwendet, damit Proxy- und +Zertifikatseinstellungen aus QGIS automatisch greifen. +""" + +import json + +from sn_basis.functions.qt_wrapper import QUrl, QNetworkRequest +from sn_basis.functions.qgiscore_wrapper import QgsBlockingNetworkRequest + +# Fest verdrahtete Produktiv-API des VLN Managers. +DEFAULT_BASE_URL = "https://api.flurneuordnung-sachsen.de/v2" + + +class ApiError(Exception): + """Fehler bei der Kommunikation mit der Karten-API.""" + + +class AuthError(ApiError): + """API-Key fehlt, ist abgelaufen oder hat keine Berechtigung (401/403).""" + + +class KartenApiClient: + def __init__(self, base_url=DEFAULT_BASE_URL, api_key=None, mail=None): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.mail = mail + + @property + def is_authenticated(self): + return bool(self.api_key) + + def login(self, mail, password): + """Meldet den Benutzer an. Die API liefert das userauth-Token + (= API-Key), das für alle weiteren Requests verwendet wird.""" + data = self._request( + "POST", + "/person/login", + payload={"mail": mail, "password": password}, + with_auth=False, + ) + payload = data.get("data") if isinstance(data, dict) else None + if not isinstance(payload, dict): + payload = data if isinstance(data, dict) else {} + api_key = payload.get("userauth") + if not api_key: + raise ApiError( + "Login-Antwort enthielt kein userauth-Token. " + "Erhaltene Antwort: %s" % json.dumps(data)[:300] + ) + self.api_key = api_key + self.mail = mail + return api_key + + def logout(self): + self.api_key = None + self.mail = None + + # ------------------------------------------------------------------ + # Verfahren (Teilnehmergemeinschaften) + # ------------------------------------------------------------------ + + def get_verfahren(self): + """Liste der Verfahren (TGs) als [{"vkz": ..., "name": ...}, ...], + sortiert nach VKZ. Quelle: GET /tgen.""" + data = self._request("GET", "/tgen") + + rows = None + if isinstance(data, list): + rows = data + elif isinstance(data, dict): + for key in ("data", "rows"): + value = data.get(key) + if isinstance(value, list): + rows = value + break + if isinstance(value, dict) and isinstance(value.get("rows"), list): + rows = value["rows"] + break + if rows is None: + raise ApiError( + "Antwort von /tgen hat ein unerwartetes Format: %s" + % json.dumps(data)[:200] + ) + + verfahren = [] + for row in rows: + if not isinstance(row, dict): + continue + vkz = row.get("vkz") or row.get("VKZ") + name = ( + row.get("kurzname") + or row.get("Kurzname") + or row.get("name") + or row.get("Name") + or "" + ) + if vkz: + verfahren.append({"vkz": str(vkz), "name": str(name)}) + verfahren.sort(key=lambda v: v["vkz"]) + return verfahren + + # ------------------------------------------------------------------ + # Karten-Layer + # ------------------------------------------------------------------ + + def load_layer_complete(self, layer_key, vkz, page_size=2000): + """Lädt ALLE Objekte eines Layers für eine VKZ aus KARTE_OBJEKT. + + Nutzt den Listen-Endpunkt /maps/?vkz=… mit limit/offset-Paging, + bis keine weitere Seite mehr kommt.""" + from urllib.parse import quote + + features = [] + offset = 0 + while True: + data = self._request( + "GET", + "/maps/%s?vkz=%s&limit=%d&offset=%d" + % (quote(str(layer_key)), quote(str(vkz)), page_size, offset), + ) + if not isinstance(data, dict) or data.get("type") != "FeatureCollection": + raise ApiError( + "Antwort von /maps/%s ist keine GeoJSON FeatureCollection." + % layer_key + ) + page = data.get("features", []) + features.extend(page) + if len(page) < page_size: + break + offset += page_size + return {"type": "FeatureCollection", "features": features} + + def load_feature_collection(self, path): + """Lädt einen Layer als GeoJSON FeatureCollection (dict).""" + data = self._request("GET", path) + if isinstance(data, dict): + if data.get("type") == "FeatureCollection": + return data + inner = data.get("data") + if isinstance(inner, dict) and inner.get("type") == "FeatureCollection": + return inner + raise ApiError( + "Antwort von %s ist keine GeoJSON FeatureCollection." % path + ) + + def save_feature_collection(self, path, feature_collection): + """Schreibt eine GeoJSON FeatureCollection zurück an die API + (PUT ersetzt den kompletten Layer der jeweiligen VKZ).""" + return self._request("PUT", path, payload=feature_collection) + + # ------------------------------------------------------------------ + # Intern + # ------------------------------------------------------------------ + + def _request(self, method, path, payload=None, with_auth=True): + if with_auth and not self.is_authenticated: + raise AuthError("Nicht angemeldet — bitte zuerst einloggen.") + + request = QNetworkRequest(QUrl(self.base_url + path)) + request.setHeader( + QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json" + ) + request.setRawHeader(b"Accept", b"application/json") + if with_auth: + request.setRawHeader(b"X-API-Key", self.api_key.encode("utf-8")) + + body = b"" + if payload is not None: + body = json.dumps(payload).encode("utf-8") + + blocking = QgsBlockingNetworkRequest() + if method == "GET": + error = blocking.get(request) + elif method == "POST": + error = blocking.post(request, body) + elif method == "PUT": + error = blocking.put(request, body) + else: + raise ApiError("Nicht unterstützte HTTP-Methode: %s" % method) + + reply = blocking.reply() + content = bytes(reply.content()) + status = reply.attribute( + QNetworkRequest.Attribute.HttpStatusCodeAttribute + ) + status = int(status) if status is not None else None + + if status in (401, 403): + raise AuthError( + "Keine Berechtigung für %s %s (HTTP %s)%s" + % (method, path, status, self._error_detail(content)) + ) + if error != QgsBlockingNetworkRequest.NoError: + raise ApiError( + "%s %s fehlgeschlagen: %s%s" + % (method, path, blocking.errorMessage(), self._error_detail(content)) + ) + if status is not None and status >= 400: + raise ApiError( + "%s %s lieferte HTTP %s%s" + % (method, path, status, self._error_detail(content)) + ) + + if not content: + return None + try: + return json.loads(content.decode("utf-8")) + except ValueError: + raise ApiError( + "Antwort von %s ist kein gültiges JSON." % path + ) + + @staticmethod + def _error_detail(content): + """Liest title/detail aus einer RFC-7807-Fehlerantwort.""" + if not content: + return "" + try: + problem = json.loads(content.decode("utf-8")) + parts = [ + str(problem[k]) for k in ("title", "detail") if problem.get(k) + ] + if parts: + return " — " + ": ".join(parts) + except (ValueError, AttributeError): + pass + return " — " + content[:300].decode("utf-8", "replace") diff --git a/modules/vln_api_logic.py b/modules/vln_api_logic.py new file mode 100644 index 0000000..a396f1b --- /dev/null +++ b/modules/vln_api_logic.py @@ -0,0 +1,378 @@ +""" +sn_plan41/modules/vln_api_logic.py – Fachlogik für die VLN-API-Integration in Tab A. + +Kapselt: +- Anmeldung / Abmeldung (KartenApiClient aus vln_karten) +- Persistierung des API-Keys in QSettings +- Verfahrensliste laden (GET /tgen) +- Persistierung der VKZ-Auswahl in der Projektdatei +- Plan 41 laden (2 Layer + Stile aus sn_plan41/assets/) +- Aktiven Layer hochladen + +Voraussetzung: Das Plugin `vln_karten` muss installiert sein. +Wenn nicht vorhanden, liefert :attr:`is_available` False; die UI +kann dann einen entsprechenden Hinweis anzeigen. + +Sitzungsablauf bei AuthError: +- :meth:`handle_session_expired` aufrufen → API-Key löschen + logout +- In der UI danach :meth:`_update_vln_ui_state` aufrufen und + QMessageBox.warning anzeigen. Kein automatischer Login-Dialog. +""" + +from __future__ import annotations + +from typing import Optional + +from sn_basis.functions.qt_wrapper import QSettings +from sn_basis.functions.qgiscore_wrapper import QgsProject +from sn_basis.functions.sys_wrapper import get_plugin_root, join_path +from sn_basis.functions.ly_style_wrapper import apply_style_from_path + +# Lokale API-Module — fallenübrig, sobald QGIS verfügbar ist. +# In der reinen Python-Testumgebung (kein qgis-Paket) schlagen die +# qgis-Imports in den Untermodulen fehl; daher der try/except-Guard. +try: + from sn_plan41.modules.vln_api_client import KartenApiClient, ApiError, AuthError + from sn_plan41.modules.vln_layer_manager import ( + feature_collection_to_layers, + plugin_layers, + layers_to_feature_collection, + layer_api_path, + PROP_DATASET, + PROP_VERFAHREN, + ) + VLN_KARTEN_AVAILABLE = True +except (ImportError, ModuleNotFoundError): + KartenApiClient = None # type: ignore[assignment,misc] + ApiError = Exception # type: ignore[assignment,misc] + AuthError = Exception # type: ignore[assignment,misc] + VLN_KARTEN_AVAILABLE = False + +# Geometrietyp-Konstanten (QGIS intern: 1 = Linie, 2 = Fläche) +try: + from qgis.core import Qgis as _Qgis + _GEOM_LINE = _Qgis.GeometryType.Line + _GEOM_POLYGON = _Qgis.GeometryType.Polygon +except (ImportError, AttributeError): + try: + from qgis.core import QgsWkbTypes as _QgsWkbTypes + _GEOM_LINE = _QgsWkbTypes.LineGeometry + _GEOM_POLYGON = _QgsWkbTypes.PolygonGeometry + except (ImportError, AttributeError): + _GEOM_LINE = 1 + _GEOM_POLYGON = 2 + +# Konstanten +SETTINGS_GROUP = "vln_karten" +PROJECT_SCOPE = "vln_karten" +PROJECT_KEY_VKZ = "/vkz" + +P41_DATASET_KEY = "p41" +P41_LABEL = "Plan 41 (Wege- und Gewässerplan)" +P41_PATH_TEMPLATE = "/maps/p41/{vkz}" +P41_GEOMETRY = "MultiPolygon" + +STYLE_FLAECHE = "QGIS_P41_API_Layer_flaeche.qml" +STYLE_LINIE = "QGIS_P41_API_Layer_Linie.qml" + + +class VlnApiLogic: + """ + Kapselt die VLN-API-Fachlogik für den Plan41-Tab. + + Alle Methoden, die Netzwerkkommunikation erfordern, können + ``ApiError`` oder ``AuthError`` werfen – die UI ist für die + Fehlerdarstellung zuständig. + """ + + def __init__(self, pruefmanager=None) -> None: + self.pruefmanager = pruefmanager + self._client: Optional[object] = None # KartenApiClient | None + + # API-Client aus gespeicherten Credentials initialisieren + if VLN_KARTEN_AVAILABLE: + api_key, mail = self.load_stored_credentials() + if api_key: + self._client = KartenApiClient(api_key=api_key, mail=mail) + + # ------------------------------------------------------------------ + # Verfügbarkeit + # ------------------------------------------------------------------ + + @staticmethod + def is_available() -> bool: + """True, wenn die lokalen API-Module geladen werden konnten + (erfordert eine QGIS-Laufzeitumgebung).""" + return VLN_KARTEN_AVAILABLE + + # ------------------------------------------------------------------ + # Authentifizierungsstatus + # ------------------------------------------------------------------ + + @property + def is_authenticated(self) -> bool: + """True, wenn ein gültiger API-Key vorhanden ist.""" + if self._client is None: + return False + return bool(getattr(self._client, "is_authenticated", False)) + + @property + def mail(self) -> Optional[str]: + """E-Mail-Adresse des angemeldeten Benutzers oder None.""" + if self._client is None: + return None + return getattr(self._client, "mail", None) + + # ------------------------------------------------------------------ + # QSettings: API-Key persistieren + # ------------------------------------------------------------------ + + def load_stored_credentials(self) -> tuple: + """Liest API-Key und Mail aus QSettings. + Gibt ``(api_key, mail)`` zurück – leere Strings wenn nicht vorhanden. + """ + settings = QSettings() + settings.beginGroup(SETTINGS_GROUP) + api_key = settings.value("api_key", "") + mail = settings.value("mail", "") + settings.endGroup() + return api_key or None, mail or None + + def save_api_key(self, api_key: str, mail: str) -> None: + """Speichert API-Key und Mail in QSettings.""" + settings = QSettings() + settings.beginGroup(SETTINGS_GROUP) + settings.setValue("api_key", api_key) + settings.setValue("mail", mail or "") + settings.endGroup() + + def clear_api_key(self) -> None: + """Entfernt API-Key aus QSettings.""" + settings = QSettings() + settings.beginGroup(SETTINGS_GROUP) + settings.remove("api_key") + settings.endGroup() + + # ------------------------------------------------------------------ + # Login / Logout + # ------------------------------------------------------------------ + + def login(self, mail: str, password: str) -> None: + """ + Meldet den Benutzer an und speichert den API-Key. + + :raises ApiError: Bei ungültigen Zugangsdaten oder Netzwerkproblem. + :raises RuntimeError: Wenn QGIS nicht verfügbar ist (kein qgis-Paket). + """ + if not VLN_KARTEN_AVAILABLE: + raise RuntimeError( + "VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)." + ) + client = KartenApiClient() + client.login(mail, password) # wirft ApiError bei Fehler + self._client = client + self.save_api_key(client.api_key, mail) + + def logout(self) -> None: + """Meldet den Benutzer ab (lokal, kein API-Aufruf).""" + if self._client is not None: + getattr(self._client, "logout", lambda: None)() + self._client = None + + def handle_session_expired(self) -> None: + """ + Behandelt einen abgelaufenen API-Key: + entfernt ihn aus QSettings und setzt den Client zurück. + """ + self.clear_api_key() + self.logout() + + # ------------------------------------------------------------------ + # Verfahrensliste + # ------------------------------------------------------------------ + + def get_verfahren(self) -> list: + """ + Lädt alle Verfahren (TGs) vom Server. + + :returns: Sortierte Liste von ``{"vkz": ..., "name": ...}``-Dicts. + :raises AuthError: Wenn nicht angemeldet oder Session abgelaufen. + :raises ApiError: Bei sonstigem Netzwerkfehler. + """ + if self._client is None: + raise AuthError("Nicht angemeldet.") + return self._client.get_verfahren() + + # ------------------------------------------------------------------ + # VKZ-Persistenz in Projektdatei + # ------------------------------------------------------------------ + + def load_stored_vkz(self) -> Optional[str]: + """Liest die zuletzt gewählte VKZ aus der Projektdatei.""" + try: + vkz, _ok = QgsProject.instance().readEntry( + PROJECT_SCOPE, PROJECT_KEY_VKZ, "" + ) + return vkz if vkz else None + except Exception: + return None + + def save_vkz(self, vkz: Optional[str]) -> None: + """Schreibt die gewählte VKZ in die Projektdatei.""" + if not vkz: + return + try: + QgsProject.instance().writeEntry(PROJECT_SCOPE, PROJECT_KEY_VKZ, vkz) + except Exception: + pass + + # ------------------------------------------------------------------ + # Plan 41 laden + # ------------------------------------------------------------------ + + def get_p41_layers(self, vkz: str) -> list: + """Gibt alle bereits geladenen P41-Layer für diese VKZ zurück.""" + return plugin_layers(P41_DATASET_KEY, vkz) + + def remove_p41_layers(self, vkz: str) -> None: + """Entfernt alle P41-Layer dieser VKZ aus dem Projekt.""" + existing = plugin_layers(P41_DATASET_KEY, vkz) + if existing: + QgsProject.instance().removeMapLayers([l.id() for l in existing]) + + def load_p41(self, vkz: str) -> list: + """ + Lädt den Plan-41-Datensatz für eine VKZ vom Server und legt + Memory-Layer im Projekt an. Auf die entstandenen Layer werden + die passenden QML-Stile aus ``sn_plan41/assets/`` angewendet. + + :returns: Liste der erzeugten ``QgsVectorLayer``. + :raises AuthError: Wenn nicht angemeldet oder Session abgelaufen. + :raises ApiError: Bei sonstigem Netzwerkfehler. + :raises RuntimeError: Wenn QGIS nicht verfügbar ist. + """ + if not VLN_KARTEN_AVAILABLE: + raise RuntimeError( + "VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)." + ) + if self._client is None: + raise AuthError("Nicht angemeldet.") + + path = P41_PATH_TEMPLATE.format(vkz=vkz) + feature_collection = self._client.load_layer_complete( + P41_DATASET_KEY, vkz + ) + base_name = "%s %s" % (P41_LABEL, vkz) + layers = feature_collection_to_layers( + feature_collection, + base_name, + P41_GEOMETRY, + P41_DATASET_KEY, + vkz, + path, + ) + self._apply_p41_styles(layers) + return layers + + def _apply_p41_styles(self, layers: list) -> None: + """Wendet den passenden QML-Stil auf jeden Layer an.""" + assets_dir = join_path(get_plugin_root(), "sn_plan41", "assets") + for layer in layers: + try: + gtype = layer.geometryType() + except Exception: + continue + + if gtype == _GEOM_POLYGON: + style_file = STYLE_FLAECHE + elif gtype == _GEOM_LINE: + style_file = STYLE_LINIE + else: + continue # Punkt- und None-Layer erhalten keinen Stil + + style_path = join_path(assets_dir, style_file) + apply_style_from_path(layer, style_path) + + # ------------------------------------------------------------------ + # Upload + # ------------------------------------------------------------------ + + def upload_active_layer(self, active_layer) -> tuple: + """ + Lädt alle Teil-Layer des aktiven Layers zum Server hoch. + + :param active_layer: Aktiver ``QgsVectorLayer`` (aus ``iface.activeLayer()``). + :returns: ``(success: bool, message: str)`` + :raises AuthError: Wenn Session abgelaufen. + :raises ApiError: Bei Netzwerkfehler. + """ + if not VLN_KARTEN_AVAILABLE: + return False, ( + "VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)." + ) + + if self._client is None: + raise AuthError("Nicht angemeldet.") + + path = layer_api_path(active_layer) if active_layer else None + if not path: + return ( + False, + "Der aktive Layer wurde nicht über 'vln_karten' geladen. " + "Bitte einen Plan41-Layer aus dem Projekt auswählen.", + ) + + dataset_key = active_layer.customProperty(PROP_DATASET) + vkz = active_layer.customProperty(PROP_VERFAHREN) + siblings = plugin_layers(dataset_key, vkz) + + for layer in siblings: + if layer.isEditable() and not layer.commitChanges(): + return ( + False, + "Die Bearbeitungssitzung von '%s' konnte nicht " + "gespeichert werden." % layer.name(), + ) + + feature_collection = layers_to_feature_collection(siblings) + self._client.save_feature_collection(path, feature_collection) + + total = sum(layer.featureCount() for layer in siblings) + return ( + True, + "%d Objekte aus %d Layer(n) erfolgreich hochgeladen." + % (total, len(siblings)), + ) + + def upload_summary(self, active_layer) -> Optional[dict]: + """ + Gibt eine Zusammenfassung der zu hochladenden Layer zurück + (für den Bestätigungs-Dialog in der UI), ohne den Upload + tatsächlich auszuführen. + + :returns: Dict mit ``dataset_label``, ``vkz``, ``siblings`` + (Layer-Liste), ``total`` (Objektanzahl), ``listing`` (str) + oder ``None`` wenn kein gültiger Layer. + """ + if not VLN_KARTEN_AVAILABLE or active_layer is None: + return None + path = layer_api_path(active_layer) + if not path: + return None + + dataset_key = active_layer.customProperty(PROP_DATASET) + vkz = active_layer.customProperty(PROP_VERFAHREN) + siblings = plugin_layers(dataset_key, vkz) + + total = sum(l.featureCount() for l in siblings) + listing = "\n".join( + "• %s (%d Objekte)" % (l.name(), l.featureCount()) + for l in siblings + ) + return { + "dataset_label": P41_LABEL if dataset_key == P41_DATASET_KEY else dataset_key, + "vkz": vkz, + "siblings": siblings, + "total": total, + "listing": listing, + } diff --git a/modules/vln_layer_manager.py b/modules/vln_layer_manager.py new file mode 100644 index 0000000..c18b35f --- /dev/null +++ b/modules/vln_layer_manager.py @@ -0,0 +1,177 @@ +"""Konvertierung GeoJSON <-> QGIS-Layer für die VLN-API. + +Lokale Kopie des Layer-Managers aus dem vln_karten-Plugin — sn_plan41 +benötigt kein separat installiertes vln_karten-Plugin mehr. + +Die API liefert je Layer (z.B. Plan 41) gemischte Geometrietypen in einer +FeatureCollection: Punkte, Linien, Polygone sowie deren Multi-Varianten. +QGIS-Memory-Layer können nur einen Geometrietyp halten — beim Laden wird +deshalb nach Geometrie-Familie (Punkte/Linien/Flächen) in bis zu drei +Layer gesplittet; Single-Geometrien werden zu Multi befördert. + +Beim Hochladen müssen alle Teil-Layer desselben Datensatzes wieder zu +EINER FeatureCollection vereint werden, weil der PUT der API den +kompletten Layer-Bestand der VKZ ersetzt. + +Custom-Properties an Layern (kompatibel mit vln_karten, falls beide +Plugins gleichzeitig installiert sind): + vln_karten/dataset — z.B. "p41" + vln_karten/verfahren — VKZ-Nummer + vln_karten/api_path — API-Pfad für Upload +""" + +import json + +from sn_basis.functions.qgiscore_wrapper import ( + QgsJsonExporter, + QgsJsonUtils, + QgsProject, + QgsVectorLayer, + GEOM_POINT, + GEOM_LINE, + GEOM_POLYGON, +) + +_GEOM_POINT = GEOM_POINT +_GEOM_LINE = GEOM_LINE +_GEOM_POLYGON = GEOM_POLYGON + +# Die VLN-API liefert Koordinaten in ETRS89 / UTM Zone 33 (EPSG:25833). +GEOJSON_CRS = "EPSG:25833" + +# Custom-Property-Schlüssel — identisch mit vln_karten für Kompatibilität. +PROP_DATASET = "vln_karten/dataset" +PROP_VERFAHREN = "vln_karten/verfahren" +PROP_PATH = "vln_karten/api_path" + +# Geometrie-Familie → (Memory-Provider-Typ, Namenszusatz). +# Reihenfolge = Lade-Reihenfolge: Flächen zuerst, damit Punkte im +# Layerbaum oben landen und nicht verdeckt werden. +_FAMILIES = ( + ("polygon", "MultiPolygon", "Flächen"), + ("line", "MultiLineString", "Linien"), + ("point", "MultiPoint", "Punkte"), + ("none", "None", "ohne Geometrie"), +) + + +def _family(feature): + """Geometrie-Familie eines Features. Unbekannte Typen (z.B. + GeometryCollection) landen bei 'none'.""" + if not feature.hasGeometry() or feature.geometry().isNull(): + return "none" + gtype = feature.geometry().type() + if gtype == _GEOM_POINT: + return "point" + if gtype == _GEOM_LINE: + return "line" + if gtype == _GEOM_POLYGON: + return "polygon" + return "none" + + +def memory_layer_from_features( + uri_type, name, fields, features, dataset_key, verfahren_nr, api_path, promote=True +): + """Baut einen Memory-Layer aus fertigen QgsFeatures, setzt die + Plugin-Custom-Properties und hängt ihn ins Projekt.""" + uri = uri_type if uri_type == "None" else "%s?crs=%s" % (uri_type, GEOJSON_CRS) + layer = QgsVectorLayer(uri, name, "memory") + if not layer.isValid(): + raise RuntimeError("Memory-Layer '%s' konnte nicht erzeugt werden." % name) + provider = layer.dataProvider() + provider.addAttributes(fields.toList()) + layer.updateFields() + prepared = [] + for feature in features: + geometry = feature.geometry() + if promote and not geometry.isNull() and not geometry.isMultipart(): + geometry.convertToMultiType() + feature.setGeometry(geometry) + prepared.append(feature) + provider.addFeatures(prepared) + layer.updateExtents() + layer.setCustomProperty(PROP_DATASET, dataset_key) + layer.setCustomProperty(PROP_VERFAHREN, verfahren_nr) + layer.setCustomProperty(PROP_PATH, api_path) + return layer + + +def feature_collection_to_layers( + feature_collection, base_name, default_geometry, dataset_key, verfahren_nr, api_path +): + """Erzeugt aus einer GeoJSON FeatureCollection je vorkommender + Geometrie-Familie einen Memory-Layer und hängt sie ins Projekt. + Liefert die Liste der erzeugten Layer.""" + text = json.dumps(feature_collection) + fields = QgsJsonUtils.stringToFields(text) + features = QgsJsonUtils.stringToFeatureList(text, fields) + + groups = {} + for feature in features: + groups.setdefault(_family(feature), []).append(feature) + + layers = [] + multiple = len(groups) > 1 + + def make_layer(uri_type, name, group_features, promote): + layers.append( + memory_layer_from_features( + uri_type, name, fields, group_features, + dataset_key, verfahren_nr, api_path, promote=promote, + ) + ) + + if not groups: + # Leerer Datensatz: ein leerer Layer im Default-Typ, damit der + # Nutzer digitalisieren und hochladen kann. + make_layer(default_geometry, base_name, [], promote=False) + else: + for family, uri_type, suffix in _FAMILIES: + if family not in groups: + continue + name = "%s — %s" % (base_name, suffix) if multiple else base_name + make_layer(uri_type, name, groups[family], promote=(family != "none")) + + QgsProject.instance().addMapLayers(layers) + return layers + + +def plugin_layers(dataset_key, verfahren_nr): + """Alle Layer im Projekt, die zu diesem Datensatz und dieser VKZ + gehören (z.B. die Punkte-/Linien-/Flächen-Teil-Layer von Plan 41).""" + result = [] + for layer in QgsProject.instance().mapLayers().values(): + if ( + isinstance(layer, QgsVectorLayer) + and layer.customProperty(PROP_DATASET) == dataset_key + and str(layer.customProperty(PROP_VERFAHREN)) == str(verfahren_nr) + ): + result.append(layer) + return result + + +def layers_to_feature_collection(layers): + """Vereint die Features mehrerer Layer zu EINER GeoJSON + FeatureCollection (für den PUT, der den Serverbestand ersetzt). + + Keine Transformation nach WGS84: Die API erwartet die Koordinaten im + selben CRS, in dem sie sie liefert (EPSG:25833).""" + merged = [] + for layer in layers: + exporter = QgsJsonExporter(layer) + exporter.setSourceCrs(layer.crs()) + exporter.setTransformGeometries(False) + collection = json.loads( + exporter.exportFeatures(list(layer.getFeatures())) + ) + merged.extend(collection.get("features", [])) + return {"type": "FeatureCollection", "features": merged} + + +def layer_api_path(layer): + """API-Pfad, unter dem dieser Layer geladen wurde — oder None, + wenn der Layer nicht von diesem Plugin stammt.""" + if not isinstance(layer, QgsVectorLayer): + return None + return layer.customProperty(PROP_PATH) or None diff --git a/tests/run_tests.py b/tests/run_tests.py index 42e1cdd..cae8f33 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -5,6 +5,7 @@ Zentraler Test-Runner für sn_plan41. Wrapper-konform, QGIS-unabhängig, CI- und IDE-fähig. """ +import io import unittest import datetime import inspect @@ -12,6 +13,19 @@ import os import sys from pathlib import Path +# Stdout auf UTF-8 umstellen, damit Emoji-Print-Statements in +# qt_wrapper.py (Mock-Modus) auf Windows-Konsolen (CP1252) nicht zu +# UnicodeEncodeError führen. +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass +elif hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper( + sys.stdout.buffer, encoding="utf-8", errors="replace" + ) + # --------------------------------------------------------- # Plugin-Roots bestimmen diff --git a/tests/test_vln_api_logic.py b/tests/test_vln_api_logic.py new file mode 100644 index 0000000..142b357 --- /dev/null +++ b/tests/test_vln_api_logic.py @@ -0,0 +1,473 @@ +""" +Tests für sn_plan41/modules/vln_api_logic.py + +Alle externen Abhängigkeiten (vln_karten, QSettings, QgsProject, Netzwerk) +werden mit unittest.mock gemockt, sodass keine QGIS-Laufzeitumgebung nötig ist. +""" + +import unittest +from typing import Any, Optional +from unittest.mock import MagicMock, patch, call + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_logic(api_key: Optional[str] = None, mail: Optional[str] = None): + """Erzeugt eine VlnApiLogic-Instanz mit gemockten QSettings.""" + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs.value.side_effect = lambda key, default="": ( + api_key if key == "api_key" else (mail if key == "mail" else default) + ) + mock_qs_cls.return_value = mock_qs + + if api_key and VlnApiLogic.is_available(): + with patch( + "sn_plan41.modules.vln_api_logic.KartenApiClient" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client.is_authenticated = True + mock_client.api_key = api_key + mock_client.mail = mail + mock_client_cls.return_value = mock_client + logic = VlnApiLogic() + else: + logic = VlnApiLogic() + + return logic + + +# --------------------------------------------------------------------------- +# Tests: Verfügbarkeit +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicAvailability(unittest.TestCase): + + def test_is_available_reflects_import_state(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + result = VlnApiLogic.is_available() + # Ergebnis hängt davon ab, ob vln_karten installiert ist — + # wir prüfen nur, dass ein bool zurückkommt + self.assertIsInstance(result, bool) + + def test_is_available_false_when_vln_karten_missing(self): + """Wenn VLN_KARTEN_AVAILABLE=False, liefert is_available() False.""" + import sn_plan41.modules.vln_api_logic as module + original = module.VLN_KARTEN_AVAILABLE + try: + module.VLN_KARTEN_AVAILABLE = False + from sn_plan41.modules.vln_api_logic import VlnApiLogic + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + self.assertFalse(VlnApiLogic.is_available()) + finally: + module.VLN_KARTEN_AVAILABLE = original + + +# --------------------------------------------------------------------------- +# Tests: Credentials-Persistenz +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicCredentials(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + return VlnApiLogic() + + def test_save_api_key_calls_qsettings(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs_cls.return_value = mock_qs + logic.save_api_key("token123", "user@test.de") + + mock_qs.setValue.assert_any_call("api_key", "token123") + mock_qs.setValue.assert_any_call("mail", "user@test.de") + mock_qs.beginGroup.assert_called_with("vln_karten") + + def test_clear_api_key_calls_remove(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs_cls.return_value = mock_qs + logic.clear_api_key() + + mock_qs.remove.assert_called_with("api_key") + + def test_load_stored_credentials_returns_api_key_and_mail(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs.value.side_effect = lambda key, default="": { + "api_key": "mykey", + "mail": "user@test.de", + }.get(key, default) + mock_qs_cls.return_value = mock_qs + + api_key, mail = logic.load_stored_credentials() + + self.assertEqual(api_key, "mykey") + self.assertEqual(mail, "user@test.de") + + def test_load_stored_credentials_returns_none_for_empty(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs.value.return_value = "" + mock_qs_cls.return_value = mock_qs + + api_key, mail = logic.load_stored_credentials() + + self.assertIsNone(api_key) + self.assertIsNone(mail) + + +# --------------------------------------------------------------------------- +# Tests: Login / Logout +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicLogin(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + return VlnApiLogic() + + def test_login_sets_client_and_saves_key(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + mock_client = MagicMock() + mock_client.api_key = "newtoken" + + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client): + with patch.object(logic, "save_api_key") as mock_save: + logic.login("user@test.de", "secret") + + mock_client.login.assert_called_once_with("user@test.de", "secret") + mock_save.assert_called_once_with("newtoken", "user@test.de") + + def test_login_raises_runtime_error_without_qgis_modules(self): + import sn_plan41.modules.vln_api_logic as module + logic = self._make() + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + with self.assertRaises(RuntimeError): + logic.login("user@test.de", "secret") + + def test_login_propagates_api_error(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + mock_client = MagicMock() + mock_client.login.side_effect = module.ApiError("Ungültige Zugangsdaten") + + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client): + with self.assertRaises(Exception): + logic.login("user@test.de", "wrong") + + def test_logout_clears_client(self): + logic = self._make() + logic._client = MagicMock() + logic.logout() + self.assertIsNone(logic._client) + + def test_is_authenticated_false_after_logout(self): + logic = self._make() + logic._client = None + self.assertFalse(logic.is_authenticated) + + def test_handle_session_expired_clears_key_and_client(self): + logic = self._make() + with patch.object(logic, "clear_api_key") as mock_clear: + with patch.object(logic, "logout") as mock_logout: + logic.handle_session_expired() + + mock_clear.assert_called_once() + mock_logout.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests: Verfahrensliste +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicVerfahren(unittest.TestCase): + + def _make_authenticated(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + + mock_client = MagicMock() + mock_client.is_authenticated = True + logic._client = mock_client + return logic, mock_client + + def test_get_verfahren_raises_when_not_authenticated(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + logic._client = None + + with self.assertRaises(Exception): + logic.get_verfahren() + + def test_get_verfahren_delegates_to_client(self): + logic, mock_client = self._make_authenticated() + mock_client.get_verfahren.return_value = [ + {"vkz": "27010", "name": "Alpha"}, + {"vkz": "27001", "name": "Beta"}, + ] + result = logic.get_verfahren() + mock_client.get_verfahren.assert_called_once() + self.assertEqual(len(result), 2) + + +# --------------------------------------------------------------------------- +# Tests: VKZ-Persistenz +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicVkz(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + return VlnApiLogic() + + def test_load_stored_vkz_reads_from_project(self): + logic = self._make() + mock_project = MagicMock() + mock_project.readEntry.return_value = ("27010", True) + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + vkz = logic.load_stored_vkz() + + self.assertEqual(vkz, "27010") + mock_project.readEntry.assert_called_once_with("vln_karten", "/vkz", "") + + def test_load_stored_vkz_returns_none_for_empty(self): + logic = self._make() + mock_project = MagicMock() + mock_project.readEntry.return_value = ("", True) + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + vkz = logic.load_stored_vkz() + + self.assertIsNone(vkz) + + def test_save_vkz_writes_to_project(self): + logic = self._make() + mock_project = MagicMock() + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + logic.save_vkz("27010") + + mock_project.writeEntry.assert_called_once_with("vln_karten", "/vkz", "27010") + + def test_save_vkz_does_nothing_for_none(self): + logic = self._make() + mock_project = MagicMock() + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + logic.save_vkz(None) + + mock_project.writeEntry.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests: Plan 41 laden (Stil-Anwendung) +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicLoadP41(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + + mock_client = MagicMock() + mock_client.is_authenticated = True + mock_client.load_layer_complete.return_value = { + "type": "FeatureCollection", + "features": [], + } + logic._client = mock_client + return logic + + def test_load_p41_raises_without_vln_karten(self): + import sn_plan41.modules.vln_api_logic as module + logic = self._make() + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + with self.assertRaises(RuntimeError): + logic.load_p41("27010") + + def test_load_p41_applies_styles_to_polygon_and_line_layers(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + # Zwei Mock-Layer: einer Polygon (2), einer Linie (1) + mock_layer_polygon = MagicMock() + mock_layer_polygon.geometryType.return_value = module._GEOM_POLYGON + + mock_layer_line = MagicMock() + mock_layer_line.geometryType.return_value = module._GEOM_LINE + + with patch( + "sn_plan41.modules.vln_api_logic.feature_collection_to_layers", + return_value=[mock_layer_polygon, mock_layer_line], + ): + with patch( + "sn_plan41.modules.vln_api_logic.apply_style_from_path" + ) as mock_style: + with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)): + with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"): + logic.load_p41("27010") + + # Prüfen, dass der richtige Stil für jeden Geometrietyp verwendet wurde + style_calls = [str(c) for c in mock_style.call_args_list] + polygon_style_used = any( + module.STYLE_FLAECHE in c for c in style_calls + ) + line_style_used = any( + module.STYLE_LINIE in c for c in style_calls + ) + self.assertTrue(polygon_style_used, "Flächen-Stil wurde nicht angewendet") + self.assertTrue(line_style_used, "Linien-Stil wurde nicht angewendet") + + def test_load_p41_no_style_for_point_layer(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + mock_layer_point = MagicMock() + mock_layer_point.geometryType.return_value = 0 # Point + + with patch( + "sn_plan41.modules.vln_api_logic.feature_collection_to_layers", + return_value=[mock_layer_point], + ): + with patch( + "sn_plan41.modules.vln_api_logic.apply_style_from_path" + ) as mock_style: + with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)): + with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"): + logic.load_p41("27010") + + mock_style.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests: Upload +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicUpload(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + + mock_client = MagicMock() + mock_client.is_authenticated = True + logic._client = mock_client + return logic + + def test_upload_returns_error_for_layer_without_api_path(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + mock_layer = MagicMock() + + with patch( + "sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None + ): + success, message = logic.upload_active_layer(mock_layer) + + self.assertFalse(success) + self.assertIn("nicht", message.lower()) + + def test_upload_returns_error_for_none_layer(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + with patch( + "sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None + ): + success, message = logic.upload_active_layer(None) + + self.assertFalse(success) + + def test_upload_returns_false_without_qgis_modules(self): + import sn_plan41.modules.vln_api_logic as module + logic = self._make() + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + success, message = logic.upload_active_layer(MagicMock()) + + self.assertFalse(success) + + def test_upload_calls_save_feature_collection(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + mock_layer = MagicMock() + mock_layer.isEditable.return_value = False + mock_layer.featureCount.return_value = 5 + + fc = {"type": "FeatureCollection", "features": []} + + with patch("sn_plan41.modules.vln_api_logic.layer_api_path", return_value="/maps/p41/27010"): + with patch("sn_plan41.modules.vln_api_logic.plugin_layers", return_value=[mock_layer]): + with patch("sn_plan41.modules.vln_api_logic.layers_to_feature_collection", return_value=fc): + success, message = logic.upload_active_layer(mock_layer) + + logic._client.save_feature_collection.assert_called_once_with("/maps/p41/27010", fc) + self.assertTrue(success) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/tab_a_ui.py b/ui/tab_a_ui.py index a224e77..b9270b4 100644 --- a/ui/tab_a_ui.py +++ b/ui/tab_a_ui.py @@ -9,7 +9,9 @@ from typing import Optional from sn_basis.functions.qt_wrapper import ( QWidget, QVBoxLayout, + QHBoxLayout, QLabel, + QLineEdit, QPushButton, QToolButton, QFileDialog, @@ -32,7 +34,8 @@ from sn_basis.modules.DataGrabber import DataGrabber from sn_basis.modules.Dateipruefer import Dateipruefer from sn_plan41.ui.tab_a_logic import TabALogic from sn_basis.modules.linkpruefer import Linkpruefer -from sn_basis.modules.stilpruefer import Stilpruefer +from sn_basis.modules.stilpruefer import Stilpruefer +from sn_plan41.modules.vln_api_logic import VlnApiLogic # Konstanten RAUMFILTER_VAR = "Raumfilter" @@ -78,7 +81,11 @@ class TabA(QWidget): # UI-Referenzen self._raumfilter_combo: Optional[QComboBox] = None - + + # VLN-API + self.vln_logic: Optional[VlnApiLogic] = None + self._vln_restoring: bool = False + self._build_ui() self._restore_state() @@ -92,14 +99,17 @@ class TabA(QWidget): self.pruefmanager = pruefmanager self.data_grabber = data_grabber self.logic = TabALogic( - pruefmanager=self.pruefmanager, - link_pruefer=Linkpruefer(), - stil_pruefer=Stilpruefer(), - ) - - # DataGrabber in die Logik injizieren + pruefmanager=self.pruefmanager, + link_pruefer=Linkpruefer(), + stil_pruefer=Stilpruefer(), + ) self.logic.data_grabber = self.data_grabber + # VLN-API-Logik initialisieren + self.vln_logic = VlnApiLogic(self.pruefmanager) + self._update_vln_ui_state() + self._restore_vln_state() + def _build_ui(self) -> None: """Erstellt die komplette UI-Hierarchie mit allen Gruppen.""" main_layout = QVBoxLayout() @@ -180,7 +190,84 @@ class TabA(QWidget): self.btn_pipeline.clicked.connect(self._on_load_fachdaten) main_layout.addWidget(self.btn_pipeline) - + # === VLN API / PLAN 41 === + self._vln_group_button = QToolButton() + self._vln_group_button.setText("VLN API / Plan 41") + self._vln_group_button.setCheckable(True) + self._vln_group_button.setChecked(False) + self._vln_group_button.setToolButtonStyle(ToolButtonTextBesideIcon) + self._vln_group_button.setArrowType(ArrowRight) + self._vln_group_button.setStyleSheet("font-weight: bold; margin-top: 8px;") + self._vln_group_button.toggled.connect(self._toggle_vln_group) + main_layout.addWidget(self._vln_group_button) + + self._vln_group_content = QWidget() + self._vln_group_content.setSizePolicy(SizePolicyPreferred, SizePolicyMaximum) + vln_layout = QVBoxLayout() + vln_layout.setSpacing(4) + vln_layout.setContentsMargins(10, 4, 4, 8) + + # Status + self._vln_status_label = QLabel("Nicht angemeldet") + self._vln_status_label.setStyleSheet("color: gray; font-style: italic;") + vln_layout.addWidget(self._vln_status_label) + + # E-Mail und Passwort + vln_layout.addWidget(QLabel("E-Mail:")) + self._vln_mail_edit = QLineEdit() + self._vln_mail_edit.setPlaceholderText("mail@beispiel.de") + vln_layout.addWidget(self._vln_mail_edit) + + vln_layout.addWidget(QLabel("Passwort:")) + self._vln_pass_edit = QLineEdit() + try: + self._vln_pass_edit.setEchoMode(QLineEdit.EchoMode.Password) + except AttributeError: + self._vln_pass_edit.setEchoMode(QLineEdit.Password) + vln_layout.addWidget(self._vln_pass_edit) + + # Login / Logout Buttons + btn_row = QHBoxLayout() + self._btn_vln_login = QPushButton("Anmelden") + self._btn_vln_login.clicked.connect(self._on_vln_login) + self._btn_vln_logout = QPushButton("Abmelden") + self._btn_vln_logout.clicked.connect(self._on_vln_logout) + btn_row.addWidget(self._btn_vln_login) + btn_row.addWidget(self._btn_vln_logout) + vln_layout.addLayout(btn_row) + + # Verfahren + vln_layout.addWidget(QLabel("Verfahren:")) + self._vln_verfahren_combo = QComboBox() + self._vln_verfahren_combo.setToolTip("Verfahren (Teilnehmergemeinschaft) wählen") + self._vln_verfahren_combo.setEnabled(False) + self._vln_verfahren_combo.currentIndexChanged.connect( + self._on_vln_verfahren_changed + ) + vln_layout.addWidget(self._vln_verfahren_combo) + + # Plan 41 laden + self._btn_p41_laden = QPushButton("Plan 41 (Wege- und Gewässerplan) laden") + self._btn_p41_laden.setToolTip( + "Lädt Flächen- und Linienlayer für das gewählte Verfahren vom Server" + ) + self._btn_p41_laden.setEnabled(False) + self._btn_p41_laden.clicked.connect(self._on_p41_laden) + vln_layout.addWidget(self._btn_p41_laden) + + # Upload + self._btn_upload = QPushButton("Aktiven Layer hochladen") + self._btn_upload.setToolTip( + "Lädt alle Plan-41-Layer des aktiven Verfahrens zum Server hoch" + ) + self._btn_upload.setEnabled(False) + self._btn_upload.clicked.connect(self._on_upload_active) + vln_layout.addWidget(self._btn_upload) + + self._vln_group_content.setLayout(vln_layout) + self._vln_group_content.setVisible(False) + main_layout.addWidget(self._vln_group_content) + main_layout.addStretch(1) self.setLayout(main_layout) @@ -206,7 +293,279 @@ class TabA(QWidget): pass # Raumfilter (schon im _build_ui behandelt) + def _restore_vln_state(self) -> None: + """Stellt die zuletzt gewählte VKZ aus der Projektdatei wieder her.""" + if self.vln_logic is None or not self.vln_logic.is_authenticated: + return + try: + self._populate_verfahren_combo(restore_vkz=True) + except Exception: + pass + # === UI CALLBACKS === + + def _toggle_vln_group(self, checked: bool) -> None: + self._vln_group_button.setArrowType(ArrowDown if checked else ArrowRight) + self._vln_group_content.setVisible(checked) + + def _update_vln_ui_state(self) -> None: + """Passt alle VLN-Controls an den aktuellen Authentifizierungsstatus an.""" + if self.vln_logic is None: + authenticated = False + available = False + else: + authenticated = self.vln_logic.is_authenticated + available = self.vln_logic.is_available() + + if not available: + self._vln_status_label.setText( + "Plugin 'vln_karten' nicht installiert" + ) + self._vln_status_label.setStyleSheet("color: red; font-style: italic;") + self._btn_vln_login.setEnabled(False) + elif authenticated: + mail = (self.vln_logic.mail or "API-Key") if self.vln_logic else "" + self._vln_status_label.setText("Angemeldet: %s" % mail) + self._vln_status_label.setStyleSheet( + "color: green; font-style: italic;" + ) + self._btn_vln_login.setEnabled(False) + else: + self._vln_status_label.setText("Nicht angemeldet") + self._vln_status_label.setStyleSheet("color: gray; font-style: italic;") + self._btn_vln_login.setEnabled(True) + + self._btn_vln_logout.setEnabled(authenticated) + self._vln_verfahren_combo.setEnabled(authenticated) + vkz_chosen = ( + authenticated + and self._vln_verfahren_combo.currentData() is not None + ) + self._btn_p41_laden.setEnabled(vkz_chosen) + self._btn_upload.setEnabled(authenticated) + + def _populate_verfahren_combo(self, restore_vkz: bool = False) -> None: + """Füllt die Verfahren-Combo und wählt ggf. die gespeicherte VKZ.""" + if self.vln_logic is None: + return + verfahren = self.vln_logic.get_verfahren() + self._vln_restoring = True + self._vln_verfahren_combo.clear() + self._vln_verfahren_combo.addItem("— Verfahren wählen —", None) + for v in verfahren: + label = ( + "%s — %s" % (v["vkz"], v["name"]) + if v["name"] + else v["vkz"] + ) + self._vln_verfahren_combo.addItem(label, v["vkz"]) + self._vln_restoring = False + + if restore_vkz: + stored_vkz = self.vln_logic.load_stored_vkz() + if stored_vkz: + idx = self._vln_verfahren_combo.findData(stored_vkz) + if idx >= 0: + self._vln_verfahren_combo.setCurrentIndex(idx) + + self._vln_verfahren_combo.setEnabled(True) + self._update_vln_ui_state() + + def _on_vln_login(self) -> None: + """Führt den API-Login durch.""" + if self.vln_logic is None: + return + mail = self._vln_mail_edit.text().strip() + password = self._vln_pass_edit.text() + if not mail or not password: + QMessageBox.warning( + self, + "Anmeldung", + "Bitte E-Mail-Adresse und Passwort eingeben.", + ) + return + try: + self.vln_logic.login(mail, password) + except Exception as exc: + QMessageBox.warning(self, "Anmeldung fehlgeschlagen", str(exc)) + return + + self._vln_pass_edit.clear() + try: + self._populate_verfahren_combo(restore_vkz=True) + except Exception as exc: + QMessageBox.warning( + self, + "Verfahrensliste", + "Anmeldung erfolgreich, aber Verfahrensliste konnte nicht " + "geladen werden: %s" % exc, + ) + self._update_vln_ui_state() + + def _on_vln_logout(self) -> None: + """Meldet den Benutzer lokal ab.""" + if self.vln_logic is None: + return + self.vln_logic.logout() + self._vln_restoring = True + self._vln_verfahren_combo.clear() + self._vln_verfahren_combo.setEnabled(False) + self._vln_restoring = False + self._update_vln_ui_state() + + def _on_vln_verfahren_changed(self, _index: int) -> None: + """Persistiert die gewählte VKZ.""" + if self._vln_restoring or self.vln_logic is None: + return + vkz = self._vln_verfahren_combo.currentData() + self.vln_logic.save_vkz(vkz) + self._update_vln_ui_state() + + def _on_p41_laden(self) -> None: + """Lädt den Plan-41-Datensatz für die gewählte VKZ.""" + if self.vln_logic is None: + return + vkz = self._vln_verfahren_combo.currentData() + if not vkz: + QMessageBox.warning( + self, "Plan 41 laden", "Bitte zuerst ein Verfahren wählen." + ) + return + + # Bereits geladene Layer prüfen + existing = self.vln_logic.get_p41_layers(vkz) + if existing: + try: + answer = QMessageBox.question( + self, + "Layer ersetzen", + "Plan 41 für VKZ %s ist bereits geladen.\n" + "Beim Neuladen werden die bestehenden Layer ersetzt. Fortfahren?" + % vkz, + ) + # Qt6: StandardButton.Yes, Qt5: QMessageBox.Yes + yes = getattr( + getattr(QMessageBox, "StandardButton", QMessageBox), + "Yes", + QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None, + ) + if answer != yes: + return + except Exception: + pass + self.vln_logic.remove_p41_layers(vkz) + + try: + layers = self.vln_logic.load_p41(vkz) + except Exception as exc: + # AuthError: Sitzung abgelaufen + try: + from sn_plan41.modules.vln_api_client import AuthError as _AuthError + if isinstance(exc, _AuthError): + self.vln_logic.handle_session_expired() + self._on_vln_logout() + QMessageBox.warning( + self, + "Sitzung abgelaufen", + "Der API-Key ist nicht mehr gültig — bitte neu anmelden.", + ) + return + except ImportError: + pass + QMessageBox.warning(self, "Laden fehlgeschlagen", str(exc)) + return + + total = sum(l.featureCount() for l in layers) + detail = ", ".join( + "%s: %d" % (l.name(), l.featureCount()) for l in layers + ) + if total == 0: + QMessageBox.warning( + self, + "Plan 41 laden", + "Keine Objekte vorhanden — ein leerer Layer wurde angelegt.", + ) + else: + try: + from sn_basis.functions.message_wrapper import info + info( + "Plan 41 geladen", + "%d Layer geladen (%s)." % (len(layers), detail), + ) + except Exception: + pass + + def _on_upload_active(self) -> None: + """Lädt den aktiven Layer (und Geschwister-Layer) zum Server hoch.""" + if self.vln_logic is None: + return + + # Zusammenfassung für Bestaetigungs-Dialog ermitteln + try: + from sn_basis.functions.qgisui_wrapper import iface as _iface + active_layer = _iface.activeLayer() if _iface else None + except Exception: + active_layer = None + + summary = self.vln_logic.upload_summary(active_layer) + if summary is None: + QMessageBox.warning( + self, + "Hochladen nicht möglich", + "Der aktive Layer wurde nicht über 'vln_karten' geladen.", + ) + return + + try: + answer = QMessageBox.question( + self, + "Zum Server hochladen", + "%s für VKZ %s übertragen?\n\n%s\n\n" + "%d Objekte; der bisherige Bestand dieser VKZ wird ersetzt." + % ( + summary["dataset_label"], + summary["vkz"], + summary["listing"], + summary["total"], + ), + ) + yes = getattr( + getattr(QMessageBox, "StandardButton", QMessageBox), + "Yes", + QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None, + ) + if answer != yes: + return + except Exception: + return + + try: + success, message = self.vln_logic.upload_active_layer(active_layer) + except Exception as exc: + try: + from sn_plan41.modules.vln_api_client import AuthError as _AuthError + if isinstance(exc, _AuthError): + self.vln_logic.handle_session_expired() + self._on_vln_logout() + QMessageBox.warning( + self, + "Sitzung abgelaufen", + "Der API-Key ist nicht mehr gültig — bitte neu anmelden.", + ) + return + except ImportError: + pass + QMessageBox.warning(self, "Hochladen fehlgeschlagen", str(exc)) + return + + if success: + try: + from sn_basis.functions.message_wrapper import info + info("Upload erfolgreich", message) + except Exception: + pass + else: + QMessageBox.warning(self, "Hochladen fehlgeschlagen", message) def _toggle_group(self, checked: bool) -> None: """Zeigt/verbirgt Verfahrens-DB-Gruppe.""" self.group_button.setArrowType(ArrowDown if checked else ArrowRight) -- 2.54.0 From 488d4bd125d41528dbb1cbb53f06ed81ac36d171 Mon Sep 17 00:00:00 2001 From: Daniel Helbig Date: Fri, 19 Jun 2026 11:53:58 +0200 Subject: [PATCH 07/13] changelog.txt aktualisiert --- changelog.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog.txt b/changelog.txt index d80bedb..bf08823 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,4 @@ +- VLN-API zum Laden der Plan41-Maßnahmen integriert --- Version 26.4.3-unstable: --- -- 2.54.0 From 303f296af10d3e13ae81eb9c0b44281aec8fbece Mon Sep 17 00:00:00 2001 From: release-bot Date: Fri, 19 Jun 2026 09:54:37 +0000 Subject: [PATCH 08/13] Release 26.6.1-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index bf08823..28b2aaa 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,5 @@ +--- +Version 26.6.1-unstable: - VLN-API zum Laden der Plan41-Maßnahmen integriert --- Version 26.4.3-unstable: diff --git a/metadata.txt b/metadata.txt index f5e877c..d6c4a70 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.4.3-unstable +version=26.6.1-unstable name=LNO Sachsen | Plan41 description=Plugin zum Erzeugen der Pläne nach §38 und §41 author=Daniel Helbig, Michael Otto -- 2.54.0 From 04ab4867f93803b2a680c036fcea4119943f41fc Mon Sep 17 00:00:00 2001 From: Daniel Helbig Date: Wed, 1 Jul 2026 15:37:59 +0200 Subject: [PATCH 09/13] =?UTF-8?q?QML-Dateien=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/GIS-P41_NSG.qml | 866 +++++++ .../GIS_P41_Biotope_Offenland_1994-2008_f.qml | 1721 ++++++++++++++ .../GIS_P41_Biotope_Offenland_ab_2010_f.qml | 2026 +++++++++++++++++ assets/GIS_P41_Habitate_f.qml | 871 +++++++ assets/GIS_p41_Biotope_gesetzl_gesch_f.qml | 1060 +++++++++ 5 files changed, 6544 insertions(+) create mode 100644 assets/GIS-P41_NSG.qml create mode 100644 assets/GIS_P41_Biotope_Offenland_1994-2008_f.qml create mode 100644 assets/GIS_P41_Biotope_Offenland_ab_2010_f.qml create mode 100644 assets/GIS_P41_Habitate_f.qml create mode 100644 assets/GIS_p41_Biotope_gesetzl_gesch_f.qml diff --git a/assets/GIS-P41_NSG.qml b/assets/GIS-P41_NSG.qml new file mode 100644 index 0000000..1effde4 --- /dev/null +++ b/assets/GIS-P41_NSG.qml @@ -0,0 +1,866 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "NAME" + + 2 + diff --git a/assets/GIS_P41_Biotope_Offenland_1994-2008_f.qml b/assets/GIS_P41_Biotope_Offenland_1994-2008_f.qml new file mode 100644 index 0000000..df1ef67 --- /dev/null +++ b/assets/GIS_P41_Biotope_Offenland_1994-2008_f.qml @@ -0,0 +1,1721 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 2 + diff --git a/assets/GIS_P41_Biotope_Offenland_ab_2010_f.qml b/assets/GIS_P41_Biotope_Offenland_ab_2010_f.qml new file mode 100644 index 0000000..4092b80 --- /dev/null +++ b/assets/GIS_P41_Biotope_Offenland_ab_2010_f.qml @@ -0,0 +1,2026 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 2 + diff --git a/assets/GIS_P41_Habitate_f.qml b/assets/GIS_P41_Habitate_f.qml new file mode 100644 index 0000000..3d1e9b9 --- /dev/null +++ b/assets/GIS_P41_Habitate_f.qml @@ -0,0 +1,871 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "ART_DEUTSCH" + + 2 + diff --git a/assets/GIS_p41_Biotope_gesetzl_gesch_f.qml b/assets/GIS_p41_Biotope_gesetzl_gesch_f.qml new file mode 100644 index 0000000..3ce073a --- /dev/null +++ b/assets/GIS_p41_Biotope_gesetzl_gesch_f.qml @@ -0,0 +1,1060 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "BTP26_LID" + + 2 + -- 2.54.0 From 477e4f6f5c4b5e3d406b2bd52da27193e20f8c76 Mon Sep 17 00:00:00 2001 From: Daniel Helbig Date: Wed, 1 Jul 2026 15:38:20 +0200 Subject: [PATCH 10/13] =?UTF-8?q?QML-Dateien=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/QGIS_Biotope_Offenland_1994-2008_l.qml | 1594 +++++++++++++++++ assets/QGIS_Biotope_Offenland_ab_2010_l.qml | 1374 ++++++++++++++ assets/QGIS_P41_Auen_und_Moorkulisse.qml | 516 ++++++ 3 files changed, 3484 insertions(+) create mode 100644 assets/QGIS_Biotope_Offenland_1994-2008_l.qml create mode 100644 assets/QGIS_Biotope_Offenland_ab_2010_l.qml create mode 100644 assets/QGIS_P41_Auen_und_Moorkulisse.qml diff --git a/assets/QGIS_Biotope_Offenland_1994-2008_l.qml b/assets/QGIS_Biotope_Offenland_1994-2008_l.qml new file mode 100644 index 0000000..a6f4e92 --- /dev/null +++ b/assets/QGIS_Biotope_Offenland_1994-2008_l.qml @@ -0,0 +1,1594 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 1 + diff --git a/assets/QGIS_Biotope_Offenland_ab_2010_l.qml b/assets/QGIS_Biotope_Offenland_ab_2010_l.qml new file mode 100644 index 0000000..ba9b969 --- /dev/null +++ b/assets/QGIS_Biotope_Offenland_ab_2010_l.qml @@ -0,0 +1,1374 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 1 + diff --git a/assets/QGIS_P41_Auen_und_Moorkulisse.qml b/assets/QGIS_P41_Auen_und_Moorkulisse.qml new file mode 100644 index 0000000..1294433 --- /dev/null +++ b/assets/QGIS_P41_Auen_und_Moorkulisse.qml @@ -0,0 +1,516 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "FB_BN_KAT" + + 2 + -- 2.54.0 From dad11bd249d8c36d9acac0e0c5fc29c73a8fdaab Mon Sep 17 00:00:00 2001 From: Daniel Helbig Date: Wed, 1 Jul 2026 15:39:43 +0200 Subject: [PATCH 11/13] =?UTF-8?q?Biotope=20und=20Schutzgebiete=20in=20Link?= =?UTF-8?q?liste=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/Linkliste.xlsx | Bin 12034 -> 14128 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/Linkliste.xlsx b/assets/Linkliste.xlsx index 3a22bc812f8ee4f4be4eedc4a657203b5bb30e51..7516f45e28f89a32eb2e150b489dba80cd27afaa 100644 GIT binary patch delta 7747 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 delta 5634 zcmZ9QbyU>fqQ!@fp}U)5hLRdU8l*c17^Fd@kx&rfOE(A%phGDwNJ>g0-AH$*fTE;Q z&)>Ui-TUr4|L(ok`Qx0u_Gj&Ck1(t1MO@XvLGSABLvD1tBDjcob25`XQkqK-_rQibA(Z6|Sql&ilk<)r3`&1e z-c&Mcx*dW0_f`YH@9ZxXZ;!?Cjuys{%blb=Qi1qDGK8DkrTj=!3W&)cZWvC`$pZR` z?aaWfcU{J}V8NX>ERa(F2chCP89gT)fmpd@YHAyEbrT9Kt@<Vkzu1?bTf; z9|7eDm6+*3p>4Y^k<5W|!BQcCnEY(I_f=rT0ZGNkDwZjQpS7t6WSEd`H$n7tB7~!4 zB!9tBU1gVPd3C+L*5HJ(ll58%8(7h>S(GkwK&K)FM=%ru1rB8IYkt_?@M34((zTMp z&ZsPfv$~o2;8$!PyGUG5h@x)w3T%@R1?l)v52C_Q+vcGnlm#^PngQPp3-scne#A3UPeHU=iR2jxeCO3wjxoxI!=-}QEk9ZF+_PDwF3bmz zRn3-M+_@ih!m0f*WhI7y&}(yVT#aTfK6QG0&Najtk#|_&3l1al_CTVjsp{z48tLq$ zR}wv_nm{@t3JD^VhGehC{<~b6P*E2$$JFWK`LZsherUw>4|H&n-cS3Qy`LRPdRn#_Hv-$EdL-I-+ejcft>~a< zP|GCYd}K9(KR%XjU$Wb!DD*}48hhofsCy>;*?+veTirR=Ba!pFXd-nCdXXvu37drx%#6%jdU9`zrVVAEcedmrZ;!#@`Sz06CvQn|&#My(`{P3DL{fYg;cU=g3oc`7(cIk1m@+mM>q(-+2XY_oC)SAj}iV z@#vOxZ$SQBCdF+bDGp<;mPvKCb>4sgpkdTI0;`(t6k6{7?zY`Newx{m9_9QjmZaUP zguvotyW0M9c^Vae0mTjNlJ5fs_m*u6KWU^4^TiK}_NDN+ele{b*V1Xf=YwZckUgBB z5w`EN6q7vxu=TlUK+(SVX0^Ib3l#=#&%l~E0!-wxHrD(lSaj+jqIdDr$rF6@K4mo3 zCOg)96!gxP!5{gX49yGEVNG!d?3Sa;K=k>zv_H=`$N6?Kd+U{!B@;rv@_cW?hTPAr z17`*DzHKc_BB6KM3kJS&(y4NC)d}3Gq2!xVsXu@xt^wnph#=9G1r!%4r#$de$^oJ- zAPVXPWfV&}5dB!xpL}|6jOd^0*y=SypVzH?-|LHR8acHF@1+nvAFu70OkWP;)Ys!{ zD2~>C-p&>N6`BCqN@2N4WCiRFp77afN`gFxTQe)IXtlYL_t(F(>05EhV4+UF9T}Qb zCzqT#?qYWKFWRYnPOjp8p&?t7i8(Y3zz9=c4*3hAmF*3s3il)j71i5aF%J$v{aVZ? ztNV1ixQgqfSexB%XkoD~+$XD00z3mMl7e`XPNnqV&cF%|d|U^AO6b!E-k6IPrJ>4Q zh2aAPWNn0ZB9t`7lPybOtjy5IW_UFvWyGm4Nqb9KGjZ-eih~H4ln)Nq#wjyE;L+wZ zP*zBT&f@59?`c!fu(q?+yb6jff`#71=)_i6Z58wmnZ^5uBt_vfXwv43%oJd(dt&fm zoAFjp^Wz{0D3wa-ecsFPvhm;-eLuqAupetzyfUUZDLbd63U=dVe_AAmR91Z+@jgaa z>24caDO6Q`)P+omPsX!v5g!RmakpPOJ$W#)fZtsNQ%nZez$SOq4!)?xQ`q6*6H;MO z;bPSrm9Egg7=}bn^XDkh9lQOL5jq@pzi$CGC`bY_e~{m>AC# zJ&kG^*f5RXoIa_h#Qf!H8;6bm(UE%+hoe*BEiwV(8JB?U37iS?p(b<5z{QLd`gmo#d`YJ9BTH*}Vf0J|gRn*MPL23D$3z~(Ysleil z&dba}wIWxJCFW6CJD?bw92E+SqIxg#?(nORznMRq2W@?>APf5WM4b>b_^j?(0-nLC3|Mfjltj=;<%J`|@mgF>8BR zf`yV7ZB_n(wx7m=$G*P0>8Ax#lCQ?&r!s=(1Jq@+k+T!|+)@cmdFXo(=H>diKb$sjLxsG^MgKS#`)gp^@w8%nTBkgc zBQx(lhJWWydn#5N{g7#9A7{)=DgGhzS1n@MUS1*XP3Eat~hJ%G(I)KM;BIOyHqgPn2x2 z_9wZrH&Tq*J~ScIW0>G%P9pL8bY~fP=dh2kRWc)L6lS|_ayx&CQ z@m`qsHTbb=rvO(|`FC2Q3xTB9V-Hs=M_-n|t%HoOZ;Zb+kUtj|%#ybmH$4wgj35xE>zd3wd3#g8r%9T0Q!1T;Qikj;i@z zQbiDeK*awdRUh8~H%FiU+lNP z8=0sQi>BgI;h6;TB^K5I?g}|!?ZQWy+)_@B$eIPr5Oa>wMXw zE2E0n0t5G0>%^rgOTY2^t}hw19d;4+=M!)b_JDA>tfA`p;Gz=Ag9eHEWI|SYh2wC1 zT3yz1BR(ec0ng;{V;5!=PhNkU+il{4%Y zRX`ym2&%;k^F5^cL@A|04uZ4K%i;ev-`>R+ky#x|y|CUR7fr?<7*16ZDPjUGAOO2!!7;}r=8uJ$O;u>TK89K+5=byLzp7|8|qhOkWfm2H}&J`vk zYMv|Bhcf}gx5pEulQVHD|1(6+JpQ>IW`xbzHx2aHVRLMGS4+5c-f3yF@5@(huNT{v zTr$g3q%VE8Z!<5A-DzDizdOAXD5ziWsUz;JC1g#TE}VESxM=t*;|RABoJk&Wa_KA; zbZ*oRv3hWlQ-{|Brhw#4)MN4N)qWXeJ_yY|cB`tg>SMSmlMvNc4X+xQ4q?h(FuSMm z&I0gaZt|DM;*McDNoZbJbI~S-Y96teq|Ea)XI|}jcTphl*2Y@eM3GBJXB88U8nG=)mml-5u!MU9o;C9 zV0PsvrDAlpcK-d{=$#blN_~e#%UAB$6+owTspXk({nC%qMAkp1)-O8v zjFru6>)*zItjLI7S7NYw!gnGJgm+wqCJYHR!j4JR{f!{4zex=)s6n9ndn}N?78a@u z3rr%gU^%w%H-k5S14sozZNO=OI>hKd$!mX5{ex{k)kwQncM4L?%%!t~4R2VL)Ci5l z2+dHXFr_Lz>G$|05_n*LB=Detb4g$}^{6m0uo^@fEy~(KPtx)I;7zu)sD7r#S07Sj z?qLYNV~_HrkEiF&?Tg9+VrudoLxu4m&~fesa9{DU{+UB)q8t^E!(3)8a7{05W}^@(ZOHTcFe!5AFFur-@X?lcX7FYa>D zxI|lu@ZIz1pE@(*O#lO)8uR$+je_od)Ce}8!az0rb;Wfn?(yy5Ho~5o2X+F};*fgC z7YBCP-$qU{#XtN_Ss*Bvqmkz@BDp`fBeA#HRcNS9=s^iN<3i@LBBcb%){`F?aL)0r zF7YiX*t2>K`)W-I&9EWK$;!S&IOi)chS53kW9wkU5$OWq0G_7JP$%eTp4b<+6xlpCBET{%oE$)JM}6W)bS7(;|l>nVswm znUv3lGI)vh_>y?+d*%}!c*l*0^e?i0JkyZeMNWVp3`vS7s7-?cWtW!wqDDlKw&yl@ zCCg;Zmf%}-EYxWJ(%36AWd5CoABn2)TLh_@kK^VJkc;J3A?8G|I$^E)*y+V_N@up6 zgq?k*UJdi}n_{Z-U)R}PQ}X*A*)Ws4KC_m@c=;gq&{(ys{astDk`3Gw0_2imWPpZy z=-lqTf%O`}$AcPbx15QYYYUR!Db(mvjS%>(QW*z&Du2o@MZj8tMJ*s5b(TDd)4V%nOh2ci+#^XKNk^|wQ7 zd!Lqj&6VMF=IVo{)bYakUgcnjN?U3>aKK-$S|K~k{SHfWB^w#!X&@u24{szz42FGC zl8}5Mg3%6{C`*U+C-i|kGGFP$m8X3w=nh{;4oIK0*4oHO1%}e*yCYuA#D3kL5rStf z57E;Fv4$nSpLMX%AK65^{gQTq1`&G6^$M?}%;wO1(d#XbjMCnjOhEC6k3w%UWF;l# zGr-$PjF9rH5Q?O}kZ-uS82*chwSI{|1M_;IGQoG|?} zYM1^7u|^46JRS%t;&B0~c2NMA@Luh0TW!a-@PXQ7vz3U7ElelN@E)vCmLZiUbJp{6 zG6I;lopi68u!pN@>1#vO$ky!MF5o_SZvF;hHH*C8t(hBoXCGbsjQW;jW`ewgR8!%j z`d8^(KB%hJ0X?G5NnUag=OZHKG!ta+nDaC~RRJnUWo{Fj@5rRgPDS4q`g-AZ-uZbyrvu>R99xHY240A>NxsS87`($@L6ghXQ54fKt^(m*Dl3*V4l)-fyex`MkTa7s;+&P=!X_$?f{#@U$ zgnp0Pa(}C9nhID*o_(!pK8F_-9`IC=r|g}4Ke`RhT$8?g*MHAc%96AAp6gex8Ju{* zDsjq^4my8Tg}%kA#o$lIBzWw8ykhY8g`8_M1qCpGANquGg%}z8pN0G$7>cgk46$+x9y!BK9m@xxo-Z|uE4nPfhmPenFYFvUpmAj>xl@Cld(rrl)F%E z?}Km!;)dfB8q*{gSp25K8YmL>)Q9v}cb_`BYYB_L!h?&3`apR+7%fE)lbU-3wph!t znIdh0FxP5rp Date: Wed, 1 Jul 2026 15:43:40 +0200 Subject: [PATCH 12/13] =?UTF-8?q?qml=20Biotop-Linien=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/Linkliste.xlsx | Bin 14128 -> 14141 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/Linkliste.xlsx b/assets/Linkliste.xlsx index 7516f45e28f89a32eb2e150b489dba80cd27afaa..50dfb1f33c64a0abc773600e95836300212657bc 100644 GIT binary patch delta 4458 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 delta 4441 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 -- 2.54.0 From a4f50d6eb54a19644c9f5c871d39a95f4f3c3194 Mon Sep 17 00:00:00 2001 From: Michael Otto Date: Thu, 2 Jul 2026 11:08:25 +0200 Subject: [PATCH 13/13] auf neuen Release-Workflow umgestellt --- metadata.txt => .gitea/templates/metadata.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename metadata.txt => .gitea/templates/metadata.template (95%) diff --git a/metadata.txt b/.gitea/templates/metadata.template similarity index 95% rename from metadata.txt rename to .gitea/templates/metadata.template index d6c4a70..983a109 100644 --- a/metadata.txt +++ b/.gitea/templates/metadata.template @@ -1,5 +1,5 @@ [general] -version=26.6.1-unstable +version= name=LNO Sachsen | Plan41 description=Plugin zum Erzeugen der Pläne nach §38 und §41 author=Daniel Helbig, Michael Otto -- 2.54.0