Files
API_Karte_QGISDemo/vln_karten/plugin.py
T
KAIandClaude Opus 5 9c2c1c57af Schilder setzen wie im Browser: Palette und zwei Kartenwerkzeuge
Bisher konnte man die Zeichen in QGIS nur sehen und ihre Attribute von Hand tippen. Jetzt
gibt es dieselbe Bedienung wie im Web (Erik, 21.09.2026; Nutzerkreis sind TG-Mitarbeiter
und der Landkreis):

* Neues Modul zeichen_werkzeug.py mit der Palette (Suchfeld nach Nummer oder Name,
  Kategorie, Kacheln aus den lokal abgelegten Vorschaubildern, Deckel bei 96 wie im
  Browser) und zwei Kartenwerkzeugen: "Schild setzen" (erster Klick Standort, zweiter
  Klick Blickrichtung; unter 0,5 m bleibt die Drehung 0) und "Zusatzzeichen" (Klick auf ein
  vorhandenes Schild haengt es darunter, kein Doppel, hoechstens zwei).
* Die Rechenregeln sind Zwillinge von vz_geom.js (vzRichtungGrad, vzNormGrad,
  vzZusatzErgaenzen) -- dieselben Werte, hier in Python, mit denselben Pruefpunkten.
* Zeichen am Weg eines Blattes brauchen ein Blatt: die Auswahl fuellt sich aus den
  geladenen Blatt-Layern, eine eigene Abfrage braucht es nicht. In der Verfahrenskarte
  bleibt sie leer.
* Seitenleiste: neue Gruppe "Verkehrszeichen setzen". Der Katalog kommt beim Start und nach
  dem Anmelden in die Palette (oeffentlich, faellt auf die lokale Ablage zurueck).

Neuer Test tools/headless_zeichen_test.py (QGIS 4.2 offscreen, ohne Zugangsschluessel):
30 Pruefungen gruen -- Rechenregeln, zusatz aus Text, Katalog, lokale Ablage, Umschreiben
des Stils, Palette mit Suche und Deckel. tools/headless_panel_test.py bleibt gruen; die
Leiste ist jetzt 1372 px hoch und rollt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:34:48 +02:00

471 lines
20 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
self._werkzeuge = {"schild": None, "zusatz": None} # Kartenwerkzeuge, faul gebaut
# ------------------------------------------------------------------
# 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,
"zeichen_setzen": self.run_zeichen_setzen,
"zusatz_anhaengen": self.run_zusatz_anhaengen,
},
)
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)
# Der Zeichenkatalog ist oeffentlich und liegt lokal — er kostet nichts und die
# Palette ist damit gleich nach dem Start bedienbar.
QTimer.singleShot(0, self._katalog_laden)
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._katalog_laden() # Palette fuellen (oeffentlicher Katalog)
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, Maßnahmenblätter oder Widmungen).")
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, client=self.client)
# ------------------------------------------------------------------
# Verkehrszeichen setzen
# ------------------------------------------------------------------
def _zeichen_melden(self, text, fehler=False):
self.panel.set_status(text)
if fehler:
self.iface.messageBar().pushMessage("VLN Karten", text, level=Qgis.Warning,
duration=6)
def _werkzeug(self, art):
"""Das Kartenwerkzeug bauen (einmal je Art) — beide teilen Palette und Blattwahl."""
from . import zeichen_werkzeug as zwm
if self._werkzeuge.get(art) is None:
canvas = self.iface.mapCanvas()
code = self.panel.aktueller_zeichencode
if art == "schild":
self._werkzeuge[art] = zwm.SchildSetzen(canvas, self.iface, code,
self._zeichen_melden,
self.panel.aktuelles_blatt)
else:
self._werkzeuge[art] = zwm.ZusatzAnhaengen(canvas, self.iface, code,
self._zeichen_melden)
return self._werkzeuge[art]
def _werkzeug_schalten(self, art, knopf):
canvas = self.iface.mapCanvas()
if not knopf.isChecked():
canvas.unsetMapTool(self._werkzeuge.get(art))
return
# Nur eines von beiden zur Zeit.
self.panel.werkzeug_zuruecksetzen()
knopf.setChecked(True)
werkzeug = self._werkzeug(art)
if hasattr(werkzeug, "reset"):
werkzeug.reset()
canvas.setMapTool(werkzeug)
def run_zeichen_setzen(self):
"""Werkzeug „Schild setzen" ein- oder ausschalten."""
self._blaetter_auffrischen()
self._werkzeug_schalten("schild", self.panel.schild_knopf)
def run_zusatz_anhaengen(self):
"""Werkzeug „Zusatzzeichen anhaengen" ein- oder ausschalten."""
self._werkzeug_schalten("zusatz", self.panel.zusatz_knopf)
def _blaetter_auffrischen(self):
"""Die Blattwahl aus den geladenen Layern fuellen (keine eigene Abfrage noetig)."""
try:
from . import zeichen_werkzeug as zwm
self.panel.setze_blaetter(zwm.blaetter_im_projekt())
except Exception: # noqa: BLE001
pass
def _katalog_laden(self):
"""Den Zeichenkatalog in die Palette holen (Server, sonst lokale Ablage)."""
try:
from . import zeichen_katalog as zk
self.panel.palette.setze_katalog(zk.katalog(self.client), self.client)
except Exception: # noqa: BLE001
pass # ohne Katalog bleibt die Palette leer
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)