Eigentümersuche mit Trefferliste im Tab „Eigentümer“ (#56)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user