Files
API_Karte_QGISDemo/vln_karten/plugin.py
T
erikandClaude Fable 5.1 b97f066b49 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>
2026-09-08 08:28:11 +02:00

487 lines
19 KiB
Python

"""Hauptklasse des Plugins.
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).
- 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 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,
feature_collection_to_layers,
layer_api_path,
layers_to_feature_collection,
plugin_layers,
)
from .login_dialog import LoginDialog, SETTINGS_GROUP
from . import ogc_layers
MENU_TITLE = "&VLN Karten"
# Schlüssel für die VKZ-Ablage in der Projektdatei (*.qgz).
PROJECT_SCOPE = "vln_karten"
PROJECT_KEY_VKZ = "/vkz"
# Karten-Layer der VLN-Manager-API (map.php). Weitere Layer (st, we)
# wären je ein Eintrag mehr. {vkz} = Verfahrenskennzeichen der TG.
DATASETS = {
"umringe": {
"label": "Verfahrensgebiet",
"path": "/maps/umringe/{vkz}",
"geometry": "MultiPolygon",
},
"p41": {
"label": "Plan 41 (Wege- und Gewässerplan)",
"path": "/maps/p41/{vkz}",
"geometry": "MultiPolygon",
},
# DB-Art KAS: enthält, was im Web-GIS über "in Datenbank speichern"
# abgelegt wurde — kann je VKZ leer sein, solange dort noch nichts
# gespeichert wurde.
"kas": {
"label": "Karte alter Stand (KAS)",
"path": "/maps/kas/{vkz}",
"geometry": "MultiPolygon",
},
# DB-Art WE: Wertermittlung (Web-GIS-Modul we.js).
"we": {
"label": "Wertermittlung (WE)",
"path": "/maps/we/{vkz}",
"geometry": "MultiPolygon",
},
}
class VlnKartenPlugin:
def __init__(self, iface):
self.iface = iface
self.client = KartenApiClient()
self.actions = []
self.panel = None
# ------------------------------------------------------------------
# Plugin-Lebenszyklus
# ------------------------------------------------------------------
def initGui(self):
# 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)
self._set_logged_out_ui()
# Gespeicherten API-Key aus früherer Sitzung wiederverwenden; sonst gleich beim Start
# anmelden (Erik, 07.09.2026: „zuerst ein Loginfenster, danach den API-Key speichern").
# Beides erst NACH dem QGIS-Start (singleShot), sonst blockiert der Dialog das Laden.
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
api_key = settings.value("api_key", "")
mail = settings.value("mail", "")
settings.endGroup()
if api_key:
self.client = KartenApiClient(api_key=api_key, mail=mail)
QTimer.singleShot(0, self.populate_verfahren)
else:
QTimer.singleShot(1500, self.run_login)
def unload(self):
for signal in (self.iface.projectRead, self.iface.newProjectCreated):
try:
signal.disconnect(self.restore_project_selection)
except (TypeError, RuntimeError):
pass
for action in self.actions:
self.iface.removePluginWebMenu(MENU_TITLE, action)
self.actions = []
if self.panel is not None:
self.iface.removeDockWidget(self.panel)
self.panel.deleteLater()
self.panel = None
# ------------------------------------------------------------------
# Anmeldung & Verfahrensliste
# ------------------------------------------------------------------
def run_login(self):
dialog = LoginDialog(self.iface.mainWindow())
if dialog.exec() != QDialog.DialogCode.Accepted:
return
client = KartenApiClient()
try:
client.login(dialog.mail(), dialog.password())
except ApiError as exc:
self._show_error("Anmeldung fehlgeschlagen", str(exc))
return
self.client = client
# Nach dem Login wird der API-Key (userauth) gespeichert und in jeder weiteren Sitzung
# wiederverwendet — das Passwort selbst speichert das Plugin nie. Ablage: QSettings
# („vln_karten/api_key"), zurücksetzen über „Abmelden".
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
settings.setValue("api_key", client.api_key)
settings.setValue("mail", client.mail or dialog.mail())
settings.endGroup()
self.iface.messageBar().pushMessage(
"VLN Karten",
"Angemeldet als %s — der API-Key ist gespeichert und gilt auch nach einem "
"QGIS-Neustart." % (client.mail or dialog.mail()),
level=Qgis.Success,
duration=6,
)
self.populate_verfahren()
def populate_verfahren(self):
"""Füllt die Auswahlliste mit allen Verfahren aus GET /tgen."""
try:
verfahren = self.client.get_verfahren()
except AuthError:
self._handle_session_expired()
return
except ApiError as exc:
self.iface.messageBar().pushMessage(
"VLN Karten",
"Verfahrensliste konnte nicht geladen werden: %s" % exc,
level=Qgis.Warning,
duration=10,
)
return
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):
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
settings.remove("api_key")
settings.endGroup()
self.client.logout()
self._set_logged_out_ui()
self._show_error(
"Sitzung abgelaufen",
"Der gespeicherte API-Key ist nicht mehr gültig — "
"bitte neu anmelden.",
)
self.run_login()
def _set_logged_out_ui(self):
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, vkz):
if vkz:
QgsProject.instance().writeEntry(
PROJECT_SCOPE, PROJECT_KEY_VKZ, vkz
)
def restore_project_selection(self):
if self.panel is None or self.panel.anzahl_eintraege() == 0:
return
vkz, _ok = QgsProject.instance().readEntry(
PROJECT_SCOPE, PROJECT_KEY_VKZ, ""
)
self.panel.waehle_vkz(vkz)
def _current_vkz(self):
return self.panel.aktuelles_vkz() if self.panel is not None else None
# ------------------------------------------------------------------
# Laden & Hochladen
# ------------------------------------------------------------------
def run_load_dataset(self, dataset_key):
if not self._ensure_logged_in():
return
vkz = self._current_vkz()
if not vkz:
self._show_error(
"Kein Verfahren gewählt",
"Bitte zuerst in der Auswahlliste ein Verfahren wählen.",
)
return
dataset = DATASETS[dataset_key]
if not self._confirm_replace_existing(dataset_key, vkz, dataset["label"]):
return
path = dataset["path"].format(vkz=vkz)
try:
# Vollständig aus KARTE_OBJEKT: Listen-Endpunkt mit Paging
# (wie das Web-GIS, aber ohne 2000er-Limit).
feature_collection = self.client.load_layer_complete(dataset_key, vkz)
layers = feature_collection_to_layers(
feature_collection,
"%s %s" % (dataset["label"], vkz),
dataset["geometry"],
dataset_key,
vkz,
path,
)
except AuthError:
self._handle_session_expired()
return
except (ApiError, RuntimeError) as exc:
self._show_error("Laden fehlgeschlagen", str(exc))
return
total = sum(layer.featureCount() for layer in layers)
if total == 0:
self.iface.messageBar().pushMessage(
"VLN Karten",
"Der Server hat für VKZ %s keine Objekte im Layer "
"'%s' — es wurde ein leerer Layer zum Digitalisieren "
"angelegt." % (vkz, dataset["label"]),
level=Qgis.Warning,
duration=10,
)
return
detail = ", ".join(
"%s: %d" % (layer.name(), layer.featureCount()) for layer in layers
)
self.iface.messageBar().pushMessage(
"VLN Karten",
"%d Layer geladen (%s)." % (len(layers), detail),
level=Qgis.Success,
duration=5,
)
def _confirm_replace_existing(self, dataset_key, vkz, label):
"""Ersetzt bereits geladene Layer dieses Datensatzes nach
Rückfrage — doppelte Layer-Sätze würden beim Hochladen alle
Objekte dupliziert an den Server schicken."""
existing = plugin_layers(dataset_key, vkz)
if not existing:
return True
answer = QMessageBox.question(
self.iface.mainWindow(),
"Layer ersetzen",
"%s für VKZ %s ist bereits im Projekt geladen (%s).\n"
"Beim Neuladen werden diese Layer ersetzt — nicht "
"hochgeladene Änderungen gehen verloren. Fortfahren?"
% (label, vkz, ", ".join(layer.name() for layer in existing)),
)
if answer != QMessageBox.StandardButton.Yes:
return False
QgsProject.instance().removeMapLayers([layer.id() for layer in existing])
return True
def run_upload_active_layer(self):
if not self._ensure_logged_in():
return
active = self.iface.activeLayer()
path = layer_api_path(active) if active else None
if not path:
self._show_error(
"Hochladen nicht möglich",
"Der aktive Layer wurde nicht über dieses Plugin geladen. "
"Bitte einen über 'VLN Karten' geladenen Layer auswählen.",
)
return
# Der PUT ersetzt den kompletten Layer-Bestand der VKZ auf dem
# Server. Deshalb werden immer alle Teil-Layer dieses Datensatzes
# (Punkte/Linien/Flächen) gemeinsam hochgeladen — nur den aktiven
# zu senden, würde die anderen Geometrietypen serverseitig löschen.
dataset_key = active.customProperty(PROP_DATASET)
vkz = active.customProperty(PROP_VERFAHREN)
siblings = plugin_layers(dataset_key, vkz)
for layer in siblings:
if layer.isEditable() and not layer.commitChanges():
self._show_error(
"Hochladen nicht möglich",
"Die offene Bearbeitungssitzung von '%s' konnte nicht "
"gespeichert werden." % layer.name(),
)
return
dataset = DATASETS.get(dataset_key, {})
total = sum(layer.featureCount() for layer in siblings)
listing = "\n".join(
"• %s (%d Objekte)" % (layer.name(), layer.featureCount())
for layer in siblings
)
answer = QMessageBox.question(
self.iface.mainWindow(),
"Zum Server hochladen",
"%s für VKZ %s übertragen?\n\n%s\n\n"
"Die Layer werden zusammengeführt (%d Objekte); der bisherige "
"Bestand dieser VKZ auf dem Server wird ersetzt."
% (dataset.get("label", dataset_key), vkz, listing, total),
)
if answer != QMessageBox.StandardButton.Yes:
return
try:
feature_collection = layers_to_feature_collection(siblings)
self.client.save_feature_collection(path, feature_collection)
except AuthError:
self._handle_session_expired()
return
except ApiError as exc:
self._show_error("Hochladen fehlgeschlagen", str(exc))
return
self.iface.messageBar().pushMessage(
"VLN Karten",
"%d Objekte aus %d Layer(n) erfolgreich hochgeladen."
% (total, len(siblings)),
level=Qgis.Success,
duration=5,
)
def run_load_ogc(self):
"""Alle Sammlungen des gewählten Verfahrens (Verfahrens-Layer, Vorerhebung,
Maßnahmenblätter) als OAPIF-Layer laden — editierbar in QGIS, Rückschreiben je Feature."""
if not self._ensure_logged_in():
return
vkz = self._current_vkz()
if not vkz:
self._show_error("Kein Verfahren", "Bitte zuerst ein Verfahren auswählen.")
return
authcfg = ogc_layers.authcfg_sicherstellen(self.client.api_key, SETTINGS_GROUP)
if not authcfg:
self.iface.messageBar().pushMessage(
"VLN Karten",
"Keine QGIS-Authentifizierungskonfiguration — der API-Key steht jetzt in den "
"Datenquellen der Layer (und damit in einer gespeicherten Projektdatei).",
level=Qgis.Warning, duration=8)
try:
collections = ogc_layers.collections_laden(self.client, vkz)
except AuthError:
self._handle_session_expired()
return
except ApiError as exc:
self._show_error("OGC API", str(exc))
return
if not collections:
self._show_error("OGC API", "Für dieses Verfahren gibt es keine Sammlungen "
"(oder keine Berechtigung).")
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.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. 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
layer = self.iface.activeLayer()
if layer is None or layer.providerType() != "OAPIF":
self._show_error("Kein OGC-Layer",
"Bitte zuerst einen über „Alle Layer (OGC API) laden“ geladenen "
"Layer auswählen.")
return
cid = QgsDataSourceUri(layer.source()).param("typename")
if not cid:
self._show_error("Kein OGC-Layer", "Die Datenquelle nennt keine Sammlung (typename).")
return
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, "")
settings.endGroup()
try:
ok, text = ogc_layers.stil_speichern(layer, self.client.base_url, cid,
api_key=self.client.api_key, authcfg=authcfg or None,
vorlage=vorlage)
except AuthError:
self._handle_session_expired()
return
if ok:
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)
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def _ensure_logged_in(self):
if self.client.is_authenticated:
return True
self.run_login()
return self.client.is_authenticated
def _show_error(self, title, message):
QMessageBox.warning(self.iface.mainWindow(), title, message)