From c8b380b5d3895092dd4857174196d7c8f77b5189 Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 6 Oct 2026 12:44:45 +0200 Subject: [PATCH] =?UTF-8?q?Tab=20=E2=80=9EEigent=C3=BCmer=E2=80=9C=20mit?= =?UTF-8?q?=20Flurst=C3=BCckssuche=20(#56)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemarkung (Name oder Nummer) und Flurstücksnummer eingeben, die Treffer werden im Layer „Flurstücke“ ausgewählt, angezoomt und kurz hervorgehoben. Verglichen wird das flurstueckskennzeichen, Gemarkungsnamen werden über den Layer „Gemarkungen“ aufgelöst. Ohne Layer „Flurstücke“ weist ein Dialog darauf hin, zuerst Verfahrensgebiet und Flurstücke zu laden. Erster Schritt von #56; Eigentümersuche und Anzeigefenster folgen. Co-Authored-By: Claude Opus 5.5 --- changelog.txt | 1 + functions/flurstueck_suche.py | 175 +++++++++++++++++++++++++++++++++ tests/test_eigentuemer_tab.py | 101 +++++++++++++++++++ tests/test_flurstueck_suche.py | 146 +++++++++++++++++++++++++++ ui/dockwidget.py | 7 +- ui/tabs/eigentuemer_tab.py | 104 ++++++++++++++++++++ 6 files changed, 531 insertions(+), 3 deletions(-) create mode 100644 functions/flurstueck_suche.py create mode 100644 tests/test_eigentuemer_tab.py create mode 100644 tests/test_flurstueck_suche.py create mode 100644 ui/tabs/eigentuemer_tab.py diff --git a/changelog.txt b/changelog.txt index eeb79e7..048480e 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,4 @@ +- 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) - Intern: Tests von TestFlurstueckAlkis an die seit längerem geänderte Ladelogik angeglichen (Mocks zeigten noch auf die alte Ein-Query-/Zwei-Knopf-Variante), bbox-Dokumentation in flurstueck_alkis.py korrigiert (bbox wird nicht mehr ausgewertet) – keine Verhaltensänderung (Test-Audit #16) diff --git a/functions/flurstueck_suche.py b/functions/flurstueck_suche.py new file mode 100644 index 0000000..d32d10a --- /dev/null +++ b/functions/flurstueck_suche.py @@ -0,0 +1,175 @@ +"""sn_verfahrensgebiet/functions/flurstueck_suche.py – Flurstückssuche im Layer „Flurstücke“ (#56). + +Findet ein Flurstück über Gemarkung und Flurstücksnummer im Layer „Flurstücke“, +den der Bearbeitungs-Tab aus ALKIS lädt. Verglichen wird das +``flurstueckskennzeichen``, nicht die von QGIS abgeflachten Einzelfelder des +WFS – deren Namen hängen vom Provider ab, das Kennzeichen nicht. + +Aufbau des Kennzeichens (20 Zeichen, unbesetzte Teile füllt ALKIS mit ``_``):: + + 14 0268 ___ 00562 ____ 00 + │ │ │ │ │ └ Flurstücksfolge + │ │ │ │ └ Nenner + │ │ │ └ Zähler + │ │ └ Flur (in Sachsen unbesetzt) + │ └ Gemarkungsnummer + └ Land + +Die Gemarkung darf als Nummer oder als Name eingegeben werden. Namen werden +über den Layer „Gemarkungen“ (vereinfachter ALKIS-Dienst, Feld ``ave_name``) +in Nummern übersetzt. Das Modul zeigt keine Dialoge; Fehler kommen als +:class:`FlurstueckSucheFehler` mit einer Meldung für den Nutzer zurück. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import List, Optional, Tuple + +from sn_basis.functions import QgsProject + +from sn_verfahrensgebiet.functions.flurstueck_alkis import ( + FELD_FLURSTUECKSKENNZEICHEN, + FLST_LAYER_NAME, +) +from sn_verfahrensgebiet.functions.gemarkung_alkis import _extract_schluessel_und_name + +GEMARKUNGEN_LAYER_NAME = "Gemarkungen" +MELDUNG_KEIN_FLURSTUECKSLAYER = ( + "Zuerst Verfahrensgebiet und zugehörige Flurstücke aus ALKIS laden." +) + +_NUMMER_MUSTER = re.compile(r"^\s*(\d+)\s*(?:/\s*([0-9A-Za-z]+)\s*)?$") + + +class FlurstueckSucheFehler(Exception): + """Eingabe- oder Suchfehler; der Text ist für den Nutzer bestimmt.""" + + +class KeinFlurstueckslayer(FlurstueckSucheFehler): + """Im Projekt gibt es keinen Layer „Flurstücke“.""" + + def __init__(self): + super().__init__(MELDUNG_KEIN_FLURSTUECKSLAYER) + + +@dataclass +class SuchErgebnis: + """Treffer einer Flurstückssuche im Layer ``layer``.""" + + layer: object + feature_ids: List[int] = field(default_factory=list) + + +def _normiere_nenner(wert: str) -> str: + return (wert or "").strip().strip("_").lstrip("0").lower() + + +def zerlege_flurstuecksnummer(text: str) -> Tuple[str, str]: + """Zerlegt ``"562"`` in ``("562", "")`` und ``"12/3"`` in ``("12", "3")``. + + Führende Nullen fallen weg, damit ``"0562"`` und ``"562"`` gleich sind. + """ + treffer = _NUMMER_MUSTER.match(text or "") + if not treffer: + raise FlurstueckSucheFehler( + "Die Flurstücksnummer bitte als Zahl oder als Zähler/Nenner eingeben, " + "z. B. 562 oder 12/3." + ) + zaehler = treffer.group(1).lstrip("0") or "0" + return zaehler, _normiere_nenner(treffer.group(2) or "") + + +def zerlege_kennzeichen(kennzeichen) -> Optional[Tuple[str, str, str]]: + """Liefert ``(gemarkungsnummer, zaehler, nenner)`` eines Flurstückskennzeichens. + + ``None``, wenn das Kennzeichen zu kurz ist oder keine Gemarkung bzw. keinen + Zähler enthält. + """ + kz = str(kennzeichen or "").strip() + if len(kz) < 18: + return None + gemarkung = kz[2:6] + zaehler = kz[9:14].strip("_").lstrip("0") + if not gemarkung.isdigit() or not zaehler: + return None + return gemarkung, zaehler, _normiere_nenner(kz[14:18]) + + +def gemarkungsnummern(eingabe: str, gemarkungen_layer=None) -> List[str]: + """Übersetzt die Gemarkungseingabe in 4-stellige Gemarkungsnummern. + + Ziffern gelten als Nummer: bis zu vier Stellen werden mit Nullen aufgefüllt, + bei längeren Schlüsseln (z. B. ``140268``) zählen die letzten vier Stellen. + Ein Name wird ohne Rücksicht auf Groß- und Kleinschreibung im Layer + „Gemarkungen“ gesucht. Gleichnamige Gemarkungen liefern mehrere Nummern. + """ + text = (eingabe or "").strip() + if not text: + raise FlurstueckSucheFehler("Bitte eine Gemarkung eingeben (Name oder Nummer).") + if text.isdigit(): + return [text[-4:].zfill(4)] + + if gemarkungen_layer is None: + raise FlurstueckSucheFehler( + f"Den Gemarkungsnamen „{text}“ kann das Plugin nur mit dem Layer " + f"„{GEMARKUNGEN_LAYER_NAME}“ auflösen. Bitte die Gemarkungsnummer " + "eingeben oder die Gemarkungen laden." + ) + + gesucht = text.casefold() + nummern = set() + for feature in gemarkungen_layer.getFeatures(): + nummer, name = _extract_schluessel_und_name(feature) + if nummer and name.casefold() == gesucht: + nummern.add(nummer.zfill(4)) + if not nummern: + raise FlurstueckSucheFehler( + f"Im Layer „{GEMARKUNGEN_LAYER_NAME}“ gibt es keine Gemarkung „{text}“." + ) + return sorted(nummern) + + +def finde_flurstuecke(layer, nummern: List[str], zaehler: str, nenner: str) -> List[int]: + """Feature-IDs der Flurstücke mit passender Gemarkung, Zähler und Nenner. + + Ohne Nenner passen alle Flurstücke mit diesem Zähler (``562``, ``562/1`` …). + """ + if layer.fields().indexFromName(FELD_FLURSTUECKSKENNZEICHEN) < 0: + raise FlurstueckSucheFehler( + f"Der Layer „{FLST_LAYER_NAME}“ hat kein Feld „{FELD_FLURSTUECKSKENNZEICHEN}“." + ) + gesucht = set(nummern) + ids: List[int] = [] + for feature in layer.getFeatures(): + teile = zerlege_kennzeichen(feature[FELD_FLURSTUECKSKENNZEICHEN]) + if teile is None: + continue + gemarkung, f_zaehler, f_nenner = teile + if gemarkung in gesucht and f_zaehler == zaehler and (not nenner or f_nenner == nenner): + ids.append(feature.id()) + return ids + + +def _hole_layer(name: str): + treffer = QgsProject.instance().mapLayersByName(name) + return treffer[0] if treffer else None + + +def flurstueck_suchen(gemarkung_text: str, nummer_text: str) -> SuchErgebnis: + """Sucht das Flurstück im Layer „Flurstücke“ des aktuellen Projekts. + + Raises + ------ + KeinFlurstueckslayer + Der Layer „Flurstücke“ fehlt. + FlurstueckSucheFehler + Die Eingabe ist unvollständig oder ungültig. + """ + flst_layer = _hole_layer(FLST_LAYER_NAME) + if flst_layer is None: + raise KeinFlurstueckslayer() + zaehler, nenner = zerlege_flurstuecksnummer(nummer_text) + nummern = gemarkungsnummern(gemarkung_text, _hole_layer(GEMARKUNGEN_LAYER_NAME)) + return SuchErgebnis(flst_layer, finde_flurstuecke(flst_layer, nummern, zaehler, nenner)) diff --git a/tests/test_eigentuemer_tab.py b/tests/test_eigentuemer_tab.py new file mode 100644 index 0000000..48164a9 --- /dev/null +++ b/tests/test_eigentuemer_tab.py @@ -0,0 +1,101 @@ +"""Tests für den Tab „Eigentümer“ – Flurstückssuche (#56).""" + +import sys +import types +import unittest +from unittest.mock import MagicMock, patch + +if "qgis" not in sys.modules: + qgis_mod = types.ModuleType("qgis") + qgis_utils = types.ModuleType("qgis.utils") + qgis_utils.iface = types.SimpleNamespace() + qgis_utils.plugins = {} + qgis_mod.utils = qgis_utils + sys.modules["qgis"] = qgis_mod + sys.modules["qgis.utils"] = qgis_utils + +from sn_verfahrensgebiet.ui.tabs import eigentuemer_tab as et +from sn_verfahrensgebiet.ui.tabs.eigentuemer_tab import EigentuemerTab +from sn_verfahrensgebiet.functions.flurstueck_suche import ( + FlurstueckSucheFehler, + KeinFlurstueckslayer, + SuchErgebnis, +) + + +def _tab(gemarkung="0268", flurstueck="562"): + tab = EigentuemerTab.__new__(EigentuemerTab) + tab.gemarkung_edit = types.SimpleNamespace(text=lambda: gemarkung) + tab.flurstueck_edit = types.SimpleNamespace(text=lambda: flurstueck) + return tab + + +@patch.object(et, "success") +@patch.object(et, "info") +@patch.object(et, "warning") +@patch.object(et, "show_info_dialog") +class TestFlurstueckSuchen(unittest.TestCase): + + def test_ohne_flurstueckslayer_erscheint_hinweisdialog(self, dialog, warnung, hinweis, erfolg): + """Prüft, dass ohne Layer „Flurstücke“ ein Dialog zum Laden von Verfahrensgebiet und Flurstücken erscheint.""" + with patch.object(et, "flurstueck_suchen", side_effect=KeinFlurstueckslayer()): + _tab()._on_flurstueck_suchen() + dialog.assert_called_once() + self.assertIn("Flurstücke aus ALKIS laden", dialog.call_args[0][1]) + warnung.assert_not_called() + erfolg.assert_not_called() + + def test_eingabefehler_als_warnung(self, dialog, warnung, hinweis, erfolg): + """Prüft, dass eine ungültige Eingabe als Warnung in der Meldungsleiste erscheint, nicht als Dialog.""" + with patch.object(et, "flurstueck_suchen", side_effect=FlurstueckSucheFehler("falsch")): + _tab()._on_flurstueck_suchen() + warnung.assert_called_once_with("Flurstückssuche", "falsch") + dialog.assert_not_called() + + def test_kein_treffer(self, dialog, warnung, hinweis, erfolg): + """Prüft, dass ohne Treffer ein Hinweis erscheint und keine Auswahl gesetzt wird.""" + layer = MagicMock() + with patch.object(et, "flurstueck_suchen", return_value=SuchErgebnis(layer, [])): + _tab(flurstueck="999")._on_flurstueck_suchen() + hinweis.assert_called_once() + self.assertIn("999", hinweis.call_args[0][1]) + layer.selectByIds.assert_not_called() + + def test_treffer_werden_ausgewaehlt_und_angezoomt(self, dialog, warnung, hinweis, erfolg): + """Prüft, dass Treffer im Flurstückslayer ausgewählt, angezoomt und gemeldet werden.""" + layer = MagicMock() + layer.name.return_value = "Flurstücke" + canvas = MagicMock() + with patch.object(et, "flurstueck_suchen", return_value=SuchErgebnis(layer, [1, 2])) as suche, \ + patch.object(et, "iface", types.SimpleNamespace(mapCanvas=lambda: canvas)): + _tab(gemarkung=" Beispielhausen ", flurstueck=" 562 ")._on_flurstueck_suchen() + suche.assert_called_once_with("Beispielhausen", "562") + layer.selectByIds.assert_called_once_with([1, 2]) + canvas.zoomToSelected.assert_called_once_with(layer) + canvas.flashFeatureIds.assert_called_once_with(layer, [1, 2]) + erfolg.assert_called_once() + self.assertIn("2 Flurstücke", erfolg.call_args[0][1]) + + def test_ohne_kartenansicht_bleibt_auswahl(self, dialog, warnung, hinweis, erfolg): + """Prüft, dass die Auswahl auch ohne Kartenansicht gesetzt und gemeldet wird.""" + layer = MagicMock() + with patch.object(et, "flurstueck_suchen", return_value=SuchErgebnis(layer, [7])), \ + patch.object(et, "iface", types.SimpleNamespace()): + _tab()._on_flurstueck_suchen() + layer.selectByIds.assert_called_once_with([7]) + self.assertIn("1 Flurstück", erfolg.call_args[0][1]) + + +class TestDockWidgetTabs(unittest.TestCase): + + def test_tab_ist_im_dockwidget_eingetragen(self): + """Prüft, dass der Tab „Eigentümer“ im DockWidget direkt nach dem Bearbeitungs-Tab steht.""" + from sn_verfahrensgebiet.ui.dockwidget import DockWidget + from sn_verfahrensgebiet.ui.tabs.working_tab import WorkingTab + + self.assertEqual(DockWidget.tabs[:2], [WorkingTab, EigentuemerTab]) + self.assertEqual(EigentuemerTab.tab_title, "Eigentümer") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_flurstueck_suche.py b/tests/test_flurstueck_suche.py new file mode 100644 index 0000000..cba8d9a --- /dev/null +++ b/tests/test_flurstueck_suche.py @@ -0,0 +1,146 @@ +"""Tests für die Flurstückssuche im Layer „Flurstücke“ (#56).""" + +import types +import unittest +from unittest.mock import patch + +from sn_verfahrensgebiet.functions import flurstueck_suche as fs + + +class _Feature(dict): + def __init__(self, fid, **attribute): + super().__init__(attribute) + self._fid = fid + + def id(self): + return self._fid + + +class _Layer: + def __init__(self, name, features, felder=("flurstueckskennzeichen",)): + self._name = name + self._features = features + self._felder = list(felder) + + def name(self): + return self._name + + def fields(self): + return types.SimpleNamespace( + indexFromName=lambda n: self._felder.index(n) if n in self._felder else -1 + ) + + def getFeatures(self): + return iter(self._features) + + +def _flurstuecke(): + return _Layer("Flurstücke", [ + _Feature(1, flurstueckskennzeichen="140268___00562____00"), + _Feature(2, flurstueckskennzeichen="140268___00562000100"), + _Feature(3, flurstueckskennzeichen="140268___00012000300"), + _Feature(4, flurstueckskennzeichen="140269___00562____00"), + _Feature(5, flurstueckskennzeichen="140268___00012000a00"), + _Feature(6, flurstueckskennzeichen=None), + ]) + + +def _gemarkungen(): + return _Layer("Gemarkungen", [ + _Feature(1, schluessel="140640268", ave_name="Beispielhausen"), + _Feature(2, schluessel="140640269", ave_name="Musterdorf"), + _Feature(3, schluessel="140650270", ave_name="Musterdorf"), + ], felder=("schluessel", "ave_name")) + + +def _projekt(*layer): + nach_name = {lyr.name(): lyr for lyr in layer} + projekt = types.SimpleNamespace( + mapLayersByName=lambda name: [nach_name[name]] if name in nach_name else [] + ) + return types.SimpleNamespace(instance=lambda: projekt) + + +class TestEingabe(unittest.TestCase): + + def test_flurstuecksnummer_mit_und_ohne_nenner(self): + """Prüft, dass Zähler und Nenner getrennt und führende Nullen entfernt werden.""" + self.assertEqual(fs.zerlege_flurstuecksnummer("562"), ("562", "")) + self.assertEqual(fs.zerlege_flurstuecksnummer(" 0012 / 03 "), ("12", "3")) + self.assertEqual(fs.zerlege_flurstuecksnummer("12/A"), ("12", "a")) + + def test_ungueltige_flurstuecksnummer(self): + """Prüft, dass leere oder unzulässige Flurstücksnummern abgelehnt werden.""" + for text in ("", "abc", "12/", "12/3/4", "12-3"): + with self.subTest(text=text): + with self.assertRaises(fs.FlurstueckSucheFehler): + fs.zerlege_flurstuecksnummer(text) + + def test_kennzeichen_zerlegen(self): + """Prüft, dass Gemarkung, Zähler und Nenner aus dem ALKIS-Kennzeichen gelesen werden.""" + self.assertEqual(fs.zerlege_kennzeichen("140268___00562____00"), ("0268", "562", "")) + self.assertEqual(fs.zerlege_kennzeichen("140268___00012000300"), ("0268", "12", "3")) + self.assertEqual(fs.zerlege_kennzeichen("140268___00012000a00"), ("0268", "12", "a")) + self.assertIsNone(fs.zerlege_kennzeichen("1402")) + self.assertIsNone(fs.zerlege_kennzeichen(None)) + + def test_gemarkungsnummer_aus_ziffern(self): + """Prüft, dass Ziffern als Gemarkungsnummer gelten, auch ohne führende Nullen oder als langer Schlüssel.""" + self.assertEqual(fs.gemarkungsnummern("268"), ["0268"]) + self.assertEqual(fs.gemarkungsnummern("140268"), ["0268"]) + + def test_gemarkungsname_ueber_gemarkungslayer(self): + """Prüft, dass ein Gemarkungsname ohne Rücksicht auf Großschreibung in alle passenden Nummern übersetzt wird.""" + self.assertEqual(fs.gemarkungsnummern("beispielhausen", _gemarkungen()), ["0268"]) + self.assertEqual(fs.gemarkungsnummern("Musterdorf", _gemarkungen()), ["0269", "0270"]) + + def test_gemarkungsname_ohne_layer_oder_unbekannt(self): + """Prüft, dass ein Name ohne Gemarkungslayer oder ohne Treffer eine verständliche Meldung ergibt.""" + with self.assertRaisesRegex(fs.FlurstueckSucheFehler, "Gemarkungsnummer"): + fs.gemarkungsnummern("Beispielhausen", None) + with self.assertRaisesRegex(fs.FlurstueckSucheFehler, "keine Gemarkung"): + fs.gemarkungsnummern("Nirgendwo", _gemarkungen()) + with self.assertRaises(fs.FlurstueckSucheFehler): + fs.gemarkungsnummern(" ") + + +class TestSuche(unittest.TestCase): + + def test_mit_nenner_genau_ein_treffer(self): + """Prüft, dass mit Nenner nur das passende Flurstück der Gemarkung gefunden wird.""" + self.assertEqual(fs.finde_flurstuecke(_flurstuecke(), ["0268"], "12", "3"), [3]) + self.assertEqual(fs.finde_flurstuecke(_flurstuecke(), ["0268"], "12", "a"), [5]) + + def test_ohne_nenner_alle_mit_diesem_zaehler(self): + """Prüft, dass ohne Nenner alle Flurstücke mit dem Zähler gefunden werden, aber nur in der gesuchten Gemarkung.""" + self.assertEqual(fs.finde_flurstuecke(_flurstuecke(), ["0268"], "562", ""), [1, 2]) + self.assertEqual(fs.finde_flurstuecke(_flurstuecke(), ["0268", "0269"], "562", ""), [1, 2, 4]) + + def test_kein_treffer(self): + """Prüft, dass ein nicht vorhandenes Flurstück eine leere Trefferliste ergibt.""" + self.assertEqual(fs.finde_flurstuecke(_flurstuecke(), ["0268"], "999", ""), []) + + def test_layer_ohne_kennzeichenfeld(self): + """Prüft, dass ein Flurstückslayer ohne Feld „flurstueckskennzeichen“ gemeldet wird.""" + layer = _Layer("Flurstücke", [], felder=("gml_id",)) + with self.assertRaisesRegex(fs.FlurstueckSucheFehler, "flurstueckskennzeichen"): + fs.finde_flurstuecke(layer, ["0268"], "562", "") + + def test_fehlender_flurstueckslayer_bricht_ab(self): + """Prüft, dass die Suche ohne Layer „Flurstücke“ mit dem Hinweis abbricht, zuerst Verfahrensgebiet und Flurstücke zu laden.""" + with patch.object(fs, "QgsProject", _projekt(_gemarkungen())): + with self.assertRaises(fs.KeinFlurstueckslayer) as kontext: + fs.flurstueck_suchen("0268", "562") + self.assertIn("Flurstücke aus ALKIS laden", str(kontext.exception)) + + def test_suche_im_projekt_mit_gemarkungsname(self): + """Prüft, dass die Suche im Projekt den Gemarkungsnamen auflöst und Layer samt Treffern liefert.""" + flurstuecke = _flurstuecke() + with patch.object(fs, "QgsProject", _projekt(flurstuecke, _gemarkungen())): + ergebnis = fs.flurstueck_suchen("Beispielhausen", "12/3") + self.assertIs(ergebnis.layer, flurstuecke) + self.assertEqual(ergebnis.feature_ids, [3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/dockwidget.py b/ui/dockwidget.py index 93b7d3d..8aecb7f 100644 --- a/ui/dockwidget.py +++ b/ui/dockwidget.py @@ -1,10 +1,11 @@ """sn_verfahrensgebiet/ui/dockwidget.py – Haupt-DockWidget für sn_verfahrensgebiet. -Verwaltet die Tabs für Verfahrensgebiet-Bearbeitung, Druck und Einstellungen. +Verwaltet die Tabs für Verfahrensgebiet-Bearbeitung, Eigentümer, Druck und Einstellungen. """ from sn_basis.ui.tabs.settings_tab import SettingsTab from sn_basis.ui.tabs.print_tab import PrintTab from sn_verfahrensgebiet.ui.tabs.working_tab import WorkingTab +from sn_verfahrensgebiet.ui.tabs.eigentuemer_tab import EigentuemerTab from sn_basis.ui.base_dockwidget import BaseDockWidget from sn_basis.functions.qt_wrapper import QTabWidget from sn_basis.functions.message_wrapper import error @@ -14,12 +15,12 @@ class DockWidget(BaseDockWidget): """Haupt-DockWidget für sn_verfahrensgebiet mit Tab-Verwaltung. Attributes: - tabs: Liste von Tab-Klassen (WorkingTab, PrintTab, SettingsTab) + tabs: Liste von Tab-Klassen (WorkingTab, EigentuemerTab, PrintTab, SettingsTab) pruefmanager: Prüfmanager-Instanz für Validierung data_grabber: DataGrabber-Instanz für Fachdatenabruf """ - tabs = [WorkingTab, PrintTab, SettingsTab] + tabs = [WorkingTab, EigentuemerTab, PrintTab, SettingsTab] def __init__(self, parent=None, subtitle="", pruefmanager=None, data_grabber=None): """Initialisiert das DockWidget mit Services und Tabs. diff --git a/ui/tabs/eigentuemer_tab.py b/ui/tabs/eigentuemer_tab.py new file mode 100644 index 0000000..533e49f --- /dev/null +++ b/ui/tabs/eigentuemer_tab.py @@ -0,0 +1,104 @@ +"""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`. +""" + +from sn_basis.functions.qt_wrapper import ( + QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QLineEdit, QPushButton, Qt, +) +from sn_basis.functions.qgisui_wrapper import iface +from sn_basis.functions.dialog_wrapper import show_info_dialog +from sn_basis.functions.message_wrapper import success, info, warning + +from sn_verfahrensgebiet.functions.flurstueck_suche import ( + flurstueck_suchen, + FlurstueckSucheFehler, + KeinFlurstueckslayer, +) + +_TITEL_SUCHE = "Flurstückssuche" + + +class EigentuemerTab(QWidget): + """Tab für Flurstückssuche und (später) Eigentümerabgleich. + + Attributes: + tab_title: Anzeigetitel des Tabs ("Eigentümer") + """ + + tab_title = "Eigentümer" + + def __init__(self, parent=None): + super().__init__(parent) + + layout = QVBoxLayout() + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + layout.addWidget(QLabel("Flurstückssuche")) + + raster = QGridLayout() + raster.addWidget(QLabel("Gemarkung:"), 0, 0) + self.gemarkung_edit = QLineEdit() + self.gemarkung_edit.setPlaceholderText("Name oder Nummer, z. B. 0268") + raster.addWidget(self.gemarkung_edit, 0, 1) + raster.addWidget(QLabel("Flurstück:"), 1, 0) + self.flurstueck_edit = QLineEdit() + self.flurstueck_edit.setPlaceholderText("z. B. 562 oder 12/3") + raster.addWidget(self.flurstueck_edit, 1, 1) + layout.addLayout(raster) + + zeile_suchen = QHBoxLayout() + zeile_suchen.addStretch() + self.btn_flurstueck_suchen = QPushButton("Suchen") + self.btn_flurstueck_suchen.setToolTip( + "Sucht im Layer „Flurstücke“. Ohne Nenner werden alle Flurstücke " + "mit diesem Zähler gefunden." + ) + zeile_suchen.addWidget(self.btn_flurstueck_suchen) + layout.addLayout(zeile_suchen) + + self.btn_flurstueck_suchen.clicked.connect(self._on_flurstueck_suchen) + self.gemarkung_edit.returnPressed.connect(self._on_flurstueck_suchen) + self.flurstueck_edit.returnPressed.connect(self._on_flurstueck_suchen) + + self.setLayout(layout) + + def _on_flurstueck_suchen(self) -> None: + """Sucht das eingegebene Flurstück, wählt die Treffer aus und zoomt hin.""" + gemarkung = self.gemarkung_edit.text().strip() + nummer = self.flurstueck_edit.text().strip() + try: + ergebnis = flurstueck_suchen(gemarkung, nummer) + except KeinFlurstueckslayer as exc: + show_info_dialog(_TITEL_SUCHE, str(exc), self) + return + except FlurstueckSucheFehler as exc: + warning(_TITEL_SUCHE, str(exc)) + return + + if not ergebnis.feature_ids: + info(_TITEL_SUCHE, f"Flurstück {nummer} in Gemarkung {gemarkung} nicht gefunden.") + return + + ergebnis.layer.selectByIds(ergebnis.feature_ids) + self._zoome_auf_auswahl(ergebnis.layer, ergebnis.feature_ids) + anzahl = len(ergebnis.feature_ids) + text = "1 Flurstück" if anzahl == 1 else f"{anzahl} Flurstücke" + success(_TITEL_SUCHE, f"{text} im Layer „{ergebnis.layer.name()}“ ausgewählt.") + + @staticmethod + def _zoome_auf_auswahl(layer, feature_ids) -> bool: + """Zoomt auf die Auswahl und lässt die Treffer kurz aufblinken. + + ``zoomToSelected`` rechnet selbst in das KBS der Kartenansicht um. + Gibt ``False`` zurück, wenn keine Kartenansicht verfügbar ist. + """ + try: + canvas = iface.mapCanvas() + canvas.zoomToSelected(layer) + canvas.flashFeatureIds(layer, feature_ids) + return True + except Exception: + return False