Compare commits

...
Author SHA1 Message Date
Daniel 7a5ed12797 Merge pull request 'wfs-abruf-fix' (#53) from wfs-abruf-fix into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #53
2026-07-22 11:14:08 +02:00
Daniel 021747ae06 wfs-abruf-fix 2026-07-22 11:12:43 +02:00
Daniel 91bafe632f changelog.txt aktualisiert
Release Plugin / release (push) Successful in 4s
2026-07-15 16:04:55 +02:00
Daniel 7ed22fb23e Merge pull request 'Shape verfahrensgebiet' (#52) from shape-Verfahrensgebiet into unstable
Reviewed-on: #52
2026-07-15 16:03:18 +02:00
Daniel 3bbb66ec32 shape-Ladefunktion Verfahrensgebiet 2026-07-15 15:27:24 +02:00
Daniel 9258f03705 Umlaute-Fix Layernamen
Release Plugin / release (push) Successful in 4s
Regex um Umlaute erweitert
2026-07-07 15:49:37 +02:00
Daniel 9862386646 changelog.txt aktualisiert 2026-07-07 10:04:22 +02:00
Daniel 706de43512 Merge pull request 'Unstable nach testing' (#51) from unstable into testing
Release Plugin / release (push) Successful in 5s
Reviewed-on: #51
2026-07-07 10:01:32 +02:00
Michael Otto 68ce088192 auf neuen Release-Workflow umgestellt 2026-07-02 11:35:01 +02:00
Michael Otto 9dc15b5b92 auf neuen Release-Workflow umgestellt
Release Plugin / release (push) Successful in 5s
2026-07-02 11:02:04 +02:00
release-bot 319d425670 Release 26.6.1-unstable 2026-06-19 09:53:11 +00:00
Daniel 970343cd98 changelog.txt aktualisiert
Release Plugin / release (push) Successful in 4s
2026-06-19 11:52:15 +02:00
Daniel 3d9bfdb316 Merge pull request 'Api integration plan41' (#50) from API-Integration_Plan41 into unstable
Reviewed-on: #50
2026-06-19 11:48:35 +02:00
Daniel 15e48f58af VLN-API integriert 2026-06-19 11:47:36 +02:00
release-bot 933b3f9cb6 Release 26.5.2-testing 2026-05-22 12:30:19 +00:00
Daniel f6b62395dd Merge pull request 'Feature/vkz differenzierung verfahrensgebiet' (#49) from feature/vkz-differenzierung-verfahrensgebiet into testing
Release Plugin / release (push) Successful in 4s
Reviewed-on: #49
2026-05-22 14:29:36 +02:00
Daniel 025a5f5e13 Messagebox-Fix "VG" in "Verfahrensgebiet" 2026-05-22 14:26:58 +02:00
Daniel e196a4896f Fokusverlust bei Dialogauswahl gefixt 2026-05-22 08:56:26 +02:00
Daniel 63a2e8d63a dialogbox für BROR-Abfrage ergänzt 2026-05-22 08:41:12 +02:00
release-bot 79b827c009 Release 26.5.1-testing 2026-05-06 11:14:30 +00:00
Daniel 0e695ba595 Merge pull request 'Grenzpunkt-Funktionen für Verfahrensgebiet' (#47) from unstable into testing
Release Plugin / release (push) Successful in 4s
Reviewed-on: #47
2026-05-06 13:13:33 +02:00
release-bot e11f84e89b Release 26.5.1-unstable 2026-05-06 08:40:38 +00:00
Daniel 171ebcbe4f Merge pull request 'Verfahrensgebiet-Prüfung als Knickpunkte' (#46) from feature/grenzpunkte_laden into unstable
Release Plugin / release (push) Successful in 3s
Reviewed-on: #46
2026-05-06 10:39:54 +02:00
Daniel b7dfac68f1 Verfahrensgebiet-Prüfung als Knickpunkte 2026-05-05 19:47:25 +02:00
release-bot 62e9bcee1e Release 26.4.8-unstable 2026-04-24 04:52:28 +00:00
Daniel e9d9a2294d Merge pull request 'verfahrensgebiet aus Flst' (#35) from verfahrensgebiet_aus_flst into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #35
2026-04-23 22:30:12 +02:00
Daniel c8fde119b4 boundary-Unterscheidung für QGIS 3/4 2026-04-23 22:23:50 +02:00
Daniel 692f690fea verfahrensgebiet aus Flst 2026-04-22 14:53:34 +02:00
11 changed files with 493 additions and 19 deletions
+13
View File
@@ -0,0 +1,13 @@
[general]
version=
name=LNO Sachsen | Plugin Basisfunktionen
description=Plugin mit Basisfunktionen
author=Daniel Helbig, Michael Otto
homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis
tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/issues
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/unstable/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
deprecated=false
supportsQt6=true
+13
View File
@@ -1,3 +1,16 @@
Version 26.7.1-unstable:
- Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt
---
Version 26.7.1-testing:
- VLN-API zum Laden der Plan41-Maßnahmen integriert
---
Version 26.5.2-testing:
---
Version 26.5.1-testing:
---
Version 26.5.1-unstable:
---
Version 26.4.8-unstable:
---
Version 26.4.7-unstable:
---
+1 -1
View File
@@ -20,7 +20,7 @@ from .ly_metadata_wrapper import (
is_layer_editable,
)
from .ly_style_wrapper import apply_style
from .dialog_wrapper import ask_yes_no, ask_overwrite_append_cancel_custom, ask_ergaenzen_entfernen_ersetzen_abbrechen
from .dialog_wrapper import ask_yes_no, ask_overwrite_append_cancel_custom, ask_ergaenzen_entfernen_ersetzen_abbrechen, ask_detailpruefung_oder_vg_laden
from .message_wrapper import (
_get_message_bar,
+162
View File
@@ -182,6 +182,102 @@ def ask_ergaenzen_entfernen_ersetzen_abbrechen(
return "abbrechen"
def ask_detailpruefung_oder_vg_laden(
parent: Any,
title: str,
message: str,
) -> Literal["detailpruefung", "als_vg_laden", "abbrechen"]:
"""Zeigt Dialog mit drei Optionen nach erkannter Flächenabweichung.
Optionen:
- **Detailprüfung starten**: Knickpunktbasierte Prüfung des BROR-Umrings
- **Bodenordnungsobjekt als VG laden**: BROR direkt als Verfahrensgebiet übernehmen
- **Abbrechen**: Vorgang beenden ohne weitere Aktion
Parameters
----------
parent :
Eltern-Widget oder None.
title : str
Dialog-Titel.
message : str
Hauptmeldung mit Flächen-Informationen.
Returns
-------
Literal["detailpruefung", "als_vg_laden", "abbrechen"]
Genaue Entscheidung des Nutzers.
"""
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_detailpruefung_oder_vg_laden('{title}') → abbrechen")
return "abbrechen"
msg = QMessageBox(parent)
msg.setIcon(ICON_QUESTION)
msg.setWindowTitle(title)
msg.setText(message)
detail_btn = msg.addButton("Detailprüfung starten", QMessageBox.ButtonRole.AcceptRole)
vg_btn = msg.addButton("Bodenordnungsobjekt als Verfahrensgebiet laden", QMessageBox.ButtonRole.ActionRole)
msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole)
exec_dialog(msg)
clicked = msg.clickedButton()
if clicked == detail_btn:
return "detailpruefung"
elif clicked == vg_btn:
return "als_vg_laden"
else:
return "abbrechen"
def ask_detailpruefung_oder_abbrechen(
parent: Any,
title: str,
message: str,
) -> Literal["detailpruefung", "abbrechen"]:
"""Zeigt Dialog mit zwei Optionen nach erkannter Flächenabweichung (Shape-Workflow).
Optionen:
- **Detailprüfung starten**: Knickpunktbasierte Prüfung des Shape-VG-Umrings
- **Abbrechen**: Vorgang beenden, Shape-Verfahrensgebiet unverändert behalten
Parameters
----------
parent :
Eltern-Widget oder None.
title : str
Dialog-Titel.
message : str
Hauptmeldung mit Flächen-Informationen.
Returns
-------
Literal["detailpruefung", "abbrechen"]
Genaue Entscheidung des Nutzers.
"""
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_detailpruefung_oder_abbrechen('{title}') → abbrechen")
return "abbrechen"
msg = QMessageBox(parent)
msg.setIcon(ICON_QUESTION)
msg.setWindowTitle(title)
msg.setText(message)
detail_btn = msg.addButton("Detailprüfung starten", QMessageBox.ButtonRole.AcceptRole)
msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole)
exec_dialog(msg)
clicked = msg.clickedButton()
if clicked == detail_btn:
return "detailpruefung"
else:
return "abbrechen"
class ProgressDialog:
def __init__(self, total: int, title: str = "Fortschritt", label: str = "Verarbeite..."):
self.total = max(total, 1)
@@ -274,7 +370,73 @@ class ProgressDialog:
if self._dlg is not None:
self._dlg.close()
def raise_to_front(self) -> None:
"""Bringt den Fortschrittsdialog in den Vordergrund.
Wird aufgerufen, nachdem ein modaler Subdialog (z.B. Auswahlbox)
geschlossen wurde und der Fortschrittsdialog wieder sichtbar sein soll.
"""
if QT_VERSION == 0:
return
if self._dlg is not None:
try:
self._dlg.raise_()
self._dlg.activateWindow()
QCoreApplication.processEvents()
except Exception:
pass
def create_progress_dialog(total: int, title: str = "Fortschritt", label: str = "Verarbeite...") -> ProgressDialog:
return ProgressDialog(total, title, label)
def ask_bror_kombination(
parent: Any,
kombinationen: list[tuple[str, str]],
) -> Optional[tuple[str, str]]:
"""Zeigt Dialog zur Auswahl einer BROR-Kombination (adv_name + bezeichnung).
Wird aufgerufen, wenn der WFS mehrere verschiedene Kombinationen aus
``adv_name`` und ``bezeichnung`` zurückliefert, sodass der Nutzer
entscheiden muss, welche Objekte in die Pipeline einfließen sollen.
Parameters
----------
parent :
Eltern-Widget oder None.
kombinationen :
Sortierte Liste von ``(adv_name, bezeichnung)``-Tupeln.
Returns
-------
Optional[tuple[str, str]]
Das gewählte ``(adv_name, bezeichnung)``-Tupel oder ``None`` bei
Abbruch bzw. leerer Eingabe.
"""
if not kombinationen:
return None
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_bror_kombination → {kombinationen[0]}")
return kombinationen[0]
items = [f"{adv} | {bez}" for adv, bez in kombinationen]
item, ok = QInputDialog.getItem(
parent,
"BROR-Objekt auswählen",
"Mehrere BauRaumOderBodenordnungsrecht-Objekte gefunden.\n"
"Bitte wählen Sie die gewünschte Kombination\n"
"aus ADV-Name und Bezeichnung:",
items,
0,
False,
)
if not ok:
return None
try:
idx = items.index(item)
return kombinationen[idx]
except ValueError:
return None
+19
View File
@@ -5,6 +5,25 @@ from sn_basis.functions.sys_wrapper import get_plugin_root, join_path
from sn_basis.modules.stilpruefer import Stilpruefer
from typing import Optional
def apply_style_from_path(layer, style_path: str) -> bool:
"""
Wendet einen Layerstil anhand eines vollständigen Pfades an.
Im Gegensatz zu :func:`apply_style` wird der Pfad extern bereitgestellt;
es findet keine eigene Stilprüfung statt.
"""
if not layer_exists(layer):
return False
try:
ok, _ = layer.loadNamedStyle(str(style_path))
if ok:
getattr(layer, "triggerRepaint", lambda: None)()
return True
except Exception:
pass
return False
def apply_style(layer, style_name: str) -> bool:
"""
Wendet einen Layerstil an, sofern er gültig ist.
+168
View File
@@ -24,6 +24,7 @@ QgsFeature: Type[Any]
QgsField: Type[Any]
QgsGeometry: Type[Any]
QgsFeatureRequest: Type[Any]
QgsRectangle: Type[Any]
QgsCoordinateTransform: Type[Any]
QgsCoordinateReferenceSystem: Type[Any]
QgsPrintLayout: Type[Any]
@@ -37,6 +38,12 @@ QgsLayoutPoint: Type[Any]
QgsLayoutSize: Type[Any]
QgsUnitTypes: Type[Any]
QgsLayoutItem: Type[Any]
QgsBlockingNetworkRequest: Type[Any]
QgsJsonExporter: Type[Any]
QgsJsonUtils: Type[Any]
GEOM_POINT: Any = None
GEOM_LINE: Any = None
GEOM_POLYGON: Any = None
MAP_GRID_STYLE_MARKERS: Any = None
MAP_GRID_STYLE_FRAME_ANNOTATIONS: Any = None
@@ -76,6 +83,7 @@ try:
QgsField as _QgsField,
QgsGeometry as _QgsGeometry,
QgsFeatureRequest as _QgsFeatureRequest,
QgsRectangle as _QgsRectangle,
QgsCoordinateTransform as _QgsCoordinateTransform,
QgsCoordinateReferenceSystem as _QgsCoordinateReferenceSystem,
QgsPrintLayout as _QgsPrintLayout,
@@ -89,6 +97,9 @@ try:
QgsLayoutSize as _QgsLayoutSize,
QgsUnitTypes as _QgsUnitTypes,
QgsLayoutItem as _QgsLayoutItem,
QgsBlockingNetworkRequest as _QgsBlockingNetworkRequest,
QgsJsonExporter as _QgsJsonExporter,
QgsJsonUtils as _QgsJsonUtils,
)
QgsProject = _QgsProject
@@ -102,6 +113,7 @@ try:
QgsField = _QgsField
QgsGeometry = _QgsGeometry
QgsFeatureRequest = _QgsFeatureRequest
QgsRectangle = _QgsRectangle
QgsCoordinateTransform = _QgsCoordinateTransform
QgsCoordinateReferenceSystem = _QgsCoordinateReferenceSystem
QgsPrintLayout = _QgsPrintLayout
@@ -115,6 +127,32 @@ try:
QgsLayoutSize = _QgsLayoutSize
QgsUnitTypes = _QgsUnitTypes
QgsLayoutItem = _QgsLayoutItem
QgsBlockingNetworkRequest = _QgsBlockingNetworkRequest
QgsJsonExporter = _QgsJsonExporter
QgsJsonUtils = _QgsJsonUtils
# Geometrietyp-Konstanten (QGIS 4 / QGIS 3.30+ vs. ältere QGIS-3)
def _resolve_geom_type(*paths):
for obj, *attrs in paths:
val = obj
for attr in attrs:
val = getattr(val, attr, None)
if val is None:
break
else:
if val is not None:
return val
return None
GEOM_POINT = _resolve_geom_type(
(_Qgis, "GeometryType", "Point"),
) or getattr(__import__("qgis.core", fromlist=["QgsWkbTypes"]), "QgsWkbTypes", type("_", (), {"PointGeometry": 0})).PointGeometry
GEOM_LINE = _resolve_geom_type(
(_Qgis, "GeometryType", "Line"),
) or getattr(__import__("qgis.core", fromlist=["QgsWkbTypes"]), "QgsWkbTypes", type("_", (), {"LineGeometry": 1})).LineGeometry
GEOM_POLYGON = _resolve_geom_type(
(_Qgis, "GeometryType", "Polygon"),
) or getattr(__import__("qgis.core", fromlist=["QgsWkbTypes"]), "QgsWkbTypes", type("_", (), {"PolygonGeometry": 2})).PolygonGeometry
def _resolve_qgis_enum(*paths: tuple[Any, ...]) -> Any:
for path in paths:
@@ -774,6 +812,60 @@ except Exception:
QgsVectorFileWriter = _MockQgsVectorFileWriter
# Geometrietyp-Konstanten (Mock-Werte entsprechen QgsWkbTypes)
GEOM_POINT = 0
GEOM_LINE = 1
GEOM_POLYGON = 2
class _MockQgsBlockingNetworkRequest:
NoError = 0
def __init__(self):
self._reply = None
def get(self, request: Any) -> int:
return self.NoError
def post(self, request: Any, data: bytes) -> int:
return self.NoError
def put(self, request: Any, data: bytes) -> int:
return self.NoError
def reply(self) -> Any:
return self._reply
def errorMessage(self) -> str:
return ""
QgsBlockingNetworkRequest = _MockQgsBlockingNetworkRequest
class _MockQgsJsonUtils:
@staticmethod
def stringToFields(text: str) -> Any:
return type("_Fields", (), {"toList": lambda self: []})()
@staticmethod
def stringToFeatureList(text: str, fields: Any) -> list:
return []
QgsJsonUtils = _MockQgsJsonUtils
class _MockQgsJsonExporter:
def __init__(self, layer: Any = None):
self._layer = layer
def setSourceCrs(self, crs: Any) -> None:
pass
def setTransformGeometries(self, transform: bool) -> None:
pass
def exportFeatures(self, features: list) -> str:
return '{"type":"FeatureCollection","features":[]}'
QgsJsonExporter = _MockQgsJsonExporter
# ---------------------------------------------------------
# Netzwerk
# ---------------------------------------------------------
@@ -951,3 +1043,79 @@ def layer_exists_in_gpkg(gpkg_path: str, layer_name: str) -> bool:
pass
return False
# ---------------------------------------------------------
# QGIS-3/4-kompatible Geometrie-Hilfsfunktionen
# ---------------------------------------------------------
def polygon_zu_linie(geom: Any) -> Optional[Any]:
"""Konvertiert ein Polygon/MultiPolygon zur Umringslinie (QGIS 3 + 4).
In QGIS 3.x existiert ``QgsGeometry.boundary()``, in QGIS 4.x wurde sie
entfernt. Diese Funktion kapselt die Versionsunterschiede und stellt dem
Fachplugin eine stabile API bereit.
Strategie (dreistufig):
1. ``geom.boundary()`` funktioniert in QGIS 3.x
2. ``geom.convertToType(1, False)`` ``1`` = LineGeometry-Wert,
identisch in ``QgsWkbTypes.LineGeometry`` (3.x) und
``Qgis.GeometryType.Line`` (4.x)
3. Manuelle Ring-Extraktion via ``asPolygon()`` / ``asMultiPolygon()``
+ ``QgsGeometry.fromPolylineXY()``
Parameters
----------
geom :
Polygon- oder MultiPolygon-``QgsGeometry``.
Returns
-------
Optional[QgsGeometry]
Liniengeometrie (Umring) oder ``None`` bei Fehler / leerer Eingabe.
"""
if geom is None:
return None
try:
if geom.isEmpty():
return None
except Exception:
return None
# Stufe 1: QGIS 3.x .boundary() existiert und liefert alle Ringe korrekt
if hasattr(geom, "boundary"):
try:
result = geom.boundary()
if result is not None and not result.isEmpty():
return result
except Exception:
pass
# Stufe 2: Manuelle Ring-Extraktion (primärer QGIS-4.x-Pfad).
# convertToType(1, False) würde bei MultiPolygon nur den ersten Außenring
# zurückgeben und alle weiteren Polygone sowie Innenringe verwerfen → daher
# direkt die vollständige manuelle Extraktion als nächste Stufe.
lines = []
try:
if geom.isMultipart():
for polygon in geom.asMultiPolygon():
for ring in polygon:
lines.append(QgsGeometry.fromPolylineXY(ring))
else:
for ring in geom.asPolygon():
lines.append(QgsGeometry.fromPolylineXY(ring))
except Exception:
pass
if lines:
return QgsGeometry.unaryUnion(lines)
# Stufe 3: Letzter Fallback convertToType mit destMultipart=True, damit
# zumindest alle Außenringe eines MultiPolygons erfasst werden.
try:
result = geom.convertToType(1, True)
if result is not None and not result.isEmpty():
return result
except Exception:
pass
return None
+32 -2
View File
@@ -45,6 +45,7 @@ QComboBox: Type[Any] = object
QCheckBox: Type[Any] = object
QHBoxLayout: Type[Any] = object
QFont: Type[Any] = object
QSettings: Type[Any] = object
def exec_dialog(dialog: Any) -> Any:
@@ -99,7 +100,8 @@ try:
QUrl as _QUrl,
QCoreApplication as _QCoreApplication,
Qt as _Qt,
QVariant as _QVariant
QVariant as _QVariant,
QSettings as _QSettings,
)
from qgis.PyQt.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -140,6 +142,7 @@ try:
QVariant = _QVariant
QHBoxLayout = _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
# ✅ QT6 ENUMS
YES = QMessageBox.StandardButton.Yes
NO = QMessageBox.StandardButton.No
@@ -199,7 +202,8 @@ except (ImportError, AttributeError):
QUrl as _QUrl,
QCoreApplication as _QCoreApplication,
Qt as _Qt,
QVariant as _QVariant
QVariant as _QVariant,
QSettings as _QSettings,
)
from PyQt5.QtNetwork import (
QNetworkRequest as _QNetworkRequest,
@@ -238,6 +242,7 @@ except (ImportError, AttributeError):
QVariant = _QVariant
QHBoxLayout= _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
# ✅ PYQT5 ENUMS
YES = QMessageBox.Yes
@@ -560,6 +565,31 @@ except (ImportError, AttributeError):
pass
QHBoxLayout = _MockQHBoxLayout
class _MockQSettings:
def __init__(self, *args, **kwargs):
self._data: dict = {}
self._group = ""
def beginGroup(self, group: str) -> None:
self._group = group
def endGroup(self) -> None:
self._group = ""
def setValue(self, key: str, value: Any) -> None:
full = "%s/%s" % (self._group, key) if self._group else key
self._data[full] = value
def value(self, key: str, default: Any = "") -> Any:
full = "%s/%s" % (self._group, key) if self._group else key
return self._data.get(full, default)
def remove(self, key: str) -> None:
full = "%s/%s" % (self._group, key) if self._group else key
self._data.pop(full, None)
QSettings = _MockQSettings
class _MockQCheckBox:
def __init__(self, text: str = "", *args, **kwargs):
self._text = text
+2 -2
View File
@@ -1,11 +1,11 @@
[general]
version=26.4.7-unstable
version=26.7.2-testing
name=LNO Sachsen | Plugin Basisfunktionen
description=Plugin mit Basisfunktionen
author=Daniel Helbig, Michael Otto
homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis
tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/issues
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/unstable/
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/testing/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
+55 -12
View File
@@ -19,7 +19,7 @@ Designprinzipien
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, quote
import json
import time
@@ -132,6 +132,7 @@ class Datenabruf:
# außer bei WMS (WMS bleibt unverändert).
use_bbox = (raumfilter != "ohne") and (str(provider).upper() != "WMS")
url = self._build_provider_url(link=link, provider=str(provider), bbox_geom=bbox_geom if use_bbox else None)
print(f"[Datenabruf] URL für {thema!r} ({provider}): {url}")
# 2b) Fachdaten abrufen
features, error_msg = self._fetch_features(
@@ -309,8 +310,11 @@ class Datenabruf:
if callable(extent_to_bbox):
bbox_str = extent_to_bbox(extent_obj)
else:
# Fallback: einfache xmin/ymin/xmax/ymax-Extraktion (duck-typing)
if hasattr(extent_obj, "xmin") and callable(getattr(extent_obj, "xmin")):
# Fallback: xmin/ymin/xmax/ymax-Extraktion (duck-typing)
# QgsRectangle verwendet xMinimum()/yMinimum()/xMaximum()/yMaximum()
if hasattr(extent_obj, "xMinimum") and callable(getattr(extent_obj, "xMinimum")):
bbox_str = f"{extent_obj.xMinimum()},{extent_obj.yMinimum()},{extent_obj.xMaximum()},{extent_obj.yMaximum()}"
elif hasattr(extent_obj, "xmin") and callable(getattr(extent_obj, "xmin")):
bbox_str = f"{extent_obj.xmin()},{extent_obj.ymin()},{extent_obj.xmax()},{extent_obj.ymax()}"
elif isinstance(extent_obj, (tuple, list)) and len(extent_obj) == 4:
bbox_str = f"{extent_obj[0]},{extent_obj[1]},{extent_obj[2]},{extent_obj[3]}"
@@ -325,12 +329,26 @@ class Datenabruf:
if provider_norm == "WFS":
query_params.setdefault("service", "WFS")
query_params.setdefault("request", "GetFeature")
query_params.setdefault("outputFormat", "application/json")
if bbox_str:
query_params.setdefault("BBOX", bbox_str)
query_params.setdefault("VERSION", "2.0.0")
# WFS 2.0: 'typename' → 'TYPENAMES'
if "typename" in query_params and "TYPENAMES" not in query_params:
query_params["TYPENAMES"] = query_params.pop("typename")
# resultType=hits: nur Anzahl vom Server abfragen, kein GML-Download
query_params.setdefault("resultType", "hits")
# CRS in OGC-URN-Format (wie QGIS Datenquellenverwaltung)
srsname_urn: Optional[str] = None
if crs_authid:
query_params.setdefault("SRSNAME", crs_authid)
new_query = urlencode(query_params, doseq=True)
if crs_authid.upper().startswith("EPSG:"):
srsname_urn = f"urn:ogc:def:crs:EPSG::{crs_authid.split(':', 1)[1]}"
else:
srsname_urn = crs_authid
if bbox_str:
# 5. Komponente: CRS-URN anhängen (Standard für WFS 2.0)
bbox_with_crs = f"{bbox_str},{srsname_urn}" if srsname_urn else bbox_str
query_params.setdefault("BBOX", bbox_with_crs)
if srsname_urn:
query_params.setdefault("SRSNAME", srsname_urn)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
@@ -389,14 +407,14 @@ class Datenabruf:
query_params.setdefault("inSR", srid)
query_params.setdefault("outSR", srid)
new_query = urlencode(query_params, doseq=True)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
rebuilt = parsed_rest._replace(query=new_query)
return urlunparse(rebuilt)
# Default: generischer bbox-Parameter (nur wenn vorhanden)
if bbox_str:
query_params.setdefault("bbox", bbox_str)
new_query = urlencode(query_params, doseq=True)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
@@ -573,7 +591,33 @@ class Datenabruf:
# Sonst: gib das gesamte JSON als einzelnes Objekt zurück
return [parsed], None
except json.JSONDecodeError:
# Nicht-JSON-Antwort (z. B. GML). Wenn QGIS verfügbar, versuche GML via temporärer Datei + OGR
# Nicht-JSON-Antwort (GML/XML). Für WFS resultType=hits: Anzahl aus XML lesen.
if prov == "WFS":
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(response_text)
# WFS 2.0: numberOfMatched; WFS 1.x: numberOfFeatures
n_str = (
root.get("numberOfMatched")
or root.get("numberMatched")
or root.get("numberOfFeatures")
or root.get("numberOfReturned")
)
if n_str is not None:
try:
count = int(n_str)
return [{}] * count, None
except (ValueError, TypeError):
pass
# Fallback: wfs:member-Elemente zählen
members = [
c for c in root
if c.tag.lower().endswith(("member", "featuremember"))
]
return [{}] * len(members), None
except Exception as exc:
return [], f"WFS-XML-Antwort konnte nicht geparst werden: {exc}"
# Nicht-WFS, nicht-JSON: GML via temporärer Datei + OGR
if getattr(qgiscore, "QGIS_AVAILABLE", False):
try:
import tempfile
@@ -588,5 +632,4 @@ class Datenabruf:
return [], "GML-Antwort konnte nicht als Layer geladen werden"
except Exception as exc:
return [], f"Fehler beim Parsen von GML: {exc}"
# Wenn alles fehlschlägt:
return [], "Antwort konnte nicht als JSON oder GML geparst werden"
+1 -1
View File
@@ -209,7 +209,7 @@ class Datenschreiber:
continue
layer_name_raw = thema or str(ident)
layer_name = re.sub(r"[^A-Za-z0-9_]+", "_", layer_name_raw).strip("_")
layer_name = re.sub(r"[^A-Za-z0-9_ÄäÖöÜüß]+", "_", layer_name_raw).strip("_")
if not layer_name:
layer_name = f"layer_{ident}"
+27 -1
View File
@@ -122,7 +122,33 @@ class LayerLoader:
try:
if provider_lower == "wfs":
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
raw_link = link.strip()
if raw_link.lower().startswith("url="):
raw_link = raw_link[4:]
# QGIS WFS-Provider erwartet die Basis-URL und 'typename' als
# separaten URI-Parameter. GetFeature-spezifische Parameter
# (request, outputFormat, resultType, BBOX, …) werden entfernt.
try:
from urllib.parse import urlparse as _up, parse_qsl as _pqsl, urlunparse as _uu, urlencode as _ue
_parsed = _up(raw_link)
_params = dict(_pqsl(_parsed.query, keep_blank_values=True))
_typename = (
_params.pop("typename", None)
or _params.pop("TYPENAME", None)
or _params.pop("TYPENAMES", None)
)
for _k in ("request", "REQUEST", "outputFormat", "OUTPUTFORMAT",
"resultType", "RESULTTYPE", "BBOX", "bbox",
"STARTINDEX", "COUNT"):
_params.pop(_k, None)
_params.setdefault("service", "WFS")
_params.setdefault("version", "2.0.0")
_base_url = _uu(_parsed._replace(query=_ue(_params)))
uri = f"url={_base_url}"
if _typename:
uri += f" typename={_typename}"
except Exception:
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
layer = QgsVectorLayer(uri, thema, "WFS")
elif provider_lower == "wms":
uri = link if link.strip().lower().startswith("url=") else f"url={link}"