Merge pull request 'Feature/56 eigentuemersuche' (#58) from feature/56-eigentuemersuche into unstable

Reviewed-on: AG_QGIS/Plugin_SN_Verfahrensgebiet#58
This commit is contained in:
2026-10-06 11:18:25 +00:00
8 changed files with 840 additions and 61 deletions
+2
View File
@@ -1,3 +1,5 @@
- Neu: Eigentümersuche im Tab „Eigentümer“. Den Serienbriefexport (CSV) von Hand als Layer laden, als Eigentümerdatei wählen, Name und/oder Vorname eingeben: Die Trefferliste zeigt Name, Anschrift, E-Mail, den Wert von PER_RECHTSPERSON (0, 1, 5 oder 9, noch nicht entschlüsselt) und das Kennzeichen „verstorben“. Gleiche Personen mit gleicher Anschrift werden zusammengefasst, Groß-/Kleinschreibung und Umlautschreibweise spielen keine Rolle (#56)
- Intern: Die Auswahltabelle aus „Verfahrensgebiete aus Landkreis laden“ (Suchfeld, „Alle auswählen“, Mehrfachauswahl) ist als gemeinsame Funktion herausgelöst und wird von der Eigentümer-Trefferliste mitgenutzt; der Landkreis-Dialog verhält sich unverändert (#56)
- Neu: Tab „Eigentümer“ mit Flurstückssuche. Gemarkung (Name oder Nummer) und Flurstücksnummer (z. B. 562 oder 12/3) eingeben, die Treffer werden im Layer „Flurstücke“ ausgewählt und angezoomt. Ohne Nenner werden alle Flurstücke mit diesem Zähler gefunden. Fehlt der Layer „Flurstücke“, weist ein Dialog darauf hin, zuerst Verfahrensgebiet und Flurstücke aus ALKIS zu laden (#56)
- Bugfix: Nach dem Laden des Verfahrensgebiets (ALKIS oder Shape) sprang die Karte an die falsche Stelle, wenn ein anderes Projekt-KBS als EPSG:25833 eingestellt war. Der Zoom rechnet die Ausdehnung jetzt in das KBS der Kartenansicht um (`zoom_auf_layer` aus sn_basis, setzt sn_basis mit Basis #99 voraus) (Basis #99)
- Neu: Grenzpunkte aus ALKIS werden nach dem Laden im ALKIS-Stil dargestellt (styles/alkis_grenzpunkte.qml), statt ungestylt zu bleiben (#53)
+229
View File
@@ -0,0 +1,229 @@
"""sn_verfahrensgebiet/functions/eigentuemer_suche.py – Eigentümersuche in einer CSV (#56).
Die Eigentümerdaten kommen als CSV-Export (Serienbriefexport, Kopfzeile siehe
:data:`SPALTEN`), den der Anwender von Hand als Layer ins Projekt lädt. Das
Plugin speichert davon nichts, es liest nur den Layer.
Gesucht wird über Nachname und Vorname, ohne Rücksicht auf Groß- und
Kleinschreibung und Umlautschreibweise (``Müller`` findet ``Mueller``). Ein
leeres Feld schränkt nicht ein. Zeilen mit gleichem Namen und gleicher Anschrift
werden zu einem :class:`Eigentuemer` zusammengefasst; dieselbe Person mit einer
anderen Anschrift bleibt ein eigener Eintrag.
**Datenschutz:** Das Modul schreibt keine Personendaten in Ausgaben oder
Meldungen. Es zeigt keine Dialoge; Fehler kommen als
:class:`EigentuemerSucheFehler` mit einer Meldung ohne Personendaten zurück.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from sn_basis.functions import QgsProject
#: Spalten des Serienbriefexports, die das Plugin auswertet.
SPALTEN = {
"konto": "EIG_KONTO",
"per_id": "PER_ID",
"verstorben": "PER_VERSTORBEN",
"anrede": "Anrede",
"titel": "Titel",
"vorname": "Vorname",
"nachname": "Nachname",
"ortsteil": "OT",
"strasse": "STRASSE",
"plz": "PLZ",
"wohnort": "WOHNORT",
"email": "Email",
"rechtsperson": "PER_RECHTSPERSON",
}
PFLICHTSPALTEN = ("Nachname", "Vorname")
_NEIN_WERTE = {"", "0", "n", "nein", "f", "false", "falsch", "null"}
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue"})
class EigentuemerSucheFehler(Exception):
"""Eingabe- oder Layerfehler; der Text enthält keine Personendaten."""
@dataclass(frozen=True)
class Eigentuemer:
"""Ein Treffer der Eigentümersuche, zusammengefasst über gleiche Zeilen."""
anrede: str = ""
titel: str = ""
vorname: str = ""
nachname: str = ""
strasse: str = ""
plz: str = ""
wohnort: str = ""
ortsteil: str = ""
emails: Tuple[str, ...] = ()
verstorben: bool = False
rechtsperson: Tuple[str, ...] = ()
per_ids: Tuple[str, ...] = ()
konten: Tuple[str, ...] = ()
feature_ids: Tuple[int, ...] = ()
@property
def name(self) -> str:
"""``Nachname, Titel Vorname`` für Listen."""
vorne = " ".join(t for t in (self.titel, self.vorname) if t)
return ", ".join(t for t in (self.nachname, vorne) if t)
@property
def anschrift(self) -> str:
"""Einzeilige Anschrift, z. B. ``Hauptstraße 1, 01234 Musterstadt, OT Beispiel``."""
ort = " ".join(t for t in (self.plz, self.wohnort) if t)
ortsteil = self.ortsteil
if ortsteil and not ortsteil.upper().startswith("OT"):
ortsteil = f"OT {ortsteil}"
return ", ".join(t for t in (self.strasse, ort, ortsteil) if t)
@property
def hinweise(self) -> str:
"""Kennzeichen für die Trefferliste (nicht für das Anzeigefenster)."""
return "verstorben" if self.verstorben else ""
@property
def rechtsperson_text(self) -> str:
"""Rohwert(e) von ``PER_RECHTSPERSON`` (bekannt: 0, 1, 5, 9), noch nicht entschlüsselt."""
return ", ".join(self.rechtsperson)
def _text(wert) -> str:
"""Attributwert als bereinigter Text; QGIS-NULL und ``None`` werden leer."""
if wert is None:
return ""
ist_null = getattr(wert, "isNull", None)
if callable(ist_null):
try:
if ist_null():
return ""
except Exception:
pass
text = " ".join(str(wert).split())
return "" if text == "NULL" else text
def _plz(text: str) -> str:
"""Stellt führende Nullen wieder her.
QGIS erkennt beim Laden einer CSV Feldtypen und liest ``01067`` dann als
Ganzzahl ``1067`` – in Sachsen beginnt fast jede PLZ mit 0.
"""
if text.endswith(".0") and text[:-2].isdigit():
text = text[:-2]
return text.zfill(5) if text.isdigit() and len(text) < 5 else text
def _ist_gesetzt(wert) -> bool:
return _text(wert).casefold() not in _NEIN_WERTE
def _vergleichstext(text: str) -> str:
"""Für die Suche: Groß-/Kleinschreibung, ß und Umlautschreibweise angleichen."""
return " ".join(text.split()).casefold().translate(_UMLAUTE)
def _spalten_des_layers(layer) -> Dict[str, str]:
"""Ordnet die Spaltennamen des Layers den Schlüsseln aus :data:`SPALTEN` zu.
Verglichen wird ohne Groß-/Kleinschreibung und ohne Byte-Order-Mark, das
beim Laden einer UTF-8-CSV an der ersten Spalte hängen bleiben kann.
"""
vorhanden = {}
for name in layer.fields().names():
vorhanden[str(name).replace("", "").strip().casefold()] = name
return {
schluessel: vorhanden[spalte.casefold()]
for schluessel, spalte in SPALTEN.items()
if spalte.casefold() in vorhanden
}
def ist_eigentuemerlayer(layer) -> bool:
"""``True``, wenn der Layer die Spalten ``Nachname`` und ``Vorname`` hat."""
try:
spalten = _spalten_des_layers(layer)
except Exception:
return False
return "nachname" in spalten and "vorname" in spalten
def eigentuemerlayer_im_projekt() -> List[object]:
"""Alle Layer des Projekts, die als Eigentümerdatei taugen, nach Namen sortiert."""
layer = [lyr for lyr in QgsProject.instance().mapLayers().values() if ist_eigentuemerlayer(lyr)]
return sorted(layer, key=lambda lyr: lyr.name().casefold())
def _gruppenschluessel(werte: Dict[str, str]) -> Tuple[str, ...]:
return tuple(werte[k].casefold() for k in ("nachname", "vorname", "strasse", "plz", "wohnort", "ortsteil"))
def suche_eigentuemer(layer, nachname: str, vorname: str) -> List[Eigentuemer]:
"""Sucht Eigentümer, deren Nach- und Vorname die Eingaben enthalten.
Raises
------
EigentuemerSucheFehler
Beide Felder sind leer oder dem Layer fehlen die Pflichtspalten.
"""
such_nachname = _vergleichstext(nachname or "")
such_vorname = _vergleichstext(vorname or "")
if not such_nachname and not such_vorname:
raise EigentuemerSucheFehler("Bitte einen Namen oder Vornamen eingeben.")
spalten = _spalten_des_layers(layer)
if "nachname" not in spalten or "vorname" not in spalten:
raise EigentuemerSucheFehler(
f"Der Layer „{layer.name()}“ hat nicht die Spalten "
f"{' und '.join(PFLICHTSPALTEN)}. Ist es der Serienbriefexport?"
)
gruppen: Dict[Tuple[str, ...], dict] = {}
for feature in layer.getFeatures():
werte = {k: _text(feature[spalte]) for k, spalte in spalten.items()}
for k in SPALTEN:
werte.setdefault(k, "")
werte["plz"] = _plz(werte["plz"])
if such_nachname and such_nachname not in _vergleichstext(werte["nachname"]):
continue
if such_vorname and such_vorname not in _vergleichstext(werte["vorname"]):
continue
gruppe = gruppen.setdefault(_gruppenschluessel(werte), {
"werte": werte, "emails": [], "per_ids": [], "konten": [], "feature_ids": [],
"verstorben": False, "rechtsperson": [],
})
for k in ("anrede", "titel"):
if not gruppe["werte"][k] and werte[k]:
gruppe["werte"][k] = werte[k]
for liste, wert in (("emails", werte["email"]), ("per_ids", werte["per_id"]),
("konten", werte["konto"]), ("rechtsperson", werte["rechtsperson"])):
if wert and wert not in gruppe[liste]:
gruppe[liste].append(wert)
gruppe["feature_ids"].append(feature.id())
gruppe["verstorben"] = gruppe["verstorben"] or _ist_gesetzt(werte["verstorben"])
treffer = []
for gruppe in gruppen.values():
w = gruppe["werte"]
treffer.append(Eigentuemer(
anrede=w["anrede"], titel=w["titel"], vorname=w["vorname"], nachname=w["nachname"],
strasse=w["strasse"], plz=w["plz"], wohnort=w["wohnort"], ortsteil=w["ortsteil"],
emails=tuple(gruppe["emails"]),
verstorben=gruppe["verstorben"], rechtsperson=tuple(gruppe["rechtsperson"]),
per_ids=tuple(gruppe["per_ids"]), konten=tuple(gruppe["konten"]),
feature_ids=tuple(gruppe["feature_ids"]),
))
return sorted(treffer, key=lambda e: (e.nachname.casefold(), e.vorname.casefold(), e.wohnort.casefold()))
def hole_layer(layer_id: Optional[str]):
"""Layer zur ID aus dem Projekt oder ``None``."""
if not layer_id:
return None
return QgsProject.instance().mapLayer(layer_id)
+103 -55
View File
@@ -1,5 +1,8 @@
"""sn_verfahrensgebiet/functions/verfahrensgebiet_dialoge.py – Dialoge zum
Laden eines Verfahrensgebiets aus ALKIS (Qt5/6/Mock-kompatibel).
Enthält außerdem die gemeinsame Auswahltabelle (:func:`baue_auswahltabelle`),
die auch die Eigentümer-Trefferliste (#56) nutzt.
"""
from typing import Any, Optional
from sn_basis.functions.qt_wrapper import (
@@ -8,6 +11,7 @@ from sn_basis.functions.qt_wrapper import (
QTableWidget, QTableWidgetItem, QTableWidgetSelectionRange,
QIcon, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox,
DialogAccepted, ExtendedSelection, SelectRows, NoEditTriggers, AscendingOrder,
UserRole,
)
from sn_basis.functions.landkreise import ALLE_LANDKREISE
import os
@@ -17,6 +21,98 @@ _PLUGINS_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_WAPPEN_ICON = os.path.join(_PLUGINS_DIR, "sn_basis", "favicon.ico")
def zeilen_sichtbar(zeilen_texte: list[list[str]], suchtext: str) -> list[bool]:
"""Ob jede Tabellenzeile zum Suchtext passt: Teilstring in irgendeiner Spalte,
ohne Groß-/Kleinschreibung. Ein leerer Suchtext zeigt alle Zeilen."""
such = (suchtext or "").strip().lower()
return [not such or any(such in (t or "").lower() for t in texte) for texte in zeilen_texte]
def baue_auswahltabelle(
layout: Any,
spaltenkoepfe: list[str],
zeilen: list[tuple[str, ...]],
platzhalter: str = "Suche …",
alle_auswaehlen: bool = True,
) -> Any:
"""Fügt Suchfeld, „Alle auswählen“, Auswahltabelle und Mehrfachauswahl-Hinweis
in ``layout`` ein und gibt die Tabelle zurück.
Gemeinsamer Baustein von :func:`ask_landkreis_objekte` und der
Eigentümer-Trefferliste (#56). Die Tabelle ist nach Spalte 0 aufsteigend
sortiert, per Klick auf den Spaltenkopf umsortierbar und erlaubt die
Mehrfachauswahl ganzer Zeilen. Spalte 0 trägt den Index der Zeile in
``zeilen`` (``UserRole``), damit die Auswahl auch nach dem Umsortieren
zugeordnet werden kann (:func:`ausgewaehlte_indizes`).
"""
suche_zeile = QHBoxLayout()
suche_zeile.addWidget(QLabel("Suche:"))
suche_feld = QLineEdit()
suche_feld.setPlaceholderText(platzhalter)
suche_zeile.addWidget(suche_feld)
layout.addLayout(suche_zeile)
alle_checkbox = None
if alle_auswaehlen:
alle_checkbox = QCheckBox("Alle auswählen")
layout.addWidget(alle_checkbox)
table = QTableWidget(len(zeilen), len(spaltenkoepfe))
table.setHorizontalHeaderLabels(spaltenkoepfe)
table.setSelectionMode(ExtendedSelection)
table.setSelectionBehavior(SelectRows)
table.setEditTriggers(NoEditTriggers)
table.verticalHeader().setVisible(False)
table.verticalHeader().setDefaultSectionSize(22) # flachere Zeilen
table.horizontalHeader().setStretchLastSection(True)
# Sortierung erst nach dem Befüllen einschalten, sonst wandern Zeilen beim setItem
table.setSortingEnabled(False)
for row, texte in enumerate(zeilen):
for col, text in enumerate(texte):
item = QTableWidgetItem(text)
if col == 0:
item.setData(UserRole, row)
table.setItem(row, col, item)
table.setSortingEnabled(True)
table.sortByColumn(0, AscendingOrder)
layout.addWidget(table, 1) # Stretch-Faktor: wächst mit Fenstergröße
def _suche_geaendert(text):
texte = [
[table.item(row, col).text() for col in range(table.columnCount())]
for row in range(table.rowCount())
]
for row, sichtbar in enumerate(zeilen_sichtbar(texte, text)):
table.setRowHidden(row, not sichtbar)
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
)
if alle_checkbox is not None:
alle_checkbox.stateChanged.connect(_alle_auswaehlen_umschalten)
layout.addWidget(QLabel("<small>Mehrfachauswahl mit Strg + Umschalt</small>"))
return table
def ausgewaehlte_indizes(table: Any) -> list[int]:
"""Indizes der ausgewählten Zeilen, bezogen auf ``zeilen`` aus
:func:`baue_auswahltabelle`, aufsteigend."""
zeilen = {item.row() for item in table.selectedItems()}
return sorted(int(table.item(row, 0).data(UserRole)) for row in zeilen)
def ask_bror_kombinationen(
parent: Any,
kombinationen: list[tuple[str, str]],
@@ -144,65 +240,17 @@ def ask_landkreis_objekte(
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).
# Standardsortierung nach Bezeichnung; per Klick auf Spaltenkopf änderbar.
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 = baue_auswahltabelle(
layout,
["Nummer", "Name"],
[(bez, adv) for adv, bez in sortierte_kombinationen],
platzhalter="Nummer oder Name …",
)
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)
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(dialog.accept)
+197
View File
@@ -0,0 +1,197 @@
"""Tests für die Eigentümersuche im Serienbriefexport (#56).
Alle Namen und Anschriften sind erfunden.
"""
import contextlib
import io
import types
import unittest
from unittest.mock import patch
from sn_verfahrensgebiet.functions import eigentuemer_suche as es
from sn_verfahrensgebiet.functions.eigentuemer_suche import Eigentuemer
KOPF = [
"EIG_KONTO", "PER_ID", "PER_VERSTORBEN", "PER_NR", "Anrede", "Titel", "Vorname", "Nachname",
"OT", "STRASSE", "PLZ", "WOHNORT", "Email", "PER_KONTOINHABER", "PER_RECHTSPERSON",
"EIG_GEMARKUNG", "EIG_BLATT", "LISTE_ZGBBL", "ANZAHL_PERSONEN", "EIG_ID",
]
class _Null:
"""Nachbildung von QGIS-NULL."""
def isNull(self):
return True
class _Feature(dict):
def __init__(self, fid, werte):
super().__init__(werte)
self._fid = fid
def id(self):
return self._fid
class _Layer:
def __init__(self, zeilen, kopf=KOPF, name="Serienbrief", layer_id="csv1"):
self._kopf = list(kopf)
self._name = name
self._id = layer_id
self._features = [
_Feature(i + 1, {spalte: zeile.get(spalte.lstrip(""), "") for spalte in self._kopf})
for i, zeile in enumerate(zeilen)
]
def name(self):
return self._name
def id(self):
return self._id
def fields(self):
return types.SimpleNamespace(names=lambda: list(self._kopf))
def getFeatures(self):
return iter(self._features)
def _zeile(nachname, vorname, strasse="Hauptstraße 1", plz="01234", ort="Musterstadt", **weitere):
zeile = {"Nachname": nachname, "Vorname": vorname, "STRASSE": strasse, "PLZ": plz, "WOHNORT": ort}
zeile.update(weitere)
return zeile
class TestLayererkennung(unittest.TestCase):
def test_layer_mit_name_und_vorname_ist_eigentuemerlayer(self):
"""Prüft, dass ein Layer mit den Spalten Nachname und Vorname als Eigentümerdatei erkannt wird, auch mit BOM und anderer Schreibweise."""
self.assertTrue(es.ist_eigentuemerlayer(_Layer([])))
self.assertTrue(es.ist_eigentuemerlayer(_Layer([], kopf=["NACHNAME", "vorname"])))
self.assertFalse(es.ist_eigentuemerlayer(_Layer([], kopf=["flurstueckskennzeichen"])))
self.assertFalse(es.ist_eigentuemerlayer(object()))
def test_eigentuemerlayer_im_projekt(self):
"""Prüft, dass aus dem Projekt nur passende Layer kommen, nach Namen sortiert."""
b = _Layer([], name="b-Export", layer_id="b")
a = _Layer([], name="A-Export", layer_id="a")
flst = _Layer([], kopf=["flurstueckskennzeichen"], name="Flurstücke", layer_id="f")
projekt = types.SimpleNamespace(mapLayers=lambda: {"b": b, "f": flst, "a": a})
with patch.object(es, "QgsProject", types.SimpleNamespace(instance=lambda: projekt)):
self.assertEqual(es.eigentuemerlayer_im_projekt(), [a, b])
class TestSuche(unittest.TestCase):
def test_ohne_eingabe_fehler(self):
"""Prüft, dass ohne Name und Vorname nicht die ganze Datei gelistet, sondern um eine Eingabe gebeten wird."""
with self.assertRaisesRegex(es.EigentuemerSucheFehler, "Namen"):
es.suche_eigentuemer(_Layer([_zeile("Beispiel", "Anna")]), " ", "")
def test_layer_ohne_pflichtspalten(self):
"""Prüft, dass ein Layer ohne Nachname/Vorname mit Hinweis auf den Serienbriefexport abgelehnt wird."""
with self.assertRaisesRegex(es.EigentuemerSucheFehler, "Serienbriefexport"):
es.suche_eigentuemer(_Layer([], kopf=["flurstueckskennzeichen"]), "Beispiel", "")
def test_teilstring_ohne_gross_kleinschreibung(self):
"""Prüft, dass Name und Vorname als Teilstring ohne Rücksicht auf Groß-/Kleinschreibung gesucht werden."""
layer = _Layer([
_zeile("Beispiel", "Anna"),
_zeile("Beispiel-Muster", "Bernd"),
_zeile("Anders", "Anna"),
])
self.assertEqual([e.nachname for e in es.suche_eigentuemer(layer, "beispiel", "")],
["Beispiel", "Beispiel-Muster"])
self.assertEqual([e.nachname for e in es.suche_eigentuemer(layer, "", "ANNA")], ["Anders", "Beispiel"])
self.assertEqual([e.vorname for e in es.suche_eigentuemer(layer, "beisp", "an")], ["Anna"])
def test_umlaute_und_eszett(self):
"""Prüft, dass „Müller“ auch „Mueller“ findet und „Strauss“ auch „Strauß“."""
layer = _Layer([_zeile("Mueller", "Jörg"), _zeile("Strauß", "Erika", ort="Anderswo")])
self.assertEqual(len(es.suche_eigentuemer(layer, "Müller", "Joerg")), 1)
self.assertEqual(len(es.suche_eigentuemer(layer, "Strauss", "")), 1)
def test_gleiche_person_gleiche_anschrift_zusammengefasst(self):
"""Prüft, dass Zeilen mit gleichem Namen und gleicher Anschrift ein Treffer werden und Konten, Personen-IDs und E-Mails gesammelt werden."""
layer = _Layer([
_zeile("Beispiel", "Anna", EIG_KONTO="100", PER_ID="7", Email="anna@example.org"),
_zeile("beispiel ", " Anna", strasse="Hauptstraße 1", EIG_KONTO="200", PER_ID="7",
Titel="Dr.", Email="anna@example.org"),
_zeile("Beispiel", "Anna", EIG_KONTO="300", PER_ID="8", Email="a.b@example.org"),
])
treffer = es.suche_eigentuemer(layer, "Beispiel", "Anna")
self.assertEqual(len(treffer), 1)
e = treffer[0]
self.assertEqual(e.konten, ("100", "200", "300"))
self.assertEqual(e.per_ids, ("7", "8"))
self.assertEqual(e.emails, ("anna@example.org", "a.b@example.org"))
self.assertEqual(e.feature_ids, (1, 2, 3))
self.assertEqual(e.titel, "Dr.")
def test_andere_anschrift_eigener_treffer(self):
"""Prüft, dass dieselbe Person mit anderer Anschrift ein eigener Treffer bleibt, sortiert nach Wohnort."""
layer = _Layer([
_zeile("Beispiel", "Anna", ort="Zettlitz"),
_zeile("Beispiel", "Anna", ort="Altdorf", plz="09999"),
])
treffer = es.suche_eigentuemer(layer, "Beispiel", "Anna")
self.assertEqual([e.wohnort for e in treffer], ["Altdorf", "Zettlitz"])
def test_kennzeichen_verstorben(self):
"""Prüft, dass „verstorben“ beim Exportwert 1 (auch als Zahl gelesen) gesetzt ist und bei 0, Leerem und NULL nicht."""
ja = ["1", 1]
nein = ["0", 0, "", None, _Null()]
zeilen = [_zeile(f"Ja{i}", "Test", PER_VERSTORBEN=w) for i, w in enumerate(ja)]
zeilen += [_zeile(f"Nein{i}", "Test", PER_VERSTORBEN=w) for i, w in enumerate(nein)]
for e in es.suche_eigentuemer(_Layer(zeilen), "", "Test"):
erwartet = e.nachname.startswith("Ja")
with self.subTest(name=e.nachname):
self.assertEqual(e.verstorben, erwartet)
self.assertEqual(e.hinweise, "verstorben" if erwartet else "")
def test_rechtsperson_als_rohwert(self):
"""Prüft, dass PER_RECHTSPERSON unverändert angezeigt wird (0, 1, 5, 9; noch nicht entschlüsselt) und abweichende Werte einer Person gesammelt werden."""
layer = _Layer([
_zeile("Beispiel", "Anna", PER_RECHTSPERSON="0"),
_zeile("Gemeinde Musterstadt", "", strasse="Markt 1", PER_RECHTSPERSON=5),
_zeile("Muster", "Bernd", PER_RECHTSPERSON="1"),
_zeile("Muster", "Bernd", PER_RECHTSPERSON="9"),
_zeile("Ohnewert", "Clara", PER_RECHTSPERSON=_Null()),
])
werte = {e.nachname: e.rechtsperson_text for e in es.suche_eigentuemer(layer, "e", "")}
self.assertEqual(werte, {"Beispiel": "0", "Gemeinde Musterstadt": "5", "Muster": "1, 9", "Ohnewert": ""})
def test_plz_mit_fuehrender_null(self):
"""Prüft, dass eine von QGIS als Zahl gelesene PLZ wieder fünfstellig mit führender Null erscheint und Zeilen dadurch zusammenfinden."""
layer = _Layer([
_zeile("Beispiel", "Anna", plz=1067),
_zeile("Beispiel", "Anna", plz="01067"),
_zeile("Muster", "Bernd", plz="1067.0"),
])
treffer = es.suche_eigentuemer(layer, "e", "")
self.assertEqual([(e.nachname, e.plz) for e in treffer], [("Beispiel", "01067"), ("Muster", "01067")])
def test_suche_gibt_nichts_aus(self):
"""Prüft, dass die Suche keine Personendaten auf die Konsole schreibt (Datenschutz)."""
ausgabe = io.StringIO()
with contextlib.redirect_stdout(ausgabe), contextlib.redirect_stderr(ausgabe):
es.suche_eigentuemer(_Layer([_zeile("Beispiel", "Anna")]), "Beispiel", "")
self.assertEqual(ausgabe.getvalue(), "")
class TestAnzeigeformen(unittest.TestCase):
def test_name_und_anschrift(self):
"""Prüft, dass Name mit Titel und einzeilige Anschrift mit Ortsteil lesbar zusammengesetzt werden."""
e = Eigentuemer(titel="Dr.", vorname="Anna", nachname="Beispiel", strasse="Hauptstraße 1",
plz="01234", wohnort="Musterstadt", ortsteil="Unterdorf")
self.assertEqual(e.name, "Beispiel, Dr. Anna")
self.assertEqual(e.anschrift, "Hauptstraße 1, 01234 Musterstadt, OT Unterdorf")
self.assertEqual(Eigentuemer(nachname="Beispiel", ortsteil="OT Unterdorf").anschrift, "OT Unterdorf")
self.assertEqual(Eigentuemer(nachname="Gemeinde Musterstadt").name, "Gemeinde Musterstadt")
if __name__ == "__main__":
unittest.main()
+85 -1
View File
@@ -1,4 +1,7 @@
"""Tests für den Tab „Eigentümer“ – Flurstückssuche (#56)."""
"""Tests für den Tab „Eigentümer“ – Flurstückssuche und Eigentümersuche (#56).
Alle Namen und Anschriften sind erfunden.
"""
import sys
import types
@@ -21,6 +24,9 @@ from sn_verfahrensgebiet.functions.flurstueck_suche import (
KeinFlurstueckslayer,
SuchErgebnis,
)
from sn_verfahrensgebiet.functions.eigentuemer_suche import Eigentuemer, EigentuemerSucheFehler
from sn_verfahrensgebiet.ui import eigentuemer_treffer as treffer_modul
from sn_verfahrensgebiet.ui.eigentuemer_treffer import EigentuemerTrefferFenster, trefferzeile
def _tab(gemarkung="0268", flurstueck="562"):
@@ -86,6 +92,84 @@ class TestFlurstueckSuchen(unittest.TestCase):
self.assertIn("1 Flurstück", erfolg.call_args[0][1])
def _eig_tab(nachname="Beispiel", vorname="", layer_id="csv1"):
tab = EigentuemerTab.__new__(EigentuemerTab)
tab.nachname_edit = types.SimpleNamespace(text=lambda: nachname)
tab.vorname_edit = types.SimpleNamespace(text=lambda: vorname)
tab.eigentuemer_layer_combo = MagicMock()
tab.eigentuemer_layer_combo.currentData.return_value = layer_id
tab._trefferfenster = None
return tab
_ANNA = Eigentuemer(vorname="Anna", nachname="Beispiel", strasse="Hauptstraße 1", plz="01234",
wohnort="Musterstadt", emails=("anna@example.org",), verstorben=True,
rechtsperson=("0",))
@patch.object(et, "info")
@patch.object(et, "warning")
class TestEigentuemerSuchen(unittest.TestCase):
def test_ohne_eigentuemerdatei_warnung(self, warnung, hinweis):
"""Prüft, dass ohne gewählte Eigentümerdatei die Liste aufgefrischt und dann auf das Laden der CSV hingewiesen wird."""
tab = _eig_tab(layer_id=None)
with patch.object(et, "hole_layer", return_value=None), patch.object(et, "suche_eigentuemer") as suche:
tab._on_eigentuemer_suchen()
tab.eigentuemer_layer_combo.aktualisieren.assert_called_once()
warnung.assert_called_once()
self.assertIn("CSV", warnung.call_args[0][1])
suche.assert_not_called()
def test_suchfehler_als_warnung(self, warnung, hinweis):
"""Prüft, dass ein Fehler der Suche (z. B. leere Eingabe) als Warnung erscheint."""
with patch.object(et, "hole_layer", return_value=object()), patch.object(et, "suche_eigentuemer", side_effect=EigentuemerSucheFehler("Bitte eingeben")):
_eig_tab()._on_eigentuemer_suchen()
warnung.assert_called_once_with("Eigentümersuche", "Bitte eingeben")
def test_kein_treffer_ohne_namen_in_meldung(self, warnung, hinweis):
"""Prüft, dass ohne Treffer ein Hinweis erscheint, der den gesuchten Namen nicht wiederholt (Datenschutz)."""
with patch.object(et, "hole_layer", return_value=object()), patch.object(et, "suche_eigentuemer", return_value=[]), patch.object(et, "EigentuemerTrefferFenster") as fenster:
_eig_tab(nachname="Beispiel")._on_eigentuemer_suchen()
hinweis.assert_called_once()
self.assertNotIn("Beispiel", hinweis.call_args[0][1])
fenster.assert_not_called()
def test_treffer_oeffnen_trefferliste_und_ersetzen_die_alte(self, warnung, hinweis):
"""Prüft, dass Treffer in einer neuen Trefferliste erscheinen, eine offene alte Liste geschlossen wird und die Eingaben an die Suche gehen."""
layer = object()
tab = _eig_tab(nachname="Beisp", vorname="An")
altes_fenster = MagicMock()
tab._trefferfenster = altes_fenster
with patch.object(et, "hole_layer", return_value=layer), patch.object(et, "suche_eigentuemer", return_value=[_ANNA]) as suche, patch.object(et, "EigentuemerTrefferFenster") as fenster:
tab._on_eigentuemer_suchen()
suche.assert_called_once_with(layer, "Beisp", "An")
altes_fenster.close.assert_called_once()
fenster.assert_called_once_with([_ANNA], tab)
fenster.return_value.show.assert_called_once()
self.assertIs(tab._trefferfenster, fenster.return_value)
class TestTrefferliste(unittest.TestCase):
def test_trefferzeile_mit_hinweis(self):
"""Prüft, dass die Trefferliste Name, Anschrift, E-Mail, den Rohwert von PER_RECHTSPERSON und das Kennzeichen „verstorben“ zeigt."""
self.assertEqual(
trefferzeile(_ANNA),
("Beispiel, Anna", "Hauptstraße 1, 01234 Musterstadt", "anna@example.org", "0", "verstorben"),
)
def test_ausgewaehlte_eigentuemer(self):
"""Prüft, dass das Trefferfenster die in der Tabelle gewählten Einträge zurückgibt."""
bernd = Eigentuemer(vorname="Bernd", nachname="Muster")
fenster = EigentuemerTrefferFenster.__new__(EigentuemerTrefferFenster)
fenster.treffer = [_ANNA, bernd]
fenster.tabelle = object()
with patch.object(treffer_modul, "ausgewaehlte_indizes", return_value=[1]) as indizes:
self.assertEqual(fenster.ausgewaehlte_eigentuemer(), [bernd])
indizes.assert_called_once_with(fenster.tabelle)
class TestDockWidgetTabs(unittest.TestCase):
def test_tab_ist_im_dockwidget_eingetragen(self):
+44
View File
@@ -0,0 +1,44 @@
"""Tests für die gemeinsame Auswahltabelle in verfahrensgebiet_dialoge (#56)."""
import types
import unittest
from sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge import (
ausgewaehlte_indizes,
zeilen_sichtbar,
)
class _Item:
def __init__(self, row, index):
self._row = row
self._index = index
def row(self):
return self._row
def data(self, _rolle):
return self._index
class TestAuswahltabelle(unittest.TestCase):
def test_suchfilter_ueber_alle_spalten(self):
"""Prüft, dass der Suchfilter Teilstrings in jeder Spalte ohne Groß-/Kleinschreibung findet und leer alles zeigt."""
zeilen = [["14612", "Verfahren Nord"], ["14613", "Süd"], ["14700", None]]
self.assertEqual(zeilen_sichtbar(zeilen, "nord"), [True, False, False])
self.assertEqual(zeilen_sichtbar(zeilen, " 146 "), [True, True, False])
self.assertEqual(zeilen_sichtbar(zeilen, ""), [True, True, True])
def test_auswahl_nach_umsortieren(self):
"""Prüft, dass die Auswahl über den gespeicherten Index auf die ursprünglichen Zeilen zurückgeführt wird, auch wenn die Tabelle umsortiert ist."""
# Tabelle zeigt Zeile 0 → Index 2, Zeile 1 → Index 0; beide Spalten je Zeile ausgewählt
tabelle = types.SimpleNamespace(
selectedItems=lambda: [_Item(0, 2), _Item(0, 2), _Item(1, 0), _Item(1, 0)],
item=lambda row, col: _Item(row, {0: 2, 1: 0}[row]),
)
self.assertEqual(ausgewaehlte_indizes(tabelle), [0, 2])
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
"""sn_verfahrensgebiet/ui/eigentuemer_treffer.py – Trefferliste der Eigentümersuche (#56).
Nicht modales Fenster für den Bearbeiter. Es zeigt die zusammengefassten
Treffer mit dem Wert von ``PER_RECHTSPERSON`` (noch nicht entschlüsselt) und dem
Kennzeichen „verstorben“ und erlaubt die Mehrfachauswahl. Die Tabelle kommt aus
:func:`~sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge.baue_auswahltabelle`,
dieselbe wie bei „Verfahrensgebiete aus Landkreis laden“.
"""
from typing import List, Sequence, Tuple
from sn_basis.functions.qt_wrapper import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
from sn_verfahrensgebiet.functions.eigentuemer_suche import Eigentuemer
from sn_verfahrensgebiet.functions.verfahrensgebiet_dialoge import (
baue_auswahltabelle,
ausgewaehlte_indizes,
)
SPALTENKOEPFE = ["Name", "Anschrift", "E-Mail", "Rechtsperson", "Hinweis"]
def trefferzeile(eigentuemer: Eigentuemer) -> Tuple[str, str, str, str, str]:
"""Spaltentexte eines Treffers in der Reihenfolge von :data:`SPALTENKOEPFE`."""
return (
eigentuemer.name,
eigentuemer.anschrift,
", ".join(eigentuemer.emails),
eigentuemer.rechtsperson_text,
eigentuemer.hinweise,
)
class EigentuemerTrefferFenster(QDialog):
"""Trefferliste der Eigentümersuche; bleibt offen, während weitergesucht wird."""
def __init__(self, treffer: Sequence[Eigentuemer], parent=None):
super().__init__(parent)
self.treffer: List[Eigentuemer] = list(treffer)
self.setWindowTitle("Eigentümersuche – Treffer")
self.setModal(False)
layout = QVBoxLayout(self)
anzahl = len(self.treffer)
layout.addWidget(QLabel("1 Eintrag gefunden." if anzahl == 1 else f"{anzahl} Einträge gefunden."))
self.tabelle = baue_auswahltabelle(
layout,
SPALTENKOEPFE,
[trefferzeile(e) for e in self.treffer],
platzhalter="Name, Ort oder Hinweis …",
)
self.tabelle.resizeColumnsToContents()
if anzahl == 1:
self.tabelle.selectRow(0)
knoepfe = QHBoxLayout()
knoepfe.addStretch()
self.btn_schliessen = QPushButton("Schließen")
self.btn_schliessen.clicked.connect(self.close)
knoepfe.addWidget(self.btn_schliessen)
layout.addLayout(knoepfe)
self.resize(760, 420)
def ausgewaehlte_eigentuemer(self) -> List[Eigentuemer]:
"""Die in der Tabelle ausgewählten Treffer, in der Reihenfolge der Trefferliste."""
return [self.treffer[i] for i in ausgewaehlte_indizes(self.tabelle)]
+112 -5
View File
@@ -1,12 +1,17 @@
"""sn_verfahrensgebiet/ui/tabs/eigentuemer_tab.py – Tab „Eigentümer“ (#56).
Enthält die Flurstückssuche: Gemarkung (Name oder Nummer) und Flurstücksnummer
eingeben, „Suchen“ wählt die Treffer im Layer „Flurstücke“ aus und zoomt hin.
Die Suchlogik liegt in :mod:`sn_verfahrensgebiet.functions.flurstueck_suche`.
Zwei Bereiche:
- **Flurstückssuche:** Gemarkung (Name oder Nummer) und Flurstücksnummer
eingeben, „Suchen“ wählt die Treffer im Layer „Flurstücke“ aus und zoomt hin.
Logik: :mod:`sn_verfahrensgebiet.functions.flurstueck_suche`.
- **Eigentümersuche:** Eigentümerdatei (CSV-Layer) wählen, Name und/oder Vorname
eingeben, „Suchen“ öffnet die Trefferliste.
Logik: :mod:`sn_verfahrensgebiet.functions.eigentuemer_suche`.
"""
from sn_basis.functions.qt_wrapper import (
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QLineEdit, QPushButton, Qt,
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QLineEdit, QPushButton, QComboBox, Qt,
)
from sn_basis.functions.qgisui_wrapper import iface
from sn_basis.functions.dialog_wrapper import show_info_dialog
@@ -17,12 +22,41 @@ from sn_verfahrensgebiet.functions.flurstueck_suche import (
FlurstueckSucheFehler,
KeinFlurstueckslayer,
)
from sn_verfahrensgebiet.functions.eigentuemer_suche import (
eigentuemerlayer_im_projekt,
hole_layer,
suche_eigentuemer,
EigentuemerSucheFehler,
)
from sn_verfahrensgebiet.ui.eigentuemer_treffer import EigentuemerTrefferFenster
_TITEL_SUCHE = "Flurstückssuche"
_TITEL_EIGENTUEMER = "Eigentümersuche"
class EigentuemerLayerAuswahl(QComboBox):
"""Auswahl der Eigentümerdatei; zeigt nur Layer mit den Spalten Nachname und Vorname.
Die Liste wird beim Aufklappen neu aufgebaut, damit eine gerade geladene CSV
ohne Projektsignale erscheint.
"""
def aktualisieren(self) -> None:
"""Baut die Liste neu auf und behält die bisherige Auswahl, solange es den Layer noch gibt."""
bisher = self.currentData()
self.clear()
for layer in eigentuemerlayer_im_projekt():
self.addItem(layer.name(), layer.id())
index = self.findData(bisher) if bisher else -1
self.setCurrentIndex(index if index >= 0 else 0)
def showPopup(self) -> None:
self.aktualisieren()
super().showPopup()
class EigentuemerTab(QWidget):
"""Tab für Flurstückssuche und (später) Eigentümerabgleich.
"""Tab für Flurstückssuche und Eigentümersuche.
Attributes:
tab_title: Anzeigetitel des Tabs ("Eigentümer")
@@ -63,6 +97,43 @@ class EigentuemerTab(QWidget):
self.gemarkung_edit.returnPressed.connect(self._on_flurstueck_suchen)
self.flurstueck_edit.returnPressed.connect(self._on_flurstueck_suchen)
titel_eigentuemer = QLabel("<b>Eigentümersuche</b>")
titel_eigentuemer.setStyleSheet("margin-top: 12px;")
layout.addWidget(titel_eigentuemer)
raster_eig = QGridLayout()
raster_eig.addWidget(QLabel("Eigentümerdatei:"), 0, 0)
self.eigentuemer_layer_combo = EigentuemerLayerAuswahl()
self.eigentuemer_layer_combo.setToolTip(
"CSV-Layer mit den Spalten Nachname und Vorname (Serienbriefexport). "
"Die CSV von Hand ins Projekt laden; die Liste wird beim Aufklappen aktualisiert."
)
raster_eig.addWidget(self.eigentuemer_layer_combo, 0, 1)
raster_eig.addWidget(QLabel("Name:"), 1, 0)
self.nachname_edit = QLineEdit()
raster_eig.addWidget(self.nachname_edit, 1, 1)
raster_eig.addWidget(QLabel("Vorname:"), 2, 0)
self.vorname_edit = QLineEdit()
raster_eig.addWidget(self.vorname_edit, 2, 1)
layout.addLayout(raster_eig)
zeile_eig_suchen = QHBoxLayout()
zeile_eig_suchen.addStretch()
self.btn_eigentuemer_suchen = QPushButton("Suchen")
self.btn_eigentuemer_suchen.setToolTip(
"Findet Einträge, deren Name und Vorname die Eingaben enthalten. "
"Gleiche Personen mit gleicher Anschrift werden zusammengefasst."
)
zeile_eig_suchen.addWidget(self.btn_eigentuemer_suchen)
layout.addLayout(zeile_eig_suchen)
self.btn_eigentuemer_suchen.clicked.connect(self._on_eigentuemer_suchen)
self.nachname_edit.returnPressed.connect(self._on_eigentuemer_suchen)
self.vorname_edit.returnPressed.connect(self._on_eigentuemer_suchen)
self._trefferfenster = None
self.eigentuemer_layer_combo.aktualisieren()
self.setLayout(layout)
def _on_flurstueck_suchen(self) -> None:
@@ -102,3 +173,39 @@ class EigentuemerTab(QWidget):
return True
except Exception:
return False
def _on_eigentuemer_suchen(self) -> None:
"""Sucht in der gewählten Eigentümerdatei und zeigt die Trefferliste.
Meldungen enthalten bewusst keine Namen (Datenschutz).
"""
layer = hole_layer(self.eigentuemer_layer_combo.currentData())
if layer is None:
self.eigentuemer_layer_combo.aktualisieren()
layer = hole_layer(self.eigentuemer_layer_combo.currentData())
if layer is None:
warning(
_TITEL_EIGENTUEMER,
"Keine Eigentümerdatei gewählt. Bitte den Serienbriefexport (CSV) "
"als Layer laden und hier auswählen.",
)
return
try:
treffer = suche_eigentuemer(layer, self.nachname_edit.text(), self.vorname_edit.text())
except EigentuemerSucheFehler as exc:
warning(_TITEL_EIGENTUEMER, str(exc))
return
if not treffer:
info(_TITEL_EIGENTUEMER, "Keine passenden Einträge gefunden.")
return
self._zeige_treffer(treffer)
def _zeige_treffer(self, treffer) -> None:
"""Ersetzt eine offene Trefferliste durch die neue."""
alt = getattr(self, "_trefferfenster", None)
if alt is not None:
alt.close()
self._trefferfenster = EigentuemerTrefferFenster(treffer, self)
self._trefferfenster.show()