diff --git a/changelog.txt b/changelog.txt index c948f93..5d6ccae 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,4 @@ +- Bugfix: Flurstücke aus ALKIS an bestehenden Layer anhängen überspringt jetzt bereits vorhandene Flurstücke (Abgleich über flurstueckskennzeichen), statt sie doppelt zu laden – betrifft sowohl den Dialog in "Flurstücke laden" als auch den Button "aus gewählten Gemarkungen laden" (#28) - Fortschrittsdialog beim Laden aus ALKIS schließt jetzt zuverlässig bei jedem Abbruch (Bugfix) - Fortschrittsanzeige beim Laden aus ALKIS in zwei Dialoge aufgeteilt (Suche / eigentlicher Ladevorgang), inkl. eigenem Suchschritt - Mehrfachauswahl bei mehreren BauRaumOderBodenordnungsrecht-Treffern zur Verfahrensnummer (gemeinsames Laden/Verschmelzen mehrerer Objekte) diff --git a/functions/flurstueck_alkis.py b/functions/flurstueck_alkis.py index 4f2c199..1064fdf 100644 --- a/functions/flurstueck_alkis.py +++ b/functions/flurstueck_alkis.py @@ -37,6 +37,7 @@ from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus ALKIS_SF_URL = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs" FLST_TYPENAME = "adv:AX_Flurstueck" FLST_LAYER_NAME = "Flurstücke" +FELD_FLURSTUECKSKENNZEICHEN = "flurstueckskennzeichen" def _check_existing_flurstueck_layer(tab_widget): @@ -74,6 +75,24 @@ def _check_existing_flurstueck_layer(tab_widget): return None, LoadStatus.NONE +def vorhandene_kennzeichen(layer) -> set: + """Liefert die ``flurstueckskennzeichen``-Werte, die ``layer`` bereits enthält. + + Fehlt das Feld, wird eine leere Menge zurückgegeben (kein Abgleich möglich). + """ + kennzeichen: set = set() + if layer.fields().indexFromName(FELD_FLURSTUECKSKENNZEICHEN) < 0: + return kennzeichen + for feat in layer.getFeatures(): + try: + kz = str(feat[FELD_FLURSTUECKSKENNZEICHEN] or "").strip() + except Exception: + kz = "" + if kz: + kennzeichen.add(kz) + return kennzeichen + + def lade_flurstuecke_per_gemarkung(schluessel_liste: List[str]): """Lädt Flurstücke für mehrere Gemarkungen, eine WFS-Anfrage pro Schlüssel. @@ -250,7 +269,22 @@ def flurstueck_alkis( # --- Anhängen an vorhandenen Layer ------------------------------------ if status == LoadStatus.APPEND and append_target is not None: - append_target.dataProvider().addFeatures(list(layer.getFeatures())) + alle_feats = list(layer.getFeatures()) + neue_feats = alle_feats + if layer.fields().indexFromName(FELD_FLURSTUECKSKENNZEICHEN) >= 0: + vorhandene = vorhandene_kennzeichen(append_target) + neue_feats = [ + f for f in alle_feats + if str(f[FELD_FLURSTUECKSKENNZEICHEN] or "").strip() not in vorhandene + ] + uebersprungen = len(alle_feats) - len(neue_feats) + if uebersprungen: + _info( + "Flurstücke", + f"{uebersprungen} bereits vorhandene Flurstücke übersprungen.", + duration=3, + ) + append_target.dataProvider().addFeatures(neue_feats) append_target.updateExtents() append_target.triggerRepaint() return append_target, LoadStatus.APPEND diff --git a/tests/test_flurstueck_alkis.py b/tests/test_flurstueck_alkis.py index 63471dd..57640b8 100644 --- a/tests/test_flurstueck_alkis.py +++ b/tests/test_flurstueck_alkis.py @@ -30,7 +30,11 @@ if "qgis" not in sys.modules: 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.flurstueck_alkis import ( + flurstueck_alkis, + FLST_LAYER_NAME, + FELD_FLURSTUECKSKENNZEICHEN, +) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus @@ -308,5 +312,116 @@ class TestFlurstueckAlkis(unittest.TestCase): self.assertEqual(status, LoadStatus.FIRST) + +# --------------------------------------------------------------------------- +# Dummies für den Anhängen-Deduplizierungstest (#28) +# --------------------------------------------------------------------------- + +class _DummyFlstFeature: + def __init__(self, kennzeichen): + self._kennzeichen = kennzeichen + + def __getitem__(self, key): + if key == FELD_FLURSTUECKSKENNZEICHEN: + return self._kennzeichen + return None + + +class _DummyFlstFields: + def __init__(self, hat_kennzeichen_feld: bool): + self._hat_feld = hat_kennzeichen_feld + + def indexFromName(self, name): + if name == FELD_FLURSTUECKSKENNZEICHEN and self._hat_feld: + return 0 + return -1 + + +class _DummyFlstLayer: + """Flurstück-Layer-Dummy mit Feature-Liste und aufzeichnendem ``dataProvider``.""" + + def __init__(self, features, hat_kennzeichen_feld: bool = True): + self._features = list(features) + self._hat_feld = hat_kennzeichen_feld + self.added = None + + def getFeatures(self): + return list(self._features) + + def fields(self): + return _DummyFlstFields(self._hat_feld) + + def dataProvider(self): + layer = self + + class _Provider: + def addFeatures(self, feats): + layer.added = list(feats) + layer._features.extend(feats) + return True + + return _Provider() + + def updateExtents(self): + pass + + def triggerRepaint(self): + pass + + def isValid(self): + return True + + def featureCount(self): + return len(self._features) + + +class TestFlurstueckAlkisAnhaengenDedup(unittest.TestCase): + """Anhängen an einen bestehenden Flurstück-Layer (Issue #28).""" + + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.lade_flurstuecke_per_gemarkung") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis._check_existing_flurstueck_layer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_append_ueberspringt_bereits_vorhandene_flurstuecke( + self, mock_ask_text, mock_check, mock_lade + ): + """Prüft, dass beim Anhängen bereits vorhandene flurstueckskennzeichen nicht erneut hinzugefügt werden.""" + mock_ask_text.return_value = ("0001, 0002", True) + bestehend = _DummyFlstLayer([_DummyFlstFeature("110001_0001__00001")]) + neu_geladen = _DummyFlstLayer([ + _DummyFlstFeature("110001_0001__00001"), # bereits im Layer vorhanden + _DummyFlstFeature("110001_0002__00007"), # noch nicht vorhanden + ]) + mock_check.return_value = (bestehend, LoadStatus.APPEND) + mock_lade.return_value = neu_geladen + + layer, status = flurstueck_alkis(None) + + self.assertEqual(status, LoadStatus.APPEND) + self.assertIs(layer, bestehend) + self.assertEqual(len(bestehend.added), 1) + self.assertEqual(bestehend.added[0][FELD_FLURSTUECKSKENNZEICHEN], "110001_0002__00007") + + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.lade_flurstuecke_per_gemarkung") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis._check_existing_flurstueck_layer") + @patch("sn_verfahrensgebiet.functions.flurstueck_alkis.ask_text") + def test_append_ohne_kennzeichen_feld_haengt_alles_an( + self, mock_ask_text, mock_check, mock_lade + ): + """Prüft, dass ohne flurstueckskennzeichen-Feld weiterhin alle Features angehängt werden (Rückfallverhalten).""" + mock_ask_text.return_value = ("0001", True) + bestehend = _DummyFlstLayer([_DummyFlstFeature("110001_0001__00001")], hat_kennzeichen_feld=False) + neu_geladen = _DummyFlstLayer( + [_DummyFlstFeature("110001_0001__00001"), _DummyFlstFeature("110001_0002__00007")], + hat_kennzeichen_feld=False, + ) + mock_check.return_value = (bestehend, LoadStatus.APPEND) + mock_lade.return_value = neu_geladen + + layer, status = flurstueck_alkis(None) + + self.assertEqual(status, LoadStatus.APPEND) + self.assertEqual(len(bestehend.added), 2) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_working_tab.py b/tests/test_working_tab.py index 9a9e1c3..d69566a 100644 --- a/tests/test_working_tab.py +++ b/tests/test_working_tab.py @@ -15,6 +15,7 @@ if "qgis" not in sys.modules: sys.modules["qgis.utils"] = qgis_utils from sn_verfahrensgebiet.ui.tabs.working_tab import WorkingTab, LoadStatus +from sn_verfahrensgebiet.functions.flurstueck_alkis import FLST_LAYER_NAME, FELD_FLURSTUECKSKENNZEICHEN class _DummyLayer: @@ -81,6 +82,92 @@ class _DummyCombo: self.layer = layer +class _DummyGemFeature: + def __init__(self, schluessel, name): + self._schluessel = schluessel + self._name = name + + def __getitem__(self, key): + if key == "schluessel": + return self._schluessel + if key == "ave_name": + return self._name + return None + + +class _DummyGemarkungenLayer: + def __init__(self, selected): + self._selected = list(selected) + + def selectedFeatureCount(self): + return len(self._selected) + + def selectedFeatures(self): + return list(self._selected) + + +class _DummyFlstFeatureWT: + def __init__(self, kennzeichen): + self._kennzeichen = kennzeichen + + def __getitem__(self, key): + if key == FELD_FLURSTUECKSKENNZEICHEN: + return self._kennzeichen + return None + + +class _DummyFlstFieldsWT: + def indexFromName(self, name): + return 0 if name == FELD_FLURSTUECKSKENNZEICHEN else -1 + + +class _DummyTempLayer: + """Rückgabe von lade_flurstuecke_per_gemarkung in Tests.""" + + def __init__(self, features): + self._features = list(features) + + def getFeatures(self): + return list(self._features) + + def fields(self): + return _DummyFlstFieldsWT() + + def isValid(self): + return True + + def featureCount(self): + return len(self._features) + + +class _DummyExistingFlstLayer: + """Bereits im Projekt vorhandener Flurstück-Layer, zeichnet addFeatures() auf.""" + + def __init__(self, features): + self._features = list(features) + self.added = None + + def getFeatures(self): + return list(self._features) + + def fields(self): + return _DummyFlstFieldsWT() + + def dataProvider(self): + layer = self + + class _Provider: + def addFeatures(self, feats): + layer.added = list(feats) + layer._features.extend(feats) + return True + + return _Provider() + + def updateExtents(self): + pass + + class TestWorkingTab(unittest.TestCase): def _make_tab(self): @@ -96,6 +183,7 @@ class TestWorkingTab(unittest.TestCase): tab.haken_flurst = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None) tab.haken_gem = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None) tab.haken_grenz = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None) + tab.btn_flurst_aus_gemarkungen = _DummyButton() tab.setze_haken = lambda *_args, **_kwargs: None # Pruefmanager-Mock: verarbeite() wird im Test ggf. überschrieben tab.pruefmanager = types.SimpleNamespace( @@ -175,6 +263,55 @@ class TestWorkingTab(unittest.TestCase): mock_set_layer_id.assert_called_once_with("") + # ------------------------------------------------------------------ + # handle_flurst_aus_gemarkungen + # ------------------------------------------------------------------ + + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.success") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.lade_flurstuecke_per_gemarkung") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QMessageBox") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance") + @patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface") + def test_handle_flurst_aus_gemarkungen_anhaengen_ueberspringt_vorhandene( + self, mock_iface, mock_project_instance, mock_msgbox_cls, mock_lade, mock_success + ): + """Anhängen über den Gemarkungen-Button überspringt bereits vorhandene flurstueckskennzeichen (#28).""" + tab = self._make_tab() + mock_iface.mapCanvas.return_value = _DummyCanvas() + + gem_feat = _DummyGemFeature("110001", "Musterstadt") + gem_layer = _DummyGemarkungenLayer([gem_feat]) + bestehend = _DummyExistingFlstLayer([_DummyFlstFeatureWT("110001_0001__00001")]) + + def map_layers_by_name(name): + if name == "Gemarkungen": + return [gem_layer] + if name == FLST_LAYER_NAME: + return [bestehend] + return [] + + project = types.SimpleNamespace(mapLayersByName=map_layers_by_name) + mock_project_instance.return_value = project + + btn_anhaengen = object() + msg_instance = mock_msgbox_cls.return_value + msg_instance.addButton.side_effect = ( + lambda text, role: btn_anhaengen if text == "Anhängen" else object() + ) + msg_instance.clickedButton.return_value = btn_anhaengen + + neu_geladen = _DummyTempLayer([ + _DummyFlstFeatureWT("110001_0001__00001"), # bereits vorhanden + _DummyFlstFeatureWT("110001_0002__00007"), # neu + ]) + mock_lade.return_value = neu_geladen + + tab.handle_flurst_aus_gemarkungen() + + self.assertEqual(len(bestehend.added), 1) + self.assertEqual(bestehend.added[0][FELD_FLURSTUECKSKENNZEICHEN], "110001_0002__00007") + mock_success.assert_called_once() + # ------------------------------------------------------------------ # handle_grenz_alkis # ------------------------------------------------------------------ diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 3f19417..d3e4382 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -18,8 +18,10 @@ from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus from sn_verfahrensgebiet.functions.flurstueck_alkis import ( flurstueck_alkis, lade_flurstuecke_per_gemarkung, + vorhandene_kennzeichen, ALKIS_SF_URL, FLST_LAYER_NAME, + FELD_FLURSTUECKSKENNZEICHEN, ) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import ( verfahrensgebiet_alkis_komplett, @@ -517,11 +519,22 @@ class WorkingTab(QWidget): duration=0, ) return - existing_flst[0].dataProvider().addFeatures(list(temp_layer.getFeatures())) + neue_feats = list(temp_layer.getFeatures()) + if temp_layer.fields().indexFromName(FELD_FLURSTUECKSKENNZEICHEN) >= 0: + vorhandene = vorhandene_kennzeichen(existing_flst[0]) + neue_feats = [ + f for f in neue_feats + if str(f[FELD_FLURSTUECKSKENNZEICHEN] or "").strip() not in vorhandene + ] + existing_flst[0].dataProvider().addFeatures(neue_feats) existing_flst[0].updateExtents() iface.mapCanvas().refresh() self.setze_haken(self.haken_flurst, True) - success("Flurstücke", "Features wurden an den vorhandenen Layer angehängt.", duration=5) + success( + "Flurstücke", + f"{len(neue_feats)} neue Features wurden an den vorhandenen Layer angehängt.", + duration=5, + ) return # else: Überschreiben → fall-through zum flurstueck_alkis(ohne_dialog=True)-Aufruf