Compare commits

...
10 Commits
Author SHA1 Message Date
Daniel 6a65c9d706 changelog.txt aktualisiert
Release Plugin / release (push) Successful in 3s
2026-08-26 16:19:14 +02:00
Daniel 1a2a22f931 Merge pull request 'Beteiligung pruefen' (#34) from beteiligung_pruefen into unstable
Reviewed-on: #34
2026-08-26 16:15:53 +02:00
Daniel 4a557e66a7 try1-fix: direkte Prüfung ohne ALKIS-Pipeline 2026-08-26 16:13:51 +02:00
Daniel 6bc5256d5f try2 und try3 laden das Verfahrensgebiet mit Detailprüfung anstelle der Flurstücke vom wfs 2026-08-26 16:08:01 +02:00
Daniel 2cb6193b98 csv-header korrigiert 2026-08-26 14:30:07 +02:00
Daniel e6332e42ad Funktion angelegt, Button eingefügt 2026-08-26 12:19:46 +02:00
Daniel 2cf0cda199 changelog.txt aktualisiert
Release Plugin / release (push) Successful in 3s
2026-07-15 16:06:55 +02:00
Daniel fd54186d62 Merge pull request 'Shape verfahrensgebiet' (#30) from shape-verfahrensgebiet into unstable
Reviewed-on: #30
2026-07-15 16:06:28 +02:00
Daniel 6e0bd213de Vergleich BROR-VG 2026-07-15 15:39:21 +02:00
Daniel f37f33584d shape Verfahrensgebiet ergänzt 2026-07-15 15:29:48 +02:00
12 changed files with 2954 additions and 32 deletions
+6
View File
@@ -1,3 +1,9 @@
Version 26.8.1-unstable:
-Vergleich einer CSV aus dem BI-Server mit den Beteiligten Flurstücken lt. ALKIS
---
Version 26.7.1-unstable:
- Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt
---
- Knickpunktprüfung "fehlende Knickpunkte" auf Extraktion der Verbindungslinien zwischen bekannten Punktorten umgestellt
- Umringsdetektion und Ladepuffer Punktort_TA als Layer im Projekt bei der Knickpunktprüfung ergänzt
- Verfahrensgebiet-Bildung mit korrigierten Knickpunkten (keine manuelle Korrektur der Flurstückszugehörigkeit mehr erforderlich)
+486
View File
@@ -0,0 +1,486 @@
"""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 ``<Gemarkung>/<Zähler>/<Nenner>/<Folge>``) wird
je Zeile ein zu ALKIS kompatibles Flurstückskennzeichen ``Flurstueck_csv``
gebildet.
Automatische 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.
- **try3**: Kein Verfahrensgebiet-Layer im Projekt → Verfahrensnummer aus
``VEF_VKZF`` als Projektvariable setzen, danach wie try2.
"""
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_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,
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
``<Gemarkungsnummer>/<Zähler>/<Nenner>/<Folge>``. 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)
# 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)
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: ALKIS-Pipeline (silent) + Listenabgleich wie try1
# ---------------------------------------------------------------------------
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
)
if enriched_layer is None:
raise BeteiligungPruefungFehler(
"Verfahrensgebiet konnte über die ALKIS-Pipeline nicht geladen werden."
)
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
)
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
# ---------------------------------------------------------------------------
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_und_try2(
tab_widget, zeilen: List[dict], spaltennamen: List[str], flurstueck_csv_liste: List[str]
) -> BeteiligungErgebnis:
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
# ---------------------------------------------------------------------------
# Ö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, WFS-Dienst nicht erreichbar). 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:
return _lade_via_alkis_pipeline_und_vergleiche(tab_widget, flurstueck_csv_liste)
return _try3_bootstrap_und_try2(tab_widget, zeilen, spaltennamen, flurstueck_csv_liste)
+19 -5
View File
@@ -72,11 +72,17 @@ def _check_existing_layer(tab_widget):
return existing_layers[0], LoadStatus.KEEP
def verfahrensgebiet_alkis(tab_widget):
def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False, silent: bool = False):
"""Lädt Verfahrensgebiet aus ALKIS-WFS basierend auf Verfahrensnummer.
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]]:
@@ -92,10 +98,14 @@ def verfahrensgebiet_alkis(tab_widget):
"In den Projekteigenschaften ist noch keine Verfahrensnummer eingetragen!")
return None, LoadStatus.NONE, []
# Vorhandenen Layer prüfen
existing, status = _check_existing_layer(tab_widget)
if status == LoadStatus.KEEP:
return existing, status, []
skip_existing_check = skip_existing_check or silent
# 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, []
# 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.
@@ -134,6 +144,10 @@ def verfahrensgebiet_alkis(tab_widget):
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]
else:
# Mehrere Kombinationen → Nutzerauswahl
key_list = sorted(kombinationen.keys())
+45 -25
View File
@@ -254,6 +254,7 @@ 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.
@@ -276,6 +277,15 @@ 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
-------
@@ -305,7 +315,7 @@ def verfahrensgebiet_alkis_komplett(
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
alkis_vg_layer, vg_status, _ = verfahrensgebiet_alkis(tab_widget)
alkis_vg_layer, vg_status, _ = verfahrensgebiet_alkis(tab_widget, silent=silent)
progress.raise_to_front()
if vg_status == LoadStatus.NONE or not alkis_vg_layer:
@@ -426,29 +436,34 @@ def verfahrensgebiet_alkis_komplett(
if stimmt_ueberein:
progress.set_value(7)
progress.close()
show_info_dialog(
"Flächenvergleich",
"Verfahrensgebiet mit erwarteter Geometrie erzeugt",
parent=tab_widget,
)
if not silent:
show_info_dialog(
"Flächenvergleich",
"Verfahrensgebiet mit erwarteter Geometrie erzeugt",
parent=tab_widget,
)
abgeschlossen = True
return enriched_layer, flst_layer, alkis_vg_layer, vg_status
# ------------------------------------------------------------------
# Flächenabweichung → 3-Optionen-Dialog
# Flächenabweichung → 3-Optionen-Dialog (im silent-Modus: immer
# Detailprüfung, ohne Nachfrage)
# ------------------------------------------------------------------
progress.close()
diff = abs(bror_flaeche - vg_flaeche)
entscheidung = ask_detailpruefung_oder_vg_laden(
tab_widget,
"Abweichung erkannt",
f"Abweichung zwischen BauRaumOderBodenordnungsrecht-Objekt und abgeleitetem "
f"Verfahrensgebiet erkannt.\n\n"
f"BauRaumOderBodenordnungsrecht: {bror_flaeche:.2f} m²\n"
f"Verfahrensgebiet: {vg_flaeche:.2f} m²\n"
f"Differenz: {diff:.2f} m²\n\n"
f"Wie möchten Sie fortfahren?",
)
if silent:
entscheidung = "detailpruefung"
else:
entscheidung = ask_detailpruefung_oder_vg_laden(
tab_widget,
"Abweichung erkannt",
f"Abweichung zwischen BauRaumOderBodenordnungsrecht-Objekt und abgeleitetem "
f"Verfahrensgebiet erkannt.\n\n"
f"As: {bror_flaeche:.2f} m²\n"
f"Verfahrensgebiet: {vg_flaeche:.2f} m²\n"
f"Differenz: {diff:.2f} m²\n\n"
f"Wie möchten Sie fortfahren?",
)
if entscheidung == "abbrechen":
abgeschlossen = True
@@ -481,6 +496,10 @@ 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(
@@ -550,14 +569,15 @@ def verfahrensgebiet_alkis_komplett(
if ergebnis_layer and ergebnis_layer.isValid():
project.addMapLayer(ergebnis_layer)
show_info_dialog(
"Detailprüfung abgeschlossen",
f"Knickpunkte geprüft: {n_knick}\n"
f" Identisch (grün): {identisch}\n"
f" Kein PunktortTA (rot): {kein_pta}\n"
f" Fehlende Knickpunkte (orange): {fehlender_knick}",
parent=tab_widget,
)
if not silent:
show_info_dialog(
"Detailprüfung abgeschlossen",
f"Knickpunkte geprüft: {n_knick}\n"
f" Identisch (grün): {identisch}\n"
f" Kein PunktortTA (rot): {kein_pta}\n"
f" Fehlende Knickpunkte (orange): {fehlender_knick}",
parent=tab_widget,
)
abgeschlossen = True
return enriched_layer, flst_layer, alkis_vg_layer, vg_status
@@ -0,0 +1,703 @@
"""sn_verfahrensgebiet/functions/verfahrensgebiet_shape_komplett.py – VG-Lade-Workflow aus Shape (ZIP).
Orchestriert den vollständigen Arbeitsablauf zum Laden eines Verfahrensgebiets
aus einem ZIP-Archiv mit Shapefile-Set:
1. Dateidialog öffnen (.zip-Filter); Nutzerabbruch beendet den Vorgang.
2. ZIP neben der ZIP-Datei entpacken und auf ein gültiges Shapefile-Set prüfen.
3. Shapefile als QGIS-Vektorlayer laden und validieren (Standard-QGIS-Routinen).
4. Alle Features per ``QgsGeometry.unaryUnion`` zu einem Single-Feature-
Memory-Layer „Verfahrensgebiet" verschmelzen.
5. Gemarkungsschlüssel per Geometrie aus ``AX_Gemarkung`` vorschlagen.
6. Flurstücke per Dialog (mit Vorschlag) laden.
7. Flurstücke räumlich auf das Shape-VG einschränken (``selectByLocation``).
8. Temporären enriched Layer aus Auswahl für den Flächenvergleich erzeugen
(wird **nicht** ins Projekt eingefügt und **nicht** zurückgegeben).
9. Flächenvergleich zwischen Shape-VG und temporärem enriched Layer.
- **Übereinstimmung** (≤ Toleranz): modale OK-Meldung, Ablauf beendet.
- **Abweichung**: 2-Optionen-Dialog (Detailprüfung / Abbrechen).
10. Bei Wahl „Detailprüfung" (knickpunktbasiert):
- Grenzpunkte (AX_PunktortTA) per BBox laden.
- Knickpunkte des Shape-VG mit PunktortTA vergleichen (grün/rot/orange).
- Korrigiertes Polygon aus identischen + gefundenen PunktortTA ableiten.
- Ergebnislayer (Punkte), Verbindungslinien und Polygon ins Projekt einfügen.
- Abschlussmeldung mit Zählung.
Das Shape-VG (``Verfahrensgebiet``) ist das finale Rückgabe-Layer. Ein
``enriched Layer aus Flurstücken`` wird nur temporär für den Flächenvergleich
erzeugt, aber weder ins Projekt eingefügt noch zurückgegeben.
**Rückgabe:**
``Tuple[shape_vg_layer, flst_layer, None, LoadStatus]``
**Abbruchverhalten:** Jeder Schritt, der :attr:`LoadStatus.NONE` zurückgibt
oder kein Ergebnis liefert, bricht den Workflow ab.
"""
from __future__ import annotations
import os
import zipfile
from typing import Optional, Tuple
from sn_basis.functions import QgsProject, QgsVectorLayer, QgsFeature, QgsField
from sn_basis.functions import QMessageBox
from sn_basis.functions.message_wrapper import warning
from sn_basis.functions.variable_wrapper import get_variable
from sn_basis.functions.qt_wrapper import QFileDialog, QVariant
from sn_basis.functions.qgiscore_wrapper import QgsGeometry
from sn_basis.functions.dialog_wrapper import (
create_progress_dialog,
show_info_dialog,
ask_detailpruefung_oder_abbrechen,
)
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus, verfahrensgebiet_alkis
from sn_verfahrensgebiet.functions.gemarkung_alkis import (
gemarkung_schluessel_und_namen_von_vg_geometrie,
)
from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis
from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import (
erstelle_verfahrensgebiet_aus_auswahl,
VERF_LAYER_NAME,
)
from sn_verfahrensgebiet.functions.grenzpunkt_alkis import lade_punktort_ta
from sn_verfahrensgebiet.functions.knickpunkt_pruefung import (
knickpunkt_pruefung,
_extrahiere_knickpunkte,
erstelle_korrigiertes_polygon,
)
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import (
_select_by_location,
_vergleiche_flaechen,
_lese_flaechen_toleranz,
_entferne_hilfs_layer,
MINDEST_FLAECHEN_TOLERANZ_M2,
BROR_LAYER_NAME,
)
# ---------------------------------------------------------------------------
# Interne Hilfsfunktionen
# ---------------------------------------------------------------------------
def _wende_vergleich_stil_an(layer) -> None:
"""Wendet einen kategorisierten Renderer auf den Vergleich-Layer an.
Farben:
- ``identisch`` (Schnittmenge): Gr\u00fcn
- ``nur_bror`` (nur im BROR): Gelb
- ``nur_vg`` (nur im Verfahrensgebiet): Blau
Schl\u00e4gt der Import der QGIS-Rendering-Klassen fehl (z.\u00a0B. im
Test-Modus), wird der Fehler still ignoriert.
"""
try:
from qgis.core import ( # type: ignore[import]
QgsCategorizedSymbolRenderer,
QgsRendererCategory,
QgsFillSymbol,
)
_FARBEN = [
("identisch", "#00aa00", 0.5), # gr\u00fcn
("nur_bror", "#ffcc00", 0.5), # gelb
("nur_vg", "#3366ff", 0.5), # blau
]
kategorien = []
for wert, farbe, opazitaet in _FARBEN:
symbol = QgsFillSymbol.createSimple({
"color": farbe,
"outline_color": "#555555",
"outline_width": "0.26",
})
symbol.setOpacity(opazitaet)
kategorien.append(QgsRendererCategory(wert, symbol, wert))
renderer = QgsCategorizedSymbolRenderer("typ", kategorien)
layer.setRenderer(renderer)
except Exception:
pass
def _erstelle_vergleich_layer(vg_layer, bror_layer) -> Optional[object]:
"""Erzeugt einen Vergleich-Layer aus geometrischen Differenzoperationen.
F\u00fcr jeden Fl\u00e4chentyp wird ein Feature angelegt:
- ``identisch``: Schnittmenge aus VG und BROR (gr\u00fcn).
- ``nur_bror``: Fl\u00e4che, die nur im BROR-Objekt liegt (gelb).
- ``nur_vg``: Fl\u00e4che, die nur im Verfahrensgebiet liegt (blau).
Parameters
----------
vg_layer :
Verfahrensgebiet-Layer (Shape).
bror_layer :
BauRaumOderBodenordnungsrecht-Layer (ALKIS).
Returns
-------
Optional[QgsVectorLayer]
Memory-Layer ``Vergleich`` mit Stil oder ``None`` bei Fehler.
"""
try:
vg_geom = None
for feat in vg_layer.getFeatures():
g = feat.geometry()
if g and not g.isEmpty():
vg_geom = g
break
bror_geom = None
for feat in bror_layer.getFeatures():
g = feat.geometry()
if g and not g.isEmpty():
bror_geom = g
break
if not vg_geom or not bror_geom:
return None
geom_identisch = vg_geom.intersection(bror_geom)
geom_nur_bror = bror_geom.difference(vg_geom)
geom_nur_vg = vg_geom.difference(bror_geom)
crs = vg_layer.crs().authid()
mem_layer = QgsVectorLayer(
f"MultiPolygon?crs={crs}", "Vergleich", "memory"
)
pr = mem_layer.dataProvider()
pr.addAttributes([QgsField("typ", QVariant.String)])
mem_layer.updateFields()
feats = []
for geom, typ in [
(geom_identisch, "identisch"),
(geom_nur_bror, "nur_bror"),
(geom_nur_vg, "nur_vg"),
]:
if geom and not geom.isEmpty():
f = QgsFeature()
f.setGeometry(geom)
f.setAttributes([typ])
feats.append(f)
pr.addFeatures(feats)
mem_layer.updateExtents()
_wende_vergleich_stil_an(mem_layer)
return mem_layer if mem_layer.isValid() else None
except Exception:
return None
def _finde_shp(verzeichnis: str) -> Optional[str]:
"""Sucht rekursiv nach der ersten ``.shp``-Datei im Verzeichnis.
Parameters
----------
verzeichnis :
Startverzeichnis für die Suche.
Returns
-------
Optional[str]
Absoluter Pfad zur ersten gefundenen ``.shp``-Datei, oder ``None``.
"""
for root, _dirs, files in os.walk(verzeichnis):
for fname in files:
if fname.lower().endswith(".shp"):
return os.path.join(root, fname)
return None
def _erstelle_memory_layer_aus_shp(shp_layer) -> Optional[object]:
"""Vereint alle Features des Shapefile-Layers zu einem Single-Feature-
Memory-Layer „Verfahrensgebiet" via ``QgsGeometry.unaryUnion``.
Alle Felder des Shapefiles werden in den Memory-Layer übernommen.
Die Attributwerte des ersten Features mit gültiger Geometrie werden
auf das Union-Feature gesetzt.
Parameters
----------
shp_layer :
Geladener ``QgsVectorLayer`` aus dem Shapefile.
Returns
-------
Optional[QgsVectorLayer]
Single-Feature-Memory-Layer mit Union-Geometrie und Shapefile-Attributen
oder ``None`` bei Fehler.
"""
geom_list = []
first_feat = None
for feat in shp_layer.getFeatures():
try:
geom = feat.geometry()
if geom and not geom.isEmpty():
geom_list.append(geom)
if first_feat is None:
first_feat = feat
except Exception:
pass
if not geom_list:
return None
union_geom = QgsGeometry.unaryUnion(geom_list)
if not union_geom or union_geom.isEmpty():
return None
crs_authid = shp_layer.crs().authid()
mem_layer = QgsVectorLayer(
f"MultiPolygon?crs={crs_authid}", VERF_LAYER_NAME, "memory"
)
pr = mem_layer.dataProvider()
# Felder des Shapefiles übernehmen
try:
pr.addAttributes(list(shp_layer.fields()))
mem_layer.updateFields()
except Exception:
pass
feat_out = QgsFeature(mem_layer.fields())
feat_out.setGeometry(union_geom)
# Attributwerte vom ersten Feature übernehmen
if first_feat is not None:
try:
feat_out.setAttributes(first_feat.attributes())
except Exception:
pass
pr.addFeatures([feat_out])
mem_layer.updateExtents()
if not mem_layer.isValid() or mem_layer.featureCount() == 0:
return None
return mem_layer
# ---------------------------------------------------------------------------
# Öffentliche Lade-Hilfsfunktion
# ---------------------------------------------------------------------------
def lade_vg_aus_zip(parent_widget) -> Optional[object]:
"""Öffnet Dateidialog, entpackt ZIP und gibt Union-Memory-Layer zurück.
Schrittfolge:
1. ``QFileDialog.getOpenFileName`` mit Filter ``ZIP-Dateien (*.zip)``.
2. ZIP neben der Quelldatei in ein Unterverzeichnis entpacken.
3. Erste ``.shp``-Datei im Verzeichnis finden.
4. Als ``QgsVectorLayer`` laden und ``isValid()`` prüfen.
5. Alle Features per ``QgsGeometry.unaryUnion`` verschmelzen.
6. Single-Feature-Memory-Layer „Verfahrensgebiet" zurückgeben.
Parameters
----------
parent_widget :
Eltern-Widget für Dateidialog und Fehlermeldungen.
Returns
-------
Optional[QgsVectorLayer]
Valider Memory-Layer „Verfahrensgebiet" oder ``None`` bei Abbruch/Fehler.
"""
pfad, _ = QFileDialog.getOpenFileName(
parent_widget,
"ZIP-Datei mit Shapefile-Set auswählen",
"",
"ZIP-Dateien (*.zip)",
)
if not pfad:
return None # Nutzerabbruch
zip_dir = os.path.dirname(pfad)
zip_base = os.path.splitext(os.path.basename(pfad))[0]
extract_dir = os.path.join(zip_dir, zip_base)
try:
with zipfile.ZipFile(pfad, "r") as zf:
zf.extractall(extract_dir)
except Exception:
QMessageBox.warning(
parent_widget,
"Fehler",
"Die ZIP-Datei konnte nicht entpackt werden.\n"
"Bitte eine gültige ZIP-Datei auswählen.",
)
return None
shp_path = _finde_shp(extract_dir)
if not shp_path:
QMessageBox.warning(
parent_widget,
"Fehler",
"gültiges Shape-Dateiset angeben",
)
return None
raw_layer = QgsVectorLayer(shp_path, "tmp_shape_vg", "ogr")
if not raw_layer or not raw_layer.isValid():
QMessageBox.warning(
parent_widget,
"Fehler",
"gültiges Shape-Dateiset angeben",
)
return None
mem_layer = _erstelle_memory_layer_aus_shp(raw_layer)
if mem_layer is None:
QMessageBox.warning(
parent_widget,
"Fehler",
"gültiges Shape-Dateiset angeben",
)
return None
return mem_layer
# ---------------------------------------------------------------------------
# Öffentliche API
# ---------------------------------------------------------------------------
def verfahrensgebiet_shape_komplett(
tab_widget,
) -> Tuple[Optional[object], Optional[object], Optional[object], LoadStatus]:
"""Vollständiger VG-Lade-Workflow aus Shape (ZIP): ZIP → BROR → Gemarkungen →
Flurstücke → Verschneidung → Flächenvergleich → opt. Umringsprüfung.
Das aus der ZIP geladene Shape-VG ist das finale Verfahrensgebiet-Layer.
Ist die Projektvariable ``verfahrensnummer`` gesetzt, wird das zugehörige
BROR-Objekt (``AX_BauRaumOderBodenordnungsrecht``) aus ALKIS geladen und
als Vergleichsobjekt im Flächenvergleich verwendet. Andernfalls wird ein
temporärer enriched Layer aus Flurstücken für den Vergleich herangezogen.
Schrittfolge:
1. ``lade_vg_aus_zip(tab_widget)`` – ZIP öffnen, Shapefile validieren,
Union-Memory-Layer „Verfahrensgebiet" erzeugen.
2. ``verfahrensgebiet_alkis(tab_widget, skip_existing_check=True)`` –
BROR-Objekt als Memory-Layer (sofern VKZ gesetzt).
Der BROR wird **vor** dem Shape-VG ins Projekt eingefügt, um den
Namenskonflikt mit ``_check_existing_layer`` zu vermeiden.
3. Shape-VG ins Projekt einfügen; BROR umbenennen zu
``BauRaumOderBodenordnungsrecht (ALKIS)`` und zum Projekt hinzufügen.
4. ``gemarkung_schluessel_und_namen_von_vg_geometrie(geom)`` – Schlüssel.
5. ``flurstueck_alkis(...)`` – Flurstücke laden.
6. ``_select_by_location(...)`` – räumliche Selektion.
7. Flächenvergleich:
- Mit BROR: ``_vergleiche_flaechen(bror_layer, shape_vg_layer, ...)``.
- Ohne BROR: temp. enriched Layer erzeugen und mit Shape-VG vergleichen.
8. Ggf. Detailprüfung (knickpunktbasiert) auf BROR bzw. Shape-VG.
Parameters
----------
tab_widget :
Eltern-Widget für alle Dialoge.
Returns
-------
Tuple[Optional[shape_vg_layer], Optional[flst_layer], Optional[bror_layer], LoadStatus]
- ``shape_vg_layer``: Memory-Layer „Verfahrensgebiet" (aus Shape).
- ``flst_layer``: Flurstücke-Layer (bleibt im Projekt).
- ``bror_layer``: BROR-Layer ``BauRaumOderBodenordnungsrecht (ALKIS)``
(wenn geladen, bleibt im Projekt); ``None`` wenn VKZ nicht gesetzt.
- :class:`LoadStatus`: ``FIRST`` oder ``NONE``.
"""
project = QgsProject.instance()
shape_vg_layer = None
bror_layer = None
shape_was_added_by_us = False
bror_was_added_by_us = False
abgeschlossen = False
progress = create_progress_dialog(
7, "Verfahrensgebiet aus Shape laden", "Initialisierung …"
)
try:
# ------------------------------------------------------------------
# Schritt 1: Shape-VG aus ZIP laden (noch NICHT ins Projekt einfügen)
# ------------------------------------------------------------------
progress.set_label("Schritt 1/6: Verfahrensgebiet aus ZIP laden …")
progress.set_value(0)
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
shape_vg_layer = lade_vg_aus_zip(tab_widget)
progress.raise_to_front()
if not shape_vg_layer:
return None, None, None, LoadStatus.NONE
vg_status = LoadStatus.FIRST
progress.set_value(1)
# ------------------------------------------------------------------
# Schritt 2: BROR aus ALKIS laden (vor shape_vg, um Namenskonflikt
# in _check_existing_layer zu vermeiden)
# ------------------------------------------------------------------
progress.set_label("Schritt 2/6: BROR aus ALKIS laden …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
vkz = get_variable("verfahrensnummer") or ""
if str(vkz).strip():
bror_result, bror_status, _ = verfahrensgebiet_alkis(
tab_widget, skip_existing_check=True
)
progress.raise_to_front()
if bror_status != LoadStatus.NONE and bror_result:
bror_result.setName(BROR_LAYER_NAME)
project.addMapLayer(bror_result)
bror_layer = bror_result
bror_was_added_by_us = True
progress.set_value(2)
# Shape-VG erst jetzt ins Projekt einfügen (nach BROR-Ladung)
project.addMapLayer(shape_vg_layer)
shape_was_added_by_us = True
# ------------------------------------------------------------------
# Schritt 3: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln
# ------------------------------------------------------------------
progress.set_label("Schritt 3/6: Gemarkungsschlüssel ermitteln …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
shape_feats = list(shape_vg_layer.getFeatures())
vg_geom = shape_feats[0].geometry() if shape_feats else None
schluessel_namen = (
gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom)
if vg_geom else []
)
vorgabe_schluessel = [nr for nr, _ in schluessel_namen]
vorgabe_namen = [nm for _, nm in schluessel_namen if nm]
if not vorgabe_schluessel:
warning(
"VG laden",
"Keine Gemarkungen in der BBox gefunden. Bitte Schlüssel manuell eingeben.",
duration=5,
)
progress.set_value(3)
# ------------------------------------------------------------------
# Schritt 4: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt)
# ------------------------------------------------------------------
progress.set_label("Schritt 4/6: Flurstücke aus ALKIS laden …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
flst_layer, flst_status = flurstueck_alkis(
tab_widget,
vorgabe_schluessel=vorgabe_schluessel,
vorgabe_namen=vorgabe_namen if vorgabe_namen else None,
ohne_dialog=True,
)
if flst_status == LoadStatus.NONE or not flst_layer:
return None, None, None, LoadStatus.NONE
progress.set_value(4)
# ------------------------------------------------------------------
# Schritt 5: Räumliche Selektion
# ------------------------------------------------------------------
progress.set_label("Schritt 5/6: Flurstücke räumlich selektieren …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
anzahl = _select_by_location(flst_layer, shape_vg_layer)
if anzahl == 0:
QMessageBox.warning(
tab_widget,
"Keine Flurstücke im VG",
"Es wurden keine Flurstücke gefunden, die das Verfahrensgebiet schneiden.\n"
"Bitte Gemarkungsschlüssel und Ausdehnung prüfen.",
)
return None, flst_layer, None, LoadStatus.NONE
progress.set_value(5)
# ------------------------------------------------------------------
# Schritt 6: Flächenvergleich als Gate
# ------------------------------------------------------------------
progress.set_label("Schritt 6/6: Flächenvergleich …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
toleranz = _lese_flaechen_toleranz()
if bror_layer and bror_layer.isValid():
# BROR verfügbar → als Referenz-Objekt verwenden
ref_flaeche, shape_flaeche, stimmt_ueberein = _vergleiche_flaechen(
bror_layer, shape_vg_layer, toleranz
)
dialog_ref_label = "BauRaumOderBodenordnungsrecht"
dialog_vg_label = "Shape-Verfahrensgebiet "
pruef_layer = bror_layer
# Vergleich-Layer (grün/gelb/blau) ins Projekt einfügen
vergleich_layer = _erstelle_vergleich_layer(shape_vg_layer, bror_layer)
if vergleich_layer:
project.addMapLayer(vergleich_layer)
else:
# Kein BROR → temp. enriched Layer aus Flurstücken erzeugen
temp_enriched, _, _ = erstelle_verfahrensgebiet_aus_auswahl(flst_layer)
ref_flaeche, shape_flaeche, stimmt_ueberein = _vergleiche_flaechen(
shape_vg_layer, temp_enriched, toleranz
)
dialog_ref_label = "Shape-Verfahrensgebiet "
dialog_vg_label = "Verfahrensgebiet (Flurstücke)"
pruef_layer = shape_vg_layer
progress.set_value(6)
if stimmt_ueberein:
progress.set_value(7)
progress.close()
show_info_dialog(
"Flächenvergleich",
"Verfahrensgebiet mit erwarteter Geometrie erzeugt",
parent=tab_widget,
)
abgeschlossen = True
return shape_vg_layer, flst_layer, bror_layer, vg_status
# ------------------------------------------------------------------
# Flächenabweichung → 2-Optionen-Dialog
# ------------------------------------------------------------------
progress.close()
diff = abs(ref_flaeche - shape_flaeche)
entscheidung = ask_detailpruefung_oder_abbrechen(
tab_widget,
"Abweichung erkannt",
f"Abweichung erkannt.\n\n"
f"{dialog_ref_label}: {ref_flaeche:.2f} m²\n"
f"{dialog_vg_label}: {shape_flaeche:.2f} m²\n"
f"Differenz: {diff:.2f} m²\n\n"
f"Wie möchten Sie fortfahren?",
)
if entscheidung == "abbrechen":
abgeschlossen = True
return shape_vg_layer, flst_layer, bror_layer, vg_status
# ------------------------------------------------------------------
# Option: Detailprüfung (knickpunktbasiert) auf pruef_layer
# (= BROR wenn vorhanden, sonst Shape-VG)
# ------------------------------------------------------------------
knickpunkte = _extrahiere_knickpunkte(pruef_layer)
n_knick = len(knickpunkte)
gesamt_schritte = n_knick + 5
detail_progress = create_progress_dialog(
gesamt_schritte,
"Detailprüfung (Knickpunkte)",
"Schritt 1: Grenzpunkte laden …",
)
detail_progress.set_value(0)
punktort_layer = lade_punktort_ta(
vorgabe_schluessel, vg_geom, progress=detail_progress
)
if detail_progress.is_canceled():
detail_progress.close()
abgeschlossen = True
return shape_vg_layer, flst_layer, bror_layer, vg_status
detail_progress.set_value(1)
if punktort_layer is None:
detail_progress.close()
abgeschlossen = True
if not detail_progress.is_canceled():
QMessageBox.critical(
tab_widget,
"WFS-Fehler",
"Die Grenzpunktdaten (PunktortTA) konnten nicht geladen werden.\n\n"
"Mögliche Ursache: Netzwerk- oder Proxy-Fehler.\n"
"Bitte prüfen Sie die QGIS-Proxy-Einstellungen "
"(Einstellungen -> Optionen -> Netzwerk) und den Log-Eintrag "
"unter Ansicht -> Bedienfelder -> Protokollnachrichten "
"(Kategorie 'sn_verfahrensgebiet').",
)
return shape_vg_layer, flst_layer, bror_layer, vg_status
ergebnis_layer, identisch, kein_pta, fehlender_knick = knickpunkt_pruefung(
pruef_layer,
punktort_layer,
flst_layer,
progress=detail_progress,
knickpunkte=knickpunkte,
)
if detail_progress.is_canceled():
detail_progress.close()
abgeschlossen = True
return shape_vg_layer, flst_layer, bror_layer, vg_status
detail_progress.set_value(gesamt_schritte)
detail_progress.close()
# ------------------------------------------------------------------
# Korrigiertes Polygon aus identischen + fehlenden Knickpunkten
# (nur für Visualisierung – shape_vg_layer bleibt das finale VG)
# ------------------------------------------------------------------
if ergebnis_layer and ergebnis_layer.isValid():
linien_layer_korr, polygon_layer_korr = erstelle_korrigiertes_polygon(
pruef_layer, ergebnis_layer, pruef_layer.crs().authid()
)
if polygon_layer_korr is not None and polygon_layer_korr.isValid():
if linien_layer_korr is not None and linien_layer_korr.isValid():
project.addMapLayer(linien_layer_korr)
project.addMapLayer(polygon_layer_korr)
if ergebnis_layer and ergebnis_layer.isValid():
project.addMapLayer(ergebnis_layer)
show_info_dialog(
"Detailprüfung abgeschlossen",
f"Knickpunkte geprüft: {n_knick}\n"
f" Identisch (grün): {identisch}\n"
f" Kein PunktortTA (rot): {kein_pta}\n"
f" Fehlende Knickpunkte (orange): {fehlender_knick}",
parent=tab_widget,
)
abgeschlossen = True
return shape_vg_layer, flst_layer, bror_layer, vg_status
finally:
_entferne_hilfs_layer(project, "TempVerfahrensgebiet")
# Shape-VG und BROR bei Fehler/Abbruch entfernen
if not abgeschlossen:
if shape_was_added_by_us and shape_vg_layer is not None:
try:
project.removeMapLayer(shape_vg_layer.id())
except Exception:
pass
if bror_was_added_by_us and bror_layer is not None:
try:
project.removeMapLayer(bror_layer.id())
except Exception:
pass
+13
View File
@@ -0,0 +1,13 @@
[general]
version=26.7.3-testing
name=LNO Sachsen | Verfahrensgebiet
description=Plugin zum Erzeugen eines Objektes "Verfahrensgebiet"
author=Daniel Helbig, Michael Otto
homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet
tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet/issues
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Verfahrensgebiet/src/branch/testing/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
deprecated=false
supportsQt6=true
+22
View File
@@ -0,0 +1,22 @@
<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
<qgis layerType="Vector" styleCategories="Symbology" version="4.0.1-Norrköping">
<renderer-v2 enableorderby="0" forceraster="0" referencescale="-1" symbollevels="0" type="singleSymbol">
<symbols>
<symbol alpha="1" clip_to_extent="1" force_rhr="0" frame_rate="10" is_animated="0" name="0" type="fill">
<layer class="SimpleFill" enabled="1" locked="0" pass="0">
<Option type="Map">
<Option name="color" type="QString" value="0,180,0,150,rgb:0,0.7058824,0,0.5882353"/>
<Option name="outline_color" type="QString" value="0,120,0,255,rgb:0,0.4705882,0,1"/>
<Option name="outline_style" type="QString" value="solid"/>
<Option name="outline_width" type="QString" value="0.4"/>
<Option name="outline_width_unit" type="QString" value="MM"/>
<Option name="style" type="QString" value="solid"/>
</Option>
</layer>
</symbol>
</symbols>
</renderer-v2>
<blendMode>0</blendMode>
<featureBlendMode>0</featureBlendMode>
<layerGeometryType>2</layerGeometryType>
</qgis>
+51
View File
@@ -0,0 +1,51 @@
<!DOCTYPE qgis PUBLIC 'http://mrcc.com/qgis.dtd' 'SYSTEM'>
<qgis layerType="Vector" styleCategories="Symbology" version="4.0.1-Norrköping">
<renderer-v2 attr="status" enableorderby="0" forceraster="0" referencescale="-1" symbollevels="0" type="categorizedSymbol">
<categories>
<category label="beide (Übereinstimmung)" render="true" symbol="0" value="beide"/>
<category label="nur im Verfahrensgebiet" render="true" symbol="1" value="nur_verfahrensgebiet"/>
<category label="nur in der CSV-Liste (AGLB)" render="true" symbol="2" value="nur_aglb"/>
</categories>
<symbols>
<symbol alpha="1" clip_to_extent="1" force_rhr="0" frame_rate="10" is_animated="0" name="0" type="fill">
<layer class="SimpleFill" enabled="1" locked="0" pass="0">
<Option type="Map">
<Option name="color" type="QString" value="0,180,0,150,rgb:0,0.7058824,0,0.5882353"/>
<Option name="outline_color" type="QString" value="0,120,0,255,rgb:0,0.4705882,0,1"/>
<Option name="outline_style" type="QString" value="solid"/>
<Option name="outline_width" type="QString" value="0.4"/>
<Option name="outline_width_unit" type="QString" value="MM"/>
<Option name="style" type="QString" value="solid"/>
</Option>
</layer>
</symbol>
<symbol alpha="1" clip_to_extent="1" force_rhr="0" frame_rate="10" is_animated="0" name="1" type="fill">
<layer class="SimpleFill" enabled="1" locked="0" pass="0">
<Option type="Map">
<Option name="color" type="QString" value="220,0,0,150,rgb:0.8627451,0,0,0.5882353"/>
<Option name="outline_color" type="QString" value="150,0,0,255,rgb:0.5882353,0,0,1"/>
<Option name="outline_style" type="QString" value="solid"/>
<Option name="outline_width" type="QString" value="0.4"/>
<Option name="outline_width_unit" type="QString" value="MM"/>
<Option name="style" type="QString" value="solid"/>
</Option>
</layer>
</symbol>
<symbol alpha="1" clip_to_extent="1" force_rhr="0" frame_rate="10" is_animated="0" name="2" type="fill">
<layer class="SimpleFill" enabled="1" locked="0" pass="0">
<Option type="Map">
<Option name="color" type="QString" value="255,128,0,150,rgb:1,0.5019608,0,0.5882353"/>
<Option name="outline_color" type="QString" value="190,96,0,255,rgb:0.745098,0.3764706,0,1"/>
<Option name="outline_style" type="QString" value="solid"/>
<Option name="outline_width" type="QString" value="0.4"/>
<Option name="outline_width_unit" type="QString" value="MM"/>
<Option name="style" type="QString" value="solid"/>
</Option>
</layer>
</symbol>
</symbols>
</renderer-v2>
<blendMode>0</blendMode>
<featureBlendMode>0</featureBlendMode>
<layerGeometryType>2</layerGeometryType>
</qgis>
+312
View File
@@ -0,0 +1,312 @@
"""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)
"""
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,
BeteiligungPruefungFehler,
BeteiligungErgebnis,
FELD_FLURSTUECK_CSV,
CSV_SPALTE_FLURSTUECK,
CSV_SPALTE_VEF_VKZF,
_lade_via_alkis_pipeline_und_vergleiche,
_try3_bootstrap_und_try2,
_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 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()
@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")
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)
class TestTry3BootstrapUndTry2(unittest.TestCase):
@patch(f"{_MOD}._lade_via_alkis_pipeline_und_vergleiche")
@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
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"])
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"))
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()
@@ -0,0 +1,932 @@
"""sn_verfahrensgebiet/tests/test_verfahrensgebiet_shape_komplett.py
Unit-Tests für :mod:`sn_verfahrensgebiet.functions.verfahrensgebiet_shape_komplett`.
Getestete Szenarien:
lade_vg_aus_zip:
- Dateidialog abgebrochen (leerer Pfad) → None
- ZIP-Entpacken schlägt fehl (Exception) → None + QMessageBox.warning
- Kein .shp in ZIP → None + Warning "gültiges Shape-Dateiset angeben"
- QgsVectorLayer nicht valid → None + Warning
- Leere Geometrien im Shapefile → None + Warning
- Erfolgspfad → Memory-Layer mit Union-Geometrie, Name "Verfahrensgebiet"
verfahrensgebiet_shape_komplett:
- lade_vg_aus_zip None (Abbruch) → (None, None, None, NONE)
- flurstueck_alkis NONE → (None, None, None, NONE)
- Keine Flurstücke selektiert → (None, flst, None, NONE) + QMessageBox.warning
- erstelle_verfahrensgebiet_aus_auswahl ungültig → Workflow noch immer abgeschlossen
(temp_enriched-Fehler: stimmt_ueberein=True wegen 0-Fläche)
- Flächenübereinstimmung → show_info_dialog, return (shape_vg, flst, None, FIRST)
- Flächenabweichung + abbrechen → return (shape_vg, flst, None, FIRST)
- Flächenabweichung + detailpruefung → lade_punktort_ta + knickpunkt_pruefung aufgerufen
- Cleanup: TempVerfahrensgebiet stets aus Projekt entfernt
"""
import sys
import os
import types
import unittest
from unittest.mock import patch, MagicMock, call
# ---------------------------------------------------------------------------
# Minimales QGIS-Mock-Setup
# ---------------------------------------------------------------------------
if "qgis" not in sys.modules:
qgis_mod = types.ModuleType("qgis")
qgis_core = types.ModuleType("qgis.core")
qgis_utils = types.ModuleType("qgis.utils")
qgis_utils.iface = types.SimpleNamespace()
qgis_utils.plugins = {}
qgis_mod.utils = qgis_utils
qgis_mod.core = qgis_core
sys.modules["qgis"] = qgis_mod
sys.modules["qgis.core"] = qgis_core
sys.modules["qgis.utils"] = qgis_utils
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus
from sn_verfahrensgebiet.functions.verfahrensgebiet_shape_komplett import (
verfahrensgebiet_shape_komplett,
lade_vg_aus_zip,
_finde_shp,
_erstelle_memory_layer_aus_shp,
)
# ---------------------------------------------------------------------------
# Hilfs-Dummies
# ---------------------------------------------------------------------------
def _make_layer(valid=True, name="Layer", lid="L1", feature_count=1, area=1000.0):
layer = MagicMock()
layer.isValid.return_value = valid
layer.id.return_value = lid
layer.name.return_value = name
layer.featureCount.return_value = feature_count
layer.selectedFeatureCount.return_value = feature_count
mock_geom = MagicMock()
mock_geom.isEmpty.return_value = False
mock_geom.area.return_value = area
mock_feat = MagicMock()
mock_feat.geometry.return_value = mock_geom
layer.getFeatures.return_value = [mock_feat]
mock_crs = MagicMock()
mock_crs.authid.return_value = "EPSG:25833"
layer.crs.return_value = mock_crs
return layer
class _DummyProject:
def __init__(self):
self.added = []
self.removed = []
self._layers_by_name: dict = {}
def addMapLayer(self, layer):
self.added.append(layer)
def removeMapLayer(self, lid):
self.removed.append(lid)
def mapLayersByName(self, name):
return self._layers_by_name.get(name, [])
def mapLayer(self, lid):
return None
def instance(self):
return self
def _make_progress_mock(canceled=False):
prog = MagicMock()
prog.is_canceled.return_value = canceled
return prog
# ---------------------------------------------------------------------------
# Patch-Konstanten
# ---------------------------------------------------------------------------
_MOD = "sn_verfahrensgebiet.functions.verfahrensgebiet_shape_komplett"
_PATCH_LADE_ZIP = f"{_MOD}.lade_vg_aus_zip"
_PATCH_GEM = f"{_MOD}.gemarkung_schluessel_und_namen_von_vg_geometrie"
_PATCH_FLST = f"{_MOD}.flurstueck_alkis"
_PATCH_SELECT = f"{_MOD}._select_by_location"
_PATCH_ERSTELLE = f"{_MOD}.erstelle_verfahrensgebiet_aus_auswahl"
_PATCH_PROJECT = f"{_MOD}.QgsProject"
_PATCH_MSGBOX = f"{_MOD}.QMessageBox"
_PATCH_WARNING = f"{_MOD}.warning"
_PATCH_PROGRESS = f"{_MOD}.create_progress_dialog"
_PATCH_VERGLEICHE = f"{_MOD}._vergleiche_flaechen"
_PATCH_SHOW_INFO = f"{_MOD}.show_info_dialog"
_PATCH_ASK_DIALOG = f"{_MOD}.ask_detailpruefung_oder_abbrechen"
_PATCH_GP_ALKIS = f"{_MOD}.lade_punktort_ta"
_PATCH_KNICK = f"{_MOD}.knickpunkt_pruefung"
_PATCH_EXTRAHIERE = f"{_MOD}._extrahiere_knickpunkte"
_PATCH_LESE_TOL = f"{_MOD}._lese_flaechen_toleranz"
# ---------------------------------------------------------------------------
# Tests: _finde_shp
# ---------------------------------------------------------------------------
class TestFindShp(unittest.TestCase):
def test_findet_shp_in_root(self):
with patch("os.walk") as mock_walk:
mock_walk.return_value = [
("/extract", [], ["shape.shp", "shape.dbf"]),
]
result = _finde_shp("/extract")
self.assertEqual(result, os.path.join("/extract", "shape.shp"))
def test_findet_shp_in_unterordner(self):
with patch("os.walk") as mock_walk:
mock_walk.return_value = [
("/extract", ["sub"], []),
("/extract/sub", [], ["daten.shp"]),
]
result = _finde_shp("/extract")
self.assertEqual(result, os.path.join("/extract/sub", "daten.shp"))
def test_kein_shp_gibt_none_zurueck(self):
with patch("os.walk") as mock_walk:
mock_walk.return_value = [
("/extract", [], ["shape.dbf", "shape.prj"]),
]
result = _finde_shp("/extract")
self.assertIsNone(result)
def test_case_insensitive_erkennung(self):
with patch("os.walk") as mock_walk:
mock_walk.return_value = [
("/extract", [], ["DATEN.SHP"]),
]
result = _finde_shp("/extract")
self.assertIsNotNone(result)
# ---------------------------------------------------------------------------
# Tests: _erstelle_memory_layer_aus_shp
# ---------------------------------------------------------------------------
class TestErstelleMemoryLayerAusShp(unittest.TestCase):
def test_leere_geometrien_geben_none(self):
shp_layer = MagicMock()
geom = MagicMock()
geom.isEmpty.return_value = True
feat = MagicMock()
feat.geometry.return_value = geom
shp_layer.getFeatures.return_value = [feat]
shp_layer.crs.return_value.authid.return_value = "EPSG:25833"
result = _erstelle_memory_layer_aus_shp(shp_layer)
self.assertIsNone(result)
def test_keine_features_gibt_none(self):
shp_layer = MagicMock()
shp_layer.getFeatures.return_value = []
shp_layer.crs.return_value.authid.return_value = "EPSG:25833"
result = _erstelle_memory_layer_aus_shp(shp_layer)
self.assertIsNone(result)
@patch(f"{_MOD}.QgsGeometry")
@patch(f"{_MOD}.QgsVectorLayer")
@patch(f"{_MOD}.QgsFeature")
def test_attribute_von_erstem_feature_werden_gesetzt(
self, mock_feat_cls, mock_vl_cls, mock_geom_cls
):
"""Attributwerte des ersten Features werden auf das Union-Feature übertragen."""
# Geometrie-Mock
mock_union = MagicMock()
mock_union.isEmpty.return_value = False
mock_geom_cls.unaryUnion.return_value = mock_union
# Memory-Layer-Mock
mock_mem = MagicMock()
mock_mem.isValid.return_value = True
mock_mem.featureCount.return_value = 1
mock_vl_cls.return_value = mock_mem
mock_pr = MagicMock()
mock_mem.dataProvider.return_value = mock_pr
# Feature-Mock (Output)
mock_feat_out = MagicMock()
mock_feat_cls.return_value = mock_feat_out
# Shapefile-Layer-Mock
shp_layer = MagicMock()
shp_layer.crs.return_value.authid.return_value = "EPSG:25833"
shp_layer.fields.return_value = [MagicMock(), MagicMock()] # list → iterable
mock_geom = MagicMock()
mock_geom.isEmpty.return_value = False
src_feat = MagicMock()
src_feat.geometry.return_value = mock_geom
src_feat.attributes.return_value = ["wert_a", 42]
shp_layer.getFeatures.return_value = [src_feat]
result = _erstelle_memory_layer_aus_shp(shp_layer)
# Felder sollen übernommen werden
mock_pr.addAttributes.assert_called_once()
# Attributwerte sollen vom ersten Feature gesetzt werden
mock_feat_out.setAttributes.assert_called_once_with(["wert_a", 42])
self.assertIs(result, mock_mem)
# ---------------------------------------------------------------------------
# Tests: lade_vg_aus_zip
# ---------------------------------------------------------------------------
class TestLadeVgAusZip(unittest.TestCase):
def _patch(self, **kwargs):
"""Hilfsmethode: kombinierter Patch-Kontext für lade_vg_aus_zip."""
return kwargs
@patch(f"{_MOD}.QFileDialog")
def test_dialog_abgebrochen_gibt_none_zurueck(self, mock_fd):
mock_fd.getOpenFileName.return_value = ("", "")
result = lade_vg_aus_zip(None)
self.assertIsNone(result)
@patch(f"{_MOD}.QMessageBox")
@patch(f"{_MOD}.zipfile")
@patch(f"{_MOD}.QFileDialog")
def test_zip_fehler_gibt_none_und_warning(self, mock_fd, mock_zf, mock_mb):
mock_fd.getOpenFileName.return_value = ("/tmp/vg.zip", "")
mock_zf.ZipFile.side_effect = Exception("bad zip")
with patch("os.path.dirname", return_value="/tmp"), \
patch("os.path.splitext", return_value=("/tmp/vg", ".zip")), \
patch("os.path.basename", return_value="vg.zip"), \
patch("os.path.join", return_value="/tmp/vg"):
result = lade_vg_aus_zip(None)
self.assertIsNone(result)
mock_mb.warning.assert_called_once()
@patch(f"{_MOD}.QMessageBox")
@patch(f"{_MOD}._finde_shp", return_value=None)
@patch(f"{_MOD}.zipfile")
@patch(f"{_MOD}.QFileDialog")
def test_kein_shp_in_zip_gibt_none_und_warning(self, mock_fd, mock_zf, mock_find, mock_mb):
mock_fd.getOpenFileName.return_value = ("/tmp/vg.zip", "")
mock_zf.ZipFile.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_zf.ZipFile.return_value.__exit__ = MagicMock(return_value=False)
with patch("os.path.dirname", return_value="/tmp"), \
patch("os.path.splitext", return_value=("/tmp/vg", ".zip")), \
patch("os.path.basename", return_value="vg.zip"), \
patch("os.path.join", return_value="/tmp/vg"):
result = lade_vg_aus_zip(None)
self.assertIsNone(result)
mock_mb.warning.assert_called_once()
args = mock_mb.warning.call_args[0]
self.assertIn("gültiges Shape-Dateiset angeben", args)
@patch(f"{_MOD}.QMessageBox")
@patch(f"{_MOD}.QgsVectorLayer")
@patch(f"{_MOD}._finde_shp", return_value="/tmp/vg/shape.shp")
@patch(f"{_MOD}.zipfile")
@patch(f"{_MOD}.QFileDialog")
def test_invalider_qgslayer_gibt_none_und_warning(
self, mock_fd, mock_zf, mock_find, mock_vl, mock_mb
):
mock_fd.getOpenFileName.return_value = ("/tmp/vg.zip", "")
mock_zf.ZipFile.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_zf.ZipFile.return_value.__exit__ = MagicMock(return_value=False)
invalid_layer = MagicMock()
invalid_layer.isValid.return_value = False
mock_vl.return_value = invalid_layer
with patch("os.path.dirname", return_value="/tmp"), \
patch("os.path.splitext", return_value=("/tmp/vg", ".zip")), \
patch("os.path.basename", return_value="vg.zip"), \
patch("os.path.join", return_value="/tmp/vg"):
result = lade_vg_aus_zip(None)
self.assertIsNone(result)
mock_mb.warning.assert_called_once()
args = mock_mb.warning.call_args[0]
self.assertIn("gültiges Shape-Dateiset angeben", args)
@patch(f"{_MOD}.QMessageBox")
@patch(f"{_MOD}._erstelle_memory_layer_aus_shp", return_value=None)
@patch(f"{_MOD}.QgsVectorLayer")
@patch(f"{_MOD}._finde_shp", return_value="/tmp/vg/shape.shp")
@patch(f"{_MOD}.zipfile")
@patch(f"{_MOD}.QFileDialog")
def test_leere_geometrien_geben_none_und_warning(
self, mock_fd, mock_zf, mock_find, mock_vl, mock_mem, mock_mb
):
mock_fd.getOpenFileName.return_value = ("/tmp/vg.zip", "")
mock_zf.ZipFile.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_zf.ZipFile.return_value.__exit__ = MagicMock(return_value=False)
valid_layer = MagicMock()
valid_layer.isValid.return_value = True
mock_vl.return_value = valid_layer
with patch("os.path.dirname", return_value="/tmp"), \
patch("os.path.splitext", return_value=("/tmp/vg", ".zip")), \
patch("os.path.basename", return_value="vg.zip"), \
patch("os.path.join", return_value="/tmp/vg"):
result = lade_vg_aus_zip(None)
self.assertIsNone(result)
mock_mb.warning.assert_called_once()
@patch(f"{_MOD}._erstelle_memory_layer_aus_shp")
@patch(f"{_MOD}.QgsVectorLayer")
@patch(f"{_MOD}._finde_shp", return_value="/tmp/vg/shape.shp")
@patch(f"{_MOD}.zipfile")
@patch(f"{_MOD}.QFileDialog")
def test_erfolg_gibt_memory_layer_zurueck(
self, mock_fd, mock_zf, mock_find, mock_vl, mock_mem
):
mock_fd.getOpenFileName.return_value = ("/tmp/vg.zip", "")
mock_zf.ZipFile.return_value.__enter__ = MagicMock(return_value=MagicMock())
mock_zf.ZipFile.return_value.__exit__ = MagicMock(return_value=False)
valid_layer = MagicMock()
valid_layer.isValid.return_value = True
mock_vl.return_value = valid_layer
mem_layer = _make_layer(name="Verfahrensgebiet", lid="MEM1")
mock_mem.return_value = mem_layer
with patch("os.path.dirname", return_value="/tmp"), \
patch("os.path.splitext", return_value=("/tmp/vg", ".zip")), \
patch("os.path.basename", return_value="vg.zip"), \
patch("os.path.join", return_value="/tmp/vg"):
result = lade_vg_aus_zip(None)
self.assertIs(result, mem_layer)
# ---------------------------------------------------------------------------
# Tests: verfahrensgebiet_shape_komplett (Hauptworkflow)
# ---------------------------------------------------------------------------
class TestVerfahrensgebietShapeKomplett(unittest.TestCase):
# ------------------------------------------------------------------ #
# Abbruchpfade
# ------------------------------------------------------------------ #
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_LADE_ZIP)
def test_lade_zip_none_aborts(self, mock_lade, mock_proj, mock_prog):
mock_proj.instance.return_value = _DummyProject()
mock_lade.return_value = None
mock_prog.return_value = _make_progress_mock()
enriched, flst, bror, status = verfahrensgebiet_shape_komplett(None)
self.assertIsNone(enriched)
self.assertIsNone(flst)
self.assertIsNone(bror)
self.assertEqual(status, LoadStatus.NONE)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_LADE_ZIP)
def test_shape_vg_sofort_ins_projekt_nach_laden(self, mock_lade, mock_gem, mock_proj, mock_prog):
"""Shape-VG wird unmittelbar nach dem Laden ins Projekt eingefügt."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
mock_prog.return_value = _make_progress_mock()
with patch(_PATCH_FLST, return_value=(None, LoadStatus.NONE)):
verfahrensgebiet_shape_komplett(None)
# Shape-VG muss ins Projekt eingefügt worden sein
self.assertIn(shape_vg, proj.added)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_LADE_ZIP)
def test_shape_vg_wird_bei_abbruch_aus_projekt_entfernt(
self, mock_lade, mock_gem, mock_proj, mock_prog
):
"""Shape-VG wird bei Fehler/Abbruch aus dem Projekt entfernt."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
mock_prog.return_value = _make_progress_mock()
with patch(_PATCH_FLST, return_value=(None, LoadStatus.NONE)):
_, _, _, status = verfahrensgebiet_shape_komplett(None)
self.assertEqual(status, LoadStatus.NONE)
# Layer-ID muss in removed sein
self.assertIn("S1", proj.removed)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_LADE_ZIP)
def test_shape_vg_bleibt_im_projekt_bei_erfolg(self, mock_lade, mock_gem, mock_proj, mock_prog):
"""Bei erfolgreichem Abschluss wird shape_vg NICHT aus dem Projekt entfernt."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_prog.return_value = _make_progress_mock()
with patch(_PATCH_FLST, return_value=(flst_layer, LoadStatus.FIRST)), \
patch(_PATCH_SELECT, return_value=5), \
patch(_PATCH_ERSTELLE, return_value=(_make_layer(lid="T1"), 5, 0)), \
patch(_PATCH_VERGLEICHE, return_value=(1000.0, 1000.0, True)), \
patch(_PATCH_SHOW_INFO):
verfahrensgebiet_shape_komplett(None)
# Layer-ID darf NICHT in removed sein
self.assertNotIn("S1", proj.removed)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_LADE_ZIP)
def test_flst_none_aborts(self, mock_lade, mock_gem, mock_proj, mock_prog):
mock_proj.instance.return_value = _DummyProject()
mock_lade.return_value = _make_layer(name="Verfahrensgebiet")
mock_gem.return_value = [("1234", "Muster")]
mock_prog.return_value = _make_progress_mock()
with patch(_PATCH_FLST, return_value=(None, LoadStatus.NONE)):
enriched, flst, bror, status = verfahrensgebiet_shape_komplett(None)
self.assertIsNone(enriched)
self.assertIsNone(flst)
self.assertEqual(status, LoadStatus.NONE)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_MSGBOX)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_LADE_ZIP)
def test_keine_flurstuecke_selektiert_gibt_warning(
self, mock_lade, mock_flst, mock_gem, mock_proj, mock_select, mock_mb, mock_prog
):
mock_proj.instance.return_value = _DummyProject()
mock_lade.return_value = _make_layer(name="Verfahrensgebiet")
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 0
mock_prog.return_value = _make_progress_mock()
enriched, flst, bror, status = verfahrensgebiet_shape_komplett(None)
self.assertIsNone(enriched)
self.assertIs(flst, flst_layer)
self.assertIsNone(bror)
self.assertEqual(status, LoadStatus.NONE)
mock_mb.warning.assert_called_once()
# ------------------------------------------------------------------ #
# Flächenübereinstimmung
# ------------------------------------------------------------------ #
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_ERSTELLE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_LADE_ZIP)
def test_flaechen_uebereinstimmung_gibt_info_und_kein_dialog(
self, mock_lade, mock_flst, mock_gem, mock_proj,
mock_select, mock_erstelle, mock_vergleiche, mock_prog, mock_info
):
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = [("1234", "Muster")]
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
temp_enriched = _make_layer(name="Verfahrensgebiet", lid="T1")
mock_erstelle.return_value = (temp_enriched, 5, 0)
mock_vergleiche.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
enriched, flst, bror, status = verfahrensgebiet_shape_komplett(None)
self.assertIs(enriched, shape_vg)
self.assertIs(flst, flst_layer)
self.assertIsNone(bror)
self.assertEqual(status, LoadStatus.FIRST)
mock_info.assert_called_once()
# ------------------------------------------------------------------ #
# Flächenabweichung + Abbrechen
# ------------------------------------------------------------------ #
@patch(_PATCH_ASK_DIALOG)
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_ERSTELLE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_LADE_ZIP)
def test_abweichung_abbrechen_gibt_shape_vg_zurueck(
self, mock_lade, mock_flst, mock_gem, mock_proj,
mock_select, mock_erstelle, mock_vergleiche, mock_prog, mock_info, mock_ask
):
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
temp_enriched = _make_layer(name="Verfahrensgebiet", lid="T1")
mock_erstelle.return_value = (temp_enriched, 5, 0)
mock_vergleiche.return_value = (1000.0, 800.0, False)
mock_prog.return_value = _make_progress_mock()
mock_ask.return_value = "abbrechen"
enriched, flst, bror, status = verfahrensgebiet_shape_komplett(None)
self.assertIs(enriched, shape_vg)
self.assertIs(flst, flst_layer)
self.assertIsNone(bror)
self.assertEqual(status, LoadStatus.FIRST)
mock_info.assert_not_called()
# ------------------------------------------------------------------ #
# Detailprüfung
# ------------------------------------------------------------------ #
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_KNICK)
@patch(_PATCH_GP_ALKIS)
@patch(_PATCH_EXTRAHIERE)
@patch(_PATCH_ASK_DIALOG)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_ERSTELLE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_LADE_ZIP)
def test_abweichung_detailpruefung_ruft_knickpunkt_pruefung_auf(
self, mock_lade, mock_flst, mock_gem, mock_proj,
mock_select, mock_erstelle, mock_vergleiche, mock_prog,
mock_ask, mock_extrahiere, mock_gp, mock_knick, mock_info
):
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = [("1234", "Muster")]
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
temp_enriched = _make_layer(name="Verfahrensgebiet", lid="T1")
mock_erstelle.return_value = (temp_enriched, 5, 0)
mock_vergleiche.return_value = (1000.0, 800.0, False)
mock_prog.return_value = _make_progress_mock()
mock_ask.return_value = "detailpruefung"
knickpunkte_mock = [MagicMock() for _ in range(3)]
mock_extrahiere.return_value = knickpunkte_mock
pta_layer = _make_layer(name="PunktortTA", lid="P1")
mock_gp.return_value = pta_layer
ergebnis = _make_layer(name="Knickpunkte", lid="K1")
mock_knick.return_value = (ergebnis, 2, 1, 0)
enriched, flst, bror, status = verfahrensgebiet_shape_komplett(None)
mock_gp.assert_called_once()
mock_knick.assert_called_once()
self.assertIs(enriched, shape_vg)
self.assertEqual(status, LoadStatus.FIRST)
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_KNICK)
@patch(_PATCH_GP_ALKIS, return_value=None)
@patch(_PATCH_EXTRAHIERE)
@patch(_PATCH_ASK_DIALOG)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_ERSTELLE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_LADE_ZIP)
def test_punktort_ta_none_gibt_shape_vg_zurueck_und_zeigt_fehler(
self, mock_lade, mock_flst, mock_gem, mock_proj,
mock_select, mock_erstelle, mock_vergleiche, mock_prog,
mock_ask, mock_extrahiere, mock_gp, mock_knick, mock_info
):
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
temp_enriched = _make_layer(name="Verfahrensgebiet", lid="T1")
mock_erstelle.return_value = (temp_enriched, 5, 0)
mock_vergleiche.return_value = (1000.0, 800.0, False)
mock_prog.return_value = _make_progress_mock()
mock_ask.return_value = "detailpruefung"
mock_extrahiere.return_value = [MagicMock()]
with patch(f"{_MOD}.QMessageBox") as mock_mb:
enriched, flst, bror, status = verfahrensgebiet_shape_komplett(None)
mock_knick.assert_not_called()
self.assertIs(enriched, shape_vg)
mock_mb.critical.assert_called_once()
# ------------------------------------------------------------------ #
# Cleanup: TempVerfahrensgebiet
# ------------------------------------------------------------------ #
@patch(_PATCH_PROGRESS)
@patch(_PATCH_GEM)
@patch(_PATCH_PROJECT)
@patch(_PATCH_LADE_ZIP)
def test_temp_layer_wird_stets_entfernt(self, mock_lade, mock_proj, mock_gem, mock_prog):
proj = _DummyProject()
temp_layer = _make_layer(name="TempVerfahrensgebiet", lid="TEMP")
proj._layers_by_name["TempVerfahrensgebiet"] = [temp_layer]
mock_proj.instance.return_value = proj
mock_lade.return_value = None # → sofortiger Abbruch nach Schritt 1
mock_gem.return_value = []
mock_prog.return_value = _make_progress_mock()
verfahrensgebiet_shape_komplett(None)
self.assertIn("TEMP", proj.removed)
# ------------------------------------------------------------------ #
# Rückgabe-Konsistenz
# ------------------------------------------------------------------ #
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_ERSTELLE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_LADE_ZIP)
def test_bror_layer_ist_stets_none(
self, mock_lade, mock_flst, mock_gem, mock_proj,
mock_select, mock_erstelle, mock_vergleiche, mock_prog, mock_info
):
"""Dritter Rückgabewert ist im Shape-Workflow immer None (kein BROR)."""
mock_proj.instance.return_value = _DummyProject()
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
temp_enriched = _make_layer(lid="T1")
mock_erstelle.return_value = (temp_enriched, 5, 0)
mock_vergleiche.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
_, _, bror, _ = verfahrensgebiet_shape_komplett(None)
self.assertIsNone(bror)
# ------------------------------------------------------------------ #
# BROR-Ladelogik
# ------------------------------------------------------------------ #
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(f"{_MOD}.verfahrensgebiet_alkis")
@patch(f"{_MOD}.get_variable", return_value="VF-2026-001")
@patch(_PATCH_LADE_ZIP)
def test_bror_geladen_und_zurueckgegeben_wenn_vkz_gesetzt(
self, mock_lade, mock_get_var, mock_vg_alkis, mock_flst,
mock_gem, mock_proj, mock_select, mock_vergleiche, mock_prog, mock_info
):
"""VKZ gesetzt → verfahrensgebiet_alkis aufgerufen, bror_layer zurückgegeben."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
bror = _make_layer(name="Verfahrensgebiet", lid="BROR")
mock_vg_alkis.return_value = (bror, LoadStatus.FIRST, [])
mock_vergleiche.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
enriched, flst, bror_ret, status = verfahrensgebiet_shape_komplett(None)
mock_vg_alkis.assert_called_once()
self.assertIs(bror_ret, bror)
self.assertIs(enriched, shape_vg)
self.assertEqual(status, LoadStatus.FIRST)
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(f"{_MOD}.verfahrensgebiet_alkis")
@patch(f"{_MOD}.get_variable", return_value="VF-2026-001")
@patch(_PATCH_LADE_ZIP)
def test_bror_wird_vor_shape_vg_ins_projekt_eingefuegt(
self, mock_lade, mock_get_var, mock_vg_alkis, mock_flst,
mock_gem, mock_proj, mock_select, mock_vergleiche, mock_prog, mock_info
):
"""BROR wird VOR shape_vg ins Projekt eingefügt (kein Namenskonflikt)."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
bror = _make_layer(name="Verfahrensgebiet", lid="BROR")
mock_vg_alkis.return_value = (bror, LoadStatus.FIRST, [])
mock_vergleiche.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
verfahrensgebiet_shape_komplett(None)
# BROR muss in added sein
self.assertIn(bror, proj.added)
# BROR muss vor shape_vg hinzugefügt werden
bror_idx = proj.added.index(bror)
shape_idx = proj.added.index(shape_vg)
self.assertLess(bror_idx, shape_idx, "BROR muss vor shape_vg eingefügt werden")
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(f"{_MOD}.verfahrensgebiet_alkis")
@patch(f"{_MOD}.get_variable", return_value="VF-2026-001")
@patch(_PATCH_LADE_ZIP)
def test_bror_wird_bei_abbruch_aus_projekt_entfernt(
self, mock_lade, mock_get_var, mock_vg_alkis, mock_flst,
mock_gem, mock_proj, mock_select, mock_vergleiche, mock_prog
):
"""BROR und shape_vg werden bei Fehler aus dem Projekt entfernt."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
bror = _make_layer(name="Verfahrensgebiet", lid="BROR")
mock_vg_alkis.return_value = (bror, LoadStatus.FIRST, [])
mock_flst.return_value = (None, LoadStatus.NONE) # → Abbruch
mock_prog.return_value = _make_progress_mock()
_, _, _, status = verfahrensgebiet_shape_komplett(None)
self.assertEqual(status, LoadStatus.NONE)
self.assertIn("S1", proj.removed)
self.assertIn("BROR", proj.removed)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(f"{_MOD}.verfahrensgebiet_alkis")
@patch(f"{_MOD}.get_variable", return_value="")
@patch(_PATCH_LADE_ZIP)
def test_verfahrensgebiet_alkis_nicht_aufgerufen_wenn_vkz_leer(
self, mock_lade, mock_get_var, mock_vg_alkis, mock_gem, mock_proj, mock_prog
):
"""Leere VKZ → verfahrensgebiet_alkis wird NICHT aufgerufen."""
mock_proj.instance.return_value = _DummyProject()
mock_lade.return_value = _make_layer(name="Verfahrensgebiet")
mock_gem.return_value = []
mock_prog.return_value = _make_progress_mock()
with patch(_PATCH_FLST, return_value=(None, LoadStatus.NONE)):
verfahrensgebiet_shape_komplett(None)
mock_vg_alkis.assert_not_called()
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(f"{_MOD}.verfahrensgebiet_alkis")
@patch(f"{_MOD}.get_variable", return_value="VF-2026-001")
@patch(_PATCH_LADE_ZIP)
def test_bror_als_erstes_argument_beim_flaechenvergleich(
self, mock_lade, mock_get_var, mock_vg_alkis, mock_flst,
mock_gem, mock_proj, mock_select, mock_vergleiche, mock_prog, mock_info
):
"""Mit BROR: _vergleiche_flaechen wird (bror, shape_vg, ...) aufgerufen."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
bror = _make_layer(name="Verfahrensgebiet", lid="BROR")
mock_vg_alkis.return_value = (bror, LoadStatus.FIRST, [])
mock_vergleiche.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
verfahrensgebiet_shape_komplett(None)
# Erster Arg = bror, zweiter Arg = shape_vg
call_args = mock_vergleiche.call_args[0]
self.assertIs(call_args[0], bror)
self.assertIs(call_args[1], shape_vg)
@patch(_PATCH_SHOW_INFO)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_VERGLEICHE)
@patch(_PATCH_ERSTELLE)
@patch(_PATCH_SELECT)
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(f"{_MOD}.verfahrensgebiet_alkis", return_value=(None, LoadStatus.NONE, []))
@patch(f"{_MOD}.get_variable", return_value="VF-2026-001")
@patch(_PATCH_LADE_ZIP)
def test_fallback_auf_temp_enriched_wenn_bror_ladeversagen(
self, mock_lade, mock_get_var, mock_vg_alkis, mock_flst,
mock_gem, mock_proj, mock_select, mock_erstelle, mock_vergleiche, mock_prog, mock_info
):
"""BROR-Ladeversagen → Fallback: _vergleiche_flaechen mit shape_vg vs temp_enriched."""
proj = _DummyProject()
mock_proj.instance.return_value = proj
shape_vg = _make_layer(name="Verfahrensgebiet", lid="S1")
mock_lade.return_value = shape_vg
mock_gem.return_value = []
flst_layer = _make_layer(name="Flurstücke", lid="F1")
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
temp_enriched = _make_layer(lid="T1")
mock_erstelle.return_value = (temp_enriched, 5, 0)
mock_vergleiche.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
_, _, bror_ret, _ = verfahrensgebiet_shape_komplett(None)
# kein BROR zurückgegeben
self.assertIsNone(bror_ret)
# Fallback: erste Arg = shape_vg
call_args = mock_vergleiche.call_args[0]
self.assertIs(call_args[0], shape_vg)
if __name__ == "__main__":
unittest.main()
+257
View File
@@ -566,5 +566,262 @@ class TestWorkingTab(unittest.TestCase):
self.assertTrue(tab.btn_gem_alkis.isEnabled()) # finally
# ---------------------------------------------------------------------------
# handle_verf_shape
# ---------------------------------------------------------------------------
class TestHandleVerfShape(unittest.TestCase):
def _make_tab(self):
tab = WorkingTab.__new__(WorkingTab)
tab.btn_verf_alkis = _DummyButton()
tab.btn_verf_shape = _DummyButton()
tab.btn_flurst_alkis = _DummyButton()
tab.btn_gem_alkis = _DummyButton()
tab.btn_grenz_alkis = _DummyButton()
tab.btn_verf_aus_auswahl = _DummyButton()
tab.btn_verf_aus_liste = _DummyButton()
tab.verf_layer_combo = _DummyCombo()
tab.haken_verf = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None)
tab.haken_flurst = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None)
tab.haken_gem = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None)
tab.haken_grenz = types.SimpleNamespace(setText=lambda _t: None, setStyleSheet=lambda _s: None)
tab.setze_haken = lambda *_args, **_kwargs: None
tab.pruefmanager = types.SimpleNamespace(verarbeite=lambda ergebnis: ergebnis)
return tab
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id")
@patch(
"sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett",
return_value=(None, None, None, LoadStatus.NONE),
)
def test_status_none_clears_layer_variable(self, _mock_shape, mock_set_id):
"""Status NONE → Layer-Variable wird auf '' gesetzt."""
tab = self._make_tab()
with patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="VF-123"), \
patch("sn_verfahrensgebiet.ui.tabs.working_tab.ask_yes_no"):
tab.handle_verf_shape()
mock_set_id.assert_called_once_with("")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id")
@patch(
"sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett",
return_value=(None, None, None, LoadStatus.NONE),
)
def test_button_always_re_enabled_on_failure(self, _mock_shape, _mock_set_id):
"""Button muss nach Status NONE wieder freigegeben sein (finally)."""
tab = self._make_tab()
with patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="VF-123"), \
patch("sn_verfahrensgebiet.ui.tabs.working_tab.ask_yes_no"):
tab.handle_verf_shape()
self.assertTrue(tab.btn_verf_shape.isEnabled())
self.assertEqual(tab.btn_verf_shape.text(), "Aus Shape laden")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.ask_yes_no", return_value=False)
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett")
def test_verfahrensnummer_nicht_gesetzt_nein_bricht_ab(
self, mock_shape, mock_get_var, mock_ask
):
"""Verfahrensnummer leer + Nutzer wählt 'Nein' → Vorgang abgebrochen."""
tab = self._make_tab()
tab.handle_verf_shape()
mock_ask.assert_called_once()
mock_shape.assert_not_called()
# Button darf nicht busy geblieben sein
self.assertTrue(tab.btn_verf_shape.isEnabled())
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.ask_yes_no", return_value=True)
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="")
@patch(
"sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett",
return_value=(None, None, None, LoadStatus.NONE),
)
def test_verfahrensnummer_nicht_gesetzt_ja_fuehrt_fort(
self, mock_shape, mock_get_var, mock_ask, mock_set_id
):
"""Verfahrensnummer leer + Nutzer wählt 'Ja' → Pipeline wird aufgerufen."""
tab = self._make_tab()
tab.handle_verf_shape()
mock_ask.assert_called_once()
mock_shape.assert_called_once_with(tab)
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.ask_yes_no")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="VF-2026-001")
@patch(
"sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett",
return_value=(None, None, None, LoadStatus.NONE),
)
def test_verfahrensnummer_gesetzt_kein_rueckfrage_dialog(
self, mock_shape, mock_get_var, mock_ask, mock_set_id
):
"""Verfahrensnummer gesetzt → kein ask_yes_no-Dialog."""
tab = self._make_tab()
tab.handle_verf_shape()
mock_ask.assert_not_called()
mock_shape.assert_called_once_with(tab)
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="VF-123")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.success")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id")
def test_success_path_applies_style_and_sets_haken(
self,
mock_set_layer_id,
mock_iface,
mock_shape_komplett,
mock_project_instance,
mock_apply_style,
mock_success,
mock_get_var,
):
"""Erfolg → verfahrensgebiet.qml angewendet, haken_verf gesetzt, success aufgerufen."""
tab = self._make_tab()
haken_calls = []
tab.setze_haken = lambda lbl, v: haken_calls.append((lbl, v))
enriched = _DummyLayer(valid=True, layer_id="shape_vg")
flst = _DummyLayer(valid=True, layer_id="flst_ok")
mock_shape_komplett.return_value = (enriched, flst, None, LoadStatus.FIRST)
proj = _DummyProject()
proj.mapLayersByName = lambda _n: []
proj.mapLayer = lambda _lid: None
mock_project_instance.return_value = proj
mock_iface.mapCanvas.return_value = _DummyCanvas()
tab.handle_verf_shape()
# Style auf enriched layer angewendet
apply_calls = [str(c) for c in mock_apply_style.call_args_list]
self.assertTrue(
any("verfahrensgebiet.qml" in c for c in apply_calls),
"verfahrensgebiet.qml muss angewendet werden",
)
# Flurstücke-Style angewendet
self.assertTrue(
any("GIS_Flst_Beschriftung_ALKIS_NAS.qml" in c for c in apply_calls),
"Flurstücke-Style muss angewendet werden",
)
# Haken gesetzt
self.assertTrue(any(v for _, v in haken_calls), "Mindestens ein Haken muss gesetzt werden")
# Layer-ID gespeichert
mock_set_layer_id.assert_called_with("shape_vg")
# Success-Meldung
mock_success.assert_called_once()
# Button wieder freigegeben
self.assertTrue(tab.btn_verf_shape.isEnabled())
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="VF-123")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.success")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id")
def test_bror_layer_erhaelt_qml_stil(
self,
mock_set_layer_id,
mock_iface,
mock_shape_komplett,
mock_project_instance,
mock_apply_style,
mock_success,
mock_get_var,
):
"""Wenn bror_layer zurückgegeben → BauRaumOderBodenordnungsrecht.qml angewendet."""
tab = self._make_tab()
tab.setze_haken = lambda *a, **kw: None
enriched = _DummyLayer(valid=True, layer_id="shape_vg")
bror = _DummyLayer(valid=True, layer_id="bror_id")
mock_shape_komplett.return_value = (enriched, None, bror, LoadStatus.FIRST)
proj = _DummyProject()
proj.mapLayersByName = lambda _n: []
proj.mapLayer = lambda lid: enriched if lid == "shape_vg" else None
mock_project_instance.return_value = proj
mock_iface.mapCanvas.return_value = _DummyCanvas()
tab.handle_verf_shape()
apply_calls = [str(c) for c in mock_apply_style.call_args_list]
self.assertTrue(
any("BauRaumOderBodenordnungsrecht.qml" in c for c in apply_calls),
"BauRaumOderBodenordnungsrecht.qml muss auf bror_layer angewendet werden",
)
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="VF-123")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.apply_style")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.QgsProject.instance")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.iface")
@patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id")
def test_enriched_layer_already_in_project_not_added_again(
self,
mock_set_layer_id,
mock_iface,
mock_shape_komplett,
mock_project_instance,
mock_apply_style,
mock_get_var,
):
"""Layer bereits im Projekt → kein erneutes addMapLayer für VG-Layer."""
tab = self._make_tab()
tab.setze_haken = lambda *a, **kw: None
enriched = _DummyLayer(valid=True, layer_id="shape_vg")
mock_shape_komplett.return_value = (enriched, None, None, LoadStatus.FIRST)
proj = _DummyProject()
proj.mapLayersByName = lambda _n: []
proj.mapLayer = lambda lid: enriched if lid == "shape_vg" else None
mock_project_instance.return_value = proj
mock_iface.mapCanvas.return_value = _DummyCanvas()
tab.handle_verf_shape()
# Enriched-Layer darf NICHT erneut via addMapLayer hinzugefügt worden sein
self.assertNotIn(enriched, proj.added)
def test_busy_state_set_during_execution(self):
"""Button wird während der Ausführung auf busy gesetzt."""
tab = self._make_tab()
busy_states = []
original_set_busy = WorkingTab._set_busy
def capture_busy(self_ref, button, busy, original_text="Aus Shape laden"):
busy_states.append(busy)
original_set_busy(self_ref, button, busy, original_text)
with patch.object(WorkingTab, "_set_busy", capture_busy), \
patch(
"sn_verfahrensgebiet.ui.tabs.working_tab.verfahrensgebiet_shape_komplett",
return_value=(None, None, None, LoadStatus.NONE),
), \
patch("sn_verfahrensgebiet.ui.tabs.working_tab.set_verfahrensgebiet_layer_id"), \
patch("sn_verfahrensgebiet.ui.tabs.working_tab.get_variable", return_value="VF-123"):
tab.handle_verf_shape()
self.assertIn(True, busy_states, "Button muss mindestens einmal auf busy gesetzt werden")
self.assertIn(False, busy_states, "Button muss am Ende wieder freigegeben werden")
if __name__ == "__main__":
unittest.main()
+108 -2
View File
@@ -8,7 +8,8 @@ from sn_basis.functions import Qgis, QgsProject #QgsMessageLog
from sn_basis.functions.qgisui_wrapper import QgsMapLayerComboBox
from sn_basis.functions.qgiscore_wrapper import QgsMapLayerProxyModel
from sn_basis.functions.verfahrensgebiet_manager import get_verfahrensgebiet_layer_id, set_verfahrensgebiet_layer_id
from sn_basis.functions.dialog_wrapper import show_info_dialog, create_progress_dialog
from sn_basis.functions.dialog_wrapper import show_info_dialog, create_progress_dialog, ask_yes_no
from sn_basis.functions.variable_wrapper import get_variable
from sn_basis.modules.Pruefmanager import Pruefmanager
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
from qgis.utils import iface
@@ -21,6 +22,11 @@ from sn_verfahrensgebiet.functions.flurstueck_alkis import (
FLST_LAYER_NAME,
)
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import verfahrensgebiet_alkis_komplett
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,
@@ -70,6 +76,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")
@@ -167,8 +182,9 @@ class WorkingTab(QWidget):
self.btn_umring_statistik.clicked.connect(self.handle_umringstatistik)
self.btn_verf_aus_auswahl.clicked.connect(self.handle_verf_aus_auswahl)
self.btn_verf_aus_liste.clicked.connect(self.handle_verf_aus_liste)
# self.btn_verf_shape.clicked.connect(self.handle_verf_shape)
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()
@@ -220,6 +236,96 @@ class WorkingTab(QWidget):
finally:
self._set_busy(self.btn_verf_alkis, False, "Aus ALKIS laden")
def handle_verf_shape(self) -> None:
"""Lädt ein Verfahrensgebiet aus einem ZIP-Archiv mit Shapefile-Set.
Delegiert an :func:`~sn_verfahrensgebiet.functions.verfahrensgebiet_shape_komplett.verfahrensgebiet_shape_komplett`.
Nach Erfolg werden Shape-VG-Layer und Flurstücks-Layer ins Projekt eingefügt,
Styles angewendet und die UI-Elemente aktualisiert.
"""
# Vorprüfung: Verfahrensnummer muss gesetzt sein
vkz = get_variable("verfahrensnummer") or ""
if not str(vkz).strip():
if not ask_yes_no(
"Verfahrensnummer nicht gesetzt",
"Verfahrensnummer nicht gesetzt. Trotzdem fortfahren?",
default=False,
parent=self,
):
return
self._set_busy(self.btn_verf_shape, True, "Aus Shape laden")
try:
enriched_layer, flst_layer, bror_layer, status = verfahrensgebiet_shape_komplett(self)
if status == LoadStatus.NONE or not enriched_layer:
set_verfahrensgebiet_layer_id("")
return
project = QgsProject.instance()
# Flurstücks-Layer ins Projekt (sofern neu geladen)
if flst_layer and flst_layer.isValid():
existing_flst = project.mapLayersByName(flst_layer.name())
if not existing_flst:
project.addMapLayer(flst_layer)
apply_style(flst_layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml")
self.setze_haken(self.haken_flurst, True)
# BROR-Layer: QML-Stil anwenden (sofern geladen)
if bror_layer and bror_layer.isValid():
apply_style(bror_layer, "BauRaumOderBodenordnungsrecht.qml")
# Shape-VG-Layer ins Projekt
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)
success("Verfahrensgebiet", "Layer aus Shape geladen.", duration=5)
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`.
Je nach Projektzustand (try1/try2/try3) 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.