Compare commits

...
19 Commits
Author SHA1 Message Date
22ottomi 70c14783c5 Merge pull request 'Shape-Ladevorgang: Mehrfachauswahl, ZIP-Temp-Entpacken, FBSchl-Pruefung' (#42) from aus-shape-laden into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #42
2026-09-02 08:40:02 +02:00
Michael OttoandClaude Sonnet 5 b4f2222b50 Shape-Ladevorgang: Mehrfachauswahl, ZIP-Temp-Entpacken, FBSchl-Prüfung
- Dateidialog erlaubt Mehrfachauswahl von .zip/.shp; ZIPs werden in ein
  temporäres Verzeichnis entpackt statt dauerhaft neben der ZIP-Datei
  (Cleanup nach dem Einlesen)
- Je Shapefile wird das Feld FBSchl geprüft und in Neuordnungs- bzw.
  Neuvermessungsgebiet unterschieden; Geometrien gleichen Typs werden über
  alle Dateien hinweg zu je einem Layer verschmolzen und entsprechend
  benannt ("Neuordnungsgebiet (NoBIS)" / "Neuvermessungsgebiet (NoBIS)")
- Fehler einzelner Dateien/Shapefiles brechen den Vorgang nicht mehr ab,
  sondern werden gesammelt und am Ende in einer Meldung ausgegeben
- Fortschrittsdialog erscheint erst nach dem Stop nach dem Laden, nicht
  mehr schon vor dem Dateidialog
- Vorprüfung auf gesetzte Verfahrensnummer vorerst auskommentiert
- Ablauf-Dokumentation aufgeteilt in aus_alkis_laden.md und
  aus_shape_laden.md (+ technische Ergänzung aus_shape_laden_technisch.md)

Löst #36, #37, #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 08:36:13 +02:00
Michael Otto 8447a2615c Temporärer Weiter?-Stopp nach 'Verfahrensgebiet aus ZIP laden' + Ablauf-Doku
- verfahrensgebiet_shape_komplett.py: TEMPORÄRER STOPP (wieder entfernen)
  zur Zwischenkontrolle nach dem ZIP-Import. Layer wird dafür schon an
  dieser Stelle sichtbar ins Projekt eingefügt statt erst nach Schritt 2;
  bei "Nein" bleibt der Layer zur Inspektion erhalten (abgeschlossen=True),
  der spätere reguläre addMapLayer-Aufruf prüft auf Duplikat.
- docs/ablauf_verfahrensgebiet_laden.md: Mermaid-Diagramm der Lade-Abläufe
  (ALKIS/Shape) bis zu den jeweiligen frühen Stopps.

Lokaler Debug-Branch, bewusst nicht für unstable vorgesehen.
2026-08-31 09:20:50 +02:00
22ottomi 4e7f796840 Merge pull request 'Feature/mehrfachauswahl alkis' (#40) from feature/mehrfachauswahl-alkis into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #40
2026-08-31 09:13:12 +02:00
Michael Otto 63a70737f9 Merge branch 'unstable' into aus-zip-laden
Konflikte aufgelöst wie mit Daniel abgestimmt:
- functions/beteiligung_pruefung.py, tests/test_beteiligung_pruefung.py:
  unsere Version behalten (ohne silent-Parameter, Aufforderung statt
  automatischem Hintergrund-Laden bei try2/try3).
- functions/verfahrensgebiet_alkis.py, functions/verfahrensgebiet_alkis_komplett.py:
  unsere Version behalten (Zwei-Dialog-Split, Landkreis-Fallback; Daniels
  silent-Parameter dadurch nicht mehr benötigt).
- ui/tabs/working_tab.py: Docstring-Konflikt trivial aufgelöst, unsere
  Fassung behalten (beschreibt das tatsächliche Verhalten ohne silent).
- changelog.txt: beide Versionsblöcke zusammengeführt.
2026-08-31 08:53:52 +02:00
Michael OttoandClaude Sonnet 5 d5db43bc57 Beteiligungsprüfung portiert (ohne automatisches Hintergrund-Laden)
Portiert aus origin/unstable (Feature "Beteiligung prüfen": Abgleich einer
BI-Server-CSV-Exportliste mit dem ALKIS-Flurstücksbestand des
Verfahrensgebiets), bewusst OHNE den dortigen silent-Modus für
verfahrensgebiet_alkis()/verfahrensgebiet_alkis_komplett():

- try1 (Verfahrensgebiet-Layer mit Kennzeichen vorhanden) bleibt unverändert
  – schneller Mengenvergleich ohne WFS-Zugriff.
- try2 (Layer vorhanden, aber ohne verwertbare Kennzeichen) und try3 (kein
  Layer vorhanden) laden nicht mehr automatisch/silent über die komplette
  ALKIS-Pipeline, sondern fordern den Nutzer per Meldung auf, das
  Verfahrensgebiet zunächst regulär über "Aus ALKIS laden" zu laden und die
  Beteiligungsprüfung danach erneut zu starten (landet dann automatisch bei
  try1). Grund: ein automatischer Durchlauf ohne Nutzerinteraktion
  (mehrdeutige BROR-Treffer, Flächenabweichungen) hat sich in der Praxis
  nicht als zuverlässig genug erwiesen.
- verfahrensgebiet_alkis.py/verfahrensgebiet_alkis_komplett.py bleiben dadurch
  unangetastet – kein silent-Parameter, kein Konflikt mit dem heutigen
  Zwei-Dialog-Split und dem Landkreis-Fallback.
- Neuer Button "Beteiligung prüfen" in working_tab.py, delegiert an
  beteiligung_pruefen().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 06:33:09 +02:00
Michael OttoandClaude Sonnet 5 7b48d29d56 Kein Fehlerdialog mehr bei Abbruch des Landkreis-Auswahldialogs
Abbruch des Dialogs ist eine bewusste Nutzerentscheidung, kein Fehlerzustand
– verfahrensgebiet_alkis_komplett() bricht jetzt still ab statt eine
QMessageBox.critical zu zeigen. Test entsprechend angepasst/umbenannt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 14:44:25 +02:00
Michael OttoandClaude Sonnet 5 5816b2af22 Landkreis-Auswahldialog bei fehlenden Projekteigenschaften, Style-Fix "Nur laden"
- Ist weder Verfahrensnummer noch Landkreis in den Projekteigenschaften
  gesetzt, wird jetzt per Dialog ein Landkreis abgefragt (inkl. "Alle
  Landkreise") statt direkt abzubrechen.
- Verfahrensgebiet-spezifische ALKIS-Dialoge aus sn_basis hierher verschoben:
  neue Datei functions/verfahrensgebiet_dialoge.py
  (ask_bror_kombinationen, ask_landkreis_objekte, ask_landkreis_auswahl).
- Bugfix: BROR-Layer bekommt seinen Style (BauRaumOderBodenordnungsrecht.qml)
  jetzt direkt beim Laden in verfahrensgebiet_alkis_komplett(), unabhängig
  vom weiteren Ablauf. working_tab.py überspringt bei "Nur Objekte laden"
  jetzt den gesamten VG-Nachbehandlungsblock (Style/Extent/Haken/Combobox/
  Erfolgsmeldung), da dort kein echter VG-Layer entsteht. Detailprüfung/
  Verifikation im echten QGIS (nicht nur per Unittest) steht noch aus.
- Bugfix: Erfolgsmeldung "Layer wurde ... geladen (inkl.
  Flurstücksverschneidung)" erscheint nicht mehr bei "Nur Objekte laden".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:55:03 +02:00
Michael Otto 148d4dfa15 BROR-Mehrfachauswahl, zweistufiger Fortschrittsdialog, Landkreis-Fallback
- Fortschrittsdialog beim Laden aus ALKIS schließt jetzt zuverlässig bei
  jedem Abbruch (progress.close() zentral im finally-Block).
- Fortschrittsanzeige in zwei Dialoge aufgeteilt: Suche (1 Schritt) läuft
  und schließt separat, bevor der eigentliche Workflow (Gemarkung/
  Flurstücke/Vergleich, 6 Schritte) startet – auch während des BROR-
  Auswahldialogs läuft kein störender Hintergrund-Fortschrittsbalken mehr.
- verfahrensgebiet_alkis() in Suche-/Auswahl-Phasen aufgeteilt, damit
  mehrere gefundene BauRaumOderBodenordnungsrecht-Objekte gemeinsam
  ausgewählt und verschmolzen werden können (öffentliche Signatur
  unverändert).
- Neu: Landkreis-weite Suche als Fallback, wenn keine Verfahrensnummer
  gesetzt ist (Landkreiskennzahl aus Projektvariable), mit Mehrfach-
  auswahl/"Alle auswählen" und "Nur laden"-Option (überspringt den
  restlichen Workflow). Gruppierung der Auswahl nach VKZ, damit je
  Verfahren ein eigenes Feature entsteht statt eines Gesamt-Blobs.
- Tests entsprechend angepasst/ergänzt (29 Tests in
  test_verfahrensgebiet_alkis_komplett.py, davon 3 neu für den
  Landkreis-Fallback).
2026-08-27 15:14:07 +02:00
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
18 changed files with 4169 additions and 109 deletions
+16
View File
@@ -1,3 +1,19 @@
- Fortschrittsdialog beim Laden aus ALKIS schließt jetzt zuverlässig bei jedem Abbruch (Bugfix)
- Fortschrittsanzeige beim Laden aus ALKIS in zwei Dialoge aufgeteilt (Suche / eigentlicher Ladevorgang), inkl. eigenem Suchschritt
- Mehrfachauswahl bei mehreren BauRaumOderBodenordnungsrecht-Treffern zur Verfahrensnummer (gemeinsames Laden/Verschmelzen mehrerer Objekte)
- Neu: Laden aller/mehrerer Objekte eines ganzen Landkreises als Fallback, wenn keine Verfahrensnummer gesetzt ist (Gruppierung je Verfahren, optional "Nur laden" ohne weiteren Ablauf)
- Neu: Ist weder Verfahrensnummer noch Landkreis in den Projekteigenschaften gesetzt, fragt ein Dialog den Landkreis ab (inkl. Option "Alle Landkreise"), statt direkt abzubrechen
- Landkreisweiter Objekt-Auswahldialog überarbeitet: Tabelle mit sortierbaren Spalten "Nummer"/"Name" (Standard: aufsteigend nach Nummer), Suchfeld, "Alle auswählen" jetzt korrekt (vorheriger Qt-Bug bei programmatischer Mehrfachauswahl behoben)
- Bugfix: BROR-Layer bekommt seinen ALKIS-Style jetzt direkt beim Laden (auch bei "Nur Objekte laden") statt später fälschlich den Verfahrensgebiet-Style zu erhalten – **Detailprüfung/Verifikation im echten QGIS-Test steht noch aus**
- Bugfix: Erfolgsmeldung "Layer wurde ... geladen (inkl. Flurstücksverschneidung)" erscheint nicht mehr fälschlich bei "Nur Objekte laden" (da dort keine Verschneidung stattfindet)
- Verfahrensgebiet-spezifische ALKIS-Dialoge aus sn_basis nach sn_verfahrensgebiet verschoben (functions/verfahrensgebiet_dialoge.py)
---
Version 26.8.1-unstable:
- Neu: "Beteiligung prüfen" – Abgleich einer BI-Server-CSV-Exportliste mit dem ALKIS-Flurstücksbestand des Verfahrensgebiets (try1: schneller Listenabgleich bei vorhandenem Kennzeichen-Bestand; try2/try3: Aufforderung, das Verfahrensgebiet zunächst über "Aus ALKIS laden" zu laden, statt automatischem Hintergrund-Laden)
---
Version 26.7.1-unstable:
- Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt
---
- 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)
+69
View File
@@ -0,0 +1,69 @@
# Ablauf: Aus ALKIS laden
Betrachteter Teilablauf: nur bis zum Erzeugen des Layers
"BauRaumOderBodenordnungsrecht (ALKIS)". Die anschließenden Schritte des
vollständigen Workflows (Gemarkungen, Flurstücke, Flächenvergleich,
Detailprüfung) sind hier bewusst nicht dargestellt.
```mermaid
flowchart TD
Start(["Aus ALKIS laden"])
B0{"Verfahrensnummer oder Landkreis in Projekteigenschaften gesetzt?"}
LKAUSW["Landkreis auswählen (Dialog)"]
Abbruch(["Abbruch"])
MODUS{"Verfahrensnummer gesetzt?"}
EXIST1{"Layer 'Verfahrensgebiet' bereits vorhanden?"}
EXIST2{"Layer 'Verfahrensgebiet' bereits vorhanden?"}
Keep[("Verfahrensgebiet (bestehender Layer)")]
WFS1["WFS-Suche per Verfahrensnummer"]
WFS2["WFS-Suche per Landkreis"]
C1{"Mehrere Objekt-Kombinationen gefunden?"}
AUSW1["Objektauswahl"]
AUSW2["Objektauswahl"]
BUILD["Layer erzeugen"]
Ende[("BauRaumOderBodenordnungsrecht (ALKIS)")]
Start --> B0
B0 -- Nein --> LKAUSW
LKAUSW -- Abbruch --> Abbruch
LKAUSW -- OK --> MODUS
B0 -- Ja --> MODUS
MODUS -- Ja --> EXIST1
MODUS -- Nein --> EXIST2
EXIST1 -- "Ja, behalten" --> Keep
EXIST2 -- "Ja, behalten" --> Keep
EXIST1 -- "Nein / neu laden" --> WFS1
EXIST2 -- "Nein / neu laden" --> WFS2
WFS1 --> C1
C1 -- Ja --> AUSW1
C1 -- Nein --> BUILD
AUSW1 --> BUILD
WFS2 --> AUSW2
AUSW2 --> BUILD
BUILD --> Ende
```
Hinweise zu den Entscheidungsknoten:
- **B0**: Es werden die Projektvariablen `verfahrensnummer` und
`landkreis_kennzahl` gemeinsam geprüft. Nur wenn **beide** fehlen, wird der
Landkreis per Dialog abgefragt.
- **MODUS**: Ist eine Verfahrensnummer gesetzt, wird über die Verfahrensnummer
gesucht (auch wenn zusätzlich ein Landkreis gesetzt ist); andernfalls über
den Landkreis.
- **EXIST1/EXIST2**: Existiert bereits ein Layer "Verfahrensgebiet" und lehnt
der Nutzer das Neuladen ab, wird direkt dieser vorhandene Layer verwendet –
ohne erneute WFS-Suche oder Objektauswahl.
- **C1 (nur im Verfahrensnummer-Zweig)**: Die Objektauswahl erscheint nur,
wenn der WFS mehrere unterschiedliche (adv_name, bezeichnung)-Kombinationen
liefert. Bei genau einem Treffer wird der Layer ohne Nutzerinteraktion
erzeugt.
- **Landkreis-Zweig**: Hier erscheint die Objektauswahl immer (inkl.
"Alle auswählen"-Option), da hier potenziell deutlich mehr Treffer als bei
der Verfahrensnummer-Suche auftreten.
+20
View File
@@ -0,0 +1,20 @@
# Ablauf: Aus Shape laden
```mermaid
flowchart TD
Start(["Aus Shape laden"])
A0{"Datei öffnen?"}
A2["entpacken"]
A3["Inhalt prüfen"]
Ende1[("Layer:</br>Neuordnungsgebiet (NoBIS)")]
Ende2[("Layer:</br>Neuvermessungsgebiet (NoBIS)")]
Start --> A0
A0 -- Shape --> A3
A0 -- ZIP --> A2
A2 --> A3
A3 -- Neuordnungsgebiet --> Ende1
A3 -- Neuvermessungsgebiet --> Ende2
```
+127
View File
@@ -0,0 +1,127 @@
# Technischer Ablauf: Aus Shape laden (bis zum Stop)
Ergänzung zu [aus_shape_laden.md](aus_shape_laden.md), die den fachlichen
Ablauf in Kurzform beschreibt. Dieses Dokument zeigt den tatsächlichen
technischen Ablauf in `lade_vg_aus_zip()` und dem anschließenden Stop in
`verfahrensgebiet_shape_komplett()` (Datei
`functions/verfahrensgebiet_shape_komplett.py`).
Betrachteter Teilablauf: nur bis zum Stop nach dem Einfügen der Layer ins
Projekt. Die anschließenden Schritte (Gemarkung, Flurstücke,
Flächenvergleich, Detailprüfung) sind hier nicht dargestellt.
```mermaid
flowchart TD
Start(["Aus Shape laden"])
DLG["Dateidialog (Mehrfachauswahl):<br/>.zip und/oder .shp"]
ABBRUCH1(["Abbruch (keine Auswahl)"])
SAMMEL["Für jede ausgewählte Datei:"]
ISZIP{"Endet auf .shp?"}
DIREKT["Direkt als Shapefile-Eintrag übernehmen"]
ENTPACK["In temporäres Verzeichnis entpacken"]
ENTPACKFEHLER{"Entpacken erfolgreich?"}
FEHLER_ZIP["Fehler notieren:<br/>'ZIP konnte nicht entpackt werden'"]
FINDEALL["Alle .shp-Dateien im<br/>entpackten Verzeichnis suchen"]
KEINSHP{"Mind. eine .shp gefunden?"}
FEHLER_KEIN_SHP["Fehler notieren:<br/>'kein gültiges Shape-Dateiset'"]
ALS_EINTRAG["Jede gefundene .shp als<br/>eigenen Eintrag übernehmen"]
PROJEDEN["Für jeden Shapefile-Eintrag:"]
LOAD["Als QgsVectorLayer laden"]
VALID{"isValid()?"}
FEHLER_INVALID["Fehler notieren:<br/>'kein gültiges Shape-Dateiset'"]
FBSCHL{"Feld 'FBSchl' vorhanden<br/>und Wert bekannt?"}
FEHLER_FBSCHL["Fehler notieren:<br/>Feld fehlt / unbekannter Wert"]
TYP{"FBSchl-Wert"}
ALS_NEUORDNUNG["Geometrien zu<br/>'Neuordnungsgebiet' sammeln"]
ALS_NEUVERMESSUNG["Geometrien zu<br/>'Neuvermessungsgebiet' sammeln"]
NACHSCHLEIFE["Nach Verarbeitung aller Einträge"]
FEHLERVORHANDEN{"Fehler gesammelt?"}
SAMMELMELDUNG["Sammelmeldung anzeigen:<br/>alle fehlerhaften Dateien"]
NICHTSVERWERTBAR{"Mind. ein Typ mit<br/>gültigen Geometrien?"}
ABBRUCH2(["Abbruch (None)"])
MERGE["Je Gebietstyp:<br/>unaryUnion aller gesammelten Geometrien<br/>→ ein Memory-Layer je Typ"]
BENENNEN["Layer benennen:<br/>'Neuordnungsgebiet (NoBIS)' /<br/>'Neuvermessungsgebiet (NoBIS)'"]
RETURN["Liste der Layer zurückgeben<br/>(1 oder 2 Layer)"]
EINFUEGEN["Alle Layer ins Projekt einfügen"]
STOP{{"STOP:<br/>'Layer ... wurde(n) geladen. Weiter?'"}}
WEITER(["weiter mit Gemarkung/Flurstücke/…"])
ABBRUCH3(["Abbruch, Layer bleiben im Projekt"])
Start --> DLG
DLG -- keine Auswahl --> ABBRUCH1
DLG -- Auswahl --> SAMMEL
SAMMEL --> ISZIP
ISZIP -- Ja --> DIREKT
ISZIP -- Nein --> ENTPACK
ENTPACK --> ENTPACKFEHLER
ENTPACKFEHLER -- Nein --> FEHLER_ZIP
ENTPACKFEHLER -- Ja --> FINDEALL
FINDEALL --> KEINSHP
KEINSHP -- Nein --> FEHLER_KEIN_SHP
KEINSHP -- Ja --> ALS_EINTRAG
DIREKT --> PROJEDEN
ALS_EINTRAG --> PROJEDEN
FEHLER_ZIP -.->|nächste Datei| SAMMEL
FEHLER_KEIN_SHP -.->|nächste Datei| SAMMEL
PROJEDEN --> LOAD
LOAD --> VALID
VALID -- Nein --> FEHLER_INVALID
VALID -- Ja --> FBSCHL
FBSCHL -- Nein --> FEHLER_FBSCHL
FBSCHL -- Ja --> TYP
TYP -- "901/10110F" --> ALS_NEUORDNUNG
TYP -- "901/10120F" --> ALS_NEUVERMESSUNG
FEHLER_INVALID -.->|nächstes Shapefile| PROJEDEN
FEHLER_FBSCHL -.->|nächstes Shapefile| PROJEDEN
ALS_NEUORDNUNG -.->|nächstes Shapefile| PROJEDEN
ALS_NEUVERMESSUNG -.->|nächstes Shapefile| PROJEDEN
PROJEDEN --> NACHSCHLEIFE
NACHSCHLEIFE --> FEHLERVORHANDEN
FEHLERVORHANDEN -- Ja --> SAMMELMELDUNG
FEHLERVORHANDEN -- Nein --> NICHTSVERWERTBAR
SAMMELMELDUNG --> NICHTSVERWERTBAR
NICHTSVERWERTBAR -- Nein --> ABBRUCH2
NICHTSVERWERTBAR -- Ja --> MERGE
MERGE --> BENENNEN
BENENNEN --> RETURN
RETURN --> EINFUEGEN
EINFUEGEN --> STOP
STOP -- Ja --> WEITER
STOP -- Nein --> ABBRUCH3
```
## Hinweise
- **Mehrfachauswahl**: Es können mehrere Dateien gleichzeitig gewählt werden,
auch gemischt `.zip` und `.shp`.
- **ZIP mit mehreren Shapefile-Sets**: Enthält ein ZIP mehrere `.shp`-Dateien,
wird jede einzeln geprüft und verarbeitet – wie ein eigener Eintrag in der
Auswahl.
- **Fehler pro Datei, nicht pro Vorgang**: Schlägt die Prüfung einer
einzelnen Datei/eines einzelnen Shapefiles fehl (ZIP nicht entpackbar,
kein Shapefile enthalten, ungültiger Layer, Feld `FBSchl` fehlt oder hat
einen unbekannten Wert), wird nur dieser Eintrag übersprungen. Alle
anderen werden weiterverarbeitet. Am Ende erscheint eine **gesammelte**
Fehlermeldung mit allen fehlerhaften Einträgen – Abbruch (`None`) nur,
wenn *kein* Eintrag verwertbare Geometrien geliefert hat.
- **Zusammenführen gleicher Gebietstypen**: Alle Geometrien mit demselben
`FBSchl`-Typ (über alle Dateien/Shapefiles hinweg) werden per
`QgsGeometry.unaryUnion` zu **einem** Memory-Layer verschmolzen – nicht
ein Layer pro Datei.
- **Temporäre Verzeichnisse**: Jedes entpackte ZIP landet in einem eigenen
`tempfile.mkdtemp()`-Verzeichnis, das nach der Verarbeitung (auch im
Fehlerfall) wieder gelöscht wird.
- **Stop-Punkt**: Nach dem Einfügen aller erzeugten Layer ins Projekt fragt
ein Ja/Nein-Dialog, ob mit dem restlichen Workflow (Gemarkung,
Flurstücke, Flächenvergleich, Detailprüfung) fortgefahren werden soll.
Bei "Nein" bleiben die geladenen Layer im Projekt erhalten, der weitere
Ablauf wird aber nicht gestartet.
+452
View File
@@ -0,0 +1,452 @@
"""sn_verfahrensgebiet/functions/beteiligung_pruefung.py – Beteiligung prüfen.
AGLB-länderspezifisch (Flurstückskennzeichen-Bildung nach sächsischem ALKIS-
Schema). Die Länderkennung wird nicht hartcodiert, sondern über
:func:`sn_basis.functions.laenderkennung.get_laenderkennung` ermittelt, damit
Schwesterplugins für andere Bundesländer dieselbe Logik mit einer anderen
Länderkennung wiederverwenden können.
Gleicht eine CSV-Exportliste des BI-Servers (Kopfzeile
``VEF_VKZF,FLA_Beteiligung,FLURSTUECK``) mit dem Verfahrensgebiet-Bestand
ab. Aus ``FLURSTUECK`` (Format ``<Gemarkung>/<Zähler>/<Nenner>/<Folge>``) wird
je Zeile ein zu ALKIS kompatibles Flurstückskennzeichen ``Flurstueck_csv``
gebildet.
Kaskade je nach Projektzustand:
- **try1**: Ein Layer ``Verfahrensgebiet`` mit befülltem Attribut
``flurstueckskennzeichen`` (Single-Feature-Modell, siehe
:mod:`verfahrensgebiet_edit_service`) ist vorhanden → schneller
Mengenvergleich ohne WFS-Zugriff.
- **try2**: Ein Verfahrensgebiet-Layer ist vorhanden, aber ohne verwertbare
Kennzeichen → statt einer automatischen (nutzerlosen) ALKIS-Ladepipeline
wird der Nutzer per Meldung aufgefordert, das Verfahrensgebiet zunächst
regulär über "Aus ALKIS laden" neu zu laden und die Beteiligungsprüfung
danach erneut zu starten (landet dann automatisch bei try1). Ein
automatischer Durchlauf hat sich in der Praxis nicht als zuverlässig genug
erwiesen (mehrdeutige BROR-Treffer, Flächenabweichungen etc. lassen sich
nur im interaktiven Ablauf sinnvoll auflösen).
- **try3**: Kein Verfahrensgebiet-Layer im Projekt → Verfahrensnummer aus
``VEF_VKZF`` wird als Projektvariable übernommen, danach wie try2 (Meldung
statt automatischem Laden).
"""
from __future__ import annotations
import csv
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from sn_basis.functions import QgsVectorLayer, QgsProject, QgsFeature, QgsField
from sn_basis.functions.qt_wrapper import QFileDialog, QVariant
from sn_basis.functions.variable_wrapper import set_variable
from sn_basis.functions.laenderkennung import get_laenderkennung
from sn_basis.functions.dialog_wrapper import create_progress_dialog
from sn_verfahrensgebiet.functions.flurstueck_alkis import FLST_LAYER_NAME
from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import (
VERF_LAYER_NAME,
get_kennzeichen_menge,
)
CSV_SPALTE_FLURSTUECK = "FLURSTUECK"
CSV_SPALTE_VEF_VKZF = "VEF_VKZF"
CSV_SPALTE_BETEILIGUNG = "FLA_Beteiligung"
FELD_FLURSTUECK_CSV = "Flurstueck_csv"
FELD_FLURSTUECKSKENNZEICHEN_WFS = "flurstueckskennzeichen"
ERGEBNIS_LAYER_VERGLEICH = "Beteiligung – Vergleich"
WERT_BEIDE = "beide"
WERT_NUR_VERFAHRENSGEBIET = "nur_verfahrensgebiet"
WERT_NUR_AGLB = "nur_aglb"
class BeteiligungPruefungFehler(Exception):
"""Erwarteter, dem Nutzer per Messagebox mitzuteilender Fehlerfall."""
@dataclass
class BeteiligungErgebnis:
"""Ergebnis eines Prüflaufs, das die aufrufende UI ins Projekt einfügt."""
modus: str
layers: List[Tuple[object, Optional[str]]] = field(default_factory=list)
statistik: Dict[str, int] = field(default_factory=dict)
statistik_text: str = ""
# ---------------------------------------------------------------------------
# CSV laden
# ---------------------------------------------------------------------------
def lade_beteiligung_csv(parent_widget) -> Optional[Tuple[List[dict], List[str]]]:
"""Öffnet Dateidialog (CSV-Filter) und liest die BI-Server-Exportliste.
Returns
-------
Optional[Tuple[List[dict], List[str]]]
``(zeilen, spaltennamen)`` oder ``None`` bei Nutzerabbruch.
Raises
------
BeteiligungPruefungFehler
Bei ungültigem CSV-Format oder fehlendem Attribut ``FLURSTUECK``.
"""
pfad, _ = QFileDialog.getOpenFileName(
parent_widget,
"CSV-Export aus BI-Server auswählen",
"",
"CSV-Dateien (*.csv)",
)
if not pfad:
return None # Nutzerabbruch
try:
with open(pfad, "r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f, delimiter=",")
zeilen = list(reader)
spaltennamen = reader.fieldnames or []
except (OSError, csv.Error, UnicodeDecodeError) as exc:
raise BeteiligungPruefungFehler(
f"CSV-Format ungültig – Datei konnte nicht gelesen werden: {exc}"
) from exc
if not spaltennamen or not zeilen:
raise BeteiligungPruefungFehler(
"CSV-Format ungültig – die Datei ist leer oder enthält keine Kopfzeile."
)
if CSV_SPALTE_FLURSTUECK not in spaltennamen:
raise BeteiligungPruefungFehler(
f"Die CSV enthält nicht das benötigte Attribut '{CSV_SPALTE_FLURSTUECK}'."
)
return zeilen, spaltennamen
# ---------------------------------------------------------------------------
# Flurstueck_csv-Bildung
# ---------------------------------------------------------------------------
#: Alte CSV-Exporte führen Buchstaben-Nenner (ALKIS: a, b, c, ...) teils als
#: numerischen Platzhalter 9001, 9002, ... statt als Buchstabe. 9001 → "a",
#: 9002 → "b" usw. (Basiswert, auf den 1..26 für a..z addiert wird).
_NENNER_BUCHSTABEN_BASIS = 9000
def _baue_nenner_kennzeichen(nenner: str) -> str:
"""Bildet den 4-stelligen Nenner-Teil des Flurstückskennzeichens.
Normalfall: numerischer Nenner, 4-stellig mit führenden Nullen
aufgefüllt; Nenner 0/leer → Platzhalter ``"____"``.
Format-Inkonsistenz alter CSV-Exporte: ein Buchstaben-Nenner (z.B. "a")
wird dort als ``9001`` (a), ``9002`` (b), usw. geführt. Wird ein solcher
Platzhalterwert erkannt, wird stattdessen der Buchstabe selbst
(4-stellig mit führenden Nullen aufgefüllt, z.B. ``"000a"``) verwendet,
damit das Kennzeichen mit dem ALKIS-Bestand übereinstimmt.
"""
nenner = (nenner or "").strip()
nenner_ohne_null = nenner.lstrip("0")
if not nenner_ohne_null:
return "____"
if nenner_ohne_null.isdigit():
zahl = int(nenner_ohne_null)
if _NENNER_BUCHSTABEN_BASIS < zahl <= _NENNER_BUCHSTABEN_BASIS + 26:
buchstabe = chr(ord("a") + (zahl - _NENNER_BUCHSTABEN_BASIS - 1))
return buchstabe.zfill(4)
return nenner.zfill(4)
def baue_flurstueck_csv(flurstueck_wert: str, laenderkennung: str) -> Optional[str]:
"""Bildet aus dem CSV-Attribut ``FLURSTUECK`` ein ALKIS-Flurstückskennzeichen.
Das Attribut hat das Format
``<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)
progress.raise_to_front()
verf_menge = get_kennzeichen_menge(verf_layer)
csv_menge = set(flurstueck_csv_liste)
progress.set_value(1)
progress.set_label("CSV-Liste abgleichen …")
beide = sorted(verf_menge & csv_menge)
nur_verfahrensgebiet = sorted(verf_menge - csv_menge)
nur_aglb = sorted(csv_menge - verf_menge)
progress.set_value(2)
progress.set_label("Ergebnislayer erstellen …")
flst_layer = _hole_flurstuecke_layer()
geom_map = _flurstueckskennzeichen_geometrien(flst_layer)
if flst_layer is not None and flst_layer.isValid():
crs = flst_layer.crs().authid()
elif verf_layer.isValid():
crs = verf_layer.crs().authid()
else:
crs = "EPSG:25833"
layer = QgsVectorLayer(f"Polygon?crs={crs}", ERGEBNIS_LAYER_VERGLEICH, "memory")
pr = layer.dataProvider()
pr.addAttributes([
QgsField(FELD_FLURSTUECK_CSV, QVariant.String),
QgsField("status", QVariant.String),
])
layer.updateFields()
fields = layer.fields()
neue_features = []
for status, menge in (
(WERT_BEIDE, beide),
(WERT_NUR_VERFAHRENSGEBIET, nur_verfahrensgebiet),
(WERT_NUR_AGLB, nur_aglb),
):
for kz in menge:
feat = QgsFeature(fields)
geom = geom_map.get(kz)
if geom is not None:
feat.setGeometry(geom)
feat.setAttributes([kz, status])
neue_features.append(feat)
pr.addFeatures(neue_features)
layer.updateExtents()
progress.set_value(3)
progress.close()
statistik = {
WERT_BEIDE: len(beide),
WERT_NUR_VERFAHRENSGEBIET: len(nur_verfahrensgebiet),
WERT_NUR_AGLB: len(nur_aglb),
}
return layer, statistik
# ---------------------------------------------------------------------------
# try2/try3: Aufforderung statt automatischer ALKIS-Pipeline
# ---------------------------------------------------------------------------
#
# Ein automatischer (nutzerloser) Durchlauf der kompletten Ladepipeline
# (mehrdeutige BROR-Treffer, Flächenabweichungen, Detailprüfung) hat sich in
# der Praxis nicht als zuverlässig genug erwiesen – deshalb hier bewusst
# KEIN silent-Aufruf von verfahrensgebiet_alkis_komplett(), sondern eine
# Aufforderung an den Nutzer, den regulären interaktiven Ablauf
# ("Aus ALKIS laden") selbst zu starten. Ein erneuter Klick auf
# "Beteiligung prüfen" landet danach automatisch bei try1, sobald der Layer
# verwertbare Kennzeichen hat.
def _fordere_manuelles_laden_an(vef_vkzf: Optional[str] = None) -> None:
"""Bricht try2/try3 mit einer Aufforderung an den Nutzer ab (statt eines
automatischen Ladevorgangs)."""
hinweis = (
"Bitte laden Sie das Verfahrensgebiet jetzt über 'Aus ALKIS laden' "
"und starten Sie die Beteiligungsprüfung danach erneut."
)
if vef_vkzf:
raise BeteiligungPruefungFehler(
f"Es ist noch kein Verfahrensgebiet geladen. Die Verfahrensnummer "
f"{vef_vkzf} wurde aus der CSV übernommen. {hinweis}"
)
raise BeteiligungPruefungFehler(
f"Der vorhandene Verfahrensgebiet-Layer enthält keine verwertbaren "
f"Flurstückskennzeichen. {hinweis}"
)
# ---------------------------------------------------------------------------
# try3: Bootstrap (Verfahrensnummer aus CSV) + Aufforderung wie try2
# ---------------------------------------------------------------------------
def _lese_vef_vkzf(zeilen: List[dict], spaltennamen: List[str]) -> str:
if CSV_SPALTE_VEF_VKZF not in spaltennamen:
raise BeteiligungPruefungFehler(
f"Die CSV enthält nicht das benötigte Attribut '{CSV_SPALTE_VEF_VKZF}'."
)
for zeile in zeilen:
wert = (zeile.get(CSV_SPALTE_VEF_VKZF) or "").strip()
if wert:
return wert
raise BeteiligungPruefungFehler(
f"Die CSV enthält keinen befüllten Wert für '{CSV_SPALTE_VEF_VKZF}'."
)
def _try3_bootstrap(tab_widget, zeilen: List[dict], spaltennamen: List[str]) -> None:
vef_vkzf = _lese_vef_vkzf(zeilen, spaltennamen)
set_variable("verfahrensnummer", vef_vkzf, scope="project")
_fordere_manuelles_laden_an(vef_vkzf)
# ---------------------------------------------------------------------------
# Öffentliche API
# ---------------------------------------------------------------------------
def beteiligung_pruefen(tab_widget) -> Optional[BeteiligungErgebnis]:
"""Vollständiger Ablauf: CSV laden → Flurstueck_csv bilden → try1/try2/try3.
Returns
-------
Optional[BeteiligungErgebnis]
``None`` bei Nutzerabbruch des Dateidialogs. Sonst das Ergebnis mit
den ins Projekt einzufügenden Layern und der Statistik.
Raises
------
BeteiligungPruefungFehler
Bei einem der dokumentierten Fehlerfälle (CSV-Format, fehlende
Spalten) oder wenn kein Verfahrensgebiet mit verwertbaren
Kennzeichen vorliegt (try2/try3 – der Nutzer wird dann aufgefordert,
das Verfahrensgebiet zunächst über "Aus ALKIS laden" zu laden und
die Beteiligungsprüfung danach erneut zu starten). Der Aufrufer
zeigt daraufhin eine Messagebox an.
"""
geladen = lade_beteiligung_csv(tab_widget)
if geladen is None:
return None
zeilen, spaltennamen = geladen
laenderkennung = get_laenderkennung("sn")
flurstueck_csv_liste = baue_flurstueck_csv_liste(zeilen, laenderkennung)
if not flurstueck_csv_liste:
raise BeteiligungPruefungFehler(
"Aus der CSV konnten keine gültigen Flurstück-Kennzeichen gebildet werden "
"(Attribut 'FLURSTUECK' muss 4 durch '/' getrennte Teile enthalten)."
)
verf_layer = _hole_verfahrensgebiet_layer(tab_widget)
if verf_layer is not None and _hat_verwendbare_kennzeichen(verf_layer):
layer, statistik = _try1_listenabgleich(tab_widget, verf_layer, flurstueck_csv_liste)
text = (
f"Übereinstimmend: {statistik[WERT_BEIDE]}\n"
f"Nur im Verfahrensgebiet: {statistik[WERT_NUR_VERFAHRENSGEBIET]}\n"
f"Nur in der CSV-Liste (AGLB): {statistik[WERT_NUR_AGLB]}"
)
return BeteiligungErgebnis("try1", [(layer, "beteiligung_vergleich.qml")], statistik, text)
if verf_layer is not None:
_fordere_manuelles_laden_an()
return None # unreachable – _fordere_manuelles_laden_an() wirft immer
_try3_bootstrap(tab_widget, zeilen, spaltennamen)
return None # unreachable – _try3_bootstrap() wirft immer
+204 -26
View File
@@ -9,7 +9,8 @@ from sn_basis.functions import (
from sn_basis.functions import QMessageBox
from sn_basis.functions.qt_wrapper import QVariant
from sn_basis.functions.variable_wrapper import get_variable
from sn_basis.functions.dialog_wrapper import ask_bror_kombination
from sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge import ask_bror_kombinationen, ask_landkreis_objekte
from sn_basis.functions.landkreise import ALLE_LANDKREISE
alkis_sf_url = "https://geodienste.sachsen.de/aaa/public_alkis/sf/wfs"
typename = "adv:AX_BauRaumOderBodenordnungsrecht"
@@ -28,7 +29,7 @@ BROR_ATTRIBUTE_FELDER = [
class LoadStatus(Enum):
"""Status eines Layer-Ladevorgangs.
Attributes:
NONE: Nichts geladen
FIRST: Erstmalig geladen
@@ -44,10 +45,10 @@ class LoadStatus(Enum):
def _check_existing_layer(tab_widget):
"""Prüft, ob ein Verfahrensgebiet-Layer bereits existiert.
Args:
tab_widget: Parent-Widget für Dialoge
Returns:
Tuple[Optional[QgsVectorLayer], LoadStatus]: Layer und Status
"""
@@ -72,30 +73,36 @@ def _check_existing_layer(tab_widget):
return existing_layers[0], LoadStatus.KEEP
def verfahrensgebiet_alkis(tab_widget):
"""Lädt Verfahrensgebiet aus ALKIS-WFS basierend auf Verfahrensnummer.
def _suche_verfahrensgebiet_features(tab_widget, skip_existing_check: bool = False):
"""Phase 1 ("Suchen"): vorhandenen Layer prüfen, WFS abfragen, nach
(adv_name, bezeichnung) gruppieren.
Args:
tab_widget: Parent-Widget für Dialoge und Meldungen
skip_existing_check: Überspringt die Nachfrage bei bereits vorhandenem
Layer (immer neu laden).
Returns:
Tuple[Optional[QgsVectorLayer], LoadStatus, List[QgsFeature]]:
Geladener Memory-Layer, Lade-Status und Liste der Roh-WFS-Features
(für geometriebasierte Weiterverarbeitung, z.B. Gemarkungsschlüssel-
Ermittlung via Spatial-Join). Bei Abbruch oder Fehler: ``(None,
LoadStatus.NONE, [])``. Bei ``LoadStatus.KEEP`` werden keine
WFS-Features neu geladen; die Feature-Liste ist in diesem Fall leer.
Tuple[Optional[QgsVectorLayer], LoadStatus, dict, Optional[str]]:
``(fertig_layer, status, kombinationen, crs)``.
Bei ``KEEP`` oder Fehler/Abbruch ist ``fertig_layer`` bereits das
Endergebnis (ggf. ``None``) und ``kombinationen`` leer – Phase 2
entfällt dann. Bei Erfolg mit mindestens einer Kombination ist
``fertig_layer`` ``None`` und ``kombinationen``/``crs`` sind für
Phase 2 (:func:`_baue_layer_aus_kombinationen`) befüllt.
"""
verfahrensnummer = get_variable("verfahrensnummer")
if not verfahrensnummer:
QMessageBox.critical(tab_widget, "Fehler",
"In den Projekteigenschaften ist noch keine Verfahrensnummer eingetragen!")
return None, LoadStatus.NONE, []
return None, LoadStatus.NONE, {}, None
# Vorhandenen Layer prüfen
existing, status = _check_existing_layer(tab_widget)
if status == LoadStatus.KEEP:
return existing, status, []
# 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, {}, None
# WFS mit Filter laden – nur erste 5 Stellen der VKZ für den Vergleich verwenden,
# da die Daten im Dienst ggf. abweichende Suffixe führen.
@@ -109,13 +116,13 @@ def verfahrensgebiet_alkis(tab_widget):
if not temp_layer.isValid():
QMessageBox.critical(tab_widget, "Fehler", "Layer konnte nicht geladen werden.")
return None, LoadStatus.NONE, []
return None, LoadStatus.NONE, {}, None
features = list(temp_layer.getFeatures())
if not features:
QMessageBox.critical(tab_widget, "Fehler",
f"Verfahrenskennzeichen {verfahrensnummer} nicht im WFS gefunden.")
return None, LoadStatus.NONE, []
return None, LoadStatus.NONE, {}, None
# Objekte nach (adv_name, bezeichnung) gruppieren
kombinationen: dict[tuple[str, str], list] = {}
@@ -131,16 +138,32 @@ def verfahrensgebiet_alkis(tab_widget):
kombinationen[key] = []
kombinationen[key].append(feat)
return None, status, kombinationen, temp_layer.crs().authid()
def _baue_layer_aus_kombinationen(tab_widget, kombinationen: dict, crs: str, status: "LoadStatus"):
"""Phase 2 ("Auswählen + Laden"): bei mehreren Kombinationen Auswahldialog
zeigen, Geometrien der gewählten Kombination(en) verschmelzen, Memory-Layer bauen.
Args:
tab_widget: Parent-Widget für Dialoge und Meldungen
kombinationen: Ergebnis aus :func:`_suche_verfahrensgebiet_features`
crs: CRS-Authid aus :func:`_suche_verfahrensgebiet_features`
status: Lade-Status aus Phase 1 (FIRST/RELOAD), wird durchgereicht
Returns:
Tuple[Optional[QgsVectorLayer], LoadStatus, List[QgsFeature]]
"""
if len(kombinationen) == 1:
selected_key = next(iter(kombinationen))
selected_feats = kombinationen[selected_key]
selected_feats = next(iter(kombinationen.values()))
else:
# Mehrere Kombinationen → Nutzerauswahl
# Mehrere Kombinationen → Nutzerauswahl, ein oder mehrere Objekte
# können gemeinsam ausgewählt und geladen (verschmolzen) werden.
key_list = sorted(kombinationen.keys())
selected_key = ask_bror_kombination(tab_widget, key_list)
if selected_key is None:
selected_keys = ask_bror_kombinationen(tab_widget, key_list)
if not selected_keys:
return None, LoadStatus.NONE, []
selected_feats = kombinationen[selected_key]
selected_feats = [feat for key in selected_keys for feat in kombinationen[key]]
union_geom = QgsGeometry.unaryUnion([f.geometry() for f in selected_feats])
if union_geom is None or union_geom.isEmpty():
@@ -148,7 +171,6 @@ def verfahrensgebiet_alkis(tab_widget):
return None, LoadStatus.NONE, []
# Memory-Layer erzeugen
crs = temp_layer.crs().authid()
memory_layer = QgsVectorLayer(f"Polygon?crs={crs}", "Verfahrensgebiet", "memory")
pr = memory_layer.dataProvider()
pr.addAttributes([QgsField(attr, QVariant.String) for attr in BROR_ATTRIBUTE_FELDER])
@@ -178,3 +200,159 @@ def verfahrensgebiet_alkis(tab_widget):
# Status: FIRST oder RELOAD; selected_feats für Gemarkungsermittlung weitergeben
return memory_layer, status, selected_feats
def _suche_landkreis_kombinationen(tab_widget, landkreis_kennzahl: str, skip_existing_check: bool = False):
"""Fallback-Suche über einen ganzen Landkreis (statt einer Verfahrensnummer).
Wie :func:`_suche_verfahrensgebiet_features`, aber mit WFS-Filter über die
Landkreiskennzahl (``bezeichnung LIKE '{landkreis_kennzahl}%'``) statt
über den 5-stelligen VKZ-Präfix. Gruppierung der Rohfeatures identisch:
nach exaktem (adv_name, bezeichnung).
Returns:
Tuple[Optional[QgsVectorLayer], LoadStatus, dict, Optional[str]]:
Gleiche Bedeutung wie bei :func:`_suche_verfahrensgebiet_features`.
"""
status = LoadStatus.FIRST
if not skip_existing_check:
existing, status = _check_existing_layer(tab_widget)
if status == LoadStatus.KEEP:
return existing, status, {}, None
# "Alle Landkreise" (Sentinel) -> kein Kennzahl-Präfix, LIKE '%' matcht alles.
praefix = "" if landkreis_kennzahl == ALLE_LANDKREISE else landkreis_kennzahl
landkreis_anzeige = "allen Landkreisen" if landkreis_kennzahl == ALLE_LANDKREISE else f"Landkreis {landkreis_kennzahl}"
wfs_uri_find = (
f"url='{alkis_sf_url}' typename='{typename}' srsname='EPSG:25833' "
f"sql=SELECT * FROM AX_BauRaumOderBodenordnungsrecht "
f"WHERE bezeichnung LIKE '{praefix}%'"
)
temp_layer = QgsVectorLayer(wfs_uri_find, "TempVerfahrensgebiet", "WFS")
if not temp_layer.isValid():
QMessageBox.critical(tab_widget, "Fehler", "Layer konnte nicht geladen werden.")
return None, LoadStatus.NONE, {}, None
features = list(temp_layer.getFeatures())
if not features:
QMessageBox.critical(tab_widget, "Fehler",
f"Für {landkreis_anzeige} wurden keine Objekte im WFS gefunden.")
return None, LoadStatus.NONE, {}, None
kombinationen: dict[tuple[str, str], list] = {}
for feat in features:
try:
adv_name_val = str(feat.attribute("adv_name") or "")
bezeichnung_val = str(feat.attribute("bezeichnung") or "")
except Exception:
adv_name_val = ""
bezeichnung_val = ""
key = (adv_name_val, bezeichnung_val)
if key not in kombinationen:
kombinationen[key] = []
kombinationen[key].append(feat)
return None, status, kombinationen, temp_layer.crs().authid()
def _baue_layer_aus_landkreis_auswahl(tab_widget, kombinationen: dict, crs: str, status: "LoadStatus"):
"""Auswahl + Bau für den Landkreis-Fallback.
Zeigt :func:`~sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge.ask_landkreis_objekte`
(Mehrfachauswahl inkl. "Alle auswählen" und "Nur laden"-Checkbox). Die
ausgewählten Kombinationen werden anhand der ersten 5 Zeichen von
``bezeichnung`` (= die eigentliche VKZ) gruppiert; je Gruppe wird EIN
Feature erzeugt (Geometrien der Gruppe verschmolzen). Damit entsteht bei
Auswahl mehrerer/aller Objekte eines Landkreises ein Layer mit mehreren
Features – einem je tatsächlichem Verfahren, nicht ein Gesamt-Blob.
Args:
tab_widget: Parent-Widget für Dialoge und Meldungen
kombinationen: Ergebnis aus :func:`_suche_landkreis_kombinationen`
crs: CRS-Authid aus :func:`_suche_landkreis_kombinationen`
status: Lade-Status aus Phase 1 (FIRST/RELOAD), wird durchgereicht
Returns:
Tuple[Optional[QgsVectorLayer], LoadStatus, bool]:
``(layer, status, nur_laden)``.
"""
key_list = sorted(kombinationen.keys())
selected_keys, nur_laden = ask_landkreis_objekte(tab_widget, key_list)
if not selected_keys:
return None, LoadStatus.NONE, False
# Nach VKZ (erste 5 Zeichen der bezeichnung) gruppieren
vkz_gruppen: dict[str, list] = {}
for key in selected_keys:
_, bezeichnung = key
vkz = bezeichnung[:5]
vkz_gruppen.setdefault(vkz, []).extend(kombinationen[key])
memory_layer = QgsVectorLayer(f"Polygon?crs={crs}", "Verfahrensgebiet", "memory")
pr = memory_layer.dataProvider()
pr.addAttributes([QgsField(attr, QVariant.String) for attr in BROR_ATTRIBUTE_FELDER])
memory_layer.updateFields()
neue_features = []
for vkz, feats in sorted(vkz_gruppen.items()):
union_geom = QgsGeometry.unaryUnion([f.geometry() for f in feats])
if union_geom is None or union_geom.isEmpty():
continue
first_feat = feats[0]
attrs = []
for attr in BROR_ATTRIBUTE_FELDER:
try:
val = first_feat.attribute(attr)
if val is None:
attrs.append("")
elif type(val).__name__ == "QDate":
attrs.append(val.toString("dd.MM.yyyy"))
else:
attrs.append(str(val))
except Exception:
attrs.append("")
feat_out = QgsFeature()
feat_out.setGeometry(union_geom)
feat_out.setAttributes(attrs)
neue_features.append(feat_out)
if not neue_features:
QMessageBox.critical(tab_widget, "Fehler", "Keine Geometrien zum Verschmelzen gefunden.")
return None, LoadStatus.NONE, False
pr.addFeatures(neue_features)
memory_layer.updateExtents()
return memory_layer, status, nur_laden
def verfahrensgebiet_alkis(tab_widget, skip_existing_check: bool = False):
"""Lädt Verfahrensgebiet aus ALKIS-WFS basierend auf Verfahrensnummer.
Args:
tab_widget: Parent-Widget für Dialoge und Meldungen
Returns:
Tuple[Optional[QgsVectorLayer], LoadStatus, List[QgsFeature]]:
Geladener Memory-Layer, Lade-Status und Liste der Roh-WFS-Features
(für geometriebasierte Weiterverarbeitung, z.B. Gemarkungsschlüssel-
Ermittlung via Spatial-Join). Bei Abbruch oder Fehler: ``(None,
LoadStatus.NONE, [])``. Bei ``LoadStatus.KEEP`` werden keine
WFS-Features neu geladen; die Feature-Liste ist in diesem Fall leer.
Ruft intern nacheinander :func:`_suche_verfahrensgebiet_features` (Phase 1)
und ggf. :func:`_baue_layer_aus_kombinationen` (Phase 2) auf – aufgeteilt,
damit ``verfahrensgebiet_alkis_komplett()`` dazwischen einen eigenen
Fortschrittsschritt anzeigen kann. Verhalten/Signatur dieser Funktion
selbst ist unverändert.
"""
layer, status, kombinationen, crs = _suche_verfahrensgebiet_features(
tab_widget, skip_existing_check
)
if layer is not None or not kombinationen:
return layer, status, []
return _baue_layer_aus_kombinationen(tab_widget, kombinationen, crs, status)
+132 -46
View File
@@ -52,9 +52,15 @@ from sn_basis.functions.dialog_wrapper import (
show_info_dialog,
ask_detailpruefung_oder_vg_laden,
)
from sn_basis.functions.landkreise import LANDKREISE, ALLE_LANDKREISE
from sn_basis.functions.ly_style_wrapper import apply_style
from sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge import ask_landkreis_auswahl
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import (
verfahrensgebiet_alkis,
_suche_verfahrensgebiet_features,
_baue_layer_aus_kombinationen,
_suche_landkreis_kombinationen,
_baue_layer_aus_landkreis_auswahl,
LoadStatus,
)
from sn_verfahrensgebiet.functions.gemarkung_alkis import (
@@ -76,6 +82,8 @@ from sn_verfahrensgebiet.functions.knickpunkt_pruefung import (
FELD_VKZ = "verfahrensnummer"
#: Anzeigename des BROR-Layers im Projekt
BROR_LAYER_NAME = "BauRaumOderBodenordnungsrecht (ALKIS)"
#: QML-Stildatei für den BROR-Layer (sn_verfahrensgebiet/styles/)
BROR_STYLE_QML = "BauRaumOderBodenordnungsrecht.qml"
#: Projektvariable für die Flächentoleranz (in m²); Default 0
FLAECHEN_TOLERANZ_VARIABLE = "flaechen_toleranz_m2"
#: Minimale implizite Flächentoleranz – Abweichungen unter diesem Wert
@@ -258,15 +266,25 @@ def verfahrensgebiet_alkis_komplett(
"""Vollständiger VG-Lade-Workflow: ALKIS-VG → Gemarkungen →
Flurstücke → Verschneidung → Flächenvergleich → opt. Umringsprüfung.
Schrittfolge:
Zwei Fortschrittsdialoge nacheinander:
1. ``verfahrensgebiet_alkis(tab_widget)`` – BROR-Objekt als Memory-Layer.
2. ``gemarkung_schluessel_und_namen_von_vg_geometrie(geom)`` – Schlüssel.
3. ``flurstueck_alkis(...)`` – Flurstücke laden.
4. ``_select_by_location(...)`` – räumliche Selektion.
5. ``erstelle_verfahrensgebiet_aus_auswahl(...)`` – enriched Layer.
6. ``_vergleiche_flaechen(...)`` – Flächenvergleich als Gate.
7. Ggf. ``umringspruefung(...)`` nach Nutzerbestätigung.
- **Dialog 1** ("Verfahrensgebiet(e) in ALKIS ermitteln/suchen"): läuft
während ``_suche_verfahrensgebiet_features(tab_widget)`` (ALKIS-WFS
durchsuchen) und wird danach immer geschlossen – auch wenn anschließend
der BROR-Auswahldialog erscheint (kein Fortschrittsdialog läuft
währenddessen parallel zum Auswahldialog).
- **Dialog 2** (eigentlicher Workflow, 6 Schritte): startet erst, nachdem
das Verfahrensgebiet feststeht (direkt nach Dialog 1 bei eindeutigem
Treffer/KEEP, oder erst nach Bestätigung des Auswahldialogs mit OK).
Schrittfolge in Dialog 2:
1. ``gemarkung_schluessel_und_namen_von_vg_geometrie(geom)`` – Schlüssel.
2. ``flurstueck_alkis(...)`` – Flurstücke laden.
3. ``_select_by_location(...)`` – räumliche Selektion.
4. ``erstelle_verfahrensgebiet_aus_auswahl(...)`` – enriched Layer.
5. ``_vergleiche_flaechen(...)`` – Flächenvergleich als Gate.
6. Ggf. ``umringspruefung(...)`` nach Nutzerbestätigung.
Der BROR-Layer (``BauRaumOderBodenordnungsrecht (ALKIS)``) bleibt bei Erfolg
im Projekt. Bei Abbruch/Fehler wird er entfernt, falls er in diesem
@@ -290,26 +308,77 @@ def verfahrensgebiet_alkis_komplett(
alkis_vg_layer = None
bror_was_added_by_us = False
abgeschlossen = False
progress = None
# Fortschrittsdialog für den Basisworkflow (7 Schritte)
progress = create_progress_dialog(
7, "Verfahrensgebiet laden", "Initialisierung …"
)
verfahrensnummer = get_variable("verfahrensnummer")
landkreis_kennzahl = get_variable("landkreis_kennzahl")
if not verfahrensnummer and not landkreis_kennzahl:
# Keine Projekteigenschaften vorhanden – Landkreis per Dialog abfragen,
# statt direkt abzubrechen.
landkreis_kennzahl = ask_landkreis_auswahl(tab_widget, LANDKREISE)
if not landkreis_kennzahl:
# Nutzer hat den Landkreis-Dialog abgebrochen – stiller Abbruch,
# keine Fehlermeldung (bewusster Abbruch, kein Fehlerzustand).
return None, None, None, LoadStatus.NONE
landkreis_modus = not verfahrensnummer
# ------------------------------------------------------------------
# Dialog 1: Verfahrensgebiet(e) in ALKIS ermitteln/suchen
# ------------------------------------------------------------------
if landkreis_modus:
if landkreis_kennzahl == ALLE_LANDKREISE:
such_label = "Objekte in allen Landkreisen ermitteln/suchen …"
else:
such_label = f"Objekte in Landkreis {landkreis_kennzahl} ermitteln/suchen …"
else:
such_label = "Verfahrensgebiet(e) in ALKIS ermitteln/suchen …"
progress_suche = create_progress_dialog(1, "Verfahrensgebiet laden", such_label)
try:
progress_suche.set_value(0)
if progress_suche.is_canceled():
return None, None, None, LoadStatus.NONE
if landkreis_modus:
vorab_layer, vg_status, kombinationen, crs = _suche_landkreis_kombinationen(
tab_widget, landkreis_kennzahl
)
else:
vorab_layer, vg_status, kombinationen, crs = _suche_verfahrensgebiet_features(tab_widget)
finally:
# Dialog 1 schließt hier immer – egal ob eine Auswahl folgt oder nicht.
progress_suche.close()
nur_laden = False
try:
# ------------------------------------------------------------------
# Schritt 1: VG aus ALKIS laden (BROR-Objekt → Memory-Layer)
# ------------------------------------------------------------------
progress.set_label("Schritt 1/6: Verfahrensgebiet aus ALKIS laden …")
progress.set_value(0)
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
alkis_vg_layer, vg_status, _ = verfahrensgebiet_alkis(tab_widget)
progress.raise_to_front()
if vg_status == LoadStatus.NONE or not alkis_vg_layer:
return None, None, None, LoadStatus.NONE
if vorab_layer is not None or not kombinationen:
# KEEP oder Fehler/Abbruch bereits in Phase 1 entschieden
if vg_status == LoadStatus.NONE or not vorab_layer:
return None, None, None, LoadStatus.NONE
alkis_vg_layer = vorab_layer
elif landkreis_modus:
# ------------------------------------------------------------------
# Objekt(e) des Landkreises auswählen – kein eigener Fortschritts-
# dialog währenddessen, nur der Auswahldialog selbst.
# ------------------------------------------------------------------
alkis_vg_layer, vg_status, nur_laden = _baue_layer_aus_landkreis_auswahl(
tab_widget, kombinationen, crs, vg_status
)
if vg_status == LoadStatus.NONE or not alkis_vg_layer:
return None, None, None, LoadStatus.NONE
else:
# ------------------------------------------------------------------
# Verfahrensgebiet auswählen (bei mehreren Treffern) – kein eigener
# Fortschrittsdialog währenddessen, nur der Auswahldialog selbst.
# ------------------------------------------------------------------
alkis_vg_layer, vg_status, _ = _baue_layer_aus_kombinationen(
tab_widget, kombinationen, crs, vg_status
)
if vg_status == LoadStatus.NONE or not alkis_vg_layer:
return None, None, None, LoadStatus.NONE
# BROR-Layer umbenennen, um Namenskonflikt mit enriched VG-Layer zu vermeiden
alkis_vg_layer.setName(BROR_LAYER_NAME)
@@ -317,13 +386,29 @@ def verfahrensgebiet_alkis_komplett(
if vg_status in (LoadStatus.FIRST, LoadStatus.RELOAD):
project.addMapLayer(alkis_vg_layer)
bror_was_added_by_us = True
# Style direkt nach dem Laden anwenden – unabhängig vom weiteren
# Ablauf, damit auch "Nur Objekte laden" den Style erhält.
apply_style(alkis_vg_layer, BROR_STYLE_QML)
progress.set_value(1)
if nur_laden:
# Nutzer hat "Nur laden" angehakt – kein Dialog 2, kein weiterer
# Workflow (Gemarkung/Flurstücke/Vergleich).
abgeschlossen = True
return alkis_vg_layer, None, None, vg_status
# ------------------------------------------------------------------
# Schritt 2: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln
# Dialog 2: eigentlicher Fortschrittsdialog für die restlichen Schritte
# (startet erst jetzt, nach erfolgreicher Auswahl/Übernahme des VG)
# ------------------------------------------------------------------
progress.set_label("Schritt 2/6: Gemarkungsschlüssel ermitteln …")
progress = create_progress_dialog(
6, "Verfahrensgebiet laden", "Initialisierung …"
)
# ------------------------------------------------------------------
# Schritt 1: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln
# ------------------------------------------------------------------
progress.set_label("Schritt 1/6: Gemarkungen ermitteln und laden …")
progress.set_value(0)
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
@@ -344,12 +429,12 @@ def verfahrensgebiet_alkis_komplett(
duration=5,
)
progress.set_value(2)
progress.set_value(1)
# ------------------------------------------------------------------
# Schritt 3: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt)
# Schritt 2: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt)
# ------------------------------------------------------------------
progress.set_label("Schritt 3/6: Flurstücke aus ALKIS laden …")
progress.set_label("Schritt 2/6: Flurstücke aus ALKIS laden …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
@@ -363,12 +448,12 @@ def verfahrensgebiet_alkis_komplett(
if flst_status == LoadStatus.NONE or not flst_layer:
return None, None, None, LoadStatus.NONE
progress.set_value(3)
progress.set_value(2)
# ------------------------------------------------------------------
# Schritt 4: Räumliche Selektion
# Schritt 3: Räumliche Selektion
# ------------------------------------------------------------------
progress.set_label("Schritt 4/6: Flurstücke räumlich selektieren …")
progress.set_label("Schritt 3/6: Flurstücke räumlich selektieren …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
@@ -383,12 +468,12 @@ def verfahrensgebiet_alkis_komplett(
)
return None, flst_layer, None, LoadStatus.NONE
progress.set_value(4)
progress.set_value(3)
# ------------------------------------------------------------------
# Schritt 5: Enriched Memory-Layer aus Auswahl erzeugen
# Schritt 4: Enriched Memory-Layer aus Auswahl erzeugen
# ------------------------------------------------------------------
progress.set_label("Schritt 5/6: Verfahrensgebiet-Layer erzeugen …")
progress.set_label("Schritt 4/6: Verfahrensgebiet-Layer erzeugen …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
@@ -407,12 +492,12 @@ def verfahrensgebiet_alkis_komplett(
vkz = get_variable("verfahrensnummer") or ""
_fuege_vkz_feld_hinzu(enriched_layer, vkz)
progress.set_value(5)
progress.set_value(4)
# ------------------------------------------------------------------
# Schritt 6: Flächenvergleich als Gate
# Schritt 5: Flächenvergleich als Gate
# ------------------------------------------------------------------
progress.set_label("Schritt 6/6: Flächenvergleich …")
progress.set_label("Schritt 5/6: Flächenvergleich …")
if progress.is_canceled():
return None, None, None, LoadStatus.NONE
@@ -421,11 +506,10 @@ def verfahrensgebiet_alkis_komplett(
alkis_vg_layer, enriched_layer, toleranz
)
progress.set_value(6)
progress.set_value(5)
if stimmt_ueberein:
progress.set_value(7)
progress.close()
progress.set_value(6)
show_info_dialog(
"Flächenvergleich",
"Verfahrensgebiet mit erwarteter Geometrie erzeugt",
@@ -437,14 +521,13 @@ def verfahrensgebiet_alkis_komplett(
# ------------------------------------------------------------------
# Flächenabweichung → 3-Optionen-Dialog
# ------------------------------------------------------------------
progress.close()
diff = abs(bror_flaeche - vg_flaeche)
entscheidung = ask_detailpruefung_oder_vg_laden(
tab_widget,
"Abweichung erkannt",
f"Abweichung zwischen BauRaumOderBodenordnungsrecht-Objekt und abgeleitetem "
f"Verfahrensgebiet erkannt.\n\n"
f"BauRaumOderBodenordnungsrecht: {bror_flaeche:.2f} m²\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?",
@@ -563,6 +646,9 @@ def verfahrensgebiet_alkis_komplett(
return enriched_layer, flst_layer, alkis_vg_layer, vg_status
finally:
# Dialog 2 (falls erstellt) bei jedem Ausgang (Erfolg, Abbruch, Fehler) schließen
if progress is not None:
progress.close()
# WFS-Temp-Layer stets aus dem Projekt entfernen
_entferne_hilfs_layer(project, "TempVerfahrensgebiet")
# BROR-Layer bei Fehler/Abbruch entfernen (nur wenn in diesem Lauf hinzugefügt)
+299
View File
@@ -0,0 +1,299 @@
"""sn_verfahrensgebiet/functions/verfahrensgebiet_dialoge.py – Dialoge zum
Laden eines Verfahrensgebiets aus ALKIS (Qt5/6/Mock-kompatibel).
"""
from typing import Any, Optional
from sn_basis.functions.qt_wrapper import (
QT_VERSION, exec_dialog, QInputDialog, QLineEdit,
QDialog, QDialogButtonBox, QListWidget, QListWidgetItem, QAbstractItemView,
QTableWidget, QTableWidgetItem, QTableWidgetSelectionRange,
QIcon, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox,
DialogAccepted, ExtendedSelection, SelectRows, NoEditTriggers, AscendingOrder,
)
from sn_basis.functions.landkreise import ALLE_LANDKREISE
import os
# .../python/plugins (drei Ebenen über dieser Datei)
_PLUGINS_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_WAPPEN_ICON = os.path.join(_PLUGINS_DIR, "sn_basis", "favicon.ico")
def ask_bror_kombinationen(
parent: Any,
kombinationen: list[tuple[str, str]],
) -> list[tuple[str, str]]:
"""Zeigt Dialog zur Auswahl einer oder mehrerer BROR-Kombinationen
(adv_name + bezeichnung).
Wird aufgerufen, wenn der WFS mehrere verschiedene Kombinationen aus
``adv_name`` und ``bezeichnung`` zurückliefert. Es können ein oder
mehrere Objekte gemeinsam ausgewählt und geladen (verschmolzen) werden.
Parameters
----------
parent :
Eltern-Widget oder None.
kombinationen :
Sortierte Liste von ``(adv_name, bezeichnung)``-Tupeln.
Returns
-------
list[tuple[str, str]]
Die gewählten ``(adv_name, bezeichnung)``-Tupel. Leere Liste bei
Abbruch oder wenn nichts ausgewählt wurde.
"""
if not kombinationen:
return []
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_bror_kombinationen → {kombinationen[:1]}")
return kombinationen[:1]
dialog = QDialog(parent)
dialog.setWindowTitle("Verfahrensgebiet aus ALKIS laden")
if os.path.exists(_WAPPEN_ICON):
dialog.setWindowIcon(QIcon(_WAPPEN_ICON))
layout = QVBoxLayout(dialog)
hinweis = QLabel(
f"Es wurden {len(kombinationen)} Objekte gefunden.\n\n"
"Bitte wählen Sie eines oder mehrere Objekte aus:"
)
hinweis.setWordWrap(True)
layout.addWidget(hinweis)
list_widget = QListWidget()
list_widget.setSelectionMode(ExtendedSelection)
for adv, bez in kombinationen:
item = QListWidgetItem(f"{adv} | {bez}")
list_widget.addItem(item)
list_widget.item(0).setSelected(True)
layout.addWidget(list_widget, 1) # Stretch-Faktor: wächst mit Fenstergröße
mehrfachauswahl_hinweis = QLabel("<small>Mehrfachauswahl mit Strg + Umschalt</small>")
layout.addWidget(mehrfachauswahl_hinweis)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
dialog.adjustSize()
breite = dialog.sizeHint().width()
# Liste auf max. 5 sichtbare Zeilen begrenzen, Rest per Scrollbar
sichtbare_zeilen = min(len(kombinationen), 5)
zeilenhoehe = list_widget.sizeHintForRow(0) if list_widget.count() else 20
# + Puffer für eine ggf. eingeblendete horizontale Scrollbar (bei langen
# Einträgen), damit diese nicht die letzte Zeile abschneidet.
scrollbar_puffer = list_widget.horizontalScrollBar().sizeHint().height()
list_hoehe = zeilenhoehe * sichtbare_zeilen + 2 * list_widget.frameWidth() + 4 + scrollbar_puffer
list_widget.setMinimumHeight(list_hoehe)
dialog.resize(breite, dialog.sizeHint().height())
if exec_dialog(dialog) != DialogAccepted:
return []
ausgewaehlte_indizes = sorted(list_widget.row(item) for item in list_widget.selectedItems())
return [kombinationen[i] for i in ausgewaehlte_indizes]
def ask_landkreis_objekte(
parent: Any,
kombinationen: list[tuple[str, str]],
) -> tuple[list[tuple[str, str]], bool]:
"""Zeigt Dialog zur Auswahl eines, mehrerer oder aller BROR-Objekte eines
Landkreises (Fallback, wenn keine Verfahrensnummer gesetzt ist).
Wie :func:`ask_bror_kombinationen`, zusätzlich mit einer "Alle
auswählen"-Checkbox (da hier potenziell deutlich mehr Treffer als bei der
VKZ-Suche auftreten) und einer "Nur laden"-Checkbox, mit der gesteuert
wird, ob nach dem Laden der weitere Workflow (Gemarkung/Flurstücke/
Flächenvergleich) fortgesetzt werden soll.
Parameters
----------
parent :
Eltern-Widget oder None.
kombinationen :
Sortierte Liste von ``(adv_name, bezeichnung)``-Tupeln.
Returns
-------
tuple[list[tuple[str, str]], bool]
``(ausgewaehlte_kombinationen, nur_laden)``. ``([], False)`` bei
Abbruch oder wenn nichts ausgewählt wurde.
"""
if not kombinationen:
return [], False
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_landkreis_objekte → {kombinationen[:1]}")
return kombinationen[:1], False
dialog = QDialog(parent)
dialog.setWindowTitle("Verfahrensgebiete aus Landkreis laden")
if os.path.exists(_WAPPEN_ICON):
dialog.setWindowIcon(QIcon(_WAPPEN_ICON))
layout = QVBoxLayout(dialog)
hinweis = QLabel(
f"Es wurden {len(kombinationen)} Objekte gefunden.\n\n"
"Bitte wählen Sie eines oder mehrere Objekte aus:"
)
hinweis.setWordWrap(True)
layout.addWidget(hinweis)
suche_zeile = QHBoxLayout()
suche_zeile.addWidget(QLabel("Suche:"))
suche_feld = QLineEdit()
suche_feld.setPlaceholderText("Nummer oder Name …")
suche_zeile.addWidget(suche_feld)
layout.addLayout(suche_zeile)
alle_auswaehlen = QCheckBox("Alle auswählen")
layout.addWidget(alle_auswaehlen)
# Standardsortierung nach Bezeichnung; per Klick auf Spaltenkopf änderbar
# (setSortingEnabled aktiviert die eingebaute Tabellensortierung).
sortierte_kombinationen = sorted(kombinationen, key=lambda kb: kb[1])
table = QTableWidget(len(sortierte_kombinationen), 2)
table.setHorizontalHeaderLabels(["Nummer", "Name"])
table.setSelectionMode(ExtendedSelection)
table.setSelectionBehavior(SelectRows)
table.setEditTriggers(NoEditTriggers)
table.verticalHeader().setVisible(False)
table.verticalHeader().setDefaultSectionSize(22) # flachere Zeilen
table.horizontalHeader().setStretchLastSection(True)
table.setSortingEnabled(False)
for row, (adv, bez) in enumerate(sortierte_kombinationen):
table.setItem(row, 0, QTableWidgetItem(bez))
table.setItem(row, 1, QTableWidgetItem(adv))
table.setSortingEnabled(True)
table.sortByColumn(0, AscendingOrder) # Standard: aufsteigend nach Nummer
table.resizeColumnToContents(0)
table.setColumnWidth(0, max(110, table.columnWidth(0)))
table.selectRow(0)
layout.addWidget(table, 1) # Stretch-Faktor: wächst mit Fenstergröße
def _suche_geaendert(text):
text_lower = text.strip().lower()
for row in range(table.rowCount()):
bez_text = table.item(row, 0).text().lower()
adv_text = table.item(row, 1).text().lower()
treffer = not text_lower or text_lower in bez_text or text_lower in adv_text
table.setRowHidden(row, not treffer)
suche_feld.textChanged.connect(_suche_geaendert)
def _alle_auswaehlen_umschalten(checked):
table.clearSelection()
if checked:
letzte_spalte = table.columnCount() - 1
for row in range(table.rowCount()):
if not table.isRowHidden(row):
# setRangeSelected (statt selectRow) fügt der Selektion
# hinzu, ohne vorherige Zeilen wieder abzuwählen.
table.setRangeSelected(
QTableWidgetSelectionRange(row, 0, row, letzte_spalte), True
)
alle_auswaehlen.stateChanged.connect(_alle_auswaehlen_umschalten)
mehrfachauswahl_hinweis = QLabel("<small>Mehrfachauswahl mit Strg + Umschalt</small>")
layout.addWidget(mehrfachauswahl_hinweis)
nur_laden_checkbox = QCheckBox("Nur Objekte laden (ohne weitere Prüfungen)")
layout.addWidget(nur_laden_checkbox)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(dialog.accept)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
dialog.adjustSize()
# Bezeichnungs-Spalte + großzügige Breite für den ADV-Namen (lange Namen
# sollen möglichst ohne Abschneiden sichtbar sein) + Rahmen/Scrollbar-Puffer.
breite = max(dialog.sizeHint().width(), table.columnWidth(0) + 480)
# Tabelle auf max. 15 sichtbare Zeilen begrenzen, Rest per Scrollbar – hier
# potenziell deutlich mehr Treffer als bei der VKZ-Suche, daher großzügiger
# bemessen als bei ask_bror_kombinationen (dort 5).
sichtbare_zeilen = min(len(sortierte_kombinationen), 15)
zeilenhoehe = table.verticalHeader().defaultSectionSize() if table.rowCount() else 20
kopfzeile_hoehe = table.horizontalHeader().height()
# + Puffer für eine ggf. eingeblendete horizontale Scrollbar (bei langen
# Einträgen), damit diese nicht die letzte Zeile abschneidet.
scrollbar_puffer = table.horizontalScrollBar().sizeHint().height()
table_hoehe = (
zeilenhoehe * sichtbare_zeilen + kopfzeile_hoehe
+ 2 * table.frameWidth() + 4 + scrollbar_puffer
)
table.setMinimumHeight(table_hoehe)
dialog.resize(breite, dialog.sizeHint().height())
if exec_dialog(dialog) != DialogAccepted:
return [], False
ausgewaehlte_zeilen = sorted(set(item.row() for item in table.selectedItems()))
ausgewaehlt = [(table.item(row, 1).text(), table.item(row, 0).text()) for row in ausgewaehlte_zeilen]
return ausgewaehlt, nur_laden_checkbox.isChecked()
def ask_landkreis_auswahl(
parent: Any,
landkreise: list[tuple[str, str]],
) -> Optional[str]:
"""Fragt per Dropdown-Dialog einen Landkreis ab (Fallback, wenn weder
Verfahrensnummer noch Landkreis in den Projekteigenschaften gesetzt ist).
Parameters
----------
parent :
Eltern-Widget oder None.
landkreise :
Liste von ``(kennzahl, name)``-Tupeln, z.B. ``LANDKREISE`` aus
:mod:`sn_basis.functions.landkreise`.
Die Auswahlliste beginnt mit einem leeren Platzhalter (der Nutzer muss
aktiv einen Eintrag wählen) und enthält zusätzlich "Alle Landkreise"
(Sentinel :data:`~sn_basis.functions.landkreise.ALLE_LANDKREISE`).
Returns
-------
Optional[str]
Gewählte Landkreiskennzahl, :data:`ALLE_LANDKREISE` bei Auswahl aller
Landkreise, oder ``None`` bei Abbruch, leerem Platzhalter oder leerer
Landkreisliste.
"""
if not landkreise:
return None
if QT_VERSION == 0: # Mock-Modus
print("Mock-Modus: ask_landkreis_auswahl → None (keine aktive Auswahl)")
return None
ALLE_LABEL = "Alle Landkreise"
namen = [""] + [ALLE_LABEL] + [name for _, name in landkreise]
dialog = QInputDialog(parent)
dialog.setWindowTitle("Landkreis auswählen")
if os.path.exists(_WAPPEN_ICON):
dialog.setWindowIcon(QIcon(_WAPPEN_ICON))
dialog.setLabelText("Landkreis:")
dialog.setComboBoxItems(namen)
dialog.setComboBoxEditable(False)
if exec_dialog(dialog) != DialogAccepted:
return None
name = dialog.textValue()
if not name:
return None
if name == ALLE_LABEL:
return ALLE_LANDKREISE
name_zu_kennzahl = {n: k for k, n in landkreise}
return name_zu_kennzahl.get(str(name))
@@ -0,0 +1,977 @@
"""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/.shp-Filter, Mehrfachauswahl); Nutzerabbruch
beendet den Vorgang.
2. ZIP in ein temporäres Verzeichnis entpacken (danach wieder gelöscht) 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 shutil
import tempfile
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,
ask_yes_no,
)
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,
)
#: Feldname im Shapefile, über den der Gebietstyp bestimmt wird.
FBSCHL_FELD = "FBSchl"
#: FBSchl-Wert für ein Neuordnungsgebiet.
FBSCHL_NEUORDNUNGSGEBIET = "901/10110F"
#: FBSchl-Wert für ein Neuvermessungsgebiet.
FBSCHL_NEUVERMESSUNGSGEBIET = "901/10120F"
#: Anzeigename des Layers für ein Neuordnungsgebiet (NoBIS).
NEUORDNUNGSGEBIET_LAYER_NAME = "Neuordnungsgebiet (NoBIS)"
#: Anzeigename des Layers für ein Neuvermessungsgebiet (NoBIS).
NEUVERMESSUNGSGEBIET_LAYER_NAME = "Neuvermessungsgebiet (NoBIS)"
#: Zuordnung Gebietstyp → Layername.
_GEBIETSTYP_LAYER_NAMEN = {
"Neuordnungsgebiet": NEUORDNUNGSGEBIET_LAYER_NAME,
"Neuvermessungsgebiet": NEUVERMESSUNGSGEBIET_LAYER_NAME,
}
# ---------------------------------------------------------------------------
# Interne Hilfsfunktionen
# ---------------------------------------------------------------------------
def _ermittle_gebietstyp_aus_fbschl(raw_layer) -> Tuple[Optional[str], Optional[str]]:
"""Prüft das Feld ``FBSchl`` und ermittelt den Gebietstyp.
Zeigt selbst keine Fehlermeldung an, sondern gibt einen Fehlertext
zurück, damit der Aufrufer Fehler mehrerer Dateien sammeln und am Ende
gemeinsam anzeigen kann.
Parameters
----------
raw_layer :
Dateibasierter Shapefile-Layer (vor dem Verschmelzen zum Memory-Layer).
Returns
-------
Tuple[Optional[str], Optional[str]]
``(gebietstyp, fehlertext)``. Bei Erfolg ist ``gebietstyp``
``"Neuordnungsgebiet"`` oder ``"Neuvermessungsgebiet"`` und
``fehlertext`` ``None``. Bei Fehler ist ``gebietstyp`` ``None`` und
``fehlertext`` beschreibt die Ursache (fehlendes Feld oder
unbekannter Wert).
"""
idx = raw_layer.fields().indexFromName(FBSCHL_FELD)
if idx < 0:
return None, f"Feld '{FBSCHL_FELD}' fehlt (Shape entspricht nicht dem erwarteten Inhalt)."
wert = None
for feat in raw_layer.getFeatures():
try:
roh = feat.attribute(idx)
except Exception:
roh = None
if roh not in (None, ""):
wert = str(roh).strip()
break
if wert == FBSCHL_NEUORDNUNGSGEBIET:
return "Neuordnungsgebiet", None
if wert == FBSCHL_NEUVERMESSUNGSGEBIET:
return "Neuvermessungsgebiet", None
return None, (
f"Unbekannter Wert im Feld '{FBSCHL_FELD}': {wert!r} "
f"(erwartet: '{FBSCHL_NEUORDNUNGSGEBIET}' oder "
f"'{FBSCHL_NEUVERMESSUNGSGEBIET}')."
)
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 _finde_alle_shp(verzeichnis: str) -> list:
"""Sucht rekursiv nach allen ``.shp``-Dateien im Verzeichnis.
Ein ZIP-Archiv kann mehrere Shapefile-Sets enthalten (z.B. je eines für
Neuordnungs- und Neuvermessungsgebiet) – im Unterschied zu
:func:`_finde_shp` werden hier alle gefunden, nicht nur die erste.
Parameters
----------
verzeichnis :
Startverzeichnis für die Suche.
Returns
-------
list
Sortierte Liste der absoluten Pfade aller gefundenen ``.shp``-Dateien.
"""
treffer = []
for root, _dirs, files in os.walk(verzeichnis):
for fname in files:
if fname.lower().endswith(".shp"):
treffer.append(os.path.join(root, fname))
return sorted(treffer)
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[list]:
"""Öffnet Dateidialog (Mehrfachauswahl), entpackt ZIPs (bzw. nutzt direkt
gewählte ``.shp``-Dateien) und liefert je Gebietstyp einen Union-Memory-Layer.
Schrittfolge:
1. ``QFileDialog.getOpenFileNames`` mit Filter für ``.zip`` und ``.shp``
(Mehrfachauswahl möglich).
2. Für jede ausgewählte Datei: bei ``.zip`` in ein temporäres Verzeichnis
entpacken (danach wieder gelöscht) und darin **alle** enthaltenen
``.shp``-Dateien finden (ein ZIP kann mehrere Shapefile-Sets
bündeln, z.B. je eines für Neuordnungs- und Neuvermessungsgebiet);
bei ``.shp`` die Datei direkt verwenden.
3. Jede gefundene Shapefile-Datei einzeln als ``QgsVectorLayer`` laden
und ``isValid()`` prüfen.
4. Je Shapefile-Datei das Feld ``FBSchl`` prüfen und den Gebietstyp
ermitteln (Neuordnungs- bzw. Neuvermessungsgebiet).
5. Die Features aller Dateien **desselben Gebietstyps** werden gemeinsam
per ``QgsGeometry.unaryUnion`` zu einem Single-Feature-Memory-Layer
verschmolzen – d.h. mehrere Neuordnungsgebiet-Dateien ergeben einen
Layer, mehrere Neuvermessungsgebiet-Dateien einen zweiten.
Fehlerverhalten: Schlägt die Prüfung eines einzelnen Shapefiles fehl
(ZIP nicht entpackbar, kein Shapefile enthalten, ungültiger Layer, Feld
``FBSchl`` fehlt oder hat einen unbekannten Wert), wird **nur dieses
Shapefile** übersprungen und sein Fehler gesammelt – das gilt auch,
wenn mehrere Shapefiles in derselben ZIP-Datei liegen: eine fehlerhafte
Datei darin bricht nicht die Verarbeitung der übrigen ab. Alle
verwertbaren Dateien werden gemäß Vorgabe eingelesen. Am Ende erscheint
eine gesammelte Fehlermeldung mit allen fehlerhaften Dateien. Erst wenn
**keine** Datei verwertbare Geometrien liefert, wird ``None``
zurückgegeben.
Parameters
----------
parent_widget :
Eltern-Widget für Dateidialog und Fehlermeldungen.
Returns
-------
Optional[list]
Liste der erzeugten Memory-Layer (ein Layer je in der Auswahl
vorkommendem, erfolgreich geprüftem Gebietstyp, max. 2), oder
``None`` wenn keine Datei verwertet werden konnte oder der Dialog
abgebrochen wurde.
"""
pfade, _ = QFileDialog.getOpenFileNames(
parent_widget,
"Shapefile-Set(s) (ZIP oder .shp) auswählen",
"",
"Shapefile-Set (*.zip *.shp);;ZIP-Dateien (*.zip);;Shapefile (*.shp)",
)
if not pfade:
return None # Nutzerabbruch
# Je Gebietstyp gesammelte Geometrien/Felder/Attribute über alle
# ausgewählten Dateien hinweg (Reihenfolge der ersten Sichtung bleibt
# über dict-Einfügereihenfolge erhalten).
geometrien_je_typ: dict = {}
felder_je_typ: dict = {}
attribute_je_typ: dict = {}
crs_je_typ: dict = {}
temp_dirs: list = []
fehler_liste: list = []
try:
# (shp_path, anzeige_name) für jede tatsächlich zu prüfende
# Shapefile-Datei sammeln – ein ZIP kann mehrere Shapefile-Sets
# enthalten, jedes wird einzeln behandelt.
shp_eintraege: list = []
for pfad in pfade:
dateiname = os.path.basename(pfad)
if pfad.lower().endswith(".shp"):
shp_eintraege.append((pfad, dateiname))
continue
extract_dir = tempfile.mkdtemp(prefix="sn_vg_shape_")
temp_dirs.append(extract_dir)
try:
with zipfile.ZipFile(pfad, "r") as zf:
zf.extractall(extract_dir)
except Exception:
fehler_liste.append(
f"{dateiname}: ZIP-Datei konnte nicht entpackt werden."
)
continue
shp_pfade_im_zip = _finde_alle_shp(extract_dir)
if not shp_pfade_im_zip:
fehler_liste.append(
f"{dateiname}: kein gültiges Shape-Dateiset enthalten."
)
continue
for shp_path in shp_pfade_im_zip:
anzeige_name = (
f"{dateiname} ({os.path.basename(shp_path)})"
if len(shp_pfade_im_zip) > 1
else dateiname
)
shp_eintraege.append((shp_path, anzeige_name))
# ------------------------------------------------------------------
# Jede gefundene Shapefile-Datei einzeln prüfen und einlesen.
# ------------------------------------------------------------------
for shp_path, anzeige_name in shp_eintraege:
raw_layer = QgsVectorLayer(shp_path, "tmp_shape_vg", "ogr")
if not raw_layer or not raw_layer.isValid():
fehler_liste.append(
f"{anzeige_name}: kein gültiges Shape-Dateiset."
)
continue
gebietstyp, fehlertext = _ermittle_gebietstyp_aus_fbschl(raw_layer)
if gebietstyp is None:
fehler_liste.append(f"{anzeige_name}: {fehlertext}")
del raw_layer
continue
geom_list = []
first_feat = None
for feat in raw_layer.getFeatures():
try:
geom = feat.geometry()
except Exception:
geom = None
if geom and not geom.isEmpty():
geom_list.append(geom)
if first_feat is None:
first_feat = feat
if geom_list:
geometrien_je_typ.setdefault(gebietstyp, []).extend(geom_list)
if gebietstyp not in felder_je_typ:
felder_je_typ[gebietstyp] = list(raw_layer.fields())
attribute_je_typ[gebietstyp] = (
first_feat.attributes() if first_feat is not None else []
)
crs_je_typ[gebietstyp] = raw_layer.crs().authid()
else:
fehler_liste.append(f"{anzeige_name}: keine gültigen Geometrien enthalten.")
# Referenz auf den dateibasierten Layer freigeben, bevor das
# temporäre Verzeichnis gelöscht wird (Windows hält sonst ggf.
# noch einen Dateihandle auf das Shapefile).
del raw_layer
if fehler_liste:
meldung = (
"Folgende Dateien konnten nicht eingelesen werden:\n\n"
+ "\n".join(fehler_liste)
)
if geometrien_je_typ:
meldung += "\n\nDie übrigen Dateien wurden gemäß Vorgabe eingelesen."
QMessageBox.warning(parent_widget, "Fehler in ausgewählten Dateien", meldung)
if not geometrien_je_typ:
return None
ergebnis_layer = []
for typ, geometrien in geometrien_je_typ.items():
union_geom = QgsGeometry.unaryUnion(geometrien)
if not union_geom or union_geom.isEmpty():
continue
mem_layer = QgsVectorLayer(
f"MultiPolygon?crs={crs_je_typ[typ]}",
_GEBIETSTYP_LAYER_NAMEN[typ],
"memory",
)
pr = mem_layer.dataProvider()
try:
pr.addAttributes(felder_je_typ[typ])
mem_layer.updateFields()
except Exception:
pass
feat_out = QgsFeature(mem_layer.fields())
feat_out.setGeometry(union_geom)
try:
feat_out.setAttributes(attribute_je_typ[typ])
except Exception:
pass
pr.addFeatures([feat_out])
mem_layer.updateExtents()
if mem_layer.isValid() and mem_layer.featureCount() > 0:
mem_layer.setCustomProperty("fb_gebietstyp", typ)
ergebnis_layer.append(mem_layer)
if not ergebnis_layer:
QMessageBox.warning(
parent_widget,
"Fehler",
"gültiges Shape-Dateiset angeben",
)
return None
return ergebnis_layer
finally:
for d in temp_dirs:
shutil.rmtree(d, ignore_errors=True)
# ---------------------------------------------------------------------------
# Ö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
zusatz_shape_layer: list = []
bror_layer = None
shape_was_added_by_us = False
bror_was_added_by_us = False
abgeschlossen = False
progress = None
try:
# ------------------------------------------------------------------
# Schritt 1: Shape-VG aus ZIP laden (noch NICHT ins Projekt einfügen)
# Läuft VOR dem Fortschrittsdialog, damit dieser nicht schon während
# des Dateidialogs sichtbar ist.
# ------------------------------------------------------------------
shape_vg_layers = lade_vg_aus_zip(tab_widget)
if not shape_vg_layers:
return None, None, None, LoadStatus.NONE
# Je Gebietstyp ein Layer (max. 2). Der erste ist das für den
# weiteren Ablauf maßgebliche Shape-VG; etwaige weitere Layer
# (zweiter Gebietstyp) werden vorerst nur mit ins Projekt geladen –
# ihre Einbindung in den weiteren Workflow folgt in einem späteren
# Schritt.
shape_vg_layer = shape_vg_layers[0]
zusatz_shape_layer = shape_vg_layers[1:]
# TEMPORÄRER STOPP (wieder entfernen): Pause nach "Verfahrensgebiet
# aus ZIP laden" zur Zwischenkontrolle – Layer dafür schon jetzt
# sichtbar ins Projekt einfügen (normalerweise erst nach Schritt 2).
project.addMapLayer(shape_vg_layer)
shape_was_added_by_us = True
for lyr in zusatz_shape_layer:
project.addMapLayer(lyr)
geladene_namen = ", ".join(lyr.name() for lyr in shape_vg_layers)
if not ask_yes_no(
"Verfahrensgebiet aus ZIP laden",
f"Layer '{geladene_namen}' wurde(n) geladen. Weiter?",
default=True,
parent=tab_widget,
):
# abgeschlossen=True, damit der finally-Block den zur Kontrolle
# eingefügten Layer NICHT wieder entfernt (kein Fehlerfall).
abgeschlossen = True
return None, None, None, LoadStatus.NONE
vg_status = LoadStatus.FIRST
# Fortschrittsdialog erst jetzt erzeugen (nach dem Stop) – er darf
# nicht schon während Dateidialog/Stop-Abfrage sichtbar sein.
progress = create_progress_dialog(
7, "Verfahrensgebiet aus Shape laden", "Schritt 2/6: BROR aus ALKIS laden …"
)
progress.set_value(1)
# ------------------------------------------------------------------
# Schritt 2: BROR aus ALKIS laden (vor shape_vg, um Namenskonflikt
# in _check_existing_layer zu vermeiden)
# ------------------------------------------------------------------
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) – außer
# der TEMPORÄRE STOPP oben hat ihn schon eingefügt.
if not project.mapLayer(shape_vg_layer.id()):
project.addMapLayer(shape_vg_layer)
shape_was_added_by_us = True
# ------------------------------------------------------------------
# Zwischenabfrage: Nutzer kann vor dem Gemarkungsschlüssel-Schritt
# abbrechen (Shape-VG und ggf. BROR bleiben im Projekt erhalten).
# ------------------------------------------------------------------
progress.raise_to_front()
if not ask_yes_no(
"Verfahrensgebiet aus Shape laden",
"Weiter mit Gemarkungsschlüssel ermitteln?",
default=True,
parent=tab_widget,
):
abgeschlossen = True
return shape_vg_layer, None, bror_layer, vg_status
# ------------------------------------------------------------------
# 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:
for lyr in zusatz_shape_layer:
try:
project.removeMapLayer(lyr.id())
except Exception:
pass
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>
+304
View File
@@ -0,0 +1,304 @@
"""sn_verfahrensgebiet/tests/test_beteiligung_pruefung.py
Unit-Tests für :mod:`sn_verfahrensgebiet.functions.beteiligung_pruefung`.
Getestete Szenarien:
baue_flurstueck_csv:
- Normalfall mit führenden Nullen bei Zähler/Nenner
- Nenner = 0 → Platzhalter "____"
- Nenner leer → Platzhalter "____"
- Ungültiges Format (nicht 4 Teile) → None
- Leerer Wert → None
baue_flurstueck_csv_liste:
- Bildet Liste nur aus gültigen Zeilen, schreibt Flurstueck_csv in jede Zeile zurück
lade_beteiligung_csv:
- Dateidialog abgebrochen → None
- Spalte FLURSTUECK fehlt → BeteiligungPruefungFehler
- Leere Datei → BeteiligungPruefungFehler
- Erfolgsfall → (zeilen, spaltennamen)
try2/try3 (kein automatisches Laden mehr, siehe Modul-Docstring):
- Verfahrensgebiet-Layer ohne Kennzeichen vorhanden → Aufforderung statt Auto-Laden
- Kein Verfahrensgebiet-Layer vorhanden → Verfahrensnummer aus CSV wird gesetzt,
dann Aufforderung statt Auto-Laden
"""
import csv
import os
import sys
import tempfile
import types
import unittest
from unittest.mock import patch, MagicMock
# ---------------------------------------------------------------------------
# Minimales QGIS-Mock-Setup
# ---------------------------------------------------------------------------
if "qgis" not in sys.modules:
qgis_mod = types.ModuleType("qgis")
qgis_core = types.ModuleType("qgis.core")
qgis_utils = types.ModuleType("qgis.utils")
qgis_utils.iface = types.SimpleNamespace()
qgis_utils.plugins = {}
qgis_mod.utils = qgis_utils
qgis_mod.core = qgis_core
sys.modules["qgis"] = qgis_mod
sys.modules["qgis.core"] = qgis_core
sys.modules["qgis.utils"] = qgis_utils
from sn_verfahrensgebiet.functions.beteiligung_pruefung import (
baue_flurstueck_csv,
baue_flurstueck_csv_liste,
lade_beteiligung_csv,
beteiligung_pruefen,
BeteiligungPruefungFehler,
FELD_FLURSTUECK_CSV,
CSV_SPALTE_FLURSTUECK,
CSV_SPALTE_VEF_VKZF,
_fordere_manuelles_laden_an,
_try3_bootstrap,
_hole_verfahrensgebiet_layer,
)
_MOD = "sn_verfahrensgebiet.functions.beteiligung_pruefung"
class TestBaueFlurstueckCsv(unittest.TestCase):
def test_normalfall_mit_fuehrenden_nullen(self):
ergebnis = baue_flurstueck_csv("1234/123/45/00", "14")
self.assertEqual(ergebnis, "141234___00123004500")
def test_nenner_null_wird_zu_unterstrichen(self):
ergebnis = baue_flurstueck_csv("1234/123/0/00", "14")
self.assertEqual(ergebnis, "141234___00123____00")
def test_nenner_leer_wird_zu_unterstrichen(self):
ergebnis = baue_flurstueck_csv("1234//45/00", "14")
# Gemarkung 1234, Zähler leer -> "00000"? -> zfill auf leeren String ergibt "00000"
self.assertTrue(ergebnis.startswith("141234___00000"))
def test_gemarkung_ohne_fuehrende_nullen_wird_aufgefuellt(self):
ergebnis = baue_flurstueck_csv("12/1/1/00", "14")
self.assertTrue(ergebnis.startswith("140012___"))
def test_ungueltiges_format_gibt_none(self):
self.assertIsNone(baue_flurstueck_csv("1234/123/45", "14"))
self.assertIsNone(baue_flurstueck_csv("1234/123/45/00/99", "14"))
def test_leerer_wert_gibt_none(self):
self.assertIsNone(baue_flurstueck_csv("", "14"))
self.assertIsNone(baue_flurstueck_csv(None, "14"))
def test_nenner_9001_wird_zu_buchstabe_a(self):
ergebnis = baue_flurstueck_csv("1234/123/9001/00", "14")
self.assertEqual(ergebnis, "141234___00123000a00")
def test_nenner_9002_wird_zu_buchstabe_b(self):
ergebnis = baue_flurstueck_csv("1234/123/9002/00", "14")
self.assertEqual(ergebnis, "141234___00123000b00")
def test_nenner_9026_wird_zu_buchstabe_z(self):
ergebnis = baue_flurstueck_csv("1234/123/9026/00", "14")
self.assertEqual(ergebnis, "141234___00123000z00")
def test_nenner_ausserhalb_buchstaben_bereich_bleibt_numerisch(self):
ergebnis = baue_flurstueck_csv("1234/123/9027/00", "14")
self.assertEqual(ergebnis, "141234___00123902700")
def test_nenner_9001_mit_fuehrenden_nullen_wird_erkannt(self):
ergebnis = baue_flurstueck_csv("1234/123/09001/00", "14")
self.assertEqual(ergebnis, "141234___00123000a00")
def test_einstellige_folge_wird_auf_2_stellen_aufgefuellt(self):
ergebnis = baue_flurstueck_csv("1234/123/45/5", "14")
self.assertEqual(ergebnis, "141234___00123004505")
class TestBaueFlurstueckCsvListe(unittest.TestCase):
def test_nur_gueltige_zeilen_in_liste(self):
zeilen = [
{CSV_SPALTE_FLURSTUECK: "1234/123/45/00"},
{CSV_SPALTE_FLURSTUECK: "ungueltig"},
{CSV_SPALTE_FLURSTUECK: "5678/1/0/01"},
]
ergebnis = baue_flurstueck_csv_liste(zeilen, "14")
self.assertEqual(len(ergebnis), 2)
self.assertEqual(zeilen[0][FELD_FLURSTUECK_CSV], "141234___00123004500")
self.assertEqual(zeilen[1][FELD_FLURSTUECK_CSV], "")
self.assertEqual(zeilen[2][FELD_FLURSTUECK_CSV], "145678___00001____01")
class TestHoleVerfahrensgebietLayer(unittest.TestCase):
@patch(f"{_MOD}.QgsProject")
def test_layer_per_name_hat_vorrang_vor_combo_auswahl(self, mock_project_cls):
namens_layer = MagicMock(name="verfahrensgebiet_layer")
mock_project = MagicMock()
mock_project.mapLayersByName.return_value = [namens_layer]
mock_project_cls.instance.return_value = mock_project
andere_auswahl = MagicMock(name="andere_layer_in_combo")
andere_auswahl.isValid.return_value = True
tab_widget = MagicMock()
tab_widget.verf_layer_combo.currentLayer.return_value = andere_auswahl
ergebnis = _hole_verfahrensgebiet_layer(tab_widget)
self.assertIs(ergebnis, namens_layer)
@patch(f"{_MOD}.QgsProject")
def test_combo_auswahl_als_fallback_ohne_namens_layer(self, mock_project_cls):
mock_project = MagicMock()
mock_project.mapLayersByName.return_value = []
mock_project_cls.instance.return_value = mock_project
combo_layer = MagicMock(name="combo_layer")
combo_layer.isValid.return_value = True
tab_widget = MagicMock()
tab_widget.verf_layer_combo.currentLayer.return_value = combo_layer
ergebnis = _hole_verfahrensgebiet_layer(tab_widget)
self.assertIs(ergebnis, combo_layer)
@patch(f"{_MOD}.QgsProject")
def test_kein_layer_gibt_none(self, mock_project_cls):
mock_project = MagicMock()
mock_project.mapLayersByName.return_value = []
mock_project_cls.instance.return_value = mock_project
tab_widget = MagicMock()
tab_widget.verf_layer_combo.currentLayer.return_value = None
self.assertIsNone(_hole_verfahrensgebiet_layer(tab_widget))
class TestFordereManuellesLadenAn(unittest.TestCase):
"""try2/try3 laden nicht mehr automatisch, sondern fordern den Nutzer auf,
den regulären interaktiven Ablauf ('Aus ALKIS laden') selbst zu starten."""
def test_ohne_vef_vkzf_nennt_fehlende_kennzeichen(self):
with self.assertRaises(BeteiligungPruefungFehler) as ctx:
_fordere_manuelles_laden_an()
self.assertIn("keine verwertbaren", str(ctx.exception))
self.assertIn("Aus ALKIS laden", str(ctx.exception))
def test_mit_vef_vkzf_nennt_verfahrensnummer(self):
with self.assertRaises(BeteiligungPruefungFehler) as ctx:
_fordere_manuelles_laden_an("12345")
self.assertIn("12345", str(ctx.exception))
self.assertIn("Aus ALKIS laden", str(ctx.exception))
class TestTry3Bootstrap(unittest.TestCase):
@patch(f"{_MOD}.set_variable")
def test_setzt_verfahrensnummer_und_fordert_manuelles_laden_an(self, mock_set_variable):
zeilen = [{CSV_SPALTE_VEF_VKZF: "12345", CSV_SPALTE_FLURSTUECK: "1234/1/1/00"}]
spaltennamen = [CSV_SPALTE_VEF_VKZF, CSV_SPALTE_FLURSTUECK]
with self.assertRaises(BeteiligungPruefungFehler) as ctx:
_try3_bootstrap(object(), zeilen, spaltennamen)
mock_set_variable.assert_called_once_with("verfahrensnummer", "12345", scope="project")
self.assertIn("12345", str(ctx.exception))
class TestBeteiligungPruefenDispatch(unittest.TestCase):
"""Dispatch-Logik in beteiligung_pruefen(): try1 unverändert, try2/try3
fordern jetzt auf statt automatisch zu laden."""
@patch(f"{_MOD}._hat_verwendbare_kennzeichen", return_value=False)
@patch(f"{_MOD}._hole_verfahrensgebiet_layer")
@patch(f"{_MOD}.baue_flurstueck_csv_liste")
@patch(f"{_MOD}.get_laenderkennung", return_value="14")
@patch(f"{_MOD}.lade_beteiligung_csv")
def test_layer_ohne_kennzeichen_fordert_manuelles_laden_an(
self, mock_lade_csv, mock_laenderkennung, mock_liste, mock_hole_layer, mock_hat_kz
):
mock_lade_csv.return_value = ([{}], ["FLURSTUECK"])
mock_liste.return_value = ["145678___00001____01"]
mock_hole_layer.return_value = MagicMock(name="verf_layer_ohne_kz")
with self.assertRaises(BeteiligungPruefungFehler) as ctx:
beteiligung_pruefen(object())
self.assertIn("keine verwertbaren", str(ctx.exception))
@patch(f"{_MOD}.set_variable")
@patch(f"{_MOD}._hole_verfahrensgebiet_layer", return_value=None)
@patch(f"{_MOD}.baue_flurstueck_csv_liste")
@patch(f"{_MOD}.get_laenderkennung", return_value="14")
@patch(f"{_MOD}.lade_beteiligung_csv")
def test_kein_layer_setzt_verfahrensnummer_und_fordert_laden_an(
self, mock_lade_csv, mock_laenderkennung, mock_liste, mock_hole_layer, mock_set_variable
):
zeilen = [{CSV_SPALTE_VEF_VKZF: "12345", CSV_SPALTE_FLURSTUECK: "1234/1/1/00"}]
mock_lade_csv.return_value = (zeilen, [CSV_SPALTE_VEF_VKZF, CSV_SPALTE_FLURSTUECK])
mock_liste.return_value = ["145678___00001____01"]
with self.assertRaises(BeteiligungPruefungFehler) as ctx:
beteiligung_pruefen(object())
mock_set_variable.assert_called_once_with("verfahrensnummer", "12345", scope="project")
self.assertIn("12345", str(ctx.exception))
class TestLadeBeteiligungCsv(unittest.TestCase):
def setUp(self):
self._tmp_files = []
def tearDown(self):
for pfad in self._tmp_files:
try:
os.remove(pfad)
except OSError:
pass
def _schreibe_csv(self, header, zeilen):
fd, pfad = tempfile.mkstemp(suffix=".csv")
self._tmp_files.append(pfad)
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
writer = csv.writer(f)
writer.writerow(header)
for zeile in zeilen:
writer.writerow(zeile)
return pfad
@patch(f"{_MOD}.QFileDialog")
def test_dialog_abgebrochen_gibt_none(self, mock_fd):
mock_fd.getOpenFileName.return_value = ("", "")
self.assertIsNone(lade_beteiligung_csv(None))
@patch(f"{_MOD}.QFileDialog")
def test_fehlende_flurstueck_spalte_wirft_fehler(self, mock_fd):
pfad = self._schreibe_csv(["FOO", "BAR"], [["1", "2"]])
mock_fd.getOpenFileName.return_value = (pfad, "")
with self.assertRaises(BeteiligungPruefungFehler) as ctx:
lade_beteiligung_csv(None)
self.assertIn(CSV_SPALTE_FLURSTUECK, str(ctx.exception))
@patch(f"{_MOD}.QFileDialog")
def test_leere_datei_wirft_fehler(self, mock_fd):
fd, pfad = tempfile.mkstemp(suffix=".csv")
os.close(fd)
self._tmp_files.append(pfad)
mock_fd.getOpenFileName.return_value = (pfad, "")
with self.assertRaises(BeteiligungPruefungFehler):
lade_beteiligung_csv(None)
@patch(f"{_MOD}.QFileDialog")
def test_erfolgsfall_gibt_zeilen_und_spalten_zurueck(self, mock_fd):
pfad = self._schreibe_csv(
[CSV_SPALTE_VEF_VKZF, "FLA_Beteiligung", CSV_SPALTE_FLURSTUECK],
[["12345", "1", "1234/123/45/00"]],
)
mock_fd.getOpenFileName.return_value = (pfad, "")
zeilen, spalten = lade_beteiligung_csv(None)
self.assertEqual(len(zeilen), 1)
self.assertIn(CSV_SPALTE_FLURSTUECK, spalten)
self.assertEqual(zeilen[0][CSV_SPALTE_FLURSTUECK], "1234/123/45/00")
if __name__ == "__main__":
unittest.main()
+33 -4
View File
@@ -186,7 +186,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase):
self.assertEqual(status, vg.LoadStatus.FIRST)
self.assertEqual(len(feats), 1)
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombination")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombinationen")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsFeature")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsGeometry.unaryUnion", return_value=_DummyGeom(empty=False))
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis._check_existing_layer", return_value=(None, vg.LoadStatus.FIRST))
@@ -202,7 +202,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase):
temp_layer = _DummyTempLayer(valid=True, features=[feat1, feat2])
memory_layer = _DummyMemoryLayer()
mock_qgsvectorlayer.side_effect = [temp_layer, memory_layer]
mock_dialog.return_value = ("ADV-A", "VKZ12/001")
mock_dialog.return_value = [("ADV-A", "VKZ12/001")]
mock_feature = mock_feature_cls.return_value
mock_feature.setGeometry.return_value = None
@@ -216,7 +216,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase):
# Nur feat1 gehört zur gewählten Kombination
self.assertEqual(feats, [feat1])
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombination")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombinationen")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis._check_existing_layer", return_value=(None, vg.LoadStatus.FIRST))
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.get_variable", return_value="VKZ123")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsVectorLayer")
@@ -229,7 +229,7 @@ class TestVerfahrensgebietAlkis(unittest.TestCase):
feat2 = _DummyFeature(attrs={"adv_name": "ADV-B", "bezeichnung": "VKZ12/002"})
temp_layer = _DummyTempLayer(valid=True, features=[feat1, feat2])
mock_qgsvectorlayer.return_value = temp_layer
mock_dialog.return_value = None # Nutzer bricht ab
mock_dialog.return_value = [] # Nutzer bricht ab
layer, status, feats = vg.verfahrensgebiet_alkis(tab_widget=None)
@@ -238,6 +238,35 @@ class TestVerfahrensgebietAlkis(unittest.TestCase):
self.assertEqual(status, vg.LoadStatus.NONE)
self.assertEqual(feats, [])
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.ask_bror_kombinationen")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsFeature")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsGeometry.unaryUnion", return_value=_DummyGeom(empty=False))
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis._check_existing_layer", return_value=(None, vg.LoadStatus.FIRST))
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.get_variable", return_value="VKZ123")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QgsVectorLayer")
@patch("sn_verfahrensgebiet.functions.verfahrensgebiet_alkis.QMessageBox", new=_DummyMessageBox)
def test_verfahrensgebiet_alkis_multi_kombination_mehrfachauswahl(
self, mock_qgsvectorlayer, _mock_get_var, _mock_check, _mock_union, mock_feature_cls, mock_dialog
):
"""Zwei verschiedene Kombinationen → beide ausgewählt → beide Feature-Gruppen genutzt."""
feat1 = _DummyFeature(attrs={"adv_name": "ADV-A", "bezeichnung": "VKZ12/001"})
feat2 = _DummyFeature(attrs={"adv_name": "ADV-B", "bezeichnung": "VKZ12/002"})
temp_layer = _DummyTempLayer(valid=True, features=[feat1, feat2])
memory_layer = _DummyMemoryLayer()
mock_qgsvectorlayer.side_effect = [temp_layer, memory_layer]
mock_dialog.return_value = [("ADV-A", "VKZ12/001"), ("ADV-B", "VKZ12/002")]
mock_feature = mock_feature_cls.return_value
mock_feature.setGeometry.return_value = None
mock_feature.setAttributes.return_value = None
layer, status, feats = vg.verfahrensgebiet_alkis(tab_widget=None)
mock_dialog.assert_called_once()
self.assertIs(layer, memory_layer)
self.assertEqual(status, vg.LoadStatus.FIRST)
self.assertEqual(feats, [feat1, feat2])
if __name__ == "__main__":
unittest.main()
+138 -25
View File
@@ -129,7 +129,7 @@ def _make_progress_mock(canceled=False):
# ---------------------------------------------------------------------------
_MOD = "sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett"
_PATCH_VG_ALKIS = f"{_MOD}.verfahrensgebiet_alkis"
_PATCH_VG_ALKIS = f"{_MOD}._suche_verfahrensgebiet_features"
_PATCH_GEM = f"{_MOD}.gemarkung_schluessel_und_namen_von_vg_geometrie"
_PATCH_FLST = f"{_MOD}.flurstueck_alkis"
_PATCH_SELECT = f"{_MOD}._select_by_location"
@@ -146,6 +146,9 @@ _PATCH_ASK_DIALOG = f"{_MOD}.ask_detailpruefung_oder_vg_laden"
_PATCH_GP_ALKIS = f"{_MOD}.lade_punktort_ta"
_PATCH_KNICK = f"{_MOD}.knickpunkt_pruefung"
_PATCH_EXTRAHIERE = f"{_MOD}._extrahiere_knickpunkte"
_PATCH_LANDKREIS_SUCHE = f"{_MOD}._suche_landkreis_kombinationen"
_PATCH_LANDKREIS_BAU = f"{_MOD}._baue_layer_aus_landkreis_auswahl"
_PATCH_ASK_LANDKREIS = f"{_MOD}.ask_landkreis_auswahl"
# ---------------------------------------------------------------------------
@@ -265,9 +268,10 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_VG_ALKIS)
def test_vg_alkis_none_aborts(self, mock_vg, mock_proj, mock_prog):
@patch(_PATCH_GET_VAR, return_value="VF-2024-001")
def test_vg_alkis_none_aborts(self, mock_get_var, mock_vg, mock_proj, mock_prog):
mock_proj.instance.return_value = _DummyProject()
mock_vg.return_value = (None, LoadStatus.NONE, [])
mock_vg.return_value = (None, LoadStatus.NONE, {}, None)
mock_prog.return_value = _make_progress_mock()
enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None)
@@ -281,10 +285,11 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_VG_ALKIS)
def test_flst_none_aborts(self, mock_vg, mock_gem, mock_proj, mock_prog):
@patch(_PATCH_GET_VAR, return_value="VF-2024-001")
def test_flst_none_aborts(self, mock_get_var, mock_vg, mock_gem, mock_proj, mock_prog):
mock_proj.instance.return_value = _DummyProject()
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)")
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = [("1234", "Muster")]
mock_prog.return_value = _make_progress_mock()
@@ -302,13 +307,14 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_VG_ALKIS)
@patch(_PATCH_GET_VAR, return_value="VF-2024-001")
def test_no_selection_returns_none_enriched(
self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_msgbox, mock_prog
self, mock_get_var, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_msgbox, mock_prog
):
mock_proj.instance.return_value = _DummyProject()
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)")
flst_layer = _make_layer(name="Flurstücke")
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 0
@@ -330,15 +336,16 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
@patch(_PATCH_GEM)
@patch(_PATCH_FLST)
@patch(_PATCH_VG_ALKIS)
@patch(_PATCH_GET_VAR, return_value="VF-2024-001")
def test_invalid_enriched_layer_returns_none(
self, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_erstelle, mock_msgbox, mock_prog
self, mock_get_var, mock_vg, mock_flst, mock_gem, mock_proj, mock_select, mock_erstelle, mock_msgbox, mock_prog
):
mock_proj.instance.return_value = _DummyProject()
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)")
flst_layer = _make_layer(name="Flurstücke")
bad_enriched = _make_layer(valid=False)
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 5
@@ -356,11 +363,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_VG_ALKIS)
def test_progress_cancel_after_step1_returns_none(self, mock_vg, mock_proj, mock_prog):
@patch(_PATCH_GET_VAR, return_value="VF-2024-001")
def test_progress_cancel_after_step1_returns_none(self, mock_get_var, mock_vg, mock_proj, mock_prog):
"""Abbruch nach Schritt 1 → sofortiger sauberer Rückgabestatus."""
mock_proj.instance.return_value = _DummyProject()
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)")
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
prog = MagicMock()
prog.is_canceled.side_effect = [False, True] # 1. Aufruf False, 2. True
@@ -398,7 +406,7 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
flst_layer = _make_layer(name="Flurstücke", lid="FLST1")
enriched = _make_layer(name="Verfahrensgebiet", lid="VG_ENRICHED", valid=True)
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = [("1234", "Muster")]
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 3
@@ -437,18 +445,18 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
flst_layer = _make_layer(name="Flurstücke")
enriched = _make_layer(name="Verfahrensgebiet", valid=True)
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 2
mock_erstelle.return_value = (enriched, 2, 0)
mock_get_var.return_value = ""
mock_get_var.return_value = "VF-2024-001"
mock_vergl.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
verfahrensgebiet_alkis_komplett(None)
mock_vkz.assert_called_once_with(enriched, "")
mock_vkz.assert_called_once_with(enriched, "VF-2024-001")
# ------------------------------------------------------------------ #
# Flächenvergleich – Abweichung + Abbrechen
@@ -476,12 +484,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
flst_layer = _make_layer(name="Flurstücke")
enriched = _make_layer(name="Verfahrensgebiet", valid=True)
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 3
mock_erstelle.return_value = (enriched, 3, 0)
mock_get_var.return_value = ""
mock_get_var.return_value = "VF-2024-001"
mock_vergl.return_value = (1000.0, 800.0, False)
mock_ask.return_value = "abbrechen"
mock_prog.return_value = _make_progress_mock()
@@ -521,12 +529,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
flst_layer = _make_layer(name="Flurstücke")
enriched = _make_layer(name="Verfahrensgebiet", valid=True)
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 3
mock_erstelle.return_value = (enriched, 3, 0)
mock_get_var.return_value = ""
mock_get_var.return_value = "VF-2024-001"
mock_vergl.return_value = (1000.0, 800.0, False)
mock_ask.return_value = "als_vg_laden"
mock_prog.return_value = _make_progress_mock()
@@ -577,12 +585,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
punktort_layer = _make_layer(name="PunktortTA", lid="PTA_L")
ergebnis_layer = _make_layer(name="Knickpunktprüfung", lid="KNICK_L")
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 3
mock_erstelle.return_value = (enriched, 3, 0)
mock_get_var.return_value = ""
mock_get_var.return_value = "VF-2024-001"
mock_vergl.return_value = (1000.0, 800.0, False)
mock_ask.return_value = "detailpruefung"
mock_extrahiere.return_value = [MagicMock(), MagicMock(), MagicMock()] # 3 Knickpunkte
@@ -630,12 +638,12 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
flst_layer = _make_layer(name="Flurstücke")
enriched = _make_layer(name="Verfahrensgebiet", valid=True)
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_flst.return_value = (flst_layer, LoadStatus.FIRST)
mock_select.return_value = 3
mock_erstelle.return_value = (enriched, 3, 0)
mock_get_var.return_value = ""
mock_get_var.return_value = "VF-2024-001"
mock_vergl.return_value = (1000.0, 1000.0, True)
mock_prog.return_value = _make_progress_mock()
@@ -647,15 +655,16 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
@patch(_PATCH_PROJECT)
@patch(_PATCH_GEM)
@patch(_PATCH_VG_ALKIS)
@patch(_PATCH_GET_VAR, return_value="VF-2024-001")
def test_bror_layer_wird_bei_fehler_entfernt(
self, mock_vg, mock_gem, mock_proj, mock_prog
self, mock_get_var, mock_vg, mock_gem, mock_proj, mock_prog
):
"""Bei Fehler (flst_alkis NONE) wird der BROR-Layer aus dem Projekt entfernt."""
proj_inst = _DummyProject()
mock_proj.instance.return_value = proj_inst
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)", lid="BROR_ERR")
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, [])
mock_vg.return_value = (vg_layer, LoadStatus.FIRST, {}, None)
mock_gem.return_value = []
mock_prog.return_value = _make_progress_mock()
@@ -664,6 +673,110 @@ class TestVerfahrensgebietAlkisKomplett(unittest.TestCase):
self.assertIn("BROR_ERR", proj_inst.removed)
# ------------------------------------------------------------------ #
# Landkreis-Fallback (keine Verfahrensnummer)
# ------------------------------------------------------------------ #
@patch(_PATCH_PROGRESS)
@patch(_PATCH_MSGBOX)
@patch(_PATCH_ASK_LANDKREIS, return_value=None)
@patch(_PATCH_GET_VAR, return_value="")
def test_kein_vkz_kein_landkreis_dialog_abgebrochen(
self, mock_get_var, mock_ask_landkreis, mock_msgbox, mock_prog
):
"""Weder Verfahrensnummer noch Landkreis gesetzt, Nutzer bricht den
Landkreis-Auswahldialog ab → stiller Abbruch, kein Layer, KEINE
Fehlermeldung (bewusster Abbruch, kein Fehlerzustand)."""
enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None)
self.assertIsNone(enriched)
self.assertIsNone(flst)
self.assertIsNone(bror)
self.assertEqual(status, LoadStatus.NONE)
mock_msgbox.critical.assert_not_called()
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_LANDKREIS_BAU)
@patch(_PATCH_LANDKREIS_SUCHE)
@patch(_PATCH_ASK_LANDKREIS, return_value="22")
@patch(_PATCH_GET_VAR, return_value="")
def test_kein_vkz_kein_landkreis_dialog_liefert_landkreis(
self, mock_get_var, mock_ask_landkreis, mock_such, mock_bau, mock_proj, mock_prog
):
"""Weder Verfahrensnummer noch Landkreis gesetzt, Nutzer wählt einen
Landkreis im Dialog → Landkreis-Suchpfad wird mit dieser Kennzahl
verwendet (wie im Fall 'Landkreis aus Projekteigenschaften')."""
proj_inst = _DummyProject()
mock_proj.instance.return_value = proj_inst
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)")
mock_such.return_value = (None, LoadStatus.FIRST, {("A", "22015"): [MagicMock()]}, "EPSG:25833")
mock_bau.return_value = (vg_layer, LoadStatus.FIRST, True) # nur_laden=True
mock_prog.return_value = _make_progress_mock()
enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None)
mock_ask_landkreis.assert_called_once()
mock_such.assert_called_once_with(None, "22")
mock_bau.assert_called_once()
self.assertIs(enriched, vg_layer)
self.assertEqual(status, LoadStatus.FIRST)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_LANDKREIS_BAU)
@patch(_PATCH_LANDKREIS_SUCHE)
def test_landkreis_modus_wird_bei_fehlender_vkz_verwendet(
self, mock_such, mock_bau, mock_proj, mock_prog
):
"""Keine Verfahrensnummer, aber Landkreis gesetzt → Landkreis-Suchpfad
statt VKZ-Pfad wird verwendet."""
def _get_var_side_effect(key, scope="project"):
return {"verfahrensnummer": "", "landkreis_kennzahl": "22"}.get(key, "")
proj_inst = _DummyProject()
mock_proj.instance.return_value = proj_inst
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)")
mock_such.return_value = (None, LoadStatus.FIRST, {("A", "22015"): [MagicMock()]}, "EPSG:25833")
mock_bau.return_value = (vg_layer, LoadStatus.FIRST, True) # nur_laden=True
mock_prog.return_value = _make_progress_mock()
with patch(_PATCH_GET_VAR, side_effect=_get_var_side_effect):
enriched, flst, bror, status = verfahrensgebiet_alkis_komplett(None)
mock_such.assert_called_once()
mock_bau.assert_called_once()
self.assertIs(enriched, vg_layer)
self.assertIsNone(flst)
self.assertIsNone(bror)
self.assertEqual(status, LoadStatus.FIRST)
@patch(_PATCH_PROGRESS)
@patch(_PATCH_PROJECT)
@patch(_PATCH_LANDKREIS_BAU)
@patch(_PATCH_LANDKREIS_SUCHE)
def test_landkreis_modus_nur_laden_ueberspringt_dialog2(
self, mock_such, mock_bau, mock_proj, mock_prog
):
"""nur_laden=True → Funktion kehrt sofort zurück, Dialog 2 (Gemarkung
etc.) wird nicht gestartet."""
def _get_var_side_effect(key, scope="project"):
return {"verfahrensnummer": "", "landkreis_kennzahl": "22"}.get(key, "")
proj_inst = _DummyProject()
mock_proj.instance.return_value = proj_inst
vg_layer = _make_layer(name="BauRaumOrdnungsrecht (ALKIS)")
mock_such.return_value = (None, LoadStatus.FIRST, {("A", "22015"): [MagicMock()]}, "EPSG:25833")
mock_bau.return_value = (vg_layer, LoadStatus.FIRST, True)
mock_prog.return_value = _make_progress_mock()
with patch(_PATCH_GET_VAR, side_effect=_get_var_side_effect), \
patch(_PATCH_GEM) as mock_gem:
verfahrensgebiet_alkis_komplett(None)
mock_gem.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -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()
+123 -8
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
@@ -20,7 +21,15 @@ from sn_verfahrensgebiet.functions.flurstueck_alkis import (
ALKIS_SF_URL,
FLST_LAYER_NAME,
)
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import verfahrensgebiet_alkis_komplett
from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import (
verfahrensgebiet_alkis_komplett,
BROR_LAYER_NAME,
)
from sn_verfahrensgebiet.functions.verfahrensgebiet_shape_komplett import verfahrensgebiet_shape_komplett
from sn_verfahrensgebiet.functions.beteiligung_pruefung import (
beteiligung_pruefen,
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 +79,15 @@ class WorkingTab(QWidget):
row_verf_buttons.addWidget(self.btn_verf_shape)
layout.addLayout(row_verf_buttons)
row_beteiligung_buttons = QHBoxLayout()
self.btn_beteiligung_pruefen = QPushButton("Beteiligung prüfen")
self.btn_beteiligung_pruefen.setToolTip(
"CSV-Export aus dem BI-Server verwenden. benötigte Spalten: "
"FLA_Verfahren-VEF_VKZF, FLA-Flurstuecke-Flurstueck, FLA_Beteiligung"
)
row_beteiligung_buttons.addWidget(self.btn_beteiligung_pruefen)
layout.addLayout(row_beteiligung_buttons)
# Abschnitt 1b: Verfahrensgebiet-Layer auswählen
row_layer_label = QHBoxLayout()
lbl_layer = QLabel("Oder: Verfahrensgebiet-Layer auswählen")
@@ -167,8 +185,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()
@@ -199,11 +218,72 @@ class WorkingTab(QWidget):
apply_style(flst_layer, "GIS_Flst_Beschriftung_ALKIS_NAS.qml")
self.setze_haken(self.haken_flurst, True)
# BROR-Layer: QML-Stil anwenden
# BROR-Layer bekommt seinen Style bereits direkt beim Laden in
# verfahrensgebiet_alkis_komplett() (auch im "Nur laden"-Fall).
# Bei "Nur Objekte laden" (kein weiterer Ablauf) ist enriched_layer
# tatsächlich nur der rohe BROR-Layer (erkennbar am Namen) – hier
# fand keine Flurstücksverschneidung statt, es gibt also keinen
# echten VG-Layer: VG-Style/-Kombobox/-Haken/-Meldung überspringen.
if enriched_layer.name() != BROR_LAYER_NAME:
# Enriched VG-Layer ins Projekt (ggf. bereits registriert, z.B. bei "als VG laden")
if not project.mapLayer(enriched_layer.id()):
project.addMapLayer(enriched_layer)
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)
msg = "erstmalig geladen" if status == LoadStatus.FIRST else "neu geladen"
success("Verfahrensgebiet", f"Layer wurde {msg} (inkl. Flurstücksverschneidung).", duration=5)
finally:
self._set_busy(self.btn_verf_alkis, False, "Aus ALKIS laden")
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")
# Enriched VG-Layer ins Projekt (ggf. bereits registriert, z.B. bei "als VG laden")
# Shape-VG-Layer ins Projekt
if not project.mapLayer(enriched_layer.id()):
project.addMapLayer(enriched_layer)
apply_style(enriched_layer, "verfahrensgebiet.qml")
@@ -214,11 +294,46 @@ class WorkingTab(QWidget):
self.verf_layer_combo.setLayer(enriched_layer)
self._on_verf_layer_changed(enriched_layer)
msg = "erstmalig geladen" if status == LoadStatus.FIRST else "neu geladen"
success("Verfahrensgebiet", f"Layer wurde {msg} (inkl. Flurstücksverschneidung).", duration=5)
success("Verfahrensgebiet", "Layer aus Shape geladen.", duration=5)
finally:
self._set_busy(self.btn_verf_alkis, False, "Aus ALKIS laden")
self._set_busy(self.btn_verf_shape, False, "Aus Shape laden")
def handle_beteiligung_pruefen(self) -> None:
"""Gleicht eine BI-Server-CSV-Beteiligungsliste mit dem Verfahrensgebiet ab.
Delegiert an :func:`~sn_verfahrensgebiet.functions.beteiligung_pruefung.beteiligung_pruefen`.
Ist noch kein (verwertbares) Verfahrensgebiet geladen, fordert die
Funktion den Nutzer per Meldung auf, es zunächst über "Aus ALKIS
laden" zu laden – es gibt kein automatisches Laden im Hintergrund.
Sonst werden ein oder mehrere Ergebnis-Layer ins Projekt eingefügt,
gestylt und eine Statistik als Messagebox angezeigt.
"""
self._set_busy(self.btn_beteiligung_pruefen, True, "Beteiligung prüfen")
try:
try:
ergebnis = beteiligung_pruefen(self)
except BeteiligungPruefungFehler as exc:
QMessageBox.critical(self, "Beteiligung prüfen", str(exc))
return
if ergebnis is None:
return # Nutzerabbruch des Dateidialogs
project = QgsProject.instance()
for layer, style_name in ergebnis.layers:
if layer is None or not layer.isValid():
continue
if not project.mapLayer(layer.id()):
project.addMapLayer(layer)
if style_name:
apply_style(layer, style_name)
iface.mapCanvas().refresh()
QMessageBox.information(self, "Beteiligung prüfen", ergebnis.statistik_text)
finally:
self._set_busy(self.btn_beteiligung_pruefen, False, "Beteiligung prüfen")
def _set_busy(self, button: QPushButton, busy: bool, original_text: str = "Aus ALKIS laden") -> None:
"""Setzt den Busy-State eines Buttons.