Compare commits

..
Author SHA1 Message Date
release-bot ffc56f5bc4 Release 26.04.1-unstable
Release Plugin / release (push) Successful in 3s
2026-04-08 12:40:04 +00:00
Daniel c01bf241db Merge pull request 'verfahrensgebiet-Vaiable wird sauber gesetzt' (#22) from bug/verfahrensgebiet_nicht_übernommen into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #22
2026-04-08 14:37:44 +02:00
Daniel 9ec465e276 verfahrensgebiet-Vaiable wird sauber gesetzt 2026-04-08 14:36:20 +02:00
Daniel d5b7482ebd Merge pull request 'Legende an Hauptkarte des Layouts angebunden' (#21) from bug/Legendeneintrag into unstable
Reviewed-on: #21
2026-04-08 14:08:45 +02:00
Daniel 8e4031a884 Legende an Hauptkarte des Layouts angebunden 2026-04-08 13:56:58 +02:00
Daniel f1f104a464 Merge pull request 'Projektspeicher_Abfrage beim Start behoben (3.40/3.44' (#20) from bug/projektabfrage into unstable
Reviewed-on: #20
2026-04-08 13:28:14 +02:00
Daniel f34e4ce458 Projektspeicher_Abfrage beim Start behoben (3.40/3.44 2026-04-08 13:27:00 +02:00
release-bot 30b17e0b83 Release 26.3.3-unstable 2026-03-27 10:29:25 +00:00
Daniel a96560b21b Merge pull request 'Verfahrensgebiet-Layerauswahl nach sn_verfahrensgebiet verschoben' (#14) from refactor/Verfahrensgebiet-Auswahl_nach_sn_verfahrensgebiet into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #14
2026-03-27 11:26:32 +01:00
Daniel bb95e0d536 Verfahrensgebiet-Layerauswahl nach sn_verfahrensgebiet verschoben 2026-03-27 11:19:27 +01:00
release-bot 4f0d3c12ba Release 26.3.2-unstable 2026-03-26 20:46:27 +00:00
11 changed files with 465 additions and 33 deletions
+6
View File
@@ -1,4 +1,10 @@
---
Version 26.04.1-unstable:
---
Version 26.3.3-unstable:
---
Version 26.3.2-unstable:
---
Version 26.3.1-testing:
- unstable als testing veröffentlicht
---
+56 -12
View File
@@ -174,6 +174,7 @@ except Exception:
self.project = project
self._name = ""
self._page = _MockQgsLayoutPage()
self.items: list[Any] = []
def initializeDefaults(self) -> None:
pass
@@ -188,7 +189,7 @@ except Exception:
return self._page
def addLayoutItem(self, item: Any) -> None:
pass
self.items.append(item)
class _MockQgsLayoutPage:
def setPageSize(self, size: Any) -> None:
@@ -201,15 +202,21 @@ except Exception:
class _MockQgsLayoutItemMap:
def __init__(self, layout: Any):
self.layout = layout
self.item_id = ""
self.extent = None
self.scale = None
self.follow_visibility_preset = False
self.follow_visibility_preset_name = ""
self.keep_layer_set = False
def setId(self, item_id: str) -> None:
pass
self.item_id = item_id
def setExtent(self, extent: Any) -> None:
pass
self.extent = extent
def setScale(self, scale: float) -> None:
pass
self.scale = scale
def attemptMove(self, point: Any) -> None:
pass
@@ -218,10 +225,13 @@ except Exception:
pass
def setFollowVisibilityPreset(self, active: bool) -> None:
pass
self.follow_visibility_preset = active
def setFollowVisibilityPresetName(self, name: str) -> None:
pass
self.follow_visibility_preset_name = name
def setKeepLayerSet(self, enabled: bool) -> None:
self.keep_layer_set = enabled
class _MockQgsLayoutItemLabel:
ModeHtml = 1
@@ -271,24 +281,30 @@ except Exception:
def __init__(self, layout: Any):
self.layout = layout
self._model = _MockLegendModel()
self.item_id = ""
self.object_name = ""
self.linked_map = None
self.auto_update_model = None
self.legend_filter_by_map_enabled = None
self.title = ""
def setId(self, item_id: str) -> None:
pass
self.item_id = item_id
def setObjectName(self, name: str) -> None:
pass
self.object_name = name
def setLinkedMap(self, map_item: Any) -> None:
pass
self.linked_map = map_item
def setAutoUpdateModel(self, enabled: bool) -> None:
pass
self.auto_update_model = enabled
def setLegendFilterByMapEnabled(self, enabled: bool) -> None:
pass
self.legend_filter_by_map_enabled = enabled
def setTitle(self, title: str) -> None:
pass
self.title = title
def setReferencePoint(self, point: Any) -> None:
pass
@@ -329,6 +345,34 @@ except Exception:
QgsUnitTypes = _MockQgsUnitTypes
QgsLayoutItem = _MockQgsLayoutItem
class _MockQgsFeature:
def __init__(self):
self._geometry = None
self._attributes = []
def setGeometry(self, geometry: Any) -> None:
self._geometry = geometry
def setAttributes(self, attributes: list[Any]) -> None:
self._attributes = attributes
class _MockQgsField:
def __init__(self, name: str = "", field_type: Any = None):
self.name = name
self.field_type = field_type
class _MockQgsGeometry:
@staticmethod
def unaryUnion(_geometries: list[Any]):
return None
def isEmpty(self) -> bool:
return True
QgsFeature = _MockQgsFeature
QgsField = _MockQgsField
QgsGeometry = _MockQgsGeometry
class _MockQgsFeatureRequest:
def __init__(self):
self._filter_rect = None
+4
View File
@@ -97,6 +97,10 @@ def set_variable(key: str, value: str, scope: str = "project") -> None:
var_name = f"sn_{key}"
if scope == "project":
current_value = get_variable(key, scope="project")
if current_value == value:
return
project = QgsProject.instance()
QgsExpressionContextUtils.setProjectVariable(
project,
+173
View File
@@ -0,0 +1,173 @@
"""
sn_basis/functions/verfahrensgebiet_manager.py
Zentrale Verwaltung des Verfahrensgebiet-Layers für alle sn_-Plugins.
Die Variable `sn_verfahrensgebiet_layer` wird automatisch aktualisiert, wenn:
- Ein Layer mit dem Namen "Verfahrensgebiet" ins Projekt geladen wird
- Das Projekt neu geladen wird
- Der Layer aus dem Projekt gelöscht wird
Nutzer können diese Auswahl über das Dropdown in sn_verfahrensgebiet/working_tab
manuell überschreiben.
"""
from typing import Optional
from sn_basis.functions.qgiscore_wrapper import QgsProject, QGIS_AVAILABLE
from sn_basis.functions.variable_wrapper import get_variable, set_variable
# ---------------------------------------------------------
# Globale State für Listener-Registrierung
# ---------------------------------------------------------
_listener_installed = False
# ---------------------------------------------------------
# Zentrale Verwaltung
# ---------------------------------------------------------
def get_verfahrensgebiet_layer_id() -> Optional[str]:
"""
Gibt die Layer-ID des aktuellen Verfahrensgebiet-Layers zurück.
Returns
-------
Optional[str]
Die Layer-ID oder None, wenn keine Variable gesetzt ist.
"""
value = get_variable("verfahrensgebiet_layer", scope="project")
return value if value else None
def set_verfahrensgebiet_layer_id(layer_id: str) -> None:
"""
Setzt die Layer-ID des Verfahrensgebiet-Layers (von Nutzer-Auswahl im Dropdown).
Parameters
----------
layer_id : str
Die eindeutige Layer-ID des gewählten Layers.
"""
set_variable("verfahrensgebiet_layer", layer_id, scope="project")
def _find_verfahrensgebiet_layer():
"""
Sucht einen Layer mit dem Namen 'Verfahrensgebiet' im aktuellen Projekt.
Returns
-------
tuple[Optional[layer], Optional[str]]
(Layer-Objekt, Layer-ID) oder (None, None), wenn nicht gefunden.
"""
if not QGIS_AVAILABLE:
return None, None
try:
project = QgsProject.instance()
layers = project.mapLayersByName("Verfahrensgebiet")
if layers:
# Nutze den ersten (bzw. zuletzt hinzugefügten) Layer mit diesem Namen
layer = layers[0]
return layer, layer.id()
except Exception:
pass
return None, None
def _update_verfahrensgebiet_from_project() -> bool:
"""
Sucht automatisch nach einem Layer namens 'Verfahrensgebiet' im Projekt
und aktualisiert die Variable `sn_verfahrensgebiet_layer`.
Diese Funktion wird aufgerufen bei:
- layersAdded Signal
- readProject Signal (Projekt-Reload)
- Projekt-Neuerstellung
Returns
-------
bool
True, wenn ein Layer gefunden und gespeichert wurde, False sonst.
"""
layer, layer_id = _find_verfahrensgebiet_layer()
if layer and layer_id:
set_variable("verfahrensgebiet_layer", layer_id, scope="project")
return True
else:
# Layer nicht gefunden: Variable zurücksetzen
set_variable("verfahrensgebiet_layer", "", scope="project")
return False
def _on_layers_added(layers) -> None:
"""Signal-Handler für QgsProject.layersAdded"""
_update_verfahrensgebiet_from_project()
def _on_layer_removed(layer_id: str) -> None:
"""Signal-Handler für QgsProject.layerRemoved"""
# Prüfe, ob der entfernte Layer der aktuelle Verfahrensgebiet-Layer war
current_id = get_verfahrensgebiet_layer_id()
if current_id and current_id == layer_id:
# Layer wurde entfernt; versuche, neu zu suchen
_update_verfahrensgebiet_from_project()
def _on_project_changed() -> None:
"""Signal-Handler für QgsProject.readProject, newProjectCreated, cleared"""
_update_verfahrensgebiet_from_project()
# ---------------------------------------------------------
# Listener-Setup
# ---------------------------------------------------------
def setup_verfahrensgebiet_listener() -> None:
"""
Registriert globale Event-Listener auf dem QgsProject-Singleton.
Diese Funktion sollte EINMAL beim Plugin-Start aufgerufen werden
(z.B. in sn_basis.main.BasisPlugin.initGui()).
Listener:
- layersAdded: automatische Aktualisierung, wenn Layer hinzugefügt werden
- layerRemoved: Validierung, wenn Layer gelöscht werden
- readProject: Neuinitialisierung beim Projekt-Reload
"""
global _listener_installed
if _listener_installed:
return # Bereits registriert
if not QGIS_AVAILABLE:
return # QGIS nicht verfügbar
try:
project = QgsProject.instance()
# Verbinde Signale
project.layersAdded.connect(_on_layers_added)
project.layerRemoved.connect(_on_layer_removed)
# readProject wird emittiert, wenn Projekt geladen wird
# newProjectCreated wird emittiert bei neuem Projekt
# cleared wird emittiert, wenn Projekt gelöscht wird
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is not None:
signal.connect(_on_project_changed)
_listener_installed = True
# Initial: versuche, existierenden Layer zu finden
_update_verfahrensgebiet_from_project()
except Exception:
pass
+5
View File
@@ -4,6 +4,7 @@ from qgis.utils import plugins
from sn_basis.functions.qt_wrapper import QCoreApplication
from sn_basis.functions.sys_wrapper import getattr_safe
from sn_basis.functions.verfahrensgebiet_manager import setup_verfahrensgebiet_listener
from sn_basis.ui.navigation import Navigation
@@ -34,6 +35,10 @@ class BasisPlugin:
"""
self.ui = Navigation()
self.ui.init_ui()
# Zentrale Verwaltung des Verfahrensgebiet-Layers starten
setup_verfahrensgebiet_listener()
for name, plugin in plugins.items():
if name.startswith("sn_") and name != "sn_basis":
try:
+2 -2
View File
@@ -1,11 +1,11 @@
[general]
version=26.3.1-testing
version=26.04.1-unstable
name=LNO Sachsen | Plugin Basisfunktionen
description=Plugin mit Basisfunktionen
author=Daniel Helbig, Michael Otto
homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis
tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/issues
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/testing/
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/unstable/
qgisMinimumVersion=3.40
experimental=true
+2 -18
View File
@@ -494,30 +494,14 @@ class PrintLayout:
set_auto_update_model = getattr(legend, "setAutoUpdateModel", None)
if callable(set_auto_update_model):
try:
set_auto_update_model(False)
set_auto_update_model(True)
except Exception:
pass
set_filter_by_map = getattr(legend, "setLegendFilterByMapEnabled", None)
if callable(set_filter_by_map):
try:
set_filter_by_map(False)
except Exception:
pass
model_method = getattr(legend, "model", None)
if callable(model_method):
try:
model = model_method()
root_group_method = getattr(model, "rootGroup", None)
root_group = root_group_method() if callable(root_group_method) else None
clear = getattr(root_group, "clear", None)
if callable(clear):
clear()
add_layer = getattr(root_group, "addLayer", None)
if callable(add_layer):
for layer in self._resolve_visible_map_layers(linked_map):
add_layer(layer)
set_filter_by_map(True)
except Exception:
pass
+8 -1
View File
@@ -68,7 +68,11 @@ class PrintLogic:
def _resolve_layer_from_project(self) -> object | None:
project = QgsProject.instance()
layer_id = (get_variable("verfahrensgebietslayer", scope="project") or "").strip()
# Primär: neue zentrale Variable aus sn_verfahrensgebiet_manager
layer_id = (get_variable("verfahrensgebiet_layer", scope="project") or "").strip()
# Rückwärtskompatibilität zu älteren Projekten
if not layer_id:
layer_id = (get_variable("verfahrensgebietslayer", scope="project") or "").strip()
if not layer_id:
layer_id = (get_variable("tab_a_layer_id", scope="project") or "").strip()
if not layer_id:
@@ -610,6 +614,9 @@ class PrintLogic:
if layer_for_extent is not None:
layer_id = getattr(layer_for_extent, "id", lambda: "")() or ""
if layer_id:
# Neu: zentrale Variable
set_variable("verfahrensgebiet_layer", layer_id, scope="project")
# Alt: Rückwärtskompatibilität
set_variable("verfahrensgebietslayer", layer_id, scope="project")
kartenbild_w = extent.width() * 1000.0 / massstab_zahl
+85
View File
@@ -0,0 +1,85 @@
import unittest
from unittest.mock import patch
from sn_basis.functions.qgiscore_wrapper import (
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsPrintLayout,
QgsProject,
)
from sn_basis.modules.print_layout import PrintLayout
class _FakeExtent:
def isNull(self) -> bool:
return False
class TestPrintLayout(unittest.TestCase):
def test_create_legend_item_links_map_and_enables_map_filtering(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = QgsPrintLayout(project)
linked_map = QgsLayoutItemMap(layout)
legend = print_layout._create_legend_item(
layout,
"legende",
linked_map,
10.0,
20.0,
width_mm=50.0,
height_mm=30.0,
)
self.assertIs(legend.linked_map, linked_map)
self.assertTrue(legend.auto_update_model)
self.assertTrue(legend.legend_filter_by_map_enabled)
def test_create_legend_item_does_not_copy_project_layers_into_legend_model(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = QgsPrintLayout(project)
linked_map = QgsLayoutItemMap(layout)
legend = print_layout._create_legend_item(
layout,
"legende",
linked_map,
10.0,
20.0,
)
root_group = legend.model().rootGroup()
self.assertEqual(root_group.layers, [])
@patch("sn_basis.modules.print_layout.open_layout_designer")
def test_create_single_page_layout_keeps_legend_linked_to_hauptkarte_and_theme(self, _mock_open_designer):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = print_layout.create_single_page_layout(
name="Testlayout",
page_width_mm=420.0,
page_height_mm=297.0,
map_width_mm=200.0,
map_height_mm=150.0,
extent=_FakeExtent(),
plotmassstab=5000.0,
thema="Thema A",
)
hauptkarte = next(item for item in layout.items if isinstance(item, QgsLayoutItemMap))
legende = next(item for item in layout.items if isinstance(item, QgsLayoutItemLegend))
self.assertTrue(hauptkarte.follow_visibility_preset)
self.assertEqual(hauptkarte.follow_visibility_preset_name, "Thema A")
self.assertTrue(hauptkarte.keep_layer_set)
self.assertIs(legende.linked_map, hauptkarte)
self.assertTrue(legende.auto_update_model)
self.assertTrue(legende.legend_filter_by_map_enabled)
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -0,0 +1,56 @@
import unittest
from unittest.mock import patch
from sn_basis.functions import variable_wrapper
class _Scope:
def __init__(self, value: str):
self._value = value
def variable(self, _name: str) -> str:
return self._value
class TestVariableWrapper(unittest.TestCase):
@patch("sn_basis.functions.variable_wrapper.QgsProject.instance")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setProjectVariable")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.projectScope")
def test_set_variable_project_noop_write_is_skipped(
self,
mock_project_scope,
mock_set_project_variable,
mock_project_instance,
):
mock_project_instance.return_value = object()
mock_project_scope.return_value = _Scope("layer_1")
variable_wrapper.set_variable("verfahrensgebiet_layer", "layer_1", scope="project")
mock_set_project_variable.assert_not_called()
@patch("sn_basis.functions.variable_wrapper.QgsProject.instance")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setProjectVariable")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.projectScope")
def test_set_variable_project_changed_value_is_written(
self,
mock_project_scope,
mock_set_project_variable,
mock_project_instance,
):
fake_project = object()
mock_project_instance.return_value = fake_project
mock_project_scope.return_value = _Scope("old_value")
variable_wrapper.set_variable("verfahrensgebiet_layer", "new_value", scope="project")
mock_set_project_variable.assert_called_once_with(
fake_project,
"sn_verfahrensgebiet_layer",
"new_value",
)
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
import unittest
from unittest.mock import patch
from sn_basis.functions import verfahrensgebiet_manager
class _Scope:
def __init__(self, value: str):
self._value = value
def variable(self, _name: str) -> str:
return self._value
class TestVerfahrensgebietManager(unittest.TestCase):
@patch("sn_basis.functions.verfahrensgebiet_manager.get_variable", return_value="layer_123")
def test_get_verfahrensgebiet_layer_id_returns_value(self, _mock_get_variable):
result = verfahrensgebiet_manager.get_verfahrensgebiet_layer_id()
self.assertEqual(result, "layer_123")
@patch("sn_basis.functions.verfahrensgebiet_manager.get_variable", return_value="")
def test_get_verfahrensgebiet_layer_id_returns_none_for_empty_value(self, _mock_get_variable):
result = verfahrensgebiet_manager.get_verfahrensgebiet_layer_id()
self.assertIsNone(result)
@patch("sn_basis.functions.verfahrensgebiet_manager.set_variable")
def test_set_verfahrensgebiet_layer_id_writes_project_variable(self, mock_set_variable):
verfahrensgebiet_manager.set_verfahrensgebiet_layer_id("layer_abc")
mock_set_variable.assert_called_once_with("verfahrensgebiet_layer", "layer_abc", scope="project")
@patch("sn_basis.functions.verfahrensgebiet_manager._find_verfahrensgebiet_layer")
@patch("sn_basis.functions.variable_wrapper.QgsProject.instance")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setProjectVariable")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.projectScope")
def test_update_without_layer_does_not_write_when_already_empty(
self,
mock_project_scope,
mock_set_project_variable,
mock_project_instance,
mock_find_layer,
):
mock_find_layer.return_value = (None, None)
mock_project_instance.return_value = object()
mock_project_scope.return_value = _Scope("")
result = verfahrensgebiet_manager._update_verfahrensgebiet_from_project()
self.assertFalse(result)
mock_set_project_variable.assert_not_called()
@patch("sn_basis.functions.verfahrensgebiet_manager._find_verfahrensgebiet_layer")
@patch("sn_basis.functions.verfahrensgebiet_manager.set_variable")
def test_update_with_layer_writes_variable(self, mock_set_variable, mock_find_layer):
mock_layer = object()
mock_find_layer.return_value = (mock_layer, "layer_99")
result = verfahrensgebiet_manager._update_verfahrensgebiet_from_project()
self.assertTrue(result)
mock_set_variable.assert_called_once_with("verfahrensgebiet_layer", "layer_99", scope="project")
if __name__ == "__main__":
unittest.main()