diff --git a/metadata.txt b/.gitea/templates/metadata.template similarity index 85% rename from metadata.txt rename to .gitea/templates/metadata.template index 27ee3e7..983a109 100644 --- a/metadata.txt +++ b/.gitea/templates/metadata.template @@ -1,12 +1,12 @@ [general] -version=26.4.2-testing +version= name=LNO Sachsen | Plan41 description=Plugin zum Erzeugen der Pläne nach §38 und §41 author=Daniel Helbig, Michael Otto homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Plan41 tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Plan41/issues -repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_Test_Action/src/branch/testing/ - +repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_Test_Action/src/branch/unstable/ +QGISMaximumVersion=4.99 qgisMinimumVersion=3.40 experimental=true deprecated=false diff --git a/__init__.py b/__init__.py index 40ff784..2a5bea3 100644 --- a/__init__.py +++ b/__init__.py @@ -1,3 +1,17 @@ +"""sn_plan41 – QGIS-Plugin für thematische Linklisten und Datenquellen. +Verwaltet Excel-basierte Link-Listen und Fachdatenabruf. +""" + + def classFactory(iface): + """Factory-Funktion für QGIS. + Wird von QGIS aufgerufen, um die Plugin-Instanz zu erstellen. + + Args: + iface: QGIS-Schnittstellenobjekt + + Returns: + Plan41: Instanziiertes Plugin + """ from .main import Plan41 return Plan41(iface) diff --git a/assets/GIS-P41_NSG.qml b/assets/GIS-P41_NSG.qml new file mode 100644 index 0000000..1effde4 --- /dev/null +++ b/assets/GIS-P41_NSG.qml @@ -0,0 +1,866 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "NAME" + + 2 + diff --git a/assets/GIS_P41_Biotope_Offenland_1994-2008_f.qml b/assets/GIS_P41_Biotope_Offenland_1994-2008_f.qml new file mode 100644 index 0000000..df1ef67 --- /dev/null +++ b/assets/GIS_P41_Biotope_Offenland_1994-2008_f.qml @@ -0,0 +1,1721 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 2 + diff --git a/assets/GIS_P41_Biotope_Offenland_ab_2010_f.qml b/assets/GIS_P41_Biotope_Offenland_ab_2010_f.qml new file mode 100644 index 0000000..4092b80 --- /dev/null +++ b/assets/GIS_P41_Biotope_Offenland_ab_2010_f.qml @@ -0,0 +1,2026 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 2 + diff --git a/assets/GIS_P41_Habitate_f.qml b/assets/GIS_P41_Habitate_f.qml new file mode 100644 index 0000000..3d1e9b9 --- /dev/null +++ b/assets/GIS_P41_Habitate_f.qml @@ -0,0 +1,871 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "ART_DEUTSCH" + + 2 + diff --git a/assets/GIS_p41_Biotope_gesetzl_gesch_f.qml b/assets/GIS_p41_Biotope_gesetzl_gesch_f.qml new file mode 100644 index 0000000..3ce073a --- /dev/null +++ b/assets/GIS_p41_Biotope_gesetzl_gesch_f.qml @@ -0,0 +1,1060 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "BTP26_LID" + + 2 + diff --git a/assets/Linkliste.xlsx b/assets/Linkliste.xlsx index 3a22bc8..50dfb1f 100644 Binary files a/assets/Linkliste.xlsx and b/assets/Linkliste.xlsx differ diff --git a/assets/QGIS_Biotope_Offenland_1994-2008_l.qml b/assets/QGIS_Biotope_Offenland_1994-2008_l.qml new file mode 100644 index 0000000..a6f4e92 --- /dev/null +++ b/assets/QGIS_Biotope_Offenland_1994-2008_l.qml @@ -0,0 +1,1594 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 1 + diff --git a/assets/QGIS_Biotope_Offenland_ab_2010_l.qml b/assets/QGIS_Biotope_Offenland_ab_2010_l.qml new file mode 100644 index 0000000..ba9b969 --- /dev/null +++ b/assets/QGIS_Biotope_Offenland_ab_2010_l.qml @@ -0,0 +1,1374 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "HC_KBEZ" + + 1 + diff --git a/assets/QGIS_P41_API_Layer_Linie.qml b/assets/QGIS_P41_API_Layer_Linie.qml new file mode 100644 index 0000000..6815606 --- /dev/null +++ b/assets/QGIS_P41_API_Layer_Linie.qml @@ -0,0 +1,1004 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "NAME" + + 1 + diff --git a/assets/QGIS_P41_API_Layer_flaeche.qml b/assets/QGIS_P41_API_Layer_flaeche.qml new file mode 100644 index 0000000..5ea684c --- /dev/null +++ b/assets/QGIS_P41_API_Layer_flaeche.qml @@ -0,0 +1,597 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "NAME" + + 2 + diff --git a/assets/QGIS_P41_Auen_und_Moorkulisse.qml b/assets/QGIS_P41_Auen_und_Moorkulisse.qml new file mode 100644 index 0000000..1294433 --- /dev/null +++ b/assets/QGIS_P41_Auen_und_Moorkulisse.qml @@ -0,0 +1,516 @@ + + + + 1 + 1 + 1 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "FB_BN_KAT" + + 2 + diff --git a/changelog.txt b/changelog.txt index 84adbba..28b2aaa 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,4 +1,9 @@ --- +Version 26.6.1-unstable: +- VLN-API zum Laden der Plan41-Maßnahmen integriert +--- +Version 26.4.3-unstable: +--- Version 26.4.2-testing: --- Version 26.04.1-unstable: diff --git a/main.py b/main.py index 87ab81e..fd8673f 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ -# sn_plan41/main.py +"""sn_plan41/main.py – Einstiegspunkt des sn_plan41 Plugins. +Orchestriert die UI und den Datenabruf für Thematische Linklisten. +""" from qgis.utils import plugins from sn_basis.ui.dockmanager import DockManager @@ -8,6 +10,9 @@ from sn_basis.modules.Pruefmanager import Pruefmanager class Plan41: + """Einstiegspunkt des sn_plan41-Plugins. + Verwaltet die DockWidget-Initialisierung und den Datenflusss. + """ def __init__(self, iface): self.iface = iface self.pruefmanager=Pruefmanager(ui_modus="qgis") diff --git a/modules/listenauswerter.py b/modules/listenauswerter.py index 4602aa5..86cd495 100644 --- a/modules/listenauswerter.py +++ b/modules/listenauswerter.py @@ -1,4 +1,7 @@ -#sn_plan41/modules/listenauswerter.py +"""sn_plan41/modules/listenauswerter.py – Validierung von Linklisten. +Prüft Zeilen aus DataDict auf Vollständigkeit und Konsistenz. +""" + from typing import Any, Dict, List, Mapping, Optional, Tuple from collections.abc import Mapping as _Mapping # Prüfer-Typen (werden als Instanzen erwartet) diff --git a/modules/vln_api_client.py b/modules/vln_api_client.py new file mode 100644 index 0000000..dd6eb7e --- /dev/null +++ b/modules/vln_api_client.py @@ -0,0 +1,248 @@ +"""HTTP-Client für die VLN-Manager-API. + +API-Vertrag (Stand 2026-06-08): + + Basis-URL: https://api.flurneuordnung-sachsen.de/v2 + + POST /person/login + Body: {"mail": "...", "password": "..."} + Antwort: {"data": {"userauth": "", "id": ...}} + Das userauth-Token ist der API-Key (= PERSON.url_token). + + GET /tgen -> Teilnehmergemeinschaften (Verfahren) + + GET /maps//{vkz} -> GeoJSON FeatureCollection (EPSG:25833) + PUT /maps//{vkz} <- GeoJSON (ersetzt den Layer dieser VKZ) + Antwort: {"data": {"vkz", "layer", "art", "speicher_id"}, "status": "ok"} + + Layer: umringe, p41, st, kas, we + Auth nach Login über den Header "X-API-Key". + Fehlerformat: RFC 7807 (application/problem+json). + +Es wird QgsBlockingNetworkRequest verwendet, damit Proxy- und +Zertifikatseinstellungen aus QGIS automatisch greifen. +""" + +import json + +from sn_basis.functions.qt_wrapper import QUrl, QNetworkRequest +from sn_basis.functions.qgiscore_wrapper import QgsBlockingNetworkRequest + +# Fest verdrahtete Produktiv-API des VLN Managers. +DEFAULT_BASE_URL = "https://api.flurneuordnung-sachsen.de/v2" + + +class ApiError(Exception): + """Fehler bei der Kommunikation mit der Karten-API.""" + + +class AuthError(ApiError): + """API-Key fehlt, ist abgelaufen oder hat keine Berechtigung (401/403).""" + + +class KartenApiClient: + def __init__(self, base_url=DEFAULT_BASE_URL, api_key=None, mail=None): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.mail = mail + + @property + def is_authenticated(self): + return bool(self.api_key) + + def login(self, mail, password): + """Meldet den Benutzer an. Die API liefert das userauth-Token + (= API-Key), das für alle weiteren Requests verwendet wird.""" + data = self._request( + "POST", + "/person/login", + payload={"mail": mail, "password": password}, + with_auth=False, + ) + payload = data.get("data") if isinstance(data, dict) else None + if not isinstance(payload, dict): + payload = data if isinstance(data, dict) else {} + api_key = payload.get("userauth") + if not api_key: + raise ApiError( + "Login-Antwort enthielt kein userauth-Token. " + "Erhaltene Antwort: %s" % json.dumps(data)[:300] + ) + self.api_key = api_key + self.mail = mail + return api_key + + def logout(self): + self.api_key = None + self.mail = None + + # ------------------------------------------------------------------ + # Verfahren (Teilnehmergemeinschaften) + # ------------------------------------------------------------------ + + def get_verfahren(self): + """Liste der Verfahren (TGs) als [{"vkz": ..., "name": ...}, ...], + sortiert nach VKZ. Quelle: GET /tgen.""" + data = self._request("GET", "/tgen") + + rows = None + if isinstance(data, list): + rows = data + elif isinstance(data, dict): + for key in ("data", "rows"): + value = data.get(key) + if isinstance(value, list): + rows = value + break + if isinstance(value, dict) and isinstance(value.get("rows"), list): + rows = value["rows"] + break + if rows is None: + raise ApiError( + "Antwort von /tgen hat ein unerwartetes Format: %s" + % json.dumps(data)[:200] + ) + + verfahren = [] + for row in rows: + if not isinstance(row, dict): + continue + vkz = row.get("vkz") or row.get("VKZ") + name = ( + row.get("kurzname") + or row.get("Kurzname") + or row.get("name") + or row.get("Name") + or "" + ) + if vkz: + verfahren.append({"vkz": str(vkz), "name": str(name)}) + verfahren.sort(key=lambda v: v["vkz"]) + return verfahren + + # ------------------------------------------------------------------ + # Karten-Layer + # ------------------------------------------------------------------ + + def load_layer_complete(self, layer_key, vkz, page_size=2000): + """Lädt ALLE Objekte eines Layers für eine VKZ aus KARTE_OBJEKT. + + Nutzt den Listen-Endpunkt /maps/?vkz=… mit limit/offset-Paging, + bis keine weitere Seite mehr kommt.""" + from urllib.parse import quote + + features = [] + offset = 0 + while True: + data = self._request( + "GET", + "/maps/%s?vkz=%s&limit=%d&offset=%d" + % (quote(str(layer_key)), quote(str(vkz)), page_size, offset), + ) + if not isinstance(data, dict) or data.get("type") != "FeatureCollection": + raise ApiError( + "Antwort von /maps/%s ist keine GeoJSON FeatureCollection." + % layer_key + ) + page = data.get("features", []) + features.extend(page) + if len(page) < page_size: + break + offset += page_size + return {"type": "FeatureCollection", "features": features} + + def load_feature_collection(self, path): + """Lädt einen Layer als GeoJSON FeatureCollection (dict).""" + data = self._request("GET", path) + if isinstance(data, dict): + if data.get("type") == "FeatureCollection": + return data + inner = data.get("data") + if isinstance(inner, dict) and inner.get("type") == "FeatureCollection": + return inner + raise ApiError( + "Antwort von %s ist keine GeoJSON FeatureCollection." % path + ) + + def save_feature_collection(self, path, feature_collection): + """Schreibt eine GeoJSON FeatureCollection zurück an die API + (PUT ersetzt den kompletten Layer der jeweiligen VKZ).""" + return self._request("PUT", path, payload=feature_collection) + + # ------------------------------------------------------------------ + # Intern + # ------------------------------------------------------------------ + + def _request(self, method, path, payload=None, with_auth=True): + if with_auth and not self.is_authenticated: + raise AuthError("Nicht angemeldet — bitte zuerst einloggen.") + + request = QNetworkRequest(QUrl(self.base_url + path)) + request.setHeader( + QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json" + ) + request.setRawHeader(b"Accept", b"application/json") + if with_auth: + request.setRawHeader(b"X-API-Key", self.api_key.encode("utf-8")) + + body = b"" + if payload is not None: + body = json.dumps(payload).encode("utf-8") + + blocking = QgsBlockingNetworkRequest() + if method == "GET": + error = blocking.get(request) + elif method == "POST": + error = blocking.post(request, body) + elif method == "PUT": + error = blocking.put(request, body) + else: + raise ApiError("Nicht unterstützte HTTP-Methode: %s" % method) + + reply = blocking.reply() + content = bytes(reply.content()) + status = reply.attribute( + QNetworkRequest.Attribute.HttpStatusCodeAttribute + ) + status = int(status) if status is not None else None + + if status in (401, 403): + raise AuthError( + "Keine Berechtigung für %s %s (HTTP %s)%s" + % (method, path, status, self._error_detail(content)) + ) + if error != QgsBlockingNetworkRequest.NoError: + raise ApiError( + "%s %s fehlgeschlagen: %s%s" + % (method, path, blocking.errorMessage(), self._error_detail(content)) + ) + if status is not None and status >= 400: + raise ApiError( + "%s %s lieferte HTTP %s%s" + % (method, path, status, self._error_detail(content)) + ) + + if not content: + return None + try: + return json.loads(content.decode("utf-8")) + except ValueError: + raise ApiError( + "Antwort von %s ist kein gültiges JSON." % path + ) + + @staticmethod + def _error_detail(content): + """Liest title/detail aus einer RFC-7807-Fehlerantwort.""" + if not content: + return "" + try: + problem = json.loads(content.decode("utf-8")) + parts = [ + str(problem[k]) for k in ("title", "detail") if problem.get(k) + ] + if parts: + return " — " + ": ".join(parts) + except (ValueError, AttributeError): + pass + return " — " + content[:300].decode("utf-8", "replace") diff --git a/modules/vln_api_logic.py b/modules/vln_api_logic.py new file mode 100644 index 0000000..a396f1b --- /dev/null +++ b/modules/vln_api_logic.py @@ -0,0 +1,378 @@ +""" +sn_plan41/modules/vln_api_logic.py – Fachlogik für die VLN-API-Integration in Tab A. + +Kapselt: +- Anmeldung / Abmeldung (KartenApiClient aus vln_karten) +- Persistierung des API-Keys in QSettings +- Verfahrensliste laden (GET /tgen) +- Persistierung der VKZ-Auswahl in der Projektdatei +- Plan 41 laden (2 Layer + Stile aus sn_plan41/assets/) +- Aktiven Layer hochladen + +Voraussetzung: Das Plugin `vln_karten` muss installiert sein. +Wenn nicht vorhanden, liefert :attr:`is_available` False; die UI +kann dann einen entsprechenden Hinweis anzeigen. + +Sitzungsablauf bei AuthError: +- :meth:`handle_session_expired` aufrufen → API-Key löschen + logout +- In der UI danach :meth:`_update_vln_ui_state` aufrufen und + QMessageBox.warning anzeigen. Kein automatischer Login-Dialog. +""" + +from __future__ import annotations + +from typing import Optional + +from sn_basis.functions.qt_wrapper import QSettings +from sn_basis.functions.qgiscore_wrapper import QgsProject +from sn_basis.functions.sys_wrapper import get_plugin_root, join_path +from sn_basis.functions.ly_style_wrapper import apply_style_from_path + +# Lokale API-Module — fallenübrig, sobald QGIS verfügbar ist. +# In der reinen Python-Testumgebung (kein qgis-Paket) schlagen die +# qgis-Imports in den Untermodulen fehl; daher der try/except-Guard. +try: + from sn_plan41.modules.vln_api_client import KartenApiClient, ApiError, AuthError + from sn_plan41.modules.vln_layer_manager import ( + feature_collection_to_layers, + plugin_layers, + layers_to_feature_collection, + layer_api_path, + PROP_DATASET, + PROP_VERFAHREN, + ) + VLN_KARTEN_AVAILABLE = True +except (ImportError, ModuleNotFoundError): + KartenApiClient = None # type: ignore[assignment,misc] + ApiError = Exception # type: ignore[assignment,misc] + AuthError = Exception # type: ignore[assignment,misc] + VLN_KARTEN_AVAILABLE = False + +# Geometrietyp-Konstanten (QGIS intern: 1 = Linie, 2 = Fläche) +try: + from qgis.core import Qgis as _Qgis + _GEOM_LINE = _Qgis.GeometryType.Line + _GEOM_POLYGON = _Qgis.GeometryType.Polygon +except (ImportError, AttributeError): + try: + from qgis.core import QgsWkbTypes as _QgsWkbTypes + _GEOM_LINE = _QgsWkbTypes.LineGeometry + _GEOM_POLYGON = _QgsWkbTypes.PolygonGeometry + except (ImportError, AttributeError): + _GEOM_LINE = 1 + _GEOM_POLYGON = 2 + +# Konstanten +SETTINGS_GROUP = "vln_karten" +PROJECT_SCOPE = "vln_karten" +PROJECT_KEY_VKZ = "/vkz" + +P41_DATASET_KEY = "p41" +P41_LABEL = "Plan 41 (Wege- und Gewässerplan)" +P41_PATH_TEMPLATE = "/maps/p41/{vkz}" +P41_GEOMETRY = "MultiPolygon" + +STYLE_FLAECHE = "QGIS_P41_API_Layer_flaeche.qml" +STYLE_LINIE = "QGIS_P41_API_Layer_Linie.qml" + + +class VlnApiLogic: + """ + Kapselt die VLN-API-Fachlogik für den Plan41-Tab. + + Alle Methoden, die Netzwerkkommunikation erfordern, können + ``ApiError`` oder ``AuthError`` werfen – die UI ist für die + Fehlerdarstellung zuständig. + """ + + def __init__(self, pruefmanager=None) -> None: + self.pruefmanager = pruefmanager + self._client: Optional[object] = None # KartenApiClient | None + + # API-Client aus gespeicherten Credentials initialisieren + if VLN_KARTEN_AVAILABLE: + api_key, mail = self.load_stored_credentials() + if api_key: + self._client = KartenApiClient(api_key=api_key, mail=mail) + + # ------------------------------------------------------------------ + # Verfügbarkeit + # ------------------------------------------------------------------ + + @staticmethod + def is_available() -> bool: + """True, wenn die lokalen API-Module geladen werden konnten + (erfordert eine QGIS-Laufzeitumgebung).""" + return VLN_KARTEN_AVAILABLE + + # ------------------------------------------------------------------ + # Authentifizierungsstatus + # ------------------------------------------------------------------ + + @property + def is_authenticated(self) -> bool: + """True, wenn ein gültiger API-Key vorhanden ist.""" + if self._client is None: + return False + return bool(getattr(self._client, "is_authenticated", False)) + + @property + def mail(self) -> Optional[str]: + """E-Mail-Adresse des angemeldeten Benutzers oder None.""" + if self._client is None: + return None + return getattr(self._client, "mail", None) + + # ------------------------------------------------------------------ + # QSettings: API-Key persistieren + # ------------------------------------------------------------------ + + def load_stored_credentials(self) -> tuple: + """Liest API-Key und Mail aus QSettings. + Gibt ``(api_key, mail)`` zurück – leere Strings wenn nicht vorhanden. + """ + settings = QSettings() + settings.beginGroup(SETTINGS_GROUP) + api_key = settings.value("api_key", "") + mail = settings.value("mail", "") + settings.endGroup() + return api_key or None, mail or None + + def save_api_key(self, api_key: str, mail: str) -> None: + """Speichert API-Key und Mail in QSettings.""" + settings = QSettings() + settings.beginGroup(SETTINGS_GROUP) + settings.setValue("api_key", api_key) + settings.setValue("mail", mail or "") + settings.endGroup() + + def clear_api_key(self) -> None: + """Entfernt API-Key aus QSettings.""" + settings = QSettings() + settings.beginGroup(SETTINGS_GROUP) + settings.remove("api_key") + settings.endGroup() + + # ------------------------------------------------------------------ + # Login / Logout + # ------------------------------------------------------------------ + + def login(self, mail: str, password: str) -> None: + """ + Meldet den Benutzer an und speichert den API-Key. + + :raises ApiError: Bei ungültigen Zugangsdaten oder Netzwerkproblem. + :raises RuntimeError: Wenn QGIS nicht verfügbar ist (kein qgis-Paket). + """ + if not VLN_KARTEN_AVAILABLE: + raise RuntimeError( + "VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)." + ) + client = KartenApiClient() + client.login(mail, password) # wirft ApiError bei Fehler + self._client = client + self.save_api_key(client.api_key, mail) + + def logout(self) -> None: + """Meldet den Benutzer ab (lokal, kein API-Aufruf).""" + if self._client is not None: + getattr(self._client, "logout", lambda: None)() + self._client = None + + def handle_session_expired(self) -> None: + """ + Behandelt einen abgelaufenen API-Key: + entfernt ihn aus QSettings und setzt den Client zurück. + """ + self.clear_api_key() + self.logout() + + # ------------------------------------------------------------------ + # Verfahrensliste + # ------------------------------------------------------------------ + + def get_verfahren(self) -> list: + """ + Lädt alle Verfahren (TGs) vom Server. + + :returns: Sortierte Liste von ``{"vkz": ..., "name": ...}``-Dicts. + :raises AuthError: Wenn nicht angemeldet oder Session abgelaufen. + :raises ApiError: Bei sonstigem Netzwerkfehler. + """ + if self._client is None: + raise AuthError("Nicht angemeldet.") + return self._client.get_verfahren() + + # ------------------------------------------------------------------ + # VKZ-Persistenz in Projektdatei + # ------------------------------------------------------------------ + + def load_stored_vkz(self) -> Optional[str]: + """Liest die zuletzt gewählte VKZ aus der Projektdatei.""" + try: + vkz, _ok = QgsProject.instance().readEntry( + PROJECT_SCOPE, PROJECT_KEY_VKZ, "" + ) + return vkz if vkz else None + except Exception: + return None + + def save_vkz(self, vkz: Optional[str]) -> None: + """Schreibt die gewählte VKZ in die Projektdatei.""" + if not vkz: + return + try: + QgsProject.instance().writeEntry(PROJECT_SCOPE, PROJECT_KEY_VKZ, vkz) + except Exception: + pass + + # ------------------------------------------------------------------ + # Plan 41 laden + # ------------------------------------------------------------------ + + def get_p41_layers(self, vkz: str) -> list: + """Gibt alle bereits geladenen P41-Layer für diese VKZ zurück.""" + return plugin_layers(P41_DATASET_KEY, vkz) + + def remove_p41_layers(self, vkz: str) -> None: + """Entfernt alle P41-Layer dieser VKZ aus dem Projekt.""" + existing = plugin_layers(P41_DATASET_KEY, vkz) + if existing: + QgsProject.instance().removeMapLayers([l.id() for l in existing]) + + def load_p41(self, vkz: str) -> list: + """ + Lädt den Plan-41-Datensatz für eine VKZ vom Server und legt + Memory-Layer im Projekt an. Auf die entstandenen Layer werden + die passenden QML-Stile aus ``sn_plan41/assets/`` angewendet. + + :returns: Liste der erzeugten ``QgsVectorLayer``. + :raises AuthError: Wenn nicht angemeldet oder Session abgelaufen. + :raises ApiError: Bei sonstigem Netzwerkfehler. + :raises RuntimeError: Wenn QGIS nicht verfügbar ist. + """ + if not VLN_KARTEN_AVAILABLE: + raise RuntimeError( + "VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)." + ) + if self._client is None: + raise AuthError("Nicht angemeldet.") + + path = P41_PATH_TEMPLATE.format(vkz=vkz) + feature_collection = self._client.load_layer_complete( + P41_DATASET_KEY, vkz + ) + base_name = "%s %s" % (P41_LABEL, vkz) + layers = feature_collection_to_layers( + feature_collection, + base_name, + P41_GEOMETRY, + P41_DATASET_KEY, + vkz, + path, + ) + self._apply_p41_styles(layers) + return layers + + def _apply_p41_styles(self, layers: list) -> None: + """Wendet den passenden QML-Stil auf jeden Layer an.""" + assets_dir = join_path(get_plugin_root(), "sn_plan41", "assets") + for layer in layers: + try: + gtype = layer.geometryType() + except Exception: + continue + + if gtype == _GEOM_POLYGON: + style_file = STYLE_FLAECHE + elif gtype == _GEOM_LINE: + style_file = STYLE_LINIE + else: + continue # Punkt- und None-Layer erhalten keinen Stil + + style_path = join_path(assets_dir, style_file) + apply_style_from_path(layer, style_path) + + # ------------------------------------------------------------------ + # Upload + # ------------------------------------------------------------------ + + def upload_active_layer(self, active_layer) -> tuple: + """ + Lädt alle Teil-Layer des aktiven Layers zum Server hoch. + + :param active_layer: Aktiver ``QgsVectorLayer`` (aus ``iface.activeLayer()``). + :returns: ``(success: bool, message: str)`` + :raises AuthError: Wenn Session abgelaufen. + :raises ApiError: Bei Netzwerkfehler. + """ + if not VLN_KARTEN_AVAILABLE: + return False, ( + "VLN-API-Module nicht verfügbar (QGIS-Laufzeitumgebung benötigt)." + ) + + if self._client is None: + raise AuthError("Nicht angemeldet.") + + path = layer_api_path(active_layer) if active_layer else None + if not path: + return ( + False, + "Der aktive Layer wurde nicht über 'vln_karten' geladen. " + "Bitte einen Plan41-Layer aus dem Projekt auswählen.", + ) + + dataset_key = active_layer.customProperty(PROP_DATASET) + vkz = active_layer.customProperty(PROP_VERFAHREN) + siblings = plugin_layers(dataset_key, vkz) + + for layer in siblings: + if layer.isEditable() and not layer.commitChanges(): + return ( + False, + "Die Bearbeitungssitzung von '%s' konnte nicht " + "gespeichert werden." % layer.name(), + ) + + feature_collection = layers_to_feature_collection(siblings) + self._client.save_feature_collection(path, feature_collection) + + total = sum(layer.featureCount() for layer in siblings) + return ( + True, + "%d Objekte aus %d Layer(n) erfolgreich hochgeladen." + % (total, len(siblings)), + ) + + def upload_summary(self, active_layer) -> Optional[dict]: + """ + Gibt eine Zusammenfassung der zu hochladenden Layer zurück + (für den Bestätigungs-Dialog in der UI), ohne den Upload + tatsächlich auszuführen. + + :returns: Dict mit ``dataset_label``, ``vkz``, ``siblings`` + (Layer-Liste), ``total`` (Objektanzahl), ``listing`` (str) + oder ``None`` wenn kein gültiger Layer. + """ + if not VLN_KARTEN_AVAILABLE or active_layer is None: + return None + path = layer_api_path(active_layer) + if not path: + return None + + dataset_key = active_layer.customProperty(PROP_DATASET) + vkz = active_layer.customProperty(PROP_VERFAHREN) + siblings = plugin_layers(dataset_key, vkz) + + total = sum(l.featureCount() for l in siblings) + listing = "\n".join( + "• %s (%d Objekte)" % (l.name(), l.featureCount()) + for l in siblings + ) + return { + "dataset_label": P41_LABEL if dataset_key == P41_DATASET_KEY else dataset_key, + "vkz": vkz, + "siblings": siblings, + "total": total, + "listing": listing, + } diff --git a/modules/vln_layer_manager.py b/modules/vln_layer_manager.py new file mode 100644 index 0000000..c18b35f --- /dev/null +++ b/modules/vln_layer_manager.py @@ -0,0 +1,177 @@ +"""Konvertierung GeoJSON <-> QGIS-Layer für die VLN-API. + +Lokale Kopie des Layer-Managers aus dem vln_karten-Plugin — sn_plan41 +benötigt kein separat installiertes vln_karten-Plugin mehr. + +Die API liefert je Layer (z.B. Plan 41) gemischte Geometrietypen in einer +FeatureCollection: Punkte, Linien, Polygone sowie deren Multi-Varianten. +QGIS-Memory-Layer können nur einen Geometrietyp halten — beim Laden wird +deshalb nach Geometrie-Familie (Punkte/Linien/Flächen) in bis zu drei +Layer gesplittet; Single-Geometrien werden zu Multi befördert. + +Beim Hochladen müssen alle Teil-Layer desselben Datensatzes wieder zu +EINER FeatureCollection vereint werden, weil der PUT der API den +kompletten Layer-Bestand der VKZ ersetzt. + +Custom-Properties an Layern (kompatibel mit vln_karten, falls beide +Plugins gleichzeitig installiert sind): + vln_karten/dataset — z.B. "p41" + vln_karten/verfahren — VKZ-Nummer + vln_karten/api_path — API-Pfad für Upload +""" + +import json + +from sn_basis.functions.qgiscore_wrapper import ( + QgsJsonExporter, + QgsJsonUtils, + QgsProject, + QgsVectorLayer, + GEOM_POINT, + GEOM_LINE, + GEOM_POLYGON, +) + +_GEOM_POINT = GEOM_POINT +_GEOM_LINE = GEOM_LINE +_GEOM_POLYGON = GEOM_POLYGON + +# Die VLN-API liefert Koordinaten in ETRS89 / UTM Zone 33 (EPSG:25833). +GEOJSON_CRS = "EPSG:25833" + +# Custom-Property-Schlüssel — identisch mit vln_karten für Kompatibilität. +PROP_DATASET = "vln_karten/dataset" +PROP_VERFAHREN = "vln_karten/verfahren" +PROP_PATH = "vln_karten/api_path" + +# Geometrie-Familie → (Memory-Provider-Typ, Namenszusatz). +# Reihenfolge = Lade-Reihenfolge: Flächen zuerst, damit Punkte im +# Layerbaum oben landen und nicht verdeckt werden. +_FAMILIES = ( + ("polygon", "MultiPolygon", "Flächen"), + ("line", "MultiLineString", "Linien"), + ("point", "MultiPoint", "Punkte"), + ("none", "None", "ohne Geometrie"), +) + + +def _family(feature): + """Geometrie-Familie eines Features. Unbekannte Typen (z.B. + GeometryCollection) landen bei 'none'.""" + if not feature.hasGeometry() or feature.geometry().isNull(): + return "none" + gtype = feature.geometry().type() + if gtype == _GEOM_POINT: + return "point" + if gtype == _GEOM_LINE: + return "line" + if gtype == _GEOM_POLYGON: + return "polygon" + return "none" + + +def memory_layer_from_features( + uri_type, name, fields, features, dataset_key, verfahren_nr, api_path, promote=True +): + """Baut einen Memory-Layer aus fertigen QgsFeatures, setzt die + Plugin-Custom-Properties und hängt ihn ins Projekt.""" + uri = uri_type if uri_type == "None" else "%s?crs=%s" % (uri_type, GEOJSON_CRS) + layer = QgsVectorLayer(uri, name, "memory") + if not layer.isValid(): + raise RuntimeError("Memory-Layer '%s' konnte nicht erzeugt werden." % name) + provider = layer.dataProvider() + provider.addAttributes(fields.toList()) + layer.updateFields() + prepared = [] + for feature in features: + geometry = feature.geometry() + if promote and not geometry.isNull() and not geometry.isMultipart(): + geometry.convertToMultiType() + feature.setGeometry(geometry) + prepared.append(feature) + provider.addFeatures(prepared) + layer.updateExtents() + layer.setCustomProperty(PROP_DATASET, dataset_key) + layer.setCustomProperty(PROP_VERFAHREN, verfahren_nr) + layer.setCustomProperty(PROP_PATH, api_path) + return layer + + +def feature_collection_to_layers( + feature_collection, base_name, default_geometry, dataset_key, verfahren_nr, api_path +): + """Erzeugt aus einer GeoJSON FeatureCollection je vorkommender + Geometrie-Familie einen Memory-Layer und hängt sie ins Projekt. + Liefert die Liste der erzeugten Layer.""" + text = json.dumps(feature_collection) + fields = QgsJsonUtils.stringToFields(text) + features = QgsJsonUtils.stringToFeatureList(text, fields) + + groups = {} + for feature in features: + groups.setdefault(_family(feature), []).append(feature) + + layers = [] + multiple = len(groups) > 1 + + def make_layer(uri_type, name, group_features, promote): + layers.append( + memory_layer_from_features( + uri_type, name, fields, group_features, + dataset_key, verfahren_nr, api_path, promote=promote, + ) + ) + + if not groups: + # Leerer Datensatz: ein leerer Layer im Default-Typ, damit der + # Nutzer digitalisieren und hochladen kann. + make_layer(default_geometry, base_name, [], promote=False) + else: + for family, uri_type, suffix in _FAMILIES: + if family not in groups: + continue + name = "%s — %s" % (base_name, suffix) if multiple else base_name + make_layer(uri_type, name, groups[family], promote=(family != "none")) + + QgsProject.instance().addMapLayers(layers) + return layers + + +def plugin_layers(dataset_key, verfahren_nr): + """Alle Layer im Projekt, die zu diesem Datensatz und dieser VKZ + gehören (z.B. die Punkte-/Linien-/Flächen-Teil-Layer von Plan 41).""" + result = [] + for layer in QgsProject.instance().mapLayers().values(): + if ( + isinstance(layer, QgsVectorLayer) + and layer.customProperty(PROP_DATASET) == dataset_key + and str(layer.customProperty(PROP_VERFAHREN)) == str(verfahren_nr) + ): + result.append(layer) + return result + + +def layers_to_feature_collection(layers): + """Vereint die Features mehrerer Layer zu EINER GeoJSON + FeatureCollection (für den PUT, der den Serverbestand ersetzt). + + Keine Transformation nach WGS84: Die API erwartet die Koordinaten im + selben CRS, in dem sie sie liefert (EPSG:25833).""" + merged = [] + for layer in layers: + exporter = QgsJsonExporter(layer) + exporter.setSourceCrs(layer.crs()) + exporter.setTransformGeometries(False) + collection = json.loads( + exporter.exportFeatures(list(layer.getFeatures())) + ) + merged.extend(collection.get("features", [])) + return {"type": "FeatureCollection", "features": merged} + + +def layer_api_path(layer): + """API-Pfad, unter dem dieser Layer geladen wurde — oder None, + wenn der Layer nicht von diesem Plugin stammt.""" + if not isinstance(layer, QgsVectorLayer): + return None + return layer.customProperty(PROP_PATH) or None diff --git a/tests/__init__.py b/tests/__init__.py index 324c4b2..f0f6299 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1,3 @@ -#Testordner \ No newline at end of file +"""sn_plan41/tests – Test-Suite für das sn_plan41 Plugin. +Enthält Unit-Tests und Integrationstests für Tab-Logik und Datengrabber. +""" diff --git a/tests/run_tests.py b/tests/run_tests.py index 42e1cdd..cae8f33 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -5,6 +5,7 @@ Zentraler Test-Runner für sn_plan41. Wrapper-konform, QGIS-unabhängig, CI- und IDE-fähig. """ +import io import unittest import datetime import inspect @@ -12,6 +13,19 @@ import os import sys from pathlib import Path +# Stdout auf UTF-8 umstellen, damit Emoji-Print-Statements in +# qt_wrapper.py (Mock-Modus) auf Windows-Konsolen (CP1252) nicht zu +# UnicodeEncodeError führen. +if hasattr(sys.stdout, "reconfigure"): + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass +elif hasattr(sys.stdout, "buffer"): + sys.stdout = io.TextIOWrapper( + sys.stdout.buffer, encoding="utf-8", errors="replace" + ) + # --------------------------------------------------------- # Plugin-Roots bestimmen diff --git a/tests/test_listenauswerter.py b/tests/test_listenauswerter.py new file mode 100644 index 0000000..1eadfcd --- /dev/null +++ b/tests/test_listenauswerter.py @@ -0,0 +1,84 @@ +import unittest +from typing import Any + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_plan41.modules.listenauswerter import Listenauswerter + + +class _DummyPruefmanager: + def __init__(self) -> None: + self.seen = [] + + def verarbeite(self, ergebnis: Any): + self.seen.append(ergebnis) + return ergebnis + + +class _DummyStilPruefer: + def __init__(self, ok: bool = True) -> None: + self.ok = ok + + def pruefe(self, _path: str) -> pruef_ergebnis: + if self.ok: + return pruef_ergebnis(ok=True, meldung="ok", aktion="ok", kontext=None) + return pruef_ergebnis(ok=False, meldung="stil fehler", aktion="falsche_endung", kontext=None) + + +class TestListenauswerter(unittest.TestCase): + + def test_validate_rows_accepts_valid_row_and_normalizes_provider(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + data = {"rows": [{"ident": "A1", "Inhalt": "Test", "Link": "http://x", "Provider": "wfs"}]} + validated, processed = validator.validate_rows(data) + + self.assertEqual(len(validated["rows"]), 1) + self.assertEqual(validated["rows"][0]["Provider"], "WFS") + self.assertEqual(processed, []) + + def test_validate_rows_rejects_missing_required_fields(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + data = {"rows": [{"ident": "", "Link": "", "Provider": ""}]} + validated, processed = validator.validate_rows(data) + + self.assertEqual(validated["rows"], []) + self.assertEqual(len(processed), 1) + self.assertEqual(processed[0].aktion, "pflichtfelder_fehlen") + + def test_validate_rows_sets_stildatei_none_on_style_failure(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=False)) + + data = { + "rows": [ + { + "ident": "A1", + "Inhalt": "Thema", + "Link": "http://x", + "Provider": "WMS", + "Stildatei": "broken.qml", + } + ] + } + validated, processed = validator.validate_rows(data) + + self.assertEqual(len(validated["rows"]), 1) + self.assertIsNone(validated["rows"][0]["stildatei"]) + self.assertEqual(len(processed), 1) + self.assertEqual(processed[0].aktion, "falsche_endung") + + def test_validate_rows_handles_non_mapping_rows_edge_case(self): + manager = _DummyPruefmanager() + validator = Listenauswerter(manager, _DummyStilPruefer(ok=True)) + + validated, processed = validator.validate_rows({"rows": ["invalid", 123, None]}) + + self.assertEqual(validated, {"rows": []}) + self.assertEqual(processed, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tab_a_logic.py b/tests/test_tab_a_logic.py index 94c3bd8..595d4d0 100644 --- a/tests/test_tab_a_logic.py +++ b/tests/test_tab_a_logic.py @@ -1,99 +1,109 @@ import unittest from pathlib import Path from tempfile import TemporaryDirectory +from typing import Any, cast +from unittest.mock import patch -from sn_plan41.ui.tab_a_logic import TabALogic # type: ignore -from sn_basis.functions.variable_wrapper import get_variable # type: ignore -from sn_basis.functions.sys_wrapper import file_exists # type: ignore +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.functions.variable_wrapper import get_variable +from sn_plan41.ui.tab_a_logic import TabALogic + + +class _DummyPruefmanager: + def verarbeite(self, ergebnis): + return ergebnis class TestTabALogic(unittest.TestCase): - # ----------------------------------------------------- - # 1. Verfahrens-DB setzen und laden - # ----------------------------------------------------- - def test_verfahrens_db_set_and_load(self): - logic = TabALogic() + def _make_logic(self) -> TabALogic: + return TabALogic( + pruefmanager=cast(Any, _DummyPruefmanager()), + link_pruefer=cast(Any, object()), + stil_pruefer=cast(Any, object()), + ) - with TemporaryDirectory() as tmp: - db_path = Path(tmp) / "test.gpkg" - db_path.write_text("") + def test_verfahrens_db_set_and_load_roundtrip(self): + logic = self._make_logic() + logic.set_verfahrens_db("C:/tmp/v_db.gpkg") + self.assertEqual(logic.load_verfahrens_db(), "C:/tmp/v_db.gpkg") - logic.set_verfahrens_db(str(db_path)) - - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, str(db_path)) - - loaded = logic.load_verfahrens_db() - self.assertEqual(loaded, str(db_path)) - - # ----------------------------------------------------- - # 2. Verfahrens-DB löschen - # ----------------------------------------------------- def test_verfahrens_db_clear(self): - logic = TabALogic() - + logic = self._make_logic() logic.set_verfahrens_db(None) + self.assertEqual(get_variable("verfahrens_db", scope="project"), "") - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, "") - - # ----------------------------------------------------- - # 3. Neue Verfahrens-DB anlegen - # ----------------------------------------------------- - def test_create_new_verfahrens_db(self): - logic = TabALogic() - - with TemporaryDirectory() as tmp: - db_path = Path(tmp) / "neu.gpkg" - - result = logic.create_new_verfahrens_db(str(db_path)) - - self.assertTrue(result) - self.assertTrue(file_exists(db_path)) - - stored = get_variable("verfahrens_db", scope="project") - self.assertEqual(stored, str(db_path)) - - def test_create_new_verfahrens_db_with_none_path(self): - logic = TabALogic() - result = logic.create_new_verfahrens_db(None) - self.assertFalse(result) - - - - # ----------------------------------------------------- - # 4. Linkliste setzen und laden - # ----------------------------------------------------- - def test_linkliste_set_and_load(self): - logic = TabALogic() - + def test_linkliste_set_and_load_existing_path(self): + logic = self._make_logic() with TemporaryDirectory() as tmp: link_path = Path(tmp) / "links.xlsx" - link_path.write_text("dummy") + link_path.write_text("x", encoding="utf-8") logic.set_linkliste(str(link_path)) + self.assertEqual(logic.load_linkliste(), str(link_path)) - stored = get_variable("linkliste", scope="project") - self.assertEqual(stored, str(link_path)) + def test_linkliste_load_returns_none_when_file_missing(self): + logic = self._make_logic() + logic.set_linkliste("C:/tmp/missing.xlsx") + self.assertIsNone(logic.load_linkliste()) - loaded = logic.load_linkliste() - self.assertEqual(loaded, str(link_path)) + @patch("sn_plan41.ui.tab_a_logic.get_plugin_root", return_value="C:/plugins") + @patch("sn_plan41.ui.tab_a_logic.join_path", return_value="C:/plugins/sn_plan41/assets/Linkliste.xlsx") + def test_resolve_linkliste_empty_uses_standard_and_persists(self, _mock_join_path, _mock_root): + logic = self._make_logic() + resolved = logic._resolve_linkliste(None) - # ----------------------------------------------------- - # 5. Linkliste löschen - # ----------------------------------------------------- - def test_linkliste_clear(self): - logic = TabALogic() + self.assertEqual(resolved, "C:/plugins/sn_plan41/assets/Linkliste.xlsx") + self.assertEqual(get_variable("linkliste", scope="project"), "C:/plugins/sn_plan41/assets/Linkliste.xlsx") - logic.set_linkliste(None) + @patch("sn_plan41.ui.tab_a_logic.Dateipruefer") + def test_resolve_linkliste_returns_none_when_pruefmanager_rejects(self, mock_dateipruefer): + logic = self._make_logic() + mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis( + ok=False, + meldung="Ungültig", + aktion="datei_nicht_gefunden", + kontext=None, + ) - stored = get_variable("linkliste", scope="project") - self.assertEqual(stored, "") + resolved = logic._resolve_linkliste("C:/tmp/missing.xlsx") + self.assertIsNone(resolved) - # ----------------------------------------------------- - # 6. Layer-ID speichern - # ----------------------------------------------------- + def test_resolve_raumfilter_without_filter_returns_none(self): + logic = self._make_logic() + result = logic._resolve_raumfilter("ohne", "C:/tmp/v_db.gpkg") + self.assertIsNone(result) + + @patch.object(TabALogic, "_get_verfahrensgebiet_layer", return_value="layer_obj") + def test_resolve_raumfilter_verfahrensgebiet_uses_layer_lookup(self, mock_get_layer): + logic = self._make_logic() + result = logic._resolve_raumfilter("Verfahrensgebiet", "C:/tmp/v_db.gpkg") + + self.assertEqual(result, "layer_obj") + mock_get_layer.assert_called_once() + + def test_extract_datenabruf_error_maps_merges_reason_maps(self): + logic = self._make_logic() + results = [ + pruef_ergebnis( + ok=False, + meldung="Head failed", + aktion="url_nicht_erreichbar", + kontext={"ident": "A1", "thema": "Dienst A", "error": "Timeout"}, + ), + pruef_ergebnis( + ok=False, + meldung="Fetch failed", + aktion="datenabruf", + kontext={"fehler": {"Dienst B": "HTTP 500", "Dienst A": "ignored"}}, + ), + ] + + merged = logic._extract_datenabruf_error_maps(results) + + self.assertEqual(merged["by_ident"]["A1"], "Timeout") + self.assertEqual(merged["by_thema"]["Dienst A"], "Timeout") + self.assertEqual(merged["by_thema"]["Dienst B"], "HTTP 500") if __name__ == "__main__": diff --git a/tests/test_tab_a_ui.py b/tests/test_tab_a_ui.py index 4911949..1fa7022 100644 --- a/tests/test_tab_a_ui.py +++ b/tests/test_tab_a_ui.py @@ -1,56 +1,113 @@ -""" -Smoke-Tests für TabA UI (sn_plan41/ui/tab_a_ui.py) - -Ziel: -- UI kann erstellt werden -- Callbacks crashen nicht -- Keine Qt-Verhaltensprüfung -""" - import unittest +from unittest.mock import patch -from sn_plan41.ui.tab_a_ui import TabA #type:ignore +from sn_basis.functions.variable_wrapper import get_variable +from sn_plan41.ui.tab_a_ui import TabA + + +class _DummySignal: + def connect(self, _callback): + return None + + +class _DummyToggleButton: + def __init__(self): + self.arrow = None + self.style = "" + + def setArrowType(self, arrow): + self.arrow = arrow + + def setStyleSheet(self, style): + self.style = style + + +class _DummyWidget: + def __init__(self): + self.visible = None + + def setVisible(self, visible): + self.visible = visible + + +class _DummyFileWidget: + def __init__(self): + self._path = "" + + def setFilePath(self, path): + self._path = path + + def filePath(self): + return self._path + + +class _DummyCombo: + def __init__(self, value="Pufferlayer"): + self._value = value + + def currentText(self): + return self._value + + +class _DummyPruefmanager: + pass + + +class _DummyDataGrabber: + pass class TestTabAUI(unittest.TestCase): - # ----------------------------------------------------- - # 1. UI kann erstellt werden - # ----------------------------------------------------- - def test_tab_a_ui_can_be_created(self): - tab = TabA(parent=None,build_ui=False) + def _make_tab_without_qt(self): + tab = TabA.__new__(TabA) + tab.group_button = _DummyToggleButton() + tab.group_content = _DummyWidget() + tab.optional_button = _DummyToggleButton() + tab.optional_content = _DummyWidget() + tab.file_widget = _DummyFileWidget() + tab.linkliste_widget = _DummyFileWidget() + tab._raumfilter_combo = _DummyCombo("ohne") + tab.verfahrens_db = None + tab.lokale_linkliste = None + tab.logic = None + tab.pruefmanager = None + tab.data_grabber = None + return tab - self.assertIsNotNone(tab) - self.assertEqual(tab.tab_title, "Daten") - - # ----------------------------------------------------- - # 2. Toggle-Callbacks crashen nicht - # ----------------------------------------------------- - def test_tab_a_toggle_callbacks_do_not_crash(self): - tab = TabA(parent=None,build_ui=False) + def test_tab_a_callbacks_can_be_called_without_crash(self): + tab = self._make_tab_without_qt() tab._toggle_group(True) tab._toggle_group(False) - tab._toggle_optional(True) tab._toggle_optional(False) - - # ----------------------------------------------------- - # 3. Datei-Callbacks akzeptieren leere Eingaben - # ----------------------------------------------------- - def test_tab_a_file_callbacks_accept_empty_input(self): - tab = TabA(parent=None,build_ui=False) - tab._on_verfahrens_db_changed("") tab._on_linkliste_changed("") - # ----------------------------------------------------- - # 4. Layer-Callback akzeptiert None - # ----------------------------------------------------- - def test_tab_a_layer_callback_accepts_none(self): - tab = TabA(parent=None,build_ui=False) + self.assertEqual(tab.group_content.visible, False) + self.assertEqual(tab.optional_content.visible, False) - tab._on_layer_changed(None) + def test_tab_a_raumfilter_callback_persists_value(self): + tab = self._make_tab_without_qt() + + tab._on_raumfilter_changed("ohne") + self.assertEqual(get_variable("Raumfilter", scope="project"), "ohne") + + @patch("sn_plan41.ui.tab_a_ui.TabALogic") + def test_set_services_initializes_logic_and_injects_data_grabber(self, mock_logic_cls): + tab = self._make_tab_without_qt() + mock_logic = mock_logic_cls.return_value + + pm = _DummyPruefmanager() + dg = _DummyDataGrabber() + + tab.set_services(pruefmanager=pm, data_grabber=dg) + + self.assertIs(tab.pruefmanager, pm) + self.assertIs(tab.data_grabber, dg) + self.assertIs(tab.logic, mock_logic) + self.assertIs(tab.logic.data_grabber, dg) if __name__ == "__main__": diff --git a/tests/test_vln_api_logic.py b/tests/test_vln_api_logic.py new file mode 100644 index 0000000..142b357 --- /dev/null +++ b/tests/test_vln_api_logic.py @@ -0,0 +1,473 @@ +""" +Tests für sn_plan41/modules/vln_api_logic.py + +Alle externen Abhängigkeiten (vln_karten, QSettings, QgsProject, Netzwerk) +werden mit unittest.mock gemockt, sodass keine QGIS-Laufzeitumgebung nötig ist. +""" + +import unittest +from typing import Any, Optional +from unittest.mock import MagicMock, patch, call + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_logic(api_key: Optional[str] = None, mail: Optional[str] = None): + """Erzeugt eine VlnApiLogic-Instanz mit gemockten QSettings.""" + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs.value.side_effect = lambda key, default="": ( + api_key if key == "api_key" else (mail if key == "mail" else default) + ) + mock_qs_cls.return_value = mock_qs + + if api_key and VlnApiLogic.is_available(): + with patch( + "sn_plan41.modules.vln_api_logic.KartenApiClient" + ) as mock_client_cls: + mock_client = MagicMock() + mock_client.is_authenticated = True + mock_client.api_key = api_key + mock_client.mail = mail + mock_client_cls.return_value = mock_client + logic = VlnApiLogic() + else: + logic = VlnApiLogic() + + return logic + + +# --------------------------------------------------------------------------- +# Tests: Verfügbarkeit +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicAvailability(unittest.TestCase): + + def test_is_available_reflects_import_state(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + result = VlnApiLogic.is_available() + # Ergebnis hängt davon ab, ob vln_karten installiert ist — + # wir prüfen nur, dass ein bool zurückkommt + self.assertIsInstance(result, bool) + + def test_is_available_false_when_vln_karten_missing(self): + """Wenn VLN_KARTEN_AVAILABLE=False, liefert is_available() False.""" + import sn_plan41.modules.vln_api_logic as module + original = module.VLN_KARTEN_AVAILABLE + try: + module.VLN_KARTEN_AVAILABLE = False + from sn_plan41.modules.vln_api_logic import VlnApiLogic + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + self.assertFalse(VlnApiLogic.is_available()) + finally: + module.VLN_KARTEN_AVAILABLE = original + + +# --------------------------------------------------------------------------- +# Tests: Credentials-Persistenz +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicCredentials(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + return VlnApiLogic() + + def test_save_api_key_calls_qsettings(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs_cls.return_value = mock_qs + logic.save_api_key("token123", "user@test.de") + + mock_qs.setValue.assert_any_call("api_key", "token123") + mock_qs.setValue.assert_any_call("mail", "user@test.de") + mock_qs.beginGroup.assert_called_with("vln_karten") + + def test_clear_api_key_calls_remove(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs_cls.return_value = mock_qs + logic.clear_api_key() + + mock_qs.remove.assert_called_with("api_key") + + def test_load_stored_credentials_returns_api_key_and_mail(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs.value.side_effect = lambda key, default="": { + "api_key": "mykey", + "mail": "user@test.de", + }.get(key, default) + mock_qs_cls.return_value = mock_qs + + api_key, mail = logic.load_stored_credentials() + + self.assertEqual(api_key, "mykey") + self.assertEqual(mail, "user@test.de") + + def test_load_stored_credentials_returns_none_for_empty(self): + logic = self._make() + with patch("sn_plan41.modules.vln_api_logic.QSettings") as mock_qs_cls: + mock_qs = MagicMock() + mock_qs.value.return_value = "" + mock_qs_cls.return_value = mock_qs + + api_key, mail = logic.load_stored_credentials() + + self.assertIsNone(api_key) + self.assertIsNone(mail) + + +# --------------------------------------------------------------------------- +# Tests: Login / Logout +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicLogin(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + return VlnApiLogic() + + def test_login_sets_client_and_saves_key(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + mock_client = MagicMock() + mock_client.api_key = "newtoken" + + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client): + with patch.object(logic, "save_api_key") as mock_save: + logic.login("user@test.de", "secret") + + mock_client.login.assert_called_once_with("user@test.de", "secret") + mock_save.assert_called_once_with("newtoken", "user@test.de") + + def test_login_raises_runtime_error_without_qgis_modules(self): + import sn_plan41.modules.vln_api_logic as module + logic = self._make() + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + with self.assertRaises(RuntimeError): + logic.login("user@test.de", "secret") + + def test_login_propagates_api_error(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + mock_client = MagicMock() + mock_client.login.side_effect = module.ApiError("Ungültige Zugangsdaten") + + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient", return_value=mock_client): + with self.assertRaises(Exception): + logic.login("user@test.de", "wrong") + + def test_logout_clears_client(self): + logic = self._make() + logic._client = MagicMock() + logic.logout() + self.assertIsNone(logic._client) + + def test_is_authenticated_false_after_logout(self): + logic = self._make() + logic._client = None + self.assertFalse(logic.is_authenticated) + + def test_handle_session_expired_clears_key_and_client(self): + logic = self._make() + with patch.object(logic, "clear_api_key") as mock_clear: + with patch.object(logic, "logout") as mock_logout: + logic.handle_session_expired() + + mock_clear.assert_called_once() + mock_logout.assert_called_once() + + +# --------------------------------------------------------------------------- +# Tests: Verfahrensliste +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicVerfahren(unittest.TestCase): + + def _make_authenticated(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + + mock_client = MagicMock() + mock_client.is_authenticated = True + logic._client = mock_client + return logic, mock_client + + def test_get_verfahren_raises_when_not_authenticated(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + logic._client = None + + with self.assertRaises(Exception): + logic.get_verfahren() + + def test_get_verfahren_delegates_to_client(self): + logic, mock_client = self._make_authenticated() + mock_client.get_verfahren.return_value = [ + {"vkz": "27010", "name": "Alpha"}, + {"vkz": "27001", "name": "Beta"}, + ] + result = logic.get_verfahren() + mock_client.get_verfahren.assert_called_once() + self.assertEqual(len(result), 2) + + +# --------------------------------------------------------------------------- +# Tests: VKZ-Persistenz +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicVkz(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + return VlnApiLogic() + + def test_load_stored_vkz_reads_from_project(self): + logic = self._make() + mock_project = MagicMock() + mock_project.readEntry.return_value = ("27010", True) + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + vkz = logic.load_stored_vkz() + + self.assertEqual(vkz, "27010") + mock_project.readEntry.assert_called_once_with("vln_karten", "/vkz", "") + + def test_load_stored_vkz_returns_none_for_empty(self): + logic = self._make() + mock_project = MagicMock() + mock_project.readEntry.return_value = ("", True) + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + vkz = logic.load_stored_vkz() + + self.assertIsNone(vkz) + + def test_save_vkz_writes_to_project(self): + logic = self._make() + mock_project = MagicMock() + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + logic.save_vkz("27010") + + mock_project.writeEntry.assert_called_once_with("vln_karten", "/vkz", "27010") + + def test_save_vkz_does_nothing_for_none(self): + logic = self._make() + mock_project = MagicMock() + + with patch("sn_plan41.modules.vln_api_logic.QgsProject") as mock_qgsproj_cls: + mock_qgsproj_cls.instance.return_value = mock_project + logic.save_vkz(None) + + mock_project.writeEntry.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests: Plan 41 laden (Stil-Anwendung) +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicLoadP41(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + + mock_client = MagicMock() + mock_client.is_authenticated = True + mock_client.load_layer_complete.return_value = { + "type": "FeatureCollection", + "features": [], + } + logic._client = mock_client + return logic + + def test_load_p41_raises_without_vln_karten(self): + import sn_plan41.modules.vln_api_logic as module + logic = self._make() + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + with self.assertRaises(RuntimeError): + logic.load_p41("27010") + + def test_load_p41_applies_styles_to_polygon_and_line_layers(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + # Zwei Mock-Layer: einer Polygon (2), einer Linie (1) + mock_layer_polygon = MagicMock() + mock_layer_polygon.geometryType.return_value = module._GEOM_POLYGON + + mock_layer_line = MagicMock() + mock_layer_line.geometryType.return_value = module._GEOM_LINE + + with patch( + "sn_plan41.modules.vln_api_logic.feature_collection_to_layers", + return_value=[mock_layer_polygon, mock_layer_line], + ): + with patch( + "sn_plan41.modules.vln_api_logic.apply_style_from_path" + ) as mock_style: + with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)): + with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"): + logic.load_p41("27010") + + # Prüfen, dass der richtige Stil für jeden Geometrietyp verwendet wurde + style_calls = [str(c) for c in mock_style.call_args_list] + polygon_style_used = any( + module.STYLE_FLAECHE in c for c in style_calls + ) + line_style_used = any( + module.STYLE_LINIE in c for c in style_calls + ) + self.assertTrue(polygon_style_used, "Flächen-Stil wurde nicht angewendet") + self.assertTrue(line_style_used, "Linien-Stil wurde nicht angewendet") + + def test_load_p41_no_style_for_point_layer(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + mock_layer_point = MagicMock() + mock_layer_point.geometryType.return_value = 0 # Point + + with patch( + "sn_plan41.modules.vln_api_logic.feature_collection_to_layers", + return_value=[mock_layer_point], + ): + with patch( + "sn_plan41.modules.vln_api_logic.apply_style_from_path" + ) as mock_style: + with patch("sn_plan41.modules.vln_api_logic.join_path", side_effect=lambda *p: "/".join(p)): + with patch("sn_plan41.modules.vln_api_logic.get_plugin_root", return_value="/plugins"): + logic.load_p41("27010") + + mock_style.assert_not_called() + + +# --------------------------------------------------------------------------- +# Tests: Upload +# --------------------------------------------------------------------------- + + +class TestVlnApiLogicUpload(unittest.TestCase): + + def _make(self): + from sn_plan41.modules.vln_api_logic import VlnApiLogic + + with patch("sn_plan41.modules.vln_api_logic.QSettings"): + with patch("sn_plan41.modules.vln_api_logic.KartenApiClient"): + logic = VlnApiLogic() + + mock_client = MagicMock() + mock_client.is_authenticated = True + logic._client = mock_client + return logic + + def test_upload_returns_error_for_layer_without_api_path(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + mock_layer = MagicMock() + + with patch( + "sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None + ): + success, message = logic.upload_active_layer(mock_layer) + + self.assertFalse(success) + self.assertIn("nicht", message.lower()) + + def test_upload_returns_error_for_none_layer(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + with patch( + "sn_plan41.modules.vln_api_logic.layer_api_path", return_value=None + ): + success, message = logic.upload_active_layer(None) + + self.assertFalse(success) + + def test_upload_returns_false_without_qgis_modules(self): + import sn_plan41.modules.vln_api_logic as module + logic = self._make() + with patch.object(module, "VLN_KARTEN_AVAILABLE", False): + success, message = logic.upload_active_layer(MagicMock()) + + self.assertFalse(success) + + def test_upload_calls_save_feature_collection(self): + import sn_plan41.modules.vln_api_logic as module + if not module.VLN_KARTEN_AVAILABLE: + self.skipTest("vln_karten nicht verfügbar") + + logic = self._make() + + mock_layer = MagicMock() + mock_layer.isEditable.return_value = False + mock_layer.featureCount.return_value = 5 + + fc = {"type": "FeatureCollection", "features": []} + + with patch("sn_plan41.modules.vln_api_logic.layer_api_path", return_value="/maps/p41/27010"): + with patch("sn_plan41.modules.vln_api_logic.plugin_layers", return_value=[mock_layer]): + with patch("sn_plan41.modules.vln_api_logic.layers_to_feature_collection", return_value=fc): + success, message = logic.upload_active_layer(mock_layer) + + logic._client.save_feature_collection.assert_called_once_with("/maps/p41/27010", fc) + self.assertTrue(success) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/dockwidget.py b/ui/dockwidget.py index 51388b3..cfafb2c 100644 --- a/ui/dockwidget.py +++ b/ui/dockwidget.py @@ -1,4 +1,7 @@ -#sn_plan41/ui/dockwidget.py +"""sn_plan41/ui/dockwidget.py – Haupt-DockWidget für sn_plan41. +Verwaltet die Tabs für Linkvalidierung, Druck und Einstellungen. +""" + from sn_basis.ui.tabs.settings_tab import SettingsTab from sn_plan41.ui.tab_a_ui import TabA from sn_basis.ui.tabs.print_tab import PrintTab @@ -8,9 +11,25 @@ from sn_basis.functions.message_wrapper import error class DockWidget(BaseDockWidget): + """Haupt-DockWidget für sn_plan41 mit Tab-Verwaltung. + + Attributes: + tabs: Liste von Tab-Klassen (TabA, PrintTab, SettingsTab) + pruefmanager: Prüfmanager-Instanz für Validierung + data_grabber: DataGrabber-Instanz für Fachdatenabruf + """ + tabs = [TabA, PrintTab, SettingsTab] def __init__(self, parent=None, subtitle="", pruefmanager=None, data_grabber=None): + """Initialisiert das DockWidget mit Services und Tabs. + + Args: + parent: Übergeordnetes Widget + subtitle: Untertitel des DockWidgets + pruefmanager: Prüfmanager-Instanz + data_grabber: DataGrabber-Instanz + """ super().__init__(parent, subtitle) # Services als Attribute speichern diff --git a/ui/tab_a_ui.py b/ui/tab_a_ui.py index a224e77..b9270b4 100644 --- a/ui/tab_a_ui.py +++ b/ui/tab_a_ui.py @@ -9,7 +9,9 @@ from typing import Optional from sn_basis.functions.qt_wrapper import ( QWidget, QVBoxLayout, + QHBoxLayout, QLabel, + QLineEdit, QPushButton, QToolButton, QFileDialog, @@ -32,7 +34,8 @@ from sn_basis.modules.DataGrabber import DataGrabber from sn_basis.modules.Dateipruefer import Dateipruefer from sn_plan41.ui.tab_a_logic import TabALogic from sn_basis.modules.linkpruefer import Linkpruefer -from sn_basis.modules.stilpruefer import Stilpruefer +from sn_basis.modules.stilpruefer import Stilpruefer +from sn_plan41.modules.vln_api_logic import VlnApiLogic # Konstanten RAUMFILTER_VAR = "Raumfilter" @@ -78,7 +81,11 @@ class TabA(QWidget): # UI-Referenzen self._raumfilter_combo: Optional[QComboBox] = None - + + # VLN-API + self.vln_logic: Optional[VlnApiLogic] = None + self._vln_restoring: bool = False + self._build_ui() self._restore_state() @@ -92,14 +99,17 @@ class TabA(QWidget): self.pruefmanager = pruefmanager self.data_grabber = data_grabber self.logic = TabALogic( - pruefmanager=self.pruefmanager, - link_pruefer=Linkpruefer(), - stil_pruefer=Stilpruefer(), - ) - - # DataGrabber in die Logik injizieren + pruefmanager=self.pruefmanager, + link_pruefer=Linkpruefer(), + stil_pruefer=Stilpruefer(), + ) self.logic.data_grabber = self.data_grabber + # VLN-API-Logik initialisieren + self.vln_logic = VlnApiLogic(self.pruefmanager) + self._update_vln_ui_state() + self._restore_vln_state() + def _build_ui(self) -> None: """Erstellt die komplette UI-Hierarchie mit allen Gruppen.""" main_layout = QVBoxLayout() @@ -180,7 +190,84 @@ class TabA(QWidget): self.btn_pipeline.clicked.connect(self._on_load_fachdaten) main_layout.addWidget(self.btn_pipeline) - + # === VLN API / PLAN 41 === + self._vln_group_button = QToolButton() + self._vln_group_button.setText("VLN API / Plan 41") + self._vln_group_button.setCheckable(True) + self._vln_group_button.setChecked(False) + self._vln_group_button.setToolButtonStyle(ToolButtonTextBesideIcon) + self._vln_group_button.setArrowType(ArrowRight) + self._vln_group_button.setStyleSheet("font-weight: bold; margin-top: 8px;") + self._vln_group_button.toggled.connect(self._toggle_vln_group) + main_layout.addWidget(self._vln_group_button) + + self._vln_group_content = QWidget() + self._vln_group_content.setSizePolicy(SizePolicyPreferred, SizePolicyMaximum) + vln_layout = QVBoxLayout() + vln_layout.setSpacing(4) + vln_layout.setContentsMargins(10, 4, 4, 8) + + # Status + self._vln_status_label = QLabel("Nicht angemeldet") + self._vln_status_label.setStyleSheet("color: gray; font-style: italic;") + vln_layout.addWidget(self._vln_status_label) + + # E-Mail und Passwort + vln_layout.addWidget(QLabel("E-Mail:")) + self._vln_mail_edit = QLineEdit() + self._vln_mail_edit.setPlaceholderText("mail@beispiel.de") + vln_layout.addWidget(self._vln_mail_edit) + + vln_layout.addWidget(QLabel("Passwort:")) + self._vln_pass_edit = QLineEdit() + try: + self._vln_pass_edit.setEchoMode(QLineEdit.EchoMode.Password) + except AttributeError: + self._vln_pass_edit.setEchoMode(QLineEdit.Password) + vln_layout.addWidget(self._vln_pass_edit) + + # Login / Logout Buttons + btn_row = QHBoxLayout() + self._btn_vln_login = QPushButton("Anmelden") + self._btn_vln_login.clicked.connect(self._on_vln_login) + self._btn_vln_logout = QPushButton("Abmelden") + self._btn_vln_logout.clicked.connect(self._on_vln_logout) + btn_row.addWidget(self._btn_vln_login) + btn_row.addWidget(self._btn_vln_logout) + vln_layout.addLayout(btn_row) + + # Verfahren + vln_layout.addWidget(QLabel("Verfahren:")) + self._vln_verfahren_combo = QComboBox() + self._vln_verfahren_combo.setToolTip("Verfahren (Teilnehmergemeinschaft) wählen") + self._vln_verfahren_combo.setEnabled(False) + self._vln_verfahren_combo.currentIndexChanged.connect( + self._on_vln_verfahren_changed + ) + vln_layout.addWidget(self._vln_verfahren_combo) + + # Plan 41 laden + self._btn_p41_laden = QPushButton("Plan 41 (Wege- und Gewässerplan) laden") + self._btn_p41_laden.setToolTip( + "Lädt Flächen- und Linienlayer für das gewählte Verfahren vom Server" + ) + self._btn_p41_laden.setEnabled(False) + self._btn_p41_laden.clicked.connect(self._on_p41_laden) + vln_layout.addWidget(self._btn_p41_laden) + + # Upload + self._btn_upload = QPushButton("Aktiven Layer hochladen") + self._btn_upload.setToolTip( + "Lädt alle Plan-41-Layer des aktiven Verfahrens zum Server hoch" + ) + self._btn_upload.setEnabled(False) + self._btn_upload.clicked.connect(self._on_upload_active) + vln_layout.addWidget(self._btn_upload) + + self._vln_group_content.setLayout(vln_layout) + self._vln_group_content.setVisible(False) + main_layout.addWidget(self._vln_group_content) + main_layout.addStretch(1) self.setLayout(main_layout) @@ -206,7 +293,279 @@ class TabA(QWidget): pass # Raumfilter (schon im _build_ui behandelt) + def _restore_vln_state(self) -> None: + """Stellt die zuletzt gewählte VKZ aus der Projektdatei wieder her.""" + if self.vln_logic is None or not self.vln_logic.is_authenticated: + return + try: + self._populate_verfahren_combo(restore_vkz=True) + except Exception: + pass + # === UI CALLBACKS === + + def _toggle_vln_group(self, checked: bool) -> None: + self._vln_group_button.setArrowType(ArrowDown if checked else ArrowRight) + self._vln_group_content.setVisible(checked) + + def _update_vln_ui_state(self) -> None: + """Passt alle VLN-Controls an den aktuellen Authentifizierungsstatus an.""" + if self.vln_logic is None: + authenticated = False + available = False + else: + authenticated = self.vln_logic.is_authenticated + available = self.vln_logic.is_available() + + if not available: + self._vln_status_label.setText( + "Plugin 'vln_karten' nicht installiert" + ) + self._vln_status_label.setStyleSheet("color: red; font-style: italic;") + self._btn_vln_login.setEnabled(False) + elif authenticated: + mail = (self.vln_logic.mail or "API-Key") if self.vln_logic else "" + self._vln_status_label.setText("Angemeldet: %s" % mail) + self._vln_status_label.setStyleSheet( + "color: green; font-style: italic;" + ) + self._btn_vln_login.setEnabled(False) + else: + self._vln_status_label.setText("Nicht angemeldet") + self._vln_status_label.setStyleSheet("color: gray; font-style: italic;") + self._btn_vln_login.setEnabled(True) + + self._btn_vln_logout.setEnabled(authenticated) + self._vln_verfahren_combo.setEnabled(authenticated) + vkz_chosen = ( + authenticated + and self._vln_verfahren_combo.currentData() is not None + ) + self._btn_p41_laden.setEnabled(vkz_chosen) + self._btn_upload.setEnabled(authenticated) + + def _populate_verfahren_combo(self, restore_vkz: bool = False) -> None: + """Füllt die Verfahren-Combo und wählt ggf. die gespeicherte VKZ.""" + if self.vln_logic is None: + return + verfahren = self.vln_logic.get_verfahren() + self._vln_restoring = True + self._vln_verfahren_combo.clear() + self._vln_verfahren_combo.addItem("— Verfahren wählen —", None) + for v in verfahren: + label = ( + "%s — %s" % (v["vkz"], v["name"]) + if v["name"] + else v["vkz"] + ) + self._vln_verfahren_combo.addItem(label, v["vkz"]) + self._vln_restoring = False + + if restore_vkz: + stored_vkz = self.vln_logic.load_stored_vkz() + if stored_vkz: + idx = self._vln_verfahren_combo.findData(stored_vkz) + if idx >= 0: + self._vln_verfahren_combo.setCurrentIndex(idx) + + self._vln_verfahren_combo.setEnabled(True) + self._update_vln_ui_state() + + def _on_vln_login(self) -> None: + """Führt den API-Login durch.""" + if self.vln_logic is None: + return + mail = self._vln_mail_edit.text().strip() + password = self._vln_pass_edit.text() + if not mail or not password: + QMessageBox.warning( + self, + "Anmeldung", + "Bitte E-Mail-Adresse und Passwort eingeben.", + ) + return + try: + self.vln_logic.login(mail, password) + except Exception as exc: + QMessageBox.warning(self, "Anmeldung fehlgeschlagen", str(exc)) + return + + self._vln_pass_edit.clear() + try: + self._populate_verfahren_combo(restore_vkz=True) + except Exception as exc: + QMessageBox.warning( + self, + "Verfahrensliste", + "Anmeldung erfolgreich, aber Verfahrensliste konnte nicht " + "geladen werden: %s" % exc, + ) + self._update_vln_ui_state() + + def _on_vln_logout(self) -> None: + """Meldet den Benutzer lokal ab.""" + if self.vln_logic is None: + return + self.vln_logic.logout() + self._vln_restoring = True + self._vln_verfahren_combo.clear() + self._vln_verfahren_combo.setEnabled(False) + self._vln_restoring = False + self._update_vln_ui_state() + + def _on_vln_verfahren_changed(self, _index: int) -> None: + """Persistiert die gewählte VKZ.""" + if self._vln_restoring or self.vln_logic is None: + return + vkz = self._vln_verfahren_combo.currentData() + self.vln_logic.save_vkz(vkz) + self._update_vln_ui_state() + + def _on_p41_laden(self) -> None: + """Lädt den Plan-41-Datensatz für die gewählte VKZ.""" + if self.vln_logic is None: + return + vkz = self._vln_verfahren_combo.currentData() + if not vkz: + QMessageBox.warning( + self, "Plan 41 laden", "Bitte zuerst ein Verfahren wählen." + ) + return + + # Bereits geladene Layer prüfen + existing = self.vln_logic.get_p41_layers(vkz) + if existing: + try: + answer = QMessageBox.question( + self, + "Layer ersetzen", + "Plan 41 für VKZ %s ist bereits geladen.\n" + "Beim Neuladen werden die bestehenden Layer ersetzt. Fortfahren?" + % vkz, + ) + # Qt6: StandardButton.Yes, Qt5: QMessageBox.Yes + yes = getattr( + getattr(QMessageBox, "StandardButton", QMessageBox), + "Yes", + QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None, + ) + if answer != yes: + return + except Exception: + pass + self.vln_logic.remove_p41_layers(vkz) + + try: + layers = self.vln_logic.load_p41(vkz) + except Exception as exc: + # AuthError: Sitzung abgelaufen + try: + from sn_plan41.modules.vln_api_client import AuthError as _AuthError + if isinstance(exc, _AuthError): + self.vln_logic.handle_session_expired() + self._on_vln_logout() + QMessageBox.warning( + self, + "Sitzung abgelaufen", + "Der API-Key ist nicht mehr gültig — bitte neu anmelden.", + ) + return + except ImportError: + pass + QMessageBox.warning(self, "Laden fehlgeschlagen", str(exc)) + return + + total = sum(l.featureCount() for l in layers) + detail = ", ".join( + "%s: %d" % (l.name(), l.featureCount()) for l in layers + ) + if total == 0: + QMessageBox.warning( + self, + "Plan 41 laden", + "Keine Objekte vorhanden — ein leerer Layer wurde angelegt.", + ) + else: + try: + from sn_basis.functions.message_wrapper import info + info( + "Plan 41 geladen", + "%d Layer geladen (%s)." % (len(layers), detail), + ) + except Exception: + pass + + def _on_upload_active(self) -> None: + """Lädt den aktiven Layer (und Geschwister-Layer) zum Server hoch.""" + if self.vln_logic is None: + return + + # Zusammenfassung für Bestaetigungs-Dialog ermitteln + try: + from sn_basis.functions.qgisui_wrapper import iface as _iface + active_layer = _iface.activeLayer() if _iface else None + except Exception: + active_layer = None + + summary = self.vln_logic.upload_summary(active_layer) + if summary is None: + QMessageBox.warning( + self, + "Hochladen nicht möglich", + "Der aktive Layer wurde nicht über 'vln_karten' geladen.", + ) + return + + try: + answer = QMessageBox.question( + self, + "Zum Server hochladen", + "%s für VKZ %s übertragen?\n\n%s\n\n" + "%d Objekte; der bisherige Bestand dieser VKZ wird ersetzt." + % ( + summary["dataset_label"], + summary["vkz"], + summary["listing"], + summary["total"], + ), + ) + yes = getattr( + getattr(QMessageBox, "StandardButton", QMessageBox), + "Yes", + QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None, + ) + if answer != yes: + return + except Exception: + return + + try: + success, message = self.vln_logic.upload_active_layer(active_layer) + except Exception as exc: + try: + from sn_plan41.modules.vln_api_client import AuthError as _AuthError + if isinstance(exc, _AuthError): + self.vln_logic.handle_session_expired() + self._on_vln_logout() + QMessageBox.warning( + self, + "Sitzung abgelaufen", + "Der API-Key ist nicht mehr gültig — bitte neu anmelden.", + ) + return + except ImportError: + pass + QMessageBox.warning(self, "Hochladen fehlgeschlagen", str(exc)) + return + + if success: + try: + from sn_basis.functions.message_wrapper import info + info("Upload erfolgreich", message) + except Exception: + pass + else: + QMessageBox.warning(self, "Hochladen fehlgeschlagen", message) def _toggle_group(self, checked: bool) -> None: """Zeigt/verbirgt Verfahrens-DB-Gruppe.""" self.group_button.setArrowType(ArrowDown if checked else ArrowRight) diff --git a/ui/tabs/tab_b.py b/ui/tabs/tab_b.py index 785b79e..97d115c 100644 --- a/ui/tabs/tab_b.py +++ b/ui/tabs/tab_b.py @@ -1,9 +1,24 @@ +"""sn_plan41/ui/tabs/tab_b.py – Beispiel-Tab für zusätzliche Funktionen. +Ein Tab für zukünftige Erweiterungen. +""" + from qgis.PyQt.QtWidgets import QWidget, QVBoxLayout, QLabel, QTextEdit class TabB(QWidget): + """Beispiel-Tab für sn_plan41. + + Attributes: + tab_title: Anzeigetitel des Tabs ("Tab B") + """ + tab_title = "Tab B" def __init__(self, parent=None): + """Initialisiert den Tab mit Beispielinhalt. + + Args: + parent: Übergeordnetes Widget + """ super().__init__(parent) layout = QVBoxLayout() layout.addWidget(QLabel("Plugin2 – Tab B"))