From f9241a11f92c47d593cec6c0d69a4192a5efed61 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 2 Oct 2026 11:57:10 +0200 Subject: [PATCH] Basemap-Bereich im Druck-Tab mit lokalen Stilvorlagen (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Einklappbarer Bereich „Basemap“: Auswertebereich (Verfahrensgebiet/Druckbereich), Basemap-Legende beim Erstellen an/aus, „Basemap-Legende aktualisieren“ für bestehende Vorlagen und Stilvorlagen des Basemap-Layers. Stilvorlagen liegen lokal im QGIS-Profil (QML plus Begleitdatei mit Legendenauswahl), mehrere Varianten, eine als Standard markierbar; geladen wird nur auf Knopfdruck nach Rückfrage, fremde Varianten (andere Style-Namen) erst nach zweiter Bestätigung. Co-Authored-By: Claude Opus 5.5 --- changelog.txt | 3 + modules/basemap_stilvorlagen.py | 181 +++++++++++++++++++ tests/test_basemap_anbindung.py | 174 ++++++++++++++++++ tests/test_basemap_stilvorlagen.py | 128 ++++++++++++++ ui/tabs/print_tab.py | 13 ++ ui/widgets/__init__.py | 1 + ui/widgets/basemap_bereich.py | 271 +++++++++++++++++++++++++++++ 7 files changed, 771 insertions(+) create mode 100644 modules/basemap_stilvorlagen.py create mode 100644 tests/test_basemap_anbindung.py create mode 100644 tests/test_basemap_stilvorlagen.py create mode 100644 ui/widgets/__init__.py create mode 100644 ui/widgets/basemap_bereich.py diff --git a/changelog.txt b/changelog.txt index ebfa6de..de5c829 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +- Basemap-Legende für Drucklayouts (#88): Beim Erstellen einer §38-/§41-Vorlage wird ausgewertet, welche Objekte des basemap.de-Vektorkachel-Layers im Verfahrensgebiet (wahlweise im Druckbereich) tatsächlich dargestellt werden; ein Auswahldialog lässt Kategorien abwählen, die gewählten erscheinen mit der Signatur des Projektlayers in einem eigenen Legendenelement „Kartengrundlage (basemap.de)“ über der Quellenangabe. Zuordnung von 434 Basemap-Styles zu 190 Legendeneinträgen (Beschriftungen, Nummernschilder und Hintergrund ausgenommen) in `data/basemap_legende.json` (erzeugt mit `modules/basemap_katalog.py`, fachlich nachbearbeitet) +- Neuer einklappbarer Bereich „Basemap“ im Druck-Tab: Auswertebereich, Basemap-Legende beim Erstellen an/aus, „Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des Basemap-Layers (mehrere Varianten, laden, speichern, als Standard markieren, löschen) – z. B. einmal Hausnummern ausschalten und in jedem Projekt wieder laden (#88) +- Neuer allgemeiner Dialog `ask_mehrfachauswahl` (Liste mit Häkchen, Gruppen, Suche, Symbolvorschau) in `functions/dialog_wrapper.py` (#88) - Bugfix: `Navigation.remove_all()` ruft jetzt `deleteLater()` auf Menü und Toolbar auf, bevor die Referenzen verworfen werden – vorher blieb die Toolbar beim Unload als Kind von main_window bestehen und der Plugin Reloader meldete beim Neuladen "removing duplicated widget(s) not cleaned up by the plugin during unload: LnoSachsenToolbar" (#72) - Restliche „ungeklärt"-Funktionen aus dem Prüfstand entschieden: Tests für Dateipruefer (`_ist_leer`, Priorität in `_handle_leere_eingabe`), Datenschreiber (`_store_style_in_gpkg` – Tabelle anlegen, Default-Stil zurücksetzen, Geometriespalte übernehmen) und Pruefmanager (`_handle_verfahrensgebiet_layer_existiert` „entfernen"/„ersetzen"); Linkpruefer (`_ist_url`, `_pruefe_url`, `_pruefe_dateipfad`), `Pruefmanager._handle_datei_existiert` und `Pruefmanager._ask_use_or_replace_pufferlayer` (toter Code, Bug #60) bekamen den Vermerk „von Test abgesehen" – sn_basis steht jetzt bei 0 ungeklärten Funktionen (#70) - Kategorie „vielleicht testen" abgeschlossen: 30 weitere Funktionen bekamen Tests (os_wrapper, qgiscore_wrapper, qgisui_wrapper, DataGrabber, pruef_ergebnis, Navigation, SettingsTab, PrintTab – Zustandslogik, Signal-Handling, Atlas-/Theme-Verwaltung), `PrintTab._build_ui` bekam den Vermerk „Test ausgesetzt, zunächst Refactor" (#67) diff --git a/modules/basemap_stilvorlagen.py b/modules/basemap_stilvorlagen.py new file mode 100644 index 0000000..a24543e --- /dev/null +++ b/modules/basemap_stilvorlagen.py @@ -0,0 +1,181 @@ +""" +sn_basis/modules/basemap_stilvorlagen.py – lokale Stilvorlagen für den Basemap-Layer + +Speichert die Darstellung des Basemap-Layers (Symbologie und Beschriftung, also auch +abgeschaltete Styles wie Hausnummern) als benannte Stilvorlage im QGIS-Profil und lädt sie +wieder. Mehrere Varianten sind möglich; eine davon kann als Standard markiert werden. + +Ablage: ``/sn_basis/basemap_stile/.qml`` plus ``.json`` mit +Anzeigename, Erstellungszeit, Style-Namen der Vorlage und – optional – der Legendenauswahl. +Der Standard steht in ``QSettings`` unter ``sn_basis/basemap_standardstil``. +Die Vorlagen sind lokal (je Rechner und Profil) und werden nicht geteilt. +""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from datetime import datetime +from typing import Any + +from sn_basis.functions.qgiscore_wrapper import QgsApplication, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG +from sn_basis.functions.qt_wrapper import QSettings +from sn_basis.functions.text_wrapper import sanitize_bezeichner + +STANDARD_SCHLUESSEL = "sn_basis/basemap_standardstil" + + +@dataclass +class Stilvorlage: + name: str # Dateiname ohne Endung + anzeigename: str + pfad: str # QML-Datei + erstellt: str = "" + standard: bool = False + + +def standard_ordner() -> str: + """Ordner der Stilvorlagen im aktiven QGIS-Profil (außerhalb von QGIS im Benutzerordner).""" + try: + basis = QgsApplication.qgisSettingsDirPath() or "" + except Exception: + basis = "" + if not basis: + basis = os.path.join(os.path.expanduser("~"), ".sn_basis") + return os.path.join(basis, "sn_basis", "basemap_stile") + + +def _stilnamen(layer: Any) -> list[str]: + try: + return [s.styleName() for s in layer.renderer().styles()] + except Exception: + return [] + + +class BasemapStilvorlagen: + """Verwaltet die lokalen Stilvorlagen des Basemap-Layers.""" + + def __init__(self, ordner: str | None = None, einstellungen: Any | None = None) -> None: + self.ordner = ordner or standard_ordner() + self.einstellungen = einstellungen if einstellungen is not None else QSettings() + + # --- Abfragen ------------------------------------------------------- + + def _pfade(self, name: str) -> tuple[str, str]: + basis = os.path.join(self.ordner, name) + return basis + ".qml", basis + ".json" + + def _metadaten(self, name: str) -> dict: + _, json_pfad = self._pfade(name) + try: + with open(json_pfad, encoding="utf-8") as datei: + daten = json.load(datei) + return daten if isinstance(daten, dict) else {} + except (OSError, ValueError): + return {} + + @staticmethod + def dateiname(anzeigename: str) -> str: + return sanitize_bezeichner(anzeigename.strip()) + + def standard(self) -> str: + return str(self.einstellungen.value(STANDARD_SCHLUESSEL, "") or "") + + def liste(self) -> list[Stilvorlage]: + """Alle Stilvorlagen, alphabetisch nach Anzeigename. Fehlende Begleitdatei wird toleriert.""" + if not os.path.isdir(self.ordner): + return [] + standard = self.standard() + vorlagen = [] + for datei in os.listdir(self.ordner): + if not datei.lower().endswith(".qml"): + continue + name = datei[:-4] + meta = self._metadaten(name) + vorlagen.append(Stilvorlage( + name=name, + anzeigename=str(meta.get("anzeigename") or name), + pfad=os.path.join(self.ordner, datei), + erstellt=str(meta.get("erstellt", "")), + standard=(name == standard), + )) + return sorted(vorlagen, key=lambda v: v.anzeigename.lower()) + + def vorhanden(self, anzeigename: str) -> bool: + return os.path.exists(self._pfade(self.dateiname(anzeigename))[0]) + + def legendenauswahl(self, name: str) -> dict | None: + """In der Vorlage gespeicherte Legendenauswahl (``ausgeblendet``/``eingeblendet``) oder ``None``.""" + auswahl = self._metadaten(name).get("legende_auswahl") + return auswahl if isinstance(auswahl, dict) else None + + def pruefe_passung(self, layer: Any, name: str) -> list[str]: + """Style-Namen der Vorlage, die der Layer nicht kennt (z. B. Vorlage aus „Grau“ für „Farbe“).""" + vorlage = self._metadaten(name).get("stile") + if not isinstance(vorlage, list): + return [] + vorhanden = set(_stilnamen(layer)) + return [s for s in vorlage if s not in vorhanden] + + # --- Ändern --------------------------------------------------------- + + def speichern(self, layer: Any, anzeigename: str, legende_auswahl: dict | None = None) -> Stilvorlage: + """Speichert die aktuelle Darstellung des Layers; eine gleichnamige Vorlage wird ersetzt. + + Raises + ------ + ValueError + Bei leerem Namen oder wenn QGIS die Datei nicht schreiben konnte. + """ + name = self.dateiname(anzeigename) + if not name: + raise ValueError("Bitte einen Namen für die Stilvorlage angeben.") + os.makedirs(self.ordner, exist_ok=True) + qml_pfad, json_pfad = self._pfade(name) + ergebnis = layer.saveNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG) + meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis)) + if not ok: + raise ValueError(f"Die Stilvorlage konnte nicht gespeichert werden: {meldung}") + erstellt = datetime.now().isoformat(timespec="seconds") + meta = { + "anzeigename": anzeigename.strip(), + "erstellt": erstellt, + "layer": layer.name(), + "stile": _stilnamen(layer), + } + if legende_auswahl: + meta["legende_auswahl"] = legende_auswahl + with open(json_pfad, "w", encoding="utf-8") as datei: + json.dump(meta, datei, ensure_ascii=False, indent=1) + return Stilvorlage(name, anzeigename.strip(), qml_pfad, erstellt, name == self.standard()) + + def laden(self, layer: Any, name: str) -> None: + """Ersetzt Symbologie und Beschriftung des Layers durch die Vorlage. + + Raises + ------ + ValueError + Wenn die Vorlage fehlt oder QGIS sie nicht laden konnte. + """ + qml_pfad, _ = self._pfade(name) + if not os.path.exists(qml_pfad): + raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht mehr.") + ergebnis = layer.loadNamedStyle(qml_pfad, STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG) + meldung, ok = ergebnis if isinstance(ergebnis, tuple) else ("", bool(ergebnis)) + if not ok: + raise ValueError(f"Die Stilvorlage konnte nicht geladen werden: {meldung}") + nachzeichnen = getattr(layer, "triggerRepaint", None) + if callable(nachzeichnen): + nachzeichnen() + + def loeschen(self, name: str) -> None: + for pfad in self._pfade(name): + if os.path.exists(pfad): + os.remove(pfad) + if self.standard() == name: + self.einstellungen.remove(STANDARD_SCHLUESSEL) + + def setze_standard(self, name: str) -> None: + if not os.path.exists(self._pfade(name)[0]): + raise ValueError(f"Die Stilvorlage „{name}“ gibt es nicht.") + self.einstellungen.setValue(STANDARD_SCHLUESSEL, name) diff --git a/tests/test_basemap_anbindung.py b/tests/test_basemap_anbindung.py new file mode 100644 index 0000000..253390c --- /dev/null +++ b/tests/test_basemap_anbindung.py @@ -0,0 +1,174 @@ +""" +Tests für die Anbindung der Basemap-Legende an Druckablauf, Dialog und Druck-Tab (Basis #88): +Mehrfachauswahl-Dialog, PrintLogic/PrintLayout und Basemap-Bereich im Druck-Tab. +""" +import json +import unittest +from unittest.mock import MagicMock, patch + +from sn_basis.functions.dialog_wrapper import ask_mehrfachauswahl +from sn_basis.functions.variable_wrapper import get_variable, set_variable +from sn_basis.modules import basemap_legende as bl +from sn_basis.modules.print_layout import PrintLayout +from sn_basis.modules.print_logic import ( + KARTENNAME_38, + KARTENNAME_41, + KARTENNAME_BAUFREIGABE, + LAYOUTS_MIT_BASEMAP_LEGENDE, + PrintLogic, +) +from sn_basis.ui.tabs.print_tab import PrintTab +from sn_basis.ui.widgets.basemap_bereich import BasemapBereich + + +class TestMehrfachauswahl(unittest.TestCase): + + def test_mock_modus_liefert_vorauswahl_in_listenreihenfolge(self): + """Prüft, dass der Mehrfachauswahl-Dialog im Mock-Modus die vorausgewählten Schlüssel in Listenreihenfolge liefert.""" + eintraege = [{"schluessel": s, "text": s.upper()} for s in ("a", "b", "c")] + self.assertEqual(ask_mehrfachauswahl("T", "H", eintraege, {"c", "a"}), ["a", "c"]) + + +class TestDruckanbindung(unittest.TestCase): + + def tearDown(self): + set_variable(bl.LEGENDE_ERZEUGEN_VAR, "", scope="project") + + def test_basemap_legende_nur_fuer_38_und_41_und_bei_eingeschaltetem_schalter(self): + """Prüft, dass nur §-38- und §-41-Vorlagen eine Basemap-Legende bekommen und nur, wenn der Schalter im Druck-Tab an ist.""" + logik = PrintLogic(pruefmanager=MagicMock()) + self.assertEqual(LAYOUTS_MIT_BASEMAP_LEGENDE, {KARTENNAME_38, KARTENNAME_41}) + self.assertTrue(logik._basemap_legende_erzeugen(KARTENNAME_41)) + self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_BAUFREIGABE)) + bl.setze_legende_erzeugen(False) + self.assertFalse(logik._basemap_legende_erzeugen(KARTENNAME_38)) + + def test_layout_wird_markiert_und_legende_erzeugt(self): + """Prüft, dass PrintLayout das Layout als Basemap-Nutzer kennzeichnet und die Basemap-Legende erzeugen lässt.""" + layout = MagicMock() + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse: + klasse.return_value.aktualisiere.return_value = {"status": "ok"} + PrintLayout(project=MagicMock())._erzeuge_basemap_legende(layout) + layout.setCustomProperty.assert_any_call("sn_basemap_legende", 1) + klasse.return_value.aktualisiere.assert_called_once_with(layout) + + def test_fehler_der_basemap_legende_verhindert_die_vorlage_nicht(self): + """Prüft, dass ein Fehler beim Erzeugen der Basemap-Legende nur als Warnung gemeldet wird und keine Ausnahme durchreicht.""" + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse, \ + patch("sn_basis.functions.message_wrapper.warning") as warnung: + klasse.return_value.aktualisiere.side_effect = RuntimeError("Netz weg") + PrintLayout(project=MagicMock())._erzeuge_basemap_legende(MagicMock()) + warnung.assert_called_once() + self.assertIn("Netz weg", warnung.call_args[0][1]) + + def test_druck_tab_aktualisiert_basemap_bereich_falls_vorhanden(self): + """Prüft, dass der Druck-Tab den Basemap-Bereich neu einliest und ohne Bereich nichts tut.""" + tab = PrintTab.__new__(PrintTab) + tab._basemap_bereich_aktualisieren() # ohne Bereich: keine Ausnahme + tab._basemap_bereich = MagicMock() + tab._basemap_bereich_aktualisieren() + tab._basemap_bereich.aktualisieren.assert_called_once() + + +def _bereich(stil_name="Ohne_Hausnummern", layer=None, layout_name="§41"): + bereich = BasemapBereich.__new__(BasemapBereich) + bereich._project = MagicMock() + bereich._stilvorlagen = MagicMock() + bereich._stil_combo = MagicMock() + bereich._stil_combo.currentData.return_value = stil_name + bereich._stil_combo.currentText.return_value = stil_name + bereich._layout_combo = MagicMock() + bereich._layout_combo.currentText.return_value = layout_name + bereich._bereich_combo = MagicMock() + bereich.basemap_layer = MagicMock(return_value=layer) + bereich.aktualisieren = MagicMock() + return bereich + + +class TestBasemapBereich(unittest.TestCase): + + def tearDown(self): + for schluessel in (bl.AUSWAHL_VAR, bl.AUSWERTEBEREICH_VAR): + set_variable(schluessel, "", scope="project") + + def test_auswertebereich_wird_gespeichert(self): + """Prüft, dass die Wahl des Auswertebereichs in der Projektvariable landet.""" + bereich = _bereich() + bereich._bereich_combo.currentData.return_value = bl.AUSWERTEBEREICH_DRUCKBEREICH + bereich._on_bereich_geaendert(1) + self.assertEqual(bl.lies_auswertebereich(), bl.AUSWERTEBEREICH_DRUCKBEREICH) + + @patch("sn_basis.ui.widgets.basemap_bereich.warning") + def test_aktualisieren_ohne_vorlage_warnt(self, warnung): + """Prüft, dass „Basemap-Legende aktualisieren“ ohne vorhandene Vorlage warnt und nichts auswertet.""" + bereich = _bereich() + bereich._project.layoutManager.return_value.layoutByName.return_value = None + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse: + bereich._on_legende_aktualisieren() + klasse.assert_not_called() + warnung.assert_called_once() + + @patch("sn_basis.ui.widgets.basemap_bereich.success") + def test_aktualisieren_wertet_gewaehlte_vorlage_aus(self, erfolg): + """Prüft, dass „Basemap-Legende aktualisieren“ die gewählte Vorlage auswertet und den Erfolg meldet.""" + bereich = _bereich() + layout = bereich._project.layoutManager.return_value.layoutByName.return_value + with patch("sn_basis.modules.basemap_legende.BasemapLegende") as klasse: + klasse.return_value.aktualisiere.return_value = {"status": "ok", "meldung": "3 Einträge"} + bereich._on_legende_aktualisieren() + klasse.return_value.aktualisiere.assert_called_once_with(layout) + erfolg.assert_called_once() + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=True) + @patch("sn_basis.ui.widgets.basemap_bereich.success") + def test_stil_laden_uebernimmt_legendenauswahl(self, _erfolg, _frage): + """Prüft, dass das Laden einer Stilvorlage den Layerstil ersetzt und ihre Legendenauswahl in das Projekt übernimmt.""" + layer = MagicMock() + bereich = _bereich(layer=layer) + bereich._stilvorlagen.pruefe_passung.return_value = [] + bereich._stilvorlagen.legendenauswahl.return_value = {"ausgeblendet": ["wald"], "eingeblendet": []} + bereich._on_stil_laden() + bereich._stilvorlagen.laden.assert_called_once_with(layer, "Ohne_Hausnummern") + self.assertEqual(json.loads(get_variable(bl.AUSWAHL_VAR, scope="project"))["ausgeblendet"], ["wald"]) + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", side_effect=[True, False]) + def test_stil_laden_bricht_bei_fremder_vorlage_ab(self, _frage): + """Prüft, dass eine Vorlage mit unbekannten Styles nur nach Bestätigung geladen wird.""" + bereich = _bereich(layer=MagicMock()) + bereich._stilvorlagen.pruefe_passung.return_value = ["Fremder_Style"] + bereich._on_stil_laden() + bereich._stilvorlagen.laden.assert_not_called() + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Ohne Hausnummern", True)) + @patch("sn_basis.ui.widgets.basemap_bereich.ask_yes_no", return_value=False) + def test_vorhandene_vorlage_wird_nur_nach_rueckfrage_ersetzt(self, _frage, _text): + """Prüft, dass eine gleichnamige Stilvorlage ohne Bestätigung nicht überschrieben wird.""" + bereich = _bereich(layer=MagicMock()) + bereich._stilvorlagen.vorhanden.return_value = True + bereich._on_stil_speichern() + bereich._stilvorlagen.speichern.assert_not_called() + + @patch("sn_basis.ui.widgets.basemap_bereich.ask_text", return_value=("Neu", True)) + @patch("sn_basis.ui.widgets.basemap_bereich.success") + def test_speichern_mit_aktueller_legendenauswahl(self, _erfolg, _text): + """Prüft, dass eine neue Stilvorlage zusammen mit der aktuellen Legendenauswahl gespeichert wird.""" + layer = MagicMock() + bereich = _bereich(layer=layer) + bereich._stilvorlagen.vorhanden.return_value = False + bl.merke_auswahl([bl.LegendenEintrag("wald", "Wald", "Vegetation", [])], []) + bereich._on_stil_speichern() + bereich._stilvorlagen.speichern.assert_called_once_with( + layer, "Neu", {"ausgeblendet": ["wald"], "eingeblendet": []}) + bereich.aktualisieren.assert_called_once() + + @patch("sn_basis.ui.widgets.basemap_bereich.warning") + def test_stil_speichern_ohne_basemap_layer_warnt(self, warnung): + """Prüft, dass ohne Basemap-Layer keine Stilvorlage gespeichert, sondern gewarnt wird.""" + bereich = _bereich(layer=None) + bereich._on_stil_speichern() + bereich._stilvorlagen.speichern.assert_not_called() + warnung.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_basemap_stilvorlagen.py b/tests/test_basemap_stilvorlagen.py new file mode 100644 index 0000000..2c5c47c --- /dev/null +++ b/tests/test_basemap_stilvorlagen.py @@ -0,0 +1,128 @@ +""" +Tests für modules/basemap_stilvorlagen.py – lokale Stilvorlagen des Basemap-Layers (Basis #88). + +Gespeichert wird in einem temporären Ordner; der Layer schreibt statt einer echten QML-Datei +eine kleine Textdatei. Laden und Speichern am echten Vektorkachel-Layer ist im QGIS-Test geprüft. +""" +import os +import shutil +import tempfile +import unittest + +from sn_basis.modules.basemap_stilvorlagen import STANDARD_SCHLUESSEL, BasemapStilvorlagen + + +class _Einstellungen: + """Ersatz für QSettings – die Tests dürfen keine echten Benutzereinstellungen verändern.""" + + def __init__(self): self.daten = {} + def value(self, k, standard=""): return self.daten.get(k, standard) + def setValue(self, k, v): self.daten[k] = v + def remove(self, k): self.daten.pop(k, None) + + +class _Stil: + def __init__(self, name): self._name = name + def styleName(self): return self._name + + +class _Layer: + def __init__(self, stile=("Kontur_Kreisstr", "Decker_Kreisstr"), speichern_ok=True): + self.stile = list(stile) + self.geladen = None + self.neu_gezeichnet = False + self._speichern_ok = speichern_ok + + def name(self): return "Basemap.de - Farbe" + def renderer(self): return type("R", (), {"styles": lambda s: [_Stil(n) for n in self.stile]})() + + def saveNamedStyle(self, pfad, kategorien): + if not self._speichern_ok: + return "kein Schreibrecht", False + with open(pfad, "w", encoding="utf-8") as datei: + datei.write("") + return "", True + + def loadNamedStyle(self, pfad, kategorien): + self.geladen = pfad + return "", True + + def triggerRepaint(self): self.neu_gezeichnet = True + + +class TestStilvorlagen(unittest.TestCase): + + def setUp(self): + self.ordner = tempfile.mkdtemp() + self.einstellungen = _Einstellungen() + self.vorlagen = BasemapStilvorlagen(os.path.join(self.ordner, "stile"), self.einstellungen) + + def tearDown(self): + shutil.rmtree(self.ordner, ignore_errors=True) + + def test_speichern_und_auflisten(self): + """Prüft, dass eine gespeicherte Vorlage mit Anzeigenamen, QML-Datei und Begleitdatei aufgelistet wird.""" + self.vorlagen.speichern(_Layer(), "Ohne Hausnummern", {"ausgeblendet": ["wald"], "eingeblendet": []}) + liste = self.vorlagen.liste() + self.assertEqual([v.anzeigename for v in liste], ["Ohne Hausnummern"]) + self.assertEqual(liste[0].name, "Ohne_Hausnummern") + self.assertTrue(os.path.exists(liste[0].pfad)) + self.assertEqual(self.vorlagen.legendenauswahl("Ohne_Hausnummern")["ausgeblendet"], ["wald"]) + + def test_mehrere_varianten_alphabetisch(self): + """Prüft, dass mehrere Varianten nebeneinander bestehen und alphabetisch nach Anzeigename sortiert werden.""" + for name in ("Zeichnung", "Abstimmung", "mit Gebäuden"): + self.vorlagen.speichern(_Layer(), name) + self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Abstimmung", "mit Gebäuden", "Zeichnung"]) + + def test_leerer_name_oder_fehler_beim_schreiben(self): + """Prüft, dass ein leerer Name und ein Schreibfehler von QGIS als ValueError gemeldet werden.""" + with self.assertRaises(ValueError): + self.vorlagen.speichern(_Layer(), " ") + with self.assertRaises(ValueError): + self.vorlagen.speichern(_Layer(speichern_ok=False), "Test") + + def test_laden_setzt_stil_und_zeichnet_neu(self): + """Prüft, dass das Laden die QML-Datei der Vorlage in den Layer lädt und neu zeichnen lässt.""" + self.vorlagen.speichern(_Layer(), "Standard LNO") + layer = _Layer() + self.vorlagen.laden(layer, "Standard_LNO") + self.assertTrue(layer.geladen.endswith("Standard_LNO.qml")) + self.assertTrue(layer.neu_gezeichnet) + with self.assertRaises(ValueError): + self.vorlagen.laden(layer, "gibt_es_nicht") + + def test_passung_meldet_fremde_styles(self): + """Prüft, dass Style-Namen der Vorlage gemeldet werden, die der Ziel-Layer nicht kennt.""" + self.vorlagen.speichern(_Layer(stile=("A", "B", "C")), "Grau") + self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A",)), "Grau"), ["B", "C"]) + self.assertEqual(self.vorlagen.pruefe_passung(_Layer(stile=("A", "B", "C")), "Grau"), []) + + def test_standard_setzen_und_beim_loeschen_entfernen(self): + """Prüft, dass eine Vorlage als Standard markiert wird und die Markierung beim Löschen der Vorlage entfällt.""" + self.vorlagen.speichern(_Layer(), "Standard LNO") + self.vorlagen.setze_standard("Standard_LNO") + self.assertTrue(self.vorlagen.liste()[0].standard) + self.vorlagen.loeschen("Standard_LNO") + self.assertEqual(self.vorlagen.liste(), []) + self.assertEqual(self.vorlagen.standard(), "") + self.assertNotIn(STANDARD_SCHLUESSEL, self.einstellungen.daten) + with self.assertRaises(ValueError): + self.vorlagen.setze_standard("Standard_LNO") + + def test_fehlende_begleitdatei_wird_toleriert(self): + """Prüft, dass eine QML-Datei ohne Begleitdatei mit ihrem Dateinamen aufgelistet wird.""" + os.makedirs(self.vorlagen.ordner, exist_ok=True) + with open(os.path.join(self.vorlagen.ordner, "Alt.qml"), "w", encoding="utf-8") as datei: + datei.write("") + self.assertEqual([v.anzeigename for v in self.vorlagen.liste()], ["Alt"]) + self.assertIsNone(self.vorlagen.legendenauswahl("Alt")) + self.assertEqual(self.vorlagen.pruefe_passung(_Layer(), "Alt"), []) + + def test_ohne_ordner_leere_liste(self): + """Prüft, dass ohne angelegten Vorlagenordner eine leere Liste zurückkommt.""" + self.assertEqual(self.vorlagen.liste(), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/tabs/print_tab.py b/ui/tabs/print_tab.py index 37ff357..fc904f5 100644 --- a/ui/tabs/print_tab.py +++ b/ui/tabs/print_tab.py @@ -22,6 +22,7 @@ from sn_basis.functions.qgiscore_wrapper import QgsProject, QgsMapLayerProxyMode from sn_basis.functions.qgisui_wrapper import iface, QgsMapLayerComboBox from sn_basis.functions.variable_wrapper import get_variable, set_variable from sn_basis.modules.Pruefmanager import Pruefmanager +from sn_basis.ui.widgets.basemap_bereich import BasemapBereich from sn_basis.modules.DataGrabber import DataGrabber from sn_basis.modules.print_logic import ( PrintLogic, @@ -257,6 +258,10 @@ class PrintTab(QWidget): self._atlas_content = atlas_content main_layout.addWidget(atlas_content) + # Basemap-Bereich (einklappbar): Basemap-Legende und Stilvorlagen (Basis #88) + self._basemap_bereich = BasemapBereich(self) + main_layout.addWidget(self._basemap_bereich) + btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self) btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen) self._btn_vorlage_erstellen = btn_vorlage_erstellen @@ -445,6 +450,13 @@ class PrintTab(QWidget): formfaktor=self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else False, druckbereich_auswahl=self._druckbereich_combo.currentText() if self._druckbereich_combo else DRUCKBEREICH_VERFAHRENSGEBIET, ) + self._basemap_bereich_aktualisieren() + + def _basemap_bereich_aktualisieren(self) -> None: + """Liest den Basemap-Bereich neu ein (neue Vorlage, Projektwechsel).""" + bereich = getattr(self, "_basemap_bereich", None) + if bereich is not None: + bereich.aktualisieren() def _disconnect_project_signals(self) -> None: try: @@ -494,6 +506,7 @@ class PrintTab(QWidget): self._theme_signal_connected = False self._connect_theme_collection_signals() self._restore_state() + self._basemap_bereich_aktualisieren() def _toggle_atlas(self, checked: bool) -> None: if self._atlas_button: diff --git a/ui/widgets/__init__.py b/ui/widgets/__init__.py new file mode 100644 index 0000000..97791b9 --- /dev/null +++ b/ui/widgets/__init__.py @@ -0,0 +1 @@ +"""sn_basis/ui/widgets – wiederverwendbare Bereiche für Tabs.""" diff --git a/ui/widgets/basemap_bereich.py b/ui/widgets/basemap_bereich.py new file mode 100644 index 0000000..c4f1816 --- /dev/null +++ b/ui/widgets/basemap_bereich.py @@ -0,0 +1,271 @@ +""" +sn_basis/ui/widgets/basemap_bereich.py – einklappbarer Bereich „Basemap“ im Druck-Tab + +Einstellungen der Basemap-Legende (Auswertebereich, Erzeugen beim Anlegen der Vorlage), +„Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des +Basemap-Layers (laden, speichern, als Standard markieren, löschen). Basis #88. + +Die Handler enthalten nur Ablauf und Rückfragen; die Logik liegt in +``modules/basemap_legende`` und ``modules/basemap_stilvorlagen``. +""" +from __future__ import annotations + +from typing import Any, Optional + +from sn_basis.functions.dialog_wrapper import ask_text, ask_yes_no +from sn_basis.functions.message_wrapper import info, success, warning +from sn_basis.functions.qgiscore_wrapper import QgsProject +from sn_basis.functions.qt_wrapper import ( + ArrowDown, + ArrowRight, + QCheckBox, + QComboBox, + QHBoxLayout, + QLabel, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, + ToolButtonTextBesideIcon, +) +from sn_basis.modules import basemap_legende as legende +from sn_basis.modules.basemap_legende_layout import layouts_mit_basemap_legende +from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen + +TITEL = "Basemap" +KEIN_LAYER_TEXT = "Kein Basemap-Layer im Projekt" + + +class BasemapBereich(QWidget): + """Einklappbarer Basemap-Bereich; standardmäßig eingeklappt.""" + + def __init__(self, parent: Optional[Any] = None, project: Any | None = None, + stilvorlagen: BasemapStilvorlagen | None = None) -> None: + super().__init__(parent) + self._project = project + self._stilvorlagen = stilvorlagen + self._build_ui() + self.aktualisieren() + + # --- Aufbau --------------------------------------------------------- + + def _build_ui(self) -> None: + """von Test abgesehen, weil reiner Widget-Aufbau; im QGIS-Test geprüft""" + haupt = QVBoxLayout() + haupt.setSpacing(4) + haupt.setContentsMargins(0, 0, 0, 0) + + knopf = QToolButton() + knopf.setText(TITEL) + knopf.setCheckable(True) + knopf.setChecked(False) + knopf.setToolButtonStyle(ToolButtonTextBesideIcon) + knopf.setArrowType(ArrowRight) + knopf.setStyleSheet("font-weight: bold; margin-top: 6px;") + knopf.toggled.connect(self._umschalten) + self._knopf = knopf + haupt.addWidget(knopf) + + inhalt = QWidget() + spalte = QVBoxLayout() + spalte.setSpacing(4) + spalte.setContentsMargins(10, 4, 4, 4) + + self._status = QLabel(KEIN_LAYER_TEXT) + self._status.setWordWrap(True) + spalte.addWidget(self._status) + + spalte.addWidget(self._ueberschrift("Legende")) + spalte.addWidget(QLabel("Auswertebereich")) + self._bereich_combo = QComboBox(self) + for schluessel, text in legende.AUSWERTEBEREICHE.items(): + self._bereich_combo.addItem(text, schluessel) + self._bereich_combo.currentIndexChanged.connect(self._on_bereich_geaendert) + spalte.addWidget(self._bereich_combo) + + self._erzeugen_cb = QCheckBox("Basemap-Legende beim Erstellen der Vorlage erzeugen (§ 38, § 41)", self) + self._erzeugen_cb.toggled.connect(self._on_erzeugen_geaendert) + spalte.addWidget(self._erzeugen_cb) + + spalte.addWidget(QLabel("Vorlage")) + self._layout_combo = QComboBox(self) + spalte.addWidget(self._layout_combo) + self._aktualisieren_btn = QPushButton("Basemap-Legende aktualisieren", self) + self._aktualisieren_btn.clicked.connect(self._on_legende_aktualisieren) + spalte.addWidget(self._aktualisieren_btn) + + spalte.addWidget(self._ueberschrift("Stilvorlage")) + self._stil_combo = QComboBox(self) + spalte.addWidget(self._stil_combo) + zeile = QHBoxLayout() + for text, handler in ( + ("Laden", self._on_stil_laden), + ("Speichern unter …", self._on_stil_speichern), + ("Als Standard", self._on_stil_standard), + ("Löschen", self._on_stil_loeschen), + ): + knopf_stil = QPushButton(text, self) + knopf_stil.clicked.connect(lambda *_, h=handler: h()) + zeile.addWidget(knopf_stil) + spalte.addLayout(zeile) + + inhalt.setLayout(spalte) + inhalt.setVisible(False) + self._inhalt = inhalt + haupt.addWidget(inhalt) + self.setLayout(haupt) + + @staticmethod + def _ueberschrift(text: str) -> Any: + beschriftung = QLabel(text) + beschriftung.setStyleSheet("font-weight: bold; margin-top: 4px;") + return beschriftung + + # --- Zustand -------------------------------------------------------- + + def _projekt(self) -> Any: + return getattr(self, "_project", None) or QgsProject.instance() + + def _vorlagen(self) -> BasemapStilvorlagen: + if getattr(self, "_stilvorlagen", None) is None: + self._stilvorlagen = BasemapStilvorlagen() + return self._stilvorlagen + + def basemap_layer(self) -> Any | None: + """Erster Basemap-Layer des Projekts (sichtbar oder nicht).""" + try: + layer = list(self._projekt().mapLayers().values()) + except Exception: + return None + gefunden = legende.finde_basemap_layer(layer) + return gefunden[0] if gefunden else None + + def _umschalten(self, offen: bool) -> None: + self._knopf.setArrowType(ArrowDown if offen else ArrowRight) + self._inhalt.setVisible(offen) + if offen: + self.aktualisieren() + + def aktualisieren(self) -> None: + """Liest Projektvariablen, Layouts und Stilvorlagen neu ein (z. B. nach Projektwechsel).""" + layer = self.basemap_layer() + self._status.setText(f"Basemap-Layer: {layer.name()}" if layer is not None else KEIN_LAYER_TEXT) + + bereich = legende.lies_auswertebereich() + self._bereich_combo.blockSignals(True) + self._bereich_combo.setCurrentIndex(max(0, self._bereich_combo.findData(bereich))) + self._bereich_combo.blockSignals(False) + self._erzeugen_cb.blockSignals(True) + self._erzeugen_cb.setChecked(legende.legende_erzeugen_aktiv()) + self._erzeugen_cb.blockSignals(False) + + self._layout_combo.clear() + self._layout_combo.addItems(layouts_mit_basemap_legende(self._projekt())) + self._aktualisieren_btn.setEnabled(layer is not None and self._layout_combo.count() > 0) + + self._stil_combo.clear() + for vorlage in self._vorlagen().liste(): + self._stil_combo.addItem(f"{vorlage.anzeigename}{' ★' if vorlage.standard else ''}", vorlage.name) + standard = self._vorlagen().standard() + if standard: + self._stil_combo.setCurrentIndex(max(0, self._stil_combo.findData(standard))) + + # --- Handler: Legende ----------------------------------------------- + + def _on_bereich_geaendert(self, *_: Any) -> None: + bereich = self._bereich_combo.currentData() + if bereich in legende.AUSWERTEBEREICHE: + legende.setze_auswertebereich(bereich) + + def _on_erzeugen_geaendert(self, aktiv: bool) -> None: + legende.setze_legende_erzeugen(bool(aktiv)) + + def _on_legende_aktualisieren(self) -> None: + name = self._layout_combo.currentText() + layout = self._projekt().layoutManager().layoutByName(name) if name else None + if layout is None: + warning(TITEL, "Bitte eine Vorlage mit Basemap-Legende wählen.") + return + try: + ergebnis = legende.BasemapLegende(self._projekt()).aktualisiere(layout) + except Exception as fehler: + warning(TITEL, f"Die Basemap-Legende konnte nicht aktualisiert werden: {fehler}") + return + if ergebnis["status"] == "ok": + success(TITEL, ergebnis["meldung"]) + elif ergebnis["status"] != "abgebrochen": + info(TITEL, ergebnis["meldung"]) + + # --- Handler: Stilvorlagen ------------------------------------------ + + def _gewaehlte_vorlage(self) -> str: + return str(self._stil_combo.currentData() or "") + + def _on_stil_laden(self) -> None: + layer = self.basemap_layer() + name = self._gewaehlte_vorlage() + if layer is None or not name: + warning(TITEL, "Kein Basemap-Layer oder keine Stilvorlage gewählt.") + return + if not ask_yes_no("Stilvorlage laden", + f"Darstellung von „{layer.name()}“ durch die Stilvorlage ersetzen?", default=False): + return + unbekannt = self._vorlagen().pruefe_passung(layer, name) + if unbekannt and not ask_yes_no( + "Stilvorlage laden", + f"{len(unbekannt)} Styles der Vorlage gibt es in diesem Layer nicht – die Vorlage stammt " + "vermutlich von einer anderen Basemap-Variante. Trotzdem laden?", + default=False, + ): + return + try: + self._vorlagen().laden(layer, name) + except ValueError as fehler: + warning(TITEL, str(fehler)) + return + auswahl = self._vorlagen().legendenauswahl(name) + if auswahl is not None: + import json + from sn_basis.functions.variable_wrapper import set_variable + set_variable(legende.AUSWAHL_VAR, json.dumps(auswahl), scope="project") + success(TITEL, "Stilvorlage geladen.") + + def _on_stil_speichern(self) -> None: + layer = self.basemap_layer() + if layer is None: + warning(TITEL, KEIN_LAYER_TEXT) + return + anzeigename, ok = ask_text("Stilvorlage speichern", "Name der Stilvorlage:", + default_text=self._stil_combo.currentText().replace(" ★", "")) + if not ok or not anzeigename.strip(): + return + if self._vorlagen().vorhanden(anzeigename) and not ask_yes_no( + "Stilvorlage speichern", f"Die Stilvorlage „{anzeigename}“ gibt es schon. Ersetzen?", default=False + ): + return + try: + self._vorlagen().speichern(layer, anzeigename, legende.lies_auswahl()) + except ValueError as fehler: + warning(TITEL, str(fehler)) + return + self.aktualisieren() + success(TITEL, f"Stilvorlage „{anzeigename.strip()}“ gespeichert.") + + def _on_stil_standard(self) -> None: + name = self._gewaehlte_vorlage() + if not name: + return + try: + self._vorlagen().setze_standard(name) + except ValueError as fehler: + warning(TITEL, str(fehler)) + return + self.aktualisieren() + + def _on_stil_loeschen(self) -> None: + name = self._gewaehlte_vorlage() + if not name or not ask_yes_no("Stilvorlage löschen", + f"Stilvorlage „{self._stil_combo.currentText()}“ löschen?", default=False): + return + self._vorlagen().loeschen(name) + self.aktualisieren()