From 9caf0e3c864ec12c9998e9943c23a13688e8f229 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 20 Apr 2026 14:33:13 +0200 Subject: [PATCH 01/22] =?UTF-8?q?pdock-dockstrings=20erg=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 14 ++++++++++++++ functions/verfahrensgebiet_alkis.py | 29 ++++++++++++++++++++++++++++- main.py | 7 +++++++ ui/dockwidget.py | 20 ++++++++++++++++++++ ui/tabs/working_tab.py | 15 +++++++++++++++ 5 files changed, 84 insertions(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index 7510e49..109fe3a 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,17 @@ +"""sn_verfahrensgebiet – QGIS-Plugin für Verfahrensgebiets-Management. +Verwaltet die Abgrenzung und Verwaltung von Verfahrensgebieten. +""" + + def classFactory(iface): + """Factory-Funktion für QGIS. + Wird von QGIS aufgerufen, um die Plugin-Instanz zu erstellen. + + Args: + iface: QGIS-Schnittstellenobjekt + + Returns: + Verfahrensgebiet: Instanziiertes Plugin + """ from .main import Verfahrensgebiet return Verfahrensgebiet(iface) diff --git a/functions/verfahrensgebiet_alkis.py b/functions/verfahrensgebiet_alkis.py index ae7df77..a7f4acf 100644 --- a/functions/verfahrensgebiet_alkis.py +++ b/functions/verfahrensgebiet_alkis.py @@ -1,4 +1,7 @@ -#sn_verfahrensgebiet/functions/verfahrensgebiet_alkis +"""sn_verfahrensgebiet/functions/verfahrensgebiet_alkis.py – ALKIS-Integration. +Lädt Verfahrensgebiete aus dem ALKIS-WFS-Dienst. +""" + from enum import Enum from sn_basis.functions import ( QgsVectorLayer, QgsProject, QgsFeature, QgsField, QgsGeometry @@ -11,6 +14,14 @@ alkis_NAS_url = "https://geodienste.sachsen.de/aaa/public_alkis/nas/wfs" typename = "adv:AX_BauRaumOderBodenordnungsrecht" class LoadStatus(Enum): + """Status eines Layer-Ladevorgangs. + + Attributes: + NONE: Nichts geladen + FIRST: Erstmalig geladen + RELOAD: Neu geladen + KEEP: Vorhandener Layer behalten + """ NONE = "none" # nichts geladen FIRST = "first" # erstmalig geladen RELOAD = "reload" # neu geladen @@ -18,6 +29,14 @@ class LoadStatus(Enum): def _check_existing_layer(tab_widget): + """Prüft, ob ein Verfahrensgebiet-Layer bereits existiert. + + Args: + tab_widget: Parent-Widget für Dialoge + + Returns: + Tuple[Optional[QgsVectorLayer], LoadStatus]: Layer und Status + """ project = QgsProject.instance() existing_layers = project.mapLayersByName("Verfahrensgebiet") if not existing_layers: @@ -40,6 +59,14 @@ def _check_existing_layer(tab_widget): def verfahrensgebiet_alkis(tab_widget): + """Lädt Verfahrensgebiet aus ALKIS-WFS basierend auf Verfahrensnummer. + + Args: + tab_widget: Parent-Widget für Dialoge und Meldungen + + Returns: + Tuple[Optional[QgsVectorLayer], LoadStatus]: Geladener Layer und Status + """ verfahrensnummer = get_variable("verfahrensnummer") if not verfahrensnummer: QMessageBox.critical(tab_widget, "Fehler", diff --git a/main.py b/main.py index a573ceb..1b55ccd 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,7 @@ +"""sn_verfahrensgebiet/main.py – Einstiegspunkt des sn_verfahrensgebiet Plugins. +Orchestriert die Verwaltung von Verfahrensgebiets-Layern und deren Eigenschaften. +""" + from qgis.utils import plugins from sn_basis.ui.dockmanager import DockManager from .ui.dockwidget import DockWidget @@ -5,6 +9,9 @@ from sn_basis.modules.DataGrabber import DataGrabber from sn_basis.modules.Pruefmanager import Pruefmanager class Verfahrensgebiet: + """Einstiegspunkt des sn_verfahrensgebiet-Plugins. + Verwaltet die DockWidget-Initialisierung und das Verfahrensgebiets-Management. + """ def __init__(self, iface): self.iface = iface self.pruefmanager = Pruefmanager(ui_modus="qgis") diff --git a/ui/dockwidget.py b/ui/dockwidget.py index 7e09d45..acd2945 100644 --- a/ui/dockwidget.py +++ b/ui/dockwidget.py @@ -1,3 +1,7 @@ +"""sn_verfahrensgebiet/ui/dockwidget.py – Haupt-DockWidget für sn_verfahrensgebiet. +Verwaltet die Tabs für Verfahrensgebiet-Bearbeitung, Druck und Einstellungen. +""" + from sn_basis.ui.tabs.settings_tab import SettingsTab from sn_basis.ui.tabs.print_tab import PrintTab from sn_verfahrensgebiet.ui.tabs.working_tab import WorkingTab @@ -7,9 +11,25 @@ from sn_basis.functions.message_wrapper import error class DockWidget(BaseDockWidget): + """Haupt-DockWidget für sn_verfahrensgebiet mit Tab-Verwaltung. + + Attributes: + tabs: Liste von Tab-Klassen (WorkingTab, PrintTab, SettingsTab) + pruefmanager: Prüfmanager-Instanz für Validierung + data_grabber: DataGrabber-Instanz für Fachdatenabruf + """ + tabs = [WorkingTab, 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) self.pruefmanager = pruefmanager diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 954f009..b0519db 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -1,3 +1,7 @@ +"""sn_verfahrensgebiet/ui/tabs/working_tab.py – Bearbeitungs-Tab. +Verwaltet die Verfahrensgebiet-Abgrenzung und ALKIS-Integration. +""" + from sn_basis.functions.qt_wrapper import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QMessageBox from sn_basis.functions.qt_wrapper import Qt from sn_basis.functions import Qgis, QgsProject #QgsMessageLog @@ -11,9 +15,20 @@ from sn_basis.functions.message_wrapper import success, info, warning, error from sn_basis.functions.ly_style_wrapper import apply_style class WorkingTab(QWidget): + """Tab für die Verfahrensgebiet-Bearbeitung und Verwaltung. + + Attributes: + tab_title: Anzeigetitel des Tabs ("Bearbeitung") + """ + tab_title = "Bearbeitung" def __init__(self, parent=None): + """Initialisiert den Working-Tab mit Verfahrensgebiet-Verwaltung. + + Args: + parent: Übergeordnetes Widget + """ super().__init__(parent) layout = QVBoxLayout() From 72c9d6fa15b8c0cbb81558ba50464684564eed03 Mon Sep 17 00:00:00 2001 From: daniel Date: Mon, 20 Apr 2026 15:36:18 +0200 Subject: [PATCH 02/22] =?UTF-8?q?tests=20=C3=BCberarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/__init__.py | 1 + tests/test_dockwidget.py | 86 +++++++++++++ tests/test_main.py | 121 ++++++++++++++++++ tests/test_verfahrensgebiet_alkis.py | 184 +++++++++++++++++++++++++++ tests/test_working_tab.py | 156 +++++++++++++++++++++++ 5 files changed, 548 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_dockwidget.py create mode 100644 tests/test_main.py create mode 100644 tests/test_verfahrensgebiet_alkis.py create mode 100644 tests/test_working_tab.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..4721e18 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests für das Plugin sn_verfahrensgebiet.""" diff --git a/tests/test_dockwidget.py b/tests/test_dockwidget.py new file mode 100644 index 0000000..da413e9 --- /dev/null +++ b/tests/test_dockwidget.py @@ -0,0 +1,86 @@ +import sys +import types +import unittest +from unittest.mock import patch + +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.ui.dockwidget import DockWidget + + +class _DummyTabWidget: + def __init__(self): + self.tabs = [] + + def addTab(self, widget, title): + self.tabs.append((widget, title)) + + +class _TabWithServices: + tab_title = "WithServices" + + def __init__(self, _parent): + self.services = None + + def set_services(self, pruefmanager=None, data_grabber=None): + self.services = (pruefmanager, data_grabber) + + +class _TabWithoutServices: + tab_title = "WithoutServices" + + def __init__(self, _parent): + pass + + +class _TabRaises: + tab_title = "Broken" + + def __init__(self, _parent): + raise RuntimeError("boom") + + +class TestDockWidget(unittest.TestCase): + + @patch("sn_verfahrensgebiet.ui.dockwidget.QTabWidget", _DummyTabWidget) + def test_init_tabs_with_services_injects_available_services(self): + obj = DockWidget.__new__(DockWidget) + obj.tabs = [_TabWithServices, _TabWithoutServices] + obj.pruefmanager = object() + obj.data_grabber = object() + obj._set_widget_calls = [] + obj.setWidget = lambda value: obj._set_widget_calls.append(value) + + DockWidget._init_tabs_with_services(obj) + + self.assertEqual(len(obj._set_widget_calls), 2) + tab_widget = obj._set_widget_calls[-1] + self.assertIsInstance(tab_widget, _DummyTabWidget) + self.assertEqual(len(tab_widget.tabs), 2) + + first_tab = tab_widget.tabs[0][0] + self.assertEqual(first_tab.services, (obj.pruefmanager, obj.data_grabber)) + + @patch("sn_verfahrensgebiet.ui.dockwidget.error") + @patch("sn_verfahrensgebiet.ui.dockwidget.QTabWidget", _DummyTabWidget) + def test_init_tabs_with_services_reports_errors(self, mock_error): + obj = DockWidget.__new__(DockWidget) + obj.tabs = [_TabRaises] + obj.pruefmanager = object() + obj.data_grabber = object() + obj.setWidget = lambda _value: None + + DockWidget._init_tabs_with_services(obj) + + mock_error.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..0f029f3 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,121 @@ +import sys +import types +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.main import Verfahrensgebiet + + +class _DummyIface: + def __init__(self): + self.removed = [] + + def mainWindow(self): + return object() + + def removeDockWidget(self, dock): + self.removed.append(dock) + + +class _DummyDock: + def __init__(self): + self.object_name = None + self.action = None + self.deleted = False + + def setObjectName(self, name): + self.object_name = name + + def deleteLater(self): + self.deleted = True + + +class TestPluginMain(unittest.TestCase): + + @patch("sn_verfahrensgebiet.main.DataGrabber") + @patch("sn_verfahrensgebiet.main.Pruefmanager") + def test_init_sets_services_and_names(self, mock_pruefmanager, mock_data_grabber): + pm = object() + dg = object() + mock_pruefmanager.return_value = pm + mock_data_grabber.return_value = dg + + plugin = Verfahrensgebiet(_DummyIface()) + + self.assertIs(plugin.pruefmanager, pm) + self.assertIs(plugin.data_grabber, dg) + self.assertEqual(plugin.plugin_name, "Verfahrensgebiet") + self.assertEqual(plugin.dock_name, "sn_dock_verfahrensgebiet") + + @patch("sn_verfahrensgebiet.main.plugins", new_callable=dict) + def test_initGui_registers_action_when_basis_ui_exists(self, mock_plugins): + iface = _DummyIface() + plugin = Verfahrensgebiet(iface) + + ui = SimpleNamespace( + add_action=Mock(return_value="action_obj"), + finalize_menu_and_toolbar=Mock(), + ) + mock_plugins["sn_basis"] = SimpleNamespace(ui=ui) + + plugin.initGui() + + self.assertEqual(plugin.action, "action_obj") + ui.add_action.assert_called_once() + ui.finalize_menu_and_toolbar.assert_called_once() + + @patch("sn_verfahrensgebiet.main.plugins", new_callable=dict) + @patch("sn_verfahrensgebiet.main.DockManager.show") + @patch("sn_verfahrensgebiet.main.DockWidget") + def test_run_creates_dock_and_marks_toolbar_active(self, mock_dockwidget_cls, mock_show, mock_plugins): + iface = _DummyIface() + plugin = Verfahrensgebiet(iface) + plugin.action = "action_obj" + + dock = _DummyDock() + mock_dockwidget_cls.return_value = dock + + ui = SimpleNamespace(set_active_plugin=Mock()) + mock_plugins["sn_basis"] = SimpleNamespace(ui=ui) + + plugin.run() + + self.assertIs(plugin.dockwidget, dock) + self.assertEqual(dock.object_name, "sn_dock_verfahrensgebiet") + self.assertEqual(dock.action, "action_obj") + mock_show.assert_called_once_with(dock) + ui.set_active_plugin.assert_called_once_with("action_obj") + + @patch("sn_verfahrensgebiet.main.plugins", new_callable=dict) + def test_unload_removes_dock_and_action(self, mock_plugins): + iface = _DummyIface() + plugin = Verfahrensgebiet(iface) + + dock = _DummyDock() + plugin.dockwidget = dock + plugin.action = "action_obj" + + ui = SimpleNamespace(remove_action=Mock()) + mock_plugins["sn_basis"] = SimpleNamespace(ui=ui) + + plugin.unload() + + self.assertIn(dock, iface.removed) + self.assertTrue(dock.deleted) + self.assertIsNone(plugin.dockwidget) + ui.remove_action.assert_called_once_with("action_obj") + self.assertIsNone(plugin.action) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verfahrensgebiet_alkis.py b/tests/test_verfahrensgebiet_alkis.py new file mode 100644 index 0000000..46ba82b --- /dev/null +++ b/tests/test_verfahrensgebiet_alkis.py @@ -0,0 +1,184 @@ +import sys +import types +import unittest +from unittest.mock import patch + +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.functions import verfahrensgebiet_alkis as vg + + +class _DummyMessageBox: + class StandardButton: + Yes = 1 + No = 2 + + last_critical = None + next_reply = StandardButton.No + + @staticmethod + def question(_parent, _title, _message, _buttons, _default): + return _DummyMessageBox.next_reply + + @staticmethod + def critical(parent, title, message): + _DummyMessageBox.last_critical = (parent, title, message) + + +class _DummyProject: + def __init__(self, layers=None): + self._layers = layers or [] + self.removed = [] + + def mapLayersByName(self, _name): + return self._layers + + def removeMapLayer(self, layer_id): + self.removed.append(layer_id) + + +class _DummyExistingLayer: + def __init__(self, layer_id="existing"): + self._id = layer_id + + def id(self): + return self._id + + +class _DummyGeom: + def __init__(self, empty=False): + self._empty = empty + + def isEmpty(self): + return self._empty + + +class _DummyFeature: + def __init__(self, geom=None): + self._geom = geom or _DummyGeom() + + def geometry(self): + return self._geom + + +class _DummyTempLayer: + def __init__(self, valid=True, features=None): + self._valid = valid + self._features = features or [] + + def isValid(self): + return self._valid + + def getFeatures(self): + return self._features + + def crs(self): + class _Crs: + @staticmethod + def authid(): + return "EPSG:25833" + + return _Crs() + + +class _DummyProvider: + def addAttributes(self, _attrs): + return True + + def addFeatures(self, _features): + return True + + +class _DummyMemoryLayer: + def __init__(self): + self._provider = _DummyProvider() + + def dataProvider(self): + return self._provider + + def updateFields(self): + return None + + def updateExtents(self): + return None + + +class TestVerfahrensgebietAlkis(unittest.TestCase): + + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QMessageBox", new=_DummyMessageBox) + def test_check_existing_layer_first_load(self, mock_project_instance): + mock_project_instance.return_value = _DummyProject(layers=[]) + + layer, status = vg._check_existing_layer(tab_widget=None) + + self.assertIsNone(layer) + self.assertEqual(status, vg.LoadStatus.FIRST) + + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QMessageBox", new=_DummyMessageBox) + def test_check_existing_layer_keep_existing(self, mock_project_instance): + existing = _DummyExistingLayer("L1") + mock_project_instance.return_value = _DummyProject(layers=[existing]) + _DummyMessageBox.next_reply = _DummyMessageBox.StandardButton.No + + layer, status = vg._check_existing_layer(tab_widget=None) + + self.assertIs(layer, existing) + self.assertEqual(status, vg.LoadStatus.KEEP) + + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QMessageBox", new=_DummyMessageBox) + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.get_variable", return_value="") + def test_verfahrensgebiet_alkis_fails_when_verfahrensnummer_missing(self, _mock_get_var): + _DummyMessageBox.last_critical = None + + layer, status = vg.verfahrensgebiet_alkis(tab_widget=None) + + self.assertIsNone(layer) + self.assertEqual(status, vg.LoadStatus.NONE) + self.assertIsNotNone(_DummyMessageBox.last_critical) + + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis._check_existing_layer", return_value=(None, vg.LoadStatus.FIRST)) + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QMessageBox", new=_DummyMessageBox) + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.get_variable", return_value="VKZ123") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsVectorLayer") + def test_verfahrensgebiet_alkis_fails_on_invalid_wfs_layer(self, mock_qgsvectorlayer, _mock_get_var, _mock_check): + _DummyMessageBox.last_critical = None + mock_qgsvectorlayer.return_value = _DummyTempLayer(valid=False) + + layer, status = vg.verfahrensgebiet_alkis(tab_widget=None) + + self.assertIsNone(layer) + self.assertEqual(status, vg.LoadStatus.NONE) + self.assertIsNotNone(_DummyMessageBox.last_critical) + + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsFeature") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsGeometry.unaryUnion", return_value=_DummyGeom(empty=False)) + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis._check_existing_layer", return_value=(None, vg.LoadStatus.FIRST)) + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.get_variable", return_value="VKZ123") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QMessageBox", new=_DummyMessageBox) + def test_verfahrensgebiet_alkis_success_returns_memory_layer(self, mock_qgsvectorlayer, _mock_get_var, _mock_check, _mock_union, mock_feature_cls): + temp_layer = _DummyTempLayer(valid=True, features=[_DummyFeature()]) + memory_layer = _DummyMemoryLayer() + mock_qgsvectorlayer.side_effect = [temp_layer, memory_layer] + + mock_feature = mock_feature_cls.return_value + mock_feature.setGeometry.return_value = None + mock_feature.setAttributes.return_value = None + + layer, status = vg.verfahrensgebiet_alkis(tab_widget=None) + + self.assertIs(layer, memory_layer) + self.assertEqual(status, vg.LoadStatus.FIRST) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_working_tab.py b/tests/test_working_tab.py new file mode 100644 index 0000000..683dd27 --- /dev/null +++ b/tests/test_working_tab.py @@ -0,0 +1,156 @@ +import sys +import types +import unittest +from unittest.mock import patch + +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace( + mapCanvas=lambda: types.SimpleNamespace(setExtent=lambda _e: None, refresh=lambda: None) + ) + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.ui.tabs.working_tab import WorkingTab, LoadStatus + + +class _DummyLayer: + def __init__(self, valid=True, layer_id="L1"): + self._valid = valid + self._id = layer_id + + def isValid(self): + return self._valid + + def id(self): + return self._id + + def extent(self): + return object() + + +class _DummyProject: + def __init__(self): + self.added = [] + + def addMapLayer(self, layer): + self.added.append(layer) + + +class _DummyCanvas: + def __init__(self): + self.extent = None + + def setExtent(self, extent): + self.extent = extent + + def refresh(self): + return None + + +class _DummyButton: + def __init__(self): + self._enabled = True + self._text = "Aus ALKIS laden" + + def setEnabled(self, enabled): + self._enabled = enabled + + def isEnabled(self): + return self._enabled + + def setText(self, text): + self._text = text + + def text(self): + return self._text + + +class _DummyCombo: + def __init__(self): + self.layer = None + + def setLayer(self, layer): + self.layer = layer + + +class TestWorkingTab(unittest.TestCase): + + def _make_tab(self): + tab = WorkingTab.__new__(WorkingTab) + tab.btn_verf_alkis = _DummyButton() + tab.verf_layer_combo = _DummyCombo() + tab.haken_verf = object() + tab.setze_haken = lambda *_args, **_kwargs: None + return tab + + def test_set_busy_updates_button_state(self): + tab = self._make_tab() + + tab._set_busy(tab.btn_verf_alkis, True) + self.assertFalse(tab.btn_verf_alkis.isEnabled()) + self.assertEqual(tab.btn_verf_alkis.text(), "Lade ...") + + tab._set_busy(tab.btn_verf_alkis, False) + self.assertTrue(tab.btn_verf_alkis.isEnabled()) + self.assertEqual(tab.btn_verf_alkis.text(), "Aus ALKIS laden") + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id") + def test_on_verf_layer_changed_valid_sets_variable(self, mock_set_layer_id): + tab = self._make_tab() + layer = _DummyLayer(valid=True, layer_id="layer_123") + + tab._on_verf_layer_changed(layer) + + mock_set_layer_id.assert_called_once_with("layer_123") + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id") + def test_on_verf_layer_changed_none_clears_variable(self, mock_set_layer_id): + tab = self._make_tab() + + tab._on_verf_layer_changed(None) + + mock_set_layer_id.assert_called_once_with("") + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.success") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_alkis") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id") + def test_handle_verf_alkis_success_path( + self, + mock_set_layer_id, + mock_iface, + mock_alkis, + mock_project_instance, + mock_apply_style, + mock_success, + ): + tab = self._make_tab() + layer = _DummyLayer(valid=True, layer_id="layer_ok") + mock_alkis.return_value = (layer, LoadStatus.FIRST) + mock_project_instance.return_value = _DummyProject() + mock_iface.mapCanvas.return_value = _DummyCanvas() + + tab.handle_verf_alkis() + + mock_apply_style.assert_called_once() + mock_success.assert_called_once() + mock_set_layer_id.assert_called_with("layer_ok") + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_alkis", return_value=(None, LoadStatus.NONE)) + def test_handle_verf_alkis_failure_clears_layer_var(self, _mock_alkis, mock_set_layer_id): + tab = self._make_tab() + + tab.handle_verf_alkis() + + mock_set_layer_id.assert_called_once_with("") + + +if __name__ == "__main__": + unittest.main() From 189855e63b811dea71a5b38620f848765c4470d4 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 21 Apr 2026 21:50:26 +0200 Subject: [PATCH 03/22] Flst_Laden, VerfGebiet bilden --- functions/flurstueck_alkis.py | 113 ++++ functions/verfahrensgebiet_edit_service.py | 358 ++++++++++++ styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml | 349 ++++++++++++ styles/verfahrensgebiet.qml | 215 +++---- tests/test_flurstueck_alkis.py | 223 ++++++++ tests/test_verfahrensgebiet_edit_service.py | 595 ++++++++++++++++++++ tests/test_working_tab.py | 283 ++++++++++ ui/tabs/working_tab.py | 235 +++++++- 8 files changed, 2247 insertions(+), 124 deletions(-) create mode 100644 functions/flurstueck_alkis.py create mode 100644 functions/verfahrensgebiet_edit_service.py create mode 100644 styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml create mode 100644 tests/test_flurstueck_alkis.py create mode 100644 tests/test_verfahrensgebiet_edit_service.py diff --git a/functions/flurstueck_alkis.py b/functions/flurstueck_alkis.py new file mode 100644 index 0000000..40876b2 --- /dev/null +++ b/functions/flurstueck_alkis.py @@ -0,0 +1,113 @@ +"""sn_verfahrensgebiet/functions/flurstueck_alkis.py – ALKIS-Integration für Flurstücke. + +Lädt Flurstücke aus dem ALKIS-WFS-Dienst (adv:AX_Flurstueck) anhand von +Gemarkungsschlüsseln, die der Nutzer interaktiv eingibt. + +Der WFS-Layer wird direkt (nicht als Memory-Layer) ins Projekt geladen, +da die einzelnen Features für die spätere Auswahl benötigt werden. +""" + +from sn_basis.functions import QgsVectorLayer, QgsProject +from sn_basis.functions import QMessageBox +from sn_basis.functions.dialog_wrapper import ask_text + +# LoadStatus aus verfahrensgebiet_alkis wiederverwenden (identische Semantik) +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus + +ALKIS_NAS_URL = "https://geodienste.sachsen.de/aaa/public_alkis/nas/wfs" +FLST_TYPENAME = "adv:AX_Flurstueck" +FLST_LAYER_NAME = "Flurstücke" + + +def _check_existing_flurstueck_layer(tab_widget): + """Prüft, ob ein Flurstück-Layer bereits im Projekt existiert. + + Returns + ------- + Tuple[Optional[QgsVectorLayer], LoadStatus] + Bei ``LoadStatus.KEEP`` enthält das Tuple den vorhandenen Layer, + bei ``LoadStatus.FIRST`` und ``LoadStatus.RELOAD`` ist es None. + """ + project = QgsProject.instance() + existing_layers = project.mapLayersByName(FLST_LAYER_NAME) + if not existing_layers: + return None, LoadStatus.FIRST + + reply = QMessageBox.question( + tab_widget, + "Layer bereits vorhanden", + f"Der Layer '{FLST_LAYER_NAME}' existiert bereits.\n" + "Möchten Sie ihn löschen und neu laden?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + for lyr in existing_layers: + project.removeMapLayer(lyr.id()) + return None, LoadStatus.RELOAD + else: + return existing_layers[0], LoadStatus.KEEP + + +def flurstueck_alkis(tab_widget): + """Lädt Flurstücke aus ALKIS-WFS anhand von Gemarkungsschlüsseln. + + Der Nutzer gibt einen oder mehrere Gemarkungsschlüssel (kommagetrennt) ein. + Der WFS wird per SQL-Filter auf + ``gemarkung_ax_gemarkung_schluessel_gemarkungsnummer IN (...)`` abgefragt. + + Parameters + ---------- + tab_widget : + Eltern-Widget für Dialoge. + + Returns + ------- + Tuple[Optional[QgsVectorLayer], LoadStatus] + Geladener WFS-Layer und Ladestatus. + Bei Fehlern ist der Layer None und der Status LoadStatus.NONE. + """ + # 1. Vorhandenen Layer prüfen + existing, status = _check_existing_flurstueck_layer(tab_widget) + if status == LoadStatus.KEEP: + return existing, status + + # 2. Gemarkungsschlüssel abfragen + eingabe, ok = ask_text( + "Gemarkungsschlüssel", + "Gemarkungsschlüssel eingeben (mehrere mit Komma trennen):", + parent=tab_widget, + ) + if not ok or not eingabe.strip(): + QMessageBox.warning( + tab_widget, + "Abgebrochen", + "Bitte mindestens einen gültigen Gemarkungsschlüssel eingeben.", + ) + return None, LoadStatus.NONE + + schluessel_liste = [s.strip() for s in eingabe.split(",") if s.strip()] + if not schluessel_liste: + QMessageBox.warning(tab_widget, "Fehler", "Keine gültigen Schlüssel gefunden.") + return None, LoadStatus.NONE + + schluessel_str = ", ".join(f"'{s}'" for s in schluessel_liste) + + # 3. WFS-URI aufbauen und Layer laden + wfs_uri = ( + f"url='{ALKIS_NAS_URL}' typename='{FLST_TYPENAME}' srsname='EPSG:25833' " + f"sql=SELECT * FROM AX_Flurstueck " + f"WHERE gemarkung_ax_gemarkung_schluessel_gemarkungsnummer IN ({schluessel_str})" + ) + layer = QgsVectorLayer(wfs_uri, FLST_LAYER_NAME, "WFS") + + if not layer.isValid(): + QMessageBox.critical( + tab_widget, + "Fehler", + "Flurstück-Layer konnte nicht geladen werden.\n" + "Bitte Gemarkungsschlüssel und Netzwerkverbindung prüfen.", + ) + return None, LoadStatus.NONE + + return layer, status diff --git a/functions/verfahrensgebiet_edit_service.py b/functions/verfahrensgebiet_edit_service.py new file mode 100644 index 0000000..cea40d7 --- /dev/null +++ b/functions/verfahrensgebiet_edit_service.py @@ -0,0 +1,358 @@ +"""sn_verfahrensgebiet/functions/verfahrensgebiet_edit_service.py + +Service-Modul für Verfahrensgebiet-Editoperationen aus Flurstück-Auswahl. + +Der Verfahrensgebiet-Layer ist ein Single-Feature-Memory-Layer: alle +Flurstück-Geometrien werden via ``QgsGeometry.unaryUnion`` zu einem einzigen +(Multi-)Polygon verschmolzen und als ein Feature gespeichert. Dadurch +erscheint das Verfahrensgebiet im Kartenfenster als zusammenhängendes Objekt. + +Die Flurstückskennzeichen werden als kommagetrennte Zeichenkette im +Attributfeld ``flurstueckskennzeichen`` des Single-Features gespeichert, +sodass Delta-Operationen (Ergänzen/Entfernen) ohne erneuten Zugriff auf den +Quell-Flurstück-Layer möglich bleiben und die ID-Menge via +``get_kennzeichen_menge()`` jederzeit abrufbar ist. +""" + +from typing import Optional, Tuple, List + +from sn_basis.functions import QgsVectorLayer, QgsFeature, QgsField +from sn_basis.functions.qgiscore_wrapper import QgsGeometry +from sn_basis.functions.qt_wrapper import QVariant +from sn_basis.modules.layerpruefer import Layerpruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + +VERF_LAYER_NAME = "Verfahrensgebiet" +FELD_KENNZEICHEN = "flurstueckskennzeichen" +FELD_GEMARKUNG = "gemarkung_ax_gemarkung_schluessel_gemarkungsnummer" +PFLICHTFELDER: List[str] = [FELD_KENNZEICHEN, FELD_GEMARKUNG] + + +# --------------------------------------------------------------------------- +# Vorbedingungsprüfungen +# --------------------------------------------------------------------------- + +def pruefe_flurstueck_layer(layer) -> pruef_ergebnis: + """Prüft, ob ein Layer als Flurstück-Quelle geeignet ist. + + Prüfreihenfolge via Layerpruefer: + existiert → Vektorlayer → Polygon-Geometrie → nicht leer → Pflichtfelder vorhanden. + + Returns + ------- + pruef_ergebnis + ok=True, wenn alle Prüfungen bestanden wurden. + """ + pruefer = Layerpruefer( + layer=layer, + erwarteter_layertyp="vector", + erwarteter_geotyp="Polygon", + erforderliche_felder=PFLICHTFELDER, + ) + return pruefer.pruefe() + + +def pruefe_auswahl(layer) -> pruef_ergebnis: + """Prüft, ob auf dem Layer mindestens ein Feature ausgewählt ist. + + Returns + ------- + pruef_ergebnis + ok=True bei mindestens einer Auswahl, sonst ok=False mit aktion ``layer_leer``. + """ + try: + count = int(layer.selectedFeatureCount()) + except Exception: + count = 0 + + if count == 0: + return pruef_ergebnis( + ok=False, + meldung="Es sind keine Flurstücke ausgewählt. Bitte zuerst eine Auswahl treffen.", + aktion="layer_leer", + kontext=None, + ) + return pruef_ergebnis( + ok=True, + meldung=f"{count} Flurstücke ausgewählt.", + aktion="ok", + kontext=None, + ) + + +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- + +def get_kennzeichen_menge(layer) -> set: + """Gibt die Menge aller ``flurstueckskennzeichen`` aus dem Layer zurück. + + Im Single-Feature-Modell sind die Kennzeichen als kommagetrennte + Zeichenkette im einzigen Feature gespeichert. + """ + result = set() + for feat in layer.getFeatures(): + val = feat[FELD_KENNZEICHEN] + if val: + for kz in str(val).split(","): + kz = kz.strip() + if kz: + result.add(kz) + return result + + +def _erstelle_memory_layer(crs_authid: str) -> QgsVectorLayer: + """Erzeugt einen leeren Multi-Feature-Memory-Layer für das Verfahrensgebiet.""" + layer = QgsVectorLayer( + f"MultiPolygon?crs={crs_authid}", VERF_LAYER_NAME, "memory" + ) + pr = layer.dataProvider() + pr.addAttributes([ + QgsField(FELD_KENNZEICHEN, QVariant.String), + QgsField(FELD_GEMARKUNG, QVariant.String), + ]) + layer.updateFields() + return layer + + +# --------------------------------------------------------------------------- +# Hauptoperationen +# --------------------------------------------------------------------------- + +def erstelle_verfahrensgebiet_aus_auswahl( + source_layer, +) -> Tuple[Optional[object], int, int]: + """Erstellt neuen Verfahrensgebiet-Memory-Layer aus der aktuellen Auswahl. + + Alle Geometrien werden via ``QgsGeometry.unaryUnion`` zu einem einzigen + (Multi-)Polygon verschmolzen. Der Layer enthält danach genau ein Feature. + Die Flurstückskennzeichen werden kommagetrennt im Attribut gespeichert. + + Parameters + ---------- + source_layer : + Flurstück-Layer mit aktiver Auswahl. + + Returns + ------- + Tuple[layer, erfolgreich, fehlgeschlagen] + """ + crs = source_layer.crs().authid() + mem_layer = _erstelle_memory_layer(crs) + pr = mem_layer.dataProvider() + + erfolgreich = 0 + fehlgeschlagen = 0 + geom_list = [] + kz_list = [] + gemarkung_set = set() + + for src_feat in source_layer.selectedFeatures(): + try: + geom = src_feat.geometry() + if not geom or geom.isEmpty(): + fehlgeschlagen += 1 + continue + geom_list.append(geom) + kz = str(src_feat[FELD_KENNZEICHEN] or "") + gm = str(src_feat[FELD_GEMARKUNG] or "") + if kz: + kz_list.append(kz) + if gm: + gemarkung_set.add(gm) + erfolgreich += 1 + except Exception: + fehlgeschlagen += 1 + + if geom_list: + union_geom = QgsGeometry.unaryUnion(geom_list) + if union_geom and not union_geom.isEmpty(): + feat = QgsFeature() + feat.setGeometry(union_geom) + feat.setAttributes([ + ",".join(kz_list), + ",".join(sorted(gemarkung_set)), + ]) + pr.addFeatures([feat]) + mem_layer.updateExtents() + + return mem_layer, erfolgreich, fehlgeschlagen + + +def ergaenze_verfahrensgebiet( + verf_layer, + source_layer, +) -> Tuple[object, int, int, int]: + """Fügt nur neue (noch nicht enthaltene) Flurstücke zum Verfahrensgebiet hinzu. + + Die Geometrien der neuen Flurstücke werden mit der bestehenden Union-Geometrie + verschmolzen. Das Single-Feature wird an Ort und Stelle aktualisiert. + + Parameters + ---------- + verf_layer : + Bestehender Verfahrensgebiet-Memory-Layer (Single-Feature). + source_layer : + Flurstück-Layer mit aktiver Auswahl. + + Returns + ------- + Tuple[verf_layer, hinzugefuegt, bereits_vorhanden, fehlgeschlagen] + """ + vorhandene_ids = get_kennzeichen_menge(verf_layer) + pr = verf_layer.dataProvider() + + hinzugefuegt = 0 + bereits_vorhanden = 0 + fehlgeschlagen = 0 + neue_geoms = [] + neue_kz = [] + neue_gemarkungen: set = set() + + for src_feat in source_layer.selectedFeatures(): + try: + kz = str(src_feat[FELD_KENNZEICHEN] or "") + if kz in vorhandene_ids: + bereits_vorhanden += 1 + continue + geom = src_feat.geometry() + if not geom or geom.isEmpty(): + fehlgeschlagen += 1 + continue + neue_geoms.append(geom) + if kz: + neue_kz.append(kz) + gm = str(src_feat[FELD_GEMARKUNG] or "") + if gm: + neue_gemarkungen.add(gm) + hinzugefuegt += 1 + except Exception: + fehlgeschlagen += 1 + + if neue_geoms: + existing_feats = list(verf_layer.getFeatures()) + if existing_feats: + existing_feat = existing_feats[0] + all_geoms = [existing_feat.geometry()] + neue_geoms + new_union = QgsGeometry.unaryUnion(all_geoms) + alle_kz = sorted(vorhandene_ids | set(neue_kz)) + existing_gm_str = existing_feat[FELD_GEMARKUNG] or "" + alle_gm = sorted( + {g.strip() for g in existing_gm_str.split(",") if g.strip()} | neue_gemarkungen + ) + kz_idx = verf_layer.fields().indexFromName(FELD_KENNZEICHEN) + gm_idx = verf_layer.fields().indexFromName(FELD_GEMARKUNG) + pr.changeAttributeValues({existing_feat.id(): { + kz_idx: ",".join(alle_kz), + gm_idx: ",".join(alle_gm), + }}) + pr.changeGeometryValues({existing_feat.id(): new_union}) + else: + # Kein vorhandenes Feature – neues anlegen + union_geom = QgsGeometry.unaryUnion(neue_geoms) + if union_geom and not union_geom.isEmpty(): + feat = QgsFeature() + feat.setGeometry(union_geom) + feat.setAttributes([ + ",".join(sorted(neue_kz)), + ",".join(sorted(neue_gemarkungen)), + ]) + pr.addFeatures([feat]) + verf_layer.updateExtents() + verf_layer.triggerRepaint() + + return verf_layer, hinzugefuegt, bereits_vorhanden, fehlgeschlagen + + +def entferne_aus_verfahrensgebiet( + verf_layer, + source_layer, +) -> Tuple[object, int, int, int]: + """Entfernt Flurstücke aus dem Verfahrensgebiet. + + Die Geometrien der zu entfernenden Flurstücke werden per ``difference()`` + von der bestehenden Union-Geometrie subtrahiert. + Nicht enthaltene Flurstücke werden gezählt, aber ignoriert. + + Parameters + ---------- + verf_layer : + Bestehender Verfahrensgebiet-Memory-Layer (Single-Feature). + source_layer : + Flurstück-Layer mit aktiver Auswahl. + + Returns + ------- + Tuple[verf_layer, entfernt, nicht_enthalten, fehlgeschlagen] + """ + existing_feats = list(verf_layer.getFeatures()) + if not existing_feats: + auswahl_kz = { + str(f[FELD_KENNZEICHEN] or "") + for f in source_layer.selectedFeatures() + if f[FELD_KENNZEICHEN] + } + return verf_layer, 0, len(auswahl_kz), 0 + + existing_feat = existing_feats[0] + existing_kz_str = existing_feat[FELD_KENNZEICHEN] or "" + vorhandene_ids = {k.strip() for k in existing_kz_str.split(",") if k.strip()} + + auswahl_kz = { + str(f[FELD_KENNZEICHEN] or "") + for f in source_layer.selectedFeatures() + if f[FELD_KENNZEICHEN] + } + nicht_enthalten = len(auswahl_kz - vorhandene_ids) + zu_entfernen_kz = auswahl_kz & vorhandene_ids + + if not zu_entfernen_kz: + return verf_layer, 0, nicht_enthalten, 0 + + result_geom = existing_feat.geometry() + fehlgeschlagen = 0 + tatsaechlich_entfernt = 0 + + for src_feat in source_layer.selectedFeatures(): + kz = str(src_feat[FELD_KENNZEICHEN] or "") + if kz not in zu_entfernen_kz: + continue + try: + geom = src_feat.geometry() + if geom and not geom.isEmpty(): + result_geom = result_geom.difference(geom) + tatsaechlich_entfernt += 1 + except Exception: + fehlgeschlagen += 1 + + pr = verf_layer.dataProvider() + verbleibende_kz = sorted(vorhandene_ids - zu_entfernen_kz) + + if not verbleibende_kz or result_geom is None or result_geom.isEmpty(): + pr.deleteFeatures([existing_feat.id()]) + else: + kz_idx = verf_layer.fields().indexFromName(FELD_KENNZEICHEN) + pr.changeGeometryValues({existing_feat.id(): result_geom}) + pr.changeAttributeValues({existing_feat.id(): { + kz_idx: ",".join(verbleibende_kz), + }}) + + verf_layer.updateExtents() + verf_layer.triggerRepaint() + return verf_layer, tatsaechlich_entfernt, nicht_enthalten, fehlgeschlagen + + +def ersetze_verfahrensgebiet( + source_layer, +) -> Tuple[Optional[object], int, int]: + """Erstellt einen neuen Verfahrensgebiet-Layer aus der Auswahl (Ersetzen-Semantik). + + Der Aufrufer ist verantwortlich, den alten Layer aus dem Projekt zu entfernen, + bevor der neue Layer hinzugefügt wird. + + Returns + ------- + Tuple[layer, erfolgreich, fehlgeschlagen] + Entspricht ``erstelle_verfahrensgebiet_aus_auswahl``. + """ + return erstelle_verfahrensgebiet_aus_auswahl(source_layer) diff --git a/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml b/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml new file mode 100644 index 0000000..5e40734 --- /dev/null +++ b/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 2 + diff --git a/styles/verfahrensgebiet.qml b/styles/verfahrensgebiet.qml index 474e368..35485d7 100644 --- a/styles/verfahrensgebiet.qml +++ b/styles/verfahrensgebiet.qml @@ -1,133 +1,142 @@ - - - - - - - - - + + + + + - - - - + - - - - - - + + + + + + + + + + + + + + + + + + - + - + diff --git a/tests/test_flurstueck_alkis.py b/tests/test_flurstueck_alkis.py new file mode 100644 index 0000000..b49e6d0 --- /dev/null +++ b/tests/test_flurstueck_alkis.py @@ -0,0 +1,223 @@ +"""sn_verfahrensgebiet/tests/test_flurstueck_alkis.py + +Unit-Tests für das flurstueck_alkis-Modul. + +Getestet werden: +- Nutzerabbruch bei ask_text → (None, LoadStatus.NONE) +- Ungültiger WFS-Layer → (None, LoadStatus.NONE) +- Erfolgreicher Ladevorgang → gültiger Layer + LoadStatus.FIRST +- Bestehender Layer: Nutzer wählt "Neu laden" → LoadStatus.RELOAD +- Bestehender Layer: Nutzer wählt "Behalten" → LoadStatus.KEEP +""" + +import sys +import types +import unittest +from unittest.mock import patch, MagicMock + +# --------------------------------------------------------------------------- +# Minimales QGIS-Mock-Setup +# --------------------------------------------------------------------------- +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_core = types.ModuleType("qgis.core") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + qgis_mod.core = qgis_core + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.core"] = qgis_core + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis, FLST_LAYER_NAME +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus + + +# --------------------------------------------------------------------------- +# Dummies +# --------------------------------------------------------------------------- + +class _DummyLayer: + def __init__(self, valid: bool, layer_id="L1"): + self._valid = valid + self._id = layer_id + + def isValid(self): + return self._valid + + def id(self): + return self._id + + def extent(self): + return object() + + +class _DummyProject: + def __init__(self, named_layers=None): + self._named_layers = named_layers or [] + self.removed = [] + + def mapLayersByName(self, name): + return self._named_layers + + def removeMapLayer(self, lid): + self.removed.append(lid) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestFlurstueckAlkis(unittest.TestCase): + + # ------------------------------------------------------------------ # + # 1. Nutzerabbruch (ask_text liefert ok=False / None) # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QMessageBox") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_user_cancel_returns_none(self, mock_ask_text, mock_project, mock_msgbox): + """ask_text → ok=False, text="" → (None, NONE).""" + mock_ask_text.return_value = ("", False) + mock_project.return_value = _DummyProject() + + layer, status = flurstueck_alkis(None) + + self.assertIsNone(layer) + self.assertEqual(status, LoadStatus.NONE) + + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QMessageBox") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_empty_text_after_confirmation_returns_none(self, mock_ask_text, mock_project, mock_msgbox): + """ask_text → ok=True, aber Text leer → (None, NONE).""" + mock_ask_text.return_value = (" ", True) + mock_project.return_value = _DummyProject() + + layer, status = flurstueck_alkis(None) + + self.assertIsNone(layer) + self.assertEqual(status, LoadStatus.NONE) + + # ------------------------------------------------------------------ # + # 2. Ungültiger WFS-Layer # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QMessageBox") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_invalid_wfs_layer_returns_none( + self, mock_ask_text, mock_project, mock_wfs_layer_cls, mock_msgbox + ): + """WFS-Layer nicht valide → (None, NONE).""" + mock_ask_text.return_value = ("1234", True) + mock_project.return_value = _DummyProject() + mock_wfs_layer_cls.return_value = _DummyLayer(valid=False) + + layer, status = flurstueck_alkis(None) + + self.assertIsNone(layer) + self.assertEqual(status, LoadStatus.NONE) + + # ------------------------------------------------------------------ # + # 3. Erfolgreicher Erstladevorgang # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_success_returns_layer_and_first_status( + self, mock_ask_text, mock_project, mock_wfs_layer_cls + ): + """Kein bestehender Layer, WFS valide → (layer, FIRST).""" + mock_ask_text.return_value = ("1234, 5678", True) + mock_project.return_value = _DummyProject() + valid_layer = _DummyLayer(valid=True, layer_id="wfs_ok") + mock_wfs_layer_cls.return_value = valid_layer + + layer, status = flurstueck_alkis(None) + + self.assertIs(layer, valid_layer) + self.assertEqual(status, LoadStatus.FIRST) + + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_sql_filter_contains_all_schluessel( + self, mock_ask_text, mock_project, mock_wfs_layer_cls + ): + """Der WFS-URI enthält alle eingegebenen Gemarkungsschlüssel.""" + mock_ask_text.return_value = ("1111, 2222, 3333", True) + mock_project.return_value = _DummyProject() + mock_wfs_layer_cls.return_value = _DummyLayer(valid=True) + + flurstueck_alkis(None) + + # Erster Positionsarg von QgsVectorLayer ist der URI-String + call_args = mock_wfs_layer_cls.call_args + uri_str = call_args[0][0] if call_args[0] else call_args[1].get("path", "") + self.assertIn("1111", uri_str) + self.assertIn("2222", uri_str) + self.assertIn("3333", uri_str) + + # ------------------------------------------------------------------ # + # 4. Bestehender Layer – Nutzer wählt "Neu laden" # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QMessageBox") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_existing_layer_reload_yes( + self, mock_ask_text, mock_project, mock_msgbox_cls, mock_wfs_layer_cls + ): + """Bestehender Layer + Nutzer: 'Ja, neu laden' → LoadStatus.RELOAD.""" + existing = _DummyLayer(valid=True, layer_id="old_flst") + project = _DummyProject(named_layers=[existing]) + mock_ask_text.return_value = ("1234", True) + mock_project.return_value = project + + # StandardButton als int, damit | Operator funktioniert + mock_msgbox_cls.StandardButton.Yes = 0x4000 + mock_msgbox_cls.StandardButton.No = 0x10000 + # QMessageBox.question → Yes + mock_msgbox_cls.question.return_value = 0x4000 + + new_layer = _DummyLayer(valid=True, layer_id="new_flst") + mock_wfs_layer_cls.return_value = new_layer + + layer, status = flurstueck_alkis(None) + + self.assertIn(existing.id(), project.removed) + self.assertIs(layer, new_layer) + self.assertEqual(status, LoadStatus.RELOAD) + + # ------------------------------------------------------------------ # + # 5. Bestehender Layer – Nutzer wählt "Behalten" # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QMessageBox") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_existing_layer_keep_no( + self, mock_ask_text, mock_project, mock_msgbox_cls + ): + """Bestehender Layer + Nutzer: 'Nein, behalten' → LoadStatus.KEEP.""" + existing = _DummyLayer(valid=True, layer_id="keep_flst") + project = _DummyProject(named_layers=[existing]) + mock_ask_text.return_value = ("1234", True) + mock_project.return_value = project + + # StandardButton als int, damit | Operator funktioniert + mock_msgbox_cls.StandardButton.Yes = 0x4000 + mock_msgbox_cls.StandardButton.No = 0x10000 + # QMessageBox.question → No + mock_msgbox_cls.question.return_value = 0x10000 + + layer, status = flurstueck_alkis(None) + + self.assertIs(layer, existing) + self.assertEqual(status, LoadStatus.KEEP) + self.assertEqual(project.removed, []) # nichts gelöscht + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verfahrensgebiet_edit_service.py b/tests/test_verfahrensgebiet_edit_service.py new file mode 100644 index 0000000..8679e4e --- /dev/null +++ b/tests/test_verfahrensgebiet_edit_service.py @@ -0,0 +1,595 @@ +"""sn_verfahrensgebiet/tests/test_verfahrensgebiet_edit_service.py + +Unit-Tests für das Verfahrensgebiet-Edit-Service-Modul. + +Getestet werden: +- pruefe_flurstueck_layer (über Layerpruefer-Integration) +- pruefe_auswahl +- erstelle_verfahrensgebiet_aus_auswahl: Erfolgs- und Fehlerpfade +- ergaenze_verfahrensgebiet: nur neue IDs werden hinzugefügt +- entferne_aus_verfahrensgebiet: nur vorhandene IDs werden entfernt +- ersetze_verfahrensgebiet: Ergebnis entspricht Erstellen +- get_kennzeichen_menge: Hilfsfunktion +- Zähler (erfolgreich/fehlgeschlagen/bereits_vorhanden/nicht_enthalten) +""" + +import sys +import types +import unittest +from unittest.mock import patch, MagicMock + +# --------------------------------------------------------------------------- +# Minimales QGIS-Mock-Setup (muss vor allen sn_*-Importen erfolgen) +# --------------------------------------------------------------------------- +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_core = types.ModuleType("qgis.core") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + qgis_mod.core = qgis_core + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.core"] = qgis_core + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( + pruefe_flurstueck_layer, + pruefe_auswahl, + erstelle_verfahrensgebiet_aus_auswahl, + ergaenze_verfahrensgebiet, + entferne_aus_verfahrensgebiet, + ersetze_verfahrensgebiet, + get_kennzeichen_menge, + FELD_KENNZEICHEN, + FELD_GEMARKUNG, +) + + +# --------------------------------------------------------------------------- +# Test-Dummies +# --------------------------------------------------------------------------- + +class _DummyGeom: + def __init__(self, empty=False): + self._empty = empty + + def isEmpty(self): + return self._empty + + def difference(self, other): + """Dummy-Differenz: immer nicht-leeres Ergebnis (kein echter Geometrieschnitt).""" + return _DummyGeom(empty=False) + + +class _DummyFeature: + """Feature-Dummy mit dict-artigem Attributzugriff und konfigurierbarer Geometrie.""" + + def __init__(self, kz: str, gemarkung: str = "1234", geom_empty: bool = False, feat_id: int = 0): + self._attrs = {FELD_KENNZEICHEN: kz, FELD_GEMARKUNG: gemarkung} + self._geom_empty = geom_empty + self._id = feat_id + self._set_geom = None + self._set_attrs = None + + def __getitem__(self, key): + return self._attrs.get(key, None) + + def geometry(self): + return _DummyGeom(self._geom_empty) + + def id(self): + return self._id + + def setGeometry(self, g): + self._set_geom = g + + def setAttributes(self, attrs): + self._set_attrs = attrs + + +class _DummyProvider: + """DataProvider-Dummy für Single-Feature-Verfahrensgebiet-Layer.""" + + def __init__(self, layer_ref): + self._layer = layer_ref + + def addAttributes(self, attrs): + pass + + def addFeatures(self, feats): + self._layer._features.extend(feats) + return True + + def deleteFeatures(self, ids): + self._layer._deleted_ids.extend(ids) + return True + + def changeAttributeValues(self, changes: dict): + """changes: {fid: {field_idx: value}}""" + for feat in self._layer._features: + fid = feat.id() if callable(getattr(feat, "id", None)) else None + if fid in changes: + for field_idx, value in changes[fid].items(): + feat._attr_by_idx[field_idx] = value + return True + + def changeGeometryValues(self, changes: dict): + """changes: {fid: new_geom}""" + for feat in self._layer._features: + fid = feat.id() if callable(getattr(feat, "id", None)) else None + if fid in changes: + feat._set_geom = changes[fid] + return True + + +class _DummySingleFeature: + """Single-Feature-Dummy für den Verfahrensgebiet-Layer. + + Speichert Kennzeichen als kommagetrennte Zeichenkette (new-style). + Feld-Indizes: 0=FELD_KENNZEICHEN, 1=FELD_GEMARKUNG + """ + + def __init__(self, kz_komma: str, gemarkung: str = "", feat_id: int = 0, geom_empty: bool = False): + self._kz_komma = kz_komma + self._gemarkung = gemarkung + self._id = feat_id + self._geom_empty = geom_empty + self._set_geom = None + # Attribut-Mapping nach Index für changeAttributeValues + self._attr_by_idx: dict = {} + + def __getitem__(self, key): + if key == FELD_KENNZEICHEN: + return self._attr_by_idx.get(0, self._kz_komma) + if key == FELD_GEMARKUNG: + return self._attr_by_idx.get(1, self._gemarkung) + return None + + def geometry(self): + return _DummyGeom(self._geom_empty) + + def id(self): + return self._id + + def setGeometry(self, g): + self._set_geom = g + + def setAttributes(self, attrs): + if len(attrs) > 0: + self._kz_komma = attrs[0] + if len(attrs) > 1: + self._gemarkung = attrs[1] + + +class _DummyVerfLayer: + """Memory-Layer-Dummy für Verfahrensgebiet (Single-Feature-Modell).""" + + def __init__(self, single_feature=None): + self._features = [single_feature] if single_feature else [] + self._deleted_ids = [] + + def getFeatures(self): + deleted = set(self._deleted_ids) + return [ + f for f in self._features + if (f.id() if callable(getattr(f, "id", None)) else None) not in deleted + ] + + def dataProvider(self): + return _DummyProvider(self) + + def fields(self): + class _Fields: + def indexFromName(self, name): + if name == FELD_KENNZEICHEN: + return 0 + if name == FELD_GEMARKUNG: + return 1 + return -1 + return _Fields() + + def updateExtents(self): + pass + + def triggerRepaint(self): + pass + + def updateFields(self): + pass + + def isValid(self): + return True + + def crs(self): + class _Crs: + def authid(self): + return "EPSG:25833" + return _Crs() + + +class _DummySourceLayer: + """Flurstück-Quell-Layer-Dummy mit konfigurierbarer Auswahl.""" + + def __init__(self, selected=None, all_features=None, feature_count=None): + self._selected = list(selected or []) + self._all = list(all_features or []) + self._count = feature_count if feature_count is not None else len(self._selected) + + def selectedFeatures(self): + return self._selected + + def selectedFeatureCount(self): + return self._count + + def getFeatures(self): + return self._all + + def crs(self): + class _Crs: + def authid(self): + return "EPSG:25833" + return _Crs() + + +class _MockLayerpruefer: + """Steuerbarer Mock für Layerpruefer.pruefe().""" + + def __init__(self, ok, aktion="ok", meldung=""): + self._ok = ok + self._aktion = aktion + self._meldung = meldung + + def pruefe(self): + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + return pruef_ergebnis(ok=self._ok, aktion=self._aktion, meldung=self._meldung) + + +# --------------------------------------------------------------------------- +# Tests: pruefe_flurstueck_layer +# --------------------------------------------------------------------------- + +class TestPruefeFlurstueckLayer(unittest.TestCase): + + def test_layer_ok_passes(self): + """Gültiger Polygon-Vektor-Layer mit Pflichtfeldern → ok=True.""" + import sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service as module + original = module.Layerpruefer + + class _PassPruefer: + def __init__(self, **_): + pass + def pruefe(self): + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + return pruef_ergebnis(ok=True, aktion="ok") + + module.Layerpruefer = _PassPruefer + try: + ergebnis = pruefe_flurstueck_layer(object()) + self.assertTrue(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "ok") + finally: + module.Layerpruefer = original + + def test_layer_missing_field_fails(self): + """Layer ohne Pflichtfelder → ok=False, aktion=felder_fehlen.""" + import sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service as module + original = module.Layerpruefer + + class _FailPruefer: + def __init__(self, **_): + pass + def pruefe(self): + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + return pruef_ergebnis(ok=False, aktion="felder_fehlen", + meldung="Feld fehlt") + + module.Layerpruefer = _FailPruefer + try: + ergebnis = pruefe_flurstueck_layer(object()) + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "felder_fehlen") + finally: + module.Layerpruefer = original + + def test_layer_wrong_geometry_fails(self): + """Layer mit falschem Geometrietyp → ok=False, aktion=falscher_geotyp.""" + import sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service as module + original = module.Layerpruefer + + class _WrongGeomPruefer: + def __init__(self, **_): + pass + def pruefe(self): + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + return pruef_ergebnis(ok=False, aktion="falscher_geotyp", + meldung="Kein Polygon") + + module.Layerpruefer = _WrongGeomPruefer + try: + ergebnis = pruefe_flurstueck_layer(object()) + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "falscher_geotyp") + finally: + module.Layerpruefer = original + + +# --------------------------------------------------------------------------- +# Tests: pruefe_auswahl +# --------------------------------------------------------------------------- + +class TestPruefeAuswahl(unittest.TestCase): + + def test_no_selection_fails(self): + layer = _DummySourceLayer(selected=[], feature_count=0) + ergebnis = pruefe_auswahl(layer) + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_leer") + + def test_with_selection_passes(self): + feats = [_DummyFeature("1000-001")] + layer = _DummySourceLayer(selected=feats) + ergebnis = pruefe_auswahl(layer) + self.assertTrue(ergebnis.ok) + self.assertIn("1", ergebnis.meldung) # "1 Flurstücke ausgewählt." + + def test_selectedFeatureCount_exception_treated_as_zero(self): + class _BrokenLayer: + def selectedFeatureCount(self): + raise RuntimeError("broken") + ergebnis = pruefe_auswahl(_BrokenLayer()) + self.assertFalse(ergebnis.ok) + + +# --------------------------------------------------------------------------- +# Tests: get_kennzeichen_menge +# --------------------------------------------------------------------------- + +class TestGetKennzeichenMenge(unittest.TestCase): + + def test_returns_set_of_ids(self): + """Kommagetrennte Kennzeichen im Single-Feature werden korrekt geparst.""" + feat = _DummySingleFeature("A001,A002,A001", feat_id=1) # Duplikat → set + layer = _DummyVerfLayer(feat) + result = get_kennzeichen_menge(layer) + self.assertEqual(result, {"A001", "A002"}) + + def test_empty_layer_returns_empty_set(self): + layer = _DummyVerfLayer() + result = get_kennzeichen_menge(layer) + self.assertEqual(result, set()) + + def test_single_kennzeichen_without_comma(self): + """Einzelnes Kennzeichen ohne Komma wird korrekt erkannt.""" + feat = _DummySingleFeature("X999", feat_id=1) + layer = _DummyVerfLayer(feat) + result = get_kennzeichen_menge(layer) + self.assertEqual(result, {"X999"}) + + +# --------------------------------------------------------------------------- +# Tests: erstelle_verfahrensgebiet_aus_auswahl +# --------------------------------------------------------------------------- + +class TestErstelle(unittest.TestCase): + + def _patch_all(self, test_func, union_empty=False): + """Patcht _erstelle_memory_layer und QgsGeometry.unaryUnion.""" + import sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service as module + orig_erstelle = module._erstelle_memory_layer + orig_union = module.QgsGeometry.unaryUnion + + mem = _DummyVerfLayer() + module._erstelle_memory_layer = lambda _crs: mem + module.QgsGeometry.unaryUnion = staticmethod( + lambda _geoms: _DummyGeom(empty=union_empty) + ) + try: + test_func(mem) + finally: + module._erstelle_memory_layer = orig_erstelle + module.QgsGeometry.unaryUnion = staticmethod(orig_union) + + def test_success_counts(self): + """3 gültige Features → erfolgreich=3, fehlgeschlagen=0, 1 Union-Feature im Layer.""" + feats = [ + _DummyFeature("A001", feat_id=0), + _DummyFeature("A002", feat_id=1), + _DummyFeature("A003", feat_id=2), + ] + source = _DummySourceLayer(selected=feats) + + def run(mem): + layer, ok, fail = erstelle_verfahrensgebiet_aus_auswahl(source) + self.assertIs(layer, mem) + self.assertEqual(ok, 3) + self.assertEqual(fail, 0) + # Single-Feature-Modell: genau 1 Union-Feature + self.assertEqual(len(mem._features), 1) + + self._patch_all(run) + + def test_partial_failure_on_empty_geometry(self): + """2 ok + 1 leere Geometrie → erfolgreich=2, fehlgeschlagen=1.""" + feats = [ + _DummyFeature("A001"), + _DummyFeature("A002", geom_empty=True), + _DummyFeature("A003"), + ] + source = _DummySourceLayer(selected=feats) + + def run(mem): + _, ok, fail = erstelle_verfahrensgebiet_aus_auswahl(source) + self.assertEqual(ok, 2) + self.assertEqual(fail, 1) + + self._patch_all(run) + + def test_all_fail_on_empty_geometries(self): + """Alle Features haben leere Geometrie → erfolgreich=0, kein Feature im Layer.""" + feats = [_DummyFeature("A001", geom_empty=True)] + source = _DummySourceLayer(selected=feats) + + def run(mem): + _, ok, fail = erstelle_verfahrensgebiet_aus_auswahl(source) + self.assertEqual(ok, 0) + self.assertEqual(fail, 1) + self.assertEqual(len(mem._features), 0) # kein Union-Feature + + self._patch_all(run) + + +# --------------------------------------------------------------------------- +# Tests: ergaenze_verfahrensgebiet +# --------------------------------------------------------------------------- + +class TestErgaenzeVerfahrensgebiet(unittest.TestCase): + + def _patch_union(self): + import sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service as module + orig = module.QgsGeometry.unaryUnion + module.QgsGeometry.unaryUnion = staticmethod(lambda _g: _DummyGeom(empty=False)) + return module, orig + + def test_adds_only_new_ids(self): + """Auswahl enthält 1 neue + 1 bereits vorhandene ID → hinzugefügt=1.""" + verf = _DummyVerfLayer(_DummySingleFeature("A001", feat_id=1)) + selected = [ + _DummyFeature("A001"), # bereits vorhanden + _DummyFeature("A002"), # neu + ] + source = _DummySourceLayer(selected=selected) + module, orig = self._patch_union() + try: + _, hinzugefuegt, bereits_vorhanden, fehlgeschlagen = ergaenze_verfahrensgebiet( + verf, source + ) + finally: + module.QgsGeometry.unaryUnion = staticmethod(orig) + self.assertEqual(hinzugefuegt, 1) + self.assertEqual(bereits_vorhanden, 1) + self.assertEqual(fehlgeschlagen, 0) + + def test_skip_empty_geometry_counts_as_failed(self): + """Neue ID mit leerer Geometrie → fehlgeschlagen=1, hinzugefügt=0.""" + verf = _DummyVerfLayer() # leer + selected = [_DummyFeature("A001", geom_empty=True)] + source = _DummySourceLayer(selected=selected) + + _, hinzugefuegt, _, fehlgeschlagen = ergaenze_verfahrensgebiet(verf, source) + self.assertEqual(hinzugefuegt, 0) + self.assertEqual(fehlgeschlagen, 1) + + def test_all_already_present(self): + """Alle ausgewählten IDs bereits vorhanden → bereits_vorhanden=n, hinzugefügt=0.""" + verf = _DummyVerfLayer(_DummySingleFeature("A001,A002", feat_id=1)) + selected = [_DummyFeature("A001"), _DummyFeature("A002")] + source = _DummySourceLayer(selected=selected) + + _, hinzugefuegt, bereits_vorhanden, fehlgeschlagen = ergaenze_verfahrensgebiet( + verf, source + ) + self.assertEqual(hinzugefuegt, 0) + self.assertEqual(bereits_vorhanden, 2) + self.assertEqual(fehlgeschlagen, 0) + + +# --------------------------------------------------------------------------- +# Tests: entferne_aus_verfahrensgebiet +# --------------------------------------------------------------------------- + +class TestEntferneAusVerfahrensgebiet(unittest.TestCase): + + def test_removes_present_ids(self): + """Auswahl enthält 1 vorhandene ID → entfernt=1, nicht_enthalten=0.""" + verf = _DummyVerfLayer(_DummySingleFeature("A001,A002", feat_id=1)) + selected = [_DummyFeature("A001")] + source = _DummySourceLayer(selected=selected) + + _, entfernt, nicht_enthalten, fehlgeschlagen = entferne_aus_verfahrensgebiet( + verf, source + ) + self.assertEqual(entfernt, 1) + self.assertEqual(nicht_enthalten, 0) + self.assertEqual(fehlgeschlagen, 0) + + def test_counts_ids_not_in_layer(self): + """Auswahl enthält ID, die nicht im Verfahrensgebiet ist → nicht_enthalten=1.""" + verf = _DummyVerfLayer(_DummySingleFeature("A001", feat_id=1)) + selected = [ + _DummyFeature("A001"), # vorhanden + _DummyFeature("A999"), # nicht enthalten + ] + source = _DummySourceLayer(selected=selected) + + _, entfernt, nicht_enthalten, fehlgeschlagen = entferne_aus_verfahrensgebiet( + verf, source + ) + self.assertEqual(entfernt, 1) + self.assertEqual(nicht_enthalten, 1) + + def test_none_present_in_layer(self): + """Auswahl hat IDs, die alle nicht im Layer sind → entfernt=0, nicht_enthalten=n.""" + verf = _DummyVerfLayer(_DummySingleFeature("X001", feat_id=1)) + selected = [_DummyFeature("A999"), _DummyFeature("B888")] + source = _DummySourceLayer(selected=selected) + + _, entfernt, nicht_enthalten, fehlgeschlagen = entferne_aus_verfahrensgebiet( + verf, source + ) + self.assertEqual(entfernt, 0) + self.assertEqual(nicht_enthalten, 2) + + def test_empty_selection_removes_nothing(self): + """Leere Auswahl → nichts entfernt, nichts nicht_enthalten.""" + verf = _DummyVerfLayer(_DummySingleFeature("A001", feat_id=1)) + source = _DummySourceLayer(selected=[]) + + _, entfernt, nicht_enthalten, fehlgeschlagen = entferne_aus_verfahrensgebiet( + verf, source + ) + self.assertEqual(entfernt, 0) + self.assertEqual(nicht_enthalten, 0) + + def test_removes_last_id_deletes_feature(self): + """Wird das letzte Kennzeichen entfernt, wird das Feature gelöscht.""" + verf = _DummyVerfLayer(_DummySingleFeature("A001", feat_id=1)) + selected = [_DummyFeature("A001")] + source = _DummySourceLayer(selected=selected) + + _, entfernt, _, _ = entferne_aus_verfahrensgebiet(verf, source) + self.assertEqual(entfernt, 1) + # Feature soll gelöscht worden sein + self.assertIn(1, verf._deleted_ids) + + +# --------------------------------------------------------------------------- +# Tests: ersetze_verfahrensgebiet +# --------------------------------------------------------------------------- + +class TestErsetze(unittest.TestCase): + + def test_ersetze_delegates_to_erstelle(self): + """ersetze_verfahrensgebiet gibt dasselbe Ergebnis wie erstelle zurück.""" + feats = [_DummyFeature("A001")] + source = _DummySourceLayer(selected=feats) + + import sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service as module + orig_erstelle = module._erstelle_memory_layer + orig_union = module.QgsGeometry.unaryUnion + mem = _DummyVerfLayer() + module._erstelle_memory_layer = lambda _crs: mem + module.QgsGeometry.unaryUnion = staticmethod(lambda _g: _DummyGeom(empty=False)) + + try: + layer, ok, fail = ersetze_verfahrensgebiet(source) + self.assertIs(layer, mem) + self.assertEqual(ok, 1) + self.assertEqual(fail, 0) + finally: + module._erstelle_memory_layer = orig_erstelle + module.QgsGeometry.unaryUnion = staticmethod(orig_union) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_working_tab.py b/tests/test_working_tab.py index 683dd27..c2c23cc 100644 --- a/tests/test_working_tab.py +++ b/tests/test_working_tab.py @@ -82,9 +82,17 @@ class TestWorkingTab(unittest.TestCase): def _make_tab(self): tab = WorkingTab.__new__(WorkingTab) tab.btn_verf_alkis = _DummyButton() + tab.btn_flurst_alkis = _DummyButton() + tab.btn_verf_aus_auswahl = _DummyButton() + tab.btn_verf_aus_liste = _DummyButton() tab.verf_layer_combo = _DummyCombo() tab.haken_verf = object() + tab.haken_flurst = object() tab.setze_haken = lambda *_args, **_kwargs: None + # Pruefmanager-Mock: verarbeite() wird im Test ggf. überschrieben + tab.pruefmanager = types.SimpleNamespace( + verarbeite=lambda ergebnis: ergebnis + ) return tab def test_set_busy_updates_button_state(self): @@ -151,6 +159,281 @@ class TestWorkingTab(unittest.TestCase): mock_set_layer_id.assert_called_once_with("") + # ------------------------------------------------------------------ + # handle_flurst_alkis + # ------------------------------------------------------------------ + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.success") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.flurstueck_alkis") + def test_handle_flurst_alkis_success_sets_haken_and_style( + self, mock_alkis, mock_iface, mock_project, mock_apply_style, mock_success + ): + tab = self._make_tab() + haken_calls = [] + tab.setze_haken = lambda lbl, v: haken_calls.append((lbl, v)) + + layer = _DummyLayer(valid=True) + mock_alkis.return_value = (layer, LoadStatus.FIRST) + mock_project.return_value = _DummyProject() + mock_iface.mapCanvas.return_value = _DummyCanvas() + + tab.handle_flurst_alkis() + + mock_apply_style.assert_called_once_with(layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml") + mock_success.assert_called_once() + self.assertEqual(len(haken_calls), 1) + self.assertTrue(haken_calls[0][1]) # haken gesetzt + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.info") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.flurstueck_alkis") + def test_handle_flurst_alkis_existing_layer_keep_path(self, mock_alkis, mock_info): + tab = self._make_tab() + haken_calls = [] + tab.setze_haken = lambda lbl, v: haken_calls.append((lbl, v)) + + layer = _DummyLayer(valid=True) + mock_alkis.return_value = (layer, LoadStatus.KEEP) + + tab.handle_flurst_alkis() + + mock_info.assert_called_once() + self.assertEqual(len(haken_calls), 1) + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.flurstueck_alkis") + def test_handle_flurst_alkis_invalid_input_returns_early(self, mock_alkis): + """Status NONE (z. B. Nutzerabbruch) → kein Layer, kein Haken.""" + tab = self._make_tab() + haken_calls = [] + tab.setze_haken = lambda lbl, v: haken_calls.append((lbl, v)) + mock_alkis.return_value = (None, LoadStatus.NONE) + + tab.handle_flurst_alkis() + + self.assertEqual(len(haken_calls), 0) + # Button muss am Ende wieder freigegeben sein (finally) + self.assertTrue(tab.btn_flurst_alkis.isEnabled()) + + # ------------------------------------------------------------------ + # handle_verf_aus_auswahl – Vorbedingungsprüfungen + # ------------------------------------------------------------------ + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.warning") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_verfahrensgebiet_from_selection_requires_active_layer( + self, mock_iface, mock_warning + ): + """Kein aktiver Layer → warning, kein Layer wird erstellt.""" + tab = self._make_tab() + mock_iface.activeLayer.return_value = None + + tab.handle_verf_aus_auswahl() + + mock_warning.assert_called_once() + self.assertIn("aktiver Layer", mock_warning.call_args[0][1]) + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.warning") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_flurstueck_layer") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_verfahrensgebiet_from_selection_requires_required_fields( + self, mock_iface, mock_pruefe, mock_warning + ): + """Layer ohne Pflichtfelder → warning, kein Layer wird erstellt.""" + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis as PruefErg + tab = self._make_tab() + active = _DummyLayer(valid=True) + mock_iface.activeLayer.return_value = active + mock_pruefe.return_value = PruefErg( + ok=False, aktion="felder_fehlen", meldung="Feld fehlt" + ) + + tab.handle_verf_aus_auswahl() + + mock_warning.assert_called_once() + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.warning") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_auswahl") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_flurstueck_layer") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_verfahrensgebiet_from_selection_requires_selection( + self, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, mock_warning + ): + """Keine Flurstück-Auswahl → warning, kein Layer erstellt.""" + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis as PruefErg + tab = self._make_tab() + active = _DummyLayer(valid=True) + mock_iface.activeLayer.return_value = active + mock_pruefe_layer.return_value = PruefErg(ok=True, aktion="ok") + mock_pruefe_auswahl.return_value = PruefErg( + ok=False, aktion="layer_leer", meldung="Keine Auswahl" + ) + + tab.handle_verf_aus_auswahl() + + mock_warning.assert_called_once() + + # ------------------------------------------------------------------ + # handle_verf_aus_auswahl – Erstlauf (kein bestehender Layer) + # ------------------------------------------------------------------ + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QMessageBox") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.erstelle_verfahrensgebiet_aus_auswahl") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_auswahl") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_flurstueck_layer") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_verfahrensgebiet_from_selection_new_layer_success( + self, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, + mock_erstelle, mock_project_inst, mock_apply_style, mock_msgbox + ): + """Erstlauf ohne bestehenden Layer → Layer wird erstellt und hinzugefügt.""" + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis as PruefErg + tab = self._make_tab() + haken_calls = [] + tab.setze_haken = lambda lbl, v: haken_calls.append(v) + active = _DummyLayer(valid=True) + mock_iface.activeLayer.return_value = active + mock_iface.mapCanvas.return_value = _DummyCanvas() + mock_pruefe_layer.return_value = PruefErg(ok=True, aktion="ok") + mock_pruefe_auswahl.return_value = PruefErg(ok=True, aktion="ok", meldung="3 Flurstücke") + + new_layer = _DummyLayer(valid=True, layer_id="new_verf") + mock_erstelle.return_value = (new_layer, 3, 0) + + project = _DummyProject() + project.mapLayersByName = lambda _name: [] # kein bestehender Layer + mock_project_inst.return_value = project + + tab.handle_verf_aus_auswahl() + + self.assertIn(new_layer, project.added) + mock_apply_style.assert_called_once_with(new_layer, "verfahrensgebiet.qml") + self.assertTrue(haken_calls[0]) + + # ------------------------------------------------------------------ + # handle_verf_aus_auswahl – Ergänzen / Entfernen / Ersetzen + # ------------------------------------------------------------------ + + def _make_entscheidungs_ergebnis(self, aktion: str): + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis as PruefErg + return PruefErg(ok=True, aktion=aktion) + + def _setup_existierenden_layer(self, tab, mock_iface, mock_pruefe_layer, + mock_pruefe_auswahl, mock_project_inst, + mock_canvas, entscheidung: str): + """Gemeinsames Setup für Delta-Operationen mit bestehendem Layer.""" + from sn_basis.modules.pruef_ergebnis import pruef_ergebnis as PruefErg + active = _DummyLayer(valid=True) + mock_iface.activeLayer.return_value = active + mock_iface.mapCanvas.return_value = mock_canvas + mock_pruefe_layer.return_value = PruefErg(ok=True, aktion="ok") + mock_pruefe_auswahl.return_value = PruefErg(ok=True, aktion="ok", meldung="2 Flurstücke") + + verf_layer = _DummyLayer(valid=True, layer_id="verf_existing") + project = _DummyProject() + project.added = [] + project.mapLayersByName = lambda _: [verf_layer] + project.removeMapLayer = lambda lid: None + mock_project_inst.return_value = project + + # Pruefmanager gibt Entscheidung zurück + tab.pruefmanager.verarbeite = lambda _e: self._make_entscheidungs_ergebnis(entscheidung) + + return active, verf_layer, project + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QMessageBox") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.ergaenze_verfahrensgebiet") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_auswahl") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_flurstueck_layer") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_verfahrensgebiet_from_selection_existing_layer_ergaenzen_dispatch( + self, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, + mock_project_inst, mock_ergaenze, mock_apply_style, mock_msgbox + ): + """Entscheidung 'ergaenzen' → ergaenze_verfahrensgebiet wird aufgerufen.""" + tab = self._make_tab() + canvas = _DummyCanvas() + active, verf_layer, _ = self._setup_existierenden_layer( + tab, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, + mock_project_inst, canvas, "verfahrensgebiet_ergaenzen" + ) + mock_ergaenze.return_value = (verf_layer, 2, 0, 0) + + tab.handle_verf_aus_auswahl() + + mock_ergaenze.assert_called_once_with(verf_layer, active) + mock_apply_style.assert_called_once_with(verf_layer, "verfahrensgebiet.qml") + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QMessageBox") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.entferne_aus_verfahrensgebiet") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_auswahl") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_flurstueck_layer") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_verfahrensgebiet_from_selection_existing_layer_entfernen_dispatch( + self, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, + mock_project_inst, mock_entferne, mock_apply_style, mock_msgbox + ): + """Entscheidung 'entfernen' → entferne_aus_verfahrensgebiet wird aufgerufen.""" + tab = self._make_tab() + canvas = _DummyCanvas() + active, verf_layer, _ = self._setup_existierenden_layer( + tab, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, + mock_project_inst, canvas, "verfahrensgebiet_entfernen" + ) + mock_entferne.return_value = (verf_layer, 1, 1, 0) + + tab.handle_verf_aus_auswahl() + + mock_entferne.assert_called_once_with(verf_layer, active) + mock_apply_style.assert_called_once_with(verf_layer, "verfahrensgebiet.qml") + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QMessageBox") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.ersetze_verfahrensgebiet") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_auswahl") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.pruefe_flurstueck_layer") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_verfahrensgebiet_from_selection_existing_layer_ersetzen_dispatch( + self, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, + mock_project_inst, mock_ersetze, mock_apply_style, mock_msgbox + ): + """Entscheidung 'ersetzen' → alter Layer entfernt, neuer hinzugefügt.""" + tab = self._make_tab() + canvas = _DummyCanvas() + active, verf_layer, project = self._setup_existierenden_layer( + tab, mock_iface, mock_pruefe_layer, mock_pruefe_auswahl, + mock_project_inst, canvas, "verfahrensgebiet_ersetzen" + ) + removed = [] + project.removeMapLayer = lambda lid: removed.append(lid) + new_layer = _DummyLayer(valid=True, layer_id="new_verf_replaced") + mock_ersetze.return_value = (new_layer, 3, 0) + + tab.handle_verf_aus_auswahl() + + mock_ersetze.assert_called_once_with(active) + self.assertIn(verf_layer.id(), removed) + self.assertIn(new_layer, project.added) + mock_apply_style.assert_called_once_with(new_layer, "verfahrensgebiet.qml") + + # ------------------------------------------------------------------ + # handle_verf_aus_liste + # ------------------------------------------------------------------ + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.show_info_dialog") + def test_aus_liste_button_shows_not_implemented_hint(self, mock_info): + tab = self._make_tab() + tab.handle_verf_aus_liste() + mock_info.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index b0519db..1f47ee9 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -8,9 +8,21 @@ from sn_basis.functions import Qgis, QgsProject #QgsMessageLog from sn_basis.functions.qgisui_wrapper import QgsMapLayerComboBox from sn_basis.functions.qgiscore_wrapper import QgsMapLayerProxyModel from sn_basis.functions.verfahrensgebiet_manager import get_verfahrensgebiet_layer_id, set_verfahrensgebiet_layer_id +from sn_basis.functions.dialog_wrapper import show_info_dialog +from sn_basis.modules.Pruefmanager import Pruefmanager +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis from qgis.utils import iface from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import verfahrensgebiet_alkis, LoadStatus +from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis +from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( + pruefe_flurstueck_layer, + pruefe_auswahl, + erstelle_verfahrensgebiet_aus_auswahl, + ergaenze_verfahrensgebiet, + entferne_aus_verfahrensgebiet, + ersetze_verfahrensgebiet, +) from sn_basis.functions.message_wrapper import success, info, warning, error from sn_basis.functions.ly_style_wrapper import apply_style @@ -24,17 +36,12 @@ class WorkingTab(QWidget): tab_title = "Bearbeitung" def __init__(self, parent=None): - """Initialisiert den Working-Tab mit Verfahrensgebiet-Verwaltung. - - Args: - parent: Übergeordnetes Widget - """ super().__init__(parent) layout = QVBoxLayout() layout.setAlignment(Qt.AlignmentFlag.AlignTop) - # Abschnitt 1: Verfahrensgebiet + # Abschnitt 1: Verfahrensgebiet laden row_verf_header = QHBoxLayout() lbl_verf = QLabel("Verfahrensgebiet") self.haken_verf = self._haken_label() @@ -64,8 +71,7 @@ class WorkingTab(QWidget): self.verf_layer_combo.layerChanged.connect(self._on_verf_layer_changed) layout.addWidget(self.verf_layer_combo) - # Abschnitt 2: Flurstücke (Platzhalter für spätere Implementierung) - + # Abschnitt 2: Flurstücke row_flurst_header = QHBoxLayout() lbl_flurst = QLabel("Flurstücke") self.haken_flurst = self._haken_label() @@ -81,20 +87,39 @@ class WorkingTab(QWidget): row_flurst_buttons.addWidget(self.btn_flurst_shape) layout.addLayout(row_flurst_buttons) + # Abschnitt 3: Verfahrensgebiet erstellen + row_verf_erstellen_header = QHBoxLayout() + lbl_verf_erstellen = QLabel("Verfahrensgebiet erstellen") + row_verf_erstellen_header.addWidget(lbl_verf_erstellen) + row_verf_erstellen_header.addStretch() + layout.addLayout(row_verf_erstellen_header) + + row_verf_erstellen_buttons = QHBoxLayout() + self.btn_verf_aus_auswahl = QPushButton("aus Auswahl") + self.btn_verf_aus_liste = QPushButton("Aus Liste") + row_verf_erstellen_buttons.addWidget(self.btn_verf_aus_auswahl) + row_verf_erstellen_buttons.addWidget(self.btn_verf_aus_liste) + layout.addLayout(row_verf_erstellen_buttons) + layout.addStretch() self.setLayout(layout) + # Pruefmanager für interaktive Entscheidungen (Ergänzen/Entfernen/Ersetzen) + self.pruefmanager = Pruefmanager(ui_modus="qgis", parent=self) + # Signale verbinden self.btn_verf_alkis.clicked.connect(self.handle_verf_alkis) + self.btn_flurst_alkis.clicked.connect(self.handle_flurst_alkis) + self.btn_verf_aus_auswahl.clicked.connect(self.handle_verf_aus_auswahl) + self.btn_verf_aus_liste.clicked.connect(self.handle_verf_aus_liste) # self.btn_verf_shape.clicked.connect(self.handle_verf_shape) - # self.btn_flurst_alkis.clicked.connect(self.handle_flurst_alkis) # self.btn_flurst_shape.clicked.connect(self.handle_flurst_shape) # Beim Init: aktuelle Verfahrensgebiet-Layer-ID laden und im Dropdown anzeigen self._restore_verf_layer_selection() def handle_verf_alkis(self): - self._set_busy(self.btn_verf_alkis, True) + self._set_busy(self.btn_verf_alkis, True, "Aus ALKIS laden") try: layer, status = verfahrensgebiet_alkis(self) if not layer or not layer.isValid(): @@ -102,20 +127,16 @@ class WorkingTab(QWidget): return if status in (LoadStatus.FIRST, LoadStatus.RELOAD): - # Gemeinsame Logik für erstmaliges und erneutes Laden QgsProject.instance().addMapLayer(layer) iface.mapCanvas().setExtent(layer.extent()) - #iface.mapCanvas().refresh() apply_style(layer, "verfahrensgebiet.qml") iface.mapCanvas().refresh() self.setze_haken(self.haken_verf, True) - # Dropdown + Variable explizit synchronisieren. self.verf_layer_combo.setLayer(layer) self._on_verf_layer_changed(layer) - # Unterschied nur in der Meldung msg = "erstmalig geladen" if status == LoadStatus.FIRST else "neu geladen" success("Verfahrensgebiet", f"Layer wurde {msg}.", duration=5) @@ -129,14 +150,23 @@ class WorkingTab(QWidget): warning("Verfahrensgebiet", "Layer konnte nicht geladen werden.", duration=None) finally: - self._set_busy(self.btn_verf_alkis, False) + self._set_busy(self.btn_verf_alkis, False, "Aus ALKIS laden") - def _set_busy(self, button: QPushButton, busy: bool): + def _set_busy(self, button: QPushButton, busy: bool, original_text: str = "Aus ALKIS laden") -> None: + """Setzt den Busy-State eines Buttons. + + Parameters + ---------- + button : + Der zu sperrende/entsperrende Button. + busy : bool + True zum Sperren (Text → "Lade ..."), False zum Freigeben. + original_text : str + Text, der nach dem Freigeben wiederhergestellt wird. + Jeder Button gibt seinen eigenen Originaltext explizit an. + """ button.setEnabled(not busy) - if busy: - button.setText("Lade ...") - else: - button.setText("Aus ALKIS laden") + button.setText("Lade ..." if busy else original_text) def _haken_label(self) -> QLabel: label = QLabel() @@ -167,7 +197,6 @@ class WorkingTab(QWidget): """Beim Tab-Init: Lade die zuletzt gespeicherte Verfahrensgebiet-Layer-ID.""" layer_id = get_verfahrensgebiet_layer_id() if layer_id: - # Versuche, den Layer im Projekt zu finden try: project = QgsProject.instance() layer = project.mapLayer(layer_id) @@ -175,3 +204,167 @@ class WorkingTab(QWidget): self.verf_layer_combo.setLayer(layer) except Exception: pass + + # ------------------------------------------------------------------ + # Flurstücke-Handler + # ------------------------------------------------------------------ + + def handle_flurst_alkis(self) -> None: + """Lädt Flurstücke aus ALKIS-WFS anhand von Gemarkungsschlüsseln.""" + self._set_busy(self.btn_flurst_alkis, True, "Aus ALKIS laden") + try: + layer, status = flurstueck_alkis(self) + if not layer or not layer.isValid(): + return + + if status in (LoadStatus.FIRST, LoadStatus.RELOAD): + QgsProject.instance().addMapLayer(layer) + # iface.mapCanvas().setExtent(layer.extent()) # Kartenausschnitt bleibt unverändert + apply_style(layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml") + iface.mapCanvas().refresh() + self.setze_haken(self.haken_flurst, True) + msg = "erstmalig geladen" if status == LoadStatus.FIRST else "neu geladen" + success("Flurstücke", f"Layer wurde {msg}.", duration=5) + + elif status == LoadStatus.KEEP: + self.setze_haken(self.haken_flurst, True) + info("Flurstücke", "Vorhandener Layer wurde behalten.", duration=4) + + finally: + self._set_busy(self.btn_flurst_alkis, False, "Aus ALKIS laden") + + # ------------------------------------------------------------------ + # Verfahrensgebiet-erstellen-Handler + # ------------------------------------------------------------------ + + def handle_verf_aus_auswahl(self) -> None: + """Erstellt oder aktualisiert den Verfahrensgebiet-Layer aus der Flurstück-Auswahl.""" + self._set_busy(self.btn_verf_aus_auswahl, True, "aus Auswahl") + try: + # 1. Aktiven Layer ermitteln + active_layer = iface.activeLayer() + if not active_layer: + warning( + "Verfahrensgebiet erstellen", + "Kein aktiver Layer. Bitte den Flurstück-Layer aktivieren.", + duration=None, + ) + return + + # 2. Vorbedingungen via Layerpruefer + ergebnis = pruefe_flurstueck_layer(active_layer) + if not ergebnis.ok: + warning("Verfahrensgebiet erstellen", ergebnis.meldung, duration=None) + return + + # 3. Auswahl prüfen + ergebnis_auswahl = pruefe_auswahl(active_layer) + if not ergebnis_auswahl.ok: + warning("Verfahrensgebiet erstellen", ergebnis_auswahl.meldung, duration=None) + return + + # 4. Bestehenden Verfahrensgebiet-Layer prüfen + existing_layers = QgsProject.instance().mapLayersByName("Verfahrensgebiet") + + if not existing_layers: + # Erstlauf: neuen Memory-Layer erzeugen + layer, erfolgreich, fehlgeschlagen = erstelle_verfahrensgebiet_aus_auswahl( + active_layer + ) + if not layer or not layer.isValid(): + error("Verfahrensgebiet erstellen", "Layer konnte nicht erstellt werden.") + return + QgsProject.instance().addMapLayer(layer) + apply_style(layer, "verfahrensgebiet.qml") + iface.mapCanvas().refresh() + self.verf_layer_combo.setLayer(layer) + self._on_verf_layer_changed(layer) + self.setze_haken(self.haken_verf, True) + QMessageBox.information( + self, + "Verfahrensgebiet erstellt", + f"Verfahrensgebiet aus Auswahl erstellt.\n" + f"Erfolgreich: {erfolgreich}\n" + f"Fehlgeschlagen: {fehlgeschlagen}", + ) + return + + # 5. Bestehender Layer: Nutzerentscheidung via Pruefmanager + entscheidung_ergebnis = self.pruefmanager.verarbeite( + pruef_ergebnis( + ok=False, + aktion="verfahrensgebiet_layer_existiert", + meldung=( + "Ein Verfahrensgebiet-Layer existiert bereits.\n" + "Was soll mit der Auswahl geschehen?" + ), + kontext=existing_layers[0], + ) + ) + + aktion = entscheidung_ergebnis.aktion + verf_layer = existing_layers[0] + + if aktion == "verfahrensgebiet_abbrechen" or not entscheidung_ergebnis.ok: + return + + elif aktion == "verfahrensgebiet_ergaenzen": + _, hinzugefuegt, bereits_vorhanden, fehlgeschlagen = ergaenze_verfahrensgebiet( + verf_layer, active_layer + ) + apply_style(verf_layer, "verfahrensgebiet.qml") + iface.mapCanvas().refresh() + self.setze_haken(self.haken_verf, True) + QMessageBox.information( + self, + "Flurstücke ergänzt", + f"Hinzugefügt: {hinzugefuegt}\n" + f"Bereits vorhanden (übersprungen): {bereits_vorhanden}\n" + f"Fehlgeschlagen: {fehlgeschlagen}", + ) + + elif aktion == "verfahrensgebiet_entfernen": + _, entfernt, nicht_enthalten, fehlgeschlagen = entferne_aus_verfahrensgebiet( + verf_layer, active_layer + ) + apply_style(verf_layer, "verfahrensgebiet.qml") + iface.mapCanvas().refresh() + QMessageBox.information( + self, + "Flurstücke entfernt", + f"Entfernt: {entfernt}\n" + f"Nicht im Verfahrensgebiet (übersprungen): {nicht_enthalten}\n" + f"Fehlgeschlagen: {fehlgeschlagen}", + ) + + elif aktion == "verfahrensgebiet_ersetzen": + for lyr in existing_layers: + QgsProject.instance().removeMapLayer(lyr.id()) + layer, erfolgreich, fehlgeschlagen = ersetze_verfahrensgebiet(active_layer) + if not layer or not layer.isValid(): + error("Verfahrensgebiet erstellen", "Layer konnte nicht erstellt werden.") + return + QgsProject.instance().addMapLayer(layer) + apply_style(layer, "verfahrensgebiet.qml") + iface.mapCanvas().refresh() + self.verf_layer_combo.setLayer(layer) + self._on_verf_layer_changed(layer) + self.setze_haken(self.haken_verf, True) + QMessageBox.information( + self, + "Verfahrensgebiet ersetzt", + f"Verfahrensgebiet neu erstellt.\n" + f"Erfolgreich: {erfolgreich}\n" + f"Fehlgeschlagen: {fehlgeschlagen}", + ) + + finally: + self._set_busy(self.btn_verf_aus_auswahl, False, "aus Auswahl") + + def handle_verf_aus_liste(self) -> None: + """Platzhalter – Verfahrensgebiet aus Liste erstellen (noch nicht implementiert).""" + show_info_dialog( + "Noch nicht implementiert", + "Die Funktion 'Aus Liste' steht in einer zukünftigen Version zur Verfügung.", + parent=self, + ) From 1d7489f4d6fcc97a8b4072431d9e68d195cd1085 Mon Sep 17 00:00:00 2001 From: release-bot Date: Tue, 21 Apr 2026 20:22:04 +0000 Subject: [PATCH 04/22] Release 26.4.3-unstable --- changelog.txt | 2 ++ metadata.txt | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/changelog.txt b/changelog.txt index bc7df54..f72ad11 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.4.3-unstable: +--- Version 26.4.2-testing: --- Version 26.4.1-unstable: diff --git a/metadata.txt b/metadata.txt index 475e0b4..31a47b3 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,11 +1,11 @@ [general] -version=26.4.2-testing +version=26.4.3-unstable name=LNO Sachsen | Verfahrensgebiet description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet" author=Daniel Helbig, Michael Otto homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet/issues -repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet/src/branch/testing/ +repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet/src/branch/unstable/ qgisMinimumVersion=3.40 experimental=true From 610e70ec5e316d45612f1e439de666783a18c699 Mon Sep 17 00:00:00 2001 From: release-bot Date: Wed, 22 Apr 2026 06:44:01 +0000 Subject: [PATCH 05/22] Release 26.4.4-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index f72ad11..7273711 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.4.4-unstable: +--- Version 26.4.3-unstable: --- Version 26.4.2-testing: diff --git a/metadata.txt b/metadata.txt index 31a47b3..c1565ed 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.4.3-unstable +version=26.4.4-unstable name=LNO Sachsen | Verfahrensgebiet description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet" author=Daniel Helbig, Michael Otto From bcbfa4c49edf8c511ae08ba36aa2cf2e4ca6357d Mon Sep 17 00:00:00 2001 From: Daniel Helbig Date: Wed, 22 Apr 2026 09:03:23 +0200 Subject: [PATCH 06/22] =?UTF-8?q?QgsMaxVersion=20erg=C3=A4nzt?= 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 c1565ed..38775ac 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_Verfahrensgebiet tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet/issues repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet/src/branch/unstable/ - +QGISMaximumVersion=4.99 qgisMinimumVersion=3.40 experimental=true deprecated=false From 168b6ec46ca69b1f98da4d9f9b3607d2b034c40f Mon Sep 17 00:00:00 2001 From: release-bot Date: Wed, 22 Apr 2026 07:05:13 +0000 Subject: [PATCH 07/22] Release 26.4.5-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 7273711..1428266 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.4.5-unstable: +--- Version 26.4.4-unstable: --- Version 26.4.3-unstable: diff --git a/metadata.txt b/metadata.txt index 38775ac..59a3338 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.4.4-unstable +version=26.4.5-unstable name=LNO Sachsen | Verfahrensgebiet description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet" author=Daniel Helbig, Michael Otto From f8bdcde115fe16e3d6506b0e199c79b70255f0cc Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 22 Apr 2026 14:53:45 +0200 Subject: [PATCH 08/22] verfahrensgebiet aus Flst --- functions/flurstueck_alkis.py | 218 ++++++++--- functions/gemarkung_alkis.py | 207 ++++++++++ functions/grenzpunkt_alkis.py | 280 ++++++++++++++ functions/verfahrensgebiet_alkis.py | 25 +- functions/verfahrensgebiet_alkis_komplett.py | 309 +++++++++++++++ tests/test_flurstueck_alkis.py | 89 +++++ tests/test_gemarkung_alkis.py | 207 ++++++++++ tests/test_grenzpunkt_alkis.py | 301 +++++++++++++++ tests/test_verfahrensgebiet_alkis_komplett.py | 360 ++++++++++++++++++ tests/test_working_tab.py | 66 +++- ui/tabs/working_tab.py | 106 ++++-- 11 files changed, 2081 insertions(+), 87 deletions(-) create mode 100644 functions/gemarkung_alkis.py create mode 100644 functions/grenzpunkt_alkis.py create mode 100644 functions/verfahrensgebiet_alkis_komplett.py create mode 100644 tests/test_gemarkung_alkis.py create mode 100644 tests/test_grenzpunkt_alkis.py create mode 100644 tests/test_verfahrensgebiet_alkis_komplett.py diff --git a/functions/flurstueck_alkis.py b/functions/flurstueck_alkis.py index 40876b2..71fd805 100644 --- a/functions/flurstueck_alkis.py +++ b/functions/flurstueck_alkis.py @@ -1,15 +1,35 @@ """sn_verfahrensgebiet/functions/flurstueck_alkis.py – ALKIS-Integration für Flurstücke. -Lädt Flurstücke aus dem ALKIS-WFS-Dienst (adv:AX_Flurstueck) anhand von +Lädt Flurstücke aus dem ALKIS-WFS-Dienst (``adv:AX_Flurstueck``) anhand von Gemarkungsschlüsseln, die der Nutzer interaktiv eingibt. Der WFS-Layer wird direkt (nicht als Memory-Layer) ins Projekt geladen, da die einzelnen Features für die spätere Auswahl benötigt werden. + +**Erweiterung gegenüber dem Original:** + +- ``vorgabe_schluessel``: Optional vorausgefüllte Gemarkungsnummern (z. B. aus + :func:`~sn_verfahrensgebiet.functions.gemarkung_alkis.gemarkung_schluessel_und_namen_by_bbox`). + Der Dialog wird mit diesem Vorschlag vorausgefüllt; der Nutzer kann die + Eingabe ändern oder ergänzen. + +- ``vorgabe_namen``: Zugehörige Bezeichnungen, die im Dialog als Hinweis + angezeigt werden (kein eigenständiges Feld, nur Anzeigetext). + +- ``bbox``: Optionaler BBox-Parameter, der – sofern übergeben – als + zusätzlicher serverseitiger Filter in die WFS-URI eingebettet wird. So + werden ausschließlich Flurstücke geladen, die den Verfahrensgebiets-Rahmen + überlappen. """ -from sn_basis.functions import QgsVectorLayer, QgsProject +from __future__ import annotations + +from typing import List, Optional, Tuple + +from sn_basis.functions import QgsVectorLayer, QgsProject, QgsFeature from sn_basis.functions import QMessageBox from sn_basis.functions.dialog_wrapper import ask_text +from sn_basis.functions.message_wrapper import info as _info # LoadStatus aus verfahrensgebiet_alkis wiederverwenden (identische Semantik) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus @@ -49,65 +69,173 @@ def _check_existing_flurstueck_layer(tab_widget): return existing_layers[0], LoadStatus.KEEP -def flurstueck_alkis(tab_widget): - """Lädt Flurstücke aus ALKIS-WFS anhand von Gemarkungsschlüsseln. +def _lade_flurstuecke_per_gemarkung(schluessel_liste: List[str]): + """Lädt Flurstücke für mehrere Gemarkungen, eine WFS-Anfrage pro Schlüssel. - Der Nutzer gibt einen oder mehrere Gemarkungsschlüssel (kommagetrennt) ein. - Der WFS wird per SQL-Filter auf - ``gemarkung_ax_gemarkung_schluessel_gemarkungsnummer IN (...)`` abgefragt. + Vermeidet das WFS-Feature-Limit einer einzelnen Abfrage mit mehreren + Gemarkungsnummern. Alle Features werden in einem Memory-Layer zusammengeführt. + + Parameters + ---------- + schluessel_liste : list of str + 4-stellige Gemarkungsnummern. + + Returns + ------- + QgsVectorLayer or None + Memory-Layer mit allen Flurstücken, oder ``None`` wenn nichts geladen wurde. + """ + alle_feats: list = [] + schema_fields = None + schema_crs = "EPSG:25833" + + for schluessel in schluessel_liste: + uri = ( + f"url='{ALKIS_NAS_URL}' typename='{FLST_TYPENAME}' srsname='EPSG:25833' " + f"sql=SELECT * FROM AX_Flurstueck " + f"WHERE gemarkung_ax_gemarkung_schluessel_gemarkungsnummer = '{schluessel}'" + ) + wfs = QgsVectorLayer(uri, f"_TmpFlst{schluessel}", "WFS") + if not wfs.isValid(): + continue + feats = list(wfs.getFeatures()) + if not feats: + continue + if schema_fields is None: + schema_fields = wfs.fields() + schema_crs = wfs.crs().authid() + _info( + "Flurstücke", + f"Gemarkung {schluessel}: {len(feats)} Flurstücke geladen.", + duration=3, + ) + alle_feats.append((wfs.fields(), feats)) + + if not alle_feats or schema_fields is None: + return None + + # Memory-Layer mit dem Feldschema des ersten WFS-Layers aufbauen + mem = QgsVectorLayer(f"Polygon?crs={schema_crs}", FLST_LAYER_NAME, "memory") + pr = mem.dataProvider() + pr.addAttributes(list(schema_fields)) + mem.updateFields() + mem_fields = mem.fields() + + neue_features = [] + for wfs_fields, feats in alle_feats: + for feat in feats: + new_f = QgsFeature(mem_fields) + new_f.setGeometry(feat.geometry()) + for i in range(wfs_fields.count()): + fname = wfs_fields.field(i).name() + idx = mem_fields.indexFromName(fname) + if idx >= 0: + try: + new_f.setAttribute(idx, feat[fname]) + except Exception: + pass + neue_features.append(new_f) + + pr.addFeatures(neue_features) + mem.updateExtents() + return mem + + +def flurstueck_alkis( + tab_widget, + vorgabe_schluessel: Optional[List[str]] = None, + vorgabe_namen: Optional[List[str]] = None, + bbox=None, + ohne_dialog: bool = False, +) -> Tuple[Optional[QgsVectorLayer], LoadStatus]: + """Lädt Flurstücke aus dem ALKIS-WFS anhand von Gemarkungsschlüsseln. + + Jede Gemarkungsnummer wird als separate WFS-Anfrage gestellt, um das + WFS-Feature-Limit bei einer Gesamtabfrage zu umgehen. Alle Ergebnisse + werden in einem Memory-Layer zusammengeführt. Parameters ---------- tab_widget : Eltern-Widget für Dialoge. + vorgabe_schluessel : list of str, optional + Vorausgefüllte Gemarkungsnummern (4-stellig). Im Dialog als Standard + angezeigt; bei ``ohne_dialog=True`` direkt verwendet. + vorgabe_namen : list of str, optional + Menschenlesbare Gemarkungsbezeichnungen für den Dialog-Hinweistext. + bbox : + Nicht mehr verwendet (WFS unterstützt keinen BBox-Filter). Bleibt + für Rückwärtskompatibilität in der Signatur erhalten. + ohne_dialog : bool, optional + ``True``: Überspringt die Gemarkungsschlüssel-Nutzerabfrage und verwendet + ``vorgabe_schluessel`` direkt. Vorhandene Layer werden ohne Rückfrage + ersetzt. Gedacht für die automatische Pipeline + (``verfahrensgebiet_alkis_komplett``). Standard: ``False``. Returns ------- Tuple[Optional[QgsVectorLayer], LoadStatus] - Geladener WFS-Layer und Ladestatus. - Bei Fehlern ist der Layer None und der Status LoadStatus.NONE. + Memory-Layer mit allen Flurstücken und Ladestatus. + Bei Fehlern ist der Layer ``None`` und der Status :attr:`LoadStatus.NONE`. """ - # 1. Vorhandenen Layer prüfen - existing, status = _check_existing_flurstueck_layer(tab_widget) - if status == LoadStatus.KEEP: - return existing, status + # --- Schritt 1: Vorhandenen Layer behandeln ---------------------------- + if ohne_dialog: + project = QgsProject.instance() + hatte_layer = bool(project.mapLayersByName(FLST_LAYER_NAME)) + for lyr in list(project.mapLayersByName(FLST_LAYER_NAME)): + project.removeMapLayer(lyr.id()) + status = LoadStatus.RELOAD if hatte_layer else LoadStatus.FIRST + else: + existing, status = _check_existing_flurstueck_layer(tab_widget) + if status == LoadStatus.KEEP: + return existing, status - # 2. Gemarkungsschlüssel abfragen - eingabe, ok = ask_text( - "Gemarkungsschlüssel", - "Gemarkungsschlüssel eingeben (mehrere mit Komma trennen):", - parent=tab_widget, - ) - if not ok or not eingabe.strip(): - QMessageBox.warning( - tab_widget, - "Abgebrochen", - "Bitte mindestens einen gültigen Gemarkungsschlüssel eingeben.", + # --- Schritt 2: Gemarkungsschlüssel bestimmen ------------------------- + if ohne_dialog: + # Pipeline-Modus: vorgabe_schluessel direkt verwenden, kein Dialog + if not vorgabe_schluessel: + return None, LoadStatus.NONE + schluessel_liste = [s.strip() for s in vorgabe_schluessel if s.strip()] + if not schluessel_liste: + return None, LoadStatus.NONE + else: + # Interaktiver Modus: Dialog mit Vorschlag + default_text = ", ".join(vorgabe_schluessel) if vorgabe_schluessel else "" + hinweis = "" + if vorgabe_namen: + hinweis = " (Vorschlag: " + ", ".join(vorgabe_namen) + ")" + label = f"Gemarkungsschlüssel eingeben (mehrere mit Komma trennen):{hinweis}" + + eingabe, ok = ask_text( + "Gemarkungsschlüssel", + label, + default_text=default_text, + parent=tab_widget, ) - return None, LoadStatus.NONE + if not ok or not eingabe.strip(): + QMessageBox.warning( + tab_widget, + "Abgebrochen", + "Bitte mindestens einen gültigen Gemarkungsschlüssel eingeben.", + ) + return None, LoadStatus.NONE - schluessel_liste = [s.strip() for s in eingabe.split(",") if s.strip()] - if not schluessel_liste: - QMessageBox.warning(tab_widget, "Fehler", "Keine gültigen Schlüssel gefunden.") - return None, LoadStatus.NONE + schluessel_liste = [s.strip() for s in eingabe.split(",") if s.strip()] + if not schluessel_liste: + QMessageBox.warning(tab_widget, "Fehler", "Keine gültigen Schlüssel gefunden.") + return None, LoadStatus.NONE - schluessel_str = ", ".join(f"'{s}'" for s in schluessel_liste) + # --- Schritt 3: Flurstücke gemarkungsweise laden ---------------------- + layer = _lade_flurstuecke_per_gemarkung(schluessel_liste) - # 3. WFS-URI aufbauen und Layer laden - wfs_uri = ( - f"url='{ALKIS_NAS_URL}' typename='{FLST_TYPENAME}' srsname='EPSG:25833' " - f"sql=SELECT * FROM AX_Flurstueck " - f"WHERE gemarkung_ax_gemarkung_schluessel_gemarkungsnummer IN ({schluessel_str})" - ) - layer = QgsVectorLayer(wfs_uri, FLST_LAYER_NAME, "WFS") - - if not layer.isValid(): - QMessageBox.critical( - tab_widget, - "Fehler", - "Flurstück-Layer konnte nicht geladen werden.\n" - "Bitte Gemarkungsschlüssel und Netzwerkverbindung prüfen.", - ) + if not layer or not layer.isValid() or layer.featureCount() == 0: + if not ohne_dialog: + QMessageBox.critical( + tab_widget, + "Fehler", + "Flurstück-Layer konnte nicht geladen werden.\n" + "Bitte Gemarkungsschlüssel und Netzwerkverbindung prüfen.", + ) return None, LoadStatus.NONE return layer, status diff --git a/functions/gemarkung_alkis.py b/functions/gemarkung_alkis.py new file mode 100644 index 0000000..fe688f6 --- /dev/null +++ b/functions/gemarkung_alkis.py @@ -0,0 +1,207 @@ +"""sn_verfahrensgebiet/functions/gemarkung_alkis.py – ALKIS-Gemarkungsabfrage. + +Stellt Funktionen bereit, um Katasterbezirk-Objekte aus dem sächsischen +ALKIS-vereinfachten WFS zu laden und daraus Gemarkungsschlüssel zu extrahieren, +die als Vorschlag für die Flurstücksabfrage (Phase 2) dienen. + +**Dienst:** ``https://geodienste.sachsen.de/aaa/public_alkis/vereinf/wfs`` +**Typename:** ``Katasterbezirk`` + +Dieser Dienst hat – im Gegensatz zu ``adv:AX_Gemarkung`` aus dem NAS-Dienst – +tatsächliche Geometrien. Die client-seitige Verschneidung mit der VG-Geometrie +ist damit möglich. + +**Attributkonvention:** + +- ``schluessel``: vollständiger Katasterbezirksschlüssel (z.B. ``140640001``), + die letzten 4 Stellen entsprechen der + ``gemarkung_ax_gemarkung_schluessel_gemarkungsnummer`` im NAS-Flurstücksdienst. +- ``ave_name``: Gemarkungsbezeichnung. + +**Wichtig:** Diese Funktion stellt keine Nutzerabfragen. Sie gibt im +Fehlerfall eine leere Liste zurück. +""" + +from __future__ import annotations + +from typing import List, Optional, Tuple + +from sn_basis.functions import QgsVectorLayer, QgsGeometry +from sn_basis.functions.message_wrapper import warning as _warn, info as _info + +ALKIS_VEREINF_URL = "https://geodienste.sachsen.de/aaa/public_alkis/vereinf/wfs" +KATASTERBEZIRK_TYPENAME = "ave:KatasterBezirk" + +# Attributnamen im vereinfachten Dienst +_ATTR_SCHLUESSEL = "schluessel" # vollständiger Schlüssel, letzte 4 = Gemarkungsnummer +_ATTR_NAME = "ave_name" + + +# --------------------------------------------------------------------------- +# Interne Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _extract_schluessel_und_name(feature) -> Tuple[str, str]: + """Extrahiert Gemarkungsnummer (letzte 4 Stellen von ``schluessel``) + und Namen (``ave_name``) aus einem Katasterbezirk-Feature. + + Returns + ------- + Tuple[str, str] + ``(gemarkungsnummer_4stellig, name)`` – leer wenn nicht ermittelbar. + """ + def _get(field: str) -> str: + try: + val = feature[field] + return str(val).strip() if val else "" + except (KeyError, Exception): + return "" + + schluessel = _get(_ATTR_SCHLUESSEL) + name = _get(_ATTR_NAME) + + # Letzte 4 Stellen des Schlüssels = Gemarkungsnummer + nummer = schluessel[-4:] if len(schluessel) >= 4 else schluessel + + return nummer, name + + +def _load_all_gemarkung_layer(): + """Lädt alle Katasterbezirk-Objekte aus dem vereinfachten ALKIS-WFS. + + Sachsen hat ≈ 2 000 Gemarkungen. Der vereinfachte Dienst liefert + tatsächliche Geometrien, sodass client-seitige Verschneidung möglich ist. + + Returns + ------- + QgsVectorLayer or None + Geladener Layer oder ``None`` bei Ungültigkeit. + """ + uri = ( + f"url='{ALKIS_VEREINF_URL}' typename='{KATASTERBEZIRK_TYPENAME}' " + f"srsname='EPSG:25833'" + ) + layer = QgsVectorLayer(uri, "_TempKatasterbezirk", "WFS") + return layer if (layer and layer.isValid()) else None + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- + +def gemarkung_schluessel_und_namen_von_vg_geometrie( + vg_geometrie, +) -> List[Tuple[str, str]]: + """Gibt Gemarkungsschlüssel und -namen aller Gemarkungen zurück, die die + VG-Geometrie schneiden. + + Da der WFS-Dienst keine serverseitigen BBox- oder Geometriefilter + unterstützt, werden alle ``AX_Gemarkung``-Objekte geladen und client-seitig + per ``QgsGeometry.intersects`` gefiltert. + + Der Aufruf ist *ohne Nutzerinteraktion* – im Fehlerfall wird eine leere + Liste zurückgegeben, damit der aufrufende Workflow manuellen Fallback + ermöglicht. + + Parameters + ---------- + vg_geometrie : + ``QgsGeometry`` der Verfahrensgebiets-Union (aus den Roh-WFS-Features + von ``AX_BauRaumOderBodenordnungsrecht``). In EPSG:25833. + + Returns + ------- + List[Tuple[str, str]] + Liste von ``(gemarkungsnummer, bezeichnung)``-Tupeln, aufsteigend + sortiert nach Nummer. Enthält keine Duplikate. Leer bei Fehler. + + Examples + -------- + >>> from qgis.core import QgsGeometry + >>> vg_geom = QgsGeometry.fromWkt("POLYGON((414000 5657000, 415000 5657000, ...))") + >>> result = gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom) + >>> # result: [('1234', 'Beispielgemarkung'), ('5678', 'Andere Gemarkung')] + """ + if vg_geometrie is None or vg_geometrie.isEmpty(): + _warn("Gemarkung", "[DIAG] VG-Geometrie ist None oder leer – Gemarkungssuche abgebrochen.", duration=8) + return [] + + try: + layer = _load_all_gemarkung_layer() + except Exception as exc: + _warn("Gemarkung", f"[DIAG] Fehler beim Laden des Gemarkung-Layers: {exc}", duration=8) + return [] + + if not layer: + _warn("Gemarkung", "[DIAG] AX_Gemarkung-Layer ungültig oder nicht ladbar.", duration=8) + return [] + + # Feature-Anzahl ermitteln (0 = WFS gibt nichts zurück) + alle_features = list(layer.getFeatures()) + if not alle_features: + _warn( + "Gemarkung", + "[DIAG] AX_Gemarkung-Layer ist gültig, aber enthält 0 Features. " + "WFS-Dienst liefert ohne Filter möglicherweise kein Ergebnis.", + duration=10, + ) + return [] + + # Feldnamen der ersten Features loggen + feldnamen = [alle_features[0].fields().field(i).name() for i in range(alle_features[0].fields().count())] + hat_geom = sum(1 for f in alle_features[:20] if f.hasGeometry() and not f.geometry().isEmpty()) + _info( + "Gemarkung", + f"[DIAG] {len(alle_features)} Gemarkungen geladen. " + f"Von ersten 20 haben {hat_geom} Geometrie. " + f"Alle Felder ({len(feldnamen)}): {feldnamen}", + duration=15, + ) + + result: dict[str, str] = {} + kein_schnitt = 0 + for feat in alle_features: + try: + gem_geom = feat.geometry() + if gem_geom is None or gem_geom.isEmpty(): + continue + if not gem_geom.intersects(vg_geometrie): + kein_schnitt += 1 + continue + except Exception as exc: + _warn("Gemarkung", f"[DIAG] intersects() Fehler: {exc}", duration=8) + continue + + nummer, name = _extract_schluessel_und_name(feat) + if nummer and nummer not in result: + result[nummer] = name + + if not result: + _warn( + "Gemarkung", + f"[DIAG] 0 Gemarkungen nach Verschneidung. {kein_schnitt} Features ohne Schnitt. " + f"VG-Geometrie gültig: {not vg_geometrie.isNull()}, " + f"VG-Typ: {vg_geometrie.type()}, VG-WKT (Anfang): {vg_geometrie.asWkt()[:80]}", + duration=15, + ) + + return sorted(result.items(), key=lambda t: t[0]) + + +def gemarkung_schluessel_von_vg_geometrie(vg_geometrie) -> List[str]: + """Gibt ausschließlich die Gemarkungsnummern für die VG-Geometrie zurück. + + Convenience-Wrapper um + :func:`gemarkung_schluessel_und_namen_von_vg_geometrie`. + + Parameters + ---------- + vg_geometrie : + ``QgsGeometry`` des Verfahrensgebiets in EPSG:25833. + + Returns + ------- + List[str] + Sortierte Liste der Gemarkungsnummern. Leer bei Fehler oder keinem Treffer. + """ + return [nr for nr, _ in gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geometrie)] diff --git a/functions/grenzpunkt_alkis.py b/functions/grenzpunkt_alkis.py new file mode 100644 index 0000000..cd28575 --- /dev/null +++ b/functions/grenzpunkt_alkis.py @@ -0,0 +1,280 @@ +"""sn_verfahrensgebiet/functions/grenzpunkt_alkis.py – ALKIS-Grenzpunktabfrage. + +Lädt Grenzpunkte aus dem ALKIS-NAS-WFS in zwei Schritten: + +1. ``adv:AX_PunktortTA`` per BBox laden. + Diese Objektart enthält die eigentliche Koordinate (Geometrie) sowie das + Attribut ``gehoertZu``, das per xlink-Referenz auf die GML-ID des + zugehörigen ``AX_Grenzpunkt``-Objekts verweist. + +2. GML-IDs aus ``gehoertZu`` extrahieren → ``AX_Grenzpunkt`` per + ``gml_id IN (...)`` abfragen (batching, ~25 IDs/Batch). + +3. Beide Layer werden über ``gehoertZu ↔ gml_id`` verknüpft (Join) und als + einzelner angereicherter Layer zurückgegeben. + +**Einschränkungen des WFS-Dienstes:** + +- ``AX_Grenzpunkt`` hat **keine eigene Geometrie** → kein BBOX-Filter möglich. +- Das Attribut ``gehoertZu`` in ``AX_PunktortTA`` ist serverseitig **nicht + filterbar** (bestätigt durch WFS-Fehlermeldung ``ErrorInPropertyName``). +- Der serverseitige GML-ID-Filter (``ResourceId``) ist aktiviert + (``ImplementsResourceId=TRUE`` in den Capabilities). + +**Strategie:** BBox → PunktortTA → GML-IDs → Grenzpunkt-Batches → +client-seitiger Join. +""" + +from __future__ import annotations + +import math +from typing import List, Optional, Tuple + +from sn_basis.functions import QgsVectorLayer, QgsProject +from sn_basis.functions import QMessageBox +from sn_basis.functions.message_wrapper import info, warning + +ALKIS_NAS_URL = "https://geodienste.sachsen.de/aaa/public_alkis/nas/wfs" +PUNKTORT_TA_TYPENAME = "adv:AX_PunktortTA" +GRENZPUNKT_TYPENAME = "adv:AX_Grenzpunkt" + +PUNKTORT_LAYER_NAME = "PunktortTA" +GRENZPUNKT_LAYER_NAME = "Grenzpunkte" + +# Attributname in AX_PunktortTA, der auf AX_Grenzpunkt verweist +_ATTR_GEHOERT_ZU = "gehoertZu" +# Attributname für die GML-ID in AX_Grenzpunkt +_ATTR_GML_ID = "gml_id" + +# Batch-Größe für ResourceId-Abfragen (URL-Längenlimit) +_BATCH_GROESSE = 25 + + +# --------------------------------------------------------------------------- +# Interne Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _extrahiere_gml_ids(punktort_layer) -> List[str]: + """Extrahiert GML-IDs aus dem ``gehoertZu``-Attribut des PunktortTA-Layers. + + Der WFS gibt ``gehoertZu`` üblicherweise als ``xlink:href``-Referenz im + Format ``urn:adv:oid:DEXXXXXXXXXXXXXXXXXX`` oder als flachen Wert. + Es wird der letzte Bestandteil nach dem letzten ``:`` verwendet. + + Parameters + ---------- + punktort_layer : + Geladener ``AX_PunktortTA``-WFS-Layer. + + Returns + ------- + List[str] + Deduplizierte, sortierte Liste von GML-IDs. + """ + ids: set[str] = set() + for feat in punktort_layer.getFeatures(): + try: + val = feat[_ATTR_GEHOERT_ZU] + if val: + raw = str(val).strip() + # xlink:href hat Format "urn:adv:oid:DE..." oder reiner GML-ID-Wert + gml_id = raw.rsplit(":", 1)[-1] if ":" in raw else raw + if gml_id: + ids.add(gml_id) + except (KeyError, Exception): + continue + return sorted(ids) + + +def _lade_grenzpunkte_per_ids(id_liste: List[str]) -> Optional[object]: + """Lädt ``AX_Grenzpunkt``-Features für die angegebenen GML-IDs. + + Die ID-Liste wird in Batches der Größe :data:`_BATCH_GROESSE` aufgeteilt, + um URL-Längenlimits zu umgehen. Alle Batch-Ergebnisse werden zu einem + Memory-Layer zusammengeführt. + + Parameters + ---------- + id_liste : + GML-IDs der gesuchten ``AX_Grenzpunkt``-Objekte. + + Returns + ------- + QgsVectorLayer | None + Memory-Layer mit allen gefundenen Grenzpunkten oder ``None`` bei Fehler. + """ + from sn_basis.functions import QgsFeature, QgsField + from sn_basis.functions.qt_wrapper import QVariant + + alle_features: List = [] + feld_namen: List[str] = [] + + anzahl_batches = math.ceil(len(id_liste) / _BATCH_GROESSE) + for i in range(anzahl_batches): + batch = id_liste[i * _BATCH_GROESSE: (i + 1) * _BATCH_GROESSE] + ids_str = ", ".join(f"'{gid}'" for gid in batch) + uri = ( + f"url='{ALKIS_NAS_URL}' typename='{GRENZPUNKT_TYPENAME}' " + f"srsname='EPSG:25833' " + f"sql=SELECT * FROM AX_Grenzpunkt WHERE gml_id IN ({ids_str})" + ) + tmp = QgsVectorLayer(uri, "_TmpGP", "WFS") + if not tmp or not tmp.isValid(): + continue + + if not feld_namen: + feld_namen = [f.name() for f in tmp.fields()] + + for feat in tmp.getFeatures(): + alle_features.append(feat) + + if not alle_features: + return None + + # Ergebnis-Memory-Layer aufbauen (ohne Geometrie, da AX_Grenzpunkt keine hat) + mem_uri = "Point?crs=EPSG:25833" if _hat_geometrie(alle_features[0]) else "none" + result_layer = QgsVectorLayer(mem_uri, GRENZPUNKT_LAYER_NAME, "memory") + pr = result_layer.dataProvider() + + felder = [QgsField(n, QVariant.String) for n in feld_namen if n] + pr.addAttributes(felder) + result_layer.updateFields() + pr.addFeatures(alle_features) + result_layer.updateExtents() + + return result_layer + + +def _hat_geometrie(feature) -> bool: + """Gibt ``True`` zurück, wenn das Feature eine nicht-leere Geometrie hat.""" + try: + g = feature.geometry() + return g is not None and not g.isEmpty() + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- + +def grenzpunkt_alkis(tab_widget, extent=None) -> Tuple[Optional[object], Optional[object]]: + """Lädt Grenzpunkte aus dem ALKIS-WFS für den angegebenen Begrenzungsrahmen. + + Ablauf: + + 1. ``AX_PunktortTA`` per BBox laden (enthält Koordinaten + Referenz auf + ``AX_Grenzpunkt`` via ``gehoertZu``). + 2. GML-IDs aus ``gehoertZu`` extrahieren. + 3. ``AX_Grenzpunkt`` per ID-Liste in Batches laden. + 4. Beide Layer ins Projekt einfügen. Der Caller ist verantwortlich dafür, + den Join in der QGIS-Projektkonfiguration einzurichten oder die + Verknüpfung client-seitig vorzunehmen. + + Wenn kein ``extent`` übergeben wird, fragt die Funktion nach dem aktuellen + Verfahrensgebiet-Layer und nutzt dessen BBox. + + Parameters + ---------- + tab_widget : + Eltern-Widget für Dialoge. + extent : + Optional. ``QgsRectangle`` in EPSG:25833. Wenn ``None``, wird der + aktive Verfahrensgebiet-Layer gesucht und dessen Extent genutzt. + + Returns + ------- + Tuple[Optional[punktort_layer], Optional[grenzpunkt_layer]] + Beide geladenen Layer (bereits im Projekt) oder ``(None, None)`` + bei Fehler. + """ + project = QgsProject.instance() + + # BBox bestimmen + if extent is None: + vg_layers = project.mapLayersByName("Verfahrensgebiet") + if not vg_layers: + QMessageBox.warning( + tab_widget, + "Kein Verfahrensgebiet", + "Bitte zuerst ein Verfahrensgebiet laden, um die BBox abzuleiten.", + ) + return None, None + extent = vg_layers[0].extent() + + xmin = extent.xMinimum() + ymin = extent.yMinimum() + xmax = extent.xMaximum() + ymax = extent.yMaximum() + + # ----------------------------------------------------------------------- + # Schritt 1: AX_PunktortTA per BBox laden + # ----------------------------------------------------------------------- + info("Grenzpunkte", "Schritt 1/3: PunktortTA per BBox laden …", duration=3) + uri_pta = ( + f"url='{ALKIS_NAS_URL}' typename='{PUNKTORT_TA_TYPENAME}' " + f"srsname='EPSG:25833' " + f"bbox={xmin},{ymin},{xmax},{ymax},EPSG:25833" + ) + punktort_layer = QgsVectorLayer(uri_pta, PUNKTORT_LAYER_NAME, "WFS") + + if not punktort_layer or not punktort_layer.isValid(): + QMessageBox.critical( + tab_widget, + "Fehler", + "PunktortTA-Layer konnte nicht geladen werden.\n" + "Bitte Netzwerkverbindung und Ausdehnung prüfen.", + ) + return None, None + + anzahl_pta = punktort_layer.featureCount() + if anzahl_pta == 0: + QMessageBox.warning( + tab_widget, + "Keine Grenzpunkte", + "Im Bereich des Verfahrensgebiets wurden keine PunktortTA-Objekte gefunden.", + ) + return None, None + + # ----------------------------------------------------------------------- + # Schritt 2: GML-IDs extrahieren + # ----------------------------------------------------------------------- + info("Grenzpunkte", "Schritt 2/3: GML-IDs aus gehoertZu extrahieren …", duration=3) + gml_ids = _extrahiere_gml_ids(punktort_layer) + + if not gml_ids: + QMessageBox.warning( + tab_widget, + "Keine Verknüpfungen", + f"{anzahl_pta} PunktortTA-Objekte gefunden, aber kein 'gehoertZu'-Attribut.\n" + "Bitte Attributnamen im WFS prüfen.", + ) + # PunktortTA trotzdem zurückgeben + project.addMapLayer(punktort_layer) + return punktort_layer, None + + # ----------------------------------------------------------------------- + # Schritt 3: AX_Grenzpunkt per ID-Listen-Batches laden + # ----------------------------------------------------------------------- + info( + "Grenzpunkte", + f"Schritt 3/3: {len(gml_ids)} Grenzpunkte in " + f"{math.ceil(len(gml_ids) / _BATCH_GROESSE)} Batches laden …", + duration=3, + ) + grenzpunkt_layer = _lade_grenzpunkte_per_ids(gml_ids) + + if not grenzpunkt_layer: + warning( + "Grenzpunkte", + "Grenzpunkt-Daten konnten nicht geladen werden (IDs gefunden, aber WFS lieferte kein Ergebnis).", + duration=6, + ) + project.addMapLayer(punktort_layer) + return punktort_layer, None + + # Beide Layer ins Projekt + project.addMapLayer(punktort_layer) + project.addMapLayer(grenzpunkt_layer) + + return punktort_layer, grenzpunkt_layer diff --git a/functions/verfahrensgebiet_alkis.py b/functions/verfahrensgebiet_alkis.py index a7f4acf..9a24cce 100644 --- a/functions/verfahrensgebiet_alkis.py +++ b/functions/verfahrensgebiet_alkis.py @@ -60,23 +60,28 @@ def _check_existing_layer(tab_widget): def verfahrensgebiet_alkis(tab_widget): """Lädt Verfahrensgebiet aus ALKIS-WFS basierend auf Verfahrensnummer. - + Args: tab_widget: Parent-Widget für Dialoge und Meldungen - + Returns: - Tuple[Optional[QgsVectorLayer], LoadStatus]: Geladener Layer und Status + Tuple[Optional[QgsVectorLayer], LoadStatus, List[QgsFeature]]: + Geladener Memory-Layer, Lade-Status und Liste der Roh-WFS-Features + (für geometriebasierte Weiterverarbeitung, z.B. Gemarkungsschlüssel- + Ermittlung via Spatial-Join). Bei Abbruch oder Fehler: ``(None, + LoadStatus.NONE, [])``. Bei ``LoadStatus.KEEP`` werden keine + WFS-Features neu geladen; die Feature-Liste ist in diesem Fall leer. """ verfahrensnummer = get_variable("verfahrensnummer") if not verfahrensnummer: QMessageBox.critical(tab_widget, "Fehler", "In den Projekteigenschaften ist noch keine Verfahrensnummer eingetragen!") - return None, LoadStatus.NONE + return None, LoadStatus.NONE, [] # Vorhandenen Layer prüfen existing, status = _check_existing_layer(tab_widget) if status == LoadStatus.KEEP: - return existing, status + return existing, status, [] # WFS mit Filter laden wfs_uri_find = ( @@ -88,18 +93,18 @@ def verfahrensgebiet_alkis(tab_widget): if not temp_layer.isValid(): QMessageBox.critical(tab_widget, "Fehler", "Layer konnte nicht geladen werden.") - return None, LoadStatus.NONE + return None, LoadStatus.NONE, [] features = list(temp_layer.getFeatures()) if not features: QMessageBox.critical(tab_widget, "Fehler", f"Verfahrenskennzeichen {verfahrensnummer} nicht im WFS gefunden.") - return None, LoadStatus.NONE + return None, LoadStatus.NONE, [] union_geom = QgsGeometry.unaryUnion([f.geometry() for f in features]) if union_geom is None or union_geom.isEmpty(): QMessageBox.critical(tab_widget, "Fehler", "Keine Geometrien zum Verschmelzen gefunden.") - return None, LoadStatus.NONE + return None, LoadStatus.NONE, [] # Memory-Layer erzeugen crs = temp_layer.crs().authid() @@ -114,5 +119,5 @@ def verfahrensgebiet_alkis(tab_widget): pr.addFeatures([feat_union]) memory_layer.updateExtents() - # Status: FIRST oder RELOAD - return memory_layer, status + # Status: FIRST oder RELOAD; features für Gemarkungsermittlung weitergeben + return memory_layer, status, features diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py new file mode 100644 index 0000000..5a2b286 --- /dev/null +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -0,0 +1,309 @@ +"""sn_verfahrensgebiet/functions/verfahrensgebiet_alkis_komplett.py – Vollständiger VG-Lade-Workflow. + +Orchestriert den vollständigen Arbeitsablauf zum Laden eines Verfahrensgebiets +aus ALKIS mit anschließender Flurstücks-Verschneidung: + +1. Verfahrensgebiet aus ``AX_BauRaumOderBodenordnungsrecht`` per VKZ laden. +2. BBox des VG ableiten. +3. Gemarkungsschlüssel per BBox aus ``AX_Gemarkung`` vorschlagen. +4. Flurstücke per Dialog (mit Vorschlag) laden. +5. Flurstücke räumlich auf das VG einschränken (``selectByLocation``). +6. Aus der Auswahl den enriched Verfahrensgebiet-Memory-Layer erzeugen. +7. ALKIS-VG-Hilfs-Layer entfernen, enriched Layer zurückgeben. + +Der enriched Layer enthält die Felder ``flurstueckskennzeichen`` und +``gemarkung_ax_gemarkung_schluessel_gemarkungsnummer`` des +:mod:`~sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service`-Schemas +und ist damit vollständig kompatibel mit ``ergaenze_verfahrensgebiet`` und +``entferne_aus_verfahrensgebiet``. + +**Abbruchverhalten:** Jeder Schritt, der :attr:`LoadStatus.NONE` zurückgibt +oder kein Ergebnis liefert, bricht den Workflow ab. ALKIS-Hilfs-Layer werden +dabei gesäubert, damit das Projekt nicht in einem inkonsistenten Zustand +verbleibt. +""" + +from __future__ import annotations + +from typing import Optional, Tuple + +from sn_basis.functions import QgsProject +from sn_basis.functions import QMessageBox +from sn_basis.functions.message_wrapper import info, warning +from sn_basis.functions.variable_wrapper import get_variable + +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import ( + verfahrensgebiet_alkis, + LoadStatus, +) +from sn_verfahrensgebiet.functions.gemarkung_alkis import ( + gemarkung_schluessel_und_namen_von_vg_geometrie, +) +from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis +from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( + erstelle_verfahrensgebiet_aus_auswahl, + FELD_KENNZEICHEN, + VERF_LAYER_NAME, +) + +# Feld für das Verfahrenskennzeichen (VKZ) im enriched Layer +FELD_VKZ = "verfahrensnummer" + + +# --------------------------------------------------------------------------- +# Interne Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _entferne_hilfs_layer(project, layer_name: str) -> None: + """Entfernt alle Layer mit dem angegebenen Namen aus dem Projekt. + + Wird verwendet, um den temporären ALKIS-VG-Layer nach der Verschneidung + aus dem Projekt zu bereinigen. + + Parameters + ---------- + project : + Aktuelles QGIS-Projekt. + layer_name : + Anzeigename des zu entfernenden Layers. + """ + for lyr in project.mapLayersByName(layer_name): + project.removeMapLayer(lyr.id()) + + +def _select_by_location(flst_layer, vg_layer) -> int: + """Selektiert Flurstücke, die das Verfahrensgebiet überlappen oder darin liegen. + + Verwendet ``processing.run("native:selectbylocation")`` mit den Prädikaten + ``overlaps`` (5) und ``within`` (6). Flurstücke, die das VG nur berühren + (gemeinsame Grenzlinie oder -punkt, aber kein gemeinsames Inneres), werden + damit ausgeschlossen. + + Parameters + ---------- + flst_layer : + Flurstücks-WFS-Layer (Quelle der Selektion). + vg_layer : + Verfahrensgebiet-Memory-Layer (Schnittmaske). + + Returns + ------- + int + Anzahl der selektierten Features. + """ + try: + import processing # type: ignore[import] + + processing.run( + "native:selectbylocation", + { + "INPUT": flst_layer, + "PREDICATE": [5, 6], # 5 = overlaps, 6 = within; schließt reines Berühren aus + "INTERSECT": vg_layer, + "METHOD": 0, # 0 = neue Selektion + }, + ) + return flst_layer.selectedFeatureCount() + + except Exception: + # Fallback: keine Selektion (z. B. im Unit-Test-Modus) + return 0 + + +def _fuege_vkz_feld_hinzu(layer, vkz: str) -> None: + """Fügt das Feld ``verfahrensnummer`` zum enriched Layer hinzu und befüllt + es mit dem Verfahrenskennzeichen, sofern dieses nicht leer ist. + + Das Feld wird nur hinzugefügt, wenn ``vkz`` nicht leer ist, damit der + Layer ohne Projektvariable weiterhin funktioniert. + + Parameters + ---------- + layer : + Enriched Verfahrensgebiet-Memory-Layer (in-place geändert). + vkz : + Verfahrenskennzeichen aus der Projektvariable ``verfahrensnummer``. + """ + if not vkz: + return + + try: + from sn_basis.functions import QgsField + from sn_basis.functions.qt_wrapper import QVariant + + pr = layer.dataProvider() + pr.addAttributes([QgsField(FELD_VKZ, QVariant.String)]) + layer.updateFields() + + idx = layer.fields().indexFromName(FELD_VKZ) + if idx < 0: + return + + changes = {} + for feat in layer.getFeatures(): + changes[feat.id()] = {idx: vkz} + pr.changeAttributeValues(changes) + layer.updateFields() + + except Exception: + pass + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- + +def verfahrensgebiet_alkis_komplett( + tab_widget, +) -> Tuple[Optional[object], Optional[object], LoadStatus]: + """Vollständiger VG-Lade-Workflow: ALKIS-VG → BBox → Gemarkungen → + Flurstücke → Verschneidung → enriched Memory-Layer. + + Schrittfolge: + + 1. ``verfahrensgebiet_alkis(tab_widget)`` – Verfahrensgebiet temporär laden + 2. ``vg_layer.extent()`` – BBox ableiten + 3. ``gemarkung_schluessel_und_namen_by_bbox(extent)`` – Schlüssel vorschlagen + 4. ``flurstueck_alkis(tab_widget, vorgabe_schluessel, vorgabe_namen, bbox)`` – Flurstücke laden + 5. ``_select_by_location(flst_layer, vg_layer)`` – räumliche Selektion + 6. ``erstelle_verfahrensgebiet_aus_auswahl(flst_layer)`` – enriched Layer erzeugen + 7. Ggf. VKZ-Feld setzen, ALKIS-VG-Hilfs-Layer entfernen + + Parameters + ---------- + tab_widget : + Eltern-Widget für alle Dialoge (weitergereicht an Unteraufrufe). + + Returns + ------- + Tuple[Optional[enriched_layer], Optional[flst_layer], LoadStatus] + - ``enriched_layer``: Memory-Layer „Verfahrensgebiet" mit + ``flurstueckskennzeichen``- und ``gemarkung``-Feldern, oder ``None`` + bei Abbruch/Fehler. + - ``flst_layer``: Geladener WFS-Flurstück-Layer (bleibt im Projekt), + oder ``None``. + - ``LoadStatus``: Status des VG-Lade-Vorgangs (``FIRST``, ``RELOAD`` + oder ``NONE`` bei Abbruch). + + Notes + ----- + Der temporäre ALKIS-VG-Layer (aus ``AX_BauRaumOderBodenordnungsrecht``) + wird nach der Verschneidung aus dem Projekt entfernt. Im Projekt verbleiben + nur der enriched Memory-Layer und der Flurstücks-WFS-Layer. + """ + project = QgsProject.instance() + alkis_vg_layer = None + + try: + # ------------------------------------------------------------------ + # Schritt 1: VG aus ALKIS laden (Zwischenobjekt) + # ------------------------------------------------------------------ + info("VG laden", "Schritt 1/6: Verfahrensgebiet aus ALKIS laden …", duration=3) + alkis_vg_layer, vg_status, vg_features = verfahrensgebiet_alkis(tab_widget) + + if vg_status == LoadStatus.NONE or not alkis_vg_layer: + return None, None, LoadStatus.NONE + + # Temporär ins Projekt hinzufügen, damit selectByLocation ihn findet + if vg_status in (LoadStatus.FIRST, LoadStatus.RELOAD): + project.addMapLayer(alkis_vg_layer) + + # ------------------------------------------------------------------ + # Schritt 2 (entfernt): BBox-Ableitung war hier – WFS unterstützt kein + # BBox-Filter, daher wird der Extent nicht mehr benötigt. + # ------------------------------------------------------------------ + + # ------------------------------------------------------------------ + # Schritt 3: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln + # ------------------------------------------------------------------ + info("VG laden", "Schritt 2/6: Gemarkungsschlüssel ermitteln \u2026", duration=3) + # VG-Geometrie aus den Roh-WFS-Features bilden (Union, damit + # alle Teilflächen berücksichtigt werden) + from sn_basis.functions import QgsGeometry as _QgsGeometry + if vg_features: + vg_geom = _QgsGeometry.unaryUnion([f.geometry() for f in vg_features]) + else: + # Bei KEEP-Status keine WFS-Features vorhanden; Fallback auf Layergeometrie + vg_geom = next(alkis_vg_layer.getFeatures()).geometry() + schluessel_namen = gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom) + vorgabe_schluessel = [nr for nr, _ in schluessel_namen] + vorgabe_namen = [nm for _, nm in schluessel_namen if nm] + + if not vorgabe_schluessel: + warning( + "VG laden", + "Keine Gemarkungen in der BBox gefunden. Bitte Schlüssel manuell eingeben.", + duration=5, + ) + + # ------------------------------------------------------------------ + # Schritt 4: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt) + # ------------------------------------------------------------------ + info("VG laden", "Schritt 3/6: Flurstücke aus ALKIS laden …", duration=3) + flst_layer, flst_status = flurstueck_alkis( + tab_widget, + vorgabe_schluessel=vorgabe_schluessel, + vorgabe_namen=vorgabe_namen if vorgabe_namen else None, + # bbox wird nicht übergeben – WFS unterstützt keinen BBox-Filter + ohne_dialog=True, # kein Nutzer-Dialog in der Pipeline + ) + + if flst_status == LoadStatus.NONE or not flst_layer: + return None, None, LoadStatus.NONE + + # ------------------------------------------------------------------ + # Schritt 5: Räumliche Selektion + # ------------------------------------------------------------------ + info("VG laden", "Schritt 4/6: Flurstücke räumlich selektieren …", duration=3) + anzahl = _select_by_location(flst_layer, alkis_vg_layer) + + if anzahl == 0: + QMessageBox.warning( + tab_widget, + "Keine Flurstücke im VG", + "Es wurden keine Flurstücke gefunden, die das Verfahrensgebiet schneiden.\n" + "Bitte Gemarkungsschlüssel und Ausdehnung prüfen.", + ) + return None, flst_layer, LoadStatus.NONE + + # ------------------------------------------------------------------ + # Schritt 6: Enriched Memory-Layer aus Auswahl erzeugen + # ------------------------------------------------------------------ + info("VG laden", "Schritt 5/6: Verfahrensgebiet-Layer erzeugen …", duration=3) + enriched_layer, erfolgreich, fehlgeschlagen = erstelle_verfahrensgebiet_aus_auswahl( + flst_layer + ) + + if not enriched_layer or not enriched_layer.isValid(): + QMessageBox.critical( + tab_widget, + "Fehler", + "Verfahrensgebiet-Layer konnte nicht erstellt werden.", + ) + return None, flst_layer, LoadStatus.NONE + + # ------------------------------------------------------------------ + # Schritt 7: VKZ-Feld setzen (optional, wenn Projektvariable vorhanden) + # ------------------------------------------------------------------ + vkz = get_variable("verfahrensnummer") or "" + _fuege_vkz_feld_hinzu(enriched_layer, vkz) + + info( + "VG laden", + f"Schritt 6/6: Fertig. {erfolgreich} Flurstücke verarbeitet, " + f"{fehlgeschlagen} fehlgeschlagen.", + duration=5, + ) + + return enriched_layer, flst_layer, vg_status + + finally: + # Hilfs-Layer (ALKIS-VG) stets aus dem Projekt entfernen + if alkis_vg_layer is not None: + _entferne_hilfs_layer(project, VERF_LAYER_NAME) + # Auch nach Layername des ALKIS-Temp-Layers bereinigen + _entferne_hilfs_layer(project, "TempVerfahrensgebiet") + try: + project.removeMapLayer(alkis_vg_layer.id()) + except Exception: + pass diff --git a/tests/test_flurstueck_alkis.py b/tests/test_flurstueck_alkis.py index b49e6d0..63471dd 100644 --- a/tests/test_flurstueck_alkis.py +++ b/tests/test_flurstueck_alkis.py @@ -219,5 +219,94 @@ class TestFlurstueckAlkis(unittest.TestCase): self.assertEqual(project.removed, []) # nichts gelöscht + # ------------------------------------------------------------------ # + # 6. vorgabe_schluessel füllt ask_text-Default vor # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_vorgabe_schluessel_prefills_default_text( + self, mock_ask_text, mock_project, mock_wfs_layer_cls + ): + """vorgabe_schluessel=['1234','5678'] → ask_text erhält '1234, 5678' als default_text.""" + mock_ask_text.return_value = ("1234, 5678", True) + mock_project.return_value = _DummyProject() + mock_wfs_layer_cls.return_value = _DummyLayer(valid=True) + + flurstueck_alkis(None, vorgabe_schluessel=["1234", "5678"]) + + args, kwargs = mock_ask_text.call_args + # ask_text("title", label, default_text=..., parent=...) + # default_text wird als Keyword-Arg übergeben + default_text_arg = kwargs.get("default_text", args[2] if len(args) > 2 else "") + self.assertIn("1234", default_text_arg) + self.assertIn("5678", default_text_arg) + + # ------------------------------------------------------------------ # + # 7. vorgabe_namen erscheint im Label-Text # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_vorgabe_namen_appears_in_label( + self, mock_ask_text, mock_project, mock_wfs_layer_cls + ): + """vorgabe_namen=['Musterstadt'] → ask_text-label enthält den Namen.""" + mock_ask_text.return_value = ("9999", True) + mock_project.return_value = _DummyProject() + mock_wfs_layer_cls.return_value = _DummyLayer(valid=True) + + flurstueck_alkis(None, vorgabe_namen=["Musterstadt"]) + + args, kwargs = mock_ask_text.call_args + # label ist das 2. Positional-Argument + label_arg = args[1] if len(args) > 1 else kwargs.get("label", "") + self.assertIn("Musterstadt", label_arg) + + # ------------------------------------------------------------------ # + # 8. bbox wird in WFS-URI eingebettet # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_bbox_embedded_in_wfs_uri( + self, mock_ask_text, mock_project, mock_wfs_layer_cls + ): + """bbox-Parameter → WFS-URI enthält 'bbox=' Substring.""" + mock_ask_text.return_value = ("1234", True) + mock_project.return_value = _DummyProject() + mock_wfs_layer_cls.return_value = _DummyLayer(valid=True) + + class _Rect: + def xMinimum(self): return 414000.0 + def yMinimum(self): return 5657000.0 + def xMaximum(self): return 415000.0 + def yMaximum(self): return 5658000.0 + + flurstueck_alkis(None, bbox=_Rect()) + + uri_str = mock_wfs_layer_cls.call_args[0][0] + self.assertIn("bbox=", uri_str.lower()) + + # ------------------------------------------------------------------ # + # 9. Rückwärtskompatibilität: alle neuen Params optional # + # ------------------------------------------------------------------ # + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsVectorLayer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.QgsProject.instance") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_backwards_compatible_no_new_params( + self, mock_ask_text, mock_project, mock_wfs_layer_cls + ): + """flurstueck_alkis(tab) ohne neue Parameter → kein Fehler.""" + mock_ask_text.return_value = ("0000", True) + mock_project.return_value = _DummyProject() + mock_wfs_layer_cls.return_value = _DummyLayer(valid=True) + + layer, status = flurstueck_alkis(None) + + self.assertIsNotNone(layer) + self.assertEqual(status, LoadStatus.FIRST) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_gemarkung_alkis.py b/tests/test_gemarkung_alkis.py new file mode 100644 index 0000000..893f579 --- /dev/null +++ b/tests/test_gemarkung_alkis.py @@ -0,0 +1,207 @@ +"""sn_verfahrensgebiet/tests/test_gemarkung_alkis.py + +Unit-Tests für :mod:`sn_verfahrensgebiet.functions.gemarkung_alkis`. + +Getestete Szenarien: + +Erfolgsfall: +- WFS-Layer gültig + Features mit Schlüssel → Liste mit Tupeln +- Schlüssel werden dedupliziert und sortiert zurückgegeben +- ``gemarkung_schluessel_by_bbox`` gibt nur Nummern (keine Tupel) zurück + +Fehlerfälle: +- WFS-Layer nicht gültig (beide Varianten) → Fallback-Ladeversuch +- Fallback-Layer ebenfalls ungültig → leere Liste +- Alle Layer leer (featureCount == 0) → client-seitige Filterung schlägt leer +- Feature ohne Schlüssel-Attribut wird übersprungen +- Exception beim Layer-Laden → leere Liste +""" + +import sys +import types +import unittest +from unittest.mock import patch, MagicMock + +# --------------------------------------------------------------------------- +# Minimales QGIS-Mock-Setup +# --------------------------------------------------------------------------- +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_core = types.ModuleType("qgis.core") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + qgis_mod.core = qgis_core + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.core"] = qgis_core + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.functions.gemarkung_alkis import ( + gemarkung_schluessel_und_namen_by_bbox, + gemarkung_schluessel_by_bbox, + _extract_schluessel_und_name, +) + + +# --------------------------------------------------------------------------- +# Dummies +# --------------------------------------------------------------------------- + +class _DummyRect: + def xMinimum(self): return 414000.0 + def yMinimum(self): return 5657000.0 + def xMaximum(self): return 415000.0 + def yMaximum(self): return 5658000.0 + + +class _DummyFeature: + def __init__(self, attrs: dict): + self._attrs = attrs + + def __getitem__(self, key): + return self._attrs.get(key, None) + + +def _make_layer(valid=True, feature_count=2, features=None): + layer = MagicMock() + layer.isValid.return_value = valid + layer.featureCount.return_value = feature_count if valid else 0 + layer.getFeatures.return_value = features or [] + return layer + + +# --------------------------------------------------------------------------- +# Tests: _extract_schluessel_und_name +# --------------------------------------------------------------------------- + +class TestExtractSchluesselUndName(unittest.TestCase): + + def test_standard_attribute(self): + feat = _DummyFeature({"schluessel_gemarkungsnummer": "1234", "bezeichnung": "Mustergemarkung"}) + nr, name = _extract_schluessel_und_name(feat) + self.assertEqual(nr, "1234") + self.assertEqual(name, "Mustergemarkung") + + def test_fallback_attribute(self): + feat = _DummyFeature({"gemarkungsnummer": "5678", "gemarkung_bezeichnung": "Alternativ"}) + nr, name = _extract_schluessel_und_name(feat) + self.assertEqual(nr, "5678") + self.assertEqual(name, "Alternativ") + + def test_missing_attribute_returns_empty(self): + feat = _DummyFeature({}) + nr, name = _extract_schluessel_und_name(feat) + self.assertEqual(nr, "") + self.assertEqual(name, "") + + def test_none_value_returns_empty(self): + feat = _DummyFeature({"schluessel_gemarkungsnummer": None, "bezeichnung": None}) + nr, name = _extract_schluessel_und_name(feat) + self.assertEqual(nr, "") + self.assertEqual(name, "") + + +# --------------------------------------------------------------------------- +# Tests: gemarkung_schluessel_und_namen_by_bbox +# --------------------------------------------------------------------------- + +class TestGemarkungSchluesselbbox(unittest.TestCase): + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_gemarkung_layer_with_bbox") + def test_success_bbox_filter(self, mock_load_bbox): + """WFS-BBox-Filter liefert Features → korrekte Tupel-Liste.""" + feat1 = _DummyFeature({"schluessel_gemarkungsnummer": "1234", "bezeichnung": "Alpha"}) + feat2 = _DummyFeature({"schluessel_gemarkungsnummer": "5678", "bezeichnung": "Beta"}) + mock_load_bbox.return_value = _make_layer(valid=True, features=[feat1, feat2]) + + result = gemarkung_schluessel_und_namen_by_bbox(_DummyRect()) + + self.assertEqual(result, [("1234", "Alpha"), ("5678", "Beta")]) + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_all_gemarkung_layer") + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_gemarkung_layer_with_bbox") + def test_fallback_when_bbox_fails(self, mock_load_bbox, mock_load_all): + """BBox-Filter schlägt fehl → Fallback auf Volllade + client-seitige Filterung.""" + mock_load_bbox.return_value = None + + feat1 = _DummyFeature({"schluessel_gemarkungsnummer": "9999", "bezeichnung": "Fallback"}) + all_layer = _make_layer(valid=True, features=[feat1]) + # getFeatures mit FilterRect soll dasselbe zurückgeben + all_layer.getFeatures.return_value = [feat1] + mock_load_all.return_value = all_layer + + result = gemarkung_schluessel_und_namen_by_bbox(_DummyRect()) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0][0], "9999") + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_all_gemarkung_layer") + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_gemarkung_layer_with_bbox") + def test_both_layers_fail_returns_empty(self, mock_load_bbox, mock_load_all): + """Beide Lade-Strategien scheitern → leere Liste.""" + mock_load_bbox.return_value = None + mock_load_all.return_value = None + + result = gemarkung_schluessel_und_namen_by_bbox(_DummyRect()) + self.assertEqual(result, []) + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_gemarkung_layer_with_bbox") + def test_deduplication_and_sorting(self, mock_load_bbox): + """Doppelte Schlüssel werden dedupliziert, Ergebnis ist sortiert.""" + feat_a = _DummyFeature({"schluessel_gemarkungsnummer": "2222", "bezeichnung": "Z"}) + feat_b = _DummyFeature({"schluessel_gemarkungsnummer": "1111", "bezeichnung": "A"}) + feat_dup = _DummyFeature({"schluessel_gemarkungsnummer": "2222", "bezeichnung": "Z2"}) + mock_load_bbox.return_value = _make_layer(features=[feat_a, feat_b, feat_dup]) + + result = gemarkung_schluessel_und_namen_by_bbox(_DummyRect()) + + nummern = [r[0] for r in result] + self.assertEqual(nummern, sorted(nummern)) + self.assertEqual(len(result), 2) + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_gemarkung_layer_with_bbox") + def test_feature_without_schluessel_skipped(self, mock_load_bbox): + """Feature ohne Schlüssel-Attribut wird übersprungen.""" + feat_ok = _DummyFeature({"schluessel_gemarkungsnummer": "1234", "bezeichnung": "X"}) + feat_bad = _DummyFeature({}) + mock_load_bbox.return_value = _make_layer(features=[feat_ok, feat_bad]) + + result = gemarkung_schluessel_und_namen_by_bbox(_DummyRect()) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0][0], "1234") + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis._load_gemarkung_layer_with_bbox") + def test_exception_returns_empty(self, mock_load_bbox): + """Exception im Ladevorgang → leere Liste (kein Crash).""" + mock_load_bbox.side_effect = RuntimeError("WFS-Fehler") + + result = gemarkung_schluessel_und_namen_by_bbox(_DummyRect()) + self.assertEqual(result, []) + + +# --------------------------------------------------------------------------- +# Tests: gemarkung_schluessel_by_bbox (Convenience-Wrapper) +# --------------------------------------------------------------------------- + +class TestGemarkungSchluesselnurNummern(unittest.TestCase): + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis.gemarkung_schluessel_und_namen_by_bbox") + def test_returns_only_nummern(self, mock_full): + mock_full.return_value = [("1234", "Alpha"), ("5678", "Beta")] + + result = gemarkung_schluessel_by_bbox(_DummyRect()) + + self.assertEqual(result, ["1234", "5678"]) + + @patch("sn_verfahrensgebiet.functions.gemarkung_alkis.gemarkung_schluessel_und_namen_by_bbox") + def test_empty_input_returns_empty(self, mock_full): + mock_full.return_value = [] + + result = gemarkung_schluessel_by_bbox(_DummyRect()) + self.assertEqual(result, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_grenzpunkt_alkis.py b/tests/test_grenzpunkt_alkis.py new file mode 100644 index 0000000..7411d51 --- /dev/null +++ b/tests/test_grenzpunkt_alkis.py @@ -0,0 +1,301 @@ +"""sn_verfahrensgebiet/tests/test_grenzpunkt_alkis.py + +Unit-Tests für :mod:`sn_verfahrensgebiet.functions.grenzpunkt_alkis`. + +Getestete Szenarien: + +Erfolgsfall: +- Vollständiger Workflow: PunktortTA laden → GML-IDs extrahieren → + Grenzpunkte laden → beide Layer zurückgegeben + +Fehlerfälle: +- PunktortTA-Layer ungültig → (None, None) +- PunktortTA leer (featureCount == 0) → Warnung + (None, None) +- Keine gehoertZu-Referenzen → (punktort_layer, None) + Warnung +- Grenzpunkt-Layer nach IDs leer → (punktort_layer, None) + Warnung +- Kein Verfahrensgebiet-Layer für BBox-Ableitung → (None, None) + +Hilfsfunktionen: +- _extrahiere_gml_ids: xlink:href-Format wird korrekt geparst +- _extrahiere_gml_ids: plain GML-ID ohne Doppelpunkt wird direkt verwendet +- _extrahiere_gml_ids: leeres Feld wird übersprungen +""" + +import sys +import types +import unittest +from unittest.mock import patch, MagicMock + +# --------------------------------------------------------------------------- +# Minimales QGIS-Mock-Setup +# --------------------------------------------------------------------------- +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_core = types.ModuleType("qgis.core") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + qgis_mod.core = qgis_core + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.core"] = qgis_core + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.functions.grenzpunkt_alkis import ( + grenzpunkt_alkis, + _extrahiere_gml_ids, +) + + +# --------------------------------------------------------------------------- +# Dummies +# --------------------------------------------------------------------------- + +class _DummyRect: + def xMinimum(self): return 414000.0 + def yMinimum(self): return 5657000.0 + def xMaximum(self): return 415000.0 + def yMaximum(self): return 5658000.0 + + +class _DummyFeature: + def __init__(self, gehoert_zu_val=None): + self._val = gehoert_zu_val + + def __getitem__(self, key): + if key == "gehoertZu": + return self._val + raise KeyError(key) + + +def _make_layer(valid=True, feature_count=3, features=None, name="Layer"): + layer = MagicMock() + layer.isValid.return_value = valid + layer.featureCount.return_value = feature_count if valid else 0 + layer.getFeatures.return_value = features or [] + layer.name.return_value = name + layer.id.return_value = "LYR1" + return layer + + +# --------------------------------------------------------------------------- +# Tests: _extrahiere_gml_ids +# --------------------------------------------------------------------------- + +class TestExtrahiereGmlIds(unittest.TestCase): + + def test_xlink_href_format(self): + """URN-Format 'urn:adv:oid:DEABC123' → nur 'DEABC123' extrahiert.""" + feat = _DummyFeature("urn:adv:oid:DEABC123") + layer = _make_layer(features=[feat]) + result = _extrahiere_gml_ids(layer) + self.assertEqual(result, ["DEABC123"]) + + def test_plain_gml_id(self): + """Plain-GML-ID ohne Doppelpunkt → direkt übernommen.""" + feat = _DummyFeature("DEPLAIN456") + layer = _make_layer(features=[feat]) + result = _extrahiere_gml_ids(layer) + self.assertEqual(result, ["DEPLAIN456"]) + + def test_empty_value_skipped(self): + """Leerer gehoertZu-Wert wird übersprungen.""" + feat = _DummyFeature(None) + layer = _make_layer(features=[feat]) + result = _extrahiere_gml_ids(layer) + self.assertEqual(result, []) + + def test_deduplication(self): + """Doppelte Referenzen werden dedupliziert.""" + f1 = _DummyFeature("urn:adv:oid:DEDUP001") + f2 = _DummyFeature("urn:adv:oid:DEDUP001") + f3 = _DummyFeature("urn:adv:oid:DEOTHER") + layer = _make_layer(features=[f1, f2, f3]) + result = _extrahiere_gml_ids(layer) + self.assertEqual(len(result), 2) + self.assertIn("DEDUP001", result) + self.assertIn("DEOTHER", result) + + def test_sorted_output(self): + """Rückgabe ist aufsteigend sortiert.""" + f1 = _DummyFeature("urn:adv:oid:ZZZ") + f2 = _DummyFeature("urn:adv:oid:AAA") + layer = _make_layer(features=[f1, f2]) + result = _extrahiere_gml_ids(layer) + self.assertEqual(result, sorted(result)) + + +# --------------------------------------------------------------------------- +# Tests: grenzpunkt_alkis +# --------------------------------------------------------------------------- + +_PATCH_PROJECT = "sn_verfahrensgebiet.functions.grenzpunkt_alkis.QgsProject" +_PATCH_MSGBOX = "sn_verfahrensgebiet.functions.grenzpunkt_alkis.QMessageBox" +_PATCH_WFS = "sn_verfahrensgebiet.functions.grenzpunkt_alkis.QgsVectorLayer" +_PATCH_EXTRAHIERE = "sn_verfahrensgebiet.functions.grenzpunkt_alkis._extrahiere_gml_ids" +_PATCH_LADE_GP = "sn_verfahrensgebiet.functions.grenzpunkt_alkis._lade_grenzpunkte_per_ids" +_PATCH_INFO = "sn_verfahrensgebiet.functions.grenzpunkt_alkis.info" +_PATCH_WARNING = "sn_verfahrensgebiet.functions.grenzpunkt_alkis.warning" + + +class TestGrenzpunktAlkis(unittest.TestCase): + + def _proj(self, vg_layer=None): + proj = MagicMock() + proj.mapLayersByName.return_value = [vg_layer] if vg_layer else [] + return proj + + # ------------------------------------------------------------------ # + # 1. Kein VG-Layer wenn extent=None → (None, None) # + # ------------------------------------------------------------------ # + @patch(_PATCH_MSGBOX) + @patch(_PATCH_PROJECT) + def test_no_vg_layer_returns_none(self, mock_proj, mock_msgbox): + mock_proj.instance.return_value = self._proj(vg_layer=None) + + pta, gp = grenzpunkt_alkis(None, extent=None) + + self.assertIsNone(pta) + self.assertIsNone(gp) + mock_msgbox.warning.assert_called_once() + + # ------------------------------------------------------------------ # + # 2. PunktortTA-Layer ungültig → (None, None) # + # ------------------------------------------------------------------ # + @patch(_PATCH_MSGBOX) + @patch(_PATCH_WFS) + @patch(_PATCH_PROJECT) + def test_invalid_punktort_layer(self, mock_proj, mock_wfs, mock_msgbox): + proj = MagicMock() + proj.mapLayersByName.return_value = [] + mock_proj.instance.return_value = proj + mock_wfs.return_value = _make_layer(valid=False) + + pta, gp = grenzpunkt_alkis(None, extent=_DummyRect()) + + self.assertIsNone(pta) + self.assertIsNone(gp) + mock_msgbox.critical.assert_called_once() + + # ------------------------------------------------------------------ # + # 3. PunktortTA leer → Warnung + (None, None) # + # ------------------------------------------------------------------ # + @patch(_PATCH_MSGBOX) + @patch(_PATCH_WFS) + @patch(_PATCH_PROJECT) + def test_empty_punktort_layer(self, mock_proj, mock_wfs, mock_msgbox): + mock_proj.instance.return_value = MagicMock() + mock_wfs.return_value = _make_layer(valid=True, feature_count=0) + + pta, gp = grenzpunkt_alkis(None, extent=_DummyRect()) + + self.assertIsNone(pta) + self.assertIsNone(gp) + mock_msgbox.warning.assert_called_once() + + # ------------------------------------------------------------------ # + # 4. Keine gehoertZu-Referenzen → (punktort_layer, None) + Warnung # + # ------------------------------------------------------------------ # + @patch(_PATCH_WARNING) + @patch(_PATCH_EXTRAHIERE) + @patch(_PATCH_WFS) + @patch(_PATCH_PROJECT) + def test_no_gml_ids_returns_pta_only( + self, mock_proj, mock_wfs, mock_extrahiere, mock_warning + ): + proj = MagicMock() + mock_proj.instance.return_value = proj + pta_layer = _make_layer(valid=True, feature_count=5) + mock_wfs.return_value = pta_layer + mock_extrahiere.return_value = [] + + pta, gp = grenzpunkt_alkis(None, extent=_DummyRect()) + + self.assertIs(pta, pta_layer) + self.assertIsNone(gp) + mock_warning.assert_called_once() + proj.addMapLayer.assert_called_once_with(pta_layer) + + # ------------------------------------------------------------------ # + # 5. Grenzpunkte-Batch liefert None → (punktort_layer, None) + Warn # + # ------------------------------------------------------------------ # + @patch(_PATCH_WARNING) + @patch(_PATCH_LADE_GP) + @patch(_PATCH_EXTRAHIERE) + @patch(_PATCH_WFS) + @patch(_PATCH_PROJECT) + def test_grenzpunkt_batch_empty( + self, mock_proj, mock_wfs, mock_extrahiere, mock_lade_gp, mock_warning + ): + proj = MagicMock() + mock_proj.instance.return_value = proj + pta_layer = _make_layer(valid=True, feature_count=3) + mock_wfs.return_value = pta_layer + mock_extrahiere.return_value = ["DE0001", "DE0002"] + mock_lade_gp.return_value = None # Batch leer + + pta, gp = grenzpunkt_alkis(None, extent=_DummyRect()) + + self.assertIs(pta, pta_layer) + self.assertIsNone(gp) + mock_warning.assert_called_once() + + # ------------------------------------------------------------------ # + # 6. Vollständiger Erfolgsfall # + # ------------------------------------------------------------------ # + @patch(_PATCH_LADE_GP) + @patch(_PATCH_EXTRAHIERE) + @patch(_PATCH_WFS) + @patch(_PATCH_PROJECT) + def test_success_returns_both_layers( + self, mock_proj, mock_wfs, mock_extrahiere, mock_lade_gp + ): + proj = MagicMock() + mock_proj.instance.return_value = proj + pta_layer = _make_layer(valid=True, feature_count=10, name="PunktortTA") + gp_layer = _make_layer(valid=True, feature_count=10, name="Grenzpunkte") + mock_wfs.return_value = pta_layer + mock_extrahiere.return_value = ["DE001", "DE002", "DE003"] + mock_lade_gp.return_value = gp_layer + + pta, gp = grenzpunkt_alkis(None, extent=_DummyRect()) + + self.assertIs(pta, pta_layer) + self.assertIs(gp, gp_layer) + # Beide Layer müssen ins Projekt eingefügt worden sein + calls = [c[0][0] for c in proj.addMapLayer.call_args_list] + self.assertIn(pta_layer, calls) + self.assertIn(gp_layer, calls) + + # ------------------------------------------------------------------ # + # 7. Extent aus vorhandenem VG-Layer ableiten # + # ------------------------------------------------------------------ # + @patch(_PATCH_LADE_GP) + @patch(_PATCH_EXTRAHIERE) + @patch(_PATCH_WFS) + @patch(_PATCH_PROJECT) + def test_extent_derived_from_vg_layer( + self, mock_proj, mock_wfs, mock_extrahiere, mock_lade_gp + ): + """Wenn kein extent übergeben → Extent aus VG-Layer holen.""" + vg_layer = MagicMock() + vg_layer.extent.return_value = _DummyRect() + proj = MagicMock() + proj.mapLayersByName.return_value = [vg_layer] + mock_proj.instance.return_value = proj + + pta_layer = _make_layer(valid=True, feature_count=2) + gp_layer = _make_layer(valid=True, feature_count=2) + mock_wfs.return_value = pta_layer + mock_extrahiere.return_value = ["DE999"] + mock_lade_gp.return_value = gp_layer + + pta, gp = grenzpunkt_alkis(None, extent=None) + + vg_layer.extent.assert_called_once() + self.assertIs(pta, pta_layer) + self.assertIs(gp, gp_layer) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verfahrensgebiet_alkis_komplett.py b/tests/test_verfahrensgebiet_alkis_komplett.py new file mode 100644 index 0000000..2e2100a --- /dev/null +++ b/tests/test_verfahrensgebiet_alkis_komplett.py @@ -0,0 +1,360 @@ +"""sn_verfahrensgebiet/tests/test_verfahrensgebiet_alkis_komplett.py + +Unit-Tests für :mod:`sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett`. + +Getestete Szenarien: + +Erfolgsfall: +- Vollständiger Workflow → enriched Layer + flst_layer + LoadStatus.FIRST +- VKZ aus Projektvariable wird als Feld gesetzt (wenn vorhanden) +- Kein VKZ in Projektvariable → Feld wird nicht gesetzt (kein Fehler) + +Abbruchpfade: +- verfahrensgebiet_alkis gibt LoadStatus.NONE → (None, None, NONE) +- flurstueck_alkis gibt LoadStatus.NONE → (None, None, NONE) +- Keine Flurstücke selektiert → (None, flst_layer, NONE) + Warnung +- erstelle_verfahrensgebiet_aus_auswahl liefert ungültigen Layer → (None, flst_layer, NONE) +- Cleanup: ALKIS-VG-Hilfs-Layer wird in allen Fällen aus Projekt entfernt + +Hilfsfunktionen: +- _select_by_location: processing-Fallback liefert 0 → keine Exception +- _fuege_vkz_feld_hinzu: leeres VKZ wird ignoriert +""" + +import sys +import types +import unittest +from unittest.mock import patch, MagicMock, call + +# --------------------------------------------------------------------------- +# Minimales QGIS-Mock-Setup +# --------------------------------------------------------------------------- +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_core = types.ModuleType("qgis.core") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + qgis_mod.core = qgis_core + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.core"] = qgis_core + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import ( + verfahrensgebiet_alkis_komplett, + _select_by_location, + _fuege_vkz_feld_hinzu, +) + + +# --------------------------------------------------------------------------- +# Dummies +# --------------------------------------------------------------------------- + +class _DummyRect: + def xMinimum(self): return 414000.0 + def yMinimum(self): return 5657000.0 + def xMaximum(self): return 415000.0 + def yMaximum(self): return 5658000.0 + + +def _make_layer(valid=True, name="Layer", lid="L1", feature_count=1): + layer = MagicMock() + layer.isValid.return_value = valid + layer.id.return_value = lid + layer.name.return_value = name + layer.extent.return_value = _DummyRect() + layer.featureCount.return_value = feature_count + layer.selectedFeatureCount.return_value = feature_count + return layer + + +class _DummyProject: + def __init__(self): + self.added = [] + self.removed = [] + self._layers_by_name: dict = {} + + def addMapLayer(self, layer): + self.added.append(layer) + + def removeMapLayer(self, lid): + self.removed.append(lid) + + def mapLayersByName(self, name): + return self._layers_by_name.get(name, []) + + def instance(self): + return self + + +# --------------------------------------------------------------------------- +# Tests: _select_by_location +# --------------------------------------------------------------------------- + +class TestSelectByLocation(unittest.TestCase): + + def test_processing_not_available_returns_zero(self): + """Wenn processing nicht importierbar ist → 0 ohne Exception.""" + flst = MagicMock() + vg = MagicMock() + with patch.dict("sys.modules", {"processing": None}): + result = _select_by_location(flst, vg) + self.assertEqual(result, 0) + + def test_processing_success_returns_count(self): + """processing.run erfolgreich → selectedFeatureCount des Layers.""" + flst = MagicMock() + flst.selectedFeatureCount.return_value = 7 + vg = MagicMock() + + mock_processing = MagicMock() + with patch.dict("sys.modules", {"processing": mock_processing}): + result = _select_by_location(flst, vg) + + self.assertEqual(result, 7) + + def test_processing_exception_returns_zero(self): + """Exception in processing.run → 0 ohne Propagation.""" + flst = MagicMock() + vg = MagicMock() + + mock_processing = MagicMock() + mock_processing.run.side_effect = RuntimeError("Kein QGIS") + with patch.dict("sys.modules", {"processing": mock_processing}): + result = _select_by_location(flst, vg) + + self.assertEqual(result, 0) + + +# --------------------------------------------------------------------------- +# Tests: _fuege_vkz_feld_hinzu +# --------------------------------------------------------------------------- + +class TestFuegeVkzFeldHinzu(unittest.TestCase): + + def test_empty_vkz_does_nothing(self): + """Leeres VKZ → kein Zugriff auf Layer.""" + layer = MagicMock() + _fuege_vkz_feld_hinzu(layer, "") + layer.dataProvider.assert_not_called() + + def test_none_vkz_does_nothing(self): + """None-VKZ → kein Zugriff auf Layer.""" + layer = MagicMock() + _fuege_vkz_feld_hinzu(layer, None) + layer.dataProvider.assert_not_called() + + def test_vkz_field_added(self): + """Nicht-leeres VKZ → Provider.addAttributes wird aufgerufen.""" + pr = MagicMock() + pr.addAttributes.return_value = True + pr.changeAttributeValues.return_value = True + + layer = MagicMock() + layer.dataProvider.return_value = pr + layer.fields.return_value.indexFromName.return_value = 2 + layer.getFeatures.return_value = [] + + _fuege_vkz_feld_hinzu(layer, "VF-2024-001") + + pr.addAttributes.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests: verfahrensgebiet_alkis_komplett (Hauptworkflow) +# --------------------------------------------------------------------------- + +_PATCH_VG_ALKIS = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.verfahrensgebiet_alkis" +_PATCH_GEM_BY_BBOX = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.gemarkung_schluessel_und_namen_by_bbox" +_PATCH_FLST = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.flurstueck_alkis" +_PATCH_SELECT = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett._select_by_location" +_PATCH_ERSTELLE = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.erstelle_verfahrensgebiet_aus_auswahl" +_PATCH_VKZ = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett._fuege_vkz_feld_hinzu" +_PATCH_PROJECT = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.QgsProject" +_PATCH_MSGBOX = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.QMessageBox" +_PATCH_INFO = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.info" +_PATCH_WARNING = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.warning" +_PATCH_GET_VAR = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.get_variable" + + +class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): + + def _make_project(self): + proj = _DummyProject() + mock = MagicMock() + mock.instance.return_value = proj + # Leere mapLayersByName für Cleanup + proj._layers_by_name = {"Verfahrensgebiet": [], "TempVerfahrensgebiet": []} + return mock, proj + + # ------------------------------------------------------------------ # + # 1. VG-ALKIS liefert NONE → sofortiger Abbruch # + # ------------------------------------------------------------------ # + @patch(_PATCH_PROJECT) + @patch(_PATCH_VG_ALKIS) + def test_vg_alkis_none_aborts(self, mock_vg, mock_proj): + mock_proj.instance.return_value = _DummyProject() + mock_vg.return_value = (None, LoadStatus.NONE) + + enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIsNone(enriched) + self.assertIsNone(flst) + self.assertEqual(status, LoadStatus.NONE) + + # ------------------------------------------------------------------ # + # 2. flurstueck_alkis liefert NONE → Abbruch nach Gemarkungsabfrage # + # ------------------------------------------------------------------ # + @patch(_PATCH_PROJECT) + @patch(_PATCH_FLST) + @patch(_PATCH_GEM_BY_BBOX) + @patch(_PATCH_VG_ALKIS) + def test_flst_none_aborts(self, mock_vg, mock_gem, mock_flst, mock_proj): + mock_proj.instance.return_value = _DummyProject() + vg_layer = _make_layer(name="Verfahrensgebiet") + mock_vg.return_value = (vg_layer, LoadStatus.FIRST) + mock_gem.return_value = [("1234", "Muster")] + mock_flst.return_value = (None, LoadStatus.NONE) + + enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIsNone(enriched) + self.assertIsNone(flst) + self.assertEqual(status, LoadStatus.NONE) + + # ------------------------------------------------------------------ # + # 3. Keine Selektion → Warnung + (None, flst_layer, NONE) # + # ------------------------------------------------------------------ # + @patch(_PATCH_MSGBOX) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_FLST) + @patch(_PATCH_GEM_BY_BBOX) + @patch(_PATCH_VG_ALKIS) + def test_no_selection_returns_none_enriched( + self, mock_vg, mock_gem, mock_flst, mock_proj, mock_select, mock_msgbox + ): + mock_proj.instance.return_value = _DummyProject() + vg_layer = _make_layer(name="Verfahrensgebiet") + flst_layer = _make_layer(name="Flurstücke") + mock_vg.return_value = (vg_layer, LoadStatus.FIRST) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 0 # keine Selektion + + enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIsNone(enriched) + self.assertIs(flst, flst_layer) + self.assertEqual(status, LoadStatus.NONE) + mock_msgbox.warning.assert_called_once() + + # ------------------------------------------------------------------ # + # 4. Vollständiger Erfolgsfall # + # ------------------------------------------------------------------ # + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_FLST) + @patch(_PATCH_GEM_BY_BBOX) + @patch(_PATCH_VG_ALKIS) + def test_success_full_workflow( + self, + mock_vg, mock_gem, mock_flst, mock_proj, + mock_select, mock_erstelle, mock_get_var, mock_vkz_fn, + ): + """Vollständiger Workflow → enriched Layer + flst Layer + FIRST.""" + proj_instance = _DummyProject() + mock_proj.instance.return_value = proj_instance + + vg_layer = _make_layer(name="Verfahrensgebiet") + flst_layer = _make_layer(name="Flurstücke", lid="FLST1") + enriched = _make_layer(name="Verfahrensgebiet", lid="VG_ENRICHED", valid=True) + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST) + mock_gem.return_value = [("1234", "Muster")] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 3 + mock_erstelle.return_value = (enriched, 3, 0) + mock_get_var.return_value = "VF-2024-001" + + result_enriched, result_flst, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIs(result_enriched, enriched) + self.assertIs(result_flst, flst_layer) + self.assertEqual(status, LoadStatus.FIRST) + mock_vkz_fn.assert_called_once_with(enriched, "VF-2024-001") + + # ------------------------------------------------------------------ # + # 5. Kein VKZ → _fuege_vkz_feld_hinzu mit leerem String # + # ------------------------------------------------------------------ # + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_FLST) + @patch(_PATCH_GEM_BY_BBOX) + @patch(_PATCH_VG_ALKIS) + def test_no_vkz_variable_passes_empty_string( + self, + mock_vg, mock_gem, mock_flst, mock_proj, + mock_select, mock_erstelle, mock_get_var, mock_vkz_fn, + ): + proj_instance = _DummyProject() + mock_proj.instance.return_value = proj_instance + + vg_layer = _make_layer(name="Verfahrensgebiet") + flst_layer = _make_layer(name="Flurstücke") + enriched = _make_layer(name="Verfahrensgebiet", valid=True) + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 2 + mock_erstelle.return_value = (enriched, 2, 0) + mock_get_var.return_value = "" # keine Projektvariable + + verfahrensgebiet_alkis_komplett(None) + + mock_vkz_fn.assert_called_once_with(enriched, "") + + # ------------------------------------------------------------------ # + # 6. erstelle_verfahrensgebiet liefert ungültigen Layer # + # ------------------------------------------------------------------ # + @patch(_PATCH_MSGBOX) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_FLST) + @patch(_PATCH_GEM_BY_BBOX) + @patch(_PATCH_VG_ALKIS) + def test_invalid_enriched_layer_returns_none( + self, mock_vg, mock_gem, mock_flst, mock_proj, mock_select, mock_erstelle, mock_msgbox + ): + mock_proj.instance.return_value = _DummyProject() + vg_layer = _make_layer() + flst_layer = _make_layer(name="Flurstücke") + bad_enriched = _make_layer(valid=False) + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 5 + mock_erstelle.return_value = (bad_enriched, 0, 5) + + enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIsNone(enriched) + self.assertIs(flst, flst_layer) + self.assertEqual(status, LoadStatus.NONE) + mock_msgbox.critical.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_working_tab.py b/tests/test_working_tab.py index c2c23cc..5af3cd7 100644 --- a/tests/test_working_tab.py +++ b/tests/test_working_tab.py @@ -83,11 +83,13 @@ class TestWorkingTab(unittest.TestCase): tab = WorkingTab.__new__(WorkingTab) tab.btn_verf_alkis = _DummyButton() tab.btn_flurst_alkis = _DummyButton() + tab.btn_grenz_alkis = _DummyButton() tab.btn_verf_aus_auswahl = _DummyButton() tab.btn_verf_aus_liste = _DummyButton() tab.verf_layer_combo = _DummyCombo() - tab.haken_verf = object() - tab.haken_flurst = object() + tab.haken_verf = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None) + tab.haken_flurst = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None) + tab.haken_grenz = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None) tab.setze_haken = lambda *_args, **_kwargs: None # Pruefmanager-Mock: verarbeite() wird im Test ggf. überschrieben tab.pruefmanager = types.SimpleNamespace( @@ -126,39 +128,83 @@ class TestWorkingTab(unittest.TestCase): @patch("sn_verfahrensgebiet.ui.tabs.working_tab.success") @patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style") @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") - @patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_alkis") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_alkis_komplett") @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") @patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id") def test_handle_verf_alkis_success_path( self, mock_set_layer_id, mock_iface, - mock_alkis, + mock_alkis_komplett, mock_project_instance, mock_apply_style, mock_success, ): + """verfahrensgebiet_alkis_komplett → 3-Tupel (enriched, flst, FIRST).""" tab = self._make_tab() - layer = _DummyLayer(valid=True, layer_id="layer_ok") - mock_alkis.return_value = (layer, LoadStatus.FIRST) - mock_project_instance.return_value = _DummyProject() + enriched = _DummyLayer(valid=True, layer_id="enriched_ok") + flst = _DummyLayer(valid=True, layer_id="flst_ok") + mock_alkis_komplett.return_value = (enriched, flst, LoadStatus.FIRST) + proj = _DummyProject() + proj.mapLayersByName = lambda _n: [] # kein bestehender Flst-Layer + mock_project_instance.return_value = proj mock_iface.mapCanvas.return_value = _DummyCanvas() tab.handle_verf_alkis() - mock_apply_style.assert_called_once() + mock_apply_style.assert_called() mock_success.assert_called_once() - mock_set_layer_id.assert_called_with("layer_ok") + mock_set_layer_id.assert_called_with("enriched_ok") @patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id") - @patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_alkis", return_value=(None, LoadStatus.NONE)) + @patch( + "sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_alkis_komplett", + return_value=(None, None, LoadStatus.NONE), + ) def test_handle_verf_alkis_failure_clears_layer_var(self, _mock_alkis, mock_set_layer_id): + """Status NONE → Layer-Variable wird auf '' gesetzt.""" tab = self._make_tab() tab.handle_verf_alkis() mock_set_layer_id.assert_called_once_with("") + # ------------------------------------------------------------------ + # handle_grenz_alkis + # ------------------------------------------------------------------ + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.grenzpunkt_alkis") + def test_handle_grenz_alkis_success_sets_haken(self, mock_grenz): + """Grenzpunkte erfolgreich geladen → haken_grenz wird gesetzt.""" + tab = self._make_tab() + haken_calls = [] + tab.setze_haken = lambda lbl, v: haken_calls.append((lbl, v)) + + pta = _DummyLayer(valid=True) + gp = _DummyLayer(valid=True) + mock_grenz.return_value = (pta, gp) + + tab.handle_grenz_alkis() + + self.assertEqual(len(haken_calls), 1) + self.assertIs(haken_calls[0][0], tab.haken_grenz) + self.assertTrue(haken_calls[0][1]) + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.grenzpunkt_alkis") + def test_handle_grenz_alkis_none_result_sets_no_haken(self, mock_grenz): + """grenzpunkt_alkis liefert (None, None) → kein Haken.""" + tab = self._make_tab() + haken_calls = [] + tab.setze_haken = lambda lbl, v: haken_calls.append((lbl, v)) + + mock_grenz.return_value = (None, None) + + tab.handle_grenz_alkis() + + self.assertEqual(len(haken_calls), 0) + # Button wieder aktiviert (finally) + self.assertTrue(tab.btn_grenz_alkis.isEnabled()) + # ------------------------------------------------------------------ # handle_flurst_alkis # ------------------------------------------------------------------ diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 1f47ee9..110f001 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -13,8 +13,12 @@ from sn_basis.modules.Pruefmanager import Pruefmanager from sn_basis.modules.pruef_ergebnis import pruef_ergebnis from qgis.utils import iface -from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import verfahrensgebiet_alkis, LoadStatus +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import verfahrensgebiet_alkis_komplett +# TODO Grenzpunkt-Pipeline: Import reaktivieren, sobald grenzpunkt_alkis.py +# mit der neuen Pipeline (PunktortTA → istTeilVon → Einzel-URN) umgeschrieben wurde. +# from sn_verfahrensgebiet.functions.grenzpunkt_alkis import grenzpunkt_alkis from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( pruefe_flurstueck_layer, pruefe_auswahl, @@ -87,6 +91,20 @@ class WorkingTab(QWidget): row_flurst_buttons.addWidget(self.btn_flurst_shape) layout.addLayout(row_flurst_buttons) + # Abschnitt 2b: Grenzpunkte + row_grenz_header = QHBoxLayout() + lbl_grenz = QLabel("Grenzpunkte") + self.haken_grenz = self._haken_label() + row_grenz_header.addWidget(lbl_grenz) + row_grenz_header.addWidget(self.haken_grenz) + row_grenz_header.addStretch() + layout.addLayout(row_grenz_header) + + row_grenz_buttons = QHBoxLayout() + self.btn_grenz_alkis = QPushButton("Grenzpunkte aus ALKIS laden") + row_grenz_buttons.addWidget(self.btn_grenz_alkis) + layout.addLayout(row_grenz_buttons) + # Abschnitt 3: Verfahrensgebiet erstellen row_verf_erstellen_header = QHBoxLayout() lbl_verf_erstellen = QLabel("Verfahrensgebiet erstellen") @@ -110,6 +128,7 @@ class WorkingTab(QWidget): # Signale verbinden self.btn_verf_alkis.clicked.connect(self.handle_verf_alkis) self.btn_flurst_alkis.clicked.connect(self.handle_flurst_alkis) + self.btn_grenz_alkis.clicked.connect(self.handle_grenz_alkis) self.btn_verf_aus_auswahl.clicked.connect(self.handle_verf_aus_auswahl) self.btn_verf_aus_liste.clicked.connect(self.handle_verf_aus_liste) # self.btn_verf_shape.clicked.connect(self.handle_verf_shape) @@ -118,36 +137,43 @@ class WorkingTab(QWidget): # Beim Init: aktuelle Verfahrensgebiet-Layer-ID laden und im Dropdown anzeigen self._restore_verf_layer_selection() - def handle_verf_alkis(self): + def handle_verf_alkis(self) -> None: + """Startet den vollständigen VG-Lade-Workflow (ALKIS → Gemarkungen → Flurstücke → Verschneidung). + + Delegiert an :func:`~sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.verfahrensgebiet_alkis_komplett`. + Nach Erfolg werden enriched VG-Layer und Flurstücks-Layer ins Projekt eingefügt, + Styles angewendet und die UI-Elemente aktualisiert. + """ self._set_busy(self.btn_verf_alkis, True, "Aus ALKIS laden") try: - layer, status = verfahrensgebiet_alkis(self) - if not layer or not layer.isValid(): + enriched_layer, flst_layer, status = verfahrensgebiet_alkis_komplett(self) + + if status == LoadStatus.NONE or not enriched_layer: set_verfahrensgebiet_layer_id("") return - if status in (LoadStatus.FIRST, LoadStatus.RELOAD): - QgsProject.instance().addMapLayer(layer) - iface.mapCanvas().setExtent(layer.extent()) + project = QgsProject.instance() - apply_style(layer, "verfahrensgebiet.qml") - iface.mapCanvas().refresh() - self.setze_haken(self.haken_verf, True) + # Flurstücks-Layer ins Projekt (sofern neu geladen) + if flst_layer and flst_layer.isValid(): + existing_flst = project.mapLayersByName(flst_layer.name()) + if not existing_flst: + project.addMapLayer(flst_layer) + apply_style(flst_layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml") + self.setze_haken(self.haken_flurst, True) - self.verf_layer_combo.setLayer(layer) - self._on_verf_layer_changed(layer) + # Enriched VG-Layer ins Projekt + project.addMapLayer(enriched_layer) + apply_style(enriched_layer, "verfahrensgebiet.qml") + iface.mapCanvas().setExtent(enriched_layer.extent()) + iface.mapCanvas().refresh() + self.setze_haken(self.haken_verf, True) - msg = "erstmalig geladen" if status == LoadStatus.FIRST else "neu geladen" - success("Verfahrensgebiet", f"Layer wurde {msg}.", duration=5) + self.verf_layer_combo.setLayer(enriched_layer) + self._on_verf_layer_changed(enriched_layer) - elif status == LoadStatus.KEEP: - self.verf_layer_combo.setLayer(layer) - self._on_verf_layer_changed(layer) - info("Verfahrensgebiet", "Vorhandener Layer wurde behalten.", duration=4) - - elif status == LoadStatus.NONE: - self._on_verf_layer_changed(None) - warning("Verfahrensgebiet", "Layer konnte nicht geladen werden.", duration=None) + msg = "erstmalig geladen" if status == LoadStatus.FIRST else "neu geladen" + success("Verfahrensgebiet", f"Layer wurde {msg} (inkl. Flurstücksverschneidung).", duration=5) finally: self._set_busy(self.btn_verf_alkis, False, "Aus ALKIS laden") @@ -368,3 +394,39 @@ class WorkingTab(QWidget): "Die Funktion 'Aus Liste' steht in einer zukünftigen Version zur Verfügung.", parent=self, ) + + # ------------------------------------------------------------------ + # Grenzpunkt-Handler + # ------------------------------------------------------------------ + + def handle_grenz_alkis(self) -> None: + """Lädt Grenzpunkte (AX_PunktortTA + AX_Grenzpunkt) aus ALKIS. + + TODO Grenzpunkt-Pipeline: Implementierung ausstehend. + Neue Pipeline: VG-Geometrie → PunktortTA (spatial filter) → + istTeilVon-Attribute → Grenzpunkt per Einzel-URN laden. + Bis dahin ist dieser Handler deaktiviert. + """ + # TODO Grenzpunkt-Pipeline: Körper reaktivieren, sobald grenzpunkt_alkis.py + # mit der neuen Pipeline umgeschrieben wurde. + # + # self._set_busy(self.btn_grenz_alkis, True, "Grenzpunkte aus ALKIS laden") + # try: + # punktort_layer, grenzpunkt_layer = grenzpunkt_alkis(self) + # + # if grenzpunkt_layer and grenzpunkt_layer.isValid(): + # iface.mapCanvas().refresh() + # self.setze_haken(self.haken_grenz, True) + # success("Grenzpunkte", "Grenzpunkte erfolgreich geladen.", duration=5) + # elif punktort_layer and punktort_layer.isValid(): + # iface.mapCanvas().refresh() + # self.setze_haken(self.haken_grenz, True) + # info( + # "Grenzpunkte", + # "PunktortTA-Layer geladen. Grenzpunkt-IDs konnten nicht aufgelöst werden.", + # duration=6, + # ) + # finally: + # self._set_busy(self.btn_grenz_alkis, False, "Grenzpunkte aus ALKIS laden") + from sn_basis.functions.message_wrapper import warning as _warn + _warn("Grenzpunkte", "Grenzpunkt-Pipeline ist noch nicht implementiert.", duration=5) From 16fa99a84e5a6a213ad552882dbf846114b45f71 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 23 Apr 2026 13:04:35 +0200 Subject: [PATCH 09/22] selectbyposition auf 6 (within) --- functions/verfahrensgebiet_alkis_komplett.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py index 5a2b286..23e62d1 100644 --- a/functions/verfahrensgebiet_alkis_komplett.py +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -98,7 +98,7 @@ def _select_by_location(flst_layer, vg_layer) -> int: "native:selectbylocation", { "INPUT": flst_layer, - "PREDICATE": [5, 6], # 5 = overlaps, 6 = within; schließt reines Berühren aus + "PREDICATE": [6], # 5 = overlaps, 6 = within; schließt reines Berühren aus "INTERSECT": vg_layer, "METHOD": 0, # 0 = neue Selektion }, From 7948131d5a9e4d6b42290fd41f739f5f0a38dbdb Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 23 Apr 2026 13:07:35 +0200 Subject: [PATCH 10/22] von geosn-NAS auf geosn-AAA umgestellt --- functions/flurstueck_alkis.py | 4 ++-- functions/grenzpunkt_alkis.py | 6 +++--- functions/verfahrensgebiet_alkis.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/functions/flurstueck_alkis.py b/functions/flurstueck_alkis.py index 71fd805..e20f83c 100644 --- a/functions/flurstueck_alkis.py +++ b/functions/flurstueck_alkis.py @@ -34,7 +34,7 @@ from sn_basis.functions.message_wrapper import info as _info # LoadStatus aus verfahrensgebiet_alkis wiederverwenden (identische Semantik) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus -ALKIS_NAS_URL = "https://geodienste.sachsen.de/aaa/public_alkis/nas/wfs" +ALKIS_SF_URL = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs" FLST_TYPENAME = "adv:AX_Flurstueck" FLST_LAYER_NAME = "Flurstücke" @@ -91,7 +91,7 @@ def _lade_flurstuecke_per_gemarkung(schluessel_liste: List[str]): for schluessel in schluessel_liste: uri = ( - f"url='{ALKIS_NAS_URL}' typename='{FLST_TYPENAME}' srsname='EPSG:25833' " + f"url='{ALKIS_SF_URL}' typename='{FLST_TYPENAME}' srsname='EPSG:25833' " f"sql=SELECT * FROM AX_Flurstueck " f"WHERE gemarkung_ax_gemarkung_schluessel_gemarkungsnummer = '{schluessel}'" ) diff --git a/functions/grenzpunkt_alkis.py b/functions/grenzpunkt_alkis.py index cd28575..cad0dac 100644 --- a/functions/grenzpunkt_alkis.py +++ b/functions/grenzpunkt_alkis.py @@ -34,7 +34,7 @@ from sn_basis.functions import QgsVectorLayer, QgsProject from sn_basis.functions import QMessageBox from sn_basis.functions.message_wrapper import info, warning -ALKIS_NAS_URL = "https://geodienste.sachsen.de/aaa/public_alkis/nas/wfs" +ALKIS_SF_URL = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs" PUNKTORT_TA_TYPENAME = "adv:AX_PunktortTA" GRENZPUNKT_TYPENAME = "adv:AX_Grenzpunkt" @@ -114,7 +114,7 @@ def _lade_grenzpunkte_per_ids(id_liste: List[str]) -> Optional[object]: batch = id_liste[i * _BATCH_GROESSE: (i + 1) * _BATCH_GROESSE] ids_str = ", ".join(f"'{gid}'" for gid in batch) uri = ( - f"url='{ALKIS_NAS_URL}' typename='{GRENZPUNKT_TYPENAME}' " + f"url='{ALKIS_SF_URL}' typename='{GRENZPUNKT_TYPENAME}' " f"srsname='EPSG:25833' " f"sql=SELECT * FROM AX_Grenzpunkt WHERE gml_id IN ({ids_str})" ) @@ -212,7 +212,7 @@ def grenzpunkt_alkis(tab_widget, extent=None) -> Tuple[Optional[object], Optiona # ----------------------------------------------------------------------- info("Grenzpunkte", "Schritt 1/3: PunktortTA per BBox laden …", duration=3) uri_pta = ( - f"url='{ALKIS_NAS_URL}' typename='{PUNKTORT_TA_TYPENAME}' " + f"url='{ALKIS_SF_URL}' typename='{PUNKTORT_TA_TYPENAME}' " f"srsname='EPSG:25833' " f"bbox={xmin},{ymin},{xmax},{ymax},EPSG:25833" ) diff --git a/functions/verfahrensgebiet_alkis.py b/functions/verfahrensgebiet_alkis.py index 9a24cce..00e05e2 100644 --- a/functions/verfahrensgebiet_alkis.py +++ b/functions/verfahrensgebiet_alkis.py @@ -10,7 +10,7 @@ from sn_basis.functions import QMessageBox from sn_basis.functions.qt_wrapper import QVariant from sn_basis.functions.variable_wrapper import get_variable -alkis_NAS_url = "https://geodienste.sachsen.de/aaa/public_alkis/nas/wfs" +alkis_sf_url = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs" typename = "adv:AX_BauRaumOderBodenordnungsrecht" class LoadStatus(Enum): @@ -85,7 +85,7 @@ def verfahrensgebiet_alkis(tab_widget): # WFS mit Filter laden wfs_uri_find = ( - f"url='{alkis_NAS_url}' typename='{typename}' srsname='EPSG:25833' " + f"url='{alkis_sf_url}' typename='{typename}' srsname='EPSG:25833' " f"sql=SELECT * FROM AX_BauRaumOderBodenordnungsrecht " f"WHERE bezeichnung LIKE '{verfahrensnummer}%'" ) From b25b4c83ac8fc921055e8896c83b81e5a586b39f Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 23 Apr 2026 22:24:14 +0200 Subject: [PATCH 11/22] =?UTF-8?q?Umringspr=C3=BCfung-Segmentbasiert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/umringspruefung.py | 311 ++++++++++ functions/verfahrensgebiet_alkis_komplett.py | 377 ++++++++---- tests/test_verfahrensgebiet_alkis_komplett.py | 574 +++++++++++++----- tests/test_working_tab.py | 12 +- ui/tabs/working_tab.py | 47 +- 5 files changed, 1047 insertions(+), 274 deletions(-) create mode 100644 functions/umringspruefung.py diff --git a/functions/umringspruefung.py b/functions/umringspruefung.py new file mode 100644 index 0000000..e498909 --- /dev/null +++ b/functions/umringspruefung.py @@ -0,0 +1,311 @@ +"""sn_verfahrensgebiet/functions/umringspruefung.py – Umringsprüfung (Variante 1). + +Vergleicht den Umring (Boundary) des BauRaumOderBodenordnungsrecht-Polygons +mit den Grenzen der angrenzenden Flurstücke und hebt Abweichungen hervor. + +**Algorithmus (Variante 1 – linienbasierter Umringvergleich):** + +1. BROR-Polygon → Umringslinie via :func:`sn_basis.functions.qgiscore_wrapper.polygon_zu_linie` + (QGIS-3/4-kompatibel). +2. Flurstücke, die den BROR-Umring berühren oder schneiden, werden gefiltert. +3. Gefilterte Flurstücke → Umringslinien. +4. Union aller Flurstücksumringe. +5. ``BROR-Umring.difference(Flurstücksumrings-Union)`` → Abweichungen (rot). +6. ``BROR-Umring.difference(rote_Segmente)`` → Übereinstimmungen (grün). +7. Ergebnislayer mit Kategoriesymbologie (grün/rot) und Legendenbeschriftung. + +**Rückgabe:** ``(result_layer, conflict_count)`` + +- ``result_layer``: Temporärer Linienlayer mit Feld ``typ`` + (``"identisch"`` = grün, ``"Abweichung"`` = rot). +- ``conflict_count``: Anzahl der roten Konfliktsegmente (Parts). +""" +from __future__ import annotations + +from typing import List, Optional, Tuple + +from sn_basis.functions.qgiscore_wrapper import ( + QgsVectorLayer, + QgsFeature, + QgsField, + QgsGeometry, + polygon_zu_linie, +) +from sn_basis.functions.qt_wrapper import QVariant + +# --------------------------------------------------------------------------- +# Konstanten +# --------------------------------------------------------------------------- + +#: Geometrische Toleranz für den Linienvergleich (EPSG:25833 = Meter). +#: Der BROR-Umring wird gegen die um diesen Betrag gepufferten Flurstücksumringe +#: verglichen, um numerische Rundungsfehler aus ALKIS-Koordinaten abzufangen. +LINIE_TOLERANZ_M: float = 0.0001 + +ERGEBNIS_LAYER_NAME = "Umringsprüfung" +FELD_TYP = "typ" +WERT_IDENTISCH = "identisch" +WERT_ABWEICHUNG = "Abweichung" + + +# --------------------------------------------------------------------------- +# Interne Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _zerlege_in_teile(geom: object) -> List[object]: + """Zerlegt eine (Multi-)LineString-Geometrie in einzelne Teile. + + Parameters + ---------- + geom : + ``QgsGeometry`` vom Typ LineString oder MultiLineString. + + Returns + ------- + List[QgsGeometry] + Liste einzelner Geometrieteile; leer wenn ``geom`` None oder leer ist. + """ + if geom is None: + return [] + try: + if getattr(geom, "isEmpty", lambda: True)(): + return [] + # asGeometryCollection() gibt eigene Kopien zurück – kein Ownership-Problem. + # QgsGeometry(part) über geom.parts() ist in QGIS 4.x aus den Python-Bindings + # entfernt worden und führt zu Absturz beim Rendern. + parts = geom.asGeometryCollection() + if parts: + return [p for p in parts if p is not None and not p.isEmpty()] + except Exception: + pass + # Fallback: Geometrie nicht zerlegbar → als Ganzes zurückgeben + try: + if not geom.isEmpty(): + return [geom] + except Exception: + pass + return [] + + +def _erstelle_ergebnis_layer(crs_authid: str) -> object: + """Erzeugt den leeren Ergebnislayer mit Feld ``typ``. + + Parameters + ---------- + crs_authid : + CRS-Kennung des Quell-Layers (z. B. ``"EPSG:25833"``). + + Returns + ------- + QgsVectorLayer + Leerer Memory-Layer vom Typ LineString. + """ + layer = QgsVectorLayer( + f"LineString?crs={crs_authid}", ERGEBNIS_LAYER_NAME, "memory" + ) + pr = layer.dataProvider() + pr.addAttributes([QgsField(FELD_TYP, QVariant.String)]) + layer.updateFields() + return layer + + +def _setze_kategorien_symbologie(layer) -> None: + """Wendet Kategoriesymbologie an: grün = identisch, rot = Abweichung. + + Wird bei fehlendem QGIS-Kontext still ignoriert (Unit-Test-Robustheit). + """ + try: + from qgis.core import ( # type: ignore[import] + QgsCategorizedSymbolRenderer, + QgsRendererCategory, + QgsLineSymbol, + ) + + green_sym = QgsLineSymbol.createSimple( + {"color": "0,180,0,255", "line_width": "0.5"} + ) + red_sym = QgsLineSymbol.createSimple( + {"color": "220,0,0,255", "line_width": "0.8"} + ) + categories = [ + QgsRendererCategory( + WERT_IDENTISCH, + green_sym, + "identisch (übereinstimmend)", + ), + QgsRendererCategory( + WERT_ABWEICHUNG, + red_sym, + "Abweichung (nicht in Flurstücken)", + ), + ] + renderer = QgsCategorizedSymbolRenderer(FELD_TYP, categories) + layer.setRenderer(renderer) + except Exception: + pass # Kein QGIS-Kontext (Tests) → Standarddarstellung behalten + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- + +def umringspruefung( + bror_layer, + flst_layer, + progress=None, + linie_toleranz_m: float = LINIE_TOLERANZ_M, +) -> Tuple[Optional[object], int]: + """Variante 1: Linienbasierter Umringvergleich. + + Ermittelt, welche Teile des BROR-Umrings mit den Flurstücksgrenzen + übereinstimmen (grün) und welche keine Entsprechung haben (rot). + + Parameters + ---------- + bror_layer : + BauRaumOderBodenordnungsrecht-Memory-Layer (Polygon). + flst_layer : + Flurstücke-Layer (Polygon, WFS oder Memory). + progress : + Optionaler ``ProgressDialog`` aus + :mod:`sn_basis.functions.dialog_wrapper` für Fortschrittsanzeige + und Abbrucherkennung. + linie_toleranz_m : float + Geometrische Puffertoleranz für den Linienvergleich in + CRS-Einheiten (Standardwert :data:`LINIE_TOLERANZ_M` = 0.001 m). + + Returns + ------- + Tuple[Optional[QgsVectorLayer], int] + ``(result_layer, conflict_count)`` + + - ``result_layer``: Linienlayer mit grünen/roten Segmenten, + oder ``None`` bei Abbruch oder fehlgeschlagener Verarbeitung. + - ``conflict_count``: Anzahl der roten Konfliktsegmente (BROR-Teile + ohne Entsprechung in den Flurstücksgrenzen). + """ + + def _canceled() -> bool: + return progress is not None and progress.is_canceled() + + def _schritt(label: str, wert: int) -> None: + if progress is not None: + progress.set_label(label) + progress.set_value(wert) + + # ------------------------------------------------------------------ + # 1. BROR-Geometrie und Umringslinie ermitteln + # ------------------------------------------------------------------ + _schritt("Umringsprüfung: BROR-Umring ermitteln …", 0) + + bror_feats = [ + f for f in bror_layer.getFeatures() + if f.geometry() and not f.geometry().isEmpty() + ] + if not bror_feats: + return None, 0 + + bror_union = QgsGeometry.unaryUnion([f.geometry() for f in bror_feats]) + if bror_union is None or bror_union.isEmpty(): + return None, 0 + + bror_boundary = polygon_zu_linie(bror_union) + if bror_boundary is None or bror_boundary.isEmpty(): + return None, 0 + + crs_authid = bror_layer.crs().authid() + + if _canceled(): + return None, 0 + + # ------------------------------------------------------------------ + # 2. Relevante Flurstücke: nur jene, deren Geometrie den BROR-Umring + # berührt oder schneidet + # ------------------------------------------------------------------ + _schritt("Umringsprüfung: Relevante Flurstücke filtern …", 1) + + relevante_boundaries: List[object] = [] + for feat in flst_layer.getFeatures(): + if _canceled(): + return None, 0 + geom = feat.geometry() + if geom is None or geom.isEmpty(): + continue + try: + if geom.intersects(bror_boundary): + boundary = polygon_zu_linie(geom) + if boundary is not None and not boundary.isEmpty(): + relevante_boundaries.append(boundary) + except Exception: + continue + + if relevante_boundaries: + flst_boundary_union = QgsGeometry.unaryUnion(relevante_boundaries) + else: + flst_boundary_union = None + + if _canceled(): + return None, 0 + + # ------------------------------------------------------------------ + # 3. Segmentvergleich: Differenz und Übereinstimmung berechnen + # ------------------------------------------------------------------ + _schritt("Umringsprüfung: Segmentvergleich …", 2) + + ergebnis_layer = _erstelle_ergebnis_layer(crs_authid) + pr = ergebnis_layer.dataProvider() + feats_to_add: List[object] = [] + conflict_count = 0 + + if flst_boundary_union is None or flst_boundary_union.isEmpty(): + # Kein Flurstück berührt den BROR-Umring → alles ist Abweichung + for part in _zerlege_in_teile(bror_boundary): + feat = QgsFeature() + feat.setGeometry(part) + feat.setAttributes([WERT_ABWEICHUNG]) + feats_to_add.append(feat) + conflict_count += 1 + else: + # Flurstücksumringe leicht puffern für Toleranz-Matching + if linie_toleranz_m > 0: + flst_fuer_diff = flst_boundary_union.buffer(linie_toleranz_m, 4) + else: + flst_fuer_diff = flst_boundary_union + + # Rote Segmente: BROR-Teile ohne Entsprechung in Flurstücksgrenzen + try: + red_geom = bror_boundary.difference(flst_fuer_diff) + except Exception: + red_geom = None + + # Grüne Segmente: BROR-Teile, die nicht rot sind + try: + if red_geom and not red_geom.isEmpty(): + green_geom = bror_boundary.difference(red_geom) + else: + green_geom = bror_boundary + except Exception: + green_geom = None + + for part in _zerlege_in_teile(green_geom): + feat = QgsFeature() + feat.setGeometry(part) + feat.setAttributes([WERT_IDENTISCH]) + feats_to_add.append(feat) + + for part in _zerlege_in_teile(red_geom): + feat = QgsFeature() + feat.setGeometry(part) + feat.setAttributes([WERT_ABWEICHUNG]) + feats_to_add.append(feat) + conflict_count += 1 + + if _canceled(): + return None, 0 + + pr.addFeatures(feats_to_add) + ergebnis_layer.updateExtents() + _setze_kategorien_symbologie(ergebnis_layer) + + _schritt("Umringsprüfung abgeschlossen.", 3) + return ergebnis_layer, conflict_count diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py index 23e62d1..25ee5ea 100644 --- a/functions/verfahrensgebiet_alkis_komplett.py +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -1,26 +1,34 @@ """sn_verfahrensgebiet/functions/verfahrensgebiet_alkis_komplett.py – Vollständiger VG-Lade-Workflow. Orchestriert den vollständigen Arbeitsablauf zum Laden eines Verfahrensgebiets -aus ALKIS mit anschließender Flurstücks-Verschneidung: +aus ALKIS mit anschließender Flurstücks-Verschneidung, Flächenvergleich und +optionaler Umringsprüfung: 1. Verfahrensgebiet aus ``AX_BauRaumOderBodenordnungsrecht`` per VKZ laden. -2. BBox des VG ableiten. -3. Gemarkungsschlüssel per BBox aus ``AX_Gemarkung`` vorschlagen. -4. Flurstücke per Dialog (mit Vorschlag) laden. -5. Flurstücke räumlich auf das VG einschränken (``selectByLocation``). -6. Aus der Auswahl den enriched Verfahrensgebiet-Memory-Layer erzeugen. -7. ALKIS-VG-Hilfs-Layer entfernen, enriched Layer zurückgeben. +2. Gemarkungsschlüssel per Geometrie aus ``AX_Gemarkung`` vorschlagen. +3. Flurstücke per Dialog (mit Vorschlag) laden. +4. Flurstücke räumlich auf das VG einschränken (``selectByLocation``). +5. Aus der Auswahl den enriched Verfahrensgebiet-Memory-Layer erzeugen. +6. Flächenvergleich zwischen BROR-Objekt und abgeleitetem Verfahrensgebiet. -Der enriched Layer enthält die Felder ``flurstueckskennzeichen`` und -``gemarkung_ax_gemarkung_schluessel_gemarkungsnummer`` des -:mod:`~sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service`-Schemas -und ist damit vollständig kompatibel mit ``ergaenze_verfahrensgebiet`` und -``entferne_aus_verfahrensgebiet``. + - **Übereinstimmung** (≤ Toleranz): modale OK-Meldung, Ablauf beendet. + - **Abweichung**: Ja/Nein-Abfrage für Detailprüfung. + +7. Optionale Umringsprüfung (Variante 1): + + - BROR-Umring gegen Flurstücksgrenzen vergleichen. + - Ergebnislayer (grün/rot) ins Projekt einfügen. + - Abschlussmeldung mit Konfliktanzahl. + +Das BROR-Objekt (``AX_BauRaumOderBodenordnungsrecht``) bleibt bei Erfolg als +temporärer Layer ``BauRaumOrdnungsrecht (ALKIS)`` im Projekt erhalten. + +**Rückgabe:** +``Tuple[enriched_layer, flst_layer, bror_layer, LoadStatus]`` **Abbruchverhalten:** Jeder Schritt, der :attr:`LoadStatus.NONE` zurückgibt -oder kein Ergebnis liefert, bricht den Workflow ab. ALKIS-Hilfs-Layer werden -dabei gesäubert, damit das Projekt nicht in einem inkonsistenten Zustand -verbleibt. +oder kein Ergebnis liefert, bricht den Workflow ab. Der BROR-Layer wird nur +bei Fehler/Abbruch entfernt (wenn er in diesem Lauf hinzugefügt wurde). """ from __future__ import annotations @@ -29,8 +37,13 @@ from typing import Optional, Tuple from sn_basis.functions import QgsProject from sn_basis.functions import QMessageBox -from sn_basis.functions.message_wrapper import info, warning +from sn_basis.functions.message_wrapper import warning from sn_basis.functions.variable_wrapper import get_variable +from sn_basis.functions.dialog_wrapper import ( + create_progress_dialog, + show_info_dialog, + ask_yes_no, +) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import ( verfahrensgebiet_alkis, @@ -42,12 +55,17 @@ from sn_verfahrensgebiet.functions.gemarkung_alkis import ( from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( erstelle_verfahrensgebiet_aus_auswahl, - FELD_KENNZEICHEN, VERF_LAYER_NAME, ) +from sn_verfahrensgebiet.functions.umringspruefung import umringspruefung -# Feld für das Verfahrenskennzeichen (VKZ) im enriched Layer +# Feldname für das Verfahrenskennzeichen im enriched Layer FELD_VKZ = "verfahrensnummer" +#: Anzeigename des BROR-Layers im Projekt +BROR_LAYER_NAME = "BauRaumOrdnungsrecht (ALKIS)" +#: Projektvariable für die Flächentoleranz (in m²); Default 0 +FLAECHEN_TOLERANZ_VARIABLE = "flaechen_toleranz_m2" + # --------------------------------------------------------------------------- @@ -57,7 +75,7 @@ FELD_VKZ = "verfahrensnummer" def _entferne_hilfs_layer(project, layer_name: str) -> None: """Entfernt alle Layer mit dem angegebenen Namen aus dem Projekt. - Wird verwendet, um den temporären ALKIS-VG-Layer nach der Verschneidung + Wird verwendet, um temporäre WFS-Hilfs-Layer nach der Verschneidung aus dem Projekt zu bereinigen. Parameters @@ -72,24 +90,22 @@ def _entferne_hilfs_layer(project, layer_name: str) -> None: def _select_by_location(flst_layer, vg_layer) -> int: - """Selektiert Flurstücke, die das Verfahrensgebiet überlappen oder darin liegen. + """Selektiert Flurstücke, die innerhalb des Verfahrensgebiets liegen. - Verwendet ``processing.run("native:selectbylocation")`` mit den Prädikaten - ``overlaps`` (5) und ``within`` (6). Flurstücke, die das VG nur berühren - (gemeinsame Grenzlinie oder -punkt, aber kein gemeinsames Inneres), werden - damit ausgeschlossen. + Verwendet ``processing.run("native:selectbylocation")`` mit Prädikat 6 + (within). Flurstücke, die das VG nur berühren, werden ausgeschlossen. Parameters ---------- flst_layer : - Flurstücks-WFS-Layer (Quelle der Selektion). + Flurstücks-Layer (Quelle der Selektion). vg_layer : - Verfahrensgebiet-Memory-Layer (Schnittmaske). + Verfahrensgebiet-Layer (Schnittmaske). Returns ------- int - Anzahl der selektierten Features. + Anzahl der selektierten Features; 0 bei Fehler oder leerem Ergebnis. """ try: import processing # type: ignore[import] @@ -98,15 +114,14 @@ def _select_by_location(flst_layer, vg_layer) -> int: "native:selectbylocation", { "INPUT": flst_layer, - "PREDICATE": [6], # 5 = overlaps, 6 = within; schließt reines Berühren aus + "PREDICATE": [6], # 6 = within; schließt reines Berühren aus "INTERSECT": vg_layer, - "METHOD": 0, # 0 = neue Selektion + "METHOD": 0, # 0 = neue Selektion }, ) return flst_layer.selectedFeatureCount() except Exception: - # Fallback: keine Selektion (z. B. im Unit-Test-Modus) return 0 @@ -114,9 +129,6 @@ def _fuege_vkz_feld_hinzu(layer, vkz: str) -> None: """Fügt das Feld ``verfahrensnummer`` zum enriched Layer hinzu und befüllt es mit dem Verfahrenskennzeichen, sofern dieses nicht leer ist. - Das Feld wird nur hinzugefügt, wenn ``vkz`` nicht leer ist, damit der - Layer ohne Projektvariable weiterhin funktioniert. - Parameters ---------- layer : @@ -149,83 +161,161 @@ def _fuege_vkz_feld_hinzu(layer, vkz: str) -> None: pass +def _lese_flaechen_toleranz() -> float: + """Liest die Flächentoleranz aus der Projektvariable (Default: 0.0 m²). + + Die Projektvariable :data:`FLAECHEN_TOLERANZ_VARIABLE` kann in den + Projekteigenschaften gesetzt werden. Fehlt sie oder ist sie ungültig, + wird 0.0 zurückgegeben (exakter Vergleich). + + Returns + ------- + float + Toleranz in m² (bei EPSG:25833 identisch mit den Flächen-Einheiten). + """ + try: + raw = get_variable(FLAECHEN_TOLERANZ_VARIABLE) + if raw is not None and str(raw).strip(): + return float(str(raw).strip()) + except Exception: + pass + return 0.0 + + +def _flaeche_des_layers(layer) -> float: + """Summiert die Flächen aller Features eines Layers (in CRS-Einheiten²). + + Parameters + ---------- + layer : + Beliebiger Polygon-Layer. + + Returns + ------- + float + Summe aller Feature-Flächen; 0.0 wenn keine Features vorhanden. + """ + total = 0.0 + for feat in layer.getFeatures(): + try: + geom = feat.geometry() + if geom and not geom.isEmpty(): + total += geom.area() + except Exception: + pass + return total + + +def _vergleiche_flaechen( + bror_layer, vg_layer, toleranz_m2: float = 0.0 +) -> Tuple[float, float, bool]: + """Vergleicht Flächen von BROR- und Verfahrensgebiet-Layer. + + Parameters + ---------- + bror_layer : + BauRaumOrdnungsrecht-Layer. + vg_layer : + Enriched Verfahrensgebiet-Layer. + toleranz_m2 : + Maximale zulässige Flächendifferenz in m². + + Returns + ------- + Tuple[float, float, bool] + ``(bror_flaeche, vg_flaeche, stimmt_ueberein)`` + """ + bror_flaeche = _flaeche_des_layers(bror_layer) + vg_flaeche = _flaeche_des_layers(vg_layer) + stimmt_ueberein = abs(bror_flaeche - vg_flaeche) <= toleranz_m2 + return bror_flaeche, vg_flaeche, stimmt_ueberein + + # --------------------------------------------------------------------------- # Öffentliche API # --------------------------------------------------------------------------- def verfahrensgebiet_alkis_komplett( tab_widget, -) -> Tuple[Optional[object], Optional[object], LoadStatus]: - """Vollständiger VG-Lade-Workflow: ALKIS-VG → BBox → Gemarkungen → - Flurstücke → Verschneidung → enriched Memory-Layer. +) -> Tuple[Optional[object], Optional[object], Optional[object], LoadStatus]: + """Vollständiger VG-Lade-Workflow: ALKIS-VG → Gemarkungen → + Flurstücke → Verschneidung → Flächenvergleich → opt. Umringsprüfung. Schrittfolge: - 1. ``verfahrensgebiet_alkis(tab_widget)`` – Verfahrensgebiet temporär laden - 2. ``vg_layer.extent()`` – BBox ableiten - 3. ``gemarkung_schluessel_und_namen_by_bbox(extent)`` – Schlüssel vorschlagen - 4. ``flurstueck_alkis(tab_widget, vorgabe_schluessel, vorgabe_namen, bbox)`` – Flurstücke laden - 5. ``_select_by_location(flst_layer, vg_layer)`` – räumliche Selektion - 6. ``erstelle_verfahrensgebiet_aus_auswahl(flst_layer)`` – enriched Layer erzeugen - 7. Ggf. VKZ-Feld setzen, ALKIS-VG-Hilfs-Layer entfernen + 1. ``verfahrensgebiet_alkis(tab_widget)`` – BROR-Objekt als Memory-Layer. + 2. ``gemarkung_schluessel_und_namen_von_vg_geometrie(geom)`` – Schlüssel. + 3. ``flurstueck_alkis(...)`` – Flurstücke laden. + 4. ``_select_by_location(...)`` – räumliche Selektion. + 5. ``erstelle_verfahrensgebiet_aus_auswahl(...)`` – enriched Layer. + 6. ``_vergleiche_flaechen(...)`` – Flächenvergleich als Gate. + 7. Ggf. ``umringspruefung(...)`` nach Nutzerbestätigung. + + Der BROR-Layer (``BauRaumOrdnungsrecht (ALKIS)``) bleibt bei Erfolg + im Projekt. Bei Abbruch/Fehler wird er entfernt, falls er in diesem + Lauf hinzugefügt wurde. Parameters ---------- tab_widget : - Eltern-Widget für alle Dialoge (weitergereicht an Unteraufrufe). + Eltern-Widget für alle Dialoge. Returns ------- - Tuple[Optional[enriched_layer], Optional[flst_layer], LoadStatus] - - ``enriched_layer``: Memory-Layer „Verfahrensgebiet" mit - ``flurstueckskennzeichen``- und ``gemarkung``-Feldern, oder ``None`` - bei Abbruch/Fehler. - - ``flst_layer``: Geladener WFS-Flurstück-Layer (bleibt im Projekt), - oder ``None``. - - ``LoadStatus``: Status des VG-Lade-Vorgangs (``FIRST``, ``RELOAD`` - oder ``NONE`` bei Abbruch). - - Notes - ----- - Der temporäre ALKIS-VG-Layer (aus ``AX_BauRaumOderBodenordnungsrecht``) - wird nach der Verschneidung aus dem Projekt entfernt. Im Projekt verbleiben - nur der enriched Memory-Layer und der Flurstücks-WFS-Layer. + Tuple[Optional[enriched_layer], Optional[flst_layer], Optional[bror_layer], LoadStatus] + - ``enriched_layer``: Memory-Layer „Verfahrensgebiet". + - ``flst_layer``: Flurstücke-Layer (bleibt im Projekt). + - ``bror_layer``: BROR-Layer ``BauRaumOrdnungsrecht (ALKIS)`` + (bleibt bei Erfolg im Projekt). + - :class:`LoadStatus`: ``FIRST``, ``RELOAD`` oder ``NONE``. """ project = QgsProject.instance() alkis_vg_layer = None + bror_was_added_by_us = False + abgeschlossen = False + + # Fortschrittsdialog für den Basisworkflow (7 Schritte) + progress = create_progress_dialog( + 7, "Verfahrensgebiet laden", "Initialisierung …" + ) try: # ------------------------------------------------------------------ - # Schritt 1: VG aus ALKIS laden (Zwischenobjekt) + # Schritt 1: VG aus ALKIS laden (BROR-Objekt → Memory-Layer) # ------------------------------------------------------------------ - info("VG laden", "Schritt 1/6: Verfahrensgebiet aus ALKIS laden …", duration=3) - alkis_vg_layer, vg_status, vg_features = verfahrensgebiet_alkis(tab_widget) + progress.set_label("Schritt 1/6: Verfahrensgebiet aus ALKIS laden …") + progress.set_value(0) + if progress.is_canceled(): + return None, None, None, LoadStatus.NONE + + alkis_vg_layer, vg_status, _ = verfahrensgebiet_alkis(tab_widget) if vg_status == LoadStatus.NONE or not alkis_vg_layer: - return None, None, LoadStatus.NONE + return None, None, None, LoadStatus.NONE + + # BROR-Layer umbenennen, um Namenskonflikt mit enriched VG-Layer zu vermeiden + alkis_vg_layer.setName(BROR_LAYER_NAME) - # Temporär ins Projekt hinzufügen, damit selectByLocation ihn findet if vg_status in (LoadStatus.FIRST, LoadStatus.RELOAD): project.addMapLayer(alkis_vg_layer) + bror_was_added_by_us = True + + progress.set_value(1) # ------------------------------------------------------------------ - # Schritt 2 (entfernt): BBox-Ableitung war hier – WFS unterstützt kein - # BBox-Filter, daher wird der Extent nicht mehr benötigt. + # Schritt 2: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln # ------------------------------------------------------------------ + progress.set_label("Schritt 2/6: Gemarkungsschlüssel ermitteln …") + if progress.is_canceled(): + return None, None, None, LoadStatus.NONE - # ------------------------------------------------------------------ - # Schritt 3: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln - # ------------------------------------------------------------------ - info("VG laden", "Schritt 2/6: Gemarkungsschlüssel ermitteln \u2026", duration=3) - # VG-Geometrie aus den Roh-WFS-Features bilden (Union, damit - # alle Teilflächen berücksichtigt werden) - from sn_basis.functions import QgsGeometry as _QgsGeometry - if vg_features: - vg_geom = _QgsGeometry.unaryUnion([f.geometry() for f in vg_features]) - else: - # Bei KEEP-Status keine WFS-Features vorhanden; Fallback auf Layergeometrie - vg_geom = next(alkis_vg_layer.getFeatures()).geometry() - schluessel_namen = gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom) + bror_feats = list(alkis_vg_layer.getFeatures()) + vg_geom = bror_feats[0].geometry() if bror_feats else None + + schluessel_namen = ( + gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom) + if vg_geom else [] + ) vorgabe_schluessel = [nr for nr, _ in schluessel_namen] vorgabe_namen = [nm for _, nm in schluessel_namen if nm] @@ -236,25 +326,34 @@ def verfahrensgebiet_alkis_komplett( duration=5, ) + progress.set_value(2) + # ------------------------------------------------------------------ - # Schritt 4: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt) + # Schritt 3: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt) # ------------------------------------------------------------------ - info("VG laden", "Schritt 3/6: Flurstücke aus ALKIS laden …", duration=3) + progress.set_label("Schritt 3/6: Flurstücke aus ALKIS laden …") + if progress.is_canceled(): + return None, None, None, LoadStatus.NONE + flst_layer, flst_status = flurstueck_alkis( tab_widget, vorgabe_schluessel=vorgabe_schluessel, vorgabe_namen=vorgabe_namen if vorgabe_namen else None, - # bbox wird nicht übergeben – WFS unterstützt keinen BBox-Filter - ohne_dialog=True, # kein Nutzer-Dialog in der Pipeline + ohne_dialog=True, ) if flst_status == LoadStatus.NONE or not flst_layer: - return None, None, LoadStatus.NONE + return None, None, None, LoadStatus.NONE + + progress.set_value(3) # ------------------------------------------------------------------ - # Schritt 5: Räumliche Selektion + # Schritt 4: Räumliche Selektion # ------------------------------------------------------------------ - info("VG laden", "Schritt 4/6: Flurstücke räumlich selektieren …", duration=3) + progress.set_label("Schritt 4/6: Flurstücke räumlich selektieren …") + if progress.is_canceled(): + return None, None, None, LoadStatus.NONE + anzahl = _select_by_location(flst_layer, alkis_vg_layer) if anzahl == 0: @@ -264,12 +363,17 @@ def verfahrensgebiet_alkis_komplett( "Es wurden keine Flurstücke gefunden, die das Verfahrensgebiet schneiden.\n" "Bitte Gemarkungsschlüssel und Ausdehnung prüfen.", ) - return None, flst_layer, LoadStatus.NONE + return None, flst_layer, None, LoadStatus.NONE + + progress.set_value(4) # ------------------------------------------------------------------ - # Schritt 6: Enriched Memory-Layer aus Auswahl erzeugen + # Schritt 5: Enriched Memory-Layer aus Auswahl erzeugen # ------------------------------------------------------------------ - info("VG laden", "Schritt 5/6: Verfahrensgebiet-Layer erzeugen …", duration=3) + progress.set_label("Schritt 5/6: Verfahrensgebiet-Layer erzeugen …") + if progress.is_canceled(): + return None, None, None, LoadStatus.NONE + enriched_layer, erfolgreich, fehlgeschlagen = erstelle_verfahrensgebiet_aus_auswahl( flst_layer ) @@ -280,30 +384,97 @@ def verfahrensgebiet_alkis_komplett( "Fehler", "Verfahrensgebiet-Layer konnte nicht erstellt werden.", ) - return None, flst_layer, LoadStatus.NONE + return None, flst_layer, None, LoadStatus.NONE - # ------------------------------------------------------------------ - # Schritt 7: VKZ-Feld setzen (optional, wenn Projektvariable vorhanden) - # ------------------------------------------------------------------ vkz = get_variable("verfahrensnummer") or "" _fuege_vkz_feld_hinzu(enriched_layer, vkz) - info( - "VG laden", - f"Schritt 6/6: Fertig. {erfolgreich} Flurstücke verarbeitet, " - f"{fehlgeschlagen} fehlgeschlagen.", - duration=5, + progress.set_value(5) + + # ------------------------------------------------------------------ + # Schritt 6: Flächenvergleich als Gate + # ------------------------------------------------------------------ + progress.set_label("Schritt 6/6: Flächenvergleich …") + if progress.is_canceled(): + return None, None, None, LoadStatus.NONE + + toleranz = _lese_flaechen_toleranz() + bror_flaeche, vg_flaeche, stimmt_ueberein = _vergleiche_flaechen( + alkis_vg_layer, enriched_layer, toleranz ) - return enriched_layer, flst_layer, vg_status + progress.set_value(6) + + if stimmt_ueberein: + progress.set_value(7) + progress.close() + show_info_dialog( + "Flächenvergleich", + "Verfahrensgebiet mit erwarteter Geometrie erzeugt", + parent=tab_widget, + ) + abgeschlossen = True + return enriched_layer, flst_layer, alkis_vg_layer, vg_status + + # ------------------------------------------------------------------ + # Flächenabweichung → Nutzerabfrage für Detailprüfung + # ------------------------------------------------------------------ + progress.close() + diff = abs(bror_flaeche - vg_flaeche) + starten = ask_yes_no( + "Abweichung erkannt", + f"Abweichung zwischen BauRaumOrdnungsrecht-Objekt und abgeleitetem " + f"Verfahrensgebiet erkannt. Detailprüfung starten?\n\n" + f"BauRaumOrdnungsrecht: {bror_flaeche:.2f} m²\n" + f"Verfahrensgebiet: {vg_flaeche:.2f} m²\n" + f"Differenz: {diff:.2f} m²", + default=True, + parent=tab_widget, + ) + + if not starten: + abgeschlossen = True + return enriched_layer, flst_layer, alkis_vg_layer, vg_status + + # ------------------------------------------------------------------ + # Umringsprüfung (Variante 1: linienbasierter Umringvergleich) + # ------------------------------------------------------------------ + umring_progress = create_progress_dialog( + 3, "Detailprüfung (Umringe)", "Starte Umringsprüfung …" + ) + + ergebnis_layer, konflikt_anzahl = umringspruefung( + alkis_vg_layer, flst_layer, progress=umring_progress + ) + + if umring_progress.is_canceled(): + umring_progress.close() + abgeschlossen = True + return enriched_layer, flst_layer, alkis_vg_layer, vg_status + + umring_progress.set_value(3) + umring_progress.close() + + if ergebnis_layer and ergebnis_layer.isValid(): + project.addMapLayer(ergebnis_layer) + + show_info_dialog( + "Detailprüfung", + f"Detailprüfung abgeschlossen. " + f"Es wurden {konflikt_anzahl} Konfliktstellen identifiziert.", + parent=tab_widget, + ) + + abgeschlossen = True + return enriched_layer, flst_layer, alkis_vg_layer, vg_status finally: - # Hilfs-Layer (ALKIS-VG) stets aus dem Projekt entfernen - if alkis_vg_layer is not None: - _entferne_hilfs_layer(project, VERF_LAYER_NAME) - # Auch nach Layername des ALKIS-Temp-Layers bereinigen - _entferne_hilfs_layer(project, "TempVerfahrensgebiet") + # WFS-Temp-Layer stets aus dem Projekt entfernen + _entferne_hilfs_layer(project, "TempVerfahrensgebiet") + # BROR-Layer bei Fehler/Abbruch entfernen (nur wenn in diesem Lauf hinzugefügt) + if not abgeschlossen and bror_was_added_by_us and alkis_vg_layer is not None: try: project.removeMapLayer(alkis_vg_layer.id()) except Exception: pass + diff --git a/tests/test_verfahrensgebiet_alkis_komplett.py b/tests/test_verfahrensgebiet_alkis_komplett.py index 2e2100a..e19d134 100644 --- a/tests/test_verfahrensgebiet_alkis_komplett.py +++ b/tests/test_verfahrensgebiet_alkis_komplett.py @@ -4,27 +4,39 @@ Unit-Tests für :mod:`sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_kompl Getestete Szenarien: -Erfolgsfall: -- Vollständiger Workflow → enriched Layer + flst_layer + LoadStatus.FIRST +Erfolgsfall (Flächenübereinstimmung): +- Vollständiger Workflow → enriched Layer + flst_layer + bror_layer + LoadStatus.FIRST - VKZ aus Projektvariable wird als Feld gesetzt (wenn vorhanden) - Kein VKZ in Projektvariable → Feld wird nicht gesetzt (kein Fehler) Abbruchpfade: -- verfahrensgebiet_alkis gibt LoadStatus.NONE → (None, None, NONE) -- flurstueck_alkis gibt LoadStatus.NONE → (None, None, NONE) -- Keine Flurstücke selektiert → (None, flst_layer, NONE) + Warnung -- erstelle_verfahrensgebiet_aus_auswahl liefert ungültigen Layer → (None, flst_layer, NONE) -- Cleanup: ALKIS-VG-Hilfs-Layer wird in allen Fällen aus Projekt entfernt +- verfahrensgebiet_alkis gibt LoadStatus.NONE → (None, None, None, NONE) +- flurstueck_alkis gibt LoadStatus.NONE → (None, None, None, NONE) +- Keine Flurstücke selektiert → (None, flst_layer, None, NONE) + Warnung +- erstelle_verfahrensgebiet_aus_auswahl liefert ungültigen Layer → (None, flst_layer, None, NONE) +- Progress-Abbruch nach Schritt 1 → sofortiger sauberer Rückgabestatus + +Flächenvergleich: +- Übereinstimmung → show_info_dialog, keine Umringsprüfung +- Abweichung + Nein → kein Umringsprüfungs-Aufruf +- Abweichung + Ja → umringspruefung aufgerufen, Ergebnislayer ins Projekt + +Cleanup: +- BROR-Layer wird bei Erfolg NICHT entfernt +- BROR-Layer wird bei Fehler entfernt (wenn in diesem Lauf hinzugefügt) +- TempVerfahrensgebiet wird stets entfernt Hilfsfunktionen: - _select_by_location: processing-Fallback liefert 0 → keine Exception - _fuege_vkz_feld_hinzu: leeres VKZ wird ignoriert +- _vergleiche_flaechen: korrekte Toleranzlogik +- _lese_flaechen_toleranz: Default 0.0, parst Projektvariable """ import sys import types import unittest -from unittest.mock import patch, MagicMock, call +from unittest.mock import patch, MagicMock # --------------------------------------------------------------------------- # Minimales QGIS-Mock-Setup @@ -46,6 +58,8 @@ from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import ( verfahrensgebiet_alkis_komplett, _select_by_location, _fuege_vkz_feld_hinzu, + _vergleiche_flaechen, + _lese_flaechen_toleranz, ) @@ -60,7 +74,8 @@ class _DummyRect: def yMaximum(self): return 5658000.0 -def _make_layer(valid=True, name="Layer", lid="L1", feature_count=1): +def _make_layer(valid=True, name="Layer", lid="L1", feature_count=1, area=1000.0): + """Erzeugt einen konfigurierten Mock-Layer mit getFeatures-Support.""" layer = MagicMock() layer.isValid.return_value = valid layer.id.return_value = lid @@ -68,6 +83,18 @@ def _make_layer(valid=True, name="Layer", lid="L1", feature_count=1): layer.extent.return_value = _DummyRect() layer.featureCount.return_value = feature_count layer.selectedFeatureCount.return_value = feature_count + + mock_geom = MagicMock() + mock_geom.isEmpty.return_value = False + mock_geom.area.return_value = area + mock_geom.boundary.return_value = MagicMock(isEmpty=lambda: True) + mock_feat = MagicMock() + mock_feat.geometry.return_value = mock_geom + layer.getFeatures.return_value = [mock_feat] + + mock_crs = MagicMock() + mock_crs.authid.return_value = "EPSG:25833" + layer.crs.return_value = mock_crs return layer @@ -90,6 +117,34 @@ class _DummyProject: return self +def _make_progress_mock(canceled=False): + prog = MagicMock() + prog.is_canceled.return_value = canceled + return prog + + +# --------------------------------------------------------------------------- +# Patch-Konstanten +# --------------------------------------------------------------------------- + +_MOD = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett" +_PATCH_VG_ALKIS = f"{_MOD}.verfahrensgebiet_alkis" +_PATCH_GEM = f"{_MOD}.gemarkung_schluessel_und_namen_von_vg_geometrie" +_PATCH_FLST = f"{_MOD}.flurstueck_alkis" +_PATCH_SELECT = f"{_MOD}._select_by_location" +_PATCH_ERSTELLE = f"{_MOD}.erstelle_verfahrensgebiet_aus_auswahl" +_PATCH_VKZ = f"{_MOD}._fuege_vkz_feld_hinzu" +_PATCH_PROJECT = f"{_MOD}.QgsProject" +_PATCH_MSGBOX = f"{_MOD}.QMessageBox" +_PATCH_WARNING = f"{_MOD}.warning" +_PATCH_GET_VAR = f"{_MOD}.get_variable" +_PATCH_PROGRESS = f"{_MOD}.create_progress_dialog" +_PATCH_VERGLEICHE = f"{_MOD}._vergleiche_flaechen" +_PATCH_SHOW_INFO = f"{_MOD}.show_info_dialog" +_PATCH_ASK_YES_NO = f"{_MOD}.ask_yes_no" +_PATCH_UMRING = f"{_MOD}.umringspruefung" + + # --------------------------------------------------------------------------- # Tests: _select_by_location # --------------------------------------------------------------------------- @@ -97,35 +152,26 @@ class _DummyProject: class TestSelectByLocation(unittest.TestCase): def test_processing_not_available_returns_zero(self): - """Wenn processing nicht importierbar ist → 0 ohne Exception.""" - flst = MagicMock() - vg = MagicMock() + flst, vg = MagicMock(), MagicMock() with patch.dict("sys.modules", {"processing": None}): result = _select_by_location(flst, vg) self.assertEqual(result, 0) def test_processing_success_returns_count(self): - """processing.run erfolgreich → selectedFeatureCount des Layers.""" flst = MagicMock() flst.selectedFeatureCount.return_value = 7 vg = MagicMock() - - mock_processing = MagicMock() - with patch.dict("sys.modules", {"processing": mock_processing}): + mock_proc = MagicMock() + with patch.dict("sys.modules", {"processing": mock_proc}): result = _select_by_location(flst, vg) - self.assertEqual(result, 7) def test_processing_exception_returns_zero(self): - """Exception in processing.run → 0 ohne Propagation.""" - flst = MagicMock() - vg = MagicMock() - - mock_processing = MagicMock() - mock_processing.run.side_effect = RuntimeError("Kein QGIS") - with patch.dict("sys.modules", {"processing": mock_processing}): + flst, vg = MagicMock(), MagicMock() + mock_proc = MagicMock() + mock_proc.run.side_effect = RuntimeError("Kein QGIS") + with patch.dict("sys.modules", {"processing": mock_proc}): result = _select_by_location(flst, vg) - self.assertEqual(result, 0) @@ -136,225 +182,427 @@ class TestSelectByLocation(unittest.TestCase): class TestFuegeVkzFeldHinzu(unittest.TestCase): def test_empty_vkz_does_nothing(self): - """Leeres VKZ → kein Zugriff auf Layer.""" layer = MagicMock() _fuege_vkz_feld_hinzu(layer, "") layer.dataProvider.assert_not_called() def test_none_vkz_does_nothing(self): - """None-VKZ → kein Zugriff auf Layer.""" layer = MagicMock() _fuege_vkz_feld_hinzu(layer, None) layer.dataProvider.assert_not_called() def test_vkz_field_added(self): - """Nicht-leeres VKZ → Provider.addAttributes wird aufgerufen.""" pr = MagicMock() pr.addAttributes.return_value = True pr.changeAttributeValues.return_value = True - layer = MagicMock() layer.dataProvider.return_value = pr layer.fields.return_value.indexFromName.return_value = 2 layer.getFeatures.return_value = [] - _fuege_vkz_feld_hinzu(layer, "VF-2024-001") - pr.addAttributes.assert_called_once() +# --------------------------------------------------------------------------- +# Tests: _vergleiche_flaechen / _lese_flaechen_toleranz +# --------------------------------------------------------------------------- + +class TestFlaechenvergleich(unittest.TestCase): + + def _layer(self, area: float): + layer = MagicMock() + geom = MagicMock() + geom.isEmpty.return_value = False + geom.area.return_value = area + feat = MagicMock() + feat.geometry.return_value = geom + layer.getFeatures.return_value = [feat] + return layer + + def test_gleiche_flaeche_stimmt_ueberein(self): + self.assertTrue(_vergleiche_flaechen(self._layer(1234.5), self._layer(1234.5), 0.0)[2]) + + def test_verschiedene_flaeche_stimmt_nicht_ueberein(self): + self.assertFalse(_vergleiche_flaechen(self._layer(1234.5), self._layer(1200.0), 0.0)[2]) + + def test_differenz_innerhalb_toleranz(self): + self.assertTrue(_vergleiche_flaechen(self._layer(1234.5), self._layer(1234.8), 1.0)[2]) + + def test_differenz_ausserhalb_toleranz(self): + self.assertFalse(_vergleiche_flaechen(self._layer(1234.5), self._layer(1236.0), 1.0)[2]) + + def test_flaechen_werte_korrekt(self): + bror_f, vg_f, _ = _vergleiche_flaechen(self._layer(800.0), self._layer(750.0)) + self.assertAlmostEqual(bror_f, 800.0) + self.assertAlmostEqual(vg_f, 750.0) + + @patch(f"{_MOD}.get_variable", return_value=None) + def test_lese_toleranz_default_ist_null(self, _): + self.assertEqual(_lese_flaechen_toleranz(), 0.0) + + @patch(f"{_MOD}.get_variable", return_value="2.5") + def test_lese_toleranz_parst_string(self, _): + self.assertAlmostEqual(_lese_flaechen_toleranz(), 2.5) + + @patch(f"{_MOD}.get_variable", return_value="ungueltig") + def test_lese_toleranz_ungueltig_fallback_null(self, _): + self.assertEqual(_lese_flaechen_toleranz(), 0.0) + + # --------------------------------------------------------------------------- # Tests: verfahrensgebiet_alkis_komplett (Hauptworkflow) # --------------------------------------------------------------------------- -_PATCH_VG_ALKIS = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.verfahrensgebiet_alkis" -_PATCH_GEM_BY_BBOX = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.gemarkung_schluessel_und_namen_by_bbox" -_PATCH_FLST = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.flurstueck_alkis" -_PATCH_SELECT = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett._select_by_location" -_PATCH_ERSTELLE = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.erstelle_verfahrensgebiet_aus_auswahl" -_PATCH_VKZ = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett._fuege_vkz_feld_hinzu" -_PATCH_PROJECT = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.QgsProject" -_PATCH_MSGBOX = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.QMessageBox" -_PATCH_INFO = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.info" -_PATCH_WARNING = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.warning" -_PATCH_GET_VAR = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.get_variable" - - class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): - def _make_project(self): - proj = _DummyProject() - mock = MagicMock() - mock.instance.return_value = proj - # Leere mapLayersByName für Cleanup - proj._layers_by_name = {"Verfahrensgebiet": [], "TempVerfahrensgebiet": []} - return mock, proj + # ------------------------------------------------------------------ # + # Abbruchpfade + # ------------------------------------------------------------------ # - # ------------------------------------------------------------------ # - # 1. VG-ALKIS liefert NONE → sofortiger Abbruch # - # ------------------------------------------------------------------ # + @patch(_PATCH_PROGRESS) @patch(_PATCH_PROJECT) @patch(_PATCH_VG_ALKIS) - def test_vg_alkis_none_aborts(self, mock_vg, mock_proj): + def test_vg_alkis_none_aborts(self, mock_vg, mock_proj, mock_prog): mock_proj.instance.return_value = _DummyProject() - mock_vg.return_value = (None, LoadStatus.NONE) + mock_vg.return_value = (None, LoadStatus.NONE, []) + mock_prog.return_value = _make_progress_mock() - enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) self.assertIsNone(enriched) self.assertIsNone(flst) + self.assertIsNone(bror) self.assertEqual(status, LoadStatus.NONE) - # ------------------------------------------------------------------ # - # 2. flurstueck_alkis liefert NONE → Abbruch nach Gemarkungsabfrage # - # ------------------------------------------------------------------ # + @patch(_PATCH_PROGRESS) @patch(_PATCH_PROJECT) - @patch(_PATCH_FLST) - @patch(_PATCH_GEM_BY_BBOX) + @patch(_PATCH_GEM) @patch(_PATCH_VG_ALKIS) - def test_flst_none_aborts(self, mock_vg, mock_gem, mock_flst, mock_proj): + def test_flst_none_aborts(self, mock_vg, mock_gem, mock_proj, mock_prog): mock_proj.instance.return_value = _DummyProject() - vg_layer = _make_layer(name="Verfahrensgebiet") - mock_vg.return_value = (vg_layer, LoadStatus.FIRST) + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) mock_gem.return_value = [("1234", "Muster")] - mock_flst.return_value = (None, LoadStatus.NONE) + mock_prog.return_value = _make_progress_mock() - enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + with patch(_PATCH_FLST, return_value=(None, LoadStatus.NONE)): + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) self.assertIsNone(enriched) self.assertIsNone(flst) self.assertEqual(status, LoadStatus.NONE) - # ------------------------------------------------------------------ # - # 3. Keine Selektion → Warnung + (None, flst_layer, NONE) # - # ------------------------------------------------------------------ # + @patch(_PATCH_PROGRESS) @patch(_PATCH_MSGBOX) @patch(_PATCH_SELECT) @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) @patch(_PATCH_FLST) - @patch(_PATCH_GEM_BY_BBOX) @patch(_PATCH_VG_ALKIS) def test_no_selection_returns_none_enriched( - self, mock_vg, mock_gem, mock_flst, mock_proj, mock_select, mock_msgbox + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_msgbox, mock_prog ): mock_proj.instance.return_value = _DummyProject() - vg_layer = _make_layer(name="Verfahrensgebiet") + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") flst_layer = _make_layer(name="Flurstücke") - mock_vg.return_value = (vg_layer, LoadStatus.FIRST) - mock_gem.return_value = [] - mock_flst.return_value = (flst_layer, LoadStatus.FIRST) - mock_select.return_value = 0 # keine Selektion + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 0 + mock_prog.return_value = _make_progress_mock() - enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) self.assertIsNone(enriched) self.assertIs(flst, flst_layer) + self.assertIsNone(bror) self.assertEqual(status, LoadStatus.NONE) mock_msgbox.warning.assert_called_once() - # ------------------------------------------------------------------ # - # 4. Vollständiger Erfolgsfall # - # ------------------------------------------------------------------ # - @patch(_PATCH_VKZ) - @patch(_PATCH_GET_VAR) - @patch(_PATCH_ERSTELLE) - @patch(_PATCH_SELECT) - @patch(_PATCH_PROJECT) - @patch(_PATCH_FLST) - @patch(_PATCH_GEM_BY_BBOX) - @patch(_PATCH_VG_ALKIS) - def test_success_full_workflow( - self, - mock_vg, mock_gem, mock_flst, mock_proj, - mock_select, mock_erstelle, mock_get_var, mock_vkz_fn, - ): - """Vollständiger Workflow → enriched Layer + flst Layer + FIRST.""" - proj_instance = _DummyProject() - mock_proj.instance.return_value = proj_instance - - vg_layer = _make_layer(name="Verfahrensgebiet") - flst_layer = _make_layer(name="Flurstücke", lid="FLST1") - enriched = _make_layer(name="Verfahrensgebiet", lid="VG_ENRICHED", valid=True) - - mock_vg.return_value = (vg_layer, LoadStatus.FIRST) - mock_gem.return_value = [("1234", "Muster")] - mock_flst.return_value = (flst_layer, LoadStatus.FIRST) - mock_select.return_value = 3 - mock_erstelle.return_value = (enriched, 3, 0) - mock_get_var.return_value = "VF-2024-001" - - result_enriched, result_flst, status = verfahrensgebiet_alkis_komplett(None) - - self.assertIs(result_enriched, enriched) - self.assertIs(result_flst, flst_layer) - self.assertEqual(status, LoadStatus.FIRST) - mock_vkz_fn.assert_called_once_with(enriched, "VF-2024-001") - - # ------------------------------------------------------------------ # - # 5. Kein VKZ → _fuege_vkz_feld_hinzu mit leerem String # - # ------------------------------------------------------------------ # - @patch(_PATCH_VKZ) - @patch(_PATCH_GET_VAR) - @patch(_PATCH_ERSTELLE) - @patch(_PATCH_SELECT) - @patch(_PATCH_PROJECT) - @patch(_PATCH_FLST) - @patch(_PATCH_GEM_BY_BBOX) - @patch(_PATCH_VG_ALKIS) - def test_no_vkz_variable_passes_empty_string( - self, - mock_vg, mock_gem, mock_flst, mock_proj, - mock_select, mock_erstelle, mock_get_var, mock_vkz_fn, - ): - proj_instance = _DummyProject() - mock_proj.instance.return_value = proj_instance - - vg_layer = _make_layer(name="Verfahrensgebiet") - flst_layer = _make_layer(name="Flurstücke") - enriched = _make_layer(name="Verfahrensgebiet", valid=True) - - mock_vg.return_value = (vg_layer, LoadStatus.FIRST) - mock_gem.return_value = [] - mock_flst.return_value = (flst_layer, LoadStatus.FIRST) - mock_select.return_value = 2 - mock_erstelle.return_value = (enriched, 2, 0) - mock_get_var.return_value = "" # keine Projektvariable - - verfahrensgebiet_alkis_komplett(None) - - mock_vkz_fn.assert_called_once_with(enriched, "") - - # ------------------------------------------------------------------ # - # 6. erstelle_verfahrensgebiet liefert ungültigen Layer # - # ------------------------------------------------------------------ # + @patch(_PATCH_PROGRESS) @patch(_PATCH_MSGBOX) @patch(_PATCH_ERSTELLE) @patch(_PATCH_SELECT) @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) @patch(_PATCH_FLST) - @patch(_PATCH_GEM_BY_BBOX) @patch(_PATCH_VG_ALKIS) def test_invalid_enriched_layer_returns_none( - self, mock_vg, mock_gem, mock_flst, mock_proj, mock_select, mock_erstelle, mock_msgbox + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_erstelle, mock_msgbox, mock_prog ): mock_proj.instance.return_value = _DummyProject() - vg_layer = _make_layer() - flst_layer = _make_layer(name="Flurstücke") + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + flst_layer = _make_layer(name="Flurstücke") bad_enriched = _make_layer(valid=False) - mock_vg.return_value = (vg_layer, LoadStatus.FIRST) - mock_gem.return_value = [] - mock_flst.return_value = (flst_layer, LoadStatus.FIRST) - mock_select.return_value = 5 - mock_erstelle.return_value = (bad_enriched, 0, 5) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 5 + mock_erstelle.return_value = (bad_enriched, 0, 5) + mock_prog.return_value = _make_progress_mock() - enriched, flst, status = verfahrensgebiet_alkis_komplett(None) + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) self.assertIsNone(enriched) self.assertIs(flst, flst_layer) + self.assertIsNone(bror) self.assertEqual(status, LoadStatus.NONE) mock_msgbox.critical.assert_called_once() + @patch(_PATCH_PROGRESS) + @patch(_PATCH_PROJECT) + @patch(_PATCH_VG_ALKIS) + def test_progress_cancel_after_step1_returns_none(self, mock_vg, mock_proj, mock_prog): + """Abbruch nach Schritt 1 → sofortiger sauberer Rückgabestatus.""" + mock_proj.instance.return_value = _DummyProject() + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + + prog = MagicMock() + prog.is_canceled.side_effect = [False, True] # 1. Aufruf False, 2. True + mock_prog.return_value = prog + + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIsNone(enriched) + self.assertEqual(status, LoadStatus.NONE) + + # ------------------------------------------------------------------ # + # Flächenvergleich – Übereinstimmung + # ------------------------------------------------------------------ # + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_SHOW_INFO) + @patch(_PATCH_VERGLEICHE) + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) + @patch(_PATCH_FLST) + @patch(_PATCH_VG_ALKIS) + def test_success_flaechen_match_zeigt_info( + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_info, mock_prog, + ): + """Flächenübereinstimmung → show_info_dialog mit erwartetem Text.""" + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + flst_layer = _make_layer(name="Flurstücke", lid="FLST1") + enriched = _make_layer(name="Verfahrensgebiet", lid="VG_ENRICHED", valid=True) + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [("1234", "Muster")] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 3 + mock_erstelle.return_value = (enriched, 3, 0) + mock_get_var.return_value = "VF-2024-001" + mock_vergl.return_value = (1234.5, 1234.5, True) + mock_prog.return_value = _make_progress_mock() + + result_enriched, result_flst, result_bror, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIs(result_enriched, enriched) + self.assertIs(result_flst, flst_layer) + self.assertIs(result_bror, vg_layer) + self.assertEqual(status, LoadStatus.FIRST) + mock_vkz.assert_called_once_with(enriched, "VF-2024-001") + mock_info.assert_called_once() + self.assertIn("erwarteter Geometrie", mock_info.call_args[0][1]) + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_SHOW_INFO) + @patch(_PATCH_VERGLEICHE) + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) + @patch(_PATCH_FLST) + @patch(_PATCH_VG_ALKIS) + def test_no_vkz_variable_passes_empty_string( + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_info, mock_prog, + ): + mock_proj.instance.return_value = _DummyProject() + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + flst_layer = _make_layer(name="Flurstücke") + enriched = _make_layer(name="Verfahrensgebiet", valid=True) + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 2 + mock_erstelle.return_value = (enriched, 2, 0) + mock_get_var.return_value = "" + mock_vergl.return_value = (1000.0, 1000.0, True) + mock_prog.return_value = _make_progress_mock() + + verfahrensgebiet_alkis_komplett(None) + + mock_vkz.assert_called_once_with(enriched, "") + + # ------------------------------------------------------------------ # + # Flächenvergleich – Abweichung + Nein + # ------------------------------------------------------------------ # + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_UMRING) + @patch(_PATCH_ASK_YES_NO) + @patch(_PATCH_VERGLEICHE) + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) + @patch(_PATCH_FLST) + @patch(_PATCH_VG_ALKIS) + def test_flaechen_abweichung_nein_kein_umring( + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_ask, mock_umring, mock_prog, + ): + """Abweichung + Nein → umringspruefung wird NICHT aufgerufen.""" + mock_proj.instance.return_value = _DummyProject() + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + flst_layer = _make_layer(name="Flurstücke") + enriched = _make_layer(name="Verfahrensgebiet", valid=True) + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 3 + mock_erstelle.return_value = (enriched, 3, 0) + mock_get_var.return_value = "" + mock_vergl.return_value = (1000.0, 800.0, False) + mock_ask.return_value = False + mock_prog.return_value = _make_progress_mock() + + result_enriched, _, _, status = verfahrensgebiet_alkis_komplett(None) + + mock_umring.assert_not_called() + self.assertIs(result_enriched, enriched) + self.assertEqual(status, LoadStatus.FIRST) + + # ------------------------------------------------------------------ # + # Flächenvergleich – Abweichung + Ja → Umringsprüfung + # ------------------------------------------------------------------ # + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_SHOW_INFO) + @patch(_PATCH_UMRING) + @patch(_PATCH_ASK_YES_NO) + @patch(_PATCH_VERGLEICHE) + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) + @patch(_PATCH_FLST) + @patch(_PATCH_VG_ALKIS) + def test_flaechen_abweichung_ja_startet_umringspruefung( + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_ask, + mock_umring, mock_info, mock_prog, + ): + """Abweichung + Ja → umringspruefung aufgerufen, Ergebnislayer ins Projekt, + Abschlussmeldung mit Konfliktanzahl.""" + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + flst_layer = _make_layer(name="Flurstücke") + enriched = _make_layer(name="Verfahrensgebiet", valid=True) + ergebnis_layer = _make_layer(name="Umringsprüfung", lid="UMRING_L") + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 3 + mock_erstelle.return_value = (enriched, 3, 0) + mock_get_var.return_value = "" + mock_vergl.return_value = (1000.0, 800.0, False) + mock_ask.return_value = True + mock_umring.return_value = (ergebnis_layer, 4) + mock_prog.return_value = _make_progress_mock() + + result_enriched, _, _, status = verfahrensgebiet_alkis_komplett(None) + + mock_umring.assert_called_once() + self.assertIn(ergebnis_layer, proj_inst.added) + mock_info.assert_called_once() + self.assertIn("4", mock_info.call_args[0][1]) + self.assertIn("Konfliktstellen", mock_info.call_args[0][1]) + self.assertEqual(status, LoadStatus.FIRST) + + # ------------------------------------------------------------------ # + # Cleanup-Verhalten + # ------------------------------------------------------------------ # + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_SHOW_INFO) + @patch(_PATCH_VERGLEICHE) + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) + @patch(_PATCH_FLST) + @patch(_PATCH_VG_ALKIS) + def test_bror_layer_bleibt_bei_erfolg_im_projekt( + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_info, mock_prog, + ): + """Bei Erfolg darf der BROR-Layer NICHT entfernt werden.""" + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)", lid="BROR_ID") + flst_layer = _make_layer(name="Flurstücke") + enriched = _make_layer(name="Verfahrensgebiet", valid=True) + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 3 + mock_erstelle.return_value = (enriched, 3, 0) + mock_get_var.return_value = "" + mock_vergl.return_value = (1000.0, 1000.0, True) + mock_prog.return_value = _make_progress_mock() + + verfahrensgebiet_alkis_komplett(None) + + self.assertNotIn("BROR_ID", proj_inst.removed) + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) + @patch(_PATCH_VG_ALKIS) + def test_bror_layer_wird_bei_fehler_entfernt( + self, mock_vg, mock_gem, mock_proj, mock_prog + ): + """Bei Fehler (flst_alkis NONE) wird der BROR-Layer aus dem Projekt entfernt.""" + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)", lid="BROR_ERR") + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_prog.return_value = _make_progress_mock() + + with patch(_PATCH_FLST, return_value=(None, LoadStatus.NONE)): + verfahrensgebiet_alkis_komplett(None) + + self.assertIn("BROR_ERR", proj_inst.removed) + if __name__ == "__main__": unittest.main() + diff --git a/tests/test_working_tab.py b/tests/test_working_tab.py index 5af3cd7..f2fc707 100644 --- a/tests/test_working_tab.py +++ b/tests/test_working_tab.py @@ -18,9 +18,10 @@ from sn_verfahrensgebiet.ui.tabs.working_tab import WorkingTab, LoadStatus class _DummyLayer: - def __init__(self, valid=True, layer_id="L1"): + def __init__(self, valid=True, layer_id="L1", layer_name="DummyLayer"): self._valid = valid self._id = layer_id + self._name = layer_name def isValid(self): return self._valid @@ -28,6 +29,9 @@ class _DummyLayer: def id(self): return self._id + def name(self): + return self._name + def extent(self): return object() @@ -140,11 +144,11 @@ class TestWorkingTab(unittest.TestCase): mock_apply_style, mock_success, ): - """verfahrensgebiet_alkis_komplett → 3-Tupel (enriched, flst, FIRST).""" + """verfahrensgebiet_alkis_komplett → 4-Tupel (enriched, flst, bror, FIRST).""" tab = self._make_tab() enriched = _DummyLayer(valid=True, layer_id="enriched_ok") flst = _DummyLayer(valid=True, layer_id="flst_ok") - mock_alkis_komplett.return_value = (enriched, flst, LoadStatus.FIRST) + mock_alkis_komplett.return_value = (enriched, flst, None, LoadStatus.FIRST) proj = _DummyProject() proj.mapLayersByName = lambda _n: [] # kein bestehender Flst-Layer mock_project_instance.return_value = proj @@ -159,7 +163,7 @@ class TestWorkingTab(unittest.TestCase): @patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id") @patch( "sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_alkis_komplett", - return_value=(None, None, LoadStatus.NONE), + return_value=(None, None, None, LoadStatus.NONE), ) def test_handle_verf_alkis_failure_clears_layer_var(self, _mock_alkis, mock_set_layer_id): """Status NONE → Layer-Variable wird auf '' gesetzt.""" diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 110f001..645f38f 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -138,15 +138,16 @@ class WorkingTab(QWidget): self._restore_verf_layer_selection() def handle_verf_alkis(self) -> None: - """Startet den vollständigen VG-Lade-Workflow (ALKIS → Gemarkungen → Flurstücke → Verschneidung). + """Startet den vollständigen VG-Lade-Workflow (ALKIS → Gemarkungen → Flurstücke → + Verschneidung → Flächenvergleich → opt. Umringsprüfung). Delegiert an :func:`~sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett.verfahrensgebiet_alkis_komplett`. - Nach Erfolg werden enriched VG-Layer und Flurstücks-Layer ins Projekt eingefügt, - Styles angewendet und die UI-Elemente aktualisiert. + Nach Erfolg werden enriched VG-Layer, Flurstücks-Layer und BROR-Layer + ins Projekt eingefügt, Styles angewendet und die UI-Elemente aktualisiert. """ self._set_busy(self.btn_verf_alkis, True, "Aus ALKIS laden") try: - enriched_layer, flst_layer, status = verfahrensgebiet_alkis_komplett(self) + enriched_layer, flst_layer, bror_layer, status = verfahrensgebiet_alkis_komplett(self) if status == LoadStatus.NONE or not enriched_layer: set_verfahrensgebiet_layer_id("") @@ -162,6 +163,10 @@ class WorkingTab(QWidget): apply_style(flst_layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml") self.setze_haken(self.haken_flurst, True) + # BROR-Layer: einfache Darstellung setzen (roter Umring, transparent) + if bror_layer and bror_layer.isValid(): + _setze_bror_stil(bror_layer) + # Enriched VG-Layer ins Projekt project.addMapLayer(enriched_layer) apply_style(enriched_layer, "verfahrensgebiet.qml") @@ -430,3 +435,37 @@ class WorkingTab(QWidget): # self._set_busy(self.btn_grenz_alkis, False, "Grenzpunkte aus ALKIS laden") from sn_basis.functions.message_wrapper import warning as _warn _warn("Grenzpunkte", "Grenzpunkt-Pipeline ist noch nicht implementiert.", duration=5) + + +# --------------------------------------------------------------------------- +# Modul-Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _setze_bror_stil(layer) -> None: + """Setzt eine einfache Darstellung für den BauRaumOrdnungsrecht-Layer. + + Roter Umring, halbtransparente Füllung; kein QGIS-Kontext → stilles Ignorieren. + + Parameters + ---------- + layer : + BauRaumOrdnungsrecht-Memory-Layer (Polygon). + """ + try: + from qgis.core import ( # type: ignore[import] + QgsSimpleFillSymbolLayer, + QgsFillSymbol, + QgsSingleSymbolRenderer, + ) + from qgis.PyQt.QtGui import QColor # type: ignore[import] + from qgis.PyQt.QtCore import Qt # type: ignore[import] + + fill = QgsSimpleFillSymbolLayer() + fill.setColor(QColor(220, 50, 50, 40)) # halbtransparent rot + fill.setStrokeColor(QColor(220, 50, 50)) + fill.setStrokeWidth(0.8) + symbol = QgsFillSymbol([fill]) + layer.setRenderer(QgsSingleSymbolRenderer(symbol)) + layer.triggerRepaint() + except Exception: + pass # Kein QGIS-Kontext (Tests, Mock-Modus) → Standarddarstellung From 821ea4ec2b33d0a58bfabafca2b8b55b3e7c1f78 Mon Sep 17 00:00:00 2001 From: release-bot Date: Fri, 24 Apr 2026 04:52:38 +0000 Subject: [PATCH 12/22] Release 26.4.6-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 1428266..0d83248 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.4.6-unstable: +--- Version 26.4.5-unstable: --- Version 26.4.4-unstable: diff --git a/metadata.txt b/metadata.txt index 59a3338..3c78650 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.4.5-unstable +version=26.4.6-unstable name=LNO Sachsen | Verfahrensgebiet description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet" author=Daniel Helbig, Michael Otto From 00ee470e08bc157130162ca2fb58c7775dfb1405 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 1 May 2026 23:32:28 +0200 Subject: [PATCH 13/22] grenzpunktemit 200m puffer um verfahrensgebiet laden --- functions/grenzpunkt_alkis.py | 1247 +++++++++++++++++++++++++++++++-- ui/tabs/working_tab.py | 88 ++- 2 files changed, 1265 insertions(+), 70 deletions(-) diff --git a/functions/grenzpunkt_alkis.py b/functions/grenzpunkt_alkis.py index cad0dac..b01d3f5 100644 --- a/functions/grenzpunkt_alkis.py +++ b/functions/grenzpunkt_alkis.py @@ -28,11 +28,12 @@ client-seitiger Join. from __future__ import annotations import math -from typing import List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple from sn_basis.functions import QgsVectorLayer, QgsProject from sn_basis.functions import QMessageBox from sn_basis.functions.message_wrapper import info, warning +from sn_basis.functions.dialog_wrapper import create_progress_dialog ALKIS_SF_URL = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs" PUNKTORT_TA_TYPENAME = "adv:AX_PunktortTA" @@ -40,24 +41,346 @@ GRENZPUNKT_TYPENAME = "adv:AX_Grenzpunkt" PUNKTORT_LAYER_NAME = "PunktortTA" GRENZPUNKT_LAYER_NAME = "Grenzpunkte" +JOINED_LAYER_NAME = "Grenzpunkte (ALKIS)" # Attributname in AX_PunktortTA, der auf AX_Grenzpunkt verweist _ATTR_GEHOERT_ZU = "gehoertZu" # Attributname für die GML-ID in AX_Grenzpunkt _ATTR_GML_ID = "gml_id" -# Batch-Größe für ResourceId-Abfragen (URL-Längenlimit) -_BATCH_GROESSE = 25 +# Batch-Größe für ResourceId-Abfragen per GET +# WAF auf geodienste.sachsen.de blockiert POST und GML-BBOX-XML in URLs. +# GET+ResourceId-Filter funktioniert, aber URL-Länge ist begrenzt (~8192 Bytes). +# Bei ~54 Bytes/ID (URL-kodiert) passen sicher 100 IDs pro Request. +_BATCH_GROESSE = 100 +# Maximale Features pro WFS-Abruf (Serverlimit) +_MAX_FEATURES = 20000 + +# Präfix für Grenzpunkt-Attribute im angereicherten Join-Layer +_GP_PREFIX = "gp_" + +# Bekannte Feldnamen für die xlink-Referenz auf AX_Grenzpunkt (ALKIS 6 + 7) +# AX_PunktortTA NAS 6.x: "istTeilVon" verweist auf den übergeordneten Grenzpunkt +_GEHOERT_ZU_KANDIDATEN = ["istTeilVon", "gehoertZu", "gehoertZuAX_Grenzpunkt"] + +# Vereinfachter ALKIS-WFS für Gemarkungs-BBox-Abfragen +_ALKIS_VEREINF_URL = "https://geodienste.sachsen.de/aaa/public_alkis/vereinf/wfs" +_KATASTERBEZIRK_TYPENAME = "ave:KatasterBezirk" +_KB_ATTR_SCHLUESSEL = "schluessel" # --------------------------------------------------------------------------- # Interne Hilfsfunktionen # --------------------------------------------------------------------------- -def _extrahiere_gml_ids(punktort_layer) -> List[str]: - """Extrahiert GML-IDs aus dem ``gehoertZu``-Attribut des PunktortTA-Layers. +def _erkenne_gehoert_zu_feld(layer) -> Optional[str]: + """Erkennt den Feldnamen im ``AX_PunktortTA``-Layer, der auf + ``AX_Grenzpunkt`` verweist – ALKIS-6/7-kompatibel. - Der WFS gibt ``gehoertZu`` üblicherweise als ``xlink:href``-Referenz im + Prüft Kandidaten in fester Reihenfolge: + + 1. Bekannte Feldnamen aus :data:`_GEHOERT_ZU_KANDIDATEN`. + 2. Fallback: erstes Feld, dessen Name ``gehoert`` (case-insensitive) + enthält. + + Parameters + ---------- + layer : + Geladener ``AX_PunktortTA``-Layer. + + Returns + ------- + str | None + Gefundener Feldname oder ``None`` wenn kein Kandidat vorhanden. + """ + feld_namen = [f.name() for f in layer.fields()] + for kandidat in _GEHOERT_ZU_KANDIDATEN: + if kandidat in feld_namen: + return kandidat + # Fallback: erstes Feld, das 'gehoert' oder 'istteil' oder 'besteht' enthält + for name in feld_namen: + low = name.lower() + if "gehoert" in low or "istteil" in low or "bestehtaus" in low: + return name + warning( + "Grenzpunkte (Diagnose)", + "Kein Referenzfeld in AX_PunktortTA gefunden. " + "Vorhandene Felder: " + ", ".join(feld_namen), + duration=15, + ) + return None + + +def _punktort_ta_bbox_param(bbox) -> str: + """Gibt den ``BBOX``-KVP-Wert für eine PunktortTA-Abfrage zurück. + + **Warum BBOX-Parameter statt FILTER-XML?** + Die WAF auf geodienste.sachsen.de blockiert URLs, die GML-Geometrie-XML + im ``FILTER``-Parameter enthalten (``xmlns:gml``, ``gml:Envelope`` usw.). + Der native WFS-2.0-KVP-Parameter ``BBOX=minx,miny,maxx,maxy,crs`` wird + dagegen akzeptiert. + + Parameters + ---------- + bbox : + ``QgsRectangle`` in EPSG:25833. + + Returns + ------- + str + Wert für den ``BBOX``-URL-Parameter. + """ + return ( + f"{bbox.xMinimum()},{bbox.yMinimum()}" + f",{bbox.xMaximum()},{bbox.yMaximum()}" + ",urn:ogc:def:crs:EPSG::25833" + ) + + +def _wfs_get_gml(url: str, typename: str, extra_params: dict) -> Optional[bytes]: + """Sendet einen WFS 2.0 GetFeature GET-Request und gibt die rohe GML-Antwort zurück. + + **Warum GET?** + Die WAF auf geodienste.sachsen.de blockiert POST-Requests und GET-Requests + mit GML-Geometrie-XML im ``FILTER``-Parameter (``xmlns:gml``, ``gml:Envelope``). + GET-Requests mit ``BBOX=``-KVP-Parameter oder ResourceId-``FILTER`` (nur + ``xmlns:fes``, kein GML) werden dagegen akzeptiert. + + Parameters + ---------- + url : + WFS-Endpoint-URL. + typename : + Vollqualifizierter Feature-Typ, z.B. ``adv:AX_PunktortTA``. + extra_params : + Zusätzliche KVP-Parameter, z.B. ``{'BBOX': 'minx,miny,maxx,maxy,crs'}`` + oder ``{'FILTER': ''}``. + + Returns + ------- + bytes | None + Rohe GML-Antwort oder ``None`` bei Fehler. + """ + import urllib.request + import urllib.parse + import urllib.error + from qgis.core import QgsMessageLog, Qgis + + params = { + 'SERVICE': 'WFS', + 'VERSION': '2.0.0', + 'REQUEST': 'GetFeature', + 'TYPENAMES': typename, + 'COUNT': str(_MAX_FEATURES), + } + params.update(extra_params) + + full_url = url + '?' + urllib.parse.urlencode(params) + + try: + with urllib.request.urlopen(full_url, timeout=120) as resp: + gml_bytes = resp.read() + except urllib.error.HTTPError as exc: + body_preview = exc.read(200).decode('utf-8', errors='replace') + QgsMessageLog.logMessage( + f"WFS GET HTTP {exc.code} ({typename}): {exc.reason}\n" + f"URL: {full_url[:300]}\nAntwort: {body_preview}", + "sn_verfahrensgebiet", + Qgis.MessageLevel.Critical, + ) + warning("WFS GET", f"HTTP {exc.code}: {exc.reason}", duration=10) + return None + except Exception as exc: + QgsMessageLog.logMessage( + f"WFS GET fehlgeschlagen ({typename}): {exc}\nURL: {full_url[:300]}", + "sn_verfahrensgebiet", + Qgis.MessageLevel.Critical, + ) + warning("WFS GET", f"Netzwerkfehler: {exc}", duration=10) + return None + + if not gml_bytes: + return None + + # WFS-Fehlermeldung erkennen + if b"ExceptionReport" in gml_bytes[:800] or b"ExceptionText" in gml_bytes[:800]: + import xml.etree.ElementTree as _ET + try: + _root = _ET.fromstring(gml_bytes) + _exc = _root.find(".//{http://www.opengis.net/ows/1.1}ExceptionText") + msg = _exc.text.strip() if _exc is not None and _exc.text else "Unbekannter WFS-Fehler" + except Exception: + msg = gml_bytes[:500].decode("utf-8", errors="replace") + QgsMessageLog.logMessage( + f"WFS ExceptionReport ({typename}):\n{msg}", + "sn_verfahrensgebiet", + Qgis.MessageLevel.Critical, + ) + warning("WFS GET", f"WFS-Fehler: {msg}", duration=10) + return None + + return gml_bytes + + +def _parse_alkis_gml_memory_layer( + gml_bytes: bytes, + feature_local_name: str, + layer_name: str, + geom_prop: Optional[str] = "position", +) -> Optional[object]: + """Parst ALKIS-GML-Bytes und erstellt einen QGIS Memory-Layer. + + Verarbeitet die GML-Antwort eines WFS-POST-Requests und extrahiert + Features des angegebenen Typs als Memory-Layer. + + Parameters + ---------- + gml_bytes : + Rohe GML-Bytes der WFS-Antwort. + feature_local_name : + Lokaler Name des Feature-Typs, z.B. ``AX_PunktortTA``. + layer_name : + Anzeigename des resultierenden Memory-Layers. + geom_prop : + Lokaler Name des Geometrie-Attributs (``None`` = kein Geometrie-Layer). + + Returns + ------- + QgsVectorLayer | None + Memory-Layer oder ``None`` wenn keine Features gefunden. + """ + import xml.etree.ElementTree as ET + from sn_basis.functions import QgsFeature, QgsField + from sn_basis.functions.qt_wrapper import QVariant + from qgis.core import QgsGeometry, QgsPointXY + + _ADV = "http://www.adv-online.de/namespaces/adv/gid/6.0" + _GML = "http://www.opengis.net/gml/3.2" + _XLINK = "http://www.w3.org/1999/xlink" + + try: + root = ET.fromstring(gml_bytes) + except ET.ParseError as exc: + warning("GML-Parser", f"GML konnte nicht geparst werden: {exc}", duration=8) + return None + + feat_tag = f"{{{_ADV}}}{feature_local_name}" + feat_elems = root.findall(f".//{feat_tag}") + if not feat_elems: + return None + + # Felder aus erstem Feature ermitteln (geom_prop ausschließen) + feld_namen: List[str] = ["gml_id"] + for child in feat_elems[0]: + local = child.tag.split("}")[-1] if "}" in child.tag else child.tag + if local not in feld_namen and local != geom_prop: + feld_namen.append(local) + + # Memory-Layer anlegen + mem_uri = f"Point?crs=EPSG:25833" if geom_prop else "none" + mem_layer = QgsVectorLayer(mem_uri, layer_name, "memory") + pr = mem_layer.dataProvider() + pr.addAttributes([QgsField(n, QVariant.String) for n in feld_namen if n]) + mem_layer.updateFields() + + neue_features: List = [] + for feat_elem in feat_elems: + gml_id = feat_elem.get(f"{{{_GML}}}id", "") + + # Geometrie extrahieren + geom = None + if geom_prop: + pos_el = feat_elem.find(f"{{{_ADV}}}{geom_prop}") + if pos_el is not None: + pe = pos_el.find(f".//{{{_GML}}}pos") + if pe is not None and pe.text: + coords = pe.text.strip().split() + if len(coords) >= 2: + try: + geom = QgsGeometry.fromPointXY( + QgsPointXY(float(coords[0]), float(coords[1])) + ) + except (ValueError, Exception): + pass + + qgs_feat = QgsFeature(mem_layer.fields()) + if geom: + qgs_feat.setGeometry(geom) + qgs_feat["gml_id"] = gml_id + + for child in feat_elem: + local = child.tag.split("}")[-1] if "}" in child.tag else child.tag + if local == geom_prop or local not in feld_namen: + continue + href = child.get(f"{{{_XLINK}}}href", "") + val = href if href else (child.text.strip() if child.text else "") + try: + qgs_feat[local] = val + except Exception: + pass + + neue_features.append(qgs_feat) + + pr.addFeatures(neue_features) + mem_layer.updateExtents() + return mem_layer + + +def _extrahiere_feature_gml_id(feat, feld_namen: List[str]) -> str: + """Extrahiert die GML-ID aus einem WFS-Feature robust. + + Versucht mehrere Strategien in dieser Reihenfolge: + + 1. Bekannte Feldnamen (``gml_id``, ``identifier``). + 2. Scan aller String-Felder auf GML-ID-Muster (``DE`` + 14-20 alphanumerische Zeichen). + + Parameters + ---------- + feat : + WFS-Feature. + feld_namen : + Liste der Feldnamen des Layers. + + Returns + ------- + str + GML-ID oder leerer String. + """ + import re as _re + _GML_ID_PATTERN = _re.compile(r'^DE[0-9A-Za-z_]{10,24}$') + + for candidate in ("gml_id", "identifier", "gmlId", "featureId", "fid"): + if candidate in feld_namen: + try: + val = feat[candidate] + if val: + s = str(val).strip() + if s: + return s + except Exception: + pass + + # Scan aller Felder auf GML-ID-Muster + for name in feld_namen: + try: + val = feat[name] + if val: + s = str(val).strip() + if _GML_ID_PATTERN.match(s): + return s + except Exception: + pass + + return "" + + +def _extrahiere_gml_ids( + punktort_layer, + gehoert_zu_feld: Optional[str] = None, +) -> List[str]: + """Extrahiert GML-IDs aus dem Referenzfeld des PunktortTA-Layers. + + Der WFS gibt das Referenzfeld üblicherweise als ``xlink:href``-Referenz im Format ``urn:adv:oid:DEXXXXXXXXXXXXXXXXXX`` oder als flachen Wert. Es wird der letzte Bestandteil nach dem letzten ``:`` verwendet. @@ -65,16 +388,21 @@ def _extrahiere_gml_ids(punktort_layer) -> List[str]: ---------- punktort_layer : Geladener ``AX_PunktortTA``-WFS-Layer. + gehoert_zu_feld : + Feldname der Referenz auf ``AX_Grenzpunkt``. Wenn ``None``, wird + :data:`_ATTR_GEHOERT_ZU` als Fallback verwendet + (Rückwärtskompatibilität). Returns ------- List[str] Deduplizierte, sortierte Liste von GML-IDs. """ + feld = gehoert_zu_feld if gehoert_zu_feld is not None else _ATTR_GEHOERT_ZU ids: set[str] = set() for feat in punktort_layer.getFeatures(): try: - val = feat[_ATTR_GEHOERT_ZU] + val = feat[feld] if val: raw = str(val).strip() # xlink:href hat Format "urn:adv:oid:DE..." oder reiner GML-ID-Wert @@ -86,6 +414,89 @@ def _extrahiere_gml_ids(punktort_layer) -> List[str]: return sorted(ids) +def _lade_grenzpunkte_als_dict( + id_liste: List[str], + progress_callback: Optional[Callable[[int], None]] = None, +) -> Tuple[Dict[str, object], List[str]]: + """Lädt ``AX_Grenzpunkt``-Features als Dict für O(1)-Lookup. + + Parameters + ---------- + id_liste : + GML-IDs der gesuchten ``AX_Grenzpunkt``-Objekte. + progress_callback : + Optionaler Callback ``fn(batch_index)`` der nach jedem Batch aufgerufen + wird (0-basierter Batch-Index). Dient zur Fortschrittsanzeige. + + Returns + ------- + Tuple[Dict[str, QgsFeature], List[str]] + ``(gp_dict, feld_namen)`` – Dict ``{gml_id: QgsFeature}`` und + Liste der GP-Feldnamen (leer wenn keine Features geladen wurden). + """ + gp_dict: Dict[str, object] = {} + feld_namen: List[str] = [] + + anzahl_batches = math.ceil(len(id_liste) / _BATCH_GROESSE) + for i in range(anzahl_batches): + batch = id_liste[i * _BATCH_GROESSE: (i + 1) * _BATCH_GROESSE] + info( + "Grenzpunkte (Diagnose)", + f"AX_Grenzpunkt Batch {i + 1}/{anzahl_batches}: {len(batch)} IDs per ResourceId-Filter …", + duration=2, + ) + + rid_elemente = "".join(f'' for gid in batch) + filter_xml = ( + '' + + rid_elemente + + "" + ) + _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, GRENZPUNKT_TYPENAME, {'FILTER': filter_xml}) + if not _gml_bytes: + warning( + "Grenzpunkte (Diagnose)", + f"Batch {i + 1}/{anzahl_batches}: GET-Anfrage fehlgeschlagen – Batch wird übersprungen.", + duration=4, + ) + else: + tmp = _parse_alkis_gml_memory_layer(_gml_bytes, "AX_Grenzpunkt", "_TmpGP", geom_prop=None) + if not tmp: + warning( + "Grenzpunkte (Diagnose)", + f"Batch {i + 1}/{anzahl_batches}: Keine Features in GML-Antwort – Batch wird übersprungen.", + duration=4, + ) + else: + batch_feld_namen = [f.name() for f in tmp.fields()] + if not feld_namen and batch_feld_namen: + feld_namen = batch_feld_namen + info( + "Grenzpunkte (Diagnose)", + f"AX_Grenzpunkt-Felder: {', '.join(feld_namen)}", + duration=8, + ) + + batch_count = 0 + for feat in tmp.getFeatures(): + gml_id = _extrahiere_feature_gml_id(feat, batch_feld_namen or feld_namen) + if gml_id: + gp_dict[gml_id] = feat + batch_count += 1 + + info( + "Grenzpunkte (Diagnose)", + f"Batch {i + 1}/{anzahl_batches}: {batch_count}/{len(batch)} Grenzpunkte empfangen " + f"(gesamt bisher: {len(gp_dict)}).", + duration=2, + ) + + if progress_callback is not None: + progress_callback(i) + + return gp_dict, feld_namen + + def _lade_grenzpunkte_per_ids(id_liste: List[str]) -> Optional[object]: """Lädt ``AX_Grenzpunkt``-Features für die angegebenen GML-IDs. @@ -93,6 +504,8 @@ def _lade_grenzpunkte_per_ids(id_liste: List[str]) -> Optional[object]: um URL-Längenlimits zu umgehen. Alle Batch-Ergebnisse werden zu einem Memory-Layer zusammengeführt. + Delegiert intern an :func:`_lade_grenzpunkte_als_dict`. + Parameters ---------- id_liste : @@ -103,34 +516,15 @@ def _lade_grenzpunkte_per_ids(id_liste: List[str]) -> Optional[object]: QgsVectorLayer | None Memory-Layer mit allen gefundenen Grenzpunkten oder ``None`` bei Fehler. """ - from sn_basis.functions import QgsFeature, QgsField + from sn_basis.functions import QgsField from sn_basis.functions.qt_wrapper import QVariant - alle_features: List = [] - feld_namen: List[str] = [] - - anzahl_batches = math.ceil(len(id_liste) / _BATCH_GROESSE) - for i in range(anzahl_batches): - batch = id_liste[i * _BATCH_GROESSE: (i + 1) * _BATCH_GROESSE] - ids_str = ", ".join(f"'{gid}'" for gid in batch) - uri = ( - f"url='{ALKIS_SF_URL}' typename='{GRENZPUNKT_TYPENAME}' " - f"srsname='EPSG:25833' " - f"sql=SELECT * FROM AX_Grenzpunkt WHERE gml_id IN ({ids_str})" - ) - tmp = QgsVectorLayer(uri, "_TmpGP", "WFS") - if not tmp or not tmp.isValid(): - continue - - if not feld_namen: - feld_namen = [f.name() for f in tmp.fields()] - - for feat in tmp.getFeatures(): - alle_features.append(feat) - - if not alle_features: + gp_dict, feld_namen = _lade_grenzpunkte_als_dict(id_liste) + if not gp_dict: return None + alle_features = list(gp_dict.values()) + # Ergebnis-Memory-Layer aufbauen (ohne Geometrie, da AX_Grenzpunkt keine hat) mem_uri = "Point?crs=EPSG:25833" if _hat_geometrie(alle_features[0]) else "none" result_layer = QgsVectorLayer(mem_uri, GRENZPUNKT_LAYER_NAME, "memory") @@ -154,9 +548,369 @@ def _hat_geometrie(feature) -> bool: return False -# --------------------------------------------------------------------------- -# Öffentliche API -# --------------------------------------------------------------------------- +def _lade_gemarkung_extents(schluessel_liste: List[str]) -> Dict[str, object]: + """Lädt BBox-Rechtecke der Gemarkungen aus dem vereinfachten ALKIS-WFS. + + Da der vereinfachte WFS keine serverseitigen Filter unterstützt, werden + alle Katasterbezirke geladen und client-seitig nach den angegebenen + Gemarkungsnummern gefiltert. Der vollständige Katasterbezirksschlüssel hat + ≥ 4 Stellen; die letzten 4 Stellen entsprechen der Gemarkungsnummer. + + Parameters + ---------- + schluessel_liste : + Liste der 4-stelligen Gemarkungsnummern. + + Returns + ------- + Dict[str, QgsRectangle] + ``{gemarkungsnummer: QgsRectangle}`` – BBox je Gemarkung. + Leer bei Fehler oder keinen Treffern. + """ + from qgis.core import QgsRectangle + + uri = ( + f"url='{_ALKIS_VEREINF_URL}' typename='{_KATASTERBEZIRK_TYPENAME}' " + f"srsname='EPSG:25833'" + ) + layer = QgsVectorLayer(uri, "_TmpKB", "WFS") + if not layer or not layer.isValid(): + return {} + + schluessel_set = set(schluessel_liste) + extents: Dict[str, object] = {} + + for feat in layer.getFeatures(): + try: + val = feat[_KB_ATTR_SCHLUESSEL] + schluessel = str(val).strip() if val else "" + except Exception: + continue + + nummer = schluessel[-4:] if len(schluessel) >= 4 else schluessel + if nummer not in schluessel_set: + continue + + if not feat.hasGeometry() or feat.geometry().isEmpty(): + continue + + bbox = feat.geometry().boundingBox() + if nummer in extents: + extents[nummer].combineExtentWith(bbox) + else: + extents[nummer] = QgsRectangle(bbox) + + return extents + + +def _lade_gemarkung_extents_mit_puffer( + schluessel_liste: List[str], + vg_geom, + puffer_m: float = 200.0, +) -> Dict[str, object]: + """Lädt Gemarkungsgeometrien, verschneidet sie mit einem Puffer um das + Verfahrensgebiet und liefert BBoxen der Schnittflächen. + + Statt der vollen Gemarkungs-BBox wird nur die BBox des Anteils verwendet, + der innerhalb des gepufferten Verfahrensgebiets liegt. Das reduziert die + Anzahl der vom WFS gelieferten PunktortTA-Objekte erheblich. + + Parameters + ---------- + schluessel_liste : + Liste der 4-stelligen Gemarkungsnummern. + vg_geom : + ``QgsGeometry`` des Verfahrensgebiets (EPSG:25833). + puffer_m : + Pufferdistanz in Metern (Standard: 200 m). + + Returns + ------- + Dict[str, QgsRectangle] + ``{gemarkungsnummer: QgsRectangle}`` – BBox der Schnittfläche. + Gemarkungen ohne Schnitt werden ausgelassen. + """ + from qgis.core import QgsRectangle + + uri = ( + f"url='{_ALKIS_VEREINF_URL}' typename='{_KATASTERBEZIRK_TYPENAME}' " + f"srsname='EPSG:25833'" + ) + layer = QgsVectorLayer(uri, "_TmpKB", "WFS") + if not layer or not layer.isValid(): + return {} + + puffer_geom = vg_geom.buffer(puffer_m, 16) if vg_geom and not vg_geom.isEmpty() else None + + schluessel_set = set(schluessel_liste) + extents: Dict[str, object] = {} + + for feat in layer.getFeatures(): + try: + val = feat[_KB_ATTR_SCHLUESSEL] + schluessel = str(val).strip() if val else "" + except Exception: + continue + + nummer = schluessel[-4:] if len(schluessel) >= 4 else schluessel + if nummer not in schluessel_set: + continue + + if not feat.hasGeometry() or feat.geometry().isEmpty(): + continue + + geom = feat.geometry() + + # Mit gepuffertem VG verschneiden + if puffer_geom and not puffer_geom.isEmpty(): + schnitt = geom.intersection(puffer_geom) + if schnitt is None or schnitt.isEmpty(): + continue + bbox = schnitt.boundingBox() + else: + bbox = geom.boundingBox() + + if bbox.isEmpty(): + continue + + if nummer in extents: + extents[nummer].combineExtentWith(bbox) + else: + extents[nummer] = QgsRectangle(bbox) + + return extents + + +def _wfs_hits_count(url: str, typename: str, extra_params: dict) -> int: + """Fragt die Anzahl der Features per ``resultType=hits`` ab. + + Parameters + ---------- + url : + WFS-Endpoint-URL. + typename : + Vollqualifizierter Feature-Typ. + extra_params : + Zus\u00e4tzliche KVP-Parameter (z.B. ``{'BBOX': '...'}``). + + Returns + ------- + int + Anzahl der Features oder ``-1`` wenn nicht ermittelbar. + """ + import urllib.request + import urllib.parse + import urllib.error + import xml.etree.ElementTree as ET + + params = { + 'SERVICE': 'WFS', + 'VERSION': '2.0.0', + 'REQUEST': 'GetFeature', + 'TYPENAMES': typename, + 'RESULTTYPE': 'hits', + } + params.update(extra_params) + + full_url = url + '?' + urllib.parse.urlencode(params) + try: + with urllib.request.urlopen(full_url, timeout=30) as resp: + gml_bytes = resp.read() + except Exception: + return -1 + + try: + root = ET.fromstring(gml_bytes) + matched = root.get('numberMatched', '') + if matched and matched != 'unknown': + return int(matched) + returned = root.get('numberReturned', '') + if returned and returned != 'unknown': + return int(returned) + except Exception: + pass + return -1 + + +def _lade_punktort_ta_batches_per_gemarkung( + schluessel_liste: List[str], +) -> Optional[object]: + """Lädt ``AX_PunktortTA`` für jede Gemarkung separat per BBox. + + Pro Gemarkung wird eine eigene WFS-Abfrage durchgeführt, was präzisere + Ergebnisse als eine kombinierte BBox liefert. Doppelte Features an + Gemarkungsgrenzen werden anhand der ``gml_id`` dedupliziert. + + Parameters + ---------- + schluessel_liste : + Liste der 4-stelligen Gemarkungsnummern. + + Returns + ------- + QgsVectorLayer | None + Memory-Layer mit allen PunktortTA-Features oder ``None`` bei Fehler. + """ + from sn_basis.functions import QgsField + from sn_basis.functions.qt_wrapper import QVariant + + extents = _lade_gemarkung_extents(schluessel_liste) + if not extents: + return None + + alle_features: List = [] + gesehene_ids: set[str] = set() + feld_namen: List[str] = [] + + n_gemarkungen_gesamt = len(extents) + for g_idx, (_gemarkungsnummer, bbox) in enumerate(extents.items(), 1): + info( + "Grenzpunkte (Diagnose)", + f"PunktortTA Gemarkung {_gemarkungsnummer} ({g_idx}/{n_gemarkungen_gesamt}): " + f"BBox {bbox.xMinimum():.0f},{bbox.yMinimum():.0f} – " + f"{bbox.xMaximum():.0f},{bbox.yMaximum():.0f} anfragen …", + duration=2, + ) + _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, {'BBOX': _punktort_ta_bbox_param(bbox)}) + if not _gml_bytes: + warning( + "Grenzpunkte (Diagnose)", + f"Gemarkung {_gemarkungsnummer}: GET-Anfrage fehlgeschlagen – wird übersprungen.", + duration=4, + ) + continue + tmp = _parse_alkis_gml_memory_layer(_gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position") + if not tmp: + warning( + "Grenzpunkte (Diagnose)", + f"Gemarkung {_gemarkungsnummer}: Keine Features in GML-Antwort – wird übersprungen.", + duration=4, + ) + continue + + if not feld_namen: + feld_namen = [f.name() for f in tmp.fields()] + + cur_feld_namen = [f.name() for f in tmp.fields()] + + neu = 0 + duplikat = 0 + for feat in tmp.getFeatures(): + gml_id = _extrahiere_feature_gml_id(feat, cur_feld_namen) + if gml_id and gml_id in gesehene_ids: + duplikat += 1 + continue + if gml_id: + gesehene_ids.add(gml_id) + alle_features.append(feat) + neu += 1 + + info( + "Grenzpunkte (Diagnose)", + f"Gemarkung {_gemarkungsnummer}: {neu} neue PunktortTA-Objekte " + f"({duplikat} Duplikate übersprungen, gesamt bisher: {len(alle_features)}).", + duration=2, + ) + + if not alle_features: + return None + + result_layer = QgsVectorLayer("Point?crs=EPSG:25833", PUNKTORT_LAYER_NAME, "memory") + pr = result_layer.dataProvider() + + felder = [QgsField(n, QVariant.String) for n in feld_namen if n] + pr.addAttributes(felder) + result_layer.updateFields() + pr.addFeatures(alle_features) + result_layer.updateExtents() + + return result_layer + + +def _erstelle_joined_layer( + punktort_layer, + gp_dict: Dict[str, object], + gp_feld_namen: List[str], + gehoert_zu_feld: str, +) -> object: + """Erstellt einen angereicherten Memory-Layer aus PunktortTA + GP-Attributen. + + Geometrie und Attribute von ``AX_PunktortTA`` werden mit den Attributen + des zugehörigen ``AX_Grenzpunkt`` zusammengeführt. GP-Felder erhalten den + Präfix :data:`_GP_PREFIX` (``gp_``), um Namenskollisionen zu vermeiden. + Features ohne GP-Match werden mit leeren ``gp_``-Feldern aufgenommen. + + Parameters + ---------- + punktort_layer : + Memory-Layer mit ``AX_PunktortTA``-Features. + gp_dict : + ``{gml_id: QgsFeature}`` der geladenen ``AX_Grenzpunkt``-Features. + gp_feld_namen : + Feldnamen aus dem ``AX_Grenzpunkt``-WFS. + gehoert_zu_feld : + Feldname in ``AX_PunktortTA``, der die xlink-Referenz enthält. + + Returns + ------- + QgsVectorLayer + Angereicherter Memory-Layer ``Point?crs=EPSG:25833``. + """ + from sn_basis.functions import QgsFeature, QgsField + from sn_basis.functions.qt_wrapper import QVariant + + pta_feld_namen = [f.name() for f in punktort_layer.fields()] + + result_layer = QgsVectorLayer("Point?crs=EPSG:25833", JOINED_LAYER_NAME, "memory") + pr = result_layer.dataProvider() + + felder: List = [] + for n in pta_feld_namen: + if n: + felder.append(QgsField(n, QVariant.String)) + for n in gp_feld_namen: + if n: + felder.append(QgsField(f"{_GP_PREFIX}{n}", QVariant.String)) + + pr.addAttributes(felder) + result_layer.updateFields() + + neue_features: List = [] + for pta_feat in punktort_layer.getFeatures(): + try: + raw = pta_feat[gehoert_zu_feld] + raw = str(raw).strip() if raw else "" + gml_id = raw.rsplit(":", 1)[-1] if ":" in raw else raw + except Exception: + gml_id = "" + + gp_feat = gp_dict.get(gml_id) + + new_feat = QgsFeature(result_layer.fields()) + new_feat.setGeometry(pta_feat.geometry()) + + # PunktortTA-Attribute + for n in pta_feld_namen: + if n: + try: + new_feat[n] = pta_feat[n] + except Exception: + pass + + # Grenzpunkt-Attribute mit gp_-Präfix + if gp_feat is not None: + for n in gp_feld_namen: + if n: + try: + new_feat[f"{_GP_PREFIX}{n}"] = gp_feat[n] + except Exception: + pass + + neue_features.append(new_feat) + + pr.addFeatures(neue_features) + result_layer.updateExtents() + return result_layer def grenzpunkt_alkis(tab_widget, extent=None) -> Tuple[Optional[object], Optional[object]]: """Lädt Grenzpunkte aus dem ALKIS-WFS für den angegebenen Begrenzungsrahmen. @@ -237,17 +991,31 @@ def grenzpunkt_alkis(tab_widget, extent=None) -> Tuple[Optional[object], Optiona return None, None # ----------------------------------------------------------------------- - # Schritt 2: GML-IDs extrahieren + # Schritt 2: Referenzfeld erkennen + GML-IDs extrahieren (ALKIS-6/7) # ----------------------------------------------------------------------- - info("Grenzpunkte", "Schritt 2/3: GML-IDs aus gehoertZu extrahieren …", duration=3) - gml_ids = _extrahiere_gml_ids(punktort_layer) + info("Grenzpunkte", "Schritt 2/3: Referenzfeld erkennen und GML-IDs extrahieren …", duration=3) + gehoert_zu_feld = _erkenne_gehoert_zu_feld(punktort_layer) + if not gehoert_zu_feld: + vorhandene_felder = ", ".join(f.name() for f in punktort_layer.fields()) + QMessageBox.warning( + tab_widget, + "Kein Referenzfeld", + f"{anzahl_pta} PunktortTA-Objekte geladen, aber kein Referenzfeld auf " + f"AX_Grenzpunkt gefunden.\n\n" + f"Gesuchte Feldnamen: {', '.join(_GEHOERT_ZU_KANDIDATEN)}\n" + f"Vorhandene Felder: {vorhandene_felder}", + ) + project.addMapLayer(punktort_layer) + return punktort_layer, None + + gml_ids = _extrahiere_gml_ids(punktort_layer, gehoert_zu_feld) if not gml_ids: QMessageBox.warning( tab_widget, "Keine Verknüpfungen", - f"{anzahl_pta} PunktortTA-Objekte gefunden, aber kein 'gehoertZu'-Attribut.\n" - "Bitte Attributnamen im WFS prüfen.", + f"{anzahl_pta} PunktortTA-Objekte gefunden, aber Feld '{gehoert_zu_feld}' " + "enthält keine gültigen IDs.\nBitte Attributwerte im WFS prüfen.", ) # PunktortTA trotzdem zurückgeben project.addMapLayer(punktort_layer) @@ -278,3 +1046,404 @@ def grenzpunkt_alkis(tab_widget, extent=None) -> Tuple[Optional[object], Optiona project.addMapLayer(grenzpunkt_layer) return punktort_layer, grenzpunkt_layer + + +# --------------------------------------------------------------------------- +# Gemarkungsweise Abfrage +# --------------------------------------------------------------------------- + +def grenzpunkte_gemarkungsweise( + tab_widget, + schluessel_liste: List[str], + vg_geom=None, + progress: Optional[Callable[[int, int], None]] = None, +) -> Optional[object]: + """Lädt Grenzpunkte aus dem ALKIS-WFS gemarkungsweise mit per-Gemarkung-Tracking. + + Ablauf: + + 1. Gemarkungsgeometrien laden, mit 200-m-Puffer um das Verfahrensgebiet + verschneiden, BBoxen der Schnittflächen bestimmen. + 2. Pro Gemarkung: Hits-Abfrage für ``AX_PunktortTA``, dann ggf. in + Batches à 5000 Features laden, Referenzfeld erkennen, GP-IDs + extrahieren, PunktortTA deduplizieren. + 3. ``AX_Grenzpunkt`` für alle eindeutigen IDs per ResourceId-Batches laden. + 4. Per-Gemarkung-Statistik ausgeben. + 5. Angereicherten Join-Layer erzeugen und ins Projekt einfügen. + + Parameters + ---------- + tab_widget : + Eltern-Widget für Fehlerdialoge. + schluessel_liste : + Liste der 4-stelligen Gemarkungsnummern (z.B. ``['1234', '5678']``). + vg_geom : + ``QgsGeometry`` des Verfahrensgebiets (EPSG:25833). Wird für den + 200-m-Puffer zur Verschneidung der Gemarkungen verwendet. + ``None`` = keine Einschränkung (volle Gemarkungs-BBox). + progress : + Reserviert (wird nicht ausgewertet; interner ``ProgressDialog`` wird + verwendet). + + Returns + ------- + QgsVectorLayer | None + Angereicherter Memory-Layer mit Geometrie und allen GP-Attributen + (Prefix ``gp_``) oder ``None`` bei Fehler. + """ + import urllib.parse + from sn_basis.functions import QgsField + from sn_basis.functions.qt_wrapper import QVariant + + # Maximale Features pro einzelnem PunktortTA-GET-Aufruf + _BBOX_BATCH = 5000 + + project = QgsProject.instance() + + if not schluessel_liste: + QMessageBox.warning( + tab_widget, + "Keine Gemarkungen", + "Bitte mindestens einen Gemarkungsschlüssel angeben.", + ) + return None + + n_gemarkungen = len(schluessel_liste) + + # Fortschrittsschritte werden nach den Hits-Abfragen präzisiert. + # Vorab-Schätzung: 1 (Extents) + N*2 (Hits+Laden) + 1 (GP) + 1 (Join) + prog_vorab = n_gemarkungen * 2 + 3 + prog = create_progress_dialog( + prog_vorab, + "Grenzpunkte laden", + f"Lade Gemarkungs-Geometrien für {n_gemarkungen} Gemarkung(en) …", + ) + abgeschlossen = False + + try: + prog.set_value(0) + if prog.is_canceled(): + return None + + # ------------------------------------------------------------------ + # Schritt 1: Gemarkungsgeometrien + Verschneidung mit VG-Puffer + # ------------------------------------------------------------------ + if vg_geom and not vg_geom.isEmpty(): + extents = _lade_gemarkung_extents_mit_puffer(schluessel_liste, vg_geom, 200.0) + else: + extents = _lade_gemarkung_extents(schluessel_liste) + + if not extents: + prog.close() + QMessageBox.critical( + tab_widget, + "Fehler", + "Gemarkungs-BBoxen konnten nicht geladen werden.\n" + "Bitte Netzwerkverbindung und Gemarkungsschlüssel prüfen.\n" + f"Gesuchte Schlüssel: {', '.join(schluessel_liste)}", + ) + return None + + n_extents = len(extents) + if n_extents < n_gemarkungen: + warning( + "Grenzpunkte", + f"Nur {n_extents}/{n_gemarkungen} Gemarkungen im WFS gefunden. " + f"Nicht gefunden: {set(schluessel_liste) - set(extents.keys())}", + duration=8, + ) + + # ------------------------------------------------------------------ + # Schritt 2a: Hits-Abfragen für alle BBoxen + # ------------------------------------------------------------------ + prog.set_label(f"Anzahl PunktortTA-Objekte abfragen ({n_extents} Gemarkungen) …") + prog.set_value(1) + if prog.is_canceled(): + return None + + # {gemarkungsnummer: anzahl_features} – -1 = unbekannt + hits_pro_gemarkung: Dict[str, int] = {} + n_pta_batches_gesamt = 0 + + for gemarkungsnummer, bbox in extents.items(): + bbox_param = _punktort_ta_bbox_param(bbox) + n_hits = _wfs_hits_count(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, {'BBOX': bbox_param}) + hits_pro_gemarkung[gemarkungsnummer] = n_hits + batches = max(1, math.ceil(n_hits / _BBOX_BATCH)) if n_hits > 0 else 1 + n_pta_batches_gesamt += batches + info( + "Grenzpunkte (Diagnose)", + f"Gemarkung {gemarkungsnummer}: ~{n_hits} PunktortTA-Objekte " + f"→ {batches} Batch(es).", + duration=2, + ) + + # Gesamtschritte jetzt präzise: + # 1 (Extents/Hits) + n_pta_batches_gesamt (PunktortTA) + n_gp_batches (GP) + 1 (Join) + # GP-Batches kennen wir noch nicht – vorläufig 0, wird unten korrigiert. + prog_gesamt_pta = 1 + n_pta_batches_gesamt + prog.set_total(prog_gesamt_pta + 10) # Platzhalter für GP + Join + + # ------------------------------------------------------------------ + # Schritt 2b: PunktortTA pro Gemarkung + Batching + # ------------------------------------------------------------------ + alle_pta_features: List = [] + gesehene_pta_ids: set[str] = set() + pta_feld_namen: List[str] = [] + gehoert_zu_feld: Optional[str] = None + + per_gemarkung_gp_ids: Dict[str, set] = {} + prog_schritt = 1 # nach Hits-Phase + + for g_idx, (gemarkungsnummer, bbox) in enumerate(extents.items(), 1): + n_hits = hits_pro_gemarkung.get(gemarkungsnummer, -1) + n_batches_diese_gemarkung = max(1, math.ceil(n_hits / _BBOX_BATCH)) if n_hits > 0 else 1 + + if prog.is_canceled(): + return None + + gp_ids_dieser_gemarkung: set[str] = set() + neu_gesamt = 0 + duplikat_gesamt = 0 + bbox_param = _punktort_ta_bbox_param(bbox) + + for b_idx in range(n_batches_diese_gemarkung): + start_index = b_idx * _BBOX_BATCH + prog.set_label( + f"Gemarkung {gemarkungsnummer} ({g_idx}/{n_extents}), " + f"Batch {b_idx + 1}/{n_batches_diese_gemarkung} " + f"(~{n_hits if n_hits >= 0 else '?'} Objekte) …" + ) + prog.set_value(prog_schritt) + prog_schritt += 1 + if prog.is_canceled(): + return None + + extra: dict = {'BBOX': bbox_param} + if n_batches_diese_gemarkung > 1: + extra['COUNT'] = str(_BBOX_BATCH) + extra['STARTINDEX'] = str(start_index) + + info( + "Grenzpunkte (Diagnose)", + f"Gemarkung {gemarkungsnummer} ({g_idx}/{n_extents}), " + f"Batch {b_idx + 1}/{n_batches_diese_gemarkung}: " + f"STARTINDEX={start_index}, COUNT={_BBOX_BATCH} …", + duration=2, + ) + + _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, extra) + if not _gml_bytes: + warning( + "Grenzpunkte (Diagnose)", + f"Gemarkung {gemarkungsnummer} Batch {b_idx + 1}: " + f"GET-Anfrage fehlgeschlagen – Batch wird übersprungen.", + duration=6, + ) + continue + + tmp = _parse_alkis_gml_memory_layer( + _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position" + ) + if not tmp: + continue + + cur_feld_namen = [f.name() for f in tmp.fields()] + + if not pta_feld_namen: + pta_feld_namen = cur_feld_namen + gehoert_zu_feld = _erkenne_gehoert_zu_feld(tmp) + if gehoert_zu_feld: + info( + "Grenzpunkte", + f"Referenzfeld erkannt: '{gehoert_zu_feld}'. " + f"PunktortTA-Felder: {', '.join(pta_feld_namen)}", + duration=5, + ) + else: + warning( + "Grenzpunkte (Diagnose)", + "Kein Referenzfeld in AX_PunktortTA erkannt. " + "Vorhandene Felder: " + ", ".join(pta_feld_namen), + duration=15, + ) + + neu = 0 + duplikat = 0 + for feat in tmp.getFeatures(): + pta_id = _extrahiere_feature_gml_id(feat, cur_feld_namen) + if pta_id and pta_id in gesehene_pta_ids: + duplikat += 1 + continue + if pta_id: + gesehene_pta_ids.add(pta_id) + alle_pta_features.append(feat) + neu += 1 + + if gehoert_zu_feld: + try: + raw = feat[gehoert_zu_feld] + raw = str(raw).strip() if raw else "" + gp_id = raw.rsplit(":", 1)[-1] if ":" in raw else raw + if gp_id: + gp_ids_dieser_gemarkung.add(gp_id) + except Exception: + pass + + neu_gesamt += neu + duplikat_gesamt += duplikat + + per_gemarkung_gp_ids[gemarkungsnummer] = gp_ids_dieser_gemarkung + info( + "Grenzpunkte", + f"Gemarkung {gemarkungsnummer}: {neu_gesamt} PunktortTA-Objekte " + f"({duplikat_gesamt} Duplikate übersprungen), " + f"{len(gp_ids_dieser_gemarkung)} GP-Referenzen.", + duration=4, + ) + + # Abbruch wenn keine Features + if not alle_pta_features: + prog.close() + QMessageBox.warning( + tab_widget, + "Keine Grenzpunkte", + "Für die angegebenen Gemarkungen wurden keine PunktortTA-Objekte gefunden.", + ) + return None + + if not gehoert_zu_feld: + prog.close() + QMessageBox.warning( + tab_widget, + "Kein Referenzfeld", + f"{len(alle_pta_features)} PunktortTA-Objekte geladen, " + f"aber kein Referenzfeld auf AX_Grenzpunkt gefunden.\n\n" + f"Gesuchte Feldnamen: {', '.join(_GEHOERT_ZU_KANDIDATEN)}\n" + f"Vorhandene Felder: {', '.join(pta_feld_namen)}", + ) + return None + + punktort_layer = QgsVectorLayer("Point?crs=EPSG:25833", PUNKTORT_LAYER_NAME, "memory") + pr_pta = punktort_layer.dataProvider() + pr_pta.addAttributes([QgsField(n, QVariant.String) for n in pta_feld_namen if n]) + punktort_layer.updateFields() + pr_pta.addFeatures(alle_pta_features) + punktort_layer.updateExtents() + + alle_gp_ids: set[str] = set() + for ids in per_gemarkung_gp_ids.values(): + alle_gp_ids.update(ids) + + n_pta_gesamt = len(alle_pta_features) + n_gp_ids = len(alle_gp_ids) + info( + "Grenzpunkte", + f"Gesamt: {n_pta_gesamt} PunktortTA-Objekte, {n_gp_ids} eindeutige GP-Referenzen " + f"aus {n_extents} Gemarkung(en).", + duration=5, + ) + + if not alle_gp_ids: + prog.close() + QMessageBox.warning( + tab_widget, + "Keine Verknüpfungen", + f"{n_pta_gesamt} PunktortTA-Objekte geladen, aber Referenzfeld " + f"'{gehoert_zu_feld}' enthält keine gültigen IDs.", + ) + return None + + # ------------------------------------------------------------------ + # Schritt 3: AX_Grenzpunkt per ResourceId-Batches + # ------------------------------------------------------------------ + anzahl_gp_batches = math.ceil(n_gp_ids / _BATCH_GROESSE) + # Gesamtschritte jetzt vollständig bekannt + prog_gesamt = prog_schritt + anzahl_gp_batches + 1 + prog.set_total(prog_gesamt) + prog.set_label( + f"Grenzpunkte laden: {n_gp_ids} IDs in {anzahl_gp_batches} Batch(es) …" + ) + prog.set_value(prog_schritt) + if prog.is_canceled(): + return None + + def _gp_progress(batch_idx: int) -> None: + prog.set_label( + f"AX_Grenzpunkt Batch {batch_idx + 1}/{anzahl_gp_batches} …" + ) + prog.set_value(prog_schritt + batch_idx + 1) + + gp_dict, gp_feld_namen = _lade_grenzpunkte_als_dict( + sorted(alle_gp_ids), progress_callback=_gp_progress + ) + + if not gp_dict: + prog.close() + warning( + "Grenzpunkte", + "Grenzpunkt-Daten konnten nicht geladen werden " + "(IDs vorhanden, WFS lieferte kein Ergebnis).", + duration=6, + ) + return None + + info( + "Grenzpunkte", + f"{len(gp_dict)}/{n_gp_ids} Grenzpunkte vom WFS empfangen " + f"({n_gp_ids - len(gp_dict)} IDs nicht aufgelöst).", + duration=4, + ) + + for gemarkungsnummer, gp_ids in per_gemarkung_gp_ids.items(): + if not gp_ids: + continue + n_gefunden = sum(1 for gid in gp_ids if gid in gp_dict) + info( + "Grenzpunkte", + f"Gemarkung {gemarkungsnummer}: {n_gefunden}/{len(gp_ids)} " + f"Grenzpunkte geladen.", + duration=6, + ) + + # ------------------------------------------------------------------ + # Schritt 4: Angereicherten Join-Layer erstellen + # ------------------------------------------------------------------ + prog.set_label("Angereicherten Layer erstellen …") + prog.set_value(prog_gesamt - 1) + if prog.is_canceled(): + return None + + joined_layer = _erstelle_joined_layer( + punktort_layer, gp_dict, gp_feld_namen, gehoert_zu_feld + ) + + if not joined_layer or not joined_layer.isValid(): + prog.close() + QMessageBox.critical( + tab_widget, + "Fehler", + "Angereicherter Grenzpunkt-Layer konnte nicht erstellt werden.", + ) + return None + + project.addMapLayer(joined_layer) + + prog.set_value(prog_gesamt) + prog.close() + abgeschlossen = True + + info( + "Grenzpunkte", + f"Fertig: {joined_layer.featureCount()} Grenzpunkte im Layer " + f"({len(gp_dict)} mit GP-Attributen, " + f"{joined_layer.featureCount() - len(gp_dict)} ohne GP-Match).", + duration=10, + ) + + return joined_layer + + finally: + if not abgeschlossen: + prog.close() + diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 645f38f..aa7830c 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -16,9 +16,8 @@ from qgis.utils import iface from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import verfahrensgebiet_alkis_komplett -# TODO Grenzpunkt-Pipeline: Import reaktivieren, sobald grenzpunkt_alkis.py -# mit der neuen Pipeline (PunktortTA → istTeilVon → Einzel-URN) umgeschrieben wurde. -# from sn_verfahrensgebiet.functions.grenzpunkt_alkis import grenzpunkt_alkis +from sn_verfahrensgebiet.functions.grenzpunkt_alkis import grenzpunkte_gemarkungsweise +from sn_verfahrensgebiet.functions.gemarkung_alkis import gemarkung_schluessel_und_namen_von_vg_geometrie from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( pruefe_flurstueck_layer, pruefe_auswahl, @@ -405,36 +404,63 @@ class WorkingTab(QWidget): # ------------------------------------------------------------------ def handle_grenz_alkis(self) -> None: - """Lädt Grenzpunkte (AX_PunktortTA + AX_Grenzpunkt) aus ALKIS. + """Lädt Grenzpunkte gemarkungsweise aus ALKIS. - TODO Grenzpunkt-Pipeline: Implementierung ausstehend. - Neue Pipeline: VG-Geometrie → PunktortTA (spatial filter) → - istTeilVon-Attribute → Grenzpunkt per Einzel-URN laden. - Bis dahin ist dieser Handler deaktiviert. + Verwendet denselben Gemarkungsfilter wie "Verfahrensgebiet aus ALKIS laden": + Die Gemarkungsschlüssel werden per Spatial-Join aus der Geometrie des + aktuell gewählten Verfahrensgebiet-Layers abgeleitet. """ - # TODO Grenzpunkt-Pipeline: Körper reaktivieren, sobald grenzpunkt_alkis.py - # mit der neuen Pipeline umgeschrieben wurde. - # - # self._set_busy(self.btn_grenz_alkis, True, "Grenzpunkte aus ALKIS laden") - # try: - # punktort_layer, grenzpunkt_layer = grenzpunkt_alkis(self) - # - # if grenzpunkt_layer and grenzpunkt_layer.isValid(): - # iface.mapCanvas().refresh() - # self.setze_haken(self.haken_grenz, True) - # success("Grenzpunkte", "Grenzpunkte erfolgreich geladen.", duration=5) - # elif punktort_layer and punktort_layer.isValid(): - # iface.mapCanvas().refresh() - # self.setze_haken(self.haken_grenz, True) - # info( - # "Grenzpunkte", - # "PunktortTA-Layer geladen. Grenzpunkt-IDs konnten nicht aufgelöst werden.", - # duration=6, - # ) - # finally: - # self._set_busy(self.btn_grenz_alkis, False, "Grenzpunkte aus ALKIS laden") - from sn_basis.functions.message_wrapper import warning as _warn - _warn("Grenzpunkte", "Grenzpunkt-Pipeline ist noch nicht implementiert.", duration=5) + # Verfahrensgebiet-Layer aus dem Dropdown holen + vg_layer = self.verf_layer_combo.currentLayer() + if not vg_layer or not vg_layer.isValid(): + warning( + "Grenzpunkte", + "Kein Verfahrensgebiet-Layer gewählt. Bitte zuerst ein Verfahrensgebiet laden.", + duration=6, + ) + return + + # Geometrie des ersten Features als Suchgeometrie + feats = list(vg_layer.getFeatures()) + if not feats: + warning("Grenzpunkte", "Verfahrensgebiet-Layer enthält keine Features.", duration=5) + return + + # Geometrie über alle Features vereinen + from qgis.core import QgsGeometry # type: ignore[import] + vg_geom = QgsGeometry.unaryUnion([f.geometry() for f in feats if f.hasGeometry()]) + + if not vg_geom or vg_geom.isEmpty(): + warning("Grenzpunkte", "Verfahrensgebiet-Layer hat keine Geometrie.", duration=5) + return + + # Gemarkungsschlüssel per Spatial-Join ermitteln (identisch zu VG-Workflow) + schluessel_namen = gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom) + schluessel_liste = [nr for nr, _ in schluessel_namen] + + if not schluessel_liste: + warning( + "Grenzpunkte", + "Keine Gemarkungen für das Verfahrensgebiet gefunden. " + "Bitte Netzwerkverbindung prüfen.", + duration=6, + ) + return + + self._set_busy(self.btn_grenz_alkis, True, "Grenzpunkte aus ALKIS laden") + try: + joined_layer = grenzpunkte_gemarkungsweise(self, schluessel_liste, vg_geom=vg_geom) + + if joined_layer and joined_layer.isValid(): + iface.mapCanvas().refresh() + self.setze_haken(self.haken_grenz, True) + success( + "Grenzpunkte", + f"Grenzpunkte erfolgreich geladen ({joined_layer.featureCount()} Objekte).", + duration=5, + ) + finally: + self._set_busy(self.btn_grenz_alkis, False, "Grenzpunkte aus ALKIS laden") # --------------------------------------------------------------------------- From 8730e1bf96d48b9941de1b11892165ba058f016e Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 1 May 2026 23:53:20 +0200 Subject: [PATCH 14/22] attributwertverschiebung gefixt --- functions/grenzpunkt_alkis.py | 71 +++++++++++++++++++++++++++-------- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/functions/grenzpunkt_alkis.py b/functions/grenzpunkt_alkis.py index b01d3f5..90c6353 100644 --- a/functions/grenzpunkt_alkis.py +++ b/functions/grenzpunkt_alkis.py @@ -269,12 +269,14 @@ def _parse_alkis_gml_memory_layer( if not feat_elems: return None - # Felder aus erstem Feature ermitteln (geom_prop ausschließen) + # Felder aus ALLEN Features ermitteln (2-Pass), damit optionale Felder + # wie "istTeilVon" nicht verloren gehen, wenn sie im ersten Feature fehlen. feld_namen: List[str] = ["gml_id"] - for child in feat_elems[0]: - local = child.tag.split("}")[-1] if "}" in child.tag else child.tag - if local not in feld_namen and local != geom_prop: - feld_namen.append(local) + for feat_el in feat_elems: + for child in feat_el: + local = child.tag.split("}")[-1] if "}" in child.tag else child.tag + if local not in feld_namen and local != geom_prop: + feld_namen.append(local) # Memory-Layer anlegen mem_uri = f"Point?crs=EPSG:25833" if geom_prop else "none" @@ -1187,7 +1189,11 @@ def grenzpunkte_gemarkungsweise( # ------------------------------------------------------------------ # Schritt 2b: PunktortTA pro Gemarkung + Batching # ------------------------------------------------------------------ - alle_pta_features: List = [] + # Statt rohe QgsFeature-Objekte (positionsbasierte Attribute!) zu sammeln, + # speichern wir (geom, attrs-dict) – vollständig namensbasiert. + # Das verhindert Feldwert-Verschiebungen, wenn verschiedene Batches + # die Felder in unterschiedlicher Reihenfolge liefern. + alle_pta_records: List[Dict] = [] # {'geom': QgsGeometry, 'attrs': {name: val}} gesehene_pta_ids: set[str] = set() pta_feld_namen: List[str] = [] gehoert_zu_feld: Optional[str] = None @@ -1250,21 +1256,27 @@ def grenzpunkte_gemarkungsweise( cur_feld_namen = [f.name() for f in tmp.fields()] - if not pta_feld_namen: - pta_feld_namen = cur_feld_namen + # Globale Feldliste inkrementell um neue Felder erweitern + # (Union über alle Batches/Gemarkungen). + for n in cur_feld_namen: + if n not in pta_feld_namen: + pta_feld_namen.append(n) + + # Referenzfeld beim ersten Batch erkennen, der es enthält + if gehoert_zu_feld is None: gehoert_zu_feld = _erkenne_gehoert_zu_feld(tmp) if gehoert_zu_feld: info( "Grenzpunkte", f"Referenzfeld erkannt: '{gehoert_zu_feld}'. " - f"PunktortTA-Felder: {', '.join(pta_feld_namen)}", + f"PunktortTA-Felder bisher: {', '.join(pta_feld_namen)}", duration=5, ) - else: + elif not pta_feld_namen: warning( "Grenzpunkte (Diagnose)", "Kein Referenzfeld in AX_PunktortTA erkannt. " - "Vorhandene Felder: " + ", ".join(pta_feld_namen), + "Vorhandene Felder: " + ", ".join(cur_feld_namen), duration=15, ) @@ -1277,7 +1289,17 @@ def grenzpunkte_gemarkungsweise( continue if pta_id: gesehene_pta_ids.add(pta_id) - alle_pta_features.append(feat) + + # Attribute namensbasiert als Dict speichern – verhindert + # Positions-Verschiebung beim späteren Aufbau von punktort_layer. + _attrs: Dict[str, str] = {} + for n in cur_feld_namen: + if n: + try: + _attrs[n] = feat[n] + except Exception: + pass + alle_pta_records.append({'geom': feat.geometry(), 'attrs': _attrs}) neu += 1 if gehoert_zu_feld: @@ -1303,7 +1325,7 @@ def grenzpunkte_gemarkungsweise( ) # Abbruch wenn keine Features - if not alle_pta_features: + if not alle_pta_records: prog.close() QMessageBox.warning( tab_widget, @@ -1317,7 +1339,7 @@ def grenzpunkte_gemarkungsweise( QMessageBox.warning( tab_widget, "Kein Referenzfeld", - f"{len(alle_pta_features)} PunktortTA-Objekte geladen, " + f"{len(alle_pta_records)} PunktortTA-Objekte geladen, " f"aber kein Referenzfeld auf AX_Grenzpunkt gefunden.\n\n" f"Gesuchte Feldnamen: {', '.join(_GEHOERT_ZU_KANDIDATEN)}\n" f"Vorhandene Felder: {', '.join(pta_feld_namen)}", @@ -1328,14 +1350,31 @@ def grenzpunkte_gemarkungsweise( pr_pta = punktort_layer.dataProvider() pr_pta.addAttributes([QgsField(n, QVariant.String) for n in pta_feld_namen if n]) punktort_layer.updateFields() - pr_pta.addFeatures(alle_pta_features) + + # Features mit vollständig namensbasierter Attributzuweisung aufbauen – + # vermeidet Positions-Verschiebungen, die entstehen würden, wenn + # QgsFeatures aus tmp-Layern mit unterschiedlicher Feldreihenfolge + # positionsbasiert in punktort_layer übertragen werden. + neue_pta_features = [] + from sn_basis.functions import QgsFeature as _QgsFeature + for record in alle_pta_records: + new_f = _QgsFeature(punktort_layer.fields()) + new_f.setGeometry(record['geom']) + for n, val in record['attrs'].items(): + if n in pta_feld_namen: + try: + new_f[n] = val + except Exception: + pass + neue_pta_features.append(new_f) + pr_pta.addFeatures(neue_pta_features) punktort_layer.updateExtents() alle_gp_ids: set[str] = set() for ids in per_gemarkung_gp_ids.values(): alle_gp_ids.update(ids) - n_pta_gesamt = len(alle_pta_features) + n_pta_gesamt = len(alle_pta_records) n_gp_ids = len(alle_gp_ids) info( "Grenzpunkte", From e344a5ad4ab88aeac6c3475c1a4f96be0b43faa3 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 5 May 2026 19:46:08 +0200 Subject: [PATCH 15/22] =?UTF-8?q?Verfahrensgebietspr=C3=BCfung=20auf=20Kni?= =?UTF-8?q?ckpunkte=20umgestellt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- functions/grenzpunkt_alkis.py | 152 ++++++ functions/knickpunkt_pruefung.py | 467 ++++++++++++++++++ functions/verfahrensgebiet_alkis_komplett.py | 110 +++-- tests/test_knickpunkt_pruefung.py | 307 ++++++++++++ tests/test_verfahrensgebiet_alkis_komplett.py | 124 +++-- ui/tabs/working_tab.py | 5 +- 6 files changed, 1104 insertions(+), 61 deletions(-) create mode 100644 functions/knickpunkt_pruefung.py create mode 100644 tests/test_knickpunkt_pruefung.py diff --git a/functions/grenzpunkt_alkis.py b/functions/grenzpunkt_alkis.py index 90c6353..452e5f9 100644 --- a/functions/grenzpunkt_alkis.py +++ b/functions/grenzpunkt_alkis.py @@ -914,6 +914,158 @@ def _erstelle_joined_layer( result_layer.updateExtents() return result_layer + +def lade_punktort_ta( + schluessel_liste: List[str], + vg_geom, + progress=None, +) -> Optional[object]: + """Lädt ``AX_PunktortTA`` gemarkungsweise mit STARTINDEX-Paginierung. + + Verwendet dieselbe Strategie wie :func:`grenzpunkte_gemarkungsweise`: + + 1. Gemarkungsextents laden und mit einem 200-m-VG-Puffer verschneiden. + 2. Hits-Abfrage pro Gemarkung zur Batch-Planung. + 3. PunktortTA je Gemarkung in 5000-Feature-Batches laden (STARTINDEX-Paging). + 4. gml_id-Deduplizierung über Gemarkungsgrenzen hinweg. + + Es werden keine Grenzpunkt-Batches geladen – die Funktion gibt ausschließlich + den PunktortTA-Layer zurück. + + Parameters + ---------- + schluessel_liste : + 4-stellige Gemarkungsnummern (aus dem aktuellen Workflow bereits bekannt). + vg_geom : + ``QgsGeometry`` des BROR-Objekts (EPSG:25833). Wird für den 200-m-Puffer + zur Einschränkung der Gemarkungsextents verwendet. + progress : + Optionales Fortschrittsdialog-Objekt mit ``set_label()``, ``set_value()`` + und ``is_canceled()``. + + Returns + ------- + QgsVectorLayer | None + Memory-Layer mit allen PunktortTA-Features oder ``None`` bei Fehler. + """ + from sn_basis.functions import QgsField + from sn_basis.functions import QgsFeature as _QgsFeature + from sn_basis.functions.qt_wrapper import QVariant + + _BBOX_BATCH = 5000 + + if not schluessel_liste: + return None + + if vg_geom and not vg_geom.isEmpty(): + extents = _lade_gemarkung_extents_mit_puffer(schluessel_liste, vg_geom, 200.0) + else: + extents = _lade_gemarkung_extents(schluessel_liste) + + if not extents: + return None + + n_extents = len(extents) + alle_records: List[Dict] = [] + gesehene_ids: set = set() + feld_namen: List[str] = [] + + for g_idx, (gemarkungsnummer, bbox) in enumerate(extents.items(), 1): + if progress is not None and progress.is_canceled(): + return None + + bbox_param = _punktort_ta_bbox_param(bbox) + n_hits = _wfs_hits_count(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, {'BBOX': bbox_param}) + n_batches = max(1, math.ceil(n_hits / _BBOX_BATCH)) if n_hits > 0 else 1 + + info( + "Grenzpunkte (Diagnose)", + f"Gemarkung {gemarkungsnummer} ({g_idx}/{n_extents}): " + f"~{n_hits} PunktortTA → {n_batches} Batch(es).", + duration=2, + ) + + for b_idx in range(n_batches): + if progress is not None: + progress.set_label( + f"PunktortTA: Gemarkung {gemarkungsnummer} " + f"({g_idx}/{n_extents}), Batch {b_idx + 1}/{n_batches} …" + ) + if progress.is_canceled(): + return None + + extra: dict = {'BBOX': bbox_param} + if n_batches > 1: + extra['COUNT'] = str(_BBOX_BATCH) + extra['STARTINDEX'] = str(b_idx * _BBOX_BATCH) + + _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, extra) + if not _gml_bytes: + warning( + "Grenzpunkte (Diagnose)", + f"Gemarkung {gemarkungsnummer} Batch {b_idx + 1}: " + f"GET-Anfrage fehlgeschlagen – übersprungen.", + duration=4, + ) + continue + + tmp = _parse_alkis_gml_memory_layer( + _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position" + ) + if not tmp: + continue + + cur_felder = [f.name() for f in tmp.fields()] + for n in cur_felder: + if n not in feld_namen: + feld_namen.append(n) + + for feat in tmp.getFeatures(): + gml_id = _extrahiere_feature_gml_id(feat, cur_felder) + if gml_id and gml_id in gesehene_ids: + continue + if gml_id: + gesehene_ids.add(gml_id) + attrs: Dict = {} + for n in cur_felder: + if n: + try: + attrs[n] = feat[n] + except Exception: + pass + alle_records.append({'geom': feat.geometry(), 'attrs': attrs}) + + info( + "Grenzpunkte (Diagnose)", + f"Gemarkung {gemarkungsnummer}: {len(gesehene_ids)} PunktortTA bisher.", + duration=2, + ) + + if not alle_records: + return None + + result = QgsVectorLayer("Point?crs=EPSG:25833", PUNKTORT_LAYER_NAME, "memory") + pr = result.dataProvider() + pr.addAttributes([QgsField(n, QVariant.String) for n in feld_namen if n]) + result.updateFields() + + neue_features = [] + for record in alle_records: + f = _QgsFeature(result.fields()) + f.setGeometry(record['geom']) + for n, val in record['attrs'].items(): + if n in feld_namen: + try: + f[n] = val + except Exception: + pass + neue_features.append(f) + + pr.addFeatures(neue_features) + result.updateExtents() + return result + + def grenzpunkt_alkis(tab_widget, extent=None) -> Tuple[Optional[object], Optional[object]]: """Lädt Grenzpunkte aus dem ALKIS-WFS für den angegebenen Begrenzungsrahmen. diff --git a/functions/knickpunkt_pruefung.py b/functions/knickpunkt_pruefung.py new file mode 100644 index 0000000..4044c74 --- /dev/null +++ b/functions/knickpunkt_pruefung.py @@ -0,0 +1,467 @@ +"""sn_verfahrensgebiet/functions/knickpunkt_pruefung.py – Knickpunktbasierte BROR-Prüfung. + +Vergleicht die Knickpunkte (Stützpunkte) des BauRaumOderBodenordnungsrecht- +Polygons mit den Punktorten der geladenen ``AX_PunktortTA``-Objekte und +identifiziert: + +- **Identische Punkte** (grün): An einem BROR-Knickpunkt liegt ein PunktortTA. +- **Fehlende PunktortTA** (rot): Ein BROR-Knickpunkt hat keinen zugehörigen PunktortTA. +- **Fehlende Knickpunkte** (orange): Ein PunktortTA liegt nahe des BROR-Umrings + auf einer Flurstücksgrenze, hat aber keinen entsprechenden Knickpunkt im BROR. + +**Algorithmus:** + +1. BROR-Polygon-Stützpunkte (Knickpunkte) extrahieren. +2. Spatial-Index auf PunktortTA aufbauen. +3. Für jeden Knickpunkt nächsten PunktortTA im Radius von + :data:`KOORDINATEN_TOLERANZ_M` suchen → grün oder rot. + Gematchte PunktortTA-Feature-IDs merken. +4. Gepufferter BROR-Umring + Flurstück-Vertex-Set ermitteln. +5. Nicht-gematchte PunktortTA nahe BROR-Umring, die auf einer Flurstücksgrenze + liegen → orange. + +**Rückgabe:** ``(result_layer, identisch, kein_pta, fehlender_knick)`` +""" +from __future__ import annotations + +from typing import Dict, List, Optional, Set, Tuple + +from sn_basis.functions.qgiscore_wrapper import ( + QgsVectorLayer, + QgsFeature, + QgsField, + QgsGeometry, +) +from sn_basis.functions.qt_wrapper import QVariant + +# --------------------------------------------------------------------------- +# Konstanten +# --------------------------------------------------------------------------- + +#: Koordinatentoleranz für die Knickpunkt ↔ PunktortTA-Übereinstimmung (m). +KOORDINATEN_TOLERANZ_M: float = 0.01 + +#: Puffer um den BROR-Umring für die Suche nach fehlenden Knickpunkten (m). +BOUNDARY_PUFFER_M: float = 0.5 + +#: Rundungsgenauigkeit für Vertex-Koordinaten im Flurstück-Set (Dezimalstellen). +_VERTEX_ROUND = 3 + +ERGEBNIS_LAYER_NAME = "Knickpunktprüfung" +FELD_TYP = "typ" +WERT_IDENTISCH = "identisch" +WERT_KEIN_PTA = "kein_pta" +WERT_FEHLENDER_KNICK = "fehlender_knick" + + +# --------------------------------------------------------------------------- +# Interne Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _extrahiere_knickpunkte(bror_layer) -> List[object]: + """Extrahiert alle Stützpunkte (Knickpunkte) des BROR-Polygons. + + Doppelte Start/End-Punkte eines Rings werden dedupliziert. Gibt eine + flache Liste von ``QgsPointXY``-Objekten zurück. + + Parameters + ---------- + bror_layer : + BauRaumOderBodenordnungsrecht-Polygon-Layer. + + Returns + ------- + List[QgsPointXY] + Alle eindeutigen Stützpunkte; leer bei Fehler. + """ + try: + from qgis.core import QgsPointXY # type: ignore[import] + + bror_feats = [ + f for f in bror_layer.getFeatures() + if f.geometry() and not f.geometry().isEmpty() + ] + if not bror_feats: + return [] + + union_geom = QgsGeometry.unaryUnion([f.geometry() for f in bror_feats]) + if union_geom is None or union_geom.isEmpty(): + return [] + + punkte: List[object] = [] + seen: Set[Tuple[float, float]] = set() + + def _add_ring(vertices): + # Letzter Punkt = erster Punkt (Ring geschlossen) → überspringen + ring_pts = list(vertices) + n = len(ring_pts) + if n >= 2: + # Prüfen ob letzter Punkt identisch mit erstem + last = n - 1 + if ( + abs(ring_pts[last].x() - ring_pts[0].x()) < 1e-9 + and abs(ring_pts[last].y() - ring_pts[0].y()) < 1e-9 + ): + ring_pts = ring_pts[:-1] + for pt in ring_pts: + key = (round(pt.x(), _VERTEX_ROUND), round(pt.y(), _VERTEX_ROUND)) + if key not in seen: + seen.add(key) + punkte.append(pt) + + geom_type = union_geom.wkbType() + # Polygon oder MultiPolygon + if union_geom.isMultipart(): + for poly in union_geom.asMultiPolygon(): + for ring in poly: + _add_ring([QgsPointXY(x, y) for x, y in ring]) + else: + for ring in union_geom.asPolygon(): + _add_ring([QgsPointXY(x, y) for x, y in ring]) + + return punkte + + except Exception: + return [] + + +def _erstelle_ergebnis_layer(crs_authid: str) -> object: + """Erzeugt den leeren Punkt-Ergebnislayer mit Feld ``typ``. + + Parameters + ---------- + crs_authid : + CRS-Kennung, z.B. ``"EPSG:25833"``. + + Returns + ------- + QgsVectorLayer + Leerer Memory-Layer vom Typ Point. + """ + layer = QgsVectorLayer( + f"Point?crs={crs_authid}", ERGEBNIS_LAYER_NAME, "memory" + ) + pr = layer.dataProvider() + pr.addAttributes([QgsField(FELD_TYP, QVariant.String)]) + layer.updateFields() + return layer + + +def _setze_kategorien_symbologie(layer) -> None: + """Wendet Kategoriesymbologie an: grün/rot/orange Punkte. + + - ``identisch`` → grün + - ``kein_pta`` → rot + - ``fehlender_knick`` → orange + + Wird bei fehlendem QGIS-Kontext still ignoriert. + """ + try: + from qgis.core import ( # type: ignore[import] + QgsCategorizedSymbolRenderer, + QgsRendererCategory, + QgsMarkerSymbol, + ) + + def _marker(color: str, size: str = "2") -> object: + return QgsMarkerSymbol.createSimple( + {"color": color, "size": size, "outline_style": "no"} + ) + + categories = [ + QgsRendererCategory( + WERT_IDENTISCH, + _marker("0,180,0,255"), + "identisch (Knickpunkt mit PunktortTA)", + ), + QgsRendererCategory( + WERT_KEIN_PTA, + _marker("220,0,0,255"), + "kein PunktortTA an Knickpunkt", + ), + QgsRendererCategory( + WERT_FEHLENDER_KNICK, + _marker("255,128,0,255"), + "fehlender Knickpunkt (PunktortTA ohne BROR-Knickpunkt)", + ), + ] + renderer = QgsCategorizedSymbolRenderer(FELD_TYP, categories) + layer.setRenderer(renderer) + except Exception: + pass # Kein QGIS-Kontext (Tests) → Standarddarstellung behalten + + +def _baue_pta_index(punktort_layer) -> Tuple[object, Dict[int, object]]: + """Baut einen QgsSpatialIndex und eine ID→Geometrie-Map für PunktortTA. + + Parameters + ---------- + punktort_layer : + Geladener ``AX_PunktortTA``-Layer. + + Returns + ------- + Tuple[QgsSpatialIndex, Dict[int, QgsGeometry]] + Index und Feature-ID-zu-Geometrie-Dict. + """ + from qgis.core import QgsSpatialIndex # type: ignore[import] + + index = QgsSpatialIndex() + geom_map: Dict[int, object] = {} + for feat in punktort_layer.getFeatures(): + geom = feat.geometry() + if geom and not geom.isEmpty(): + index.insertFeature(feat) + geom_map[feat.id()] = geom + return index, geom_map + + +def _baue_flst_vertex_set( + flst_layer, + bror_buffer_geom, +) -> Set[Tuple[float, float]]: + """Erstellt ein Set gerundeter (x, y)-Koordinaten aller Flurstück-Vertices + innerhalb des gepufferten BROR-Umrings. + + Parameters + ---------- + flst_layer : + Flurstücke-Layer. + bror_buffer_geom : + Gepufferter BROR-Umring (Polygon-Geometrie). + + Returns + ------- + Set[Tuple[float, float]] + Gerundete Vertex-Koordinaten. + """ + vertex_set: Set[Tuple[float, float]] = set() + if flst_layer is None: + return vertex_set + + try: + for feat in flst_layer.getFeatures(): + geom = feat.geometry() + if geom is None or geom.isEmpty(): + continue + try: + # Nur Flurstücke berücksichtigen, die den Puffer berühren + if not geom.intersects(bror_buffer_geom): + continue + boundary = geom.convertToType(1) # 1 = Line + if boundary is None or boundary.isEmpty(): + continue + if boundary.isMultipart(): + parts = boundary.asMultiPolyline() + else: + parts = [boundary.asPolyline()] + for line in parts: + for pt in line: + vertex_set.add( + (round(pt.x(), _VERTEX_ROUND), round(pt.y(), _VERTEX_ROUND)) + ) + except Exception: + continue + except Exception: + pass + return vertex_set + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- + +def knickpunkt_pruefung( + bror_layer, + punktort_layer, + flst_layer, + progress=None, + knickpunkte: Optional[List] = None, +) -> Tuple[Optional[object], int, int, int]: + """Knickpunktbasierte BROR-Prüfung. + + Vergleicht BROR-Knickpunkte mit ``AX_PunktortTA``-Objekten und ermittelt + fehlende Knickpunkte anhand von Flurstücksgrenzen. + + Parameters + ---------- + bror_layer : + BauRaumOderBodenordnungsrecht-Polygon-Layer. + punktort_layer : + Geladener ``AX_PunktortTA``-Layer (Ergebnis aus ``grenzpunkt_alkis``). + flst_layer : + Flurstücke-Layer für die Orange-Prüfung. Kann ``None`` sein. + progress : + Optionaler ``ProgressDialog`` für Fortschrittsanzeige. + knickpunkte : + Vorberechnete Knickpunktliste (optional). Wenn ``None``, werden die + Knickpunkte aus ``bror_layer`` extrahiert. + + Returns + ------- + Tuple[Optional[QgsVectorLayer], int, int, int] + ``(result_layer, identisch_count, kein_pta_count, fehlender_knick_count)`` + + - ``result_layer``: Punkt-Layer mit grünen/roten/orangen Punkten, + oder ``None`` bei Abbruch oder fehlgeschlagener Verarbeitung. + - ``identisch_count``: Knickpunkte mit passendem PunktortTA (grün). + - ``kein_pta_count``: Knickpunkte ohne passenden PunktortTA (rot). + - ``fehlender_knick_count``: PunktortTA ohne BROR-Knickpunkt (orange). + """ + + def _canceled() -> bool: + return progress is not None and progress.is_canceled() + + def _schritt(label: str, wert: int) -> None: + if progress is not None: + progress.set_label(label) + progress.set_value(wert) + + # ------------------------------------------------------------------ + # Schritt 0: Knickpunkte ermitteln + # ------------------------------------------------------------------ + _schritt("Knickpunktprüfung: Knickpunkte extrahieren …", 0) + + if knickpunkte is None: + knickpunkte = _extrahiere_knickpunkte(bror_layer) + + if not knickpunkte: + return None, 0, 0, 0 + + n = len(knickpunkte) + crs_authid = bror_layer.crs().authid() + + if _canceled(): + return None, 0, 0, 0 + + # ------------------------------------------------------------------ + # Schritt 1: Spatial-Index für PunktortTA aufbauen + # ------------------------------------------------------------------ + _schritt("Knickpunktprüfung: PunktortTA-Index aufbauen …", 1) + + if punktort_layer is None: + return None, 0, 0, 0 + + try: + pta_index, geom_map = _baue_pta_index(punktort_layer) + except Exception: + return None, 0, 0, 0 + + if _canceled(): + return None, 0, 0, 0 + + # ------------------------------------------------------------------ + # Schritte 2…n+1: Jeden Knickpunkt mit PunktortTA vergleichen + # ------------------------------------------------------------------ + try: + from qgis.core import QgsRectangle # type: ignore[import] + except ImportError: + return None, 0, 0, 0 + + ergebnis_layer = _erstelle_ergebnis_layer(crs_authid) + pr = ergebnis_layer.dataProvider() + feats_to_add: List[object] = [] + + identisch_count = 0 + kein_pta_count = 0 + matched_pta_ids: Set[int] = set() + + for i, knick_pt in enumerate(knickpunkte): + if _canceled(): + return None, 0, 0, 0 + + _schritt(f"Knickpunktprüfung: Knickpunkt {i + 1}/{n} prüfen …", i + 2) + + x, y = knick_pt.x(), knick_pt.y() + t = KOORDINATEN_TOLERANZ_M + suchbox = QgsRectangle(x - t, y - t, x + t, y + t) + kandidaten = pta_index.intersects(suchbox) + + gefunden = False + for fid in kandidaten: + pta_geom = geom_map.get(fid) + if pta_geom is None: + continue + pta_pt = pta_geom.asPoint() + dist = ((pta_pt.x() - x) ** 2 + (pta_pt.y() - y) ** 2) ** 0.5 + if dist <= KOORDINATEN_TOLERANZ_M: + gefunden = True + matched_pta_ids.add(fid) + break + + feat = QgsFeature() + try: + from qgis.core import QgsPointXY # type: ignore[import] + feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y))) + except Exception: + continue + feat.setAttributes([WERT_IDENTISCH if gefunden else WERT_KEIN_PTA]) + feats_to_add.append(feat) + + if gefunden: + identisch_count += 1 + else: + kein_pta_count += 1 + + if _canceled(): + return None, 0, 0, 0 + + # ------------------------------------------------------------------ + # Schritt n+2: Fehlende Knickpunkte (orange) ermitteln + # ------------------------------------------------------------------ + _schritt("Knickpunktprüfung: Fehlende Knickpunkte ermitteln …", n + 2) + + fehlender_knick_count = 0 + + # BROR-Umring ermitteln und puffern + try: + bror_feats = [ + f for f in bror_layer.getFeatures() + if f.geometry() and not f.geometry().isEmpty() + ] + bror_union = QgsGeometry.unaryUnion([f.geometry() for f in bror_feats]) + bror_boundary = bror_union.convertToType(1) if bror_union else None + bror_buffer_geom = ( + bror_boundary.buffer(BOUNDARY_PUFFER_M, 8) if bror_boundary else None + ) + except Exception: + bror_buffer_geom = None + + if bror_buffer_geom is not None and flst_layer is not None: + flst_vertex_set = _baue_flst_vertex_set(flst_layer, bror_buffer_geom) + + for feat in punktort_layer.getFeatures(): + if _canceled(): + break + if feat.id() in matched_pta_ids: + continue + geom = feat.geometry() + if geom is None or geom.isEmpty(): + continue + try: + if not geom.within(bror_buffer_geom): + continue + pta_pt = geom.asPoint() + key = (round(pta_pt.x(), _VERTEX_ROUND), round(pta_pt.y(), _VERTEX_ROUND)) + if key in flst_vertex_set: + orange_feat = QgsFeature() + orange_feat.setGeometry(geom) + orange_feat.setAttributes([WERT_FEHLENDER_KNICK]) + feats_to_add.append(orange_feat) + fehlender_knick_count += 1 + except Exception: + continue + + if _canceled(): + return None, 0, 0, 0 + + # ------------------------------------------------------------------ + # Schritt n+3: Ergebnislayer fertigstellen + # ------------------------------------------------------------------ + _schritt("Knickpunktprüfung: Ergebnislayer erstellen …", n + 3) + + pr.addFeatures(feats_to_add) + ergebnis_layer.updateExtents() + _setze_kategorien_symbologie(ergebnis_layer) + + return ergebnis_layer, identisch_count, kein_pta_count, fehlender_knick_count diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py index 25ee5ea..2d98739 100644 --- a/functions/verfahrensgebiet_alkis_komplett.py +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -12,13 +12,18 @@ optionaler Umringsprüfung: 6. Flächenvergleich zwischen BROR-Objekt und abgeleitetem Verfahrensgebiet. - **Übereinstimmung** (≤ Toleranz): modale OK-Meldung, Ablauf beendet. - - **Abweichung**: Ja/Nein-Abfrage für Detailprüfung. + - **Abweichung**: 3-Optionen-Dialog (Detailprüfung / Als VG laden / Abbrechen). -7. Optionale Umringsprüfung (Variante 1): +7. Bei Wahl „Als VG laden": - - BROR-Umring gegen Flurstücksgrenzen vergleichen. - - Ergebnislayer (grün/rot) ins Projekt einfügen. - - Abschlussmeldung mit Konfliktanzahl. + - BROR-Objekt direkt mit VG-Namen ins Projekt übernehmen. + +8. Bei Wahl „Detailprüfung" (knickpunktbasiert): + + - Grenzpunkte (AX_PunktortTA) per BBox laden. + - Knickpunkte des BROR mit PunktortTA vergleichen (grün/rot/orange). + - Ergebnislayer (Punkte) ins Projekt einfügen. + - Abschlussmeldung mit Zählung. Das BROR-Objekt (``AX_BauRaumOderBodenordnungsrecht``) bleibt bei Erfolg als temporärer Layer ``BauRaumOrdnungsrecht (ALKIS)`` im Projekt erhalten. @@ -42,7 +47,7 @@ from sn_basis.functions.variable_wrapper import get_variable from sn_basis.functions.dialog_wrapper import ( create_progress_dialog, show_info_dialog, - ask_yes_no, + ask_detailpruefung_oder_vg_laden, ) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import ( @@ -57,7 +62,11 @@ from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( erstelle_verfahrensgebiet_aus_auswahl, VERF_LAYER_NAME, ) -from sn_verfahrensgebiet.functions.umringspruefung import umringspruefung +from sn_verfahrensgebiet.functions.grenzpunkt_alkis import lade_punktort_ta +from sn_verfahrensgebiet.functions.knickpunkt_pruefung import ( + knickpunkt_pruefung, + _extrahiere_knickpunkte, +) # Feldname für das Verfahrenskennzeichen im enriched Layer FELD_VKZ = "verfahrensnummer" @@ -417,51 +426,96 @@ def verfahrensgebiet_alkis_komplett( return enriched_layer, flst_layer, alkis_vg_layer, vg_status # ------------------------------------------------------------------ - # Flächenabweichung → Nutzerabfrage für Detailprüfung + # Flächenabweichung → 3-Optionen-Dialog # ------------------------------------------------------------------ progress.close() diff = abs(bror_flaeche - vg_flaeche) - starten = ask_yes_no( + entscheidung = ask_detailpruefung_oder_vg_laden( + tab_widget, "Abweichung erkannt", f"Abweichung zwischen BauRaumOrdnungsrecht-Objekt und abgeleitetem " - f"Verfahrensgebiet erkannt. Detailprüfung starten?\n\n" + f"Verfahrensgebiet erkannt.\n\n" f"BauRaumOrdnungsrecht: {bror_flaeche:.2f} m²\n" f"Verfahrensgebiet: {vg_flaeche:.2f} m²\n" - f"Differenz: {diff:.2f} m²", - default=True, - parent=tab_widget, + f"Differenz: {diff:.2f} m²\n\n" + f"Wie möchten Sie fortfahren?", ) - if not starten: + if entscheidung == "abbrechen": abgeschlossen = True return enriched_layer, flst_layer, alkis_vg_layer, vg_status # ------------------------------------------------------------------ - # Umringsprüfung (Variante 1: linienbasierter Umringvergleich) + # Option: Bodenordnungsobjekt direkt als Verfahrensgebiet laden # ------------------------------------------------------------------ - umring_progress = create_progress_dialog( - 3, "Detailprüfung (Umringe)", "Starte Umringsprüfung …" + if entscheidung == "als_vg_laden": + # Layer bleibt im Projekt – nur umbenennen. working_tab überspringt + # addMapLayer wenn der Layer bereits registriert ist. + alkis_vg_layer.setName(VERF_LAYER_NAME) + bror_was_added_by_us = False + abgeschlossen = True + return alkis_vg_layer, None, None, vg_status + + # ------------------------------------------------------------------ + # Option: Detailprüfung (knickpunktbasiert) + # ------------------------------------------------------------------ + # Knickpunkte vorab extrahieren, um Schrittanzahl zu bestimmen + knickpunkte = _extrahiere_knickpunkte(alkis_vg_layer) + n_knick = len(knickpunkte) + # Gesamtschritte: 0=Start, 1=PTA-Index, 2…n+1=Knickpunkte, n+2=Orange, n+3=Fertig + # + 1 Schritt vorab für Grenzpunkte laden + gesamt_schritte = n_knick + 5 + + detail_progress = create_progress_dialog( + gesamt_schritte, + "Detailprüfung (Knickpunkte)", + "Schritt 1: Grenzpunkte laden …", + ) + detail_progress.set_value(0) + + # Schritt 1: PunktortTA gemarkungsweise laden (Hits + 5000er-Batches) + punktort_layer = lade_punktort_ta( + vorgabe_schluessel, vg_geom, progress=detail_progress ) - ergebnis_layer, konflikt_anzahl = umringspruefung( - alkis_vg_layer, flst_layer, progress=umring_progress - ) - - if umring_progress.is_canceled(): - umring_progress.close() + if detail_progress.is_canceled(): + detail_progress.close() abgeschlossen = True return enriched_layer, flst_layer, alkis_vg_layer, vg_status - umring_progress.set_value(3) - umring_progress.close() + detail_progress.set_value(1) + + if punktort_layer is None: + detail_progress.close() + abgeschlossen = True + return enriched_layer, flst_layer, alkis_vg_layer, vg_status + + # Schritte 2…Ende: Knickpunktprüfung + ergebnis_layer, identisch, kein_pta, fehlender_knick = knickpunkt_pruefung( + alkis_vg_layer, + punktort_layer, + flst_layer, + progress=detail_progress, + knickpunkte=knickpunkte, + ) + + if detail_progress.is_canceled(): + detail_progress.close() + abgeschlossen = True + return enriched_layer, flst_layer, alkis_vg_layer, vg_status + + detail_progress.set_value(gesamt_schritte) + detail_progress.close() if ergebnis_layer and ergebnis_layer.isValid(): project.addMapLayer(ergebnis_layer) show_info_dialog( - "Detailprüfung", - f"Detailprüfung abgeschlossen. " - f"Es wurden {konflikt_anzahl} Konfliktstellen identifiziert.", + "Detailprüfung abgeschlossen", + f"Knickpunkte geprüft: {n_knick}\n" + f" Identisch (grün): {identisch}\n" + f" Kein PunktortTA (rot): {kein_pta}\n" + f" Fehlende Knickpunkte (orange): {fehlender_knick}", parent=tab_widget, ) diff --git a/tests/test_knickpunkt_pruefung.py b/tests/test_knickpunkt_pruefung.py new file mode 100644 index 0000000..882208a --- /dev/null +++ b/tests/test_knickpunkt_pruefung.py @@ -0,0 +1,307 @@ +"""sn_verfahrensgebiet/tests/test_knickpunkt_pruefung.py + +Unit-Tests für :mod:`sn_verfahrensgebiet.functions.knickpunkt_pruefung`. + +Getestete Szenarien: + +_extrahiere_knickpunkte: +- Leerer Layer → leere Liste +- Polygon mit 4 Knickpunkten → korrekte Anzahl (ohne doppelten Schlusspunkt) +- Keine Geometrie → leere Liste + +knickpunkt_pruefung: +- Leere Knickpunktliste → (None, 0, 0, 0) +- Kein PunktortTA-Layer → (None, 0, 0, 0) +- Identischer Punkt → grün gezählt +- Kein passender PunktortTA → rot gezählt +- PunktortTA nahe Umring auf Flurstücksgrenze → orange gezählt +- Abbruch via progress.is_canceled() → (None, 0, 0, 0) +""" + +import sys +import types +import unittest +from unittest.mock import MagicMock, patch + +# --------------------------------------------------------------------------- +# Minimales QGIS-Mock-Setup (identisch mit test_verfahrensgebiet_alkis_komplett) +# --------------------------------------------------------------------------- +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_core = types.ModuleType("qgis.core") + # Attribute vorab setzen, damit patch() sie überschreiben kann + qgis_core.QgsPointXY = None + qgis_core.QgsRectangle = None + qgis_core.QgsSpatialIndex = None + qgis_core.QgsMarkerSymbol = None + qgis_core.QgsCategorizedSymbolRenderer = None + qgis_core.QgsRendererCategory = None + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + qgis_mod.core = qgis_core + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.core"] = qgis_core + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.functions.knickpunkt_pruefung import ( + knickpunkt_pruefung, + _extrahiere_knickpunkte, + WERT_IDENTISCH, + WERT_KEIN_PTA, + WERT_FEHLENDER_KNICK, +) + +_MOD = "sn_verfahrensgebiet.functions.knickpunkt_pruefung" + + +# --------------------------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _make_layer(valid=True, crs="EPSG:25833", lid="L1"): + layer = MagicMock() + layer.isValid.return_value = valid + layer.id.return_value = lid + mock_crs = MagicMock() + mock_crs.authid.return_value = crs + layer.crs.return_value = mock_crs + layer.getFeatures.return_value = [] + return layer + + +def _make_progress(canceled_after: int = 9999): + """Progress-Mock: gibt is_canceled() nach N Aufrufen True zurück.""" + call_count = [0] + + def _is_canceled(): + call_count[0] += 1 + return call_count[0] > canceled_after + + prog = MagicMock() + prog.is_canceled.side_effect = _is_canceled + return prog + + +# --------------------------------------------------------------------------- +# Tests: _extrahiere_knickpunkte +# --------------------------------------------------------------------------- + +class TestExtrahiereKnickpunkte(unittest.TestCase): + + def test_leerer_layer_gibt_leere_liste(self): + layer = _make_layer() + layer.getFeatures.return_value = [] + result = _extrahiere_knickpunkte(layer) + self.assertEqual(result, []) + + def test_keine_gueltige_geometrie_gibt_leere_liste(self): + feat = MagicMock() + feat.geometry.return_value = None + layer = _make_layer() + layer.getFeatures.return_value = [feat] + result = _extrahiere_knickpunkte(layer) + self.assertEqual(result, []) + + def test_feature_mit_leerer_geometrie_gibt_leere_liste(self): + feat = MagicMock() + geom = MagicMock() + geom.isEmpty.return_value = True + feat.geometry.return_value = geom + layer = _make_layer() + layer.getFeatures.return_value = [feat] + result = _extrahiere_knickpunkte(layer) + self.assertEqual(result, []) + + def test_qgis_nicht_verfuegbar_gibt_leere_liste(self): + """Wenn qgis.core kein QgsPointXY hat (ImportError in Funktion), leere Liste.""" + layer = _make_layer() + feat = MagicMock() + geom = MagicMock() + geom.isEmpty.return_value = False + feat.geometry.return_value = geom + layer.getFeatures.return_value = [feat] + + # QgsGeometry.unaryUnion wirft eine Exception → leere Liste erwartet + with patch(f"{_MOD}.QgsGeometry") as mock_geom_cls: + mock_geom_cls.unaryUnion.side_effect = ImportError("kein QGIS") + result = _extrahiere_knickpunkte(layer) + self.assertEqual(result, []) + + +# --------------------------------------------------------------------------- +# Tests: knickpunkt_pruefung – Eingabevalidierung +# --------------------------------------------------------------------------- + +class TestKnickpunktPruefungInputvalidierung(unittest.TestCase): + + def test_keine_knickpunkte_gibt_none_zurueck(self): + bror = _make_layer() + bror.getFeatures.return_value = [] + result = knickpunkt_pruefung(bror, MagicMock(), None) + layer, identisch, kein_pta, fehlend = result + self.assertIsNone(layer) + self.assertEqual(identisch, 0) + self.assertEqual(kein_pta, 0) + self.assertEqual(fehlend, 0) + + def test_kein_punktort_layer_gibt_none_zurueck(self): + bror = _make_layer() + result = knickpunkt_pruefung(bror, None, None, knickpunkte=[MagicMock()]) + layer, identisch, kein_pta, fehlend = result + self.assertIsNone(layer) + + def test_abbruch_via_progress_gibt_none_zurueck(self): + bror = _make_layer() + pta = _make_layer() + prog = _make_progress(canceled_after=0) # sofort abbrechen + result = knickpunkt_pruefung( + bror, pta, None, + progress=prog, + knickpunkte=[MagicMock()], + ) + self.assertIsNone(result[0]) + + +# --------------------------------------------------------------------------- +# Tests: knickpunkt_pruefung – Klassifizierungslogik (mit QGIS-Mocks) +# --------------------------------------------------------------------------- + +class TestKnickpunktPruefungKlassifizierung(unittest.TestCase): + """Testet die Grün/Rot/Orange-Klassifizierung mit gesteuertem QGIS-Mock.""" + + def _make_pta_layer_mit_punkt(self, x: float, y: float, fid: int = 1): + """Erstellt einen PunktortTA-Layer-Mock mit einem Punkt bei (x, y).""" + layer = _make_layer(lid=f"PTA_{fid}") + geom = MagicMock() + geom.isEmpty.return_value = False + pt = MagicMock() + pt.x.return_value = x + pt.y.return_value = y + geom.asPoint.return_value = pt + feat = MagicMock() + feat.id.return_value = fid + feat.geometry.return_value = geom + layer.getFeatures.return_value = [feat] + return layer + + def _make_bror_layer(self, x: float = 0.0, y: float = 0.0): + """BROR-Layer mit einer minimalen Polygon-Geometrie.""" + layer = _make_layer() + geom = MagicMock() + geom.isEmpty.return_value = False + geom.area.return_value = 10000.0 + feat = MagicMock() + feat.geometry.return_value = geom + layer.getFeatures.return_value = [feat] + return layer + + @patch(f"{_MOD}.QgsGeometry") + @patch(f"{_MOD}._baue_pta_index") + @patch(f"{_MOD}._erstelle_ergebnis_layer") + @patch(f"qgis.core.QgsRectangle", create=True) + @patch(f"qgis.core.QgsPointXY", create=True) + def test_identischer_punkt_wird_gruen_gezaehlt( + self, mock_ptxy, mock_rect, mock_erstelle, mock_index, mock_geom_cls + ): + """PunktortTA exakt am Knickpunkt → identisch_count == 1.""" + # Knickpunkt bei (100, 200) + knick = MagicMock() + knick.x.return_value = 100.0 + knick.y.return_value = 200.0 + + # PunktortTA ebenfalls bei (100, 200) – exakter Treffer + pta_geom = MagicMock() + pta_geom.isEmpty.return_value = False + pta_pt = MagicMock() + pta_pt.x.return_value = 100.0 + pta_pt.y.return_value = 200.0 + pta_geom.asPoint.return_value = pta_pt + + # Spatial-Index gibt Feature-ID 1 zurück + mock_idx = MagicMock() + mock_idx.intersects.return_value = [1] + mock_index.return_value = (mock_idx, {1: pta_geom}) + + # Ergebnis-Layer + pr = MagicMock() + pr.addFeatures.return_value = True + ergebnis = MagicMock() + ergebnis.dataProvider.return_value = pr + ergebnis.isValid.return_value = True + mock_erstelle.return_value = ergebnis + + bror = self._make_bror_layer() + pta = self._make_pta_layer_mit_punkt(100.0, 200.0) + + result = knickpunkt_pruefung( + bror, pta, None, knickpunkte=[knick] + ) + _, identisch, kein_pta, _ = result + self.assertEqual(identisch, 1) + self.assertEqual(kein_pta, 0) + + @patch(f"{_MOD}.QgsGeometry") + @patch(f"{_MOD}._baue_pta_index") + @patch(f"{_MOD}._erstelle_ergebnis_layer") + @patch(f"qgis.core.QgsRectangle", create=True) + @patch(f"qgis.core.QgsPointXY", create=True) + def test_kein_pta_an_knickpunkt_wird_rot_gezaehlt( + self, mock_ptxy, mock_rect, mock_erstelle, mock_index, mock_geom_cls + ): + """Kein PunktortTA am Knickpunkt → kein_pta_count == 1.""" + knick = MagicMock() + knick.x.return_value = 100.0 + knick.y.return_value = 200.0 + + # Spatial-Index gibt leere Liste zurück → kein Treffer + mock_idx = MagicMock() + mock_idx.intersects.return_value = [] + mock_index.return_value = (mock_idx, {}) + + pr = MagicMock() + pr.addFeatures.return_value = True + ergebnis = MagicMock() + ergebnis.dataProvider.return_value = pr + ergebnis.isValid.return_value = True + mock_erstelle.return_value = ergebnis + + bror = self._make_bror_layer() + pta = self._make_pta_layer_mit_punkt(999.0, 999.0) # weit weg + + result = knickpunkt_pruefung( + bror, pta, None, knickpunkte=[knick] + ) + _, identisch, kein_pta, _ = result + self.assertEqual(identisch, 0) + self.assertEqual(kein_pta, 1) + + +# --------------------------------------------------------------------------- +# Tests: _baue_flst_vertex_set +# --------------------------------------------------------------------------- + +class TestBaueFlstVertexSet(unittest.TestCase): + + def test_none_layer_gibt_leeres_set(self): + from sn_verfahrensgebiet.functions.knickpunkt_pruefung import _baue_flst_vertex_set + result = _baue_flst_vertex_set(None, MagicMock()) + self.assertEqual(result, set()) + + def test_layer_ohne_schnitt_mit_puffer_ignoriert(self): + from sn_verfahrensgebiet.functions.knickpunkt_pruefung import _baue_flst_vertex_set + layer = _make_layer() + geom = MagicMock() + geom.isEmpty.return_value = False + geom.intersects.return_value = False # kein Schnitt + feat = MagicMock() + feat.geometry.return_value = geom + layer.getFeatures.return_value = [feat] + buffer_geom = MagicMock() + result = _baue_flst_vertex_set(layer, buffer_geom) + self.assertEqual(result, set()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verfahrensgebiet_alkis_komplett.py b/tests/test_verfahrensgebiet_alkis_komplett.py index e19d134..3048b43 100644 --- a/tests/test_verfahrensgebiet_alkis_komplett.py +++ b/tests/test_verfahrensgebiet_alkis_komplett.py @@ -17,9 +17,10 @@ Abbruchpfade: - Progress-Abbruch nach Schritt 1 → sofortiger sauberer Rückgabestatus Flächenvergleich: -- Übereinstimmung → show_info_dialog, keine Umringsprüfung -- Abweichung + Nein → kein Umringsprüfungs-Aufruf -- Abweichung + Ja → umringspruefung aufgerufen, Ergebnislayer ins Projekt +- Übereinstimmung → show_info_dialog, keine Detailprüfung +- Abweichung + abbrechen → kein Detailprüfungs-Aufruf +- Abweichung + als_vg_laden → BROR umbenannt, als enriched_layer zurückgegeben +- Abweichung + detailpruefung → lade_punktort_ta + knickpunkt_pruefung aufgerufen Cleanup: - BROR-Layer wird bei Erfolg NICHT entfernt @@ -141,8 +142,10 @@ _PATCH_GET_VAR = f"{_MOD}.get_variable" _PATCH_PROGRESS = f"{_MOD}.create_progress_dialog" _PATCH_VERGLEICHE = f"{_MOD}._vergleiche_flaechen" _PATCH_SHOW_INFO = f"{_MOD}.show_info_dialog" -_PATCH_ASK_YES_NO = f"{_MOD}.ask_yes_no" -_PATCH_UMRING = f"{_MOD}.umringspruefung" +_PATCH_ASK_DIALOG = f"{_MOD}.ask_detailpruefung_oder_vg_laden" +_PATCH_GP_ALKIS = f"{_MOD}.lade_punktort_ta" +_PATCH_KNICK = f"{_MOD}.knickpunkt_pruefung" +_PATCH_EXTRAHIERE = f"{_MOD}._extrahiere_knickpunkte" # --------------------------------------------------------------------------- @@ -448,12 +451,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): mock_vkz.assert_called_once_with(enriched, "") # ------------------------------------------------------------------ # - # Flächenvergleich – Abweichung + Nein + # Flächenvergleich – Abweichung + Abbrechen # ------------------------------------------------------------------ # @patch(_PATCH_PROGRESS) - @patch(_PATCH_UMRING) - @patch(_PATCH_ASK_YES_NO) + @patch(_PATCH_KNICK) + @patch(_PATCH_ASK_DIALOG) @patch(_PATCH_VERGLEICHE) @patch(_PATCH_VKZ) @patch(_PATCH_GET_VAR) @@ -463,11 +466,11 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_GEM) @patch(_PATCH_FLST) @patch(_PATCH_VG_ALKIS) - def test_flaechen_abweichung_nein_kein_umring( + def test_flaechen_abweichung_abbrechen_kein_detailcheck( self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, - mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_ask, mock_umring, mock_prog, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_ask, mock_knick, mock_prog, ): - """Abweichung + Nein → umringspruefung wird NICHT aufgerufen.""" + """Abweichung + Abbrechen → knickpunkt_pruefung wird NICHT aufgerufen.""" mock_proj.instance.return_value = _DummyProject() vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") flst_layer = _make_layer(name="Flurstücke") @@ -480,23 +483,22 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): mock_erstelle.return_value = (enriched, 3, 0) mock_get_var.return_value = "" mock_vergl.return_value = (1000.0, 800.0, False) - mock_ask.return_value = False + mock_ask.return_value = "abbrechen" mock_prog.return_value = _make_progress_mock() result_enriched, _, _, status = verfahrensgebiet_alkis_komplett(None) - mock_umring.assert_not_called() + mock_knick.assert_not_called() self.assertIs(result_enriched, enriched) self.assertEqual(status, LoadStatus.FIRST) # ------------------------------------------------------------------ # - # Flächenvergleich – Abweichung + Ja → Umringsprüfung + # Flächenvergleich – Abweichung + Als VG laden # ------------------------------------------------------------------ # @patch(_PATCH_PROGRESS) - @patch(_PATCH_SHOW_INFO) - @patch(_PATCH_UMRING) - @patch(_PATCH_ASK_YES_NO) + @patch(_PATCH_KNICK) + @patch(_PATCH_ASK_DIALOG) @patch(_PATCH_VERGLEICHE) @patch(_PATCH_VKZ) @patch(_PATCH_GET_VAR) @@ -506,20 +508,18 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_GEM) @patch(_PATCH_FLST) @patch(_PATCH_VG_ALKIS) - def test_flaechen_abweichung_ja_startet_umringspruefung( + def test_flaechen_abweichung_als_vg_laden( self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, - mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_ask, - mock_umring, mock_info, mock_prog, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_ask, mock_knick, mock_prog, ): - """Abweichung + Ja → umringspruefung aufgerufen, Ergebnislayer ins Projekt, - Abschlussmeldung mit Konfliktanzahl.""" + """Abweichung + Als VG laden → BROR-Layer wird umbenannt und als + enriched_layer zurückgegeben; flst_layer und bror_layer sind None.""" proj_inst = _DummyProject() mock_proj.instance.return_value = proj_inst - vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") - flst_layer = _make_layer(name="Flurstücke") - enriched = _make_layer(name="Verfahrensgebiet", valid=True) - ergebnis_layer = _make_layer(name="Umringsprüfung", lid="UMRING_L") + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)", lid="BROR_VG") + flst_layer = _make_layer(name="Flurstücke") + enriched = _make_layer(name="Verfahrensgebiet", valid=True) mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) mock_gem.return_value = [] @@ -528,17 +528,79 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): mock_erstelle.return_value = (enriched, 3, 0) mock_get_var.return_value = "" mock_vergl.return_value = (1000.0, 800.0, False) - mock_ask.return_value = True - mock_umring.return_value = (ergebnis_layer, 4) + mock_ask.return_value = "als_vg_laden" mock_prog.return_value = _make_progress_mock() + result_enriched, result_flst, result_bror, status = verfahrensgebiet_alkis_komplett(None) + + mock_knick.assert_not_called() + # BROR-Layer wird als enriched_layer zurückgegeben + self.assertIs(result_enriched, vg_layer) + self.assertIsNone(result_flst) + self.assertIsNone(result_bror) + self.assertEqual(status, LoadStatus.FIRST) + # Name wurde auf VERF_LAYER_NAME geändert + vg_layer.setName.assert_called() + + # ------------------------------------------------------------------ # + # Flächenvergleich – Abweichung + Detailprüfung + # ------------------------------------------------------------------ # + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_SHOW_INFO) + @patch(_PATCH_EXTRAHIERE) + @patch(_PATCH_KNICK) + @patch(_PATCH_GP_ALKIS) + @patch(_PATCH_ASK_DIALOG) + @patch(_PATCH_VERGLEICHE) + @patch(_PATCH_VKZ) + @patch(_PATCH_GET_VAR) + @patch(_PATCH_ERSTELLE) + @patch(_PATCH_SELECT) + @patch(_PATCH_PROJECT) + @patch(_PATCH_GEM) + @patch(_PATCH_FLST) + @patch(_PATCH_VG_ALKIS) + def test_flaechen_abweichung_detailpruefung_startet_knickpruefung( + self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, + mock_erstelle, mock_get_var, mock_vkz, mock_vergl, mock_ask, + mock_gp, mock_knick, mock_extrahiere, mock_info, mock_prog, + ): + """Abweichung + Detailprüfung → lade_punktort_ta + knickpunkt_pruefung + aufgerufen, Ergebnislayer ins Projekt, Abschlussmeldung mit Zählung.""" + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + flst_layer = _make_layer(name="Flurstücke") + enriched = _make_layer(name="Verfahrensgebiet", valid=True) + punktort_layer = _make_layer(name="PunktortTA", lid="PTA_L") + ergebnis_layer = _make_layer(name="Knickpunktprüfung", lid="KNICK_L") + + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_gem.return_value = [] + mock_flst.return_value = (flst_layer, LoadStatus.FIRST) + mock_select.return_value = 3 + mock_erstelle.return_value = (enriched, 3, 0) + mock_get_var.return_value = "" + mock_vergl.return_value = (1000.0, 800.0, False) + mock_ask.return_value = "detailpruefung" + mock_extrahiere.return_value = [MagicMock(), MagicMock(), MagicMock()] # 3 Knickpunkte + mock_gp.return_value = punktort_layer + mock_knick.return_value = (ergebnis_layer, 2, 1, 0) + mock_prog.return_value = _make_progress_mock() + result_enriched, _, _, status = verfahrensgebiet_alkis_komplett(None) - mock_umring.assert_called_once() + mock_gp.assert_called_once() + mock_knick.assert_called_once() self.assertIn(ergebnis_layer, proj_inst.added) mock_info.assert_called_once() - self.assertIn("4", mock_info.call_args[0][1]) - self.assertIn("Konfliktstellen", mock_info.call_args[0][1]) + # Abschlussmeldung muss Zahlen enthalten + info_text = mock_info.call_args[0][1] + self.assertIn("3", info_text) # Anzahl Knickpunkte + self.assertIn("2", info_text) # identisch + self.assertIn("1", info_text) # kein_pta self.assertEqual(status, LoadStatus.FIRST) # ------------------------------------------------------------------ # diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index aa7830c..c306f04 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -166,8 +166,9 @@ class WorkingTab(QWidget): if bror_layer and bror_layer.isValid(): _setze_bror_stil(bror_layer) - # Enriched VG-Layer ins Projekt - project.addMapLayer(enriched_layer) + # Enriched VG-Layer ins Projekt (ggf. bereits registriert, z.B. bei "als VG laden") + if not project.mapLayer(enriched_layer.id()): + project.addMapLayer(enriched_layer) apply_style(enriched_layer, "verfahrensgebiet.qml") iface.mapCanvas().setExtent(enriched_layer.extent()) iface.mapCanvas().refresh() From 5e3261a8de547798304d239653f9a49c1a1a671d Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 5 May 2026 19:57:35 +0200 Subject: [PATCH 16/22] fortschrittsanzeige grenzpunkte kommt zeitiger --- ui/tabs/working_tab.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index c306f04..914249e 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -8,7 +8,7 @@ from sn_basis.functions import Qgis, QgsProject #QgsMessageLog from sn_basis.functions.qgisui_wrapper import QgsMapLayerComboBox from sn_basis.functions.qgiscore_wrapper import QgsMapLayerProxyModel from sn_basis.functions.verfahrensgebiet_manager import get_verfahrensgebiet_layer_id, set_verfahrensgebiet_layer_id -from sn_basis.functions.dialog_wrapper import show_info_dialog +from sn_basis.functions.dialog_wrapper import show_info_dialog, create_progress_dialog from sn_basis.modules.Pruefmanager import Pruefmanager from sn_basis.modules.pruef_ergebnis import pruef_ergebnis from qgis.utils import iface @@ -411,9 +411,20 @@ class WorkingTab(QWidget): Die Gemarkungsschlüssel werden per Spatial-Join aus der Geometrie des aktuell gewählten Verfahrensgebiet-Layers abgeleitet. """ + # ------------------------------------------------------------------ # + # Schritt 0: Fortschrittsdialog sofort anzeigen # + # ------------------------------------------------------------------ # + # 4 Vorbereitungsschritte (Geometrie, Gemarkungen, Hits, Laden) – + # die genaue Schrittzahl wird in grenzpunkte_gemarkungsweise verfeinert. + prog = create_progress_dialog( + 4, "Grenzpunkte aus ALKIS laden", "Verfahrensgebiet prüfen …" + ) + prog.set_value(0) + # Verfahrensgebiet-Layer aus dem Dropdown holen vg_layer = self.verf_layer_combo.currentLayer() if not vg_layer or not vg_layer.isValid(): + prog.close() warning( "Grenzpunkte", "Kein Verfahrensgebiet-Layer gewählt. Bitte zuerst ein Verfahrensgebiet laden.", @@ -424,6 +435,7 @@ class WorkingTab(QWidget): # Geometrie des ersten Features als Suchgeometrie feats = list(vg_layer.getFeatures()) if not feats: + prog.close() warning("Grenzpunkte", "Verfahrensgebiet-Layer enthält keine Features.", duration=5) return @@ -432,14 +444,21 @@ class WorkingTab(QWidget): vg_geom = QgsGeometry.unaryUnion([f.geometry() for f in feats if f.hasGeometry()]) if not vg_geom or vg_geom.isEmpty(): + prog.close() warning("Grenzpunkte", "Verfahrensgebiet-Layer hat keine Geometrie.", duration=5) return + prog.set_label("Gemarkungsschlüssel per WFS ermitteln …") + prog.set_value(1) + if prog.is_canceled(): + return + # Gemarkungsschlüssel per Spatial-Join ermitteln (identisch zu VG-Workflow) schluessel_namen = gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom) schluessel_liste = [nr for nr, _ in schluessel_namen] if not schluessel_liste: + prog.close() warning( "Grenzpunkte", "Keine Gemarkungen für das Verfahrensgebiet gefunden. " @@ -448,7 +467,13 @@ class WorkingTab(QWidget): ) return + prog.set_label(f"{len(schluessel_liste)} Gemarkung(en) gefunden – Grenzpunkte laden …") + prog.set_value(2) + if prog.is_canceled(): + return + self._set_busy(self.btn_grenz_alkis, True, "Grenzpunkte aus ALKIS laden") + prog.close() # Vorbereitungs-Dialog schließen; grenzpunkte_gemarkungsweise öffnet eigenen try: joined_layer = grenzpunkte_gemarkungsweise(self, schluessel_liste, vg_geom=vg_geom) From a829716810cda866ff9a9288d6b9ef816c87f11d Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 5 May 2026 21:17:19 +0200 Subject: [PATCH 17/22] =?UTF-8?q?Statisitik=20Umringsgrenzpunkte=20erg?= =?UTF-8?q?=C3=A4nzt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/grenzpunkt_alkis.py | 27 +- functions/umringstatistik_grenzpunkte.py | 373 +++++++++++++++++++++++ ui/tabs/working_tab.py | 47 ++- 3 files changed, 443 insertions(+), 4 deletions(-) create mode 100644 functions/umringstatistik_grenzpunkte.py diff --git a/functions/grenzpunkt_alkis.py b/functions/grenzpunkt_alkis.py index 452e5f9..0885cd7 100644 --- a/functions/grenzpunkt_alkis.py +++ b/functions/grenzpunkt_alkis.py @@ -227,6 +227,7 @@ def _parse_alkis_gml_memory_layer( feature_local_name: str, layer_name: str, geom_prop: Optional[str] = "position", + extract_qualitaet: bool = False, ) -> Optional[object]: """Parst ALKIS-GML-Bytes und erstellt einen QGIS Memory-Layer. @@ -243,6 +244,11 @@ def _parse_alkis_gml_memory_layer( Anzeigename des resultierenden Memory-Layers. geom_prop : Lokaler Name des Geometrie-Attributs (``None`` = kein Geometrie-Layer). + extract_qualitaet : + Wenn ``True``, wird das verschachtelte Attribut + ``qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe`` aus + ``adv:qualitaetsangaben/adv:genauigkeitsstufe`` extrahiert. + Nur für ``AX_PunktortTA`` sinnvoll. Returns ------- @@ -271,12 +277,18 @@ def _parse_alkis_gml_memory_layer( # Felder aus ALLEN Features ermitteln (2-Pass), damit optionale Felder # wie "istTeilVon" nicht verloren gehen, wenn sie im ersten Feature fehlen. + _QUALITAET_CONTAINER = "qualitaetsangaben" + _QUALITAET_FELD = "qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe" feld_namen: List[str] = ["gml_id"] for feat_el in feat_elems: for child in feat_el: local = child.tag.split("}")[-1] if "}" in child.tag else child.tag + if extract_qualitaet and local == _QUALITAET_CONTAINER: + continue # Container wird durch verschachteltes Einzelfeld ersetzt if local not in feld_namen and local != geom_prop: feld_namen.append(local) + if extract_qualitaet and _QUALITAET_FELD not in feld_namen: + feld_namen.append(_QUALITAET_FELD) # Memory-Layer anlegen mem_uri = f"Point?crs=EPSG:25833" if geom_prop else "none" @@ -312,6 +324,15 @@ def _parse_alkis_gml_memory_layer( for child in feat_elem: local = child.tag.split("}")[-1] if "}" in child.tag else child.tag + if extract_qualitaet and local == _QUALITAET_CONTAINER: + # Verschachtelten Genauigkeitswert extrahieren + qual_el = child.find(f".//{{{_ADV}}}genauigkeitsstufe") + if qual_el is not None and qual_el.text: + try: + qgs_feat[_QUALITAET_FELD] = qual_el.text.strip() + except Exception: + pass + continue if local == geom_prop or local not in feld_namen: continue href = child.get(f"{{{_XLINK}}}href", "") @@ -781,7 +802,7 @@ def _lade_punktort_ta_batches_per_gemarkung( duration=4, ) continue - tmp = _parse_alkis_gml_memory_layer(_gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position") + tmp = _parse_alkis_gml_memory_layer(_gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position", extract_qualitaet=True) if not tmp: warning( "Grenzpunkte (Diagnose)", @@ -1010,7 +1031,7 @@ def lade_punktort_ta( continue tmp = _parse_alkis_gml_memory_layer( - _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position" + _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position", extract_qualitaet=True ) if not tmp: continue @@ -1401,7 +1422,7 @@ def grenzpunkte_gemarkungsweise( continue tmp = _parse_alkis_gml_memory_layer( - _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position" + _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position", extract_qualitaet=True ) if not tmp: continue diff --git a/functions/umringstatistik_grenzpunkte.py b/functions/umringstatistik_grenzpunkte.py new file mode 100644 index 0000000..3fe98e8 --- /dev/null +++ b/functions/umringstatistik_grenzpunkte.py @@ -0,0 +1,373 @@ +"""sn_verfahrensgebiet/functions/umringstatistik_grenzpunkte.py – Umringstatistik Grenzpunkte. + +Extrahiert aus einem vorhandenen ``Grenzpunkte (ALKIS)``-Layer alle Punkte, +die auf dem Umring des Verfahrensgebiets liegen, und klassifiziert sie in +drei Kategorien: + +1. **FGP** (grün): ``gp_festgestellterGrenzpunkt == "true"`` +2. **<3cm** (gelb): ``gp_festgestellterGrenzpunkt != "true"`` + UND ``qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe == 2100`` +3. **>3cm** (rot): ``gp_festgestellterGrenzpunkt != "true"`` + UND ``qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe > 2100`` +4. **unbekannt** (grau): keiner der obigen Fälle + +Der Umring-Test verwendet einen Abstandsschwellwert von :data:`UMRING_TOLERANZ_M`. + +**Rückgabe:** Memory-Layer ``Umringstatistik Grenzpunkte`` oder ``None`` bei Fehler. +""" +from __future__ import annotations + +from typing import Optional + +from sn_basis.functions import QgsProject +from sn_basis.functions.qt_wrapper import QMessageBox +from sn_basis.functions.dialog_wrapper import create_progress_dialog +from sn_basis.functions.message_wrapper import warning +from sn_basis.functions.qgiscore_wrapper import ( + QgsVectorLayer, + QgsFeature, + QgsField, + QgsGeometry, + polygon_zu_linie, +) +from sn_basis.functions.qt_wrapper import QVariant + +# --------------------------------------------------------------------------- +# Konstanten +# --------------------------------------------------------------------------- + +#: Anzeigename des Ergebnis-Layers. +ERGEBNIS_LAYER_NAME = "Umringstatistik Grenzpunkte" + +#: Feldname für die Klassifikationskategorie im Ergebnis-Layer. +FELD_KATEGORIE = "kategorie" + +#: Feldname für ``gp_festgestellterGrenzpunkt`` im Grenzpunkte(ALKIS)-Layer. +FELD_FESTGESTELLT = "gp_festgestellterGrenzpunkt" + +#: Feldname für das Genauigkeitsattribut im Grenzpunkte(ALKIS)-Layer. +FELD_GENAUIGKEIT = "qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe" + +#: Feldname für die GML-ID im Grenzpunkte(ALKIS)-Layer. +FELD_GML_ID = "gml_id" + +#: Kategoriewert: festgestellter Grenzpunkt. +KATEGORIE_FGP = "FGP" +#: Kategoriewert: Lagegenauigkeit ≤ 3 cm (Gütestufe 2100). +KATEGORIE_KLEIN = "<3cm" +#: Kategoriewert: Lagegenauigkeit > 3 cm (schlechtere Gütestufe). +KATEGORIE_GROSS = ">3cm" +#: Kategoriewert: Kategorie konnte nicht bestimmt werden. +KATEGORIE_UNBEKANNT = "unbekannt" + +#: Maximaler Abstand (m) eines Punktes vom Umring, um als „auf dem Umring" zu gelten. +UMRING_TOLERANZ_M: float = 0.1 + +#: Grenzwert für ``qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe`` (3-cm-Schwelle). +GENAUIGKEIT_GRENZWERT: int = 2100 + + +# --------------------------------------------------------------------------- +# Interne Hilfsfunktionen +# --------------------------------------------------------------------------- + +def _setze_kategorien_symbologie(layer) -> None: + """Wendet Kategoriesymbologie an: grün / gelb / rot / grau. + + Wird bei fehlendem QGIS-Kontext still ignoriert. + """ + try: + from qgis.core import ( # type: ignore[import] + QgsCategorizedSymbolRenderer, + QgsRendererCategory, + QgsMarkerSymbol, + ) + + def _marker(color: str, size: str = "2.5") -> object: + return QgsMarkerSymbol.createSimple( + {"color": color, "size": size, "outline_style": "no"} + ) + + categories = [ + QgsRendererCategory( + KATEGORIE_FGP, + _marker("0,180,0,255"), + f"{KATEGORIE_FGP} – festgestellter Grenzpunkt", + ), + QgsRendererCategory( + KATEGORIE_KLEIN, + _marker("255,220,0,255"), + f"{KATEGORIE_KLEIN} – Gütestufe {GENAUIGKEIT_GRENZWERT}", + ), + QgsRendererCategory( + KATEGORIE_GROSS, + _marker("220,0,0,255"), + f"{KATEGORIE_GROSS} – Gütestufe > {GENAUIGKEIT_GRENZWERT}", + ), + QgsRendererCategory( + KATEGORIE_UNBEKANNT, + _marker("128,128,128,255"), + f"{KATEGORIE_UNBEKANNT} – Kategorie nicht bestimmbar", + ), + ] + renderer = QgsCategorizedSymbolRenderer(FELD_KATEGORIE, categories) + layer.setRenderer(renderer) + except Exception: + pass # Kein QGIS-Kontext (Tests) → Standarddarstellung behalten + + +def _klassifiziere(feat) -> str: + """Bestimmt die Kategorie eines Grenzpunkts anhand seiner Attribute. + + Parameters + ---------- + feat : + Feature aus dem Grenzpunkte(ALKIS)-Layer. + + Returns + ------- + str + Einer der Kategorienwerte :data:`KATEGORIE_FGP`, :data:`KATEGORIE_KLEIN`, + :data:`KATEGORIE_GROSS` oder :data:`KATEGORIE_UNBEKANNT`. + """ + # festgestellterGrenzpunkt + try: + festgestellt_raw = feat[FELD_FESTGESTELLT] + festgestellt = str(festgestellt_raw).lower().strip() == "true" if festgestellt_raw else False + except Exception: + festgestellt = False + + if festgestellt: + return KATEGORIE_FGP + + # Genauigkeitsstufe auslesen + try: + gen_raw = feat[FELD_GENAUIGKEIT] + genauigkeit = int(str(gen_raw).strip()) if gen_raw else None + except (ValueError, TypeError, Exception): + genauigkeit = None + + if genauigkeit is None: + return KATEGORIE_UNBEKANNT + if genauigkeit == GENAUIGKEIT_GRENZWERT: + return KATEGORIE_KLEIN + if genauigkeit > GENAUIGKEIT_GRENZWERT: + return KATEGORIE_GROSS + # Genauigkeit < 2100 (besser als 3 cm, aber nicht festgestellt) + return KATEGORIE_UNBEKANNT + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- + +def umringstatistik_grenzpunkte( + tab_widget, + grenzpunkte_layer, + vg_layer, +) -> Optional[object]: + """Klassifiziert Grenzpunkte auf dem VG-Umring nach Lagegenauigkeit. + + Ablauf: + + 1. VG-Geometrie aus ``vg_layer`` berechnen → Umringslinie. + 2. Alle Features aus ``grenzpunkte_layer`` auf Abstand ≤ :data:`UMRING_TOLERANZ_M` + zum Umring prüfen. + 3. Treffer klassifizieren (FGP / <3cm / >3cm / unbekannt). + 4. Ergebnis-Memory-Layer aufbauen und Kategoriesymbologie setzen. + 5. Layer ins Projekt einfügen, Zusammenfassungs-MessageBox anzeigen. + + Parameters + ---------- + tab_widget : + Eltern-Widget für Dialoge. + grenzpunkte_layer : + Geladener ``Grenzpunkte (ALKIS)``-Layer mit Attributen ``gp_festgestellterGrenzpunkt`` + und ``qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe``. + vg_layer : + Verfahrensgebiet-Layer (Polygon, EPSG:25833). + + Returns + ------- + QgsVectorLayer | None + Ergebnis-Memory-Layer oder ``None`` bei Fehler/Abbruch. + """ + project = QgsProject.instance() + prog = create_progress_dialog(5, "Umringstatistik Grenzpunkte", "Schritt 1/5: VG-Umring berechnen …") + abgeschlossen = False + + try: + prog.set_value(0) + if prog.is_canceled(): + return None + + # ------------------------------------------------------------------ + # Schritt 1: VG-Umring berechnen + # ------------------------------------------------------------------ + feats_vg = [f for f in vg_layer.getFeatures() if f.hasGeometry() and not f.geometry().isEmpty()] + if not feats_vg: + prog.close() + warning("Umringstatistik", "Verfahrensgebiet-Layer enthält keine Geometrien.", duration=6) + return None + + vg_geom = QgsGeometry.unaryUnion([f.geometry() for f in feats_vg]) + if vg_geom is None or vg_geom.isEmpty(): + prog.close() + warning("Umringstatistik", "Verfahrensgebiet-Geometrie konnte nicht vereint werden.", duration=6) + return None + + umring_geom = polygon_zu_linie(vg_geom) + if umring_geom is None or umring_geom.isEmpty(): + prog.close() + warning("Umringstatistik", "Umringslinie konnte aus der VG-Geometrie nicht extrahiert werden.", duration=6) + return None + + prog.set_value(1) + if prog.is_canceled(): + return None + + # ------------------------------------------------------------------ + # Schritt 2: Grenzpunkte auf dem Umring filtern + # ------------------------------------------------------------------ + prog.set_label("Schritt 2/5: Grenzpunkte auf Umring prüfen …") + + n_gesamt = grenzpunkte_layer.featureCount() + treffer: list = [] + + for i, feat in enumerate(grenzpunkte_layer.getFeatures()): + if i % 200 == 0: + prog.set_label( + f"Schritt 2/5: Grenzpunkte prüfen … ({i}/{n_gesamt})" + ) + if prog.is_canceled(): + return None + + if not feat.hasGeometry() or feat.geometry().isEmpty(): + continue + try: + dist = feat.geometry().distance(umring_geom) + except Exception: + continue + if dist <= UMRING_TOLERANZ_M: + treffer.append(feat) + + prog.set_value(2) + if prog.is_canceled(): + return None + + if not treffer: + prog.close() + QMessageBox.information( + tab_widget, + "Umringstatistik Grenzpunkte", + "Es wurden keine Grenzpunkte auf dem Umring des Verfahrensgebiets gefunden.\n\n" + f"Geprüft: {n_gesamt} Grenzpunkte\n" + f"Toleranz: {UMRING_TOLERANZ_M} m", + ) + return None + + # ------------------------------------------------------------------ + # Schritt 3: Klassifizieren + # ------------------------------------------------------------------ + prog.set_label(f"Schritt 3/5: {len(treffer)} Treffer klassifizieren …") + + n_fgp = 0 + n_klein = 0 + n_gross = 0 + n_unbekannt = 0 + + klassifiziert: list = [] # [(feat, kategorie), ...] + for feat in treffer: + kat = _klassifiziere(feat) + klassifiziert.append((feat, kat)) + if kat == KATEGORIE_FGP: + n_fgp += 1 + elif kat == KATEGORIE_KLEIN: + n_klein += 1 + elif kat == KATEGORIE_GROSS: + n_gross += 1 + else: + n_unbekannt += 1 + + prog.set_value(3) + if prog.is_canceled(): + return None + + # ------------------------------------------------------------------ + # Schritt 4: Ergebnis-Layer aufbauen + # ------------------------------------------------------------------ + prog.set_label("Schritt 4/5: Ergebnis-Layer aufbauen …") + + crs_authid = vg_layer.crs().authid() or "EPSG:25833" + result_layer = QgsVectorLayer( + f"Point?crs={crs_authid}", ERGEBNIS_LAYER_NAME, "memory" + ) + pr = result_layer.dataProvider() + + felder = [ + QgsField(FELD_KATEGORIE, QVariant.String), + QgsField(FELD_GML_ID, QVariant.String), + QgsField(FELD_FESTGESTELLT, QVariant.String), + QgsField(FELD_GENAUIGKEIT, QVariant.String), + ] + pr.addAttributes(felder) + result_layer.updateFields() + + neue_features = [] + for feat, kat in klassifiziert: + new_f = QgsFeature(result_layer.fields()) + new_f.setGeometry(feat.geometry()) + + new_f[FELD_KATEGORIE] = kat + + for src_feld in (FELD_GML_ID, FELD_FESTGESTELLT, FELD_GENAUIGKEIT): + try: + new_f[src_feld] = feat[src_feld] + except Exception: + pass + + neue_features.append(new_f) + + pr.addFeatures(neue_features) + result_layer.updateExtents() + _setze_kategorien_symbologie(result_layer) + + prog.set_value(4) + if prog.is_canceled(): + return None + + # ------------------------------------------------------------------ + # Schritt 5: Layer ins Projekt + Ergebnis-MessageBox + # ------------------------------------------------------------------ + prog.set_label("Schritt 5/5: Layer ins Projekt einfügen …") + + # Bestehenden Ergebnis-Layer ggf. ersetzen + vorhandene = project.mapLayersByName(ERGEBNIS_LAYER_NAME) + for lyr in vorhandene: + project.removeMapLayer(lyr.id()) + + project.addMapLayer(result_layer) + + prog.set_value(5) + abgeschlossen = True + prog.close() + + n_umring = len(treffer) + QMessageBox.information( + tab_widget, + "Umringstatistik Grenzpunkte", + f"Grenzpunkte auf dem Umring: {n_umring}\n" + f"\n" + f" FGP (festgestellt): {n_fgp}\n" + f" < 3 cm (Gütestufe {GENAUIGKEIT_GRENZWERT}): {n_klein}\n" + f" > 3 cm (schlechtere Güte): {n_gross}\n" + f" Unbekannt: {n_unbekannt}\n" + f"\n" + f"(Geprüft: {n_gesamt} Grenzpunkte, Toleranz: {UMRING_TOLERANZ_M} m)", + ) + + return result_layer + + finally: + if not abgeschlossen: + prog.close() diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 914249e..46332c9 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -16,8 +16,9 @@ from qgis.utils import iface from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import verfahrensgebiet_alkis_komplett -from sn_verfahrensgebiet.functions.grenzpunkt_alkis import grenzpunkte_gemarkungsweise +from sn_verfahrensgebiet.functions.grenzpunkt_alkis import grenzpunkte_gemarkungsweise, JOINED_LAYER_NAME from sn_verfahrensgebiet.functions.gemarkung_alkis import gemarkung_schluessel_und_namen_von_vg_geometrie +from sn_verfahrensgebiet.functions.umringstatistik_grenzpunkte import umringstatistik_grenzpunkte from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( pruefe_flurstueck_layer, pruefe_auswahl, @@ -104,6 +105,11 @@ class WorkingTab(QWidget): row_grenz_buttons.addWidget(self.btn_grenz_alkis) layout.addLayout(row_grenz_buttons) + row_umring_buttons = QHBoxLayout() + self.btn_umring_statistik = QPushButton("Umringstatistik Grenzpunkte") + row_umring_buttons.addWidget(self.btn_umring_statistik) + layout.addLayout(row_umring_buttons) + # Abschnitt 3: Verfahrensgebiet erstellen row_verf_erstellen_header = QHBoxLayout() lbl_verf_erstellen = QLabel("Verfahrensgebiet erstellen") @@ -128,6 +134,7 @@ class WorkingTab(QWidget): self.btn_verf_alkis.clicked.connect(self.handle_verf_alkis) self.btn_flurst_alkis.clicked.connect(self.handle_flurst_alkis) self.btn_grenz_alkis.clicked.connect(self.handle_grenz_alkis) + self.btn_umring_statistik.clicked.connect(self.handle_umringstatistik) self.btn_verf_aus_auswahl.clicked.connect(self.handle_verf_aus_auswahl) self.btn_verf_aus_liste.clicked.connect(self.handle_verf_aus_liste) # self.btn_verf_shape.clicked.connect(self.handle_verf_shape) @@ -488,6 +495,44 @@ class WorkingTab(QWidget): finally: self._set_busy(self.btn_grenz_alkis, False, "Grenzpunkte aus ALKIS laden") + def handle_umringstatistik(self) -> None: + """Klassifiziert Grenzpunkte auf dem VG-Umring nach Lagegenauigkeit. + + Sucht den vorhandenen ``Grenzpunkte (ALKIS)``-Layer im Projekt und + kombiniert ihn mit dem gewählten Verfahrensgebiet-Layer, um die + Umringstatistik zu berechnen. + """ + # 1. Grenzpunkte-Layer aus dem Projekt holen + project = QgsProject.instance() + gp_layers = project.mapLayersByName(JOINED_LAYER_NAME) + if not gp_layers: + warning( + "Umringstatistik", + f"Kein Layer '{JOINED_LAYER_NAME}' im Projekt gefunden. " + "Bitte zuerst 'Grenzpunkte aus ALKIS laden' ausführen.", + duration=8, + ) + return + grenzpunkte_layer = gp_layers[0] + + # 2. Verfahrensgebiet-Layer aus Dropdown + vg_layer = self.verf_layer_combo.currentLayer() + if not vg_layer or not vg_layer.isValid(): + warning( + "Umringstatistik", + "Kein Verfahrensgebiet-Layer gewählt. Bitte zuerst ein Verfahrensgebiet laden.", + duration=6, + ) + return + + self._set_busy(self.btn_umring_statistik, True, "Umringstatistik Grenzpunkte") + try: + result_layer = umringstatistik_grenzpunkte(self, grenzpunkte_layer, vg_layer) + if result_layer and result_layer.isValid(): + iface.mapCanvas().refresh() + finally: + self._set_busy(self.btn_umring_statistik, False, "Umringstatistik Grenzpunkte") + # --------------------------------------------------------------------------- # Modul-Hilfsfunktionen From cee02ae9357b32606e7c3c5c794bd5c6c4fbacef Mon Sep 17 00:00:00 2001 From: release-bot Date: Wed, 6 May 2026 08:39:31 +0000 Subject: [PATCH 18/22] Release 26.5.1-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 0d83248..3db9553 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.5.1-unstable: +--- Version 26.4.6-unstable: --- Version 26.4.5-unstable: diff --git a/metadata.txt b/metadata.txt index 3c78650..6d659b7 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.4.6-unstable +version=26.5.1-unstable name=LNO Sachsen | Verfahrensgebiet description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet" author=Daniel Helbig, Michael Otto From 28cfc751211b2499325dcb805259a5eb1472de80 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 6 May 2026 11:43:44 +0200 Subject: [PATCH 19/22] proxy-fix Co-authored-by: Copilot --- functions/grenzpunkt_alkis.py | 120 ++++++++++--------- functions/verfahrensgebiet_alkis_komplett.py | 11 ++ ui/tabs/working_tab.py | 15 ++- 3 files changed, 89 insertions(+), 57 deletions(-) diff --git a/functions/grenzpunkt_alkis.py b/functions/grenzpunkt_alkis.py index 0885cd7..e7c5f6e 100644 --- a/functions/grenzpunkt_alkis.py +++ b/functions/grenzpunkt_alkis.py @@ -146,6 +146,9 @@ def _wfs_get_gml(url: str, typename: str, extra_params: dict) -> Optional[bytes] GET-Requests mit ``BBOX=``-KVP-Parameter oder ResourceId-``FILTER`` (nur ``xmlns:fes``, kein GML) werden dagegen akzeptiert. + **Proxy-Unterstützung:** Verwendet ``QgsBlockingNetworkRequest``, das automatisch + die in QGIS konfigurierten Proxy-Einstellungen berücksichtigt. + Parameters ---------- url : @@ -161,10 +164,10 @@ def _wfs_get_gml(url: str, typename: str, extra_params: dict) -> Optional[bytes] bytes | None Rohe GML-Antwort oder ``None`` bei Fehler. """ - import urllib.request import urllib.parse - import urllib.error - from qgis.core import QgsMessageLog, Qgis + from qgis.core import QgsMessageLog, Qgis, QgsBlockingNetworkRequest + from qgis.PyQt.QtNetwork import QNetworkRequest + from qgis.PyQt.QtCore import QUrl params = { 'SERVICE': 'WFS', @@ -177,26 +180,34 @@ def _wfs_get_gml(url: str, typename: str, extra_params: dict) -> Optional[bytes] full_url = url + '?' + urllib.parse.urlencode(params) - try: - with urllib.request.urlopen(full_url, timeout=120) as resp: - gml_bytes = resp.read() - except urllib.error.HTTPError as exc: - body_preview = exc.read(200).decode('utf-8', errors='replace') + req = QNetworkRequest(QUrl(full_url)) + req.setTransferTimeout(120_000) + blocker = QgsBlockingNetworkRequest() + err = blocker.get(req) + + if err != QgsBlockingNetworkRequest.ErrorCode.NoError: + msg = blocker.errorMessage() QgsMessageLog.logMessage( - f"WFS GET HTTP {exc.code} ({typename}): {exc.reason}\n" + f"WFS GET fehlgeschlagen ({typename}): {msg}\nURL: {full_url[:300]}", + "sn_verfahrensgebiet", + Qgis.MessageLevel.Critical, + ) + warning("WFS GET", f"Netzwerk-/Proxy-Fehler: {msg}", duration=10) + return None + + reply = blocker.reply() + http_status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + gml_bytes = bytes(reply.content()) + + if http_status is not None and http_status >= 400: + body_preview = gml_bytes[:200].decode('utf-8', errors='replace') + QgsMessageLog.logMessage( + f"WFS GET HTTP {http_status} ({typename})\n" f"URL: {full_url[:300]}\nAntwort: {body_preview}", "sn_verfahrensgebiet", Qgis.MessageLevel.Critical, ) - warning("WFS GET", f"HTTP {exc.code}: {exc.reason}", duration=10) - return None - except Exception as exc: - QgsMessageLog.logMessage( - f"WFS GET fehlgeschlagen ({typename}): {exc}\nURL: {full_url[:300]}", - "sn_verfahrensgebiet", - Qgis.MessageLevel.Critical, - ) - warning("WFS GET", f"Netzwerkfehler: {exc}", duration=10) + warning("WFS GET", f"HTTP {http_status}", duration=10) return None if not gml_bytes: @@ -477,11 +488,7 @@ def _lade_grenzpunkte_als_dict( ) _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, GRENZPUNKT_TYPENAME, {'FILTER': filter_xml}) if not _gml_bytes: - warning( - "Grenzpunkte (Diagnose)", - f"Batch {i + 1}/{anzahl_batches}: GET-Anfrage fehlgeschlagen – Batch wird übersprungen.", - duration=4, - ) + return {}, [] else: tmp = _parse_alkis_gml_memory_layer(_gml_bytes, "AX_Grenzpunkt", "_TmpGP", geom_prop=None) if not tmp: @@ -704,9 +711,12 @@ def _lade_gemarkung_extents_mit_puffer( return extents -def _wfs_hits_count(url: str, typename: str, extra_params: dict) -> int: +def _wfs_hits_count(url: str, typename: str, extra_params: dict) -> Optional[int]: """Fragt die Anzahl der Features per ``resultType=hits`` ab. + **Proxy-Unterstützung:** Verwendet ``QgsBlockingNetworkRequest``, das automatisch + die in QGIS konfigurierten Proxy-Einstellungen berücksichtigt. + Parameters ---------- url : @@ -714,17 +724,21 @@ def _wfs_hits_count(url: str, typename: str, extra_params: dict) -> int: typename : Vollqualifizierter Feature-Typ. extra_params : - Zus\u00e4tzliche KVP-Parameter (z.B. ``{'BBOX': '...'}``). + Zusätzliche KVP-Parameter (z.B. ``{'BBOX': '...'}``). Returns ------- - int - Anzahl der Features oder ``-1`` wenn nicht ermittelbar. + Optional[int] + Anzahl der Features, + ``-1`` wenn der Server antwortete aber ``numberMatched='unknown'`` lieferte + (gültiger WFS-Fallback, kein Fehler), oder + ``None`` bei Netzwerk-/Proxy-Fehler (Aufrufer soll abbrechen). """ - import urllib.request import urllib.parse - import urllib.error import xml.etree.ElementTree as ET + from qgis.core import QgsMessageLog, Qgis, QgsBlockingNetworkRequest + from qgis.PyQt.QtNetwork import QNetworkRequest + from qgis.PyQt.QtCore import QUrl params = { 'SERVICE': 'WFS', @@ -736,11 +750,22 @@ def _wfs_hits_count(url: str, typename: str, extra_params: dict) -> int: params.update(extra_params) full_url = url + '?' + urllib.parse.urlencode(params) - try: - with urllib.request.urlopen(full_url, timeout=30) as resp: - gml_bytes = resp.read() - except Exception: - return -1 + + req = QNetworkRequest(QUrl(full_url)) + req.setTransferTimeout(30_000) + blocker = QgsBlockingNetworkRequest() + err = blocker.get(req) + + if err != QgsBlockingNetworkRequest.ErrorCode.NoError: + msg = blocker.errorMessage() + QgsMessageLog.logMessage( + f"WFS Hits-Abfrage fehlgeschlagen ({typename}): {msg}\nURL: {full_url[:300]}", + "sn_verfahrensgebiet", + Qgis.MessageLevel.Critical, + ) + return None + + gml_bytes = bytes(blocker.reply().content()) try: root = ET.fromstring(gml_bytes) @@ -796,12 +821,7 @@ def _lade_punktort_ta_batches_per_gemarkung( ) _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, {'BBOX': _punktort_ta_bbox_param(bbox)}) if not _gml_bytes: - warning( - "Grenzpunkte (Diagnose)", - f"Gemarkung {_gemarkungsnummer}: GET-Anfrage fehlgeschlagen – wird übersprungen.", - duration=4, - ) - continue + return None tmp = _parse_alkis_gml_memory_layer(_gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position", extract_qualitaet=True) if not tmp: warning( @@ -997,6 +1017,8 @@ def lade_punktort_ta( bbox_param = _punktort_ta_bbox_param(bbox) n_hits = _wfs_hits_count(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, {'BBOX': bbox_param}) + if n_hits is None: + return None n_batches = max(1, math.ceil(n_hits / _BBOX_BATCH)) if n_hits > 0 else 1 info( @@ -1022,13 +1044,7 @@ def lade_punktort_ta( _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, extra) if not _gml_bytes: - warning( - "Grenzpunkte (Diagnose)", - f"Gemarkung {gemarkungsnummer} Batch {b_idx + 1}: " - f"GET-Anfrage fehlgeschlagen – übersprungen.", - duration=4, - ) - continue + return None tmp = _parse_alkis_gml_memory_layer( _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position", extract_qualitaet=True @@ -1343,6 +1359,8 @@ def grenzpunkte_gemarkungsweise( for gemarkungsnummer, bbox in extents.items(): bbox_param = _punktort_ta_bbox_param(bbox) n_hits = _wfs_hits_count(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, {'BBOX': bbox_param}) + if n_hits is None: + return None hits_pro_gemarkung[gemarkungsnummer] = n_hits batches = max(1, math.ceil(n_hits / _BBOX_BATCH)) if n_hits > 0 else 1 n_pta_batches_gesamt += batches @@ -1413,13 +1431,7 @@ def grenzpunkte_gemarkungsweise( _gml_bytes = _wfs_get_gml(ALKIS_SF_URL, PUNKTORT_TA_TYPENAME, extra) if not _gml_bytes: - warning( - "Grenzpunkte (Diagnose)", - f"Gemarkung {gemarkungsnummer} Batch {b_idx + 1}: " - f"GET-Anfrage fehlgeschlagen – Batch wird übersprungen.", - duration=6, - ) - continue + return None tmp = _parse_alkis_gml_memory_layer( _gml_bytes, "AX_PunktortTA", "_TmpPTA", geom_prop="position", extract_qualitaet=True diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py index 2d98739..7de8af0 100644 --- a/functions/verfahrensgebiet_alkis_komplett.py +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -488,6 +488,17 @@ def verfahrensgebiet_alkis_komplett( if punktort_layer is None: detail_progress.close() abgeschlossen = True + if not detail_progress.is_canceled(): + QMessageBox.critical( + tab_widget, + "WFS-Fehler", + "Die Grenzpunktdaten (PunktortTA) konnten nicht geladen werden.\n\n" + "Mögliche Ursache: Netzwerk- oder Proxy-Fehler.\n" + "Bitte prüfen Sie die QGIS-Proxy-Einstellungen " + "(Einstellungen -> Optionen -> Netzwerk) und den Log-Eintrag " + "unter Ansicht -> Bedienfelder -> Protokollnachrichten " + "(Kategorie 'sn_verfahrensgebiet').", + ) return enriched_layer, flst_layer, alkis_vg_layer, vg_status # Schritte 2…Ende: Knickpunktprüfung diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 46332c9..dd0a1f5 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -487,10 +487,19 @@ class WorkingTab(QWidget): if joined_layer and joined_layer.isValid(): iface.mapCanvas().refresh() self.setze_haken(self.haken_grenz, True) - success( - "Grenzpunkte", + QMessageBox.information( + self, + "Grenzpunkte geladen", f"Grenzpunkte erfolgreich geladen ({joined_layer.featureCount()} Objekte).", - duration=5, + ) + elif joined_layer is None: + error( + "Grenzpunkte", + "Laden fehlgeschlagen – WFS- oder Netzwerkfehler. " + "Details unter Ansicht -> Bedienfelder -> Protokollnachrichten " + "(Kategorie 'sn_verfahrensgebiet'). " + "Bitte prüfen Sie ggf. die Proxy-Einstellungen.", + duration=0, ) finally: self._set_busy(self.btn_grenz_alkis, False, "Grenzpunkte aus ALKIS laden") From aa6166e652763100c798a850bc4a26dab5c52875 Mon Sep 17 00:00:00 2001 From: release-bot Date: Wed, 6 May 2026 09:50:27 +0000 Subject: [PATCH 20/22] Release 26.5.2-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index 3db9553..c1491fa 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.5.2-unstable: +--- Version 26.5.1-unstable: --- Version 26.4.6-unstable: diff --git a/metadata.txt b/metadata.txt index 6d659b7..2079902 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.5.1-unstable +version=26.5.2-unstable name=LNO Sachsen | Verfahrensgebiet description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet" author=Daniel Helbig, Michael Otto From 4bbdb96bcddec5ee76852404f3ecfaa473b019fa Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 6 May 2026 11:57:50 +0200 Subject: [PATCH 21/22] grenzpunkttolerant auf 1mm gesetzt --- functions/umringstatistik_grenzpunkte.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/umringstatistik_grenzpunkte.py b/functions/umringstatistik_grenzpunkte.py index 3fe98e8..027f828 100644 --- a/functions/umringstatistik_grenzpunkte.py +++ b/functions/umringstatistik_grenzpunkte.py @@ -61,7 +61,7 @@ KATEGORIE_GROSS = ">3cm" KATEGORIE_UNBEKANNT = "unbekannt" #: Maximaler Abstand (m) eines Punktes vom Umring, um als „auf dem Umring" zu gelten. -UMRING_TOLERANZ_M: float = 0.1 +UMRING_TOLERANZ_M: float = 0.01 #: Grenzwert für ``qualitaetsangaben_ax_dqpunktort_genauigkeitsstufe`` (3-cm-Schwelle). GENAUIGKEIT_GRENZWERT: int = 2100 From 3b47e41bbe4b2e17c47bfaaf119a2b082b687878 Mon Sep 17 00:00:00 2001 From: release-bot Date: Wed, 6 May 2026 10:17:14 +0000 Subject: [PATCH 22/22] Release 26.5.3-unstable --- changelog.txt | 2 ++ metadata.txt | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/changelog.txt b/changelog.txt index c1491fa..f6e09ed 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,6 @@ --- +Version 26.5.3-unstable: +--- Version 26.5.2-unstable: --- Version 26.5.1-unstable: diff --git a/metadata.txt b/metadata.txt index 2079902..5019ecf 100644 --- a/metadata.txt +++ b/metadata.txt @@ -1,5 +1,5 @@ [general] -version=26.5.2-unstable +version=26.5.3-unstable name=LNO Sachsen | Verfahrensgebiet description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet" author=Daniel Helbig, Michael Otto