Files
API_Karte_QGISDemo/vln_karten/plugin.py
T
erikandClaude Opus 5 c8b723b653 Geodienste aus der Linkliste laden — je Verfahren, aus der Seitenleiste
Erik, 11.09.2026: "als angemeldeter Nutzer TG-spezifisch die Dienste aus der
Linkliste einbinden".

- vln_karten/linkliste.py (neu): holt GET /v2/linkliste?vkz=…, baut je Anbieter
  den passenden Layer (wms, wfs, rest/ArcGIS, geojson/ogr), wendet den beim
  Eintrag hinterlegten QML-Stil an und haengt alles schrittweise in die Gruppe
  "Geodienste <VKZ> <Name>" mit Untergruppen je Listengruppe.
- Seitenleiste: neuer Block "Geodienste (Linkliste)" mit Liste holen, Haken je
  Dienst (Name, Gruppe, Herkunft, Stil-Hinweis), alle an/aus und Ausgewaehlte
  laden.
- Die Liste kommt fertig aufgeloest vom Server; das Plugin rechnet nichts nach.

Zwei Dinge, die der Test gezeigt hat (tools/headless_dienste_test.py, QGIS 4.2
ohne Fenster gegen die Live-Schnittstelle):

* Die Adressform muss schlicht "url=<Adresse>" sein, wie in
  Plugin_SN_Basis/modules/LayerLoader.py. Mit einem selbst gebauten
  QgsDataSourceUri (crs, format) kam der ArcGIS-Layer erst nach 60 s und
  ungueltig zurueck; jetzt 0,4 s und 4457 Objekte.
* Ein 404 auf style.qml erreicht QgsBlockingNetworkRequest als Transportfehler.
  Erst den Statuscode ansehen, sonst meldet das Plugin einen Fehler, wo schlicht
  kein Stil hinterlegt ist.

Testlauf 11.09.2026: 44 Dienste in 0,8 s, Layer gueltig, Stil angewendet,
"kein Stil hinterlegt" sauber gemeldet — 0 Fehler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 17:37:58 +02:00

400 lines
17 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 .login_dialog import LoginDialog, SETTINGS_GROUP
from . import linkliste
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"
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). Geladen und gespeichert wird
# ausschließlich über die OGC API: die Layer sind nativ editierbar, QGIS schreibt jede
# Änderung einzeln zurück (Part 4). Der frühere GeoJSON-Weg ist entfallen.
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_edits": self.run_save_edits,
"dienste_holen": self.run_dienste_holen,
"dienste_laden": self.run_dienste_laden,
"save_style": self.run_save_style,
},
)
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_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
# Nur die in der Seitenleiste angehakten Gruppen laden — jede Sammlung kostet beim
# Anlegen rund eine halbe Sekunde, und selten braucht man alle 27 gleichzeitig.
gewaehlt = self.panel.gewaehlte_gruppen()
gruppe_von = dict(ogc_layers.GRUPPEN)
collections = [c for c in collections
if gruppe_von.get(str(c.get("id") or "").split("-", 1)[0], "Weitere")
in gewaehlt]
if not collections:
self._show_error("Nichts ausgewählt",
"Bitte oben mindestens eine Gruppe anhaken (Verfahren, "
"Vorerhebung oder Maßnahmenblätter).")
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_edits(self):
"""Offene Bearbeitungssitzungen aller geladenen VLN-Layer schließen.
Über die OGC API schreibt QGIS jede Änderung einzeln zurück (Part 4) — dieser Knopf
beendet nur die Bearbeitung, so wie „Layeränderungen speichern" in QGIS, aber für
alle Layer des Verfahrens auf einmal.
"""
layer = [l for l in QgsProject.instance().mapLayers().values()
if getattr(l, "providerType", None) and l.providerType() == "OAPIF"]
offen = [l for l in layer if l.isEditable()]
if not offen:
self.iface.messageBar().pushMessage(
"VLN Karten", "Keine offene Bearbeitung — es gibt nichts zu speichern.",
level=Qgis.Info, duration=5)
return
gespeichert, fehler = [], []
for l in offen:
if l.commitChanges():
gespeichert.append(l.name())
else:
fehler.append("%s: %s" % (l.name(), "; ".join(l.commitErrors())[:120]))
if fehler:
self._show_error("Nicht alles gespeichert", "\n".join(fehler))
if gespeichert:
self.iface.messageBar().pushMessage(
"VLN Karten", "%d Layer gespeichert (%s)." % (len(gespeichert),
", ".join(gespeichert[:4])),
level=Qgis.Success, duration=6)
if self.panel is not None:
self.panel.set_status("%d von %d Layern gespeichert." % (len(gespeichert), len(offen)))
def run_dienste_holen(self):
"""Die Linkliste des gewählten Verfahrens holen und in der Seitenleiste anzeigen.
Der Server liefert sie bereits aufgelöst: landesweit, Landkreis, Team und Verfahren
sind zusammengeführt, geerbte Einträge tragen ihre Herkunft. Das Plugin rechnet nichts
nach, es zeigt und lädt.
"""
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
try:
dienste = linkliste.dienste_laden(self.client, vkz)
except AuthError:
self._handle_session_expired()
return
except ApiError as exc:
self._show_error("Linkliste", str(exc))
return
self.panel.set_dienste(dienste)
herkunft = {}
for d in dienste:
herkunft[d.get("herkunft_text") or "?"] = herkunft.get(d.get("herkunft_text") or "?", 0) + 1
text = ", ".join("%d %s" % (n, k) for k, n in herkunft.items()) or "nichts hinterlegt"
self.panel.set_status("%d Geodienste: %s" % (len(dienste), text))
self.iface.messageBar().pushMessage(
"VLN Karten", "%d Geodienste für dieses Verfahren (%s)." % (len(dienste), text),
level=Qgis.Info, duration=6)
def run_dienste_laden(self):
"""Die angehakten Dienste als Layer in die Gruppe „Geodienste" hängen."""
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
gewaehlt = self.panel.gewaehlte_dienste()
if not gewaehlt:
self._show_error("Nichts ausgewählt",
"Bitte zuerst die Liste holen und dann die gewünschten Dienste "
"anhaken.")
return
linkliste.dienste_hinzufuegen(
self.iface, self.client.base_url, vkz, self.panel.aktueller_text(), gewaehlt,
api_key=self.client.api_key)
self.panel.set_status("%d Geodienste werden geladen …" % len(gewaehlt))
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)