Files
API_Karte_QGISDemo/vln_karten/plugin.py
T
erik 091ef281b0 QGIS-Plugin VLN Karten: Verfahrens-Layer der Karten-API laden und hochladen
Lädt Verfahrensgebiet, Plan 41, Karte alter Stand (KAS) und Wertermittlung
(WE) je VKZ vollständig aus KARTE_OBJEKT (Listen-Endpunkt mit Paging) und
schreibt sie per PUT zurück. Einmaliger Login (mail/password -> userauth),
API-Key persistiert in QSettings; Verfahrens-Auswahl in der Toolbar wird
je QGIS-Projekt gemerkt. Gemischte Geometrietypen werden beim Laden in
Punkte-/Linien-/Flächen-Layer gesplittet und beim Hochladen wieder
vereint. Qt5/Qt6-kompatibel (QGIS 3.22+ und QGIS 4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 13:31:44 +02:00

417 lines
15 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).
- 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.
"""
from functools import partial
from qgis.PyQt.QtCore import QSettings, QTimer
from qgis.PyQt.QtWidgets import QAction, QComboBox, QDialog, QMessageBox
from qgis.core import Qgis, QgsProject
from .api_client import ApiError, AuthError, KartenApiClient
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
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.toolbar = None
self.combo = None
# Unterdrückt das Mitschreiben in die Projektdatei, während die
# Auswahl programmatisch gesetzt wird.
self._restoring = False
# ------------------------------------------------------------------
# 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 = []
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)
)
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;
# die Verfahrensliste erst nach dem QGIS-Start abrufen.
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)
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.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
# ------------------------------------------------------------------
# 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
settings = QSettings()
settings.beginGroup(SETTINGS_GROUP)
settings.setValue("api_key", client.api_key)
settings.endGroup()
self.iface.messageBar().pushMessage(
"VLN Karten",
"Anmeldung erfolgreich — die Zugangsdaten werden für künftige "
"QGIS-Sitzungen gemerkt.",
level=Qgis.Success,
duration=5,
)
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._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.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):
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)
# ------------------------------------------------------------------
# Verfahrens-Auswahl <-> Projektdatei
# ------------------------------------------------------------------
def on_verfahren_changed(self, index):
if self._restoring or self.combo is None:
return
vkz = self.combo.currentData()
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:
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
def _current_vkz(self):
return self.combo.currentData() if self.combo 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,
)
# ------------------------------------------------------------------
# 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)