Seitenleiste statt Werkzeugleiste

Erik, 08.09.2026: "ich will keine Toolbar, sondern so eine Seitenleiste auf der
rechten Seite."

- vln_karten/panel.py (neu): andockbares QDockWidget, rechts angedockt, mit den
  Abschnitten Anmeldung, Verfahren, OGC API und Klassisch (GeoJSON) sowie einer
  Statuszeile. Reine Oberflaeche; Klicks gehen als Rueckrufe ans Plugin.
- Verfahrensliste bekommt ein Suchfeld (Hausregel: Auswahllisten immer mit
  Filter). Die Auswahl bleibt beim Filtern erhalten, "kein Treffer" liefert
  kein VKZ.
- Der Stil-Knopf hat jetzt ein Haekchen "als Vorlage fuer alle Verfahren"; die
  Umschalttaste wirkt weiterhin.
- Abmelden ist ein eigener Knopf (verwirft den gespeicherten API-Key).
- Menue Web -> VLN Karten -> "Seitenleiste anzeigen" blendet sie wieder ein.
- tools/headless_panel_test.py: prueft Zustaende, Filter, Rueckrufe und rendert
  das Layout als PNG (QGIS 4.2 offscreen). Lauf am 08.09.2026: 17 Pruefungen,
  0 Fehler, 285 Verfahren in der Liste.
- README und metadata.txt (0.3.0) nachgezogen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
erik
2026-09-08 08:28:11 +02:00
co-authored by Claude Fable 5.1
parent 4504f9a34c
commit b97f066b49
5 changed files with 426 additions and 101 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ qgisMaximumVersion=4.99
supportsQt6=True
description=Beispiel: Karten-API des VLN Managers aus QGIS ansteuern — OGC API Features (alle Layer editierbar, Vorerhebung bis Maßnahmenblatt) und der klassische GeoJSON-Weg.
about=Referenz-Implementierung für die VLN-Manager-Karten-API: Login (mail/password -> userauth-API-Key), vollständiges Laden je VKZ mit Paging (EPSG:25833), Splitten gemischter Geometrietypen in Memory-Layer und vereintes Zurückschreiben per PUT.
version=0.2.0
version=0.3.0
author=Erik
email=phpwelt@gmail.com
tracker=https://entwicklung.flurneuordnung-sachsen.de/VLN_SN/API_Karte_QGISDemo/issues
+238
View File
@@ -0,0 +1,238 @@
# -*- coding: utf-8 -*-
"""Seitenleiste (andockbares Panel) des Plugins — rechts in QGIS.
Ersetzt die frühere Werkzeugleiste (Erik, 08.09.2026: „keine Toolbar, sondern eine
Seitenleiste auf der rechten Seite"). Das Panel ist reine Oberfläche: es hält keinen
Zustand der Fachlogik, sondern meldet Klicks über Rückrufe (``callbacks``) an
``VlnKartenPlugin`` und wird von dort über ``set_angemeldet`` / ``set_verfahren``
umgeschaltet.
Aufbau von oben nach unten:
1. Anmeldung — Status + Knopf (Anmelden/Abmelden)
2. Verfahren — Suchfeld (filtert) + Auswahlliste
3. OGC API — alle Layer laden, Stil speichern (mit Häkchen „für alle Verfahren")
4. Klassisch — die einzelnen GeoJSON-Datensätze, aktiven Layer hochladen
5. Statuszeile
"""
from qgis.PyQt.QtCore import Qt
from qgis.PyQt.QtWidgets import (QCheckBox, QComboBox, QDockWidget, QFrame, QGroupBox,
QHBoxLayout, QLabel, QLineEdit, QPushButton, QSizePolicy,
QVBoxLayout, QWidget)
PANEL_TITEL = "VLN Karten"
PANEL_OBJEKT = "VlnKartenPanel"
def _klein(widget):
"""Hinweistexte eine Stufe kleiner und gedämpft — wie in QGIS-Panels üblich.
Die Farbe kommt aus der Palette (``palette(mid)``), damit der Text in hellen wie
dunklen QGIS-Themen lesbar bleibt; ``setEnabled(False)`` wäre dafür der falsche Weg,
weil es das Widget zugleich unbedienbar macht.
"""
schrift = widget.font()
schrift.setPointSizeF(max(8.0, schrift.pointSizeF() - 1.0))
widget.setFont(schrift)
widget.setWordWrap(True)
widget.setStyleSheet("color: palette(mid);")
return widget
class VlnPanel(QDockWidget):
"""Die Seitenleiste. ``callbacks`` bündelt die Rückrufe ins Plugin."""
def __init__(self, parent=None, callbacks=None, datensaetze=()):
super().__init__(PANEL_TITEL, parent)
self.setObjectName(PANEL_OBJEKT)
self.setAllowedAreas(Qt.DockWidgetArea.RightDockWidgetArea
| Qt.DockWidgetArea.LeftDockWidgetArea)
self._cb = callbacks or {}
self._verfahren = [] # [(beschriftung, vkz)] — ungefiltert
self._stumm = False # unterdrückt Signale beim programmatischen Füllen
inhalt = QWidget()
aussen = QVBoxLayout(inhalt)
aussen.setContentsMargins(8, 8, 8, 8)
aussen.setSpacing(10)
aussen.addWidget(self._bau_anmeldung())
aussen.addWidget(self._bau_verfahren())
aussen.addWidget(self._bau_ogc())
aussen.addWidget(self._bau_klassisch(datensaetze))
aussen.addStretch(1)
aussen.addWidget(self._bau_status())
inhalt.setMinimumWidth(260)
self.setWidget(inhalt)
self.set_angemeldet(False)
# ------------------------------------------------------------------
# Aufbau
# ------------------------------------------------------------------
def _bau_anmeldung(self):
kasten = QGroupBox("Anmeldung")
lage = QVBoxLayout(kasten)
lage.setSpacing(6)
self.status_label = QLabel("Nicht angemeldet")
self.status_label.setWordWrap(True)
self.login_knopf = QPushButton("Anmelden …")
self.login_knopf.clicked.connect(lambda: self._ruf("login"))
self.logout_knopf = QPushButton("Abmelden")
self.logout_knopf.clicked.connect(lambda: self._ruf("logout"))
zeile = QHBoxLayout()
zeile.addWidget(self.login_knopf)
zeile.addWidget(self.logout_knopf)
lage.addWidget(self.status_label)
lage.addLayout(zeile)
return kasten
def _bau_verfahren(self):
kasten = QGroupBox("Verfahren")
lage = QVBoxLayout(kasten)
lage.setSpacing(6)
self.filter_feld = QLineEdit()
self.filter_feld.setPlaceholderText("filtern (Name oder Kennzeichen) …")
self.filter_feld.setClearButtonEnabled(True)
self.filter_feld.textChanged.connect(self._filter_geaendert)
self.combo = QComboBox()
self.combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.combo.setToolTip("Teilnehmergemeinschaft, für die geladen und gespeichert wird")
self.combo.currentIndexChanged.connect(self._auswahl_geaendert)
lage.addWidget(self.filter_feld)
lage.addWidget(self.combo)
return kasten
def _bau_ogc(self):
kasten = QGroupBox("OGC API (empfohlen)")
lage = QVBoxLayout(kasten)
lage.setSpacing(6)
self.ogc_knopf = QPushButton("Alle Layer laden")
self.ogc_knopf.setToolTip("Verfahrenslayer, Vorerhebung und Maßnahmenblätter als "
"editierbare OAPIF-Layer; QGIS schreibt Änderungen direkt zurück")
self.ogc_knopf.clicked.connect(lambda: self._ruf("load_ogc"))
self.stil_knopf = QPushButton("Stil des aktiven Layers speichern")
self.stil_knopf.setToolTip("Braucht die Gruppe MODUL_KARTENSTIL (Verbandsmitarbeiter "
"immer)")
self.stil_knopf.clicked.connect(lambda: self._ruf("save_style"))
self.vorlage_haken = QCheckBox("als Vorlage für alle Verfahren")
self.vorlage_haken.setToolTip("Der Stil gilt dann für diese Art in jedem Verfahren, "
"z. B. für jede Karte alter Stand")
lage.addWidget(self.ogc_knopf)
lage.addWidget(self.stil_knopf)
lage.addWidget(self.vorlage_haken)
return kasten
def _bau_klassisch(self, datensaetze):
kasten = QGroupBox("Klassisch (GeoJSON)")
lage = QVBoxLayout(kasten)
lage.setSpacing(6)
lage.addWidget(_klein(QLabel("Lädt eine Kopie; Änderungen gehen erst mit "
"„Aktiven Layer hochladen“ zurück.")))
self.datensatz_knoepfe = []
for schluessel, beschriftung in datensaetze:
knopf = QPushButton(beschriftung)
knopf.clicked.connect(lambda _=False, s=schluessel: self._ruf("load_dataset", s))
lage.addWidget(knopf)
self.datensatz_knoepfe.append(knopf)
linie = QFrame()
linie.setFrameShape(QFrame.Shape.HLine)
linie.setFrameShadow(QFrame.Shadow.Sunken)
lage.addWidget(linie)
self.upload_knopf = QPushButton("Aktiven Layer hochladen")
self.upload_knopf.clicked.connect(lambda: self._ruf("upload"))
lage.addWidget(self.upload_knopf)
return kasten
def _bau_status(self):
self.status_zeile = _klein(QLabel(""))
self.status_zeile.setToolTip("letzte Aktion")
return self.status_zeile
# ------------------------------------------------------------------
# Zustand (vom Plugin gesetzt)
# ------------------------------------------------------------------
def set_angemeldet(self, angemeldet, wer=None):
self.status_label.setText(("Angemeldet: %s" % wer) if angemeldet and wer
else ("Angemeldet" if angemeldet else "Nicht angemeldet"))
self.login_knopf.setVisible(not angemeldet)
self.logout_knopf.setVisible(angemeldet)
for widget in (self.filter_feld, self.combo, self.ogc_knopf, self.stil_knopf,
self.vorlage_haken, self.upload_knopf, *self.datensatz_knoepfe):
widget.setEnabled(angemeldet)
if not angemeldet:
self._stumm = True
self._verfahren = []
self.combo.clear()
self.combo.addItem("— bitte anmelden —", None)
self.filter_feld.clear()
self._stumm = False
def set_verfahren(self, verfahren):
"""``verfahren``: Liste von ``{"vkz":…, "name":…}`` aus der API."""
self._verfahren = [
("%s — %s" % (v["vkz"], v["name"]) if v.get("name") else str(v["vkz"]), v["vkz"])
for v in verfahren
]
self._liste_fuellen()
def set_status(self, text):
self.status_zeile.setText(text or "")
def aktuelles_vkz(self):
return self.combo.currentData()
def aktueller_text(self):
return self.combo.currentText()
def als_vorlage(self):
return self.vorlage_haken.isChecked()
def waehle_vkz(self, vkz):
"""Setzt die Auswahl ohne Rückruf (Wiederherstellen aus der Projektdatei)."""
self._stumm = True
index = self.combo.findData(vkz) if vkz else -1
self.combo.setCurrentIndex(index if index >= 0 else 0)
self._stumm = False
def anzahl_eintraege(self):
return self.combo.count()
# ------------------------------------------------------------------
# Innenleben
# ------------------------------------------------------------------
def _liste_fuellen(self, muster=""):
"""Füllt die Auswahlliste, gefiltert; die bisherige Auswahl bleibt wenn möglich."""
vorher = self.combo.currentData()
muster = (muster or "").strip().lower()
treffer = [(t, v) for t, v in self._verfahren if muster in t.lower()]
self._stumm = True
self.combo.clear()
if not self._verfahren:
self.combo.addItem("— keine Verfahren —", None)
elif not treffer:
self.combo.addItem("— kein Treffer —", None)
else:
self.combo.addItem("— Verfahren wählen —", None)
for beschriftung, vkz in treffer:
self.combo.addItem(beschriftung, vkz)
index = self.combo.findData(vorher) if vorher else -1
self.combo.setCurrentIndex(index if index >= 0 else 0)
self._stumm = False
if index < 0 and vorher:
self._auswahl_geaendert(0)
def _filter_geaendert(self, text):
self._liste_fuellen(text)
def _auswahl_geaendert(self, _index):
if not self._stumm:
self._ruf("verfahren_gewaehlt", self.combo.currentData())
def _ruf(self, name, *args):
rueckruf = self._cb.get(name)
if rueckruf:
rueckruf(*args)
+73 -93
View File
@@ -4,20 +4,20 @@ Ablauf:
- Der API-Key wird nach dem ersten Login in den QGIS-Einstellungen
gespeichert und bei jedem QGIS-Start wiederverwendet (kein erneuter
Login nötig, solange der Key gültig ist).
- Eine Toolbar-Auswahlliste zeigt alle Verfahren (TGs) aus GET /tgen.
Das gewählte Verfahren wird in der Projektdatei gemerkt und beim
Öffnen des Projekts wiederhergestellt.
- Buttons laden Verfahrensgebiet / Plan 41 / Karte alter Stand für das
gewählte Verfahren bzw. laden den aktiven Layer zur API hoch.
- Die Bedienung sitzt in einer andockbaren Seitenleiste rechts (``panel.py``,
seit 08.09.2026 anstelle der früheren Werkzeugleiste). Sie zeigt Anmeldung,
Verfahrensliste aus GET /tgen mit Suchfeld sowie die Knöpfe zum Laden,
Speichern und Hochladen.
- Das gewählte Verfahren wird in der Projektdatei gemerkt und beim Öffnen
des Projekts wiederhergestellt.
"""
from functools import partial
from qgis.PyQt.QtCore import QSettings, QTimer
from qgis.PyQt.QtWidgets import QAction, QComboBox, QDialog, QMessageBox
from qgis.PyQt.QtCore import QSettings, Qt, QTimer
from qgis.PyQt.QtWidgets import QAction, QDialog, QMessageBox
from qgis.core import Qgis, QgsDataSourceUri, QgsProject
from .api_client import ApiError, AuthError, KartenApiClient
from .panel import VlnPanel
from .layer_manager import (
PROP_DATASET,
PROP_VERFAHREN,
@@ -70,48 +70,39 @@ class VlnKartenPlugin:
self.iface = iface
self.client = KartenApiClient()
self.actions = []
self.toolbar = None
self.combo = None
# Unterdrückt das Mitschreiben in die Projektdatei, während die
# Auswahl programmatisch gesetzt wird.
self._restoring = False
self.panel = None
# ------------------------------------------------------------------
# Plugin-Lebenszyklus
# ------------------------------------------------------------------
def initGui(self):
self.toolbar = self.iface.addToolBar("VLN Karten")
self.toolbar.setObjectName("VlnKartenToolbar")
self.login_action = self._add_action("Anmelden …", self.run_login)
self.combo = QComboBox()
self.combo.setMinimumWidth(280)
self.combo.setToolTip("Verfahren (Teilnehmergemeinschaft) auswählen")
self.combo.currentIndexChanged.connect(self.on_verfahren_changed)
self.toolbar.addWidget(self.combo)
self.dataset_actions = []
# OGC API Features (seit 07.09.2026) — der empfohlene Weg, darum ganz vorn: alle
# Sammlungen des Verfahrens als native, editierbare OAPIF-Layer; QGIS schreibt jede
# Änderung selbst zurück (Part 4). Die GeoJSON-Knöpfe darunter sind der klassische Weg.
self.dataset_actions.append(
self._add_action("Alle Layer (OGC API) laden", self.run_load_ogc)
)
self.dataset_actions.append(
self._add_action("Stil des aktiven Layers speichern", self.run_save_style)
)
for key, dataset in DATASETS.items():
self.dataset_actions.append(
self._add_action(
"%s laden" % dataset["label"],
partial(self.run_load_dataset, key),
)
)
self.dataset_actions.append(
self._add_action("Aktiven Layer hochladen", self.run_upload_active_layer)
# Seitenleiste statt Werkzeugleiste (Erik, 08.09.2026). Die OGC-Knöpfe stehen oben:
# sie laden native, editierbare OAPIF-Layer, die QGIS selbst zurückschreibt (Part 4).
# Die GeoJSON-Knöpfe darunter sind der klassische Weg (Kopie laden, später hochladen).
self.panel = VlnPanel(
self.iface.mainWindow(),
callbacks={
"login": self.run_login,
"logout": self.run_logout,
"verfahren_gewaehlt": self.on_verfahren_changed,
"load_ogc": self.run_load_ogc,
"save_style": self.run_save_style,
"load_dataset": self.run_load_dataset,
"upload": self.run_upload_active_layer,
},
datensaetze=[(key, dataset["label"]) for key, dataset in DATASETS.items()],
)
self.iface.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.panel)
# Menüeintrag zum Wiedereinblenden, falls die Leiste geschlossen wurde.
self.panel_action = QAction("Seitenleiste anzeigen", self.iface.mainWindow())
self.panel_action.setCheckable(True)
self.panel_action.setChecked(True)
self.panel_action.toggled.connect(self.panel.setVisible)
self.panel.visibilityChanged.connect(self.panel_action.setChecked)
self.iface.addPluginToWebMenu(MENU_TITLE, self.panel_action)
self.actions.append(self.panel_action)
self.iface.projectRead.connect(self.restore_project_selection)
self.iface.newProjectCreated.connect(self.restore_project_selection)
@@ -141,18 +132,10 @@ class VlnKartenPlugin:
for action in self.actions:
self.iface.removePluginWebMenu(MENU_TITLE, action)
self.actions = []
if self.toolbar is not None:
self.toolbar.deleteLater()
self.toolbar = None
self.combo = None
def _add_action(self, text, callback):
action = QAction(text, self.iface.mainWindow())
action.triggered.connect(callback)
self.iface.addPluginToWebMenu(MENU_TITLE, action)
self.toolbar.addAction(action)
self.actions.append(action)
return action
if self.panel is not None:
self.iface.removeDockWidget(self.panel)
self.panel.deleteLater()
self.panel = None
# ------------------------------------------------------------------
# Anmeldung & Verfahrensliste
@@ -205,20 +188,9 @@ class VlnKartenPlugin:
)
return
self._restoring = True
self.combo.clear()
self.combo.addItem("— Verfahren wählen —", None)
for v in verfahren:
label = "%s — %s" % (v["vkz"], v["name"]) if v["name"] else v["vkz"]
self.combo.addItem(label, v["vkz"])
self.combo.setEnabled(True)
self._restoring = False
self.login_action.setText(
"Angemeldet: %s" % (self.client.mail or "API-Key")
)
for action in self.dataset_actions:
action.setEnabled(True)
self.panel.set_angemeldet(True, self.client.mail or "API-Key")
self.panel.set_verfahren(verfahren)
self.panel.set_status("%d Verfahren geladen." % len(verfahren))
self.restore_project_selection()
def _handle_session_expired(self):
@@ -236,41 +208,43 @@ class VlnKartenPlugin:
self.run_login()
def _set_logged_out_ui(self):
self.login_action.setText("Anmelden …")
self._restoring = True
self.combo.clear()
self.combo.addItem("— bitte anmelden —", None)
self.combo.setEnabled(False)
self._restoring = False
for action in self.dataset_actions:
action.setEnabled(False)
if self.panel is not None:
self.panel.set_angemeldet(False)
self.panel.set_status("")
def run_logout(self):
"""Abmelden: gespeicherten API-Key verwerfen, Oberfläche zurücksetzen."""
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
settings.remove("api_key")
settings.endGroup()
self.client.logout()
self.client = KartenApiClient()
self._set_logged_out_ui()
self.iface.messageBar().pushMessage(
"VLN Karten", "Abgemeldet — der gespeicherte API-Key wurde entfernt.",
level=Qgis.Info, duration=5)
# ------------------------------------------------------------------
# Verfahrens-Auswahl <-> Projektdatei
# ------------------------------------------------------------------
def on_verfahren_changed(self, index):
if self._restoring or self.combo is None:
return
vkz = self.combo.currentData()
def on_verfahren_changed(self, vkz):
if vkz:
QgsProject.instance().writeEntry(
PROJECT_SCOPE, PROJECT_KEY_VKZ, vkz
)
def restore_project_selection(self):
if self.combo is None or self.combo.count() == 0:
if self.panel is None or self.panel.anzahl_eintraege() == 0:
return
vkz, _ok = QgsProject.instance().readEntry(
PROJECT_SCOPE, PROJECT_KEY_VKZ, ""
)
self._restoring = True
index = self.combo.findData(vkz) if vkz else -1
self.combo.setCurrentIndex(index if index >= 0 else 0)
self._restoring = False
self.panel.waehle_vkz(vkz)
def _current_vkz(self):
return self.combo.currentData() if self.combo is not None else None
return self.panel.aktuelles_vkz() if self.panel is not None else None
# ------------------------------------------------------------------
# Laden & Hochladen
@@ -449,16 +423,18 @@ class VlnKartenPlugin:
return
# Schrittweise: je Ereignisschleifen-Durchlauf ein Layer, Fortschritt in der
# Meldungsleiste. Fehler kommen dort am Ende gesammelt, nicht als Modal-Dialog.
self.panel.set_status("Lade %d Sammlungen …" % len(collections))
ogc_layers.layer_hinzufuegen(
self.iface, self.client.base_url, vkz, self.combo.currentText(), collections, authcfg,
api_key=None if authcfg else self.client.api_key)
self.iface, self.client.base_url, vkz, self.panel.aktueller_text(), collections,
authcfg, api_key=None if authcfg else self.client.api_key)
def run_save_style(self):
"""Den Stil des aktiven OGC-Layers auf den Server zurückschreiben.
Erlaubt für Verbandsmitarbeiter und die Gruppe MODUL_KARTENSTIL; der Server antwortet
sonst mit 403. Mit gedrückter Umschalttaste wird als **Vorlage der Art** gespeichert —
die gilt dann für alle Verfahren (z. B. ein Stil für jede „Karte alter Stand").
sonst mit 403. Ist in der Seitenleiste „als Vorlage für alle Verfahren“ angehakt (oder
die Umschalttaste gedrückt), gilt der Stil für die **Art** statt nur für dieses
Verfahren — z. B. ein Stil für jede „Karte alter Stand“.
"""
from qgis.PyQt.QtWidgets import QApplication
from qgis.PyQt.QtCore import Qt as _Qt
@@ -473,7 +449,8 @@ class VlnKartenPlugin:
if not cid:
self._show_error("Kein OGC-Layer", "Die Datenquelle nennt keine Sammlung (typename).")
return
vorlage = bool(QApplication.keyboardModifiers() & _Qt.KeyboardModifier.ShiftModifier)
vorlage = (self.panel is not None and self.panel.als_vorlage()) or bool(
QApplication.keyboardModifiers() & _Qt.KeyboardModifier.ShiftModifier)
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
authcfg = settings.value(ogc_layers.SETTINGS_AUTHCFG, "")
@@ -489,6 +466,9 @@ class VlnKartenPlugin:
self.iface.messageBar().pushMessage(
"VLN Karten", "Stil für %s gespeichert — %s" % (cid, text),
level=Qgis.Success, duration=8)
if self.panel is not None:
self.panel.set_status("Stil gespeichert: %s%s"
% (cid, " (Vorlage)" if vorlage else ""))
else:
self._show_error("Stil nicht gespeichert", text)