Basemap-Bereich im Druck-Tab mit lokalen Stilvorlagen (#88)

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 11:57:10 +02:00
co-authored by Claude Opus 5.5
parent b549cb8a5a
commit f9241a11f9
7 changed files with 771 additions and 0 deletions
+3
View File
@@ -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)
+181
View File
@@ -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: ``<QGIS-Profil>/sn_basis/basemap_stile/<name>.qml`` plus ``<name>.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)
+174
View File
@@ -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()
+128
View File
@@ -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("<qgis/>")
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("<qgis/>")
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()
+13
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
"""sn_basis/ui/widgets – wiederverwendbare Bereiche für Tabs."""
+271
View File
@@ -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()