Compare commits

..
Author SHA1 Message Date
22ottomi 4e7f796840 Merge pull request 'Feature/mehrfachauswahl alkis' (#40) from feature/mehrfachauswahl-alkis into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #40
2026-08-31 09:13:12 +02:00
Michael Otto 63a70737f9 Merge branch 'unstable' into aus-zip-laden
Konflikte aufgelöst wie mit Daniel abgestimmt:
- functions/beteiligung_pruefung.py, tests/test_beteiligung_pruefung.py:
  unsere Version behalten (ohne silent-Parameter, Aufforderung statt
  automatischem Hintergrund-Laden bei try2/try3).
- functions/verfahrensgebiet_alkis.py, functions/verfahrensgebiet_alkis_komplett.py:
  unsere Version behalten (Zwei-Dialog-Split, Landkreis-Fallback; Daniels
  silent-Parameter dadurch nicht mehr benötigt).
- ui/tabs/working_tab.py: Docstring-Konflikt trivial aufgelöst, unsere
  Fassung behalten (beschreibt das tatsächliche Verhalten ohne silent).
- changelog.txt: beide Versionsblöcke zusammengeführt.
2026-08-31 08:53:52 +02:00
Michael OttoandClaude Sonnet 5 d5db43bc57 Beteiligungsprüfung portiert (ohne automatisches Hintergrund-Laden)
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 <noreply@anthropic.com>
2026-08-31 06:33:09 +02:00
Michael OttoandClaude Sonnet 5 7b48d29d56 Kein Fehlerdialog mehr bei Abbruch des Landkreis-Auswahldialogs
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 <noreply@anthropic.com>
2026-08-28 14:44:25 +02:00
Michael OttoandClaude Sonnet 5 5816b2af22 Landkreis-Auswahldialog bei fehlenden Projekteigenschaften, Style-Fix "Nur laden"
- 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 <noreply@anthropic.com>
2026-08-28 13:55:03 +02:00
Michael Otto 148d4dfa15 BROR-Mehrfachauswahl, zweistufiger Fortschrittsdialog, Landkreis-Fallback
- 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).
2026-08-27 15:14:07 +02:00
9 changed files with 969 additions and 321 deletions
+11 -1
View File
@@ -1,5 +1,15 @@
- 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)
- 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.8.1-unstable:
-Vergleich einer CSV aus dem BI-Server mit den Beteiligten Flurstücken lt. ALKIS
- 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
+44 -78
View File
@@ -12,19 +12,23 @@ ab. Aus ``FLURSTUECK`` (Format ``<Gemarkung>/<Zähler>/<Nenner>/<Folge>``) wird
je Zeile ein zu ALKIS kompatibles Flurstückskennzeichen ``Flurstueck_csv``
gebildet.
Automatische Kaskade je nach Projektzustand:
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 → die Pipeline "Verfahrensgebiet aus ALKIS laden"
(:func:`verfahrensgebiet_alkis_komplett`) wird silent (ohne
Nutzerrückfragen, aber inkl. Detailprüfung) durchlaufen, um einen
vollständigen, aktuellen Bestand zu erzeugen – danach wie try1.
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`` als Projektvariable setzen, danach wie try2.
``VEF_VKZF`` wird als Projektvariable übernommen, danach wie try2 (Meldung
statt automatischem Laden).
"""
from __future__ import annotations
@@ -38,11 +42,7 @@ 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_basis.functions.ly_style_wrapper import apply_style
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import (
verfahrensgebiet_alkis_komplett,
)
from sn_verfahrensgebiet.functions.flurstueck_alkis import FLST_LAYER_NAME
from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import (
VERF_LAYER_NAME,
@@ -283,9 +283,6 @@ def _try1_listenabgleich(
3, "Beteiligung prüfen", "Verfahrensgebiet-Kennzeichen einlesen …"
)
progress.set_value(0)
# Ohne eine dazwischenliegende Nutzerinteraktion (z.B. nach der silent
# durchlaufenen ALKIS-Pipeline in try2/try3) holt sich kein anderer
# Dialog den Fokus zurück – Dialog daher explizit in den Vordergrund holen.
progress.raise_to_front()
verf_menge = get_kennzeichen_menge(verf_layer)
@@ -346,68 +343,38 @@ def _try1_listenabgleich(
# ---------------------------------------------------------------------------
# try2/try3: ALKIS-Pipeline (silent) + Listenabgleich wie try1
# 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 _lade_via_alkis_pipeline_und_vergleiche(
tab_widget, flurstueck_csv_liste: List[str]
) -> BeteiligungErgebnis:
"""Lädt das Verfahrensgebiet silent per ALKIS-Pipeline (inkl. Detailprüfung)
und gleicht den Bestand danach wie in try1 gegen die CSV-Liste ab.
Raises
------
BeteiligungPruefungFehler
Wenn die ALKIS-Pipeline kein Verfahrensgebiet liefert (Pipeline hat
bereits eine erklärende Messagebox gezeigt).
"""
enriched_layer, flst_layer, bror_layer, status = verfahrensgebiet_alkis_komplett(
tab_widget, silent=True
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 enriched_layer is None:
if vef_vkzf:
raise BeteiligungPruefungFehler(
"Verfahrensgebiet konnte über die ALKIS-Pipeline nicht geladen werden."
f"Es ist noch kein Verfahrensgebiet geladen. Die Verfahrensnummer "
f"{vef_vkzf} wurde aus der CSV übernommen. {hinweis}"
)
project = QgsProject.instance()
if flst_layer is not None and flst_layer.isValid():
if not project.mapLayersByName(flst_layer.name()):
project.addMapLayer(flst_layer)
apply_style(flst_layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml")
if bror_layer is not None and bror_layer.isValid():
if not project.mapLayer(bror_layer.id()):
project.addMapLayer(bror_layer)
apply_style(bror_layer, "BauRaumOderBodenordnungsrecht.qml")
if not project.mapLayer(enriched_layer.id()):
project.addMapLayer(enriched_layer)
apply_style(enriched_layer, "verfahrensgebiet.qml")
vergleich_layer, statistik = _try1_listenabgleich(
tab_widget, enriched_layer, flurstueck_csv_liste
raise BeteiligungPruefungFehler(
f"Der vorhandene Verfahrensgebiet-Layer enthält keine verwertbaren "
f"Flurstückskennzeichen. {hinweis}"
)
layers: List[Tuple[object, Optional[str]]] = [
(enriched_layer, None),
(vergleich_layer, "beteiligung_vergleich.qml"),
]
if flst_layer is not None:
layers.append((flst_layer, None))
if bror_layer is not None:
layers.append((bror_layer, None))
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("try2", layers, statistik, text)
# ---------------------------------------------------------------------------
# try3: Bootstrap (Verfahrensnummer aus CSV) + wie try2
# try3: Bootstrap (Verfahrensnummer aus CSV) + Aufforderung wie try2
# ---------------------------------------------------------------------------
def _lese_vef_vkzf(zeilen: List[dict], spaltennamen: List[str]) -> str:
@@ -424,16 +391,10 @@ def _lese_vef_vkzf(zeilen: List[dict], spaltennamen: List[str]) -> str:
)
def _try3_bootstrap_und_try2(
tab_widget, zeilen: List[dict], spaltennamen: List[str], flurstueck_csv_liste: List[str]
) -> BeteiligungErgebnis:
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")
ergebnis = _lade_via_alkis_pipeline_und_vergleiche(tab_widget, flurstueck_csv_liste)
ergebnis.modus = "try3"
ergebnis.statistik_text = f"Verfahrensnummer aus CSV: {vef_vkzf}\n" + ergebnis.statistik_text
return ergebnis
_fordere_manuelles_laden_an(vef_vkzf)
# ---------------------------------------------------------------------------
@@ -453,8 +414,11 @@ def beteiligung_pruefen(tab_widget) -> Optional[BeteiligungErgebnis]:
------
BeteiligungPruefungFehler
Bei einem der dokumentierten Fehlerfälle (CSV-Format, fehlende
Spalten, WFS-Dienst nicht erreichbar). Der Aufrufer zeigt daraufhin
eine Messagebox an.
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:
@@ -481,6 +445,8 @@ def beteiligung_pruefen(tab_widget) -> Optional[BeteiligungErgebnis]:
return BeteiligungErgebnis("try1", [(layer, "beteiligung_vergleich.qml")], statistik, text)
if verf_layer is not None:
return _lade_via_alkis_pipeline_und_vergleiche(tab_widget, flurstueck_csv_liste)
_fordere_manuelles_laden_an()
return None # unreachable – _fordere_manuelles_laden_an() wirft immer
return _try3_bootstrap_und_try2(tab_widget, zeilen, spaltennamen, flurstueck_csv_liste)
_try3_bootstrap(tab_widget, zeilen, spaltennamen)
return None # unreachable – _try3_bootstrap() wirft immer
+194 -30
View File
@@ -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_kombination
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"
@@ -72,40 +73,36 @@ def _check_existing_layer(tab_widget):
return existing_layers[0], LoadStatus.KEEP
def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False, silent: 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).
silent: Automatisierter Modus ohne Nutzerinteraktion. Impliziert
``skip_existing_check=True`` und wählt bei mehreren
BROR-Kombinationen automatisch die erste (sortierte) statt den
Nutzer per Dialog entscheiden zu lassen.
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, []
skip_existing_check = skip_existing_check or silent
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.
@@ -119,13 +116,13 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False, silent
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] = {}
@@ -141,20 +138,32 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False, silent
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]
elif silent:
# Automatisierter Modus: erste (sortierte) Kombination ohne Nachfrage
selected_key = sorted(kombinationen.keys())[0]
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():
@@ -162,7 +171,6 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False, silent
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])
@@ -192,3 +200,159 @@ def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False, silent
# 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
# "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 '{praefix}%'"
)
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_anzeige} 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_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
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)
+128 -62
View File
@@ -52,9 +52,15 @@ 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 (
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 (
@@ -76,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
@@ -254,20 +262,29 @@ def _vergleiche_flaechen(
def verfahrensgebiet_alkis_komplett(
tab_widget,
silent: bool = False,
) -> Tuple[Optional[object], Optional[object], Optional[object], LoadStatus]:
"""Vollständiger VG-Lade-Workflow: ALKIS-VG → Gemarkungen →
Flurstücke → Verschneidung → Flächenvergleich → opt. Umringsprüfung.
Schrittfolge:
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
@@ -277,15 +294,6 @@ def verfahrensgebiet_alkis_komplett(
----------
tab_widget :
Eltern-Widget für alle Dialoge.
silent :
Automatisierter Modus ohne Nutzerentscheidungen: bestehender
VG-Layer wird ohne Nachfrage neu geladen, mehrdeutige
BROR-Kombinationen werden automatisch aufgelöst, bei
Flächenabweichung wird immer die Detailprüfung gestartet (nie „als
VG laden“, nie abbrechen), und die beiden rein informativen
Abschluss-Meldungen (Flächenvergleich-OK, Detailprüfung
abgeschlossen) werden unterdrückt. Die Fortschrittsdialoge bleiben
sichtbar.
Returns
-------
@@ -300,24 +308,75 @@ 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")
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():
if not verfahrensnummer and not landkreis_kennzahl:
# Keine Projekteigenschaften vorhanden – Landkreis per Dialog abfragen,
# statt direkt abzubrechen.
landkreis_kennzahl = ask_landkreis_auswahl(tab_widget, LANDKREISE)
if not landkreis_kennzahl:
# Nutzer hat den Landkreis-Dialog abgebrochen – stiller Abbruch,
# keine Fehlermeldung (bewusster Abbruch, kein Fehlerzustand).
return None, None, None, LoadStatus.NONE
alkis_vg_layer, vg_status, _ = verfahrensgebiet_alkis(tab_widget, silent=silent)
progress.raise_to_front()
landkreis_modus = not verfahrensnummer
# ------------------------------------------------------------------
# Dialog 1: Verfahrensgebiet(e) in ALKIS ermitteln/suchen
# ------------------------------------------------------------------
if landkreis_modus:
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 …"
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:
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
@@ -327,13 +386,29 @@ 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)
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
@@ -354,12 +429,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
@@ -373,12 +448,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
@@ -393,12 +468,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
@@ -417,12 +492,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
@@ -431,12 +506,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()
if not silent:
progress.set_value(6)
show_info_dialog(
"Flächenvergleich",
"Verfahrensgebiet mit erwarteter Geometrie erzeugt",
@@ -446,14 +519,9 @@ def verfahrensgebiet_alkis_komplett(
return enriched_layer, flst_layer, alkis_vg_layer, vg_status
# ------------------------------------------------------------------
# Flächenabweichung → 3-Optionen-Dialog (im silent-Modus: immer
# Detailprüfung, ohne Nachfrage)
# Flächenabweichung → 3-Optionen-Dialog
# ------------------------------------------------------------------
progress.close()
diff = abs(bror_flaeche - vg_flaeche)
if silent:
entscheidung = "detailpruefung"
else:
entscheidung = ask_detailpruefung_oder_vg_laden(
tab_widget,
"Abweichung erkannt",
@@ -496,10 +564,6 @@ def verfahrensgebiet_alkis_komplett(
"Schritt 1: Grenzpunkte laden …",
)
detail_progress.set_value(0)
# Ohne die (im silent-Modus übersprungene) Nachfrage vor diesem Punkt
# holt sich kein anderer Dialog mehr den Fokus zurück – Dialog daher
# explizit in den Vordergrund holen, sonst bleibt er unsichtbar.
detail_progress.raise_to_front()
# Schritt 1: PunktortTA gemarkungsweise laden (Hits + 5000er-Batches)
punktort_layer = lade_punktort_ta(
@@ -569,7 +633,6 @@ def verfahrensgebiet_alkis_komplett(
if ergebnis_layer and ergebnis_layer.isValid():
project.addMapLayer(ergebnis_layer)
if not silent:
show_info_dialog(
"Detailprüfung abgeschlossen",
f"Knickpunkte geprüft: {n_knick}\n"
@@ -583,6 +646,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)
+299
View File
@@ -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("<small>Mehrfachauswahl mit Strg + Umschalt</small>")
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("<small>Mehrfachauswahl mit Strg + Umschalt</small>")
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))
+65 -73
View File
@@ -19,6 +19,11 @@ lade_beteiligung_csv:
- 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
@@ -48,13 +53,13 @@ from sn_verfahrensgebiet.functions.beteiligung_pruefung import (
baue_flurstueck_csv,
baue_flurstueck_csv_liste,
lade_beteiligung_csv,
beteiligung_pruefen,
BeteiligungPruefungFehler,
BeteiligungErgebnis,
FELD_FLURSTUECK_CSV,
CSV_SPALTE_FLURSTUECK,
CSV_SPALTE_VEF_VKZF,
_lade_via_alkis_pipeline_und_vergleiche,
_try3_bootstrap_und_try2,
_fordere_manuelles_laden_an,
_try3_bootstrap,
_hole_verfahrensgebiet_layer,
)
@@ -170,86 +175,73 @@ class TestHoleVerfahrensgebietLayer(unittest.TestCase):
self.assertIsNone(_hole_verfahrensgebiet_layer(tab_widget))
class TestLadeViaAlkisPipelineUndVergleiche(unittest.TestCase):
@patch(f"{_MOD}._try1_listenabgleich")
@patch(f"{_MOD}.apply_style")
@patch(f"{_MOD}.QgsProject")
@patch(f"{_MOD}.verfahrensgebiet_alkis_komplett")
def test_pipeline_fehler_wirft_beteiligung_pruefung_fehler(
self, mock_pipeline, mock_project_cls, mock_apply_style, mock_try1
):
mock_pipeline.return_value = (None, None, None, None)
with self.assertRaises(BeteiligungPruefungFehler):
_lade_via_alkis_pipeline_und_vergleiche(object(), ["145678___00001____01"])
mock_try1.assert_not_called()
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."""
@patch(f"{_MOD}._try1_listenabgleich")
@patch(f"{_MOD}.apply_style")
@patch(f"{_MOD}.QgsProject")
@patch(f"{_MOD}.verfahrensgebiet_alkis_komplett")
def test_ruft_pipeline_silent_auf_und_delegiert_an_try1(
self, mock_pipeline, mock_project_cls, mock_apply_style, mock_try1
):
tab_widget = object()
enriched = MagicMock(name="enriched")
enriched.isValid.return_value = True
enriched.id.return_value = "vg1"
flst = MagicMock(name="flst")
flst.isValid.return_value = True
flst.name.return_value = "Flurstücke"
bror = MagicMock(name="bror")
bror.isValid.return_value = True
bror.id.return_value = "bror1"
mock_pipeline.return_value = (enriched, flst, bror, "FIRST")
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))
mock_project = MagicMock()
mock_project.mapLayersByName.return_value = []
mock_project.mapLayer.return_value = None
mock_project_cls.instance.return_value = mock_project
vergleich_layer = MagicMock(name="vergleich")
statistik = {"beide": 1, "nur_verfahrensgebiet": 2, "nur_aglb": 3}
mock_try1.return_value = (vergleich_layer, statistik)
flurstueck_csv_liste = ["145678___00001____01"]
ergebnis = _lade_via_alkis_pipeline_und_vergleiche(tab_widget, flurstueck_csv_liste)
mock_pipeline.assert_called_once_with(tab_widget, silent=True)
mock_try1.assert_called_once_with(tab_widget, enriched, flurstueck_csv_liste)
self.assertIsInstance(ergebnis, BeteiligungErgebnis)
self.assertEqual(ergebnis.modus, "try2")
self.assertEqual(ergebnis.statistik, statistik)
self.assertIn("Übereinstimmend: 1", ergebnis.statistik_text)
layers = [lyr for lyr, _ in ergebnis.layers]
self.assertIn(enriched, layers)
self.assertIn(vergleich_layer, layers)
self.assertIn(flst, layers)
self.assertIn(bror, layers)
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 TestTry3BootstrapUndTry2(unittest.TestCase):
@patch(f"{_MOD}._lade_via_alkis_pipeline_und_vergleiche")
class TestTry3Bootstrap(unittest.TestCase):
@patch(f"{_MOD}.set_variable")
def test_setzt_verfahrensnummer_und_ergaenzt_statistik_text(
self, mock_set_variable, mock_lade
):
basis_ergebnis = BeteiligungErgebnis(
modus="try2",
layers=[],
statistik={"beide": 0, "nur_verfahrensgebiet": 0, "nur_aglb": 0},
statistik_text="Übereinstimmend: 0\n...",
)
mock_lade.return_value = basis_ergebnis
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]
ergebnis = _try3_bootstrap_und_try2(object(), zeilen, spaltennamen, ["kz1"])
with self.assertRaises(BeteiligungPruefungFehler) as ctx:
_try3_bootstrap(object(), zeilen, spaltennamen)
mock_set_variable.assert_called_once_with("verfahrensnummer", "12345", scope="project")
self.assertEqual(ergebnis.modus, "try3")
self.assertTrue(ergebnis.statistik_text.startswith("Verfahrensnummer aus CSV: 12345"))
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):
+33 -4
View File
@@ -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()
+138 -25
View File
@@ -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,9 @@ _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"
_PATCH_ASK_LANDKREIS = f"{_MOD}.ask_landkreis_auswahl"
# ---------------------------------------------------------------------------
@@ -265,9 +268,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 +285,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 +307,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 +336,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 +363,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 +406,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 +445,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 +484,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 +529,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 +585,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 +638,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 +655,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 +673,110 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
self.assertIn("BROR_ERR", proj_inst.removed)
# ------------------------------------------------------------------ #
# Landkreis-Fallback (keine Verfahrensnummer)
# ------------------------------------------------------------------ #
@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_dialog_abgebrochen(
self, mock_get_var, mock_ask_landkreis, mock_msgbox, mock_prog
):
"""Weder Verfahrensnummer noch Landkreis gesetzt, Nutzer bricht den
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_not_called()
@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)
@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()
+16 -7
View File
@@ -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.beteiligung_pruefung import (
beteiligung_pruefen,
@@ -215,10 +218,14 @@ 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).
# 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)
@@ -296,9 +303,11 @@ class WorkingTab(QWidget):
"""Gleicht eine BI-Server-CSV-Beteiligungsliste mit dem Verfahrensgebiet ab.
Delegiert an :func:`~sn_verfahrensgebiet.functions.beteiligung_pruefung.beteiligung_pruefen`.
Je nach Projektzustand (try1/try2/try3) werden ein oder mehrere
Ergebnis-Layer ins Projekt eingefügt, gestylt und eine Statistik als
Messagebox angezeigt.
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: