Stile: beim Laden vom Server anwenden, Knopf zum Zurueckspeichern (Gruppe MODUL_KARTENSTIL)

- ogc_layers.stil_anwenden(): GET …/style.qml, in eine Temp-Datei, loadNamedStyle; scheitert
  der Abruf, bleibt die QGIS-Zufallsfarbe (kein Abbruch des Ladens).
- ogc_layers.stil_speichern(): saveNamedStyle -> PUT; 403 meldet die fehlende Gruppe im Klartext.
- plugin.run_save_style(): Knopf 'Stil des aktiven Layers speichern'; mit Umschalttaste als
  Vorlage der Art (gilt fuer alle Verfahren, z. B. Karte alter Stand).
- README: Abschnitt Stile samt Routen und Berechtigung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik
2026-09-07 21:07:54 +02:00
co-authored by Claude Opus 4.8
parent dc960ddca4
commit 4504f9a34c
3 changed files with 197 additions and 31 deletions
+23
View File
@@ -152,6 +152,29 @@ networksetup -setautoproxystate Wi-Fi off # nur wenn im Netz kein Proxy nöti
In der QGIS-Meldungsleiste steht dann `Execution of PAC script … failed (…-1002)`.
### Stile (QML) — mitgeladen und zurückspeicherbar
Jede Sammlung hat einen Stil: `GET …/collections/{cid}/style.qml`. Der Server liefert den für
die Sammlung gespeicherten Stil, sonst die **Vorlage der Art** (`kas-*-flaeche` gilt für die
Karte alter Stand aller Verfahren), sonst einen erzeugten Standard — kategorisiert nach `typ`
mit den Farben des Web-GIS (Wege dunkel, Wasser blau, Ökologie grün, Problemstellen rot).
- **Plugin:** wendet den Stil beim Laden automatisch an. Toolbar-Knopf **„Stil des aktiven
Layers speichern“** schreibt den in QGIS eingestellten Stil zurück; mit gedrückter
**Umschalttaste** als Vorlage für die ganze Art (alle Verfahren).
- **Ohne Plugin:** Layer → Eigenschaften → Stil → *Stil laden* … bzw. die QML-Datei von der
URL holen. Zurückschreiben geht per `PUT` (z. B. `curl -T stil.qml -H "X-API-Key: …"`).
- **Berechtigung:** Lesen darf jeder, der die Sammlung sieht. Speichern und Löschen nur
Verbandsmitarbeiter oder Mitglieder der Gruppe **`MODUL_KARTENSTIL`**; sonst HTTP 403. Ob der
eigene Zugang darf, steht in der Sammlung als `vln:styleWritable` und in der Kopfzeile
`X-Stil-Schreibbar` der QML-Antwort.
```
GET /ogc/collections/{cid}/style.qml Stil (Kopfzeile X-Stil-Quelle: sammlung|vorlage|standard)
PUT /ogc/collections/{cid}/style.qml[?vorlage=1] Stil speichern (Gruppe MODUL_KARTENSTIL)
DELETE /ogc/collections/{cid}/style.qml[?vorlage=1] gespeicherten Stil entfernen
```
### Regeln beim Schreiben
- **Geometriefamilie:** in `…-punkt` nur Punkte, in `…-linie` nur Linien, in `…-flaeche` nur Flächen (sonst HTTP 400).
+128 -26
View File
@@ -24,7 +24,12 @@ Blatt-Spalten als ``blatt_*``-Attribute; neue Features brauchen ``blatt_uuid`` o
``blatt_typ`` + ``blatt_name``).
"""
from qgis.PyQt.QtCore import QSettings
import os
import tempfile
from qgis.PyQt.QtCore import QSettings, QUrl, Qt, QTimer
from qgis.PyQt.QtNetwork import QNetworkRequest
from qgis.PyQt.QtWidgets import QProgressBar
from qgis.core import (
Qgis,
QgsApplication,
@@ -34,7 +39,9 @@ from qgis.core import (
QgsVectorLayer,
)
from .api_client import ApiError
from qgis.core import QgsBlockingNetworkRequest
from .api_client import ApiError, AuthError
AUTHCFG_NAME = "VLN Karten (API-Key)"
SETTINGS_AUTHCFG = "ogc_authcfg"
@@ -101,6 +108,74 @@ def collections_laden(client, vkz):
return rows
def stil_url(base_url, cid):
"""Adresse des QGIS-Stils einer Sammlung (QML) auf dem Server."""
return "%s/collections/%s/style.qml" % (ogc_url(base_url), cid)
def stil_anwenden(layer, base_url, cid, api_key=None, authcfg=None):
"""Den Stil der Sammlung vom Server holen und auf den Layer anwenden.
Der Server liefert immer etwas: den fuer die Sammlung gespeicherten Stil, sonst die Vorlage
der Art, sonst einen erzeugten Standard (kategorisiert nach ``typ``). Schlaegt der Abruf
fehl, behaelt der Layer die QGIS-Zufallsfarbe — kein Grund, das Laden abzubrechen.
:returns: ``(True, quelle)`` oder ``(False, Meldung)``.
"""
req = QNetworkRequest(QUrl(stil_url(base_url, cid)))
if api_key:
req.setRawHeader(b"X-API-Key", api_key.encode("utf-8"))
blk = QgsBlockingNetworkRequest()
if authcfg:
blk.setAuthCfg(authcfg)
if blk.get(req) != QgsBlockingNetworkRequest.NoError:
return False, blk.errorMessage()
antwort = blk.reply()
if antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) != 200:
return False, "HTTP %s" % antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
quelle = bytes(antwort.rawHeader(b"X-Stil-Quelle")).decode() or "?"
pfad = os.path.join(tempfile.gettempdir(), "vln_%s.qml" % cid.replace("*", "_"))
with open(pfad, "wb") as fh:
fh.write(bytes(antwort.content()))
meldung, ok = layer.loadNamedStyle(pfad)
if ok:
layer.triggerRepaint()
return bool(ok), quelle if ok else meldung
def stil_speichern(layer, base_url, cid, api_key=None, authcfg=None, vorlage=False):
"""Den aktuellen Layer-Stil als QML an den Server schicken (PUT …/style.qml).
:param vorlage: als Vorlage der Art speichern — gilt dann fuer alle Verfahren.
:returns: ``(True, Text)`` oder ``(False, Meldung)``; 403 heisst: Gruppe MODUL_KARTENSTIL fehlt.
"""
pfad = os.path.join(tempfile.gettempdir(), "vln_upload_%s.qml" % cid.replace("*", "_"))
meldung = layer.saveNamedStyle(pfad)
if not os.path.exists(pfad):
return False, "QGIS konnte den Stil nicht schreiben: %s" % meldung
with open(pfad, "rb") as fh:
rumpf = fh.read()
url = stil_url(base_url, cid) + ("?vorlage=1" if vorlage else "")
req = QNetworkRequest(QUrl(url))
req.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/x-qgis-layer-settings")
if api_key:
req.setRawHeader(b"X-API-Key", api_key.encode("utf-8"))
blk = QgsBlockingNetworkRequest()
if authcfg:
blk.setAuthCfg(authcfg)
blk.put(req, rumpf)
antwort = blk.reply()
code = antwort.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
if code == 200:
return True, "%d Zeichen gespeichert%s" % (len(rumpf), " (Vorlage der Art)" if vorlage else "")
if code == 403:
return False, ("Keine Berechtigung: Stile darf nur speichern, wer in der Gruppe "
"MODUL_KARTENSTIL ist (oder Verbandsmitarbeiter).")
if code in (401, None):
raise AuthError("Anmeldung abgelaufen")
return False, "Server antwortete mit HTTP %s: %s" % (code, bytes(antwort.content())[:200].decode("utf-8", "replace"))
def layer_uri(base_url, cid, authcfg=None, storage_crs=None, api_key=None):
"""Die OAPIF-Datenquelle: Landing-URL + Sammlungs-ID (+ Authentifizierung).
@@ -130,36 +205,63 @@ def layer_uri(base_url, cid, authcfg=None, storage_crs=None, api_key=None):
def layer_hinzufuegen(iface, base_url, vkz, vkz_name, collections, authcfg=None, api_key=None):
"""Je Sammlung einen OAPIF-Layer anlegen, gruppiert unter „VLN <Verfahren>".
"""Alle Sammlungen als OAPIF-Layer in die Gruppe „VLN <Verfahren>" hängen — **schrittweise**.
:returns: ``(geladen, fehler)`` — Anzahl Layer und Liste ``(cid, Meldung)``.
Warum nicht in einer Schleife: der OAPIF-Provider stellt beim Anlegen jedes Layers mehrere
**blockierende** Netzanfragen (Landing Page, OpenAPI, Conformance, Collection, Items, Schema,
zweimal OPTIONS). Bei rund 30 Sammlungen friert die Oberfläche dabei minutenlang ein und
sieht aus wie abgestürzt (Erik, 07.09.2026). Darum wird je Ereignisschleifen-Durchlauf
genau EIN Layer angelegt: QGIS bleibt bedienbar, der Fortschritt steht in der Meldungsleiste.
:returns: ``QProgressBar``-Träger (die Meldung) — der Aufrufer muss nichts damit tun.
"""
projekt = QgsProject.instance()
wurzel = projekt.layerTreeRoot()
gruppe_name = "VLN %s %s" % (vkz, vkz_name or "")
gruppe = wurzel.findGroup(gruppe_name.strip())
if gruppe is None:
gruppe = wurzel.insertGroup(0, gruppe_name.strip())
gruppe_name = ("VLN %s %s" % (vkz, vkz_name or "")).strip()
gruppe = wurzel.findGroup(gruppe_name) or wurzel.insertGroup(0, gruppe_name)
untergruppen = {}
geladen, fehler = 0, []
for coll in collections:
balken = QProgressBar()
balken.setMaximum(len(collections))
balken.setValue(0)
balken.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
meldung = iface.messageBar().createMessage("VLN Karten", "Layer werden geladen …")
meldung.layout().addWidget(balken)
iface.messageBar().pushWidget(meldung, Qgis.Info)
zustand = {"i": 0, "geladen": 0, "fehler": []}
def schritt():
if zustand["i"] >= len(collections):
iface.messageBar().popWidget(meldung)
text = "%d Layer geladen — zum Bearbeiten den Layer in den Bearbeitungsmodus " \
"schalten; jede gespeicherte Änderung geht sofort an die API." % zustand["geladen"]
if zustand["fehler"]:
text += " %d Sammlung(en) nicht ladbar: %s" % (
len(zustand["fehler"]), ", ".join(f[0] for f in zustand["fehler"][:3]))
iface.messageBar().pushMessage(
"VLN Karten", text,
level=Qgis.Success if zustand["geladen"] else Qgis.Warning, duration=10)
return
coll = collections[zustand["i"]]
zustand["i"] += 1
cid = str(coll.get("id") or "")
praefix = cid.split("-", 1)[0]
gruppen_name = dict(GRUPPEN).get(praefix, "Weitere")
gruppen_name = dict(GRUPPEN).get(cid.split("-", 1)[0], "Weitere")
if gruppen_name not in untergruppen:
untergruppen[gruppen_name] = gruppe.findGroup(gruppen_name) or gruppe.addGroup(gruppen_name)
titel = str(coll.get("title") or cid)
layer = QgsVectorLayer(layer_uri(base_url, cid, authcfg, coll.get("storageCrs"), api_key), titel, "OAPIF")
if not layer.isValid():
fehler.append((cid, layer.error().message() if layer.error() else "Layer ungültig"))
continue
projekt.addMapLayer(layer, False)
untergruppen[gruppen_name].addLayer(layer)
geladen += 1
if geladen:
iface.messageBar().pushMessage(
"VLN Karten",
"%d Layer aus der OGC API geladen — zum Bearbeiten den Layer in den "
"Bearbeitungsmodus schalten; jede gespeicherte Änderung geht sofort an die API." % geladen,
level=Qgis.Success, duration=8)
return geladen, fehler
layer = QgsVectorLayer(layer_uri(base_url, cid, authcfg, coll.get("storageCrs"), api_key),
titel, "OAPIF")
if layer.isValid():
stil_anwenden(layer, base_url, cid, api_key, authcfg) # Darstellung wie im Web-GIS
projekt.addMapLayer(layer, False)
untergruppen[gruppen_name].addLayer(layer)
zustand["geladen"] += 1
else:
zustand["fehler"].append((cid, layer.error().message() if layer.error() else "ungültig"))
balken.setValue(zustand["i"])
meldung.setText("Layer werden geladen … %d von %d (%s)" % (zustand["i"], len(collections), titel))
QTimer.singleShot(0, schritt) # zurück in die Ereignisschleife: QGIS bleibt bedienbar
QTimer.singleShot(0, schritt)
return meldung
+46 -5
View File
@@ -15,7 +15,7 @@ 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 qgis.core import Qgis, QgsDataSourceUri, QgsProject
from .api_client import ApiError, AuthError, KartenApiClient
from .layer_manager import (
@@ -99,6 +99,9 @@ class VlnKartenPlugin:
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(
@@ -444,12 +447,50 @@ class VlnKartenPlugin:
self._show_error("OGC API", "Für dieses Verfahren gibt es keine Sammlungen "
"(oder keine Berechtigung).")
return
geladen, fehler = ogc_layers.layer_hinzufuegen(
# Schrittweise: je Ereignisschleifen-Durchlauf ein Layer, Fortschritt in der
# Meldungsleiste. Fehler kommen dort am Ende gesammelt, nicht als Modal-Dialog.
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)
if fehler:
self._show_error("OGC API — nicht geladen",
"\n".join("%s: %s" % f for f in fehler))
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").
"""
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 = 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)
else:
self._show_error("Stil nicht gespeichert", text)
# ------------------------------------------------------------------
# Hilfsfunktionen