From 148d4dfa15729c7f39e628d28d8a60ede1b433ba Mon Sep 17 00:00:00 2001 From: Michael Otto Date: Thu, 27 Aug 2026 15:14:07 +0200 Subject: [PATCH 1/4] BROR-Mehrfachauswahl, zweistufiger Fortschrittsdialog, Landkreis-Fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fortschrittsdialog beim Laden aus ALKIS schließt jetzt zuverlässig bei jedem Abbruch (progress.close() zentral im finally-Block). - Fortschrittsanzeige in zwei Dialoge aufgeteilt: Suche (1 Schritt) läuft und schließt separat, bevor der eigentliche Workflow (Gemarkung/ Flurstücke/Vergleich, 6 Schritte) startet – auch während des BROR- Auswahldialogs läuft kein störender Hintergrund-Fortschrittsbalken mehr. - verfahrensgebiet_alkis() in Suche-/Auswahl-Phasen aufgeteilt, damit mehrere gefundene BauRaumOderBodenordnungsrecht-Objekte gemeinsam ausgewählt und verschmolzen werden können (öffentliche Signatur unverändert). - Neu: Landkreis-weite Suche als Fallback, wenn keine Verfahrensnummer gesetzt ist (Landkreiskennzahl aus Projektvariable), mit Mehrfach- auswahl/"Alle auswählen" und "Nur laden"-Option (überspringt den restlichen Workflow). Gruppierung der Auswahl nach VKZ, damit je Verfahren ein eigenes Feature entsteht statt eines Gesamt-Blobs. - Tests entsprechend angepasst/ergänzt (29 Tests in test_verfahrensgebiet_alkis_komplett.py, davon 3 neu für den Landkreis-Fallback). --- changelog.txt | 5 + functions/verfahrensgebiet_alkis.py | 217 ++++++++++++++++-- functions/verfahrensgebiet_alkis_komplett.py | 165 +++++++++---- tests/test_verfahrensgebiet_alkis.py | 37 ++- tests/test_verfahrensgebiet_alkis_komplett.py | 130 +++++++++-- 5 files changed, 457 insertions(+), 97 deletions(-) diff --git a/changelog.txt b/changelog.txt index d6aebb1..df8d39d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,8 @@ +- 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) +- Neu: Laden aller/mehrerer Objekte eines ganzen Landkreises als Fallback, wenn keine Verfahrensnummer gesetzt ist (Gruppierung je Verfahren, optional "Nur laden" ohne weiteren Ablauf) +--- Version 26.7.1-unstable: - Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt --- diff --git a/functions/verfahrensgebiet_alkis.py b/functions/verfahrensgebiet_alkis.py index be56b8e..a58332c 100644 --- a/functions/verfahrensgebiet_alkis.py +++ b/functions/verfahrensgebiet_alkis.py @@ -9,7 +9,7 @@ from sn_basis.functions import ( from sn_basis.functions import QMessageBox from sn_basis.functions.qt_wrapper import QVariant from sn_basis.functions.variable_wrapper import get_variable -from sn_basis.functions.dialog_wrapper import ask_bror_kombination +from sn_basis.functions.dialog_wrapper import ask_bror_kombinationen, ask_landkreis_objekte alkis_sf_url = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs" typename = "adv:AX_BauRaumOderBodenordnungsrecht" @@ -28,7 +28,7 @@ BROR_ATTRIBUTE_FELDER = [ class LoadStatus(Enum): """Status eines Layer-Ladevorgangs. - + Attributes: NONE: Nichts geladen FIRST: Erstmalig geladen @@ -44,10 +44,10 @@ 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 """ @@ -72,32 +72,36 @@ def _check_existing_layer(tab_widget): return existing_layers[0], LoadStatus.KEEP -def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False): - """Lädt Verfahrensgebiet aus ALKIS-WFS basierend auf Verfahrensnummer. +def _suche_verfahrensgebiet_features(tab_widget, skip_existing_check: bool = False): + """Phase 1 ("Suchen"): vorhandenen Layer prüfen, WFS abfragen, nach + (adv_name, bezeichnung) gruppieren. Args: tab_widget: Parent-Widget für Dialoge und Meldungen + skip_existing_check: Überspringt die Nachfrage bei bereits vorhandenem + Layer (immer neu laden). Returns: - 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. + Tuple[Optional[QgsVectorLayer], LoadStatus, dict, Optional[str]]: + ``(fertig_layer, status, kombinationen, crs)``. + Bei ``KEEP`` oder Fehler/Abbruch ist ``fertig_layer`` bereits das + Endergebnis (ggf. ``None``) und ``kombinationen`` leer – Phase 2 + entfällt dann. Bei Erfolg mit mindestens einer Kombination ist + ``fertig_layer`` ``None`` und ``kombinationen``/``crs`` sind für + Phase 2 (:func:`_baue_layer_aus_kombinationen`) befüllt. """ 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, {}, None # Vorhandenen Layer prüfen (überspringbar für die Shape-Pipeline) status = LoadStatus.FIRST if not skip_existing_check: existing, status = _check_existing_layer(tab_widget) if status == LoadStatus.KEEP: - return existing, status, [] + return existing, status, {}, None # WFS mit Filter laden – nur erste 5 Stellen der VKZ für den Vergleich verwenden, # da die Daten im Dienst ggf. abweichende Suffixe führen. @@ -111,13 +115,13 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False): if not temp_layer.isValid(): QMessageBox.critical(tab_widget, "Fehler", "Layer konnte nicht geladen werden.") - return None, LoadStatus.NONE, [] + return None, LoadStatus.NONE, {}, 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, {}, None # Objekte nach (adv_name, bezeichnung) gruppieren kombinationen: dict[tuple[str, str], list] = {} @@ -133,16 +137,32 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False): kombinationen[key] = [] kombinationen[key].append(feat) + return None, status, kombinationen, temp_layer.crs().authid() + + +def _baue_layer_aus_kombinationen(tab_widget, kombinationen: dict, crs: str, status: "LoadStatus"): + """Phase 2 ("Auswählen + Laden"): bei mehreren Kombinationen Auswahldialog + zeigen, Geometrien der gewählten Kombination(en) verschmelzen, Memory-Layer bauen. + + Args: + tab_widget: Parent-Widget für Dialoge und Meldungen + kombinationen: Ergebnis aus :func:`_suche_verfahrensgebiet_features` + crs: CRS-Authid aus :func:`_suche_verfahrensgebiet_features` + status: Lade-Status aus Phase 1 (FIRST/RELOAD), wird durchgereicht + + Returns: + Tuple[Optional[QgsVectorLayer], LoadStatus, List[QgsFeature]] + """ if len(kombinationen) == 1: - selected_key = next(iter(kombinationen)) - selected_feats = kombinationen[selected_key] + selected_feats = next(iter(kombinationen.values())) else: - # Mehrere Kombinationen → Nutzerauswahl + # Mehrere Kombinationen → Nutzerauswahl, ein oder mehrere Objekte + # können gemeinsam ausgewählt und geladen (verschmolzen) werden. key_list = sorted(kombinationen.keys()) - selected_key = ask_bror_kombination(tab_widget, key_list) - if selected_key is None: + selected_keys = ask_bror_kombinationen(tab_widget, key_list) + if not selected_keys: return None, LoadStatus.NONE, [] - selected_feats = kombinationen[selected_key] + selected_feats = [feat for key in selected_keys for feat in kombinationen[key]] union_geom = QgsGeometry.unaryUnion([f.geometry() for f in selected_feats]) if union_geom is None or union_geom.isEmpty(): @@ -150,7 +170,6 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False): return None, LoadStatus.NONE, [] # Memory-Layer erzeugen - crs = temp_layer.crs().authid() memory_layer = QgsVectorLayer(f"Polygon?crs={crs}", "Verfahrensgebiet", "memory") pr = memory_layer.dataProvider() pr.addAttributes([QgsField(attr, QVariant.String) for attr in BROR_ATTRIBUTE_FELDER]) @@ -180,3 +199,155 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False): # Status: FIRST oder RELOAD; selected_feats für Gemarkungsermittlung weitergeben return memory_layer, status, selected_feats + + +def _suche_landkreis_kombinationen(tab_widget, landkreis_kennzahl: str, skip_existing_check: bool = False): + """Fallback-Suche über einen ganzen Landkreis (statt einer Verfahrensnummer). + + Wie :func:`_suche_verfahrensgebiet_features`, aber mit WFS-Filter über die + Landkreiskennzahl (``bezeichnung LIKE '{landkreis_kennzahl}%'``) statt + über den 5-stelligen VKZ-Präfix. Gruppierung der Rohfeatures identisch: + nach exaktem (adv_name, bezeichnung). + + Returns: + Tuple[Optional[QgsVectorLayer], LoadStatus, dict, Optional[str]]: + Gleiche Bedeutung wie bei :func:`_suche_verfahrensgebiet_features`. + """ + status = LoadStatus.FIRST + if not skip_existing_check: + existing, status = _check_existing_layer(tab_widget) + if status == LoadStatus.KEEP: + return existing, status, {}, None + + wfs_uri_find = ( + f"url='{alkis_sf_url}' typename='{typename}' srsname='EPSG:25833' " + f"sql=SELECT * FROM AX_BauRaumOderBodenordnungsrecht " + f"WHERE bezeichnung LIKE '{landkreis_kennzahl}%'" + ) + temp_layer = QgsVectorLayer(wfs_uri_find, "TempVerfahrensgebiet", "WFS") + + if not temp_layer.isValid(): + QMessageBox.critical(tab_widget, "Fehler", "Layer konnte nicht geladen werden.") + return None, LoadStatus.NONE, {}, None + + features = list(temp_layer.getFeatures()) + if not features: + QMessageBox.critical(tab_widget, "Fehler", + f"Für Landkreis {landkreis_kennzahl} wurden keine Objekte im WFS gefunden.") + return None, LoadStatus.NONE, {}, None + + kombinationen: dict[tuple[str, str], list] = {} + for feat in features: + try: + adv_name_val = str(feat.attribute("adv_name") or "") + bezeichnung_val = str(feat.attribute("bezeichnung") or "") + except Exception: + adv_name_val = "" + bezeichnung_val = "" + key = (adv_name_val, bezeichnung_val) + if key not in kombinationen: + kombinationen[key] = [] + kombinationen[key].append(feat) + + return None, status, kombinationen, temp_layer.crs().authid() + + +def _baue_layer_aus_landkreis_auswahl(tab_widget, kombinationen: dict, crs: str, status: "LoadStatus"): + """Auswahl + Bau für den Landkreis-Fallback. + + Zeigt :func:`~sn_basis.functions.dialog_wrapper.ask_landkreis_objekte` + (Mehrfachauswahl inkl. "Alle auswählen" und "Nur laden"-Checkbox). Die + ausgewählten Kombinationen werden anhand der ersten 5 Zeichen von + ``bezeichnung`` (= die eigentliche VKZ) gruppiert; je Gruppe wird EIN + Feature erzeugt (Geometrien der Gruppe verschmolzen). Damit entsteht bei + Auswahl mehrerer/aller Objekte eines Landkreises ein Layer mit mehreren + Features – einem je tatsächlichem Verfahren, nicht ein Gesamt-Blob. + + Args: + tab_widget: Parent-Widget für Dialoge und Meldungen + kombinationen: Ergebnis aus :func:`_suche_landkreis_kombinationen` + crs: CRS-Authid aus :func:`_suche_landkreis_kombinationen` + status: Lade-Status aus Phase 1 (FIRST/RELOAD), wird durchgereicht + + Returns: + Tuple[Optional[QgsVectorLayer], LoadStatus, bool]: + ``(layer, status, nur_laden)``. + """ + key_list = sorted(kombinationen.keys()) + selected_keys, nur_laden = ask_landkreis_objekte(tab_widget, key_list) + if not selected_keys: + return None, LoadStatus.NONE, False + + # Nach VKZ (erste 5 Zeichen der bezeichnung) gruppieren + vkz_gruppen: dict[str, list] = {} + for key in selected_keys: + _, bezeichnung = key + vkz = bezeichnung[:5] + vkz_gruppen.setdefault(vkz, []).extend(kombinationen[key]) + + memory_layer = QgsVectorLayer(f"Polygon?crs={crs}", "Verfahrensgebiet", "memory") + pr = memory_layer.dataProvider() + pr.addAttributes([QgsField(attr, QVariant.String) for attr in BROR_ATTRIBUTE_FELDER]) + memory_layer.updateFields() + + neue_features = [] + for vkz, feats in sorted(vkz_gruppen.items()): + union_geom = QgsGeometry.unaryUnion([f.geometry() for f in feats]) + if union_geom is None or union_geom.isEmpty(): + continue + + first_feat = feats[0] + attrs = [] + for attr in BROR_ATTRIBUTE_FELDER: + try: + val = first_feat.attribute(attr) + if val is None: + attrs.append("") + elif type(val).__name__ == "QDate": + attrs.append(val.toString("dd.MM.yyyy")) + else: + attrs.append(str(val)) + except Exception: + attrs.append("") + + feat_out = QgsFeature() + feat_out.setGeometry(union_geom) + feat_out.setAttributes(attrs) + neue_features.append(feat_out) + + if not neue_features: + QMessageBox.critical(tab_widget, "Fehler", "Keine Geometrien zum Verschmelzen gefunden.") + return None, LoadStatus.NONE, False + + pr.addFeatures(neue_features) + memory_layer.updateExtents() + + return memory_layer, status, nur_laden + + +def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False): + """Lädt Verfahrensgebiet aus ALKIS-WFS basierend auf Verfahrensnummer. + + Args: + tab_widget: Parent-Widget für Dialoge und Meldungen + + Returns: + 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. + + Ruft intern nacheinander :func:`_suche_verfahrensgebiet_features` (Phase 1) + und ggf. :func:`_baue_layer_aus_kombinationen` (Phase 2) auf – aufgeteilt, + damit ``verfahrensgebiet_alkis_komplett()`` dazwischen einen eigenen + Fortschrittsschritt anzeigen kann. Verhalten/Signatur dieser Funktion + selbst ist unverändert. + """ + layer, status, kombinationen, crs = _suche_verfahrensgebiet_features( + tab_widget, skip_existing_check + ) + if layer is not None or not kombinationen: + return layer, status, [] + return _baue_layer_aus_kombinationen(tab_widget, kombinationen, crs, status) diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py index adca779..8e329c8 100644 --- a/functions/verfahrensgebiet_alkis_komplett.py +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -54,7 +54,10 @@ from sn_basis.functions.dialog_wrapper import ( ) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import ( - verfahrensgebiet_alkis, + _suche_verfahrensgebiet_features, + _baue_layer_aus_kombinationen, + _suche_landkreis_kombinationen, + _baue_layer_aus_landkreis_auswahl, LoadStatus, ) from sn_verfahrensgebiet.functions.gemarkung_alkis import ( @@ -258,15 +261,25 @@ def verfahrensgebiet_alkis_komplett( """Vollständiger VG-Lade-Workflow: ALKIS-VG → Gemarkungen → Flurstücke → Verschneidung → Flächenvergleich → opt. Umringsprüfung. - Schrittfolge: + Zwei Fortschrittsdialoge nacheinander: - 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. + - **Dialog 1** ("Verfahrensgebiet(e) in ALKIS ermitteln/suchen"): läuft + während ``_suche_verfahrensgebiet_features(tab_widget)`` (ALKIS-WFS + durchsuchen) und wird danach immer geschlossen – auch wenn anschließend + der BROR-Auswahldialog erscheint (kein Fortschrittsdialog läuft + währenddessen parallel zum Auswahldialog). + - **Dialog 2** (eigentlicher Workflow, 6 Schritte): startet erst, nachdem + das Verfahrensgebiet feststeht (direkt nach Dialog 1 bei eindeutigem + Treffer/KEEP, oder erst nach Bestätigung des Auswahldialogs mit OK). + + Schrittfolge in Dialog 2: + + 1. ``gemarkung_schluessel_und_namen_von_vg_geometrie(geom)`` – Schlüssel. + 2. ``flurstueck_alkis(...)`` – Flurstücke laden. + 3. ``_select_by_location(...)`` – räumliche Selektion. + 4. ``erstelle_verfahrensgebiet_aus_auswahl(...)`` – enriched Layer. + 5. ``_vergleiche_flaechen(...)`` – Flächenvergleich als Gate. + 6. Ggf. ``umringspruefung(...)`` nach Nutzerbestätigung. Der BROR-Layer (``BauRaumOderBodenordnungsrecht (ALKIS)``) bleibt bei Erfolg im Projekt. Bei Abbruch/Fehler wird er entfernt, falls er in diesem @@ -290,26 +303,74 @@ def verfahrensgebiet_alkis_komplett( alkis_vg_layer = None bror_was_added_by_us = False abgeschlossen = False + progress = None - # Fortschrittsdialog für den Basisworkflow (7 Schritte) - progress = create_progress_dialog( - 7, "Verfahrensgebiet laden", "Initialisierung …" - ) + verfahrensnummer = get_variable("verfahrensnummer") + landkreis_kennzahl = get_variable("landkreis_kennzahl") + + if not verfahrensnummer and not landkreis_kennzahl: + QMessageBox.critical( + tab_widget, + "Fehler", + "Weder eine Verfahrensnummer noch ein Landkreis ist in den " + "Projekteigenschaften eingetragen!", + ) + return None, None, None, LoadStatus.NONE + + landkreis_modus = not verfahrensnummer + + # ------------------------------------------------------------------ + # Dialog 1: Verfahrensgebiet(e) in ALKIS ermitteln/suchen + # ------------------------------------------------------------------ + if landkreis_modus: + such_label = f"Objekte in Landkreis {landkreis_kennzahl} ermitteln/suchen …" + else: + such_label = "Verfahrensgebiet(e) in ALKIS ermitteln/suchen …" + + progress_suche = create_progress_dialog(1, "Verfahrensgebiet laden", such_label) + try: + progress_suche.set_value(0) + if progress_suche.is_canceled(): + return None, None, None, LoadStatus.NONE + + if landkreis_modus: + vorab_layer, vg_status, kombinationen, crs = _suche_landkreis_kombinationen( + tab_widget, landkreis_kennzahl + ) + else: + vorab_layer, vg_status, kombinationen, crs = _suche_verfahrensgebiet_features(tab_widget) + finally: + # Dialog 1 schließt hier immer – egal ob eine Auswahl folgt oder nicht. + progress_suche.close() + + nur_laden = False try: - # ------------------------------------------------------------------ - # Schritt 1: VG aus ALKIS laden (BROR-Objekt → Memory-Layer) - # ------------------------------------------------------------------ - 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) - progress.raise_to_front() - - if vg_status == LoadStatus.NONE or not alkis_vg_layer: - return None, None, None, LoadStatus.NONE + if vorab_layer is not None or not kombinationen: + # KEEP oder Fehler/Abbruch bereits in Phase 1 entschieden + if vg_status == LoadStatus.NONE or not vorab_layer: + return None, None, None, LoadStatus.NONE + alkis_vg_layer = vorab_layer + elif landkreis_modus: + # ------------------------------------------------------------------ + # Objekt(e) des Landkreises auswählen – kein eigener Fortschritts- + # dialog währenddessen, nur der Auswahldialog selbst. + # ------------------------------------------------------------------ + alkis_vg_layer, vg_status, nur_laden = _baue_layer_aus_landkreis_auswahl( + tab_widget, kombinationen, crs, vg_status + ) + if vg_status == LoadStatus.NONE or not alkis_vg_layer: + return None, None, None, LoadStatus.NONE + else: + # ------------------------------------------------------------------ + # Verfahrensgebiet auswählen (bei mehreren Treffern) – kein eigener + # Fortschrittsdialog währenddessen, nur der Auswahldialog selbst. + # ------------------------------------------------------------------ + alkis_vg_layer, vg_status, _ = _baue_layer_aus_kombinationen( + tab_widget, kombinationen, crs, vg_status + ) + if vg_status == LoadStatus.NONE or not alkis_vg_layer: + return None, None, None, LoadStatus.NONE # BROR-Layer umbenennen, um Namenskonflikt mit enriched VG-Layer zu vermeiden alkis_vg_layer.setName(BROR_LAYER_NAME) @@ -318,12 +379,25 @@ def verfahrensgebiet_alkis_komplett( project.addMapLayer(alkis_vg_layer) bror_was_added_by_us = True - progress.set_value(1) + if nur_laden: + # Nutzer hat "Nur laden" angehakt – kein Dialog 2, kein weiterer + # Workflow (Gemarkung/Flurstücke/Vergleich). + abgeschlossen = True + return alkis_vg_layer, None, None, vg_status # ------------------------------------------------------------------ - # Schritt 2: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln + # Dialog 2: eigentlicher Fortschrittsdialog für die restlichen Schritte + # (startet erst jetzt, nach erfolgreicher Auswahl/Übernahme des VG) # ------------------------------------------------------------------ - progress.set_label("Schritt 2/6: Gemarkungsschlüssel ermitteln …") + progress = create_progress_dialog( + 6, "Verfahrensgebiet laden", "Initialisierung …" + ) + + # ------------------------------------------------------------------ + # Schritt 1: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln + # ------------------------------------------------------------------ + progress.set_label("Schritt 1/6: Gemarkungen ermitteln und laden …") + progress.set_value(0) if progress.is_canceled(): return None, None, None, LoadStatus.NONE @@ -344,12 +418,12 @@ def verfahrensgebiet_alkis_komplett( duration=5, ) - progress.set_value(2) + progress.set_value(1) # ------------------------------------------------------------------ - # Schritt 3: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt) + # Schritt 2: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt) # ------------------------------------------------------------------ - progress.set_label("Schritt 3/6: Flurstücke aus ALKIS laden …") + progress.set_label("Schritt 2/6: Flurstücke aus ALKIS laden …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE @@ -363,12 +437,12 @@ def verfahrensgebiet_alkis_komplett( if flst_status == LoadStatus.NONE or not flst_layer: return None, None, None, LoadStatus.NONE - progress.set_value(3) + progress.set_value(2) # ------------------------------------------------------------------ - # Schritt 4: Räumliche Selektion + # Schritt 3: Räumliche Selektion # ------------------------------------------------------------------ - progress.set_label("Schritt 4/6: Flurstücke räumlich selektieren …") + progress.set_label("Schritt 3/6: Flurstücke räumlich selektieren …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE @@ -383,12 +457,12 @@ def verfahrensgebiet_alkis_komplett( ) return None, flst_layer, None, LoadStatus.NONE - progress.set_value(4) + progress.set_value(3) # ------------------------------------------------------------------ - # Schritt 5: Enriched Memory-Layer aus Auswahl erzeugen + # Schritt 4: Enriched Memory-Layer aus Auswahl erzeugen # ------------------------------------------------------------------ - progress.set_label("Schritt 5/6: Verfahrensgebiet-Layer erzeugen …") + progress.set_label("Schritt 4/6: Verfahrensgebiet-Layer erzeugen …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE @@ -407,12 +481,12 @@ def verfahrensgebiet_alkis_komplett( vkz = get_variable("verfahrensnummer") or "" _fuege_vkz_feld_hinzu(enriched_layer, vkz) - progress.set_value(5) + progress.set_value(4) # ------------------------------------------------------------------ - # Schritt 6: Flächenvergleich als Gate + # Schritt 5: Flächenvergleich als Gate # ------------------------------------------------------------------ - progress.set_label("Schritt 6/6: Flächenvergleich …") + progress.set_label("Schritt 5/6: Flächenvergleich …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE @@ -421,11 +495,10 @@ def verfahrensgebiet_alkis_komplett( alkis_vg_layer, enriched_layer, toleranz ) - progress.set_value(6) + progress.set_value(5) if stimmt_ueberein: - progress.set_value(7) - progress.close() + progress.set_value(6) show_info_dialog( "Flächenvergleich", "Verfahrensgebiet mit erwarteter Geometrie erzeugt", @@ -437,7 +510,6 @@ def verfahrensgebiet_alkis_komplett( # ------------------------------------------------------------------ # Flächenabweichung → 3-Optionen-Dialog # ------------------------------------------------------------------ - progress.close() diff = abs(bror_flaeche - vg_flaeche) entscheidung = ask_detailpruefung_oder_vg_laden( tab_widget, @@ -563,6 +635,9 @@ def verfahrensgebiet_alkis_komplett( return enriched_layer, flst_layer, alkis_vg_layer, vg_status finally: + # Dialog 2 (falls erstellt) bei jedem Ausgang (Erfolg, Abbruch, Fehler) schließen + if progress is not None: + progress.close() # 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) diff --git a/tests/test_verfahrensgebiet_alkis.py b/tests/test_verfahrensgebiet_alkis.py index 9ed7c0c..d0e57c9 100644 --- a/tests/test_verfahrensgebiet_alkis.py +++ b/tests/test_verfahrensgebiet_alkis.py @@ -186,7 +186,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase): self.assertEqual(status, vg.LoadStatus.FIRST) self.assertEqual(len(feats), 1) - @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombination") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombinationen") @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)) @@ -202,7 +202,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase): temp_layer = _DummyTempLayer(valid=True, features=[feat1, feat2]) memory_layer = _DummyMemoryLayer() mock_qgsvectorlayer.side_effect = [temp_layer, memory_layer] - mock_dialog.return_value = ("ADV-A", "VKZ12/001") + mock_dialog.return_value = [("ADV-A", "VKZ12/001")] mock_feature = mock_feature_cls.return_value mock_feature.setGeometry.return_value = None @@ -216,7 +216,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase): # Nur feat1 gehört zur gewählten Kombination self.assertEqual(feats, [feat1]) - @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombination") + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombinationen") @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") @@ -229,7 +229,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase): feat2 = _DummyFeature(attrs={"adv_name": "ADV-B", "bezeichnung": "VKZ12/002"}) temp_layer = _DummyTempLayer(valid=True, features=[feat1, feat2]) mock_qgsvectorlayer.return_value = temp_layer - mock_dialog.return_value = None # Nutzer bricht ab + mock_dialog.return_value = [] # Nutzer bricht ab layer, status, feats = vg.verfahrensgebiet_alkis(tab_widget=None) @@ -238,6 +238,35 @@ class TestVerfahrensgebietAlkis(unittest.TestCase): self.assertEqual(status, vg.LoadStatus.NONE) self.assertEqual(feats, []) + @patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombinationen") + @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_multi_kombination_mehrfachauswahl( + self, mock_qgsvectorlayer, _mock_get_var, _mock_check, _mock_union, mock_feature_cls, mock_dialog + ): + """Zwei verschiedene Kombinationen → beide ausgewählt → beide Feature-Gruppen genutzt.""" + feat1 = _DummyFeature(attrs={"adv_name": "ADV-A", "bezeichnung": "VKZ12/001"}) + feat2 = _DummyFeature(attrs={"adv_name": "ADV-B", "bezeichnung": "VKZ12/002"}) + temp_layer = _DummyTempLayer(valid=True, features=[feat1, feat2]) + memory_layer = _DummyMemoryLayer() + mock_qgsvectorlayer.side_effect = [temp_layer, memory_layer] + mock_dialog.return_value = [("ADV-A", "VKZ12/001"), ("ADV-B", "VKZ12/002")] + + mock_feature = mock_feature_cls.return_value + mock_feature.setGeometry.return_value = None + mock_feature.setAttributes.return_value = None + + layer, status, feats = vg.verfahrensgebiet_alkis(tab_widget=None) + + mock_dialog.assert_called_once() + self.assertIs(layer, memory_layer) + self.assertEqual(status, vg.LoadStatus.FIRST) + self.assertEqual(feats, [feat1, feat2]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_verfahrensgebiet_alkis_komplett.py b/tests/test_verfahrensgebiet_alkis_komplett.py index 3048b43..04f4287 100644 --- a/tests/test_verfahrensgebiet_alkis_komplett.py +++ b/tests/test_verfahrensgebiet_alkis_komplett.py @@ -129,7 +129,7 @@ def _make_progress_mock(canceled=False): # --------------------------------------------------------------------------- _MOD = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett" -_PATCH_VG_ALKIS = f"{_MOD}.verfahrensgebiet_alkis" +_PATCH_VG_ALKIS = f"{_MOD}._suche_verfahrensgebiet_features" _PATCH_GEM = f"{_MOD}.gemarkung_schluessel_und_namen_von_vg_geometrie" _PATCH_FLST = f"{_MOD}.flurstueck_alkis" _PATCH_SELECT = f"{_MOD}._select_by_location" @@ -146,6 +146,8 @@ _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" +_PATCH_LANDKREIS_SUCHE = f"{_MOD}._suche_landkreis_kombinationen" +_PATCH_LANDKREIS_BAU = f"{_MOD}._baue_layer_aus_landkreis_auswahl" # --------------------------------------------------------------------------- @@ -265,9 +267,10 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_PROGRESS) @patch(_PATCH_PROJECT) @patch(_PATCH_VG_ALKIS) - def test_vg_alkis_none_aborts(self, mock_vg, mock_proj, mock_prog): + @patch(_PATCH_GET_VAR, return_value="VF-2024-001") + def test_vg_alkis_none_aborts(self, mock_get_var, 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, {}, None) mock_prog.return_value = _make_progress_mock() enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) @@ -281,10 +284,11 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_PROJECT) @patch(_PATCH_GEM) @patch(_PATCH_VG_ALKIS) - def test_flst_none_aborts(self, mock_vg, mock_gem, mock_proj, mock_prog): + @patch(_PATCH_GET_VAR, return_value="VF-2024-001") + def test_flst_none_aborts(self, mock_get_var, mock_vg, mock_gem, mock_proj, mock_prog): mock_proj.instance.return_value = _DummyProject() vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") - mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) mock_gem.return_value = [("1234", "Muster")] mock_prog.return_value = _make_progress_mock() @@ -302,13 +306,14 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_GEM) @patch(_PATCH_FLST) @patch(_PATCH_VG_ALKIS) + @patch(_PATCH_GET_VAR, return_value="VF-2024-001") def test_no_selection_returns_none_enriched( - self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_msgbox, mock_prog + self, mock_get_var, 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="BauRaumOrdnungsrecht (ALKIS)") flst_layer = _make_layer(name="Flurstücke") - mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) mock_gem.return_value = [] mock_flst.return_value = (flst_layer, LoadStatus.FIRST) mock_select.return_value = 0 @@ -330,15 +335,16 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_GEM) @patch(_PATCH_FLST) @patch(_PATCH_VG_ALKIS) + @patch(_PATCH_GET_VAR, return_value="VF-2024-001") def test_invalid_enriched_layer_returns_none( - self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_erstelle, mock_msgbox, mock_prog + self, mock_get_var, 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(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_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) mock_gem.return_value = [] mock_flst.return_value = (flst_layer, LoadStatus.FIRST) mock_select.return_value = 5 @@ -356,11 +362,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @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): + @patch(_PATCH_GET_VAR, return_value="VF-2024-001") + def test_progress_cancel_after_step1_returns_none(self, mock_get_var, 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, []) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) prog = MagicMock() prog.is_canceled.side_effect = [False, True] # 1. Aufruf False, 2. True @@ -398,7 +405,7 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): 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_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) mock_gem.return_value = [("1234", "Muster")] mock_flst.return_value = (flst_layer, LoadStatus.FIRST) mock_select.return_value = 3 @@ -437,18 +444,18 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): flst_layer = _make_layer(name="Flurstücke") enriched = _make_layer(name="Verfahrensgebiet", valid=True) - mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) 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_get_var.return_value = "VF-2024-001" 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, "") + mock_vkz.assert_called_once_with(enriched, "VF-2024-001") # ------------------------------------------------------------------ # # Flächenvergleich – Abweichung + Abbrechen @@ -476,12 +483,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): flst_layer = _make_layer(name="Flurstücke") enriched = _make_layer(name="Verfahrensgebiet", valid=True) - mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) 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_get_var.return_value = "VF-2024-001" mock_vergl.return_value = (1000.0, 800.0, False) mock_ask.return_value = "abbrechen" mock_prog.return_value = _make_progress_mock() @@ -521,12 +528,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): flst_layer = _make_layer(name="Flurstücke") enriched = _make_layer(name="Verfahrensgebiet", valid=True) - mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) 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_get_var.return_value = "VF-2024-001" mock_vergl.return_value = (1000.0, 800.0, False) mock_ask.return_value = "als_vg_laden" mock_prog.return_value = _make_progress_mock() @@ -577,12 +584,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): 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_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) 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_get_var.return_value = "VF-2024-001" mock_vergl.return_value = (1000.0, 800.0, False) mock_ask.return_value = "detailpruefung" mock_extrahiere.return_value = [MagicMock(), MagicMock(), MagicMock()] # 3 Knickpunkte @@ -630,12 +637,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): flst_layer = _make_layer(name="Flurstücke") enriched = _make_layer(name="Verfahrensgebiet", valid=True) - mock_vg.return_value = (vg_layer, LoadStatus.FIRST, []) + mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) 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_get_var.return_value = "VF-2024-001" mock_vergl.return_value = (1000.0, 1000.0, True) mock_prog.return_value = _make_progress_mock() @@ -647,15 +654,16 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_PROJECT) @patch(_PATCH_GEM) @patch(_PATCH_VG_ALKIS) + @patch(_PATCH_GET_VAR, return_value="VF-2024-001") def test_bror_layer_wird_bei_fehler_entfernt( - self, mock_vg, mock_gem, mock_proj, mock_prog + self, mock_get_var, 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_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None) mock_gem.return_value = [] mock_prog.return_value = _make_progress_mock() @@ -664,6 +672,78 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): self.assertIn("BROR_ERR", proj_inst.removed) + # ------------------------------------------------------------------ # + # Landkreis-Fallback (keine Verfahrensnummer) + # ------------------------------------------------------------------ # + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_MSGBOX) + @patch(_PATCH_GET_VAR, return_value="") + def test_kein_vkz_kein_landkreis_zeigt_fehler(self, mock_get_var, mock_msgbox, mock_prog): + """Weder Verfahrensnummer noch Landkreis gesetzt → Fehlermeldung, kein Layer.""" + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) + + self.assertIsNone(enriched) + self.assertIsNone(flst) + self.assertIsNone(bror) + self.assertEqual(status, LoadStatus.NONE) + mock_msgbox.critical.assert_called_once() + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_PROJECT) + @patch(_PATCH_LANDKREIS_BAU) + @patch(_PATCH_LANDKREIS_SUCHE) + def test_landkreis_modus_wird_bei_fehlender_vkz_verwendet( + self, mock_such, mock_bau, mock_proj, mock_prog + ): + """Keine Verfahrensnummer, aber Landkreis gesetzt → Landkreis-Suchpfad + statt VKZ-Pfad wird verwendet.""" + + def _get_var_side_effect(key, scope="project"): + return {"verfahrensnummer": "", "landkreis_kennzahl": "22"}.get(key, "") + + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + mock_such.return_value = (None, LoadStatus.FIRST, {("A", "22015"): [MagicMock()]}, "EPSG:25833") + mock_bau.return_value = (vg_layer, LoadStatus.FIRST, True) # nur_laden=True + mock_prog.return_value = _make_progress_mock() + + with patch(_PATCH_GET_VAR, side_effect=_get_var_side_effect): + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) + + mock_such.assert_called_once() + mock_bau.assert_called_once() + self.assertIs(enriched, vg_layer) + self.assertIsNone(flst) + self.assertIsNone(bror) + self.assertEqual(status, LoadStatus.FIRST) + + @patch(_PATCH_PROGRESS) + @patch(_PATCH_PROJECT) + @patch(_PATCH_LANDKREIS_BAU) + @patch(_PATCH_LANDKREIS_SUCHE) + def test_landkreis_modus_nur_laden_ueberspringt_dialog2( + self, mock_such, mock_bau, mock_proj, mock_prog + ): + """nur_laden=True → Funktion kehrt sofort zurück, Dialog 2 (Gemarkung + etc.) wird nicht gestartet.""" + + def _get_var_side_effect(key, scope="project"): + return {"verfahrensnummer": "", "landkreis_kennzahl": "22"}.get(key, "") + + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + mock_such.return_value = (None, LoadStatus.FIRST, {("A", "22015"): [MagicMock()]}, "EPSG:25833") + mock_bau.return_value = (vg_layer, LoadStatus.FIRST, True) + mock_prog.return_value = _make_progress_mock() + + with patch(_PATCH_GET_VAR, side_effect=_get_var_side_effect), \ + patch(_PATCH_GEM) as mock_gem: + verfahrensgebiet_alkis_komplett(None) + mock_gem.assert_not_called() + if __name__ == "__main__": unittest.main() From 5816b2af22c4e6729df99b1899387810568f7f4d Mon Sep 17 00:00:00 2001 From: Michael Otto Date: Fri, 28 Aug 2026 13:55:03 +0200 Subject: [PATCH 2/4] Landkreis-Auswahldialog bei fehlenden Projekteigenschaften, Style-Fix "Nur laden" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ist weder Verfahrensnummer noch Landkreis in den Projekteigenschaften gesetzt, wird jetzt per Dialog ein Landkreis abgefragt (inkl. "Alle Landkreise") statt direkt abzubrechen. - Verfahrensgebiet-spezifische ALKIS-Dialoge aus sn_basis hierher verschoben: neue Datei functions/verfahrensgebiet_dialoge.py (ask_bror_kombinationen, ask_landkreis_objekte, ask_landkreis_auswahl). - Bugfix: BROR-Layer bekommt seinen Style (BauRaumOderBodenordnungsrecht.qml) jetzt direkt beim Laden in verfahrensgebiet_alkis_komplett(), unabhängig vom weiteren Ablauf. working_tab.py überspringt bei "Nur Objekte laden" jetzt den gesamten VG-Nachbehandlungsblock (Style/Extent/Haken/Combobox/ Erfolgsmeldung), da dort kein echter VG-Layer entsteht. Detailprüfung/ Verifikation im echten QGIS (nicht nur per Unittest) steht noch aus. - Bugfix: Erfolgsmeldung "Layer wurde ... geladen (inkl. Flurstücksverschneidung)" erscheint nicht mehr bei "Nur Objekte laden". Co-Authored-By: Claude Sonnet 5 --- changelog.txt | 5 + functions/verfahrensgebiet_alkis.py | 13 +- functions/verfahrensgebiet_alkis_komplett.py | 31 +- functions/verfahrensgebiet_dialoge.py | 299 ++++++++++++++++++ tests/test_verfahrensgebiet_alkis_komplett.py | 36 ++- ui/tabs/working_tab.py | 37 ++- 6 files changed, 392 insertions(+), 29 deletions(-) create mode 100644 functions/verfahrensgebiet_dialoge.py diff --git a/changelog.txt b/changelog.txt index df8d39d..6d17ae6 100644 --- a/changelog.txt +++ b/changelog.txt @@ -2,6 +2,11 @@ - 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) - Neu: Laden aller/mehrerer Objekte eines ganzen Landkreises als Fallback, wenn keine Verfahrensnummer gesetzt ist (Gruppierung je Verfahren, optional "Nur laden" ohne weiteren Ablauf) +- Neu: Ist weder Verfahrensnummer noch Landkreis in den Projekteigenschaften gesetzt, fragt ein Dialog den Landkreis ab (inkl. Option "Alle Landkreise"), statt direkt abzubrechen +- Landkreisweiter Objekt-Auswahldialog überarbeitet: Tabelle mit sortierbaren Spalten "Nummer"/"Name" (Standard: aufsteigend nach Nummer), Suchfeld, "Alle auswählen" jetzt korrekt (vorheriger Qt-Bug bei programmatischer Mehrfachauswahl behoben) +- Bugfix: BROR-Layer bekommt seinen ALKIS-Style jetzt direkt beim Laden (auch bei "Nur Objekte laden") statt später fälschlich den Verfahrensgebiet-Style zu erhalten – **Detailprüfung/Verifikation im echten QGIS-Test steht noch aus** +- Bugfix: Erfolgsmeldung "Layer wurde ... geladen (inkl. Flurstücksverschneidung)" erscheint nicht mehr fälschlich bei "Nur Objekte laden" (da dort keine Verschneidung stattfindet) +- Verfahrensgebiet-spezifische ALKIS-Dialoge aus sn_basis nach sn_verfahrensgebiet verschoben (functions/verfahrensgebiet_dialoge.py) --- Version 26.7.1-unstable: - Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt diff --git a/functions/verfahrensgebiet_alkis.py b/functions/verfahrensgebiet_alkis.py index a58332c..1ba8d9d 100644 --- a/functions/verfahrensgebiet_alkis.py +++ b/functions/verfahrensgebiet_alkis.py @@ -9,7 +9,8 @@ from sn_basis.functions import ( from sn_basis.functions import QMessageBox from sn_basis.functions.qt_wrapper import QVariant from sn_basis.functions.variable_wrapper import get_variable -from sn_basis.functions.dialog_wrapper import ask_bror_kombinationen, ask_landkreis_objekte +from sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge import ask_bror_kombinationen, ask_landkreis_objekte +from sn_basis.functions.landkreise import ALLE_LANDKREISE alkis_sf_url = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs" typename = "adv:AX_BauRaumOderBodenordnungsrecht" @@ -219,10 +220,14 @@ def _suche_landkreis_kombinationen(tab_widget, landkreis_kennzahl: str, skip_exi if status == LoadStatus.KEEP: return existing, status, {}, None + # "Alle Landkreise" (Sentinel) -> kein Kennzahl-Präfix, LIKE '%' matcht alles. + praefix = "" if landkreis_kennzahl == ALLE_LANDKREISE else landkreis_kennzahl + landkreis_anzeige = "allen Landkreisen" if landkreis_kennzahl == ALLE_LANDKREISE else f"Landkreis {landkreis_kennzahl}" + wfs_uri_find = ( f"url='{alkis_sf_url}' typename='{typename}' srsname='EPSG:25833' " f"sql=SELECT * FROM AX_BauRaumOderBodenordnungsrecht " - f"WHERE bezeichnung LIKE '{landkreis_kennzahl}%'" + f"WHERE bezeichnung LIKE '{praefix}%'" ) temp_layer = QgsVectorLayer(wfs_uri_find, "TempVerfahrensgebiet", "WFS") @@ -233,7 +238,7 @@ def _suche_landkreis_kombinationen(tab_widget, landkreis_kennzahl: str, skip_exi features = list(temp_layer.getFeatures()) if not features: QMessageBox.critical(tab_widget, "Fehler", - f"Für Landkreis {landkreis_kennzahl} wurden keine Objekte im WFS gefunden.") + f"Für {landkreis_anzeige} wurden keine Objekte im WFS gefunden.") return None, LoadStatus.NONE, {}, None kombinationen: dict[tuple[str, str], list] = {} @@ -255,7 +260,7 @@ def _suche_landkreis_kombinationen(tab_widget, landkreis_kennzahl: str, skip_exi def _baue_layer_aus_landkreis_auswahl(tab_widget, kombinationen: dict, crs: str, status: "LoadStatus"): """Auswahl + Bau für den Landkreis-Fallback. - Zeigt :func:`~sn_basis.functions.dialog_wrapper.ask_landkreis_objekte` + Zeigt :func:`~sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge.ask_landkreis_objekte` (Mehrfachauswahl inkl. "Alle auswählen" und "Nur laden"-Checkbox). Die ausgewählten Kombinationen werden anhand der ersten 5 Zeichen von ``bezeichnung`` (= die eigentliche VKZ) gruppiert; je Gruppe wird EIN diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py index 8e329c8..880dbfb 100644 --- a/functions/verfahrensgebiet_alkis_komplett.py +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -52,6 +52,9 @@ from sn_basis.functions.dialog_wrapper import ( show_info_dialog, ask_detailpruefung_oder_vg_laden, ) +from sn_basis.functions.landkreise import LANDKREISE, ALLE_LANDKREISE +from sn_basis.functions.ly_style_wrapper import apply_style +from sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge import ask_landkreis_auswahl from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import ( _suche_verfahrensgebiet_features, @@ -79,6 +82,8 @@ from sn_verfahrensgebiet.functions.knickpunkt_pruefung import ( FELD_VKZ = "verfahrensnummer" #: Anzeigename des BROR-Layers im Projekt BROR_LAYER_NAME = "BauRaumOderBodenordnungsrecht (ALKIS)" +#: QML-Stildatei für den BROR-Layer (sn_verfahrensgebiet/styles/) +BROR_STYLE_QML = "BauRaumOderBodenordnungsrecht.qml" #: Projektvariable für die Flächentoleranz (in m²); Default 0 FLAECHEN_TOLERANZ_VARIABLE = "flaechen_toleranz_m2" #: Minimale implizite Flächentoleranz – Abweichungen unter diesem Wert @@ -309,13 +314,17 @@ def verfahrensgebiet_alkis_komplett( landkreis_kennzahl = get_variable("landkreis_kennzahl") if not verfahrensnummer and not landkreis_kennzahl: - QMessageBox.critical( - tab_widget, - "Fehler", - "Weder eine Verfahrensnummer noch ein Landkreis ist in den " - "Projekteigenschaften eingetragen!", - ) - return None, None, None, LoadStatus.NONE + # Keine Projekteigenschaften vorhanden – Landkreis per Dialog abfragen, + # statt direkt abzubrechen. + landkreis_kennzahl = ask_landkreis_auswahl(tab_widget, LANDKREISE) + if not landkreis_kennzahl: + QMessageBox.critical( + tab_widget, + "Fehler", + "Weder eine Verfahrensnummer noch ein Landkreis ist in den " + "Projekteigenschaften eingetragen!", + ) + return None, None, None, LoadStatus.NONE landkreis_modus = not verfahrensnummer @@ -323,7 +332,10 @@ def verfahrensgebiet_alkis_komplett( # Dialog 1: Verfahrensgebiet(e) in ALKIS ermitteln/suchen # ------------------------------------------------------------------ if landkreis_modus: - such_label = f"Objekte in Landkreis {landkreis_kennzahl} ermitteln/suchen …" + if landkreis_kennzahl == ALLE_LANDKREISE: + such_label = "Objekte in allen Landkreisen ermitteln/suchen …" + else: + such_label = f"Objekte in Landkreis {landkreis_kennzahl} ermitteln/suchen …" else: such_label = "Verfahrensgebiet(e) in ALKIS ermitteln/suchen …" @@ -378,6 +390,9 @@ def verfahrensgebiet_alkis_komplett( if vg_status in (LoadStatus.FIRST, LoadStatus.RELOAD): project.addMapLayer(alkis_vg_layer) bror_was_added_by_us = True + # Style direkt nach dem Laden anwenden – unabhängig vom weiteren + # Ablauf, damit auch "Nur Objekte laden" den Style erhält. + apply_style(alkis_vg_layer, BROR_STYLE_QML) if nur_laden: # Nutzer hat "Nur laden" angehakt – kein Dialog 2, kein weiterer diff --git a/functions/verfahrensgebiet_dialoge.py b/functions/verfahrensgebiet_dialoge.py new file mode 100644 index 0000000..d4c6eef --- /dev/null +++ b/functions/verfahrensgebiet_dialoge.py @@ -0,0 +1,299 @@ +"""sn_verfahrensgebiet/functions/verfahrensgebiet_dialoge.py – Dialoge zum +Laden eines Verfahrensgebiets aus ALKIS (Qt5/6/Mock-kompatibel). +""" +from typing import Any, Optional +from sn_basis.functions.qt_wrapper import ( + QT_VERSION, exec_dialog, QInputDialog, QLineEdit, + QDialog, QDialogButtonBox, QListWidget, QListWidgetItem, QAbstractItemView, + QTableWidget, QTableWidgetItem, QTableWidgetSelectionRange, + QIcon, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, + DialogAccepted, ExtendedSelection, SelectRows, NoEditTriggers, AscendingOrder, +) +from sn_basis.functions.landkreise import ALLE_LANDKREISE +import os + +# .../python/plugins (drei Ebenen über dieser Datei) +_PLUGINS_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) +_WAPPEN_ICON = os.path.join(_PLUGINS_DIR, "sn_basis", "favicon.ico") + + +def ask_bror_kombinationen( + parent: Any, + kombinationen: list[tuple[str, str]], +) -> list[tuple[str, str]]: + """Zeigt Dialog zur Auswahl einer oder mehrerer BROR-Kombinationen + (adv_name + bezeichnung). + + Wird aufgerufen, wenn der WFS mehrere verschiedene Kombinationen aus + ``adv_name`` und ``bezeichnung`` zurückliefert. Es können ein oder + mehrere Objekte gemeinsam ausgewählt und geladen (verschmolzen) werden. + + Parameters + ---------- + parent : + Eltern-Widget oder None. + kombinationen : + Sortierte Liste von ``(adv_name, bezeichnung)``-Tupeln. + + Returns + ------- + list[tuple[str, str]] + Die gewählten ``(adv_name, bezeichnung)``-Tupel. Leere Liste bei + Abbruch oder wenn nichts ausgewählt wurde. + """ + if not kombinationen: + return [] + + if QT_VERSION == 0: # Mock-Modus + print(f"Mock-Modus: ask_bror_kombinationen → {kombinationen[:1]}") + return kombinationen[:1] + + dialog = QDialog(parent) + dialog.setWindowTitle("Verfahrensgebiet aus ALKIS laden") + if os.path.exists(_WAPPEN_ICON): + dialog.setWindowIcon(QIcon(_WAPPEN_ICON)) + layout = QVBoxLayout(dialog) + + hinweis = QLabel( + f"Es wurden {len(kombinationen)} Objekte gefunden.\n\n" + "Bitte wählen Sie eines oder mehrere Objekte aus:" + ) + hinweis.setWordWrap(True) + layout.addWidget(hinweis) + + list_widget = QListWidget() + list_widget.setSelectionMode(ExtendedSelection) + for adv, bez in kombinationen: + item = QListWidgetItem(f"{adv} | {bez}") + list_widget.addItem(item) + list_widget.item(0).setSelected(True) + layout.addWidget(list_widget, 1) # Stretch-Faktor: wächst mit Fenstergröße + + mehrfachauswahl_hinweis = QLabel("Mehrfachauswahl mit Strg + Umschalt") + layout.addWidget(mehrfachauswahl_hinweis) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + layout.addWidget(buttons) + + dialog.adjustSize() + breite = dialog.sizeHint().width() + + # Liste auf max. 5 sichtbare Zeilen begrenzen, Rest per Scrollbar + sichtbare_zeilen = min(len(kombinationen), 5) + zeilenhoehe = list_widget.sizeHintForRow(0) if list_widget.count() else 20 + # + Puffer für eine ggf. eingeblendete horizontale Scrollbar (bei langen + # Einträgen), damit diese nicht die letzte Zeile abschneidet. + scrollbar_puffer = list_widget.horizontalScrollBar().sizeHint().height() + list_hoehe = zeilenhoehe * sichtbare_zeilen + 2 * list_widget.frameWidth() + 4 + scrollbar_puffer + list_widget.setMinimumHeight(list_hoehe) + + dialog.resize(breite, dialog.sizeHint().height()) + + if exec_dialog(dialog) != DialogAccepted: + return [] + + ausgewaehlte_indizes = sorted(list_widget.row(item) for item in list_widget.selectedItems()) + return [kombinationen[i] for i in ausgewaehlte_indizes] + + +def ask_landkreis_objekte( + parent: Any, + kombinationen: list[tuple[str, str]], +) -> tuple[list[tuple[str, str]], bool]: + """Zeigt Dialog zur Auswahl eines, mehrerer oder aller BROR-Objekte eines + Landkreises (Fallback, wenn keine Verfahrensnummer gesetzt ist). + + Wie :func:`ask_bror_kombinationen`, zusätzlich mit einer "Alle + auswählen"-Checkbox (da hier potenziell deutlich mehr Treffer als bei der + VKZ-Suche auftreten) und einer "Nur laden"-Checkbox, mit der gesteuert + wird, ob nach dem Laden der weitere Workflow (Gemarkung/Flurstücke/ + Flächenvergleich) fortgesetzt werden soll. + + Parameters + ---------- + parent : + Eltern-Widget oder None. + kombinationen : + Sortierte Liste von ``(adv_name, bezeichnung)``-Tupeln. + + Returns + ------- + tuple[list[tuple[str, str]], bool] + ``(ausgewaehlte_kombinationen, nur_laden)``. ``([], False)`` bei + Abbruch oder wenn nichts ausgewählt wurde. + """ + if not kombinationen: + return [], False + + if QT_VERSION == 0: # Mock-Modus + print(f"Mock-Modus: ask_landkreis_objekte → {kombinationen[:1]}") + return kombinationen[:1], False + + dialog = QDialog(parent) + dialog.setWindowTitle("Verfahrensgebiete aus Landkreis laden") + if os.path.exists(_WAPPEN_ICON): + dialog.setWindowIcon(QIcon(_WAPPEN_ICON)) + layout = QVBoxLayout(dialog) + + hinweis = QLabel( + f"Es wurden {len(kombinationen)} Objekte gefunden.\n\n" + "Bitte wählen Sie eines oder mehrere Objekte aus:" + ) + hinweis.setWordWrap(True) + layout.addWidget(hinweis) + + suche_zeile = QHBoxLayout() + suche_zeile.addWidget(QLabel("Suche:")) + suche_feld = QLineEdit() + suche_feld.setPlaceholderText("Nummer oder Name …") + suche_zeile.addWidget(suche_feld) + layout.addLayout(suche_zeile) + + alle_auswaehlen = QCheckBox("Alle auswählen") + layout.addWidget(alle_auswaehlen) + + # Standardsortierung nach Bezeichnung; per Klick auf Spaltenkopf änderbar + # (setSortingEnabled aktiviert die eingebaute Tabellensortierung). + sortierte_kombinationen = sorted(kombinationen, key=lambda kb: kb[1]) + + table = QTableWidget(len(sortierte_kombinationen), 2) + table.setHorizontalHeaderLabels(["Nummer", "Name"]) + table.setSelectionMode(ExtendedSelection) + table.setSelectionBehavior(SelectRows) + table.setEditTriggers(NoEditTriggers) + table.verticalHeader().setVisible(False) + table.verticalHeader().setDefaultSectionSize(22) # flachere Zeilen + table.horizontalHeader().setStretchLastSection(True) + table.setSortingEnabled(False) + for row, (adv, bez) in enumerate(sortierte_kombinationen): + table.setItem(row, 0, QTableWidgetItem(bez)) + table.setItem(row, 1, QTableWidgetItem(adv)) + table.setSortingEnabled(True) + table.sortByColumn(0, AscendingOrder) # Standard: aufsteigend nach Nummer + table.resizeColumnToContents(0) + table.setColumnWidth(0, max(110, table.columnWidth(0))) + table.selectRow(0) + layout.addWidget(table, 1) # Stretch-Faktor: wächst mit Fenstergröße + + def _suche_geaendert(text): + text_lower = text.strip().lower() + for row in range(table.rowCount()): + bez_text = table.item(row, 0).text().lower() + adv_text = table.item(row, 1).text().lower() + treffer = not text_lower or text_lower in bez_text or text_lower in adv_text + table.setRowHidden(row, not treffer) + + suche_feld.textChanged.connect(_suche_geaendert) + + def _alle_auswaehlen_umschalten(checked): + table.clearSelection() + if checked: + letzte_spalte = table.columnCount() - 1 + for row in range(table.rowCount()): + if not table.isRowHidden(row): + # setRangeSelected (statt selectRow) fügt der Selektion + # hinzu, ohne vorherige Zeilen wieder abzuwählen. + table.setRangeSelected( + QTableWidgetSelectionRange(row, 0, row, letzte_spalte), True + ) + + alle_auswaehlen.stateChanged.connect(_alle_auswaehlen_umschalten) + + mehrfachauswahl_hinweis = QLabel("Mehrfachauswahl mit Strg + Umschalt") + layout.addWidget(mehrfachauswahl_hinweis) + + nur_laden_checkbox = QCheckBox("Nur Objekte laden (ohne weitere Prüfungen)") + layout.addWidget(nur_laden_checkbox) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + layout.addWidget(buttons) + + dialog.adjustSize() + # Bezeichnungs-Spalte + großzügige Breite für den ADV-Namen (lange Namen + # sollen möglichst ohne Abschneiden sichtbar sein) + Rahmen/Scrollbar-Puffer. + breite = max(dialog.sizeHint().width(), table.columnWidth(0) + 480) + + # Tabelle auf max. 15 sichtbare Zeilen begrenzen, Rest per Scrollbar – hier + # potenziell deutlich mehr Treffer als bei der VKZ-Suche, daher großzügiger + # bemessen als bei ask_bror_kombinationen (dort 5). + sichtbare_zeilen = min(len(sortierte_kombinationen), 15) + zeilenhoehe = table.verticalHeader().defaultSectionSize() if table.rowCount() else 20 + kopfzeile_hoehe = table.horizontalHeader().height() + # + Puffer für eine ggf. eingeblendete horizontale Scrollbar (bei langen + # Einträgen), damit diese nicht die letzte Zeile abschneidet. + scrollbar_puffer = table.horizontalScrollBar().sizeHint().height() + table_hoehe = ( + zeilenhoehe * sichtbare_zeilen + kopfzeile_hoehe + + 2 * table.frameWidth() + 4 + scrollbar_puffer + ) + table.setMinimumHeight(table_hoehe) + + dialog.resize(breite, dialog.sizeHint().height()) + + if exec_dialog(dialog) != DialogAccepted: + return [], False + + ausgewaehlte_zeilen = sorted(set(item.row() for item in table.selectedItems())) + ausgewaehlt = [(table.item(row, 1).text(), table.item(row, 0).text()) for row in ausgewaehlte_zeilen] + return ausgewaehlt, nur_laden_checkbox.isChecked() + + +def ask_landkreis_auswahl( + parent: Any, + landkreise: list[tuple[str, str]], +) -> Optional[str]: + """Fragt per Dropdown-Dialog einen Landkreis ab (Fallback, wenn weder + Verfahrensnummer noch Landkreis in den Projekteigenschaften gesetzt ist). + + Parameters + ---------- + parent : + Eltern-Widget oder None. + landkreise : + Liste von ``(kennzahl, name)``-Tupeln, z.B. ``LANDKREISE`` aus + :mod:`sn_basis.functions.landkreise`. + + Die Auswahlliste beginnt mit einem leeren Platzhalter (der Nutzer muss + aktiv einen Eintrag wählen) und enthält zusätzlich "Alle Landkreise" + (Sentinel :data:`~sn_basis.functions.landkreise.ALLE_LANDKREISE`). + + Returns + ------- + Optional[str] + Gewählte Landkreiskennzahl, :data:`ALLE_LANDKREISE` bei Auswahl aller + Landkreise, oder ``None`` bei Abbruch, leerem Platzhalter oder leerer + Landkreisliste. + """ + if not landkreise: + return None + + if QT_VERSION == 0: # Mock-Modus + print("Mock-Modus: ask_landkreis_auswahl → None (keine aktive Auswahl)") + return None + + ALLE_LABEL = "Alle Landkreise" + namen = [""] + [ALLE_LABEL] + [name for _, name in landkreise] + + dialog = QInputDialog(parent) + dialog.setWindowTitle("Landkreis auswählen") + if os.path.exists(_WAPPEN_ICON): + dialog.setWindowIcon(QIcon(_WAPPEN_ICON)) + dialog.setLabelText("Landkreis:") + dialog.setComboBoxItems(namen) + dialog.setComboBoxEditable(False) + + if exec_dialog(dialog) != DialogAccepted: + return None + + name = dialog.textValue() + if not name: + return None + if name == ALLE_LABEL: + return ALLE_LANDKREISE + + name_zu_kennzahl = {n: k for k, n in landkreise} + return name_zu_kennzahl.get(str(name)) diff --git a/tests/test_verfahrensgebiet_alkis_komplett.py b/tests/test_verfahrensgebiet_alkis_komplett.py index 04f4287..c6b11ce 100644 --- a/tests/test_verfahrensgebiet_alkis_komplett.py +++ b/tests/test_verfahrensgebiet_alkis_komplett.py @@ -148,6 +148,7 @@ _PATCH_KNICK = f"{_MOD}.knickpunkt_pruefung" _PATCH_EXTRAHIERE = f"{_MOD}._extrahiere_knickpunkte" _PATCH_LANDKREIS_SUCHE = f"{_MOD}._suche_landkreis_kombinationen" _PATCH_LANDKREIS_BAU = f"{_MOD}._baue_layer_aus_landkreis_auswahl" +_PATCH_ASK_LANDKREIS = f"{_MOD}.ask_landkreis_auswahl" # --------------------------------------------------------------------------- @@ -678,9 +679,13 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_PROGRESS) @patch(_PATCH_MSGBOX) + @patch(_PATCH_ASK_LANDKREIS, return_value=None) @patch(_PATCH_GET_VAR, return_value="") - def test_kein_vkz_kein_landkreis_zeigt_fehler(self, mock_get_var, mock_msgbox, mock_prog): - """Weder Verfahrensnummer noch Landkreis gesetzt → Fehlermeldung, kein Layer.""" + def test_kein_vkz_kein_landkreis_zeigt_fehler( + self, mock_get_var, mock_ask_landkreis, mock_msgbox, mock_prog + ): + """Weder Verfahrensnummer noch Landkreis gesetzt, Nutzer bricht den + Landkreis-Auswahldialog ab → Fehlermeldung, kein Layer.""" enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) self.assertIsNone(enriched) @@ -689,6 +694,33 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): self.assertEqual(status, LoadStatus.NONE) mock_msgbox.critical.assert_called_once() + @patch(_PATCH_PROGRESS) + @patch(_PATCH_PROJECT) + @patch(_PATCH_LANDKREIS_BAU) + @patch(_PATCH_LANDKREIS_SUCHE) + @patch(_PATCH_ASK_LANDKREIS, return_value="22") + @patch(_PATCH_GET_VAR, return_value="") + def test_kein_vkz_kein_landkreis_dialog_liefert_landkreis( + self, mock_get_var, mock_ask_landkreis, mock_such, mock_bau, mock_proj, mock_prog + ): + """Weder Verfahrensnummer noch Landkreis gesetzt, Nutzer wählt einen + Landkreis im Dialog → Landkreis-Suchpfad wird mit dieser Kennzahl + verwendet (wie im Fall 'Landkreis aus Projekteigenschaften').""" + proj_inst = _DummyProject() + mock_proj.instance.return_value = proj_inst + vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)") + mock_such.return_value = (None, LoadStatus.FIRST, {("A", "22015"): [MagicMock()]}, "EPSG:25833") + mock_bau.return_value = (vg_layer, LoadStatus.FIRST, True) # nur_laden=True + mock_prog.return_value = _make_progress_mock() + + enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) + + mock_ask_landkreis.assert_called_once() + mock_such.assert_called_once_with(None, "22") + mock_bau.assert_called_once() + self.assertIs(enriched, vg_layer) + self.assertEqual(status, LoadStatus.FIRST) + @patch(_PATCH_PROGRESS) @patch(_PATCH_PROJECT) @patch(_PATCH_LANDKREIS_BAU) diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 813a10f..5c090d8 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -21,7 +21,10 @@ from sn_verfahrensgebiet.functions.flurstueck_alkis import ( ALKIS_SF_URL, FLST_LAYER_NAME, ) -from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import verfahrensgebiet_alkis_komplett +from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import ( + verfahrensgebiet_alkis_komplett, + BROR_LAYER_NAME, +) from sn_verfahrensgebiet.functions.verfahrensgebiet_shape_komplett import verfahrensgebiet_shape_komplett from sn_verfahrensgebiet.functions.grenzpunkt_alkis import grenzpunkte_gemarkungsweise, JOINED_LAYER_NAME from sn_verfahrensgebiet.functions.gemarkung_alkis import ( @@ -201,23 +204,27 @@ class WorkingTab(QWidget): apply_style(flst_layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml") self.setze_haken(self.haken_flurst, True) - # BROR-Layer: QML-Stil anwenden - if bror_layer and bror_layer.isValid(): - apply_style(bror_layer, "BauRaumOderBodenordnungsrecht.qml") + # BROR-Layer bekommt seinen Style bereits direkt beim Laden in + # verfahrensgebiet_alkis_komplett() (auch im "Nur laden"-Fall). - # 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() - self.setze_haken(self.haken_verf, True) + # Bei "Nur Objekte laden" (kein weiterer Ablauf) ist enriched_layer + # tatsächlich nur der rohe BROR-Layer (erkennbar am Namen) – hier + # fand keine Flurstücksverschneidung statt, es gibt also keinen + # echten VG-Layer: VG-Style/-Kombobox/-Haken/-Meldung überspringen. + if enriched_layer.name() != BROR_LAYER_NAME: + # 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() + self.setze_haken(self.haken_verf, True) - self.verf_layer_combo.setLayer(enriched_layer) - self._on_verf_layer_changed(enriched_layer) + self.verf_layer_combo.setLayer(enriched_layer) + self._on_verf_layer_changed(enriched_layer) - msg = "erstmalig geladen" if status == LoadStatus.FIRST else "neu geladen" - success("Verfahrensgebiet", f"Layer wurde {msg} (inkl. Flurstücksverschneidung).", duration=5) + 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") From 7b48d29d56d7fc0bc01ef844037f7e26c27fdbef Mon Sep 17 00:00:00 2001 From: Michael Otto Date: Fri, 28 Aug 2026 14:44:25 +0200 Subject: [PATCH 3/4] Kein Fehlerdialog mehr bei Abbruch des Landkreis-Auswahldialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Abbruch des Dialogs ist eine bewusste Nutzerentscheidung, kein Fehlerzustand – verfahrensgebiet_alkis_komplett() bricht jetzt still ab statt eine QMessageBox.critical zu zeigen. Test entsprechend angepasst/umbenannt. Co-Authored-By: Claude Sonnet 5 --- functions/verfahrensgebiet_alkis_komplett.py | 8 ++------ tests/test_verfahrensgebiet_alkis_komplett.py | 7 ++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/functions/verfahrensgebiet_alkis_komplett.py b/functions/verfahrensgebiet_alkis_komplett.py index 880dbfb..15fb092 100644 --- a/functions/verfahrensgebiet_alkis_komplett.py +++ b/functions/verfahrensgebiet_alkis_komplett.py @@ -318,12 +318,8 @@ def verfahrensgebiet_alkis_komplett( # statt direkt abzubrechen. landkreis_kennzahl = ask_landkreis_auswahl(tab_widget, LANDKREISE) if not landkreis_kennzahl: - QMessageBox.critical( - tab_widget, - "Fehler", - "Weder eine Verfahrensnummer noch ein Landkreis ist in den " - "Projekteigenschaften eingetragen!", - ) + # Nutzer hat den Landkreis-Dialog abgebrochen – stiller Abbruch, + # keine Fehlermeldung (bewusster Abbruch, kein Fehlerzustand). return None, None, None, LoadStatus.NONE landkreis_modus = not verfahrensnummer diff --git a/tests/test_verfahrensgebiet_alkis_komplett.py b/tests/test_verfahrensgebiet_alkis_komplett.py index c6b11ce..aea3f94 100644 --- a/tests/test_verfahrensgebiet_alkis_komplett.py +++ b/tests/test_verfahrensgebiet_alkis_komplett.py @@ -681,18 +681,19 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase): @patch(_PATCH_MSGBOX) @patch(_PATCH_ASK_LANDKREIS, return_value=None) @patch(_PATCH_GET_VAR, return_value="") - def test_kein_vkz_kein_landkreis_zeigt_fehler( + def test_kein_vkz_kein_landkreis_dialog_abgebrochen( self, mock_get_var, mock_ask_landkreis, mock_msgbox, mock_prog ): """Weder Verfahrensnummer noch Landkreis gesetzt, Nutzer bricht den - Landkreis-Auswahldialog ab → Fehlermeldung, kein Layer.""" + Landkreis-Auswahldialog ab → stiller Abbruch, kein Layer, KEINE + Fehlermeldung (bewusster Abbruch, kein Fehlerzustand).""" enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None) self.assertIsNone(enriched) self.assertIsNone(flst) self.assertIsNone(bror) self.assertEqual(status, LoadStatus.NONE) - mock_msgbox.critical.assert_called_once() + mock_msgbox.critical.assert_not_called() @patch(_PATCH_PROGRESS) @patch(_PATCH_PROJECT) From d5db43bc57e825c10f61ca466d99d3011e68b16f Mon Sep 17 00:00:00 2001 From: Michael Otto Date: Mon, 31 Aug 2026 06:33:09 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Beteiligungspr=C3=BCfung=20portiert=20(ohne?= =?UTF-8?q?=20automatisches=20Hintergrund-Laden)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portiert aus origin/unstable (Feature "Beteiligung prüfen": Abgleich einer BI-Server-CSV-Exportliste mit dem ALKIS-Flurstücksbestand des Verfahrensgebiets), bewusst OHNE den dortigen silent-Modus für verfahrensgebiet_alkis()/verfahrensgebiet_alkis_komplett(): - try1 (Verfahrensgebiet-Layer mit Kennzeichen vorhanden) bleibt unverändert – schneller Mengenvergleich ohne WFS-Zugriff. - try2 (Layer vorhanden, aber ohne verwertbare Kennzeichen) und try3 (kein Layer vorhanden) laden nicht mehr automatisch/silent über die komplette ALKIS-Pipeline, sondern fordern den Nutzer per Meldung auf, das Verfahrensgebiet zunächst regulär über "Aus ALKIS laden" zu laden und die Beteiligungsprüfung danach erneut zu starten (landet dann automatisch bei try1). Grund: ein automatischer Durchlauf ohne Nutzerinteraktion (mehrdeutige BROR-Treffer, Flächenabweichungen) hat sich in der Praxis nicht als zuverlässig genug erwiesen. - verfahrensgebiet_alkis.py/verfahrensgebiet_alkis_komplett.py bleiben dadurch unangetastet – kein silent-Parameter, kein Konflikt mit dem heutigen Zwei-Dialog-Split und dem Landkreis-Fallback. - Neuer Button "Beteiligung prüfen" in working_tab.py, delegiert an beteiligung_pruefen(). Co-Authored-By: Claude Sonnet 5 --- changelog.txt | 3 + functions/beteiligung_pruefung.py | 452 +++++++++++++++++++++++++++++ styles/beteiligung_erfolgreich.qml | 22 ++ styles/beteiligung_vergleich.qml | 51 ++++ tests/test_beteiligung_pruefung.py | 304 +++++++++++++++++++ ui/tabs/working_tab.py | 50 ++++ 6 files changed, 882 insertions(+) create mode 100644 functions/beteiligung_pruefung.py create mode 100644 styles/beteiligung_erfolgreich.qml create mode 100644 styles/beteiligung_vergleich.qml create mode 100644 tests/test_beteiligung_pruefung.py diff --git a/changelog.txt b/changelog.txt index 6d17ae6..c948f93 100644 --- a/changelog.txt +++ b/changelog.txt @@ -8,6 +8,9 @@ - Bugfix: Erfolgsmeldung "Layer wurde ... geladen (inkl. Flurstücksverschneidung)" erscheint nicht mehr fälschlich bei "Nur Objekte laden" (da dort keine Verschneidung stattfindet) - Verfahrensgebiet-spezifische ALKIS-Dialoge aus sn_basis nach sn_verfahrensgebiet verschoben (functions/verfahrensgebiet_dialoge.py) --- +Version 26.8.1-unstable: +- Neu: "Beteiligung prüfen" – Abgleich einer BI-Server-CSV-Exportliste mit dem ALKIS-Flurstücksbestand des Verfahrensgebiets (try1: schneller Listenabgleich bei vorhandenem Kennzeichen-Bestand; try2/try3: Aufforderung, das Verfahrensgebiet zunächst über "Aus ALKIS laden" zu laden, statt automatischem Hintergrund-Laden) +--- Version 26.7.1-unstable: - Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt --- diff --git a/functions/beteiligung_pruefung.py b/functions/beteiligung_pruefung.py new file mode 100644 index 0000000..74e75f9 --- /dev/null +++ b/functions/beteiligung_pruefung.py @@ -0,0 +1,452 @@ +"""sn_verfahrensgebiet/functions/beteiligung_pruefung.py – Beteiligung prüfen. + +AGLB-länderspezifisch (Flurstückskennzeichen-Bildung nach sächsischem ALKIS- +Schema). Die Länderkennung wird nicht hartcodiert, sondern über +:func:`sn_basis.functions.laenderkennung.get_laenderkennung` ermittelt, damit +Schwesterplugins für andere Bundesländer dieselbe Logik mit einer anderen +Länderkennung wiederverwenden können. + +Gleicht eine CSV-Exportliste des BI-Servers (Kopfzeile +``VEF_VKZF,FLA_Beteiligung,FLURSTUECK``) mit dem Verfahrensgebiet-Bestand +ab. Aus ``FLURSTUECK`` (Format ``///``) wird +je Zeile ein zu ALKIS kompatibles Flurstückskennzeichen ``Flurstueck_csv`` +gebildet. + +Kaskade je nach Projektzustand: + +- **try1**: Ein Layer ``Verfahrensgebiet`` mit befülltem Attribut + ``flurstueckskennzeichen`` (Single-Feature-Modell, siehe + :mod:`verfahrensgebiet_edit_service`) ist vorhanden → schneller + Mengenvergleich ohne WFS-Zugriff. +- **try2**: Ein Verfahrensgebiet-Layer ist vorhanden, aber ohne verwertbare + Kennzeichen → statt einer automatischen (nutzerlosen) ALKIS-Ladepipeline + wird der Nutzer per Meldung aufgefordert, das Verfahrensgebiet zunächst + regulär über "Aus ALKIS laden" neu zu laden und die Beteiligungsprüfung + danach erneut zu starten (landet dann automatisch bei try1). Ein + automatischer Durchlauf hat sich in der Praxis nicht als zuverlässig genug + erwiesen (mehrdeutige BROR-Treffer, Flächenabweichungen etc. lassen sich + nur im interaktiven Ablauf sinnvoll auflösen). +- **try3**: Kein Verfahrensgebiet-Layer im Projekt → Verfahrensnummer aus + ``VEF_VKZF`` wird als Projektvariable übernommen, danach wie try2 (Meldung + statt automatischem Laden). +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from sn_basis.functions import QgsVectorLayer, QgsProject, QgsFeature, QgsField +from sn_basis.functions.qt_wrapper import QFileDialog, QVariant +from sn_basis.functions.variable_wrapper import set_variable +from sn_basis.functions.laenderkennung import get_laenderkennung +from sn_basis.functions.dialog_wrapper import create_progress_dialog + +from sn_verfahrensgebiet.functions.flurstueck_alkis import FLST_LAYER_NAME +from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( + VERF_LAYER_NAME, + get_kennzeichen_menge, +) + +CSV_SPALTE_FLURSTUECK = "FLURSTUECK" +CSV_SPALTE_VEF_VKZF = "VEF_VKZF" +CSV_SPALTE_BETEILIGUNG = "FLA_Beteiligung" + +FELD_FLURSTUECK_CSV = "Flurstueck_csv" +FELD_FLURSTUECKSKENNZEICHEN_WFS = "flurstueckskennzeichen" + +ERGEBNIS_LAYER_VERGLEICH = "Beteiligung – Vergleich" + +WERT_BEIDE = "beide" +WERT_NUR_VERFAHRENSGEBIET = "nur_verfahrensgebiet" +WERT_NUR_AGLB = "nur_aglb" + + +class BeteiligungPruefungFehler(Exception): + """Erwarteter, dem Nutzer per Messagebox mitzuteilender Fehlerfall.""" + + +@dataclass +class BeteiligungErgebnis: + """Ergebnis eines Prüflaufs, das die aufrufende UI ins Projekt einfügt.""" + + modus: str + layers: List[Tuple[object, Optional[str]]] = field(default_factory=list) + statistik: Dict[str, int] = field(default_factory=dict) + statistik_text: str = "" + + +# --------------------------------------------------------------------------- +# CSV laden +# --------------------------------------------------------------------------- + +def lade_beteiligung_csv(parent_widget) -> Optional[Tuple[List[dict], List[str]]]: + """Öffnet Dateidialog (CSV-Filter) und liest die BI-Server-Exportliste. + + Returns + ------- + Optional[Tuple[List[dict], List[str]]] + ``(zeilen, spaltennamen)`` oder ``None`` bei Nutzerabbruch. + + Raises + ------ + BeteiligungPruefungFehler + Bei ungültigem CSV-Format oder fehlendem Attribut ``FLURSTUECK``. + """ + pfad, _ = QFileDialog.getOpenFileName( + parent_widget, + "CSV-Export aus BI-Server auswählen", + "", + "CSV-Dateien (*.csv)", + ) + if not pfad: + return None # Nutzerabbruch + + try: + with open(pfad, "r", encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f, delimiter=",") + zeilen = list(reader) + spaltennamen = reader.fieldnames or [] + except (OSError, csv.Error, UnicodeDecodeError) as exc: + raise BeteiligungPruefungFehler( + f"CSV-Format ungültig – Datei konnte nicht gelesen werden: {exc}" + ) from exc + + if not spaltennamen or not zeilen: + raise BeteiligungPruefungFehler( + "CSV-Format ungültig – die Datei ist leer oder enthält keine Kopfzeile." + ) + + if CSV_SPALTE_FLURSTUECK not in spaltennamen: + raise BeteiligungPruefungFehler( + f"Die CSV enthält nicht das benötigte Attribut '{CSV_SPALTE_FLURSTUECK}'." + ) + + return zeilen, spaltennamen + + +# --------------------------------------------------------------------------- +# Flurstueck_csv-Bildung +# --------------------------------------------------------------------------- + +#: Alte CSV-Exporte führen Buchstaben-Nenner (ALKIS: a, b, c, ...) teils als +#: numerischen Platzhalter 9001, 9002, ... statt als Buchstabe. 9001 → "a", +#: 9002 → "b" usw. (Basiswert, auf den 1..26 für a..z addiert wird). +_NENNER_BUCHSTABEN_BASIS = 9000 + + +def _baue_nenner_kennzeichen(nenner: str) -> str: + """Bildet den 4-stelligen Nenner-Teil des Flurstückskennzeichens. + + Normalfall: numerischer Nenner, 4-stellig mit führenden Nullen + aufgefüllt; Nenner 0/leer → Platzhalter ``"____"``. + + Format-Inkonsistenz alter CSV-Exporte: ein Buchstaben-Nenner (z.B. "a") + wird dort als ``9001`` (a), ``9002`` (b), usw. geführt. Wird ein solcher + Platzhalterwert erkannt, wird stattdessen der Buchstabe selbst + (4-stellig mit führenden Nullen aufgefüllt, z.B. ``"000a"``) verwendet, + damit das Kennzeichen mit dem ALKIS-Bestand übereinstimmt. + """ + nenner = (nenner or "").strip() + nenner_ohne_null = nenner.lstrip("0") + if not nenner_ohne_null: + return "____" + + if nenner_ohne_null.isdigit(): + zahl = int(nenner_ohne_null) + if _NENNER_BUCHSTABEN_BASIS < zahl <= _NENNER_BUCHSTABEN_BASIS + 26: + buchstabe = chr(ord("a") + (zahl - _NENNER_BUCHSTABEN_BASIS - 1)) + return buchstabe.zfill(4) + + return nenner.zfill(4) + + +def baue_flurstueck_csv(flurstueck_wert: str, laenderkennung: str) -> Optional[str]: + """Bildet aus dem CSV-Attribut ``FLURSTUECK`` ein ALKIS-Flurstückskennzeichen. + + Das Attribut hat das Format + ``///``. Die Flurnummer ist in der + CSV nicht enthalten und wird als fixer Platzhalter ``___`` geführt (so wie + ALKIS unbesetzte Kennzeichen-Bestandteile selbst mit Unterstrichen füllt). + #Für Länderanpassung dieses Verhalten ändern, so dass die Flurnummer extrahiert und berücksichtigt wird, wenn angegeben + + Returns + ------- + Optional[str] + Das zusammengesetzte Kennzeichen, oder ``None`` bei ungültigem Format + (nicht genau 4 durch ``/`` getrennte Teile). + """ + if not flurstueck_wert: + return None + + teile = [t.strip() for t in flurstueck_wert.strip().split("/")] + if len(teile) != 4: + return None + + gemarkung, zaehler, nenner, folge = teile + + gemarkung_kz = gemarkung.zfill(4) + flur_kz = "___" + zaehler_kz = zaehler.zfill(5) + nenner_kz = _baue_nenner_kennzeichen(nenner) + folge_kz = folge.zfill(2) + + return f"{laenderkennung}{gemarkung_kz}{flur_kz}{zaehler_kz}{nenner_kz}{folge_kz}" + + +def baue_flurstueck_csv_liste(zeilen: List[dict], laenderkennung: str) -> List[str]: + """Bildet für alle CSV-Zeilen ``Flurstueck_csv`` und gibt die Liste zurück. + + Schreibt das Ergebnis zusätzlich als Attribut ``Flurstueck_csv`` in jede + Zeile zurück und gibt die ersten 20 Einträge als DEBUG-Ausgabe aus. + """ + ergebnisse: List[str] = [] + for zeile in zeilen: + wert = zeile.get(CSV_SPALTE_FLURSTUECK, "") + fkz = baue_flurstueck_csv(wert, laenderkennung) + zeile[FELD_FLURSTUECK_CSV] = fkz or "" + if fkz: + ergebnisse.append(fkz) + + print(f">>> Flurstueck_csv – {len(ergebnisse)} gebildet, erste 20:") + for wert in ergebnisse[:20]: + print(f">>> {wert}") + + return ergebnisse + + +# --------------------------------------------------------------------------- +# Hilfsfunktionen: Layer-Lookup +# --------------------------------------------------------------------------- + +def _hole_verfahrensgebiet_layer(tab_widget) -> Optional[object]: + """Liefert den Verfahrensgebiet-Layer, falls vorhanden. + + Der per Name (``VERF_LAYER_NAME``) identifizierte Layer hat Vorrang vor + der Dropdown-Auswahl im Tab: try1/try2/try3 orientieren sich am + tatsächlichen "Verfahrensgebiet"-Bestand im Projekt, nicht an einer + ggf. anderweitig belegten Nutzerauswahl (z.B. für Grenzpunkte/ + Umringstatistik). Die Dropdown-Auswahl dient nur als Fallback, falls + kein so benannter Layer existiert. + """ + treffer = QgsProject.instance().mapLayersByName(VERF_LAYER_NAME) + if treffer: + return treffer[0] + + combo = getattr(tab_widget, "verf_layer_combo", None) + if combo is not None: + layer = combo.currentLayer() + if layer is not None and layer.isValid(): + return layer + + return None + + +def _hat_verwendbare_kennzeichen(verf_layer) -> bool: + try: + return bool(get_kennzeichen_menge(verf_layer)) + except Exception: + return False + + +def _hole_flurstuecke_layer() -> Optional[object]: + treffer = QgsProject.instance().mapLayersByName(FLST_LAYER_NAME) + return treffer[0] if treffer else None + + +def _flurstueckskennzeichen_geometrien(flst_layer) -> Dict[str, object]: + """Baut ``flurstueckskennzeichen`` → Geometrie-Map aus dem Flurstücke-Layer.""" + mapping: Dict[str, object] = {} + if flst_layer is None: + return mapping + if flst_layer.fields().indexFromName(FELD_FLURSTUECKSKENNZEICHEN_WFS) < 0: + return mapping + for feat in flst_layer.getFeatures(): + try: + kz = str(feat[FELD_FLURSTUECKSKENNZEICHEN_WFS] or "").strip() + except Exception: + kz = "" + if kz: + mapping[kz] = feat.geometry() + return mapping + + +# --------------------------------------------------------------------------- +# try1: schneller Listenabgleich +# --------------------------------------------------------------------------- + +def _try1_listenabgleich( + tab_widget, verf_layer, flurstueck_csv_liste: List[str] +) -> Tuple[object, Dict[str, int]]: + progress = create_progress_dialog( + 3, "Beteiligung prüfen", "Verfahrensgebiet-Kennzeichen einlesen …" + ) + progress.set_value(0) + progress.raise_to_front() + + verf_menge = get_kennzeichen_menge(verf_layer) + csv_menge = set(flurstueck_csv_liste) + progress.set_value(1) + + progress.set_label("CSV-Liste abgleichen …") + beide = sorted(verf_menge & csv_menge) + nur_verfahrensgebiet = sorted(verf_menge - csv_menge) + nur_aglb = sorted(csv_menge - verf_menge) + progress.set_value(2) + + progress.set_label("Ergebnislayer erstellen …") + flst_layer = _hole_flurstuecke_layer() + geom_map = _flurstueckskennzeichen_geometrien(flst_layer) + if flst_layer is not None and flst_layer.isValid(): + crs = flst_layer.crs().authid() + elif verf_layer.isValid(): + crs = verf_layer.crs().authid() + else: + crs = "EPSG:25833" + + layer = QgsVectorLayer(f"Polygon?crs={crs}", ERGEBNIS_LAYER_VERGLEICH, "memory") + pr = layer.dataProvider() + pr.addAttributes([ + QgsField(FELD_FLURSTUECK_CSV, QVariant.String), + QgsField("status", QVariant.String), + ]) + layer.updateFields() + fields = layer.fields() + + neue_features = [] + for status, menge in ( + (WERT_BEIDE, beide), + (WERT_NUR_VERFAHRENSGEBIET, nur_verfahrensgebiet), + (WERT_NUR_AGLB, nur_aglb), + ): + for kz in menge: + feat = QgsFeature(fields) + geom = geom_map.get(kz) + if geom is not None: + feat.setGeometry(geom) + feat.setAttributes([kz, status]) + neue_features.append(feat) + + pr.addFeatures(neue_features) + layer.updateExtents() + + progress.set_value(3) + progress.close() + + statistik = { + WERT_BEIDE: len(beide), + WERT_NUR_VERFAHRENSGEBIET: len(nur_verfahrensgebiet), + WERT_NUR_AGLB: len(nur_aglb), + } + return layer, statistik + + +# --------------------------------------------------------------------------- +# try2/try3: Aufforderung statt automatischer ALKIS-Pipeline +# --------------------------------------------------------------------------- +# +# Ein automatischer (nutzerloser) Durchlauf der kompletten Ladepipeline +# (mehrdeutige BROR-Treffer, Flächenabweichungen, Detailprüfung) hat sich in +# der Praxis nicht als zuverlässig genug erwiesen – deshalb hier bewusst +# KEIN silent-Aufruf von verfahrensgebiet_alkis_komplett(), sondern eine +# Aufforderung an den Nutzer, den regulären interaktiven Ablauf +# ("Aus ALKIS laden") selbst zu starten. Ein erneuter Klick auf +# "Beteiligung prüfen" landet danach automatisch bei try1, sobald der Layer +# verwertbare Kennzeichen hat. + +def _fordere_manuelles_laden_an(vef_vkzf: Optional[str] = None) -> None: + """Bricht try2/try3 mit einer Aufforderung an den Nutzer ab (statt eines + automatischen Ladevorgangs).""" + hinweis = ( + "Bitte laden Sie das Verfahrensgebiet jetzt über 'Aus ALKIS laden' " + "und starten Sie die Beteiligungsprüfung danach erneut." + ) + if vef_vkzf: + raise BeteiligungPruefungFehler( + f"Es ist noch kein Verfahrensgebiet geladen. Die Verfahrensnummer " + f"{vef_vkzf} wurde aus der CSV übernommen. {hinweis}" + ) + raise BeteiligungPruefungFehler( + f"Der vorhandene Verfahrensgebiet-Layer enthält keine verwertbaren " + f"Flurstückskennzeichen. {hinweis}" + ) + + +# --------------------------------------------------------------------------- +# try3: Bootstrap (Verfahrensnummer aus CSV) + Aufforderung wie try2 +# --------------------------------------------------------------------------- + +def _lese_vef_vkzf(zeilen: List[dict], spaltennamen: List[str]) -> str: + if CSV_SPALTE_VEF_VKZF not in spaltennamen: + raise BeteiligungPruefungFehler( + f"Die CSV enthält nicht das benötigte Attribut '{CSV_SPALTE_VEF_VKZF}'." + ) + for zeile in zeilen: + wert = (zeile.get(CSV_SPALTE_VEF_VKZF) or "").strip() + if wert: + return wert + raise BeteiligungPruefungFehler( + f"Die CSV enthält keinen befüllten Wert für '{CSV_SPALTE_VEF_VKZF}'." + ) + + +def _try3_bootstrap(tab_widget, zeilen: List[dict], spaltennamen: List[str]) -> None: + vef_vkzf = _lese_vef_vkzf(zeilen, spaltennamen) + set_variable("verfahrensnummer", vef_vkzf, scope="project") + _fordere_manuelles_laden_an(vef_vkzf) + + +# --------------------------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------------------------- + +def beteiligung_pruefen(tab_widget) -> Optional[BeteiligungErgebnis]: + """Vollständiger Ablauf: CSV laden → Flurstueck_csv bilden → try1/try2/try3. + + Returns + ------- + Optional[BeteiligungErgebnis] + ``None`` bei Nutzerabbruch des Dateidialogs. Sonst das Ergebnis mit + den ins Projekt einzufügenden Layern und der Statistik. + + Raises + ------ + BeteiligungPruefungFehler + Bei einem der dokumentierten Fehlerfälle (CSV-Format, fehlende + Spalten) oder wenn kein Verfahrensgebiet mit verwertbaren + Kennzeichen vorliegt (try2/try3 – der Nutzer wird dann aufgefordert, + das Verfahrensgebiet zunächst über "Aus ALKIS laden" zu laden und + die Beteiligungsprüfung danach erneut zu starten). Der Aufrufer + zeigt daraufhin eine Messagebox an. + """ + geladen = lade_beteiligung_csv(tab_widget) + if geladen is None: + return None + zeilen, spaltennamen = geladen + + laenderkennung = get_laenderkennung("sn") + flurstueck_csv_liste = baue_flurstueck_csv_liste(zeilen, laenderkennung) + if not flurstueck_csv_liste: + raise BeteiligungPruefungFehler( + "Aus der CSV konnten keine gültigen Flurstück-Kennzeichen gebildet werden " + "(Attribut 'FLURSTUECK' muss 4 durch '/' getrennte Teile enthalten)." + ) + + verf_layer = _hole_verfahrensgebiet_layer(tab_widget) + + if verf_layer is not None and _hat_verwendbare_kennzeichen(verf_layer): + layer, statistik = _try1_listenabgleich(tab_widget, verf_layer, flurstueck_csv_liste) + text = ( + f"Übereinstimmend: {statistik[WERT_BEIDE]}\n" + f"Nur im Verfahrensgebiet: {statistik[WERT_NUR_VERFAHRENSGEBIET]}\n" + f"Nur in der CSV-Liste (AGLB): {statistik[WERT_NUR_AGLB]}" + ) + return BeteiligungErgebnis("try1", [(layer, "beteiligung_vergleich.qml")], statistik, text) + + if verf_layer is not None: + _fordere_manuelles_laden_an() + return None # unreachable – _fordere_manuelles_laden_an() wirft immer + + _try3_bootstrap(tab_widget, zeilen, spaltennamen) + return None # unreachable – _try3_bootstrap() wirft immer diff --git a/styles/beteiligung_erfolgreich.qml b/styles/beteiligung_erfolgreich.qml new file mode 100644 index 0000000..3691aa3 --- /dev/null +++ b/styles/beteiligung_erfolgreich.qml @@ -0,0 +1,22 @@ + + + + + + + + + + + + 0 + 0 + 2 + diff --git a/styles/beteiligung_vergleich.qml b/styles/beteiligung_vergleich.qml new file mode 100644 index 0000000..1062501 --- /dev/null +++ b/styles/beteiligung_vergleich.qml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 2 + diff --git a/tests/test_beteiligung_pruefung.py b/tests/test_beteiligung_pruefung.py new file mode 100644 index 0000000..6a393bc --- /dev/null +++ b/tests/test_beteiligung_pruefung.py @@ -0,0 +1,304 @@ +"""sn_verfahrensgebiet/tests/test_beteiligung_pruefung.py + +Unit-Tests für :mod:`sn_verfahrensgebiet.functions.beteiligung_pruefung`. + +Getestete Szenarien: + +baue_flurstueck_csv: +- Normalfall mit führenden Nullen bei Zähler/Nenner +- Nenner = 0 → Platzhalter "____" +- Nenner leer → Platzhalter "____" +- Ungültiges Format (nicht 4 Teile) → None +- Leerer Wert → None + +baue_flurstueck_csv_liste: +- Bildet Liste nur aus gültigen Zeilen, schreibt Flurstueck_csv in jede Zeile zurück + +lade_beteiligung_csv: +- Dateidialog abgebrochen → None +- Spalte FLURSTUECK fehlt → BeteiligungPruefungFehler +- Leere Datei → BeteiligungPruefungFehler +- Erfolgsfall → (zeilen, spaltennamen) + +try2/try3 (kein automatisches Laden mehr, siehe Modul-Docstring): +- Verfahrensgebiet-Layer ohne Kennzeichen vorhanden → Aufforderung statt Auto-Laden +- Kein Verfahrensgebiet-Layer vorhanden → Verfahrensnummer aus CSV wird gesetzt, + dann Aufforderung statt Auto-Laden +""" + +import csv +import os +import sys +import tempfile +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.beteiligung_pruefung import ( + baue_flurstueck_csv, + baue_flurstueck_csv_liste, + lade_beteiligung_csv, + beteiligung_pruefen, + BeteiligungPruefungFehler, + FELD_FLURSTUECK_CSV, + CSV_SPALTE_FLURSTUECK, + CSV_SPALTE_VEF_VKZF, + _fordere_manuelles_laden_an, + _try3_bootstrap, + _hole_verfahrensgebiet_layer, +) + +_MOD = "sn_verfahrensgebiet.functions.beteiligung_pruefung" + + +class TestBaueFlurstueckCsv(unittest.TestCase): + def test_normalfall_mit_fuehrenden_nullen(self): + ergebnis = baue_flurstueck_csv("1234/123/45/00", "14") + self.assertEqual(ergebnis, "141234___00123004500") + + def test_nenner_null_wird_zu_unterstrichen(self): + ergebnis = baue_flurstueck_csv("1234/123/0/00", "14") + self.assertEqual(ergebnis, "141234___00123____00") + + def test_nenner_leer_wird_zu_unterstrichen(self): + ergebnis = baue_flurstueck_csv("1234//45/00", "14") + # Gemarkung 1234, Zähler leer -> "00000"? -> zfill auf leeren String ergibt "00000" + self.assertTrue(ergebnis.startswith("141234___00000")) + + def test_gemarkung_ohne_fuehrende_nullen_wird_aufgefuellt(self): + ergebnis = baue_flurstueck_csv("12/1/1/00", "14") + self.assertTrue(ergebnis.startswith("140012___")) + + def test_ungueltiges_format_gibt_none(self): + self.assertIsNone(baue_flurstueck_csv("1234/123/45", "14")) + self.assertIsNone(baue_flurstueck_csv("1234/123/45/00/99", "14")) + + def test_leerer_wert_gibt_none(self): + self.assertIsNone(baue_flurstueck_csv("", "14")) + self.assertIsNone(baue_flurstueck_csv(None, "14")) + + def test_nenner_9001_wird_zu_buchstabe_a(self): + ergebnis = baue_flurstueck_csv("1234/123/9001/00", "14") + self.assertEqual(ergebnis, "141234___00123000a00") + + def test_nenner_9002_wird_zu_buchstabe_b(self): + ergebnis = baue_flurstueck_csv("1234/123/9002/00", "14") + self.assertEqual(ergebnis, "141234___00123000b00") + + def test_nenner_9026_wird_zu_buchstabe_z(self): + ergebnis = baue_flurstueck_csv("1234/123/9026/00", "14") + self.assertEqual(ergebnis, "141234___00123000z00") + + def test_nenner_ausserhalb_buchstaben_bereich_bleibt_numerisch(self): + ergebnis = baue_flurstueck_csv("1234/123/9027/00", "14") + self.assertEqual(ergebnis, "141234___00123902700") + + def test_nenner_9001_mit_fuehrenden_nullen_wird_erkannt(self): + ergebnis = baue_flurstueck_csv("1234/123/09001/00", "14") + self.assertEqual(ergebnis, "141234___00123000a00") + + def test_einstellige_folge_wird_auf_2_stellen_aufgefuellt(self): + ergebnis = baue_flurstueck_csv("1234/123/45/5", "14") + self.assertEqual(ergebnis, "141234___00123004505") + + +class TestBaueFlurstueckCsvListe(unittest.TestCase): + def test_nur_gueltige_zeilen_in_liste(self): + zeilen = [ + {CSV_SPALTE_FLURSTUECK: "1234/123/45/00"}, + {CSV_SPALTE_FLURSTUECK: "ungueltig"}, + {CSV_SPALTE_FLURSTUECK: "5678/1/0/01"}, + ] + ergebnis = baue_flurstueck_csv_liste(zeilen, "14") + self.assertEqual(len(ergebnis), 2) + self.assertEqual(zeilen[0][FELD_FLURSTUECK_CSV], "141234___00123004500") + self.assertEqual(zeilen[1][FELD_FLURSTUECK_CSV], "") + self.assertEqual(zeilen[2][FELD_FLURSTUECK_CSV], "145678___00001____01") + + +class TestHoleVerfahrensgebietLayer(unittest.TestCase): + @patch(f"{_MOD}.QgsProject") + def test_layer_per_name_hat_vorrang_vor_combo_auswahl(self, mock_project_cls): + namens_layer = MagicMock(name="verfahrensgebiet_layer") + mock_project = MagicMock() + mock_project.mapLayersByName.return_value = [namens_layer] + mock_project_cls.instance.return_value = mock_project + + andere_auswahl = MagicMock(name="andere_layer_in_combo") + andere_auswahl.isValid.return_value = True + tab_widget = MagicMock() + tab_widget.verf_layer_combo.currentLayer.return_value = andere_auswahl + + ergebnis = _hole_verfahrensgebiet_layer(tab_widget) + + self.assertIs(ergebnis, namens_layer) + + @patch(f"{_MOD}.QgsProject") + def test_combo_auswahl_als_fallback_ohne_namens_layer(self, mock_project_cls): + mock_project = MagicMock() + mock_project.mapLayersByName.return_value = [] + mock_project_cls.instance.return_value = mock_project + + combo_layer = MagicMock(name="combo_layer") + combo_layer.isValid.return_value = True + tab_widget = MagicMock() + tab_widget.verf_layer_combo.currentLayer.return_value = combo_layer + + ergebnis = _hole_verfahrensgebiet_layer(tab_widget) + + self.assertIs(ergebnis, combo_layer) + + @patch(f"{_MOD}.QgsProject") + def test_kein_layer_gibt_none(self, mock_project_cls): + mock_project = MagicMock() + mock_project.mapLayersByName.return_value = [] + mock_project_cls.instance.return_value = mock_project + + tab_widget = MagicMock() + tab_widget.verf_layer_combo.currentLayer.return_value = None + + self.assertIsNone(_hole_verfahrensgebiet_layer(tab_widget)) + + +class TestFordereManuellesLadenAn(unittest.TestCase): + """try2/try3 laden nicht mehr automatisch, sondern fordern den Nutzer auf, + den regulären interaktiven Ablauf ('Aus ALKIS laden') selbst zu starten.""" + + def test_ohne_vef_vkzf_nennt_fehlende_kennzeichen(self): + with self.assertRaises(BeteiligungPruefungFehler) as ctx: + _fordere_manuelles_laden_an() + self.assertIn("keine verwertbaren", str(ctx.exception)) + self.assertIn("Aus ALKIS laden", str(ctx.exception)) + + def test_mit_vef_vkzf_nennt_verfahrensnummer(self): + with self.assertRaises(BeteiligungPruefungFehler) as ctx: + _fordere_manuelles_laden_an("12345") + self.assertIn("12345", str(ctx.exception)) + self.assertIn("Aus ALKIS laden", str(ctx.exception)) + + +class TestTry3Bootstrap(unittest.TestCase): + @patch(f"{_MOD}.set_variable") + def test_setzt_verfahrensnummer_und_fordert_manuelles_laden_an(self, mock_set_variable): + zeilen = [{CSV_SPALTE_VEF_VKZF: "12345", CSV_SPALTE_FLURSTUECK: "1234/1/1/00"}] + spaltennamen = [CSV_SPALTE_VEF_VKZF, CSV_SPALTE_FLURSTUECK] + + with self.assertRaises(BeteiligungPruefungFehler) as ctx: + _try3_bootstrap(object(), zeilen, spaltennamen) + + mock_set_variable.assert_called_once_with("verfahrensnummer", "12345", scope="project") + self.assertIn("12345", str(ctx.exception)) + + +class TestBeteiligungPruefenDispatch(unittest.TestCase): + """Dispatch-Logik in beteiligung_pruefen(): try1 unverändert, try2/try3 + fordern jetzt auf statt automatisch zu laden.""" + + @patch(f"{_MOD}._hat_verwendbare_kennzeichen", return_value=False) + @patch(f"{_MOD}._hole_verfahrensgebiet_layer") + @patch(f"{_MOD}.baue_flurstueck_csv_liste") + @patch(f"{_MOD}.get_laenderkennung", return_value="14") + @patch(f"{_MOD}.lade_beteiligung_csv") + def test_layer_ohne_kennzeichen_fordert_manuelles_laden_an( + self, mock_lade_csv, mock_laenderkennung, mock_liste, mock_hole_layer, mock_hat_kz + ): + mock_lade_csv.return_value = ([{}], ["FLURSTUECK"]) + mock_liste.return_value = ["145678___00001____01"] + mock_hole_layer.return_value = MagicMock(name="verf_layer_ohne_kz") + + with self.assertRaises(BeteiligungPruefungFehler) as ctx: + beteiligung_pruefen(object()) + self.assertIn("keine verwertbaren", str(ctx.exception)) + + @patch(f"{_MOD}.set_variable") + @patch(f"{_MOD}._hole_verfahrensgebiet_layer", return_value=None) + @patch(f"{_MOD}.baue_flurstueck_csv_liste") + @patch(f"{_MOD}.get_laenderkennung", return_value="14") + @patch(f"{_MOD}.lade_beteiligung_csv") + def test_kein_layer_setzt_verfahrensnummer_und_fordert_laden_an( + self, mock_lade_csv, mock_laenderkennung, mock_liste, mock_hole_layer, mock_set_variable + ): + zeilen = [{CSV_SPALTE_VEF_VKZF: "12345", CSV_SPALTE_FLURSTUECK: "1234/1/1/00"}] + mock_lade_csv.return_value = (zeilen, [CSV_SPALTE_VEF_VKZF, CSV_SPALTE_FLURSTUECK]) + mock_liste.return_value = ["145678___00001____01"] + + with self.assertRaises(BeteiligungPruefungFehler) as ctx: + beteiligung_pruefen(object()) + + mock_set_variable.assert_called_once_with("verfahrensnummer", "12345", scope="project") + self.assertIn("12345", str(ctx.exception)) + + +class TestLadeBeteiligungCsv(unittest.TestCase): + def setUp(self): + self._tmp_files = [] + + def tearDown(self): + for pfad in self._tmp_files: + try: + os.remove(pfad) + except OSError: + pass + + def _schreibe_csv(self, header, zeilen): + fd, pfad = tempfile.mkstemp(suffix=".csv") + self._tmp_files.append(pfad) + with os.fdopen(fd, "w", encoding="utf-8", newline="") as f: + writer = csv.writer(f) + writer.writerow(header) + for zeile in zeilen: + writer.writerow(zeile) + return pfad + + @patch(f"{_MOD}.QFileDialog") + def test_dialog_abgebrochen_gibt_none(self, mock_fd): + mock_fd.getOpenFileName.return_value = ("", "") + self.assertIsNone(lade_beteiligung_csv(None)) + + @patch(f"{_MOD}.QFileDialog") + def test_fehlende_flurstueck_spalte_wirft_fehler(self, mock_fd): + pfad = self._schreibe_csv(["FOO", "BAR"], [["1", "2"]]) + mock_fd.getOpenFileName.return_value = (pfad, "") + with self.assertRaises(BeteiligungPruefungFehler) as ctx: + lade_beteiligung_csv(None) + self.assertIn(CSV_SPALTE_FLURSTUECK, str(ctx.exception)) + + @patch(f"{_MOD}.QFileDialog") + def test_leere_datei_wirft_fehler(self, mock_fd): + fd, pfad = tempfile.mkstemp(suffix=".csv") + os.close(fd) + self._tmp_files.append(pfad) + mock_fd.getOpenFileName.return_value = (pfad, "") + with self.assertRaises(BeteiligungPruefungFehler): + lade_beteiligung_csv(None) + + @patch(f"{_MOD}.QFileDialog") + def test_erfolgsfall_gibt_zeilen_und_spalten_zurueck(self, mock_fd): + pfad = self._schreibe_csv( + [CSV_SPALTE_VEF_VKZF, "FLA_Beteiligung", CSV_SPALTE_FLURSTUECK], + [["12345", "1", "1234/123/45/00"]], + ) + mock_fd.getOpenFileName.return_value = (pfad, "") + zeilen, spalten = lade_beteiligung_csv(None) + self.assertEqual(len(zeilen), 1) + self.assertIn(CSV_SPALTE_FLURSTUECK, spalten) + self.assertEqual(zeilen[0][CSV_SPALTE_FLURSTUECK], "1234/123/45/00") + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/tabs/working_tab.py b/ui/tabs/working_tab.py index 5c090d8..348bce8 100644 --- a/ui/tabs/working_tab.py +++ b/ui/tabs/working_tab.py @@ -26,6 +26,10 @@ from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import ( BROR_LAYER_NAME, ) from sn_verfahrensgebiet.functions.verfahrensgebiet_shape_komplett import verfahrensgebiet_shape_komplett +from sn_verfahrensgebiet.functions.beteiligung_pruefung import ( + beteiligung_pruefen, + BeteiligungPruefungFehler, +) 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, @@ -75,6 +79,15 @@ class WorkingTab(QWidget): row_verf_buttons.addWidget(self.btn_verf_shape) layout.addLayout(row_verf_buttons) + row_beteiligung_buttons = QHBoxLayout() + self.btn_beteiligung_pruefen = QPushButton("Beteiligung prüfen") + self.btn_beteiligung_pruefen.setToolTip( + "CSV-Export aus dem BI-Server verwenden. benötigte Spalten: " + "FLA_Verfahren-VEF_VKZF, FLA-Flurstuecke-Flurstueck, FLA_Beteiligung" + ) + row_beteiligung_buttons.addWidget(self.btn_beteiligung_pruefen) + layout.addLayout(row_beteiligung_buttons) + # Abschnitt 1b: Verfahrensgebiet-Layer auswählen row_layer_label = QHBoxLayout() lbl_layer = QLabel("Oder: Verfahrensgebiet-Layer auswählen") @@ -174,6 +187,7 @@ class WorkingTab(QWidget): 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_shape.clicked.connect(self.handle_flurst_shape) + self.btn_beteiligung_pruefen.clicked.connect(self.handle_beteiligung_pruefen) # Beim Init: aktuelle Verfahrensgebiet-Layer-ID laden und im Dropdown anzeigen self._restore_verf_layer_selection() @@ -285,6 +299,42 @@ class WorkingTab(QWidget): finally: self._set_busy(self.btn_verf_shape, False, "Aus Shape laden") + def handle_beteiligung_pruefen(self) -> None: + """Gleicht eine BI-Server-CSV-Beteiligungsliste mit dem Verfahrensgebiet ab. + + Delegiert an :func:`~sn_verfahrensgebiet.functions.beteiligung_pruefung.beteiligung_pruefen`. + Ist noch kein (verwertbares) Verfahrensgebiet geladen, fordert die + Funktion den Nutzer per Meldung auf, es zunächst über "Aus ALKIS + laden" zu laden – es gibt kein automatisches Laden im Hintergrund. + Sonst werden ein oder mehrere Ergebnis-Layer ins Projekt eingefügt, + gestylt und eine Statistik als Messagebox angezeigt. + """ + self._set_busy(self.btn_beteiligung_pruefen, True, "Beteiligung prüfen") + try: + try: + ergebnis = beteiligung_pruefen(self) + except BeteiligungPruefungFehler as exc: + QMessageBox.critical(self, "Beteiligung prüfen", str(exc)) + return + + if ergebnis is None: + return # Nutzerabbruch des Dateidialogs + + project = QgsProject.instance() + for layer, style_name in ergebnis.layers: + if layer is None or not layer.isValid(): + continue + if not project.mapLayer(layer.id()): + project.addMapLayer(layer) + if style_name: + apply_style(layer, style_name) + + iface.mapCanvas().refresh() + QMessageBox.information(self, "Beteiligung prüfen", ergebnis.statistik_text) + + finally: + self._set_busy(self.btn_beteiligung_pruefen, False, "Beteiligung prüfen") + def _set_busy(self, button: QPushButton, busy: bool, original_text: str = "Aus ALKIS laden") -> None: """Setzt den Busy-State eines Buttons.