From 29719a8a99700d1e3643dae4f9bf90eed153af15 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 6 Oct 2026 13:04:16 +0200 Subject: [PATCH] =?UTF-8?q?Eigent=C3=BCmersuche=20mit=20Trefferliste=20im?= =?UTF-8?q?=20Tab=20=E2=80=9EEigent=C3=BCmer=E2=80=9C=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigentümerdatei (CSV-Layer mit Nachname/Vorname) wählen, Name und/oder Vorname eingeben: Eine nicht modale Trefferliste zeigt Name, Anschrift, E-Mail und die Kennzeichen „verstorben“/„Rechtsperson“. Gleiche Personen mit gleicher Anschrift werden zusammengefasst; Groß-/Kleinschreibung und Umlautschreibweise spielen keine Rolle. Von QGIS als Zahl gelesene PLZ bekommen ihre führende Null zurück. Meldungen enthalten keine Namen. Die Auswahltabelle aus „Verfahrensgebiete aus Landkreis laden“ ist als baue_auswahltabelle herausgelöst und wird von der Trefferliste mitgenutzt. Zweiter Schritt von #56; Anzeigefenster und Korrekturen folgen. Co-Authored-By: Claude Opus 5.5 --- changelog.txt | 2 + functions/eigentuemer_suche.py | 225 +++++++++++++++++++++++++ functions/verfahrensgebiet_dialoge.py | 158 +++++++++++------ tests/test_eigentuemer_suche.py | 186 ++++++++++++++++++++ tests/test_eigentuemer_tab.py | 85 +++++++++- tests/test_verfahrensgebiet_dialoge.py | 44 +++++ ui/eigentuemer_treffer.py | 67 ++++++++ ui/tabs/eigentuemer_tab.py | 117 ++++++++++++- 8 files changed, 823 insertions(+), 61 deletions(-) create mode 100644 functions/eigentuemer_suche.py create mode 100644 tests/test_eigentuemer_suche.py create mode 100644 tests/test_verfahrensgebiet_dialoge.py create mode 100644 ui/eigentuemer_treffer.py diff --git a/changelog.txt b/changelog.txt index 048480e..ebdd89c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -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 und die Kennzeichen „verstorben“/„Rechtsperson“. 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) diff --git a/functions/eigentuemer_suche.py b/functions/eigentuemer_suche.py new file mode 100644 index 0000000..3bdbd99 --- /dev/null +++ b/functions/eigentuemer_suche.py @@ -0,0 +1,225 @@ +"""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: bool = False + 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 ", ".join(t for t, gesetzt in (("verstorben", self.verstorben), + ("Rechtsperson", self.rechtsperson)) if gesetzt) + + +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": False, + }) + 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"])): + 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"]) + gruppe["rechtsperson"] = gruppe["rechtsperson"] or _ist_gesetzt(werte["rechtsperson"]) + + 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=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) diff --git a/functions/verfahrensgebiet_dialoge.py b/functions/verfahrensgebiet_dialoge.py index e561885..429c613 100644 --- a/functions/verfahrensgebiet_dialoge.py +++ b/functions/verfahrensgebiet_dialoge.py @@ -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("Mehrfachauswahl mit Strg + Umschalt")) + 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("Mehrfachauswahl mit Strg + Umschalt") - layout.addWidget(mehrfachauswahl_hinweis) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(dialog.accept) diff --git a/tests/test_eigentuemer_suche.py b/tests/test_eigentuemer_suche.py new file mode 100644 index 0000000..b311324 --- /dev/null +++ b/tests/test_eigentuemer_suche.py @@ -0,0 +1,186 @@ +"""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_und_rechtsperson(self): + """Prüft, dass „verstorben“ und „Rechtsperson“ aus üblichen Ja-Werten erkannt werden und Nein-Werte, Leeres und NULL nicht zählen.""" + ja = ["J", "ja", "1", "true", "X"] + nein = ["", "0", "N", "nein", "false", None, _Null()] + zeilen = [_zeile(f"Ja{i}", "Test", PER_VERSTORBEN=w, PER_RECHTSPERSON=w) for i, w in enumerate(ja)] + zeilen += [_zeile(f"Nein{i}", "Test", PER_VERSTORBEN=w, PER_RECHTSPERSON=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.rechtsperson, erwartet) + self.assertEqual(e.hinweise, "verstorben, Rechtsperson" if erwartet else "") + + 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() diff --git a/tests/test_eigentuemer_tab.py b/tests/test_eigentuemer_tab.py index 48164a9..b9523b6 100644 --- a/tests/test_eigentuemer_tab.py +++ b/tests/test_eigentuemer_tab.py @@ -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,83 @@ 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) + + +@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 und die Kennzeichen „verstorben“/„Rechtsperson“ zeigt.""" + self.assertEqual( + trefferzeile(_ANNA), + ("Beispiel, Anna", "Hauptstraße 1, 01234 Musterstadt", "anna@example.org", "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): diff --git a/tests/test_verfahrensgebiet_dialoge.py b/tests/test_verfahrensgebiet_dialoge.py new file mode 100644 index 0000000..f014bc3 --- /dev/null +++ b/tests/test_verfahrensgebiet_dialoge.py @@ -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() diff --git a/ui/eigentuemer_treffer.py b/ui/eigentuemer_treffer.py new file mode 100644 index 0000000..c75ce18 --- /dev/null +++ b/ui/eigentuemer_treffer.py @@ -0,0 +1,67 @@ +"""sn_verfahrensgebiet/ui/eigentuemer_treffer.py – Trefferliste der Eigentümersuche (#56). + +Nicht modales Fenster für den Bearbeiter. Es zeigt die zusammengefassten +Treffer mit den Kennzeichen „verstorben“ und „Rechtsperson“ 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", "Hinweis"] + + +def trefferzeile(eigentuemer: Eigentuemer) -> Tuple[str, str, str, str]: + """Spaltentexte eines Treffers in der Reihenfolge von :data:`SPALTENKOEPFE`.""" + return ( + eigentuemer.name, + eigentuemer.anschrift, + ", ".join(eigentuemer.emails), + 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)] diff --git a/ui/tabs/eigentuemer_tab.py b/ui/tabs/eigentuemer_tab.py index 533e49f..e2e1ab6 100644 --- a/ui/tabs/eigentuemer_tab.py +++ b/ui/tabs/eigentuemer_tab.py @@ -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("Eigentümersuche") + 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()