Compare commits

...
113 Commits
Author SHA1 Message Date
Daniel 91bafe632f changelog.txt aktualisiert 2026-07-15 16:04:55 +02:00
Daniel 7ed22fb23e Merge pull request 'Shape verfahrensgebiet' (#52) from shape-Verfahrensgebiet into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#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
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
Reviewed-on: AG_QGIS/Plugin_SN_Basis#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 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 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: AG_QGIS/Plugin_SN_Basis#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
Reviewed-on: AG_QGIS/Plugin_SN_Basis#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
Reviewed-on: AG_QGIS/Plugin_SN_Basis#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
Reviewed-on: AG_QGIS/Plugin_SN_Basis#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
Reviewed-on: AG_QGIS/Plugin_SN_Basis#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
release-bot 94b957bb73 Release 26.4.7-unstable 2026-04-22 06:02:15 +00:00
release-bot 00023c632f Release 26.4.6-unstable 2026-04-22 05:42:26 +00:00
22ottomi fba89478a0 metadata.txt aktualisiert
QGISMaximumVersion=4.99 ergänzt
2026-04-22 07:41:07 +02:00
release-bot 3c1d8fc4e5 Release 26.4.5-unstable 2026-04-21 20:22:12 +00:00
Daniel 44eee228a8 Merge pull request 'Verfahrensgebiet bilden ergänzt' (#29) from feature/Verfahrensgebiet_bilden into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#29
2026-04-21 22:19:13 +02:00
Daniel 34e2ff4f66 Flst_Laden, VerfGebiet bilden 2026-04-21 21:50:33 +02:00
Daniel 91669b34d6 tests überarbeitet 2026-04-20 15:36:38 +02:00
Daniel d2035139d9 pdoc-Dockstrings ergänzt 2026-04-20 14:31:16 +02:00
release-bot 30bfcc597d Release 26.4.4-unstable 2026-04-20 11:31:23 +00:00
Daniel 88761eab22 exit_Crash_fix 2026-04-20 13:28:00 +02:00
Daniel d762f2d94f Merge branch 'unstable' of https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis into unstable 2026-04-20 10:32:19 +02:00
Daniel 9f7e81e8b2 Merge branch 'feature/Drucklayout-überarbeiten' into unstable 2026-04-20 10:19:36 +02:00
Daniel b4433222f3 Kartenschild angepasst 2026-04-17 17:15:13 +02:00
Daniel 779a1ed63f cleanup 2026-04-17 16:57:26 +02:00
Daniel 4049090e92 Druck-refactor 2026-04-17 16:52:53 +02:00
Daniel 4b842bf635 Vertikale Ausrichtung für Textfelder ergänzt; default: top 2026-04-17 14:44:30 +02:00
release-bot 943aab7509 Release 26.4.3-unstable 2026-04-17 12:17:57 +00:00
Daniel 5564c06d5c 3.40exit-crash-fix als plan in assets 2026-04-17 14:16:50 +02:00
22ottomi 5b4cbc3127 Merge pull request 'fix: Access Violation beim Beenden von QGIS in PrintTab beheben' (#27) from bug/crash_on_exit into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#27
2026-04-17 14:15:56 +02:00
Michael Otto f0944aab99 fix: Access Violation beim Beenden von QGIS in PrintTab beheben
QgsProject-Signale (readProject, newProjectCreated, cleared) wurden beim
Shutdown von QGIS nicht getrennt. Das Signal 'cleared' wurde emittiert
waehrend QgsProject sich selbst zerstoerte, was _on_project_changed
triggerte und zu einem Zugriff auf bereits destrukturierte Objekte fuehrte.

Loesung: aboutToQuit-Signal verbunden mit neuer _cleanup_signals()-Methode,
die alle QGIS-Signalverbindungen sauber trennt, bevor QGIS Kernobjekte
zerstoert.
2026-04-17 14:06:28 +02:00
Daniel 9306028df8 Anpassungen am Kartenschild 2026-04-17 13:48:14 +02:00
Daniel abf5808117 Herausgeber case sensitiv 2026-04-13 19:58:06 +02:00
Daniel 7ad8ba054f Baufreigabe-Layout fix auf A3/A4 2026-04-10 23:26:58 +02:00
Daniel 0900c6d3b0 neues Layout "Baufreigabe" hinzugefügt 2026-04-10 22:56:34 +02:00
Daniel 40c224959d Maßstab gefixt 2026-04-10 21:39:27 +02:00
Daniel 9d949d158f Umlaute im Kartenschild und Quellenangabe gefixt 2026-04-10 21:37:37 +02:00
Daniel dbccb5322f Koordinatengitter eingefügt, Rahmenbreiten einheitlich auf 0,5mm gesetzt 2026-04-10 21:30:44 +02:00
Daniel fef72b7e53 Position Kartenname angepasst 2026-04-10 19:32:50 +02:00
Daniel 8e80ffb02e unnötige assets gelöscht 2026-04-10 19:21:07 +02:00
release-bot 5dce7c4d96 Release 26.4.2-testing 2026-04-10 10:13:02 +00:00
release-bot ffc56f5bc4 Release 26.04.1-unstable 2026-04-08 12:40:04 +00:00
Daniel c01bf241db Merge pull request 'verfahrensgebiet-Vaiable wird sauber gesetzt' (#22) from bug/verfahrensgebiet_nicht_übernommen into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#22
2026-04-08 14:37:44 +02:00
Daniel 9ec465e276 verfahrensgebiet-Vaiable wird sauber gesetzt 2026-04-08 14:36:20 +02:00
Daniel d5b7482ebd Merge pull request 'Legende an Hauptkarte des Layouts angebunden' (#21) from bug/Legendeneintrag into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#21
2026-04-08 14:08:45 +02:00
Daniel 8e4031a884 Legende an Hauptkarte des Layouts angebunden 2026-04-08 13:56:58 +02:00
Daniel f1f104a464 Merge pull request 'Projektspeicher_Abfrage beim Start behoben (3.40/3.44' (#20) from bug/projektabfrage into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#20
2026-04-08 13:28:14 +02:00
Daniel f34e4ce458 Projektspeicher_Abfrage beim Start behoben (3.40/3.44 2026-04-08 13:27:00 +02:00
release-bot 30b17e0b83 Release 26.3.3-unstable 2026-03-27 10:29:25 +00:00
Daniel a96560b21b Merge pull request 'Verfahrensgebiet-Layerauswahl nach sn_verfahrensgebiet verschoben' (#14) from refactor/Verfahrensgebiet-Auswahl_nach_sn_verfahrensgebiet into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#14
2026-03-27 11:26:32 +01:00
Daniel bb95e0d536 Verfahrensgebiet-Layerauswahl nach sn_verfahrensgebiet verschoben 2026-03-27 11:19:27 +01:00
release-bot 4f0d3c12ba Release 26.3.2-unstable 2026-03-26 20:46:27 +00:00
Daniel 493e14c67f Merge pull request 'refactor/Druckfunktion-in-sn_basis' (#13) from refactor/Druckfunktion-in-sn_basis into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#13
2026-03-26 21:41:45 +01:00
Daniel 5303a44ce8 variable Blattgröße bis zum eingegebenen Maximum 2026-03-26 21:39:41 +01:00
Daniel b014b92c19 Druckbereich eingeführt (Verfahrensgebiet, Auswahl, Rechteck) 2026-03-26 21:23:50 +01:00
Daniel 3d8c0aff5d drucktab aus sn_plan41 eingefügt, Atlasfunktion entfernt 2026-03-26 20:18:17 +01:00
release-bot e397ccde71 Release 26.3.1-testing 2026-03-26 07:34:07 +00:00
22ottomi 81c80f4e79 changelog.txt aktualisiert 2026-03-26 08:33:41 +01:00
release-bot 095767b8c7 Release 26.3.1-unstable 2026-03-25 07:41:59 +00:00
22ottomi 5ca70b9318 changelog.txt aktualisiert 2026-03-25 08:41:19 +01:00
Michael Otto d3971df28c Umstellung auf neuen Release Workflow 2026-03-25 08:38:39 +01:00
Daniel eb05fb260f Merge pull request 'feature/variable-name-hinzugefügt' (#12) from feature/variable-name-hinzugefügt into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#12
2026-03-24 16:32:53 +01:00
Daniel 295f9f1db7 legendenobjekt ergänzt 2026-03-24 07:53:04 +01:00
Daniel eebb909080 Variable name eingefügt 2026-03-23 15:45:17 +01:00
Daniel 2ff465b86d Merge pull request 'feture/Druck_tab' (#11) from feture/Druck_tab into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#11
2026-03-20 22:58:08 +01:00
Daniel f19fe71bfa Ergänzungen in de Wrappern/ Prüfmanager für Layouts 2026-03-20 14:01:57 +01:00
Daniel ae5f88c5b8 Dialoge für Vorlagen-Piepeline ergänzt 2026-03-20 12:42:21 +01:00
Daniel 7cd6e3ef24 checkbox im qt_wrapper ergänzt 2026-03-20 12:01:16 +01:00
22ottomi 1be1420f66 changelog.txt aktualisiert 2026-03-19 06:44:35 +01:00
release-bot f25e30c489 Release 26.3.6-unstable 2026-03-19 05:32:02 +00:00
22ottomi 0eb32453d6 changelog.txt aktualisiert 2026-03-19 06:31:36 +01:00
release-bot 841b529ad8 Release 26.3.5-unstable 2026-03-19 05:30:39 +00:00
release-bot ae5725cd03 Release 26.3.4-unstable 2026-03-19 05:23:20 +00:00
release-bot ac5a3993c8 Release 26.3.3-unstable 2026-03-18 14:34:41 +00:00
release-bot 22b45fe19a Release 26.3.2-unstable 2026-03-18 14:18:14 +00:00
Michael Otto 24c2137dc2 Auf neuen Release Workflow umgestellt 2026-03-18 15:13:37 +01:00
Michael Otto c0c0387b1d Änderungen an plugin.cfg 2026-03-13 13:58:33 +01:00
Michael Otto 663ca770a1 Schritt zu 'plugin.cfg einlesen' umbenannt und Schleife angepasst, um letzte Zeile ohne Newline zu lesen 2026-03-13 12:08:32 +01:00
Michael Otto 04319b6f7b plugin.info->plugin.cfg 2026-03-13 12:06:34 +01:00
Michael Otto 1c70d62739 plugin.info einlesen: echo eingefügt 2026-03-13 12:04:32 +01:00
Michael Otto 3971bd3408 plugin.info einlesen angepasst 2026-03-13 12:02:39 +01:00
Michael Otto fa04fc80e3 ZIP Erstellung auf Original zurückgesetzt 2026-03-13 11:30:11 +01:00
Michael Otto 04bdfbe9d8 Fallback für ZIP_FOLDER hinzugefügt: Wenn leer, auf sn_basis setzen 2026-03-13 11:27:18 +01:00
Michael Otto b6b791e5bd Behebung des Parsens von plugin.info: Robuste Schleife für Outputs 2026-03-13 11:24:36 +01:00
Michael Otto 82be564c29 Verbesserung des ZIP-Erstellungsprozesses: Debugging hinzugefügt und Warnungen bei /dev-Dateien durch Behandlung symbolischer Links behoben 2026-03-13 11:21:38 +01:00
Michael Otto f42260b66c ZIP Erstellung geändert um im Runner genauer zu sehen wo es zu Problemen gekommen ist 2026-03-13 11:16:46 +01:00
Michael Otto 327c25388f metadata.txt gelöscht, changelog.txt eingefügt 2026-03-13 11:06:58 +01:00
Michael Otto c6c9613120 Update plugin.info based on metadata.txt 2026-03-13 08:39:24 +01:00
Daniel 6e1f4c615b Merge pull request 'daniel@feature/dataGrabber_anbinden' (#9) from Daniel/Plugin_SN_Basis:daniel@feature/dataGrabber_anbinden into unstable
Reviewed-on: AG_QGIS/Plugin_SN_Basis#9
2026-03-13 06:51:56 +01:00
Daniel f876218134 plugin.info hinzugefügt 2026-03-13 06:45:55 +01:00
Daniel 9829ac9c81 Diensteabruf integriert 2026-03-12 16:14:02 +01:00
Daniel ae956b0046 Überarbeitung für Pufferlayer-Fachdaten laden und gpkg-speichern/laden 2026-03-11 20:56:02 +01:00
Daniel 0ec24029d8 fix QGIS 4.0-Kompatibilität;
Linkpruefer-Eingabe als String normalisiert, falls Paf-Objekte übergeben werden
2026-03-11 12:38:48 +01:00
Daniel 26f426dfcd Merge pull request 'dev' (#8) from Daniel/Plugin_SN_Basis:dev into main, Basisfunktion für sn_Verfahrensgebiet
Reviewed-on: AG_QGIS/Plugin_SN_Basis#8
2026-03-06 10:34:43 +01:00
Michael Otto 00f800b1e6 metadata.txt gelöscht, plugin.info eingefügt, workflow hinzugefügt 2026-03-05 16:02:03 +01:00
Daniel 948041da52 Merge pull request 'qt_wrapper, dialog;wrapper, Pruef_ergebnis und Pruefmanager überarbeitet, so dass die Übergaben jetzt stimmen. Nutzerabfragen werden tatsächlich ausgelöst- Nutzerabfrage Datei überschreiebn... ist noch Blödsinn' (#7) from Daniel/Plugin_SN_Basis:dev into dev
Reviewed-on: AG_QGIS/Plugin_SN_Basis#7
2026-03-04 19:42:41 +01:00
Erik 137baaf19c .gitea/workflows/release.yaml hinzugefügt 2026-03-01 13:36:27 +01:00
Daniel 439de5527a Merge pull request 'dev' (#6) from Daniel/Plugin_SN_Basis:dev into dev
Reviewed-on: https://entwicklung.vln-sn.de/AG_QGIS/Plugin_SN_Basis/pulls/6
2026-02-25 13:27:01 +01:00
55 changed files with 7339 additions and 870 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
+133
View File
@@ -0,0 +1,133 @@
name: Release Plugin
run-name: "Release | ${{ github.ref_name }}"
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: alpine-latest
defaults:
run:
shell: bash
steps:
- name: Notwendige Abhängigkeiten installieren
shell: sh
run: |
apk add --no-cache bash git jq curl
- name: Code holen
run: |
REPO_URL="https://${RELEASE_TOKEN}:x-oauth-basic@${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}.git"
git clone "$REPO_URL" repo
cd repo
git checkout "$TAG"
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: "${{ github.ref_name }}"
- name: Version und Kanal bestimmen
id: releaseinfo
run: |
TAG="${{ github.ref_name }}"
RAW_VERSION="${TAG#v}"
VERSION="${RAW_VERSION%%-*}"
# Channel und Suffix automatisch bestimmen anhand des Tag-Suffix
case "$RAW_VERSION" in
*-testing*|*-t|*-T)
CHANNEL="testing"
PRERELEASE="true"
SUFFIX="-testing"
;;
*-unstable*|*-u|*-U)
CHANNEL="unstable"
PRERELEASE="true"
SUFFIX="-unstable"
;;
*)
CHANNEL="stable"
PRERELEASE="false"
SUFFIX=""
;;
esac
# Output setzen
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
echo "prerelease=$PRERELEASE" >> $GITHUB_OUTPUT
# Optional Debug
echo "VERSION=$VERSION"
echo "CHANNEL=$CHANNEL"
echo "PRERELEASE=$PRERELEASE"
# - name: plugin.cfg einlesen
# id: config
# run: |
# cd repo
# while read -r line || [ -n "$line" ]; do
# key="${line%%=*}"
# value="${line#*=}"
# echo "$key=$value" >> $GITHUB_OUTPUT
# done < plugin.cfg
- name: Payload erzeugen
id: payload
run: |
cd repo
NAME="${GITHUB_REPOSITORY##*/}"
GROUP="${GITHUB_REPOSITORY%%/*}"
VERSION="${{ steps.releaseinfo.outputs.version }}"
CHANNEL="${{ steps.releaseinfo.outputs.channel }}"
PRERELEASE="${{ steps.releaseinfo.outputs.prerelease }}"
ZIP_FOLDER="${{ vars.ZIP_FOLDER }}"
ZIP_FILE="${ZIP_FOLDER}.zip"
TAG="${{ github.ref_name }}"
#GIT_URL=${GITHUB_REPOSITORY}
jq -n \
--arg name "$NAME" \
--arg group "$GROUP" \
--arg version "$VERSION" \
--arg channel "$CHANNEL" \
--arg prerelease "$PRERELEASE" \
--arg zip_folder "$ZIP_FOLDER" \
--arg zip_file "$ZIP_FILE" \
--arg tag "$TAG" \
'{
name: $name,
group: $group,
version: $version,
channel: $channel,
prerelease: ($prerelease == "true"),
zip_folder: $zip_folder,
zip_file: $zip_file,
tag: $tag
}' > payload.json
cat payload.json
- name: Repository aktualisieren
run: |
NAME="${GITHUB_REPOSITORY##*/}"
TAG="${{ steps.releaseinfo.outputs.version }}"-"${{ steps.releaseinfo.outputs.channel }}"
PAYLOAD_B64=$(base64 -w0 repo/payload.json)
JSON="{\"ref\":\"hidden/workflows\",\"inputs\":{\"payload\":\"$PAYLOAD_B64\",\"name\":\"$NAME\",\"tag\":\"$TAG\"}}"
curl -X POST \
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
-H "Content-Type: application/json" \
-d "$JSON" \
"https://${{ vars.RELEASE_URL }}/api/v1/repos/${OWNER}/Repository/actions/workflows/${WORKFLOW}/dispatches"
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
OWNER: "AG_QGIS"
WORKFLOW: "release.yaml"
+16
View File
@@ -1,3 +1,19 @@
"""
sn_basis QGIS-Plugin für Basis-Verwaltung und zentrale Koordination.
Orchestriert die Navigation und initialisiert abhängige sn_-Plugins.
"""
def classFactory(iface):
"""
Factory-Funktion für QGIS.
Wird von QGIS aufgerufen, um die Plugin-Instanz zu erstellen.
Args:
iface: QGIS-Schnittstellenobjekt
Returns:
BasisPlugin: Instanziiertes Plugin
"""
from .main import BasisPlugin
return BasisPlugin(iface)
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 160 KiB

-188
View File
@@ -1,188 +0,0 @@
# WrapperArchitektur Übersicht
Die WrapperArchitektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGISPluginEntwicklung.
Sie kapselt sämtliche QGIS und QtAbhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch:
Mockfähige UnitTests ohne QGIS
PyQt5/6Kompatibilität ohne Code‑Änderungen
saubere Trennung von UI, Logik und Infrastruktur
stabile APIs, die unabhängig von QGISVersionen bleiben
klare Erweiterbarkeit für zukünftige Module und Plugins
Die WrapperSchicht ist das zentrale Bindeglied zwischen der PluginLogik und der QGIS/QtUmgebung.
## Ziele der WrapperArchitektur
🎯 1. Entkopplung von QGIS und Qt
Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der PluginLogik.
Stattdessen werden sie über WrapperModule abstrahiert.
🎯 2. Testbarkeit ohne QGIS
Im MockModus liefern die Wrapper:
DummyObjekte
simulierte Rückgabewerte
speicherbare Zustände (z.B. Variablen, Layer, Nachrichten)
Damit laufen Tests in jeder CIUmgebung.
🎯 3. Einheitliche API für alle Plugins
Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen:
Code
sn_basis.functions.qgiscore_wrapper
sn_basis.functions.qgisui_wrapper
sn_basis.functions.qt_wrapper
sn_basis.functions.variable_wrapper
sn_basis.functions.message_wrapper
sn_basis.functions.dialog_wrapper
🎯 4. Zukunftssicherheit
Ändert sich die QGIS oder QtAPI, wird nur der Wrapper angepasst, nicht jedes Plugin.
## Architekturüberblick
Die WrapperSchicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben.
### 1. qt_wrapper QtAbstraktion
Kapselt alle QtWidgets, Dialoge und Konstanten:
QWidget, QDialog, QMessageBox, QToolBar, QMenu, …
Layouts, Buttons, Labels, LineEdits
QtKonstanten wie YES, NO, DockAreas
MockModus:
Stellt DummyWidgets bereit, die keine UI öffnen.
### 2. qgiscore_wrapper QGISCoreAbstraktion
Abstraktion für:
QgsProject
LayerZugriff
ProjektMetadaten
Pfade, CRS, FeatureZugriff
MockModus:
Simuliert ein Projekt und LayerContainer.
### 3. qgisui_wrapper QGISUIAbstraktion
Kapselt UIbezogene QGISFunktionen:
Zugriff auf iface
DockManagement
Menü‑ und ToolbarIntegration
HauptfensterZugriff
MockModus:
Stellt ein DummyInterface bereit.
### 4. variable_wrapper QGISVariablen
Abstraktion für:
Projektvariablen (projectScope)
globale Variablen (globalScope)
MockSpeicher für Tests
Vorteile:
keine QGISAbhängigkeit in der Logik
testbare Variablenverwaltung
einheitliches API
### 5. message_wrapper Meldungen & Logging
Einheitliche Schnittstelle für:
Fehlermeldungen
Warnungen
InfoMeldungen
Logging
MockModus:
Speichert Nachrichten statt sie an QGIS zu senden.
### 6. dialog_wrapper BenutzerDialoge
Abstraktion für:
Ja/NeinDialoge
spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.)
MockModus:
Gibt DefaultWerte zurück, öffnet keine UI.
### 7. DockManager & Navigation
Diese Module nutzen die WrapperSchicht, um:
DockWidgets sicher zu verwalten
Toolbars und Menüs zu erzeugen
Reloadsichere UIStrukturen aufzubauen
Sie sind keine Wrapper, sondern WrapperKonsumenten.
## Designprinzipien
🧱 1. Single Source of Truth
Jede QGIS oder QtFunktionalität wird nur an einer Stelle implementiert.
🔄 2. Austauschbarkeit
MockModus und Echtmodus sind vollständig austauschbar.
🧪 3. Testbarkeit
Jede Funktion kann ohne QGIS getestet werden.
🧼 4. Saubere Trennung
UI → qt_wrapper
QGISCore → qgiscore_wrapper
QGISUI → qgisui_wrapper
Logik → settings_logic, layer_logic, prüfmanager, …
🔌 5. Erweiterbarkeit
Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen.
## Vorteile für Entwickler
Keine QGISAbhängigkeiten in der Logik
IDEfreundlich (Pylance, Autocomplete, Typing)
CIfähig (Tests ohne QGIS)
saubere Architektur
leichte Wartbarkeit
klare Dokumentation
## Fazit
Die WrapperArchitektur ist das Herzstück von sn_basis.
Sie ermöglicht eine moderne, modulare und testbare QGISPluginEntwicklung, die unabhängig von QGISVersionen, QtVersionen und Entwicklungsumgebungen funktioniert.
Sie bildet die Grundlage für:
stabile APIs
saubere UIAbstraktion
automatisierte Tests
nachhaltige Weiterentwicklung
-144
View File
@@ -1,144 +0,0 @@
# WrapperArchitektur Übersicht
Die WrapperArchitektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGISPluginEntwicklung.
Sie kapselt sämtliche QGIS und QtAbhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch:
- Mockfähige UnitTests ohne QGIS
- PyQt5/6Kompatibilität ohne Code‑Änderungen
- saubere Trennung von UI, Logik und Infrastruktur
- stabile APIs, die unabhängig von QGISVersionen bleiben
- klare Erweiterbarkeit für zukünftige Module und Plugins
Die WrapperSchicht ist das zentrale Bindeglied zwischen der PluginLogik und der QGIS/QtUmgebung.
## Ziele der WrapperArchitektur
1. Entkopplung von QGIS und Qt
Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der PluginLogik.
Stattdessen werden sie über WrapperModule abstrahiert.
2. Testbarkeit ohne QGIS
Im MockModus liefern die Wrapper:
- DummyObjekte
- simulierte Rückgabewerte
- speicherbare Zustände (z.B. Variablen, Layer, Nachrichten)
Damit laufen Tests in jeder CIUmgebung.
3. Einheitliche API für alle Plugins
Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen:
- sn_basis.functions.qgiscore_wrapper
- sn_basis.functions.qgisui_wrapper
- sn_basis.functions.qt_wrapper
- sn_basis.functions.variable_wrapper
- sn_basis.functions.message_wrapper
- sn_basis.functions.dialog_wrapper
Aufgrund des Umfangs ist der Wrapper für die Layerbehandlung aufgeteilt:
- ly_existence_wrapper
- ly_geometry_wrapper
- ly_Metadata_wrapper
- ly_style_wrapper
- ly_visibility_wrapper
4. Zukunftssicherheit
Ändert sich die QGIS oder QtAPI, wird nur der Wrapper angepasst, nicht jedes Plugin.
## Architekturüberblick
Die WrapperSchicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben.
### 1. qt_wrapper QtAbstraktion
Kapselt alle QtWidgets, Dialoge und Konstanten:
- QWidget, QDialog, QMessageBox, QToolBar, QMenu, …
- Layouts, Buttons, Labels, LineEdits
- QtKonstanten wie YES, NO, DockAreas
MockModus:
Stellt DummyWidgets bereit, die keine UI öffnen.
### 2. qgiscore_wrapper QGISCoreAbstraktion
Abstraktion für:
- QgsProject
- LayerZugriff
- ProjektMetadaten
- Pfade, CRS, FeatureZugriff
MockModus:
Simuliert ein Projekt und LayerContainer.
### 3. qgisui_wrapper QGISUIAbstraktion
Kapselt UIbezogene QGISFunktionen:
- Zugriff auf iface
- DockManagement
- Menü‑ und ToolbarIntegration
- HauptfensterZugriff
MockModus:
Stellt ein DummyInterface bereit.
### 4. variable_wrapper QGISVariablen
Abstraktion für:
- Projektvariablen (projectScope)
- globale Variablen (globalScope)
- MockSpeicher für Tests
Vorteile:
- keine QGISAbhängigkeit in der Logik
- testbare Variablenverwaltung
- einheitliches API
### 5. message_wrapper Meldungen & Logging
Einheitliche Schnittstelle für:
- Fehlermeldungen
- Warnungen
- InfoMeldungen
- Logging
MockModus:
Speichert Nachrichten statt sie an QGIS zu senden.
### 6. dialog_wrapper BenutzerDialoge
Abstraktion für:
- Ja/NeinDialoge
- spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.)
MockModus:
Gibt DefaultWerte zurück, öffnet keine UI.
### 7. DockManager & Navigation
Diese Module nutzen die WrapperSchicht, um:
- DockWidgets sicher zu verwalten
- Toolbars und Menüs zu erzeugen
- Reloadsichere UIStrukturen aufzubauen
Sie sind keine Wrapper, sondern WrapperKonsumenten. Alle Fach-Plugins nutzen den Dockmanager des Basisplugins.
## Designprinzipien
1. Single Source of Truth
Jede QGIS oder QtFunktionalität wird nur an einer Stelle implementiert.
2. Austauschbarkeit
MockModus und Echtmodus sind vollständig austauschbar.
3. Testbarkeit
Jede Funktion kann ohne QGIS getestet werden.
4. Saubere Trennung
- UI → qt_wrapper
- QGISCore → qgiscore_wrapper
- QGISUI → qgisui_wrapper
- Logik → settings_logic, layer_logic, prüfmanager, …
5. Erweiterbarkeit
Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen.
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

-38
View File
@@ -1,38 +0,0 @@
```mermaid
flowchart TD
subgraph Plugin
P[sn_plan41 Fachplugin]
A[Adapter Plan41LinklistAdapter]
PM[Pruefmanager]
LP[Layerpruefer]
KP[Linkpruefer]
SP[Stilpruefer]
end
subgraph Core
DG[DataGrabber]
NL[normalized entries]
LL[Layer Loader Provider Dispatch]
SM[Spatial Matcher]
ST[Storage GPKG / PostGIS]
PR[Project QGIS - addMapLayer]
LOG[Log / Ergebnisstruktur]
end
P -->|gibt Adapter, Prüfer, Pruefmanager| DG
A -->|load liefert Rohdaten| DG
DG -->|adapter.normalize| NL
NL --> DG
DG -->|für jeden Eintrag: _check_link -> KP.check| KP
DG -->|für jeden Eintrag: _check_style -> SP.check| SP
DG -->|prüfe vorhandene Layer| LP
DG -->|lade Layer via provider| LL
LL -->|Features| SM
SM -->|Abgleich| DG
DG -->|speichern| ST
ST --> PR
DG --> PR
DG -->|Ergebnis/Fehler| LOG
LOG --> PM
DG --> PM
```
Binary file not shown.
-9
View File
@@ -1,9 +0,0 @@
```mermaid
graph TD
M1["<div style='text-align:center'><b>sn_basis</b><br/><br/></b></div><div style='text-align:left'>➡ Initialisierung der GUI<br/> ➡ Exception Handling<br/> ➡ Bereitstellung der Stile"]
M2["<div style='text-align:center'><b>sn_verfahrensgebiet</b><br/><br/><div style='text-align:left'>➡ Abruf und Aufbereitung der Gebietsgrenze"<br/>➡ Erstellung neuer Gebietsgrenzen <br/>➡ Grenzpunktextraktion<br/>➡ Grenzpunktprüfung]
M3["<div style='text-align:center'><b>sn_Plan41</b><br/><br/><div style='text-align:left'>➡ Fachdatenabruf<br/>➡Versionierung der Fachdaten<br/>➡ Planung der TG-Maßnahmen <br/>➡Kartenerzeugung (NGG und P41)<br/>➡ Erzeugung der Begleitdokumente (Anlagenverzeichnis, MVZ, Maßnahmeblätter)"]
M1 --> M2
M1 --> M3
```
+57
View File
@@ -0,0 +1,57 @@
# Plan: QGIS 3.40 Exit-Crash absichern
Der wahrscheinlichste Auslöser ist fehlendes Signal-Cleanup beim Plugin-Unload: globale Projekt-Listener und PrintTab-Listener bleiben aktiv, während QgsProject beim QGIS-Exit bereits zerstört wird. Die empfohlene Lösung ist ein minimaler, versionsneutraler Cleanup-Pfad (disconnect + defensive Guards), ohne Fachlogik zu ändern.
## Steps
1. Teardown-Funktion für globale Verfahrensgebiet-Listener ergänzen, inkl. idempotentem Disconnect und Rücksetzen des Installationsflags.
- Datei: `sn_basis/functions/verfahrensgebiet_manager.py` (nach `setup_verfahrensgebiet_listener`, ca. Zeile 132)
- Neue Funktion `teardown_verfahrensgebiet_listener()` disconnect alle 5 QgsProject-Signale (`layersAdded`, `layerRemoved`, `readProject`, `newProjectCreated`, `cleared`) und setzt `_listener_installed = False` zurück.
- RuntimeError abfangen (Projekt wird gerade zerstört → normal).
2. Teardown in Unload-Pfad vor UI-Abbau aufrufen. (depends on step 1)
- Datei: `sn_basis/main.py` (in `BasisPlugin.unload`, ca. Zeile 52)
- Import und Aufruf von `teardown_verfahrensgebiet_listener()` als erste Zeile in `unload()`, bevor `self.ui.remove_all()` ausgeführt wird.
3. PrintTab um kleinen Projekt-Signal-Cleanup ergänzen, symmetrisch zu bestehendem Theme-Cleanup. (parallel mit step 1)
- Datei: `sn_basis/ui/tabs/print_tab.py`
- Neue Methode `cleanup()` nach `_disconnect_theme_collection_signals` (ca. Zeile 485).
- Disconnectet QgsProject-Signale (`readProject`, `newProjectCreated`, `cleared`) von `self._on_project_changed` (analog zu bestehendem Theme-Disconnect-Muster).
- Ruft danach `self._disconnect_theme_collection_signals()` auf.
4. BaseDockWidget.closeEvent so erweitern, dass Tab-Cleanup-Hooks vor Schließen aufgerufen werden. (depends on step 3)
- Datei: `sn_basis/ui/base_dockwidget.py` (in `closeEvent`, ca. Zeile 97)
- Vor dem bestehenden `self.action.setChecked(False)` Block: über alle Tabs iterieren, auf `cleanup`-Attribut prüfen, aufrufen; Exception abfangen.
5. Optional nur bei Restinstabilität: defensive Guards bei Projektzugriffen in kritischen Handlern ergänzen.
- Datei: `sn_basis/functions/variable_wrapper.py` (in `get_variable`/`set_variable`, ca. Zeile 60 und 89)
- Datei: `sn_basis/functions/verfahrensgebiet_manager.py` (in `_on_layer_removed`, ca. Zeile 114)
- `QgsProject.instance()` auf None/RuntimeError prüfen und bei Zerstörung früh zurückkehren.
## Relevant files
- `sn_basis/functions/verfahrensgebiet_manager.py` — Listener-Setup/Teardown, Handler-Guards
- `sn_basis/main.py` — Unload-Reihenfolge
- `sn_basis/ui/tabs/print_tab.py` — Projekt-Signal-Cleanup
- `sn_basis/ui/base_dockwidget.py` — zentraler Cleanup-Hook
- `sn_basis/ui/dockmanager.py` — asynchrones deleteLater als Lifecycle-Kontext
- `sn_basis/functions/variable_wrapper.py` — optional defensive Guards
## Decisions
- Include: minimaler Lifecycle-Fix über disconnect + Guards.
- Exclude: Refactoring der gesamten Signal-Architektur, größere UI-Umbauten, versionsspezifische If-Branches.
- Begründung: geringstes Risiko für Regressionen bei maximaler Wirkung gegen Shutdown-Crashs.
- Keine Änderung an Business-Logik, keine Änderung an Variablennamen, keine QGIS-versionsspezifischen Sonderpfade — rein sichere Disconnect/Guard-Muster.
## Verification
1. Plugin laden, Dock öffnen/schließen, Projekt neu laden, Layer „Verfahrensgebiet" add/remove, dann QGIS 3.40.7 beenden → kein Access Violation.
2. Dasselbe unter QGIS 4 → unverändertes Verhalten, keine Regression.
3. Mehrfacher Plugin-Reload in einer Sitzung → keine doppelten Signal-Reaktionen, keine Exceptions beim Unload.
4. Optionaler Stresstest: Unit-Test für idempotentes Teardown (mehrfaches unload ohne Exception).
## Further considerations
- Step 5 (defensive Guards in variable_wrapper) nur umsetzen, wenn nach Steps 14 noch reproduzierbare Exit-Crashs auftreten.
- Ein kleiner Unit-Test für idempotentes Teardown (mehrfaches `unload()` ohne Exception) wäre empfehlenswert, falls die Testsuite erweitert wird.
+35
View File
@@ -0,0 +1,35 @@
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:
---
Version 26.4.6-unstable:
---
Version 26.4.5-unstable:
---
Version 26.4.4-unstable:
---
Version 26.4.3-unstable:
---
Version 26.04.1-unstable:
---
Version 26.3.3-unstable:
---
Version 26.3.2-unstable:
---
Version 26.3.1-testing:
- unstable als testing veröffentlicht
---
Version 26.3.1-unstable:
- erster Test als echtes PlugIn
+6 -1
View File
@@ -1,3 +1,8 @@
"""
sn_basis/functions Wrapper und Hilfsfunktionen für QGIS-Kompatibilität.
Bietet abstrahierte Schnittstellen für Qt, QGIS und OS-Operationen.
"""
from .ly_existence_wrapper import layer_exists
from .ly_geometry_wrapper import (
get_layer_geometry_type,
@@ -15,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
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,
+359 -1
View File
@@ -5,7 +5,7 @@ from typing import Any
from typing import Literal, Optional
from sn_basis.functions.qt_wrapper import (
QMessageBox, YES, NO, CANCEL, QT_VERSION, exec_dialog, ICON_QUESTION,
QProgressDialog, QCoreApplication, Qt, QInputDialog, QLineEdit,
)
def ask_yes_no(
@@ -39,6 +39,50 @@ def ask_yes_no(
return default
def show_info_dialog(title: str, message: str, parent: Any = None) -> None:
"""
Zeigt einen modalen Info-Dialog mit OK-Button.
Blockiert bis der Nutzer bestätigt.
"""
try:
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: show_info_dialog('{title}')")
return
QMessageBox.information(parent, title, message)
except Exception as e:
print(f"⚠️ show_info_dialog Fehler: {e}")
def ask_text(
title: str,
label: str,
default_text: str = "",
parent: Any = None,
) -> tuple[str, bool]:
"""Zeigt einen modalen Texteingabe-Dialog und gibt Text + OK-Status zurück."""
try:
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_text('{title}') -> '{default_text}'")
return default_text, True
# PyQt6: QLineEdit.EchoMode.Normal / PyQt5: QLineEdit.Normal
echo_mode = (
getattr(QLineEdit, "Normal", None)
or getattr(getattr(QLineEdit, "EchoMode", None), "Normal", None)
or 0
)
text, accepted = QInputDialog.getText(
parent,
title,
label,
echo_mode,
default_text,
)
return str(text or ""), bool(accepted)
except Exception as e:
print(f"⚠️ ask_text Fehler: {e}")
return default_text, False
OverwriteDecision = Optional[Literal["overwrite", "append", "cancel"]]
@@ -82,3 +126,317 @@ def ask_overwrite_append_cancel_custom(
return "append"
else: # cancel_btn
return "cancel"
def ask_ergaenzen_entfernen_ersetzen_abbrechen(
parent: Any,
title: str,
message: str,
) -> Literal["ergaenzen", "entfernen", "ersetzen", "abbrechen"]:
"""Zeigt Dialog mit vier Optionen für Verfahrensgebiet-Editoperationen.
Optionen:
- **Ergänzen**: ausgewählte Flurstücke zum Verfahrensgebiet hinzufügen
- **Entfernen**: ausgewählte Flurstücke aus dem Verfahrensgebiet entfernen
- **Ersetzen**: Verfahrensgebiet vollständig durch Auswahl ersetzen
- **Abbrechen**: Vorgang beenden ohne Änderung
Parameters
----------
parent :
Eltern-Widget oder None.
title : str
Dialog-Titel.
message : str
Hauptmeldung mit Erklärung.
Returns
-------
Literal["ergaenzen", "entfernen", "ersetzen", "abbrechen"]
Genaue Entscheidung des Nutzers.
"""
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_ergaenzen_entfernen_ersetzen_abbrechen('{title}') → abbrechen")
return "abbrechen"
msg = QMessageBox(parent)
msg.setIcon(ICON_QUESTION)
msg.setWindowTitle(title)
msg.setText(message)
ergaenzen_btn = msg.addButton("Ergänzen", QMessageBox.ButtonRole.ActionRole)
entfernen_btn = msg.addButton("Entfernen", QMessageBox.ButtonRole.ActionRole)
ersetzen_btn = msg.addButton("Ersetzen", QMessageBox.ButtonRole.AcceptRole)
msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole)
exec_dialog(msg)
clicked = msg.clickedButton()
if clicked == ergaenzen_btn:
return "ergaenzen"
elif clicked == entfernen_btn:
return "entfernen"
elif clicked == ersetzen_btn:
return "ersetzen"
else:
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)
self._canceled = False
if QT_VERSION == 0:
self.value = 0
self.label = label
self.title = title
return
self._dlg = QProgressDialog(label, "Abbrechen", 0, self.total)
self._dlg.setWindowTitle(title)
# Qt5 vs Qt6: WindowModality-Enum unterschiedlich verfügbar
modality = None
if hasattr(Qt, "WindowModality"):
try:
modality = Qt.WindowModality.WindowModal
except Exception:
modality = None
if modality is None and hasattr(Qt, "WindowModal"):
modality = Qt.WindowModal
if modality is not None:
try:
self._dlg.setWindowModality(modality)
except Exception:
pass
self._dlg.setMinimumDuration(0)
self._dlg.setAutoClose(False)
self._dlg.setAutoReset(False)
self._dlg.setValue(0)
def on_cancel():
if self._dlg and self._dlg.value() >= self.total:
# OK-Button am Ende
self._dlg.close()
return
self._canceled = True
self._dlg.close()
try:
self._dlg.canceled.connect(on_cancel)
except Exception:
pass
def set_total(self, total: int) -> None:
self.total = max(total, 1)
if QT_VERSION == 0:
return
if self._dlg is not None:
self._dlg.setMaximum(self.total)
def set_value(self, value: int) -> None:
if QT_VERSION == 0:
self.value = value
return
if self._dlg is not None:
self._dlg.setValue(min(value, self.total))
if value >= self.total:
self._dlg.setLabelText("Fertig. Klicken Sie auf OK, um das Fenster zu schließen.")
self._dlg.setCancelButtonText("OK")
QCoreApplication.processEvents()
def set_label(self, text: str) -> None:
if QT_VERSION == 0:
self.label = text
return
if self._dlg is not None:
self._dlg.setLabelText(text)
QCoreApplication.processEvents()
def is_canceled(self) -> bool:
if QT_VERSION == 0:
return self._canceled
if self._dlg is not None:
return self._canceled or self._dlg.wasCanceled()
return self._canceled
def close(self) -> None:
if QT_VERSION == 0:
return
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.
+24
View File
@@ -57,6 +57,22 @@ def get_home_dir() -> Path:
return Path.home()
def is_absolute_path(path: _PathLike) -> bool:
"""Prüft, ob ein Pfad absolut ist."""
try:
return Path(path).is_absolute()
except Exception:
return False
def basename(path: _PathLike) -> str:
"""Gibt den finalen Namen des Pfades zurück (Dateiname oder Ordner)."""
try:
return Path(path).name
except Exception:
return ""
# ---------------------------------------------------------
# Dateisystem-Eigenschaften
# ---------------------------------------------------------
@@ -75,3 +91,11 @@ def is_case_sensitive_fs() -> bool:
# Linux praktisch immer case-sensitiv
return True
def path_suffix(path: _PathLike) -> str:
"""Gibt die Dateiendung eines Pfades zurück (inklusive Punkt)."""
try:
return Path(path).suffix
except Exception:
return ""
+740 -3
View File
@@ -20,6 +20,49 @@ QgsNetworkAccessManager: Type[Any]
Qgis: Type[Any]
QgsMapLayerProxyModel: Type[Any]
QgsVectorFileWriter: Type[Any] # neu: Schreib-API
QgsFeature: Type[Any]
QgsField: Type[Any]
QgsGeometry: Type[Any]
QgsFeatureRequest: Type[Any]
QgsRectangle: Type[Any]
QgsCoordinateTransform: Type[Any]
QgsCoordinateReferenceSystem: Type[Any]
QgsPrintLayout: Type[Any]
QgsLayoutItemMap: Type[Any]
QgsLayoutItemMapGrid: Type[Any]
QgsTextFormat: Type[Any]
QgsTextBackgroundSettings: Type[Any]
QgsLayoutItemLabel: Type[Any]
QgsLayoutItemLegend: Type[Any]
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
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS: Any = None
MAP_GRID_UNIT_MAP_UNIT: Any = None
MAP_GRID_ANNOTATION_FORMAT_DECIMAL: Any = None
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL: Any = None
MAP_GRID_ANNOTATION_DISPLAY_SHOW_ALL: Any = None
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X: Any = None
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y: Any = None
MAP_GRID_BORDER_LEFT: Any = None
MAP_GRID_BORDER_RIGHT: Any = None
MAP_GRID_BORDER_TOP: Any = None
MAP_GRID_BORDER_BOTTOM: Any = None
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME: Any = None
TEXT_BACKGROUND_SHAPE_RECTANGLE: Any = None
TEXT_BACKGROUND_SIZE_BUFFER: Any = None
TEXT_RENDER_UNIT_MILLIMETERS: Any = None
TEXT_RENDER_UNIT_POINTS: Any = None
QGIS_AVAILABLE = False
@@ -36,9 +79,27 @@ try:
Qgis as _Qgis,
QgsMapLayerProxyModel as _QgsMaplLayerProxyModel,
QgsVectorFileWriter as _QgsVectorFileWriter,
QgsFeature as _QgsFeature,
QgsFeature as _QgsFeature,
QgsField as _QgsField,
QgsGeometry as _QgsGeometry,
QgsGeometry as _QgsGeometry,
QgsFeatureRequest as _QgsFeatureRequest,
QgsRectangle as _QgsRectangle,
QgsCoordinateTransform as _QgsCoordinateTransform,
QgsCoordinateReferenceSystem as _QgsCoordinateReferenceSystem,
QgsPrintLayout as _QgsPrintLayout,
QgsLayoutItemMap as _QgsLayoutItemMap,
QgsLayoutItemMapGrid as _QgsLayoutItemMapGrid,
QgsTextFormat as _QgsTextFormat,
QgsTextBackgroundSettings as _QgsTextBackgroundSettings,
QgsLayoutItemLabel as _QgsLayoutItemLabel,
QgsLayoutItemLegend as _QgsLayoutItemLegend,
QgsLayoutPoint as _QgsLayoutPoint,
QgsLayoutSize as _QgsLayoutSize,
QgsUnitTypes as _QgsUnitTypes,
QgsLayoutItem as _QgsLayoutItem,
QgsBlockingNetworkRequest as _QgsBlockingNetworkRequest,
QgsJsonExporter as _QgsJsonExporter,
QgsJsonUtils as _QgsJsonUtils,
)
QgsProject = _QgsProject
@@ -50,7 +111,140 @@ try:
QgsVectorFileWriter = _QgsVectorFileWriter
QgsFeature = _QgsFeature
QgsField = _QgsField
QgsGeometry = _QgsGeometry
QgsGeometry = _QgsGeometry
QgsFeatureRequest = _QgsFeatureRequest
QgsRectangle = _QgsRectangle
QgsCoordinateTransform = _QgsCoordinateTransform
QgsCoordinateReferenceSystem = _QgsCoordinateReferenceSystem
QgsPrintLayout = _QgsPrintLayout
QgsLayoutItemMap = _QgsLayoutItemMap
QgsLayoutItemMapGrid = _QgsLayoutItemMapGrid
QgsTextFormat = _QgsTextFormat
QgsTextBackgroundSettings = _QgsTextBackgroundSettings
QgsLayoutItemLabel = _QgsLayoutItemLabel
QgsLayoutItemLegend = _QgsLayoutItemLegend
QgsLayoutPoint = _QgsLayoutPoint
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:
value: Any = path[0]
ok = True
for name in path[1:]:
value = getattr(value, name, None)
if value is None:
ok = False
break
if ok:
return value
return None
MAP_GRID_STYLE_MARKERS = _resolve_qgis_enum(
(_Qgis, "MapGridStyle", "Markers"),
(_QgsLayoutItemMapGrid, "Markers"),
)
MAP_GRID_STYLE_FRAME_ANNOTATIONS = _resolve_qgis_enum(
(_Qgis, "MapGridStyle", "FrameAndAnnotationsOnly"),
(_QgsLayoutItemMapGrid, "FrameAndAnnotationsOnly"),
(_Qgis, "MapGridStyle", "FrameAnnotations"),
(_QgsLayoutItemMapGrid, "FrameAnnotations"),
)
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS = _resolve_qgis_enum(
(_Qgis, "MapGridFrameStyle", "InteriorTicks"),
(_QgsLayoutItemMapGrid, "InteriorTicks"),
)
MAP_GRID_UNIT_MAP_UNIT = _resolve_qgis_enum(
(_Qgis, "MapGridUnit", "MapUnits"),
(_QgsLayoutItemMapGrid, "MapUnits"),
(_Qgis, "MapGridUnit", "MapUnit"),
(_QgsLayoutItemMapGrid, "MapUnit"),
)
MAP_GRID_ANNOTATION_FORMAT_DECIMAL = _resolve_qgis_enum(
(_Qgis, "MapGridAnnotationFormat", "Decimal"),
(_QgsLayoutItemMapGrid, "Decimal"),
)
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "HideAll"),
(_QgsLayoutItemMapGrid, "HideAll"),
)
MAP_GRID_ANNOTATION_DISPLAY_SHOW_ALL = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "ShowAll"),
(_QgsLayoutItemMapGrid, "ShowAll"),
)
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "LongitudeOnly"),
(_QgsLayoutItemMapGrid, "LongitudeOnly"),
(_Qgis, "MapGridComponentVisibility", "ShowLongitudeOnly"),
(_QgsLayoutItemMapGrid, "ShowLongitudeOnly"),
)
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y = _resolve_qgis_enum(
(_Qgis, "MapGridComponentVisibility", "LatitudeOnly"),
(_QgsLayoutItemMapGrid, "LatitudeOnly"),
(_Qgis, "MapGridComponentVisibility", "ShowLatitudeOnly"),
(_QgsLayoutItemMapGrid, "ShowLatitudeOnly"),
)
MAP_GRID_BORDER_LEFT = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Left"),
(_QgsLayoutItemMapGrid, "Left"),
)
MAP_GRID_BORDER_RIGHT = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Right"),
(_QgsLayoutItemMapGrid, "Right"),
)
MAP_GRID_BORDER_TOP = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Top"),
(_QgsLayoutItemMapGrid, "Top"),
)
MAP_GRID_BORDER_BOTTOM = _resolve_qgis_enum(
(_Qgis, "MapGridBorderSide", "Bottom"),
(_QgsLayoutItemMapGrid, "Bottom"),
)
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME = _resolve_qgis_enum(
(_Qgis, "MapGridAnnotationPosition", "InsideMapFrame"),
(_QgsLayoutItemMapGrid, "InsideMapFrame"),
)
TEXT_BACKGROUND_SHAPE_RECTANGLE = _resolve_qgis_enum(
(_QgsTextBackgroundSettings, "ShapeRectangle"),
)
TEXT_BACKGROUND_SIZE_BUFFER = _resolve_qgis_enum(
(_QgsTextBackgroundSettings, "SizeBuffer"),
)
TEXT_RENDER_UNIT_MILLIMETERS = _resolve_qgis_enum(
(_Qgis, "RenderUnit", "Millimeters"),
(_QgsUnitTypes, "RenderMillimeters"),
)
TEXT_RENDER_UNIT_POINTS = _resolve_qgis_enum(
(_Qgis, "RenderUnit", "Points"),
(_QgsUnitTypes, "RenderPoints"),
)
QGIS_AVAILABLE = True
@@ -61,9 +255,17 @@ try:
except Exception:
QGIS_AVAILABLE = False
class _MockLayoutManager:
def layoutByName(self, name: str):
return None
def addLayout(self, layout: Any) -> bool:
return True
class _MockQgsProject:
def __init__(self):
self._variables = {}
self._layout_manager = _MockLayoutManager()
@staticmethod
def instance() -> "_MockQgsProject":
@@ -72,6 +274,9 @@ except Exception:
def read(self) -> bool:
return True
def layoutManager(self):
return self._layout_manager
QgsProject = _MockQgsProject
class _MockQgsVectorLayer:
@@ -122,6 +327,408 @@ except Exception:
QgsRasterLayer = _MockQgsRasterLayer
class _MockQgsPrintLayout:
def __init__(self, project: Any):
self.project = project
self._name = ""
self._page = _MockQgsLayoutPage()
self.items: list[Any] = []
def initializeDefaults(self) -> None:
pass
def setName(self, name: str) -> None:
self._name = name
def pageCollection(self):
return self
def page(self, index: int):
return self._page
def addLayoutItem(self, item: Any) -> None:
self.items.append(item)
class _MockQgsLayoutPage:
def setPageSize(self, size: Any) -> None:
self.size = size
class _MockQgsLayoutItem:
class ReferencePoint:
LowerLeft = 0
class _MockQgsLayoutItemMap:
def __init__(self, layout: Any):
self.layout = layout
self.item_id = ""
self.extent = None
self.scale = None
self.follow_visibility_preset = False
self.follow_visibility_preset_name = ""
self.keep_layer_set = False
self._grids = _MockQgsLayoutItemMapGridStack(self)
def setId(self, item_id: str) -> None:
self.item_id = item_id
def setExtent(self, extent: Any) -> None:
self.extent = extent
def setScale(self, scale: float) -> None:
self.scale = scale
def attemptMove(self, point: Any) -> None:
pass
def attemptResize(self, size: Any) -> None:
pass
def setFollowVisibilityPreset(self, active: bool) -> None:
self.follow_visibility_preset = active
def setFollowVisibilityPresetName(self, name: str) -> None:
self.follow_visibility_preset_name = name
def setKeepLayerSet(self, enabled: bool) -> None:
self.keep_layer_set = enabled
def grids(self):
return self._grids
def updateBoundingRect(self) -> None:
pass
def refresh(self) -> None:
pass
class _MockQgsLayoutItemMapGrid:
Markers = "Markers"
FrameAndAnnotationsOnly = "FrameAndAnnotationsOnly"
InteriorTicks = "InteriorTicks"
MapUnits = "MapUnits"
Decimal = "Decimal"
HideAll = "HideAll"
ShowAll = "ShowAll"
LongitudeOnly = "LongitudeOnly"
LatitudeOnly = "LatitudeOnly"
Left = "Left"
Right = "Right"
Top = "Top"
Bottom = "Bottom"
InsideMapFrame = "InsideMapFrame"
def __init__(self, name: str, map_item: Any):
self.name = name
self.map_item = map_item
self.enabled = False
self.style = None
self.units = None
self.interval_x = None
self.interval_y = None
self.annotation_enabled = False
self.annotation_format = None
self.annotation_precision = None
self.annotation_display: dict[Any, Any] = {}
self.annotation_position: dict[Any, Any] = {}
self.frame_style = None
self.annotation_text_format = None
def setEnabled(self, enabled: bool) -> None:
self.enabled = enabled
def setStyle(self, style: Any) -> None:
self.style = style
def setUnits(self, units: Any) -> None:
self.units = units
def setIntervalX(self, interval: float) -> None:
self.interval_x = interval
def setIntervalY(self, interval: float) -> None:
self.interval_y = interval
def setAnnotationEnabled(self, enabled: bool) -> None:
self.annotation_enabled = enabled
def setAnnotationFormat(self, annotation_format: Any) -> None:
self.annotation_format = annotation_format
def setAnnotationPrecision(self, precision: int) -> None:
self.annotation_precision = precision
def setAnnotationDisplay(self, display: Any, border: Any) -> None:
self.annotation_display[border] = display
def setAnnotationPosition(self, position: Any, border: Any) -> None:
self.annotation_position[border] = position
def setFrameStyle(self, frame_style: Any) -> None:
self.frame_style = frame_style
def setAnnotationTextFormat(self, annotation_text_format: Any) -> None:
self.annotation_text_format = annotation_text_format
class _MockQgsTextBackgroundSettings:
ShapeRectangle = "ShapeRectangle"
SizeBuffer = "SizeBuffer"
def __init__(self):
self.enabled = False
self.shape_type = None
self.size_type = None
self.size = None
self.size_unit = None
self.offset = None
self.offset_unit = None
self.fill_color = None
def setEnabled(self, enabled: bool) -> None:
self.enabled = enabled
def setType(self, shape_type: Any) -> None:
self.shape_type = shape_type
def setSizeType(self, size_type: Any) -> None:
self.size_type = size_type
def setSize(self, size: Any) -> None:
self.size = size
def setSizeUnit(self, unit: Any) -> None:
self.size_unit = unit
def setOffset(self, offset: Any) -> None:
self.offset = offset
def setOffsetUnit(self, unit: Any) -> None:
self.offset_unit = unit
def setFillColor(self, color: Any) -> None:
self.fill_color = color
class _MockQgsTextFormat:
def __init__(self):
self.font = None
self.size = None
self.size_unit = None
self.background = None
def setFont(self, font: Any) -> None:
self.font = font
def setSize(self, size: float) -> None:
self.size = size
def setSizeUnit(self, unit: Any) -> None:
self.size_unit = unit
def setBackground(self, background: Any) -> None:
self.background = background
class _MockQgsLayoutItemMapGridStack:
def __init__(self, map_item: Any):
self.map_item = map_item
self._grids: list[_MockQgsLayoutItemMapGrid] = []
def addGrid(self, grid: Any) -> None:
self._grids.append(grid)
def removeGrid(self, grid_id: str) -> None:
self._grids = [grid for grid in self._grids if getattr(grid, "name", "") != grid_id]
def asList(self) -> list[Any]:
return list(self._grids)
class _MockQgsLayoutItemLabel:
ModeHtml = 1
def __init__(self, layout: Any):
self.layout = layout
def setId(self, item_id: str) -> None:
pass
def setText(self, text: str) -> None:
pass
def setMode(self, mode: Any) -> None:
pass
def setFont(self, font: Any) -> None:
pass
def setReferencePoint(self, point: Any) -> None:
pass
def setVAlign(self, alignment: Any) -> None:
pass
def attemptMove(self, point: Any) -> None:
pass
def attemptResize(self, size: Any) -> None:
pass
class _MockLegendRootGroup:
def __init__(self) -> None:
self.layers: list[Any] = []
def clear(self) -> None:
self.layers = []
def addLayer(self, layer: Any) -> None:
self.layers.append(layer)
class _MockLegendModel:
def __init__(self) -> None:
self._root_group = _MockLegendRootGroup()
def rootGroup(self) -> _MockLegendRootGroup:
return self._root_group
class _MockQgsLayoutItemLegend:
def __init__(self, layout: Any):
self.layout = layout
self._model = _MockLegendModel()
self.item_id = ""
self.object_name = ""
self.linked_map = None
self.auto_update_model = None
self.legend_filter_by_map_enabled = None
self.title = ""
def setId(self, item_id: str) -> None:
self.item_id = item_id
def setObjectName(self, name: str) -> None:
self.object_name = name
def setLinkedMap(self, map_item: Any) -> None:
self.linked_map = map_item
def setAutoUpdateModel(self, enabled: bool) -> None:
self.auto_update_model = enabled
def setLegendFilterByMapEnabled(self, enabled: bool) -> None:
self.legend_filter_by_map_enabled = enabled
def setTitle(self, title: str) -> None:
self.title = title
def setReferencePoint(self, point: Any) -> None:
pass
def attemptMove(self, point: Any) -> None:
pass
def attemptResize(self, size: Any) -> None:
pass
def model(self) -> _MockLegendModel:
return self._model
def refresh(self) -> None:
pass
class _MockQgsLayoutPoint:
def __init__(self, x: float, y: float, unit: Any):
self.x = x
self.y = y
self.unit = unit
class _MockQgsLayoutSize:
def __init__(self, width: float, height: float, unit: Any):
self.width = width
self.height = height
self.unit = unit
class _MockQgsUnitTypes:
LayoutMillimeters = 0
QgsPrintLayout = _MockQgsPrintLayout
QgsLayoutItemMap = _MockQgsLayoutItemMap
QgsLayoutItemMapGrid = _MockQgsLayoutItemMapGrid
QgsTextFormat = _MockQgsTextFormat
QgsTextBackgroundSettings = _MockQgsTextBackgroundSettings
QgsLayoutItemLabel = _MockQgsLayoutItemLabel
QgsLayoutItemLegend = _MockQgsLayoutItemLegend
QgsLayoutPoint = _MockQgsLayoutPoint
QgsLayoutSize = _MockQgsLayoutSize
QgsUnitTypes = _MockQgsUnitTypes
QgsLayoutItem = _MockQgsLayoutItem
MAP_GRID_STYLE_MARKERS = _MockQgsLayoutItemMapGrid.Markers
MAP_GRID_STYLE_FRAME_ANNOTATIONS = _MockQgsLayoutItemMapGrid.FrameAndAnnotationsOnly
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS = _MockQgsLayoutItemMapGrid.InteriorTicks
MAP_GRID_UNIT_MAP_UNIT = _MockQgsLayoutItemMapGrid.MapUnits
MAP_GRID_ANNOTATION_FORMAT_DECIMAL = _MockQgsLayoutItemMapGrid.Decimal
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL = _MockQgsLayoutItemMapGrid.HideAll
MAP_GRID_ANNOTATION_DISPLAY_SHOW_ALL = _MockQgsLayoutItemMapGrid.ShowAll
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X = _MockQgsLayoutItemMapGrid.LongitudeOnly
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y = _MockQgsLayoutItemMapGrid.LatitudeOnly
MAP_GRID_BORDER_LEFT = _MockQgsLayoutItemMapGrid.Left
MAP_GRID_BORDER_RIGHT = _MockQgsLayoutItemMapGrid.Right
MAP_GRID_BORDER_TOP = _MockQgsLayoutItemMapGrid.Top
MAP_GRID_BORDER_BOTTOM = _MockQgsLayoutItemMapGrid.Bottom
MAP_GRID_ANNOTATION_POSITION_INSIDE_FRAME = _MockQgsLayoutItemMapGrid.InsideMapFrame
TEXT_BACKGROUND_SHAPE_RECTANGLE = _MockQgsTextBackgroundSettings.ShapeRectangle
TEXT_BACKGROUND_SIZE_BUFFER = _MockQgsTextBackgroundSettings.SizeBuffer
TEXT_RENDER_UNIT_MILLIMETERS = "Millimeters"
TEXT_RENDER_UNIT_POINTS = "Points"
class _MockQgsFeature:
def __init__(self):
self._geometry = None
self._attributes = []
def setGeometry(self, geometry: Any) -> None:
self._geometry = geometry
def setAttributes(self, attributes: list[Any]) -> None:
self._attributes = attributes
class _MockQgsField:
def __init__(self, name: str = "", field_type: Any = None):
self.name = name
self.field_type = field_type
class _MockQgsGeometry:
@staticmethod
def unaryUnion(_geometries: list[Any]):
return None
def isEmpty(self) -> bool:
return True
QgsFeature = _MockQgsFeature
QgsField = _MockQgsField
QgsGeometry = _MockQgsGeometry
class _MockQgsFeatureRequest:
def __init__(self):
self._filter_rect = None
def setFilterRect(self, rect):
self._filter_rect = rect
return self
QgsFeatureRequest = _MockQgsFeatureRequest
class _MockQgsCoordinateTransform:
def __init__(self, *args, **kwargs):
pass
def transformBoundingBox(self, rect):
return rect
class _MockQgsCoordinateReferenceSystem:
def __init__(self, *args, **kwargs):
pass
QgsCoordinateTransform = _MockQgsCoordinateTransform
QgsCoordinateReferenceSystem = _MockQgsCoordinateReferenceSystem
QgsNetworkAccessManager = _MockQgsNetworkAccessManager
class _MockQgis:
@@ -205,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
# ---------------------------------------------------------
@@ -382,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
+10
View File
@@ -76,6 +76,9 @@ except Exception:
def removeToolBar(self, *args, **kwargs):
pass
def openLayoutDesigner(self, layout):
return layout
iface = _MockIface()
class _MockQgsFileWidget:
@@ -132,6 +135,13 @@ def get_main_window():
return None
def open_layout_designer(layout: Any) -> Any:
try:
return iface.openLayoutDesigner(layout)
except Exception:
return None
# ---------------------------------------------------------
# Dock-Handling
# ---------------------------------------------------------
+103 -5
View File
@@ -10,13 +10,17 @@ YES: Optional[Any] = None
NO: Optional[Any] = None
CANCEL: Optional[Any] = None
ICON_QUESTION: Optional[Any] = None
QVariant: Type[Any] = object
# Qt-Klassen (werden dynamisch gesetzt)
QDockWidget: Type[Any] = object
QMessageBox: Type[Any] = object
QFileDialog: Type[Any] = object
QProgressDialog: Type[Any] = object
QEventLoop: Type[Any] = object
QTimer: Type[Any] = object
QUrl: Type[Any] = object
QNetworkRequest: Type[Any] = object
QNetworkReply: Type[Any] = object
@@ -25,6 +29,7 @@ QWidget: Type[Any] = object
QGridLayout: Type[Any] = object
QLabel: Type[Any] = object
QLineEdit: Type[Any] = object
QInputDialog: Type[Any] = object
QGroupBox: Type[Any] = object
QVBoxLayout: Type[Any] = object
QPushButton: Type[Any] = object
@@ -37,7 +42,10 @@ QToolButton: Type[Any] = object
QSizePolicy: Type[Any] = object
Qt: Type[Any] = object
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:
@@ -64,10 +72,12 @@ try:
from qgis.PyQt.QtWidgets import (
QMessageBox as _QMessageBox,
QFileDialog as _QFileDialog,
QProgressDialog as _QProgressDialog,
QWidget as _QWidget,
QGridLayout as _QGridLayout,
QLabel as _QLabel,
QLineEdit as _QLineEdit,
QInputDialog as _QInputDialog,
QGroupBox as _QGroupBox,
QVBoxLayout as _QVBoxLayout,
QPushButton as _QPushButton,
@@ -80,14 +90,18 @@ try:
QToolButton as _QToolButton,
QSizePolicy as _QSizePolicy,
QComboBox as _QComboBox,
QCheckBox as _QCheckBox,
QHBoxLayout as _QHBoxLayout,
)
from qgis.PyQt.QtGui import QFont as _QFont
from qgis.PyQt.QtCore import (
QEventLoop as _QEventLoop,
QTimer as _QTimer,
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,
@@ -98,7 +112,10 @@ try:
QT_VERSION = 6
QMessageBox = _QMessageBox
QFileDialog = _QFileDialog
QProgressDialog = _QProgressDialog
QProgressDialog = _QProgressDialog
QEventLoop = _QEventLoop
QTimer = _QTimer
QUrl = _QUrl
QNetworkRequest = _QNetworkRequest
QNetworkReply = _QNetworkReply
@@ -109,6 +126,7 @@ try:
QGridLayout = _QGridLayout
QLabel = _QLabel
QLineEdit = _QLineEdit
QInputDialog = _QInputDialog
QGroupBox = _QGroupBox
QVBoxLayout = _QVBoxLayout
QPushButton = _QPushButton
@@ -120,8 +138,11 @@ try:
QToolButton = _QToolButton
QSizePolicy = _QSizePolicy
QComboBox = _QComboBox
QCheckBox = _QCheckBox
QVariant = _QVariant
QHBoxLayout= _QHBoxLayout
QHBoxLayout = _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
# ✅ QT6 ENUMS
YES = QMessageBox.StandardButton.Yes
NO = QMessageBox.StandardButton.No
@@ -158,6 +179,7 @@ except (ImportError, AttributeError):
QGridLayout as _QGridLayout,
QLabel as _QLabel,
QLineEdit as _QLineEdit,
QInputDialog as _QInputDialog,
QGroupBox as _QGroupBox,
QVBoxLayout as _QVBoxLayout,
QPushButton as _QPushButton,
@@ -170,14 +192,18 @@ except (ImportError, AttributeError):
QToolButton as _QToolButton,
QSizePolicy as _QSizePolicy,
QComboBox as _QComboBox,
QCheckBox as _QCheckBox,
QHBoxLayout as _QHBoxLayout,
)
from PyQt5.QtGui import QFont as _QFont
from PyQt5.QtCore import (
QEventLoop as _QEventLoop,
QTimer as _QTimer,
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,
@@ -189,6 +215,7 @@ except (ImportError, AttributeError):
QMessageBox = _QMessageBox
QFileDialog = _QFileDialog
QEventLoop = _QEventLoop
QTimer = _QTimer
QUrl = _QUrl
QNetworkRequest = _QNetworkRequest
QNetworkReply = _QNetworkReply
@@ -199,6 +226,7 @@ except (ImportError, AttributeError):
QGridLayout = _QGridLayout
QLabel = _QLabel
QLineEdit = _QLineEdit
QInputDialog = _QInputDialog
QGroupBox = _QGroupBox
QVBoxLayout = _QVBoxLayout
QPushButton = _QPushButton
@@ -210,8 +238,11 @@ except (ImportError, AttributeError):
QToolButton = _QToolButton
QSizePolicy = _QSizePolicy
QComboBox = _QComboBox
QCheckBox = _QCheckBox
QVariant = _QVariant
QHBoxLayout = _QHBoxLayout
QHBoxLayout= _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
# ✅ PYQT5 ENUMS
YES = QMessageBox.Yes
@@ -283,12 +314,30 @@ except (ImportError, AttributeError):
QFileDialog = _MockQFileDialog
class _MockQInputDialog:
@staticmethod
def getText(parent, title, label, mode=None, text=""):
return text, True
QInputDialog = _MockQInputDialog
class _MockQEventLoop:
def exec(self) -> int: return 0
def quit(self) -> None: pass
QEventLoop = _MockQEventLoop
class _MockQTimer:
def __init__(self, *args, **kwargs):
self.timeout = type('Signal', (), {
'connect': lambda s, cb: None,
})()
def setSingleShot(self, v: bool) -> None: pass
def start(self, ms: int) -> None: pass
def stop(self) -> None: pass
QTimer = _MockQTimer
class _MockQUrl(str):
def isValid(self) -> bool: return True
@@ -319,11 +368,18 @@ except (ImportError, AttributeError):
class _MockLabel:
def __init__(self, text: str = ""): self._text = text
class _MockLineEdit:
Normal = 0
def __init__(self, *args, **kwargs): self._text = ""
def text(self) -> str: return self._text
def setText(self, value: str) -> None: self._text = value
class _MockButton:
class _MockFont:
def __init__(self, family: str = "", pointSize: int = 10):
self.family = family
self.pointSize = pointSize
class _MockButton:
def __init__(self, *args, **kwargs): self.clicked = lambda *a, **k: None
QWidget = _MockWidget
@@ -333,6 +389,7 @@ except (ImportError, AttributeError):
QGroupBox = _MockWidget
QVBoxLayout = _MockLayout
QPushButton = _MockButton
QFont = _MockFont
QCoreApplication = object()
class _MockQt:
@@ -507,6 +564,47 @@ except (ImportError, AttributeError):
def setContentsMargins(self, *args, **kwargs):
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
self._checked = False
def setText(self, text: str) -> None:
self._text = text
def isChecked(self) -> bool:
return self._checked
def setChecked(self, checked: bool) -> None:
self._checked = checked
QCheckBox = _MockQCheckBox
def exec_dialog(dialog: Any) -> Any:
return YES
# --------------------------- TEST ---------------------------
+1
View File
@@ -22,6 +22,7 @@ class SettingsLogic:
"landkreis_user",
"sachgebiet",
"bezeichnung",
"name",
"verfahrensnummer",
"gemeinden",
"landkreise_proj",
+1
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Union
import sys
from sn_basis.functions.os_wrapper import is_absolute_path, basename
_PathLike = Union[str, Path]
-14
View File
@@ -1,14 +0,0 @@
mermaid´´´
flowchart TD
A[Projekt]
subgraph children[ ]
direction TB
B[src]
C[docs]
D[README.md]
end
A --> B
A --> C
A --> D
+4
View File
@@ -97,6 +97,10 @@ def set_variable(key: str, value: str, scope: str = "project") -> None:
var_name = f"sn_{key}"
if scope == "project":
current_value = get_variable(key, scope="project")
if current_value == value:
return
project = QgsProject.instance()
QgsExpressionContextUtils.setProjectVariable(
project,
+216
View File
@@ -0,0 +1,216 @@
"""
sn_basis/functions/verfahrensgebiet_manager.py
Zentrale Verwaltung des Verfahrensgebiet-Layers für alle sn_-Plugins.
Die Variable `sn_verfahrensgebiet_layer` wird automatisch aktualisiert, wenn:
- Ein Layer mit dem Namen "Verfahrensgebiet" ins Projekt geladen wird
- Das Projekt neu geladen wird
- Der Layer aus dem Projekt gelöscht wird
Nutzer können diese Auswahl über das Dropdown in sn_verfahrensgebiet/working_tab
manuell überschreiben.
"""
from typing import Optional
from sn_basis.functions.qgiscore_wrapper import QgsProject, QGIS_AVAILABLE
from sn_basis.functions.variable_wrapper import get_variable, set_variable
# ---------------------------------------------------------
# Globale State für Listener-Registrierung
# ---------------------------------------------------------
_listener_installed = False
# ---------------------------------------------------------
# Zentrale Verwaltung
# ---------------------------------------------------------
def get_verfahrensgebiet_layer_id() -> Optional[str]:
"""
Gibt die Layer-ID des aktuellen Verfahrensgebiet-Layers zurück.
Returns
-------
Optional[str]
Die Layer-ID oder None, wenn keine Variable gesetzt ist.
"""
value = get_variable("verfahrensgebiet_layer", scope="project")
return value if value else None
def set_verfahrensgebiet_layer_id(layer_id: str) -> None:
"""
Setzt die Layer-ID des Verfahrensgebiet-Layers (von Nutzer-Auswahl im Dropdown).
Parameters
----------
layer_id : str
Die eindeutige Layer-ID des gewählten Layers.
"""
set_variable("verfahrensgebiet_layer", layer_id, scope="project")
def _find_verfahrensgebiet_layer():
"""
Sucht einen Layer mit dem Namen 'Verfahrensgebiet' im aktuellen Projekt.
Returns
-------
tuple[Optional[layer], Optional[str]]
(Layer-Objekt, Layer-ID) oder (None, None), wenn nicht gefunden.
"""
if not QGIS_AVAILABLE:
return None, None
try:
project = QgsProject.instance()
layers = project.mapLayersByName("Verfahrensgebiet")
if layers:
# Nutze den ersten (bzw. zuletzt hinzugefügten) Layer mit diesem Namen
layer = layers[0]
return layer, layer.id()
except Exception:
pass
return None, None
def _update_verfahrensgebiet_from_project() -> bool:
"""
Sucht automatisch nach einem Layer namens 'Verfahrensgebiet' im Projekt
und aktualisiert die Variable `sn_verfahrensgebiet_layer`.
Diese Funktion wird aufgerufen bei:
- layersAdded Signal
- readProject Signal (Projekt-Reload)
- Projekt-Neuerstellung
Returns
-------
bool
True, wenn ein Layer gefunden und gespeichert wurde, False sonst.
"""
layer, layer_id = _find_verfahrensgebiet_layer()
if layer and layer_id:
set_variable("verfahrensgebiet_layer", layer_id, scope="project")
return True
else:
# Layer nicht gefunden: Variable zurücksetzen
set_variable("verfahrensgebiet_layer", "", scope="project")
return False
def _on_layers_added(layers) -> None:
"""Signal-Handler für QgsProject.layersAdded"""
_update_verfahrensgebiet_from_project()
def _on_layer_removed(layer_id: str) -> None:
"""Signal-Handler für QgsProject.layerRemoved"""
# Prüfe, ob der entfernte Layer der aktuelle Verfahrensgebiet-Layer war
current_id = get_verfahrensgebiet_layer_id()
if current_id and current_id == layer_id:
# Layer wurde entfernt; versuche, neu zu suchen
_update_verfahrensgebiet_from_project()
def _on_project_changed() -> None:
"""Signal-Handler für QgsProject.readProject, newProjectCreated, cleared"""
_update_verfahrensgebiet_from_project()
# ---------------------------------------------------------
# Listener-Setup
# ---------------------------------------------------------
def setup_verfahrensgebiet_listener() -> None:
"""
Registriert globale Event-Listener auf dem QgsProject-Singleton.
Diese Funktion sollte EINMAL beim Plugin-Start aufgerufen werden
(z.B. in sn_basis.main.BasisPlugin.initGui()).
Listener:
- layersAdded: automatische Aktualisierung, wenn Layer hinzugefügt werden
- layerRemoved: Validierung, wenn Layer gelöscht werden
- readProject: Neuinitialisierung beim Projekt-Reload
"""
global _listener_installed
if _listener_installed:
return # Bereits registriert
if not QGIS_AVAILABLE:
return # QGIS nicht verfügbar
try:
project = QgsProject.instance()
# Verbinde Signale
project.layersAdded.connect(_on_layers_added)
project.layerRemoved.connect(_on_layer_removed)
# readProject wird emittiert, wenn Projekt geladen wird
# newProjectCreated wird emittiert bei neuem Projekt
# cleared wird emittiert, wenn Projekt gelöscht wird
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is not None:
signal.connect(_on_project_changed)
_listener_installed = True
# Initial: versuche, existierenden Layer zu finden
_update_verfahrensgebiet_from_project()
except Exception:
pass
def teardown_verfahrensgebiet_listener() -> None:
"""
Trennt globale Event-Listener vom QgsProject-Singleton.
Diese Funktion ist idempotent und kann mehrfach aufgerufen werden.
"""
global _listener_installed
if not _listener_installed:
return
try:
project = QgsProject.instance()
if project is None:
return
try:
project.layersAdded.disconnect(_on_layers_added)
except Exception:
pass
try:
project.layerRemoved.disconnect(_on_layer_removed)
except Exception:
pass
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is None:
continue
try:
signal.disconnect(_on_project_changed)
except Exception:
pass
except RuntimeError:
pass
except Exception:
pass
finally:
_listener_installed = False
+10
View File
@@ -4,6 +4,10 @@ from qgis.utils import plugins
from sn_basis.functions.qt_wrapper import QCoreApplication
from sn_basis.functions.sys_wrapper import getattr_safe
from sn_basis.functions.verfahrensgebiet_manager import (
setup_verfahrensgebiet_listener,
teardown_verfahrensgebiet_listener,
)
from sn_basis.ui.navigation import Navigation
@@ -34,6 +38,10 @@ class BasisPlugin:
"""
self.ui = Navigation()
self.ui.init_ui()
# Zentrale Verwaltung des Verfahrensgebiet-Layers starten
setup_verfahrensgebiet_listener()
for name, plugin in plugins.items():
if name.startswith("sn_") and name != "sn_basis":
try:
@@ -48,6 +56,8 @@ class BasisPlugin:
"""
Räumt UI-Komponenten sauber auf.
"""
teardown_verfahrensgebiet_listener()
if self.ui:
self.ui.remove_all()
self.ui = None
+11 -11
View File
@@ -1,13 +1,13 @@
[general]
name=LNO Sachsen | Basisfunktionen
qgisMinimumVersion=3.0
version=26.7.2-testing
name=LNO Sachsen | Plugin Basisfunktionen
description=Plugin mit Basisfunktionen
version=25.11.4
author=Michael Otto
email=michael.otto@landkreis-mittelsachsen.de
about=Plugin mit Basisfunktionen
category=Plugins
homepage=https://entwicklung.vln-sn.de/AG_QGIS/Plugin_SN_Basis
repository=https://entwicklung.vln-sn.de/AG_QGIS/Repository
supportsQt6=true
experimental=true
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/testing/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
deprecated=false
supportsQt6=true
+212 -33
View File
@@ -2,14 +2,14 @@
DataGrabber module
==================
UIfreier Orchestrator für die Prüfung und Klassifikation von Datenquellen.
UI-freier Orchestrator für die Prüfung und Klassifikation von Datenquellen.
Der DataGrabber:
- klassifiziert die übergebene Quelle (Datei, Dienst, Datenbank, Excel),
- ruft passende Prüfer (Dateipruefer, Linkpruefer, Layerpruefer, Stilpruefer) auf,
- sammelt alle rohen ``pruef_ergebnis``Objekte,
- sammelt alle rohen ``pruef_ergebnis``-Objekte,
- aggregiert diese zu einem zusammenfassenden Ergebnis,
- **löst selbst keinerlei UIInteraktion aus**.
- **löst selbst keinerlei UI-Interaktion aus**.
Alle Nutzerinteraktionen (MessageBar, QMessageBox, Logging) erfolgen
ausschließlich über den ``Pruefmanager`` im aufrufenden Kontext (UI / Pipeline).
@@ -17,8 +17,11 @@ ausschließlich über den ``Pruefmanager`` im aufrufenden Kontext (UI / Pipeline
from __future__ import annotations
import re
from typing import Any, Dict, List, Mapping, Optional, Tuple, Literal
from sn_basis.functions.os_wrapper import basename, path_suffix
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
from sn_basis.modules.Pruefmanager import Pruefmanager
@@ -27,6 +30,7 @@ from sn_basis.modules.linkpruefer import Linkpruefer
from sn_basis.modules.layerpruefer import Layerpruefer
from sn_basis.modules.stilpruefer import Stilpruefer
from sn_basis.modules.excel_importer import ExcelImporter
from sn_plan41.modules.listenauswerter import Listenauswerter
SourceType = Literal["service", "database", "excel", "unknown"]
@@ -38,9 +42,6 @@ class DataGrabber:
"""
Analysiert und prüft Datenquellen für den Fachdatenabruf.
Der DataGrabber ist **UIfrei**. Er erzeugt ausschließlich rohe
``pruef_ergebnis``Objekte und überlässt deren Verarbeitung
vollständig dem aufrufenden Code.
"""
def __init__(
@@ -55,9 +56,9 @@ class DataGrabber:
) -> None:
self.pruefmanager = pruefmanager
self._datei_pruefer_cls = datei_pruefer_cls
self.link_pruefer = link_pruefer
self.layer_pruefer = layer_pruefer
self.stil_pruefer = stil_pruefer
self.link_pruefer = link_pruefer or Linkpruefer()
self.layer_pruefer = layer_pruefer or Layerpruefer()
self.stil_pruefer = stil_pruefer or Stilpruefer()
self._excel_importer_cls = excel_importer_cls
self._source: Optional[str] = None
@@ -69,23 +70,61 @@ class DataGrabber:
"""Setzt die aktuell zu untersuchende Rohquelle."""
self._source = source
def analyze_source_type(self, source: str) -> SourceType:
"""
Klassifiziert die Quelle.
SourceType = str # "excel" | "datenbank" | "dienst" | "unbekannt"
Aktuell Platzhalter liefert ``"unknown"``.
def analyze_source_type(self, quelle: str) -> Tuple[SourceType, pruef_ergebnis]:
"""
return "unknown"
Klassifiziert die Quelle und liefert das zugehörige pruef_ergebnis.
Reihenfolge:
1. Dateipruefer (Datei + Dateityp)
2. Linkpruefer (Dienst)
"""
# --------------------------------------------------
# 1. Datei prüfen (inkl. Typ-Erkennung)
# --------------------------------------------------
dateipruefer = Dateipruefer(pfad=quelle)
datei_ergebnis = dateipruefer.pruefe()
if datei_ergebnis.ok:
suffix = path_suffix(datei_ergebnis.kontext).lower()
print(f"[DataGrabber] Debug: analyze_source_type source={quelle} -> suffix={suffix}")
if suffix == ".xlsx":
return "excel", datei_ergebnis
if suffix in (".gpkg", ".sqlite"):
return "datenbank", datei_ergebnis
return "unbekannter_dateityp", datei_ergebnis
# --------------------------------------------------
# 2. Keine Datei → Link prüfen
# --------------------------------------------------
linkpruefer = Linkpruefer()
link_ergebnis = linkpruefer.pruefe(quelle)
if link_ergebnis.ok:
return "dienst", link_ergebnis
# --------------------------------------------------
# 3. Weder Datei noch Dienst
# --------------------------------------------------
return "unbekannte_quelle", link_ergebnis
def run(self, source: str) -> Tuple[SourceDict, pruef_ergebnis]:
"""
Führt die vollständige Quellprüfung aus.
Diese Methode ist **UIfrei**. Sie gibt rohe Ergebnisse zurück,
Diese Methode ist **UIfrei**. Sie gibt rohe Ergebnisse zurück,
die vom Aufrufer über den ``Pruefmanager`` verarbeitet werden.
"""
self.set_source(source)
source_type = self.analyze_source_type(source)
source_type, source_result = self.analyze_source_type(source)
print(f"[DataGrabber] Debug: run source={source} -> source_type={source_type}")
source_dict: SourceDict = {}
partial_results: List[pruef_ergebnis] = []
@@ -97,14 +136,7 @@ class DataGrabber:
elif source_type == "service":
source_dict, partial_results = self._process_service_source(source)
else:
partial_results.append(
pruef_ergebnis(
ok=False,
meldung="Quelle konnte nicht klassifiziert werden",
aktion="kein_dateipfad",
kontext={"source": source},
)
)
partial_results.append(source_result)
summary = self._aggregate_results(source, source_dict, partial_results)
return source_dict, summary
@@ -115,9 +147,150 @@ class DataGrabber:
def _process_excel_source(
self, filepath: str
) -> Tuple[SourceDict, List[pruef_ergebnis]]:
source_dict: SourceDict = {}
source_dict: SourceDict = {"rows": []}
results: List[pruef_ergebnis] = []
return source_dict, results
rows = ExcelImporter(filepath, self.pruefmanager).import_xlsx()
print(f"[DataGrabber] Debug: Excel-Linkliste geladen: {filepath}")
print(f"[DataGrabber] Debug: raw rows count: {len(rows)}")
if rows:
first = rows[:min(5, len(rows))]
print(f"[DataGrabber] Debug: first rows: {first}")
if not rows:
return source_dict, results
required_keys = {"ident", "gruppe", "kartenebene", "inhalt", "link", "provider", "stildatei"}
def extract_url(raw_link: str, provider: str) -> str:
if not raw_link:
return ""
if not isinstance(raw_link, str):
return str(raw_link)
if provider == "wfs":
url_match = re.search(r"url\s*=\s*['\"]([^'\"]+)['\"]", raw_link, re.IGNORECASE)
type_match = re.search(r"typename\s*=\s*['\"]([^'\"]+)['\"]", raw_link, re.IGNORECASE)
if url_match:
url = url_match.group(1).strip()
if type_match:
typename = type_match.group(1).strip()
separator = "&" if "?" in url else "?"
return f"url={url}{separator}service=WFS&request=GetFeature&typename={typename}"
return f"url={url}"
if provider == "wms":
# falls WMS-URL als url='...' vorliegt
match = re.search(r"url\s*=\s*['\"]([^'\"]+)['\"]", raw_link, re.IGNORECASE)
if match:
return match.group(1).strip()
if provider == "rest":
# REST/ArcGIS-Server: direkt nutzen
match = re.search(r"url\s*=\s*['\"]([^'\"]+)['\"]", raw_link, re.IGNORECASE)
if match:
return match.group(1).strip()
# allgemeines Rückfallverhalten
match = re.search(r"url\s*=\s*['\"]([^'\"]+)['\"]", raw_link, re.IGNORECASE)
if match:
return match.group(1).strip()
return raw_link.strip()
for row_index, raw_row in enumerate(rows, start=2):
if not isinstance(raw_row, Mapping):
pe = pruef_ergebnis(
ok=False,
meldung="Linklisten-Zeile ist nicht als Dictionary formatiert.",
aktion="ungueltige_zeile",
kontext={"zeile": row_index, "wert": raw_row},
)
results.append(self.pruefmanager.verarbeite(pe))
continue
normalized = {str(k).strip().lower(): v for k, v in raw_row.items() if k is not None}
if not required_keys.issubset(normalized.keys()):
missing = required_keys.difference(normalized.keys())
pe = pruef_ergebnis(
ok=False,
meldung=f"Linkliste fehlt erforderliche Spalten: {', '.join(sorted(missing))}",
aktion="spaltenfehlend",
kontext={"zeile": row_index, "fehlend": sorted(missing)},
)
results.append(self.pruefmanager.verarbeite(pe))
continue
ident = normalized.get("ident")
link_raw = normalized.get("link") or ""
provider = str(normalized.get("provider") or "").strip().lower()
stildatei_raw = normalized.get("stildatei") or ""
stildatei = None
if stildatei_raw and str(stildatei_raw).strip():
style_result = self.stil_pruefer.pruefe(str(stildatei_raw).strip())
results.append(self.pruefmanager.verarbeite(style_result))
if style_result.ok:
# Style-Pfad in der Datenkette beibehalten (absolut, wenn vorhanden).
stildatei = str(style_result.kontext or stildatei_raw).strip()
else:
stildatei = None
else:
results.append(self.pruefmanager.verarbeite(pruef_ergebnis(ok=True, meldung="Kein Stil angegeben", aktion="stil_optional", kontext=None)))
stildatei = None
if not ident or not link_raw or not provider:
pe = pruef_ergebnis(
ok=False,
meldung="Linklisten-Zeile hat fehlende Pflichtfelder (ident/link/provider).",
aktion="pflichtfelder_fehlen",
kontext={"zeile": row_index, "daten": raw_row},
)
results.append(self.pruefmanager.verarbeite(pe))
continue
link_url = extract_url(link_raw, provider)
# Provider-abhängige Linkvalidierung
if provider in ("wfs", "wms", "rest"):
# Webdienste: wir akzeptieren die URL-Form und prüfen nicht per network_head.
link_result = pruef_ergebnis(ok=True, meldung="Service-Link angenommen", aktion="service_link", kontext=link_url)
elif provider in ("ogr", "gpkg", "shp", "geojson"):
# OGR/Pfad: mit Linkpruefer (pfad oder lokale Datei) prüfen
link_result = self.link_pruefer.pruefe(link_url)
else:
link_result = self.link_pruefer.pruefe(link_url)
results.append(self.pruefmanager.verarbeite(link_result))
# stildatei wurde bereits oben geprüft und ggf. auf Dateiname gesetzt oder auf None
if not link_result.ok:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Zeile {row_index}: fehlerhafter Link",
aktion="link_unvollstaendig",
kontext={"row": row_index, "ident": ident},
)
)
continue
result_row = {
"ident": ident,
"gruppe": normalized.get("gruppe"),
"Kartenebene": normalized.get("kartenebene"),
"Inhalt": normalized.get("inhalt"),
"Link": link_url,
"Provider": provider,
"stildatei": stildatei,
}
source_dict["rows"].append(result_row)
# Validierung über Listenauswerter
listenauswerter = Listenauswerter(self.pruefmanager, self.stil_pruefer or Stilpruefer())
validated, validation_results = listenauswerter.validate_rows(source_dict)
results.extend(validation_results)
return validated, results
# ------------------------------------------------------------------
# DatenbankQuellen
@@ -125,6 +298,7 @@ class DataGrabber:
def _process_database_source(
self, db_path: str
) -> Tuple[SourceDict, List[pruef_ergebnis]]:
print(f"[DataGrabber] Debug: _process_database_source called, db_path={db_path}")
source_dict: SourceDict = {}
results: List[pruef_ergebnis] = []
return source_dict, results
@@ -149,24 +323,29 @@ class DataGrabber:
partial_results: List[pruef_ergebnis],
) -> pruef_ergebnis:
"""
Aggregiert Einzelprüfungen zu einem Gesamt``pruef_ergebnis``.
Aggregiert Einzelprüfungen zu einem Gesamt-``pruef_ergebnis``.
**Keine UIInteraktion.**
**Keine UI-Interaktion.**
"""
if source_dict:
rows = source_dict.get("rows") if isinstance(source_dict, dict) else None
if rows:
return pruef_ergebnis(
ok=True,
meldung="Quelle erfolgreich geprüft",
aktion="ok",
kontext={
"source": source,
"valid_entries": sum(len(v) for v in source_dict.values()),
"valid_entries": len(rows),
},
)
# Wenn die Linkliste zwar gelesen wurde, aber keine gültigen Zeilen verfügbar sind, geben wir spezifischere Infos zurück.
return pruef_ergebnis(
ok=False,
meldung="Keine gültigen Einträge in der Quelle gefunden",
aktion="read_error",
kontext={"source": source},
meldung="Keine validen Einträge in der Linkliste gefunden",
aktion="keine_validen_eintraege",
kontext={
"source": source,
"eintraege_gesamt": len(source_dict.get("rows", [])),
},
)
+62 -4
View File
@@ -6,11 +6,11 @@ der Anforderungen 1-2.e (leerer Pfad, fehlende Datei, bestehende Datei).
"""
from pathlib import Path
from typing import Optional
from typing import Optional, Literal
from sn_basis.functions.sys_wrapper import join_path, file_exists
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion
DateiTyp = Literal["excel","datenbank","unbekannt"]
class Dateipruefer:
"""
@@ -46,7 +46,7 @@ class Dateipruefer:
standarddatei: Optional[str] = None,
temporaer_erlaubt: bool = False,
*,
verfahrens_db_modus: bool = True, # 🆕 Verfahrens-DB-spezifische Logik
verfahrens_db_modus: bool = False,
) -> None:
"""
Parameters
@@ -74,10 +74,27 @@ class Dateipruefer:
# ------------------------------------------------------------------
# Hilfsfunktionen
# ------------------------------------------------------------------
def erkenne_dateityp(self, pfad: Path) -> DateiTyp:
"""
Erkennt den Dateityp anhand der Endung.
"""
suffix = pfad.suffix.lower()
if suffix == ".xlsx":
return "excel"
if suffix in (".gpkg", ".sqlite"):
return "datenbank"
return "unbekannt"
def _pfad(self, relativer_pfad: str) -> Path:
"""Erzeugt OS-unabhängigen Pfad relativ zum Basisverzeichnis."""
if relativer_pfad.startswith(("/", "\\")):
return Path(relativer_pfad)
return join_path(self.basis_pfad, relativer_pfad)
def _ist_leer(self) -> bool:
"""
Prüft robust, ob Eingabe als „leer" zu behandeln ist.
@@ -134,9 +151,50 @@ class Dateipruefer:
# 2. Pfad normalisieren
pfad = self._pfad(self.pfad.strip())
#Excel-dateien erkennen
dateityp = self.erkenne_dateityp(pfad)
if not self.verfahrens_db_modus:
if not file_exists(pfad):
return pruef_ergebnis(
ok=False,
meldung=f"Datei '{self.pfad}' wurde nicht gefunden.",
aktion="datei_nicht_gefunden",
kontext=pfad,
)
return pruef_ergebnis(
ok=True,
meldung="Datei ist gültig.",
aktion="ok",
kontext=pfad,
)
if dateityp == "excel":
if not file_exists(pfad):
return pruef_ergebnis(
ok=False,
meldung=f"Excel-Datei '{self.pfad}' wurde nicht gefunden.",
aktion="datei_nicht_gefunden",
kontext=pfad,
)
return pruef_ergebnis(
ok=True,
meldung="Excel-Datei ist gültig.",
aktion="ok",
kontext=pfad,
)
if dateityp != "datenbank":
return pruef_ergebnis(
ok=False,
meldung=f"Der Pfad '{self.pfad}' ist kein unterstützter Dateityp.",
aktion="unbekannter_dateityp",
kontext=pfad,
)
# 🆕 2.c: Ungültiger GPKG-Pfad?
if not self.verfahrens_db_modus or not self._ist_gueltiger_gpkg_pfad(pfad):
if not self._ist_gueltiger_gpkg_pfad(pfad):
return pruef_ergebnis(
ok=False,
meldung=f"Der Pfad '{self.pfad}' ist kein gültiger GPKG-Pfad.",
+236 -49
View File
@@ -17,10 +17,11 @@ Designprinzipien
- Die Methode ist pdoc-kompatibel dokumentiert und bewusst einfach gehalten.
"""
from typing import Any, Dict, List, Mapping, Optional, Tuple
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse
import json
import time
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
from sn_basis.functions import qgiscore_wrapper as qgiscore
@@ -59,6 +60,7 @@ class Datenabruf:
verfahrensgebiet_layer: Any,
speicherort: str,
pruef_ergebnisse: Optional[List[Any]] = None,
progress: Optional[Any] = None,
) -> Tuple[Dict[str, Any], List[Any]]:
"""
Ruft für alle Zeilen in ``result_dict["rows"]`` die Fachdaten ab und
@@ -82,6 +84,10 @@ class Datenabruf:
# 1) Räumliche Filtergeometrie bestimmen (BBox oder None)
bbox_geom = self._determine_spatial_filter(raumfilter, verfahrensgebiet_layer)
filter_crs_authid = None
if isinstance(bbox_geom, dict):
raw_crs = bbox_geom.get("crs_authid")
filter_crs_authid = str(raw_crs) if raw_crs else None
# Globale Logs über alle Dienste hinweg
log_geladen: Dict[str, int] = {}
@@ -90,7 +96,20 @@ class Datenabruf:
log_ausserhalb: Dict[str, int] = {}
# 2) Über alle Zeilen iterieren
for row in rows:
total_rows = len(rows)
for idx, row in enumerate(rows, start=1):
if progress is not None:
progress.set_label(f"Datenabruf {idx}/{total_rows}")
if progress.is_canceled():
pe_cancel = pruef_ergebnis(
ok=False,
meldung="Datenabruf durch Benutzer abgebrochen",
aktion="abbruch",
kontext={"schritt": idx},
)
processed_results.append(self.pruefmanager.verarbeite(pe_cancel))
break
ident = row.get("ident")
link = row.get("Link")
provider = row.get("Provider")
@@ -115,7 +134,16 @@ class Datenabruf:
url = self._build_provider_url(link=link, provider=str(provider), bbox_geom=bbox_geom if use_bbox else None)
# 2b) Fachdaten abrufen
features, error_msg = self._fetch_features(url=url, provider=str(provider))
features, error_msg = self._fetch_features(
url=url,
provider=str(provider),
cancel_callback=(progress.is_canceled if progress is not None else None),
)
if progress is not None:
if hasattr(progress, "set_value"):
progress.set_value(idx)
# 2c) Logs und Aggregation
if error_msg:
@@ -207,7 +235,18 @@ class Datenabruf:
return None
if raumfilter == "Verfahrensgebiet":
return qgiscore.get_layer_extent(verfahrensgebiet_layer)
extent = qgiscore.get_layer_extent(verfahrensgebiet_layer)
if extent is None:
return None
crs_authid = None
try:
if hasattr(verfahrensgebiet_layer, "crs") and callable(getattr(verfahrensgebiet_layer, "crs")):
crs = verfahrensgebiet_layer.crs()
if crs is not None and hasattr(crs, "authid") and callable(getattr(crs, "authid")):
crs_authid = crs.authid()
except Exception:
crs_authid = None
return {"extent": extent, "crs_authid": crs_authid}
if raumfilter == "Pufferlayer":
buffer_layer = qgiscore.create_buffer_layer(
@@ -216,8 +255,18 @@ class Datenabruf:
layer_name="Verfahrensgebiet_Puffer_1km",
)
if buffer_layer is not None:
qgisui.add_layer_to_project(buffer_layer)
return qgiscore.get_layer_extent(buffer_layer)
extent = qgiscore.get_layer_extent(buffer_layer)
if extent is None:
return None
crs_authid = None
try:
if hasattr(buffer_layer, "crs") and callable(getattr(buffer_layer, "crs")):
crs = buffer_layer.crs()
if crs is not None and hasattr(crs, "authid") and callable(getattr(crs, "authid")):
crs_authid = crs.authid()
except Exception:
crs_authid = None
return {"extent": extent, "crs_authid": crs_authid}
return None
@@ -233,60 +282,130 @@ class Datenabruf:
Erwartet: provider ist gesetzt (z. B. "WFS", "REST", "OGR", "WMS").
"""
provider_norm = (provider or "").upper()
base_link = link or ""
base_link = (link or "").strip()
if base_link.lower().startswith("url="):
base_link = base_link[4:].strip()
# WMS: niemals BBOX anhängen
if provider_norm == "WFS" and base_link.count("?") > 1:
first, rest = base_link.split("?", 1)
base_link = f"{first}?{rest.replace('?', '&')}"
extent_obj = bbox_geom
crs_authid: Optional[str] = None
if isinstance(bbox_geom, dict):
extent_obj = bbox_geom.get("extent")
raw_crs = bbox_geom.get("crs_authid")
crs_authid = str(raw_crs) if raw_crs else None
# WMS: unverändert durchreichen
if provider_norm == "WMS":
return base_link
if bbox_geom is None:
return base_link
# Versuche bbox-String zu erzeugen (nutzt qgiscore.extent_to_bbox_string wenn vorhanden)
# Versuche bbox-String zu erzeugen (falls Raumfilter aktiv)
bbox_str: Optional[str] = None
try:
extent_to_bbox = getattr(__import__("sn_basis.functions.qgiscore_wrapper", fromlist=["qgiscore_wrapper"]), "extent_to_bbox_string", None)
if callable(extent_to_bbox):
bbox_str = extent_to_bbox(bbox_geom)
else:
# Fallback: einfache xmin/ymin/xmax/ymax-Extraktion (duck-typing)
if hasattr(bbox_geom, "xmin") and callable(getattr(bbox_geom, "xmin")):
bbox_str = f"{bbox_geom.xmin()},{bbox_geom.ymin()},{bbox_geom.xmax()},{bbox_geom.ymax()}"
elif isinstance(bbox_geom, (tuple, list)) and len(bbox_geom) == 4:
bbox_str = f"{bbox_geom[0]},{bbox_geom[1]},{bbox_geom[2]},{bbox_geom[3]}"
if extent_obj is not None:
try:
extent_to_bbox = getattr(__import__("sn_basis.functions.qgiscore_wrapper", fromlist=["qgiscore_wrapper"]), "extent_to_bbox_string", None)
if callable(extent_to_bbox):
bbox_str = extent_to_bbox(extent_obj)
else:
bbox_str = str(bbox_geom)
except Exception:
bbox_str = None
if not bbox_str:
return base_link
# Fallback: einfache xmin/ymin/xmax/ymax-Extraktion (duck-typing)
if 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]}"
else:
bbox_str = str(extent_obj)
except Exception:
bbox_str = None
parsed = urlparse(base_link)
query_params = dict(parse_qsl(parsed.query, keep_blank_values=True))
if provider_norm == "WFS":
query_params.setdefault("BBOX", bbox_str)
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)
if crs_authid:
query_params.setdefault("SRSNAME", crs_authid)
new_query = urlencode(query_params, doseq=True)
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
if provider_norm in ("REST", "ARCGIS", "ARCGISFEATURESERVER", "ARCGIS_FEATURESERVER"):
query_params.setdefault("geometry", bbox_str)
query_params.setdefault("geometryType", "esriGeometryEnvelope")
query_params.setdefault("spatialRel", "esriSpatialRelIntersects")
# ArcGIS FeatureServer erwartet i.d.R. den /query-Endpunkt
rest_base = base_link.rstrip("/")
if not rest_base.lower().endswith("/query"):
rest_base = f"{rest_base}/query"
parsed_rest = urlparse(rest_base)
query_params = dict(parse_qsl(parsed_rest.query, keep_blank_values=True))
query_params.setdefault("where", "1=1")
query_params.setdefault("outFields", "*")
query_params.setdefault("returnGeometry", "true")
query_params.setdefault("f", query_params.get("f", "json"))
if bbox_str:
geometry_envelope = None
try:
if hasattr(extent_obj, "xmin") and callable(getattr(extent_obj, "xmin")):
geometry_envelope = {
"xmin": extent_obj.xmin(),
"ymin": extent_obj.ymin(),
"xmax": extent_obj.xmax(),
"ymax": extent_obj.ymax(),
}
elif isinstance(extent_obj, (tuple, list)) and len(extent_obj) == 4:
geometry_envelope = {
"xmin": extent_obj[0],
"ymin": extent_obj[1],
"xmax": extent_obj[2],
"ymax": extent_obj[3],
}
else:
parts = [p.strip() for p in str(bbox_str).split(",")]
if len(parts) == 4:
geometry_envelope = {
"xmin": float(parts[0]),
"ymin": float(parts[1]),
"xmax": float(parts[2]),
"ymax": float(parts[3]),
}
except Exception:
geometry_envelope = None
if geometry_envelope is not None:
query_params.setdefault("geometry", json.dumps(geometry_envelope))
else:
query_params.setdefault("geometry", bbox_str)
query_params.setdefault("geometryType", "esriGeometryEnvelope")
query_params.setdefault("spatialRel", "esriSpatialRelIntersects")
if crs_authid and ":" in crs_authid:
srid = crs_authid.split(":", 1)[1]
if srid.isdigit():
query_params.setdefault("inSR", srid)
query_params.setdefault("outSR", srid)
new_query = urlencode(query_params, doseq=True)
rebuilt = parsed._replace(query=new_query)
rebuilt = parsed_rest._replace(query=new_query)
return urlunparse(rebuilt)
# Default: generischer bbox-Parameter
query_params.setdefault("bbox", bbox_str)
# Default: generischer bbox-Parameter (nur wenn vorhanden)
if bbox_str:
query_params.setdefault("bbox", bbox_str)
new_query = urlencode(query_params, doseq=True)
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
def _fetch_features(self, url: str, provider: str) -> Tuple[List[Any], Optional[str]]:
def _fetch_features(
self,
url: str,
provider: str,
cancel_callback: Optional[Callable[[], bool]] = None,
) -> Tuple[List[Any], Optional[str]]:
"""
Führt den eigentlichen Abruf der Fachdaten durch.
@@ -336,34 +455,100 @@ class Datenabruf:
http_error: Optional[str] = None
# QGIS NetworkAccessManager bevorzugen
_FETCH_TIMEOUT_MS = 30_000 # 30 Sekunden
aborted_or_timed_out = False
attempted_qgis_fetch = False
if callable(cancel_callback) and cancel_callback():
return [], "Abbruch durch Benutzer"
if getattr(qgiscore, "QGIS_AVAILABLE", False) and getattr(qgiscore, "QgsNetworkAccessManager", None) is not None:
attempted_qgis_fetch = True
try:
manager = qgiscore.QgsNetworkAccessManager.instance()
QUrl = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QUrl", None)
QNetworkRequest = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QNetworkRequest", None)
QEventLoop = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QEventLoop", None)
# Netzwerk-Timeout global setzen (QGIS >= 3.6)
if hasattr(manager, "setTimeout"):
manager.setTimeout(_FETCH_TIMEOUT_MS)
_qt = __import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"])
QUrl = getattr(_qt, "QUrl", None)
QNetworkRequest = getattr(_qt, "QNetworkRequest", None)
QEventLoop = getattr(_qt, "QEventLoop", None)
QTimer = getattr(_qt, "QTimer", None)
if QUrl is not None and QNetworkRequest is not None:
req = QNetworkRequest(QUrl(url))
reply = manager.get(req)
if QEventLoop is not None:
loop = QEventLoop()
reply.finished.connect(loop.quit)
loop.exec()
try:
raw = reply.readAll()
data_bytes = bytes(raw) if hasattr(raw, "__bytes__") else raw
response_text = data_bytes.decode("utf-8", errors="replace")
except Exception:
_poll_timer = None
if QTimer is not None:
try:
_poll_timer = QTimer()
_poll_timer.setSingleShot(False)
_poll_timer.timeout.connect(loop.quit)
_poll_timer.start(100)
except Exception:
_poll_timer = None
start_time = time.monotonic()
while True:
if callable(cancel_callback) and cancel_callback():
reply.abort()
http_error = "Abbruch durch Benutzer"
aborted_or_timed_out = True
break
elapsed_ms = int((time.monotonic() - start_time) * 1000)
if elapsed_ms >= _FETCH_TIMEOUT_MS:
reply.abort()
http_error = f"Timeout nach {_FETCH_TIMEOUT_MS // 1000} s: {url}"
aborted_or_timed_out = True
break
if hasattr(reply, "isFinished") and reply.isFinished():
break
loop.exec()
try:
if hasattr(qt, "QCoreApplication") and hasattr(qt.QCoreApplication, "processEvents"):
qt.QCoreApplication.processEvents()
except Exception:
pass
if _poll_timer is not None:
try:
_poll_timer.stop()
except Exception:
pass
if not aborted_or_timed_out:
# Fehler aus Reply auslesen
err_code = None
try:
err_code = reply.error()
except Exception:
pass
if err_code and int(err_code) != 0:
http_error = f"Netzwerkfehler ({err_code}): {reply.errorString()}"
if http_error:
# Timeout oder Netzwerkfehler keinen Body lesen
pass
else:
try:
response_text = reply.text()
raw = reply.readAll()
data_bytes = bytes(raw) if hasattr(raw, "__bytes__") else raw
response_text = data_bytes.decode("utf-8", errors="replace")
except Exception:
response_text = None
try:
response_text = reply.text()
except Exception:
response_text = None
except Exception as exc:
http_error = f"QgsNetworkAccessManager error: {exc}"
response_text = None
# Fallback: requests
if response_text is None:
# Fallback: requests nur wenn kein harter Abbruch/Timeout im QGIS-Request vorlag
if response_text is None and (not attempted_qgis_fetch or not aborted_or_timed_out):
try:
import requests # lokal import, keine harte Abhängigkeit
r = requests.get(url, timeout=30)
@@ -383,6 +568,8 @@ class Datenabruf:
return parsed.get("features", []), None
if isinstance(parsed, dict) and "features" in parsed:
return parsed.get("features", []), None
if prov in ("REST", "ARCGIS", "ARCGISFEATURESERVER", "ARCGIS_FEATURESERVER", "WFS"):
return [], "Antwort enthält keine Feature-Liste"
# Sonst: gib das gesamte JSON als einzelnes Objekt zurück
return [parsed], None
except json.JSONDecodeError:
+40 -1
View File
@@ -1 +1,40 @@
#Datenbankpruefer.py
"""
sn_basis/modules/Datenbankpruefer.py Prüfung von Datenbanken.
Validiert Datenbank-Verbindungen und -Strukturen.
"""
from typing import Optional, Any
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
class Datenbankpruefer:
"""
Prüft Datenbankverbindungen und Datenbankstrukturen auf Gültigkeit.
Gibt pruef_ergebnis für die Weiterverarbeitung durch Pruefmanager zurück.
"""
def __init__(self, pfad: Optional[str] = None) -> None:
"""
Initialisiert den Datenbankprüfer.
Args:
pfad: Optional Pfad zur Datenbankdatei (z.B. .gpkg, .sqlite)
"""
self.pfad = pfad
def pruefe(self) -> pruef_ergebnis:
"""
Prüft die Datenbank auf Gültigkeit und Verbindbarkeit.
Returns:
pruef_ergebnis: Ergebnis der Prüfung mit ok-Flag und Aktion
"""
if not self.pfad:
return pruef_ergebnis(
ok=False,
meldung="Kein Datenbankpfad angegeben.",
aktion="kein_dateipfad",
kontext=None,
)
# Implementierung folgt
return pruef_ergebnis(ok=True, meldung="Datenbank OK", aktion="ok", kontext=self.pfad)
+235 -223
View File
@@ -30,9 +30,13 @@ from __future__ import annotations
from typing import Any, Dict, List, Optional
import os
import json
import re
import datetime
import sqlite3
from sn_basis.functions import qgiscore_wrapper as qgiscore
from sn_basis.functions.os_wrapper import normalize_path, is_absolute_path
from sn_basis.functions.sys_wrapper import get_plugin_root, join_path, file_exists
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
@@ -53,10 +57,97 @@ class Datenschreiber:
def __init__(self, pruefmanager: Any, gpkg_path: Optional[str] = None) -> None:
self.pruefmanager = pruefmanager
self.gpkg_path = gpkg_path
self.gpkg_path = str(gpkg_path) if gpkg_path else None
# ------------------------------------------------------------------ #
# Schreibe Daten
def _resolve_style_path(self, style_path: Optional[str]) -> Optional[str]:
if not style_path:
return None
style_path_str = str(style_path).strip()
if not style_path_str:
return None
if not is_absolute_path(style_path_str):
plugin_root = get_plugin_root()
style_path_str = str(join_path(plugin_root, "sn_plan41", "assets", style_path_str))
style_path_str = str(normalize_path(style_path_str))
return style_path_str if file_exists(style_path_str) else None
def _store_style_in_gpkg(self, layer_name: str, style_path: str, layer: Optional[Any] = None) -> None:
"""Stellt sicher, dass der Stil in der layer_styles-Tabelle der GPKG gespeichert wird."""
try:
with open(style_path, "r", encoding="utf-8") as fh:
style_qml = fh.read()
f_geometry_column = ''
if layer is not None:
try:
if hasattr(layer, 'geometryColumn'):
f_geometry_column = str(layer.geometryColumn())
elif hasattr(layer, 'dataProvider') and hasattr(layer.dataProvider(), 'geometryColumnName'):
f_geometry_column = str(layer.dataProvider().geometryColumnName())
except Exception:
f_geometry_column = ''
with sqlite3.connect(self.gpkg_path) as conn:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS layer_styles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
f_table_catalog TEXT,
f_table_schema TEXT,
f_table_name TEXT NOT NULL,
f_geometry_column TEXT,
styleName TEXT,
styleQML TEXT,
styleSLD TEXT,
useAsDefault BOOLEAN,
description TEXT,
owner TEXT,
ui TEXT,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP
)
"""
)
# Das aktuelle QGIS-Style-Verhalten: bestehenden Style für denselben Layer nicht löschen (nur appenden)
# Wir wollen aber Default-Style setzen: alte Default-Styles entfernen.
cur.execute(
"UPDATE layer_styles SET useAsDefault = 0 WHERE f_table_name = ?",
(layer_name,),
)
# Fülle die bekannten QGIS-Kolonnen
style_name = os.path.basename(style_path)
cur.execute(
"INSERT INTO layer_styles (f_table_catalog, f_table_schema, f_table_name, f_geometry_column, styleName, styleQML, styleSLD, useAsDefault, description, owner, ui) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
'',
'',
layer_name,
f_geometry_column,
style_name,
style_qml,
None,
1,
'',
'',
'',
),
)
conn.commit()
except Exception as exc:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Speichern des Layer-Stils in GPKG: {exc}",
aktion="style_gpkg_speichern_fehlgeschlagen",
kontext={"layer_name": layer_name, "style_path": style_path},
)
)
# ------------------------------------------------------------------ #
def schreibe_Daten(
self,
@@ -65,192 +156,93 @@ class Datenschreiber:
speicherort: str,
) -> List[Dict[str, Any]]:
"""
Schreibt die abgerufenen Daten in die Zieldatenbank/Dateien.
Schreibt die übergebenen Layer in die Ziel-GPKG.
Ablauf
------
Für jede Zeile (ident) in ``daten_dict["daten"]``:
1. Bestimme Ziel-Layername (z. B. Thema oder ident).
2. Prüfe, ob ein Layer mit diesem Namen bereits existiert (Wrapper).
3. Falls vorhanden, frage den Benutzer (Überschreiben / Anhängen / Abbrechen)
über die zentrale Pruefmanager-Methode `ask_overwrite_append_cancel`.
4. Führe die gewählte Operation aus oder schreibe den Layer, wenn er noch nicht existiert.
5. Schreibe ggf. den Stil in die GPKG und setze ihn als Vorgabe.
6. Sammle und gib eine Liste der angelegten/geänderten Layer zurück.
Returns
-------
List[Dict[str, Any]]
Liste von Dicts mit Informationen zu jedem angelegten/geänderten Layer.
Erwartung:
- daten_dict["daten"] enthält Einträge der Form:
ident -> {"layer": QgsVectorLayer}
- self.gpkg_path ist ein str
"""
if not speicherort:
raise ValueError("Ein gültiger Speicherort (speicherort) muss übergeben werden.")
# Setze gpkg_path falls noch nicht vorhanden
# gpkg_path einmalig setzen / normalisieren
if not self.gpkg_path:
self.gpkg_path = speicherort
self.gpkg_path = str(speicherort)
results: List[Dict[str, Any]] = []
daten_map: Dict[str, List[Any]] = daten_dict.get("daten", {})
daten_map: Dict[str, Any] = daten_dict.get("daten", {})
# Iteriere über alle Einträge
for ident, features in daten_map.items():
# Thema/Name ableiten (falls vorhanden in processed_results oder ident)
for ident, entry in daten_map.items():
layer = None
style_path = None
# -----------------------------
# Layer extrahieren
# -----------------------------
if isinstance(entry, dict):
layer = entry.get("layer")
style_path = self._resolve_style_path(entry.get("style_path"))
if layer is None or not hasattr(layer, "isValid") or not layer.isValid():
pe_err = pruef_ergebnis(
ok=False,
meldung=f"Ungültiger Layer für {ident}",
aktion="save_exception",
kontext={"ident": ident},
)
self.pruefmanager.verarbeite(pe_err)
continue
# -----------------------------
# Layername bestimmen
# -----------------------------
thema = None
for pe in processed_results:
try:
kontext = getattr(pe, "kontext", None) or {}
if kontext and kontext.get("ident") == ident:
if kontext.get("ident") == ident:
thema = kontext.get("thema")
break
except Exception:
continue
if not thema:
thema = str(ident)
layer_name = thema
layer_name_raw = thema or str(ident)
layer_name = re.sub(r"[^A-Za-z0-9_ÄäÖöÜüß]+", "_", layer_name_raw).strip("_")
if not layer_name:
layer_name = f"layer_{ident}"
# Prüfe, ob Layer bereits existiert in der Ziel-GPKG
layer_exists = False
try:
layer_exists_fn = getattr(qgiscore, "layer_exists_in_gpkg", None)
if callable(layer_exists_fn):
layer_exists = layer_exists_fn(self.gpkg_path, layer_name)
else:
# Fallback: QGIS-Fallback-Check via QgsVectorLayer
if getattr(qgiscore, "QgsVectorLayer", None) is not None and qgiscore.QGIS_AVAILABLE:
uri = f"{self.gpkg_path}|layername={layer_name}"
layer = qgiscore.QgsVectorLayer(uri, layer_name, "ogr")
layer_exists = bool(layer and getattr(layer, "isValid", lambda: False)())
except Exception:
layer_exists = False
operation = "created"
if layer_exists:
# Zentrale Nutzerabfrage über Pruefmanager
# Erwartet Rückgabe: "overwrite" | "append" | "cancel"
try:
user_choice = self.pruefmanager.ask_overwrite_append_cancel(layer_name)
except Exception:
# Fallback: overwrite, falls Pruefmanager nicht verfügbar
user_choice = "overwrite"
if user_choice == "cancel":
operation = "skipped"
results.append({
"ident": ident,
"thema": thema,
"operation": operation,
"layer_path": f"{self.gpkg_path}|layername={layer_name}",
"feature_count": 0,
})
continue
if user_choice == "overwrite":
write_err = self._write_layer_to_gpkg(layer_name, features, mode="overwrite")
if write_err:
pe_err = pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Überschreiben von {layer_name}: {write_err}",
aktion="save_exception",
kontext={"ident": ident, "thema": thema, "error": write_err},
)
self.pruefmanager.verarbeite(pe_err)
operation = "skipped"
results.append({
"ident": ident,
"thema": thema,
"operation": operation,
"layer_path": f"{self.gpkg_path}|layername={layer_name}",
"feature_count": 0,
})
continue
else:
operation = "overwritten"
elif user_choice == "append":
write_err = self._write_layer_to_gpkg(layer_name, features, mode="append")
if write_err:
pe_err = pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Anhängen an {layer_name}: {write_err}",
aktion="save_exception",
kontext={"ident": ident, "thema": thema, "error": write_err},
)
self.pruefmanager.verarbeite(pe_err)
operation = "skipped"
results.append({
"ident": ident,
"thema": thema,
"operation": operation,
"layer_path": f"{self.gpkg_path}|layername={layer_name}",
"feature_count": 0,
})
continue
else:
operation = "appended"
else:
# Layer existiert nicht -> neu anlegen
write_err = self._write_layer_to_gpkg(layer_name, features, mode="create")
if write_err:
pe_err = pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Erstellen von {layer_name}: {write_err}",
aktion="save_exception",
kontext={"ident": ident, "thema": thema, "error": write_err},
)
self.pruefmanager.verarbeite(pe_err)
operation = "skipped"
results.append({
"ident": ident,
"thema": thema,
"operation": operation,
"layer_path": f"{self.gpkg_path}|layername={layer_name}",
"feature_count": 0,
})
continue
else:
operation = "created"
# Stilbehandlung (falls in processed_results referenziert)
style_written = False
style_path = None
for pe in processed_results:
try:
kontext = getattr(pe, "kontext", None) or {}
if kontext and kontext.get("ident") == ident:
style_path = kontext.get("stildatei") or kontext.get("Stildatei")
break
except Exception:
continue
# Layer in GPKG schreiben
err_msg = self._write_layer_to_gpkg(layer_name=layer_name, layer=layer)
if err_msg is not None:
pe_err = pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Schreiben des Layers {layer_name}: {err_msg}",
aktion="save_exception",
kontext={"ident": ident, "layer_name": layer_name},
)
self.pruefmanager.verarbeite(pe_err)
continue
# Wenn der Stil vorhanden und valide ist, als Default in GPKG-Style-Tabelle ablegen
if style_path:
if not os.path.isabs(style_path):
base_dir = os.path.dirname(__file__)
style_path = os.path.join(base_dir, style_path)
write_style_fn = getattr(qgiscore, "write_style_to_gpkg", None)
if callable(write_style_fn):
try:
write_style_fn(self.gpkg_path, style_path, layer_name)
style_written = True
except Exception:
style_written = False
feature_count = len(features) if isinstance(features, list) else 0
self._store_style_in_gpkg(layer_name, style_path, layer)
# Erfolgsfall: Info für lade_Layer sammeln
layer_path = f"{self.gpkg_path}|layername={layer_name}"
results.append({
"layer_path": layer_path,
"thema": layer_name,
"ident": ident,
"thema": thema,
"operation": operation,
"layer_path": f"{self.gpkg_path}|layername={layer_name}",
"feature_count": feature_count,
"style_written": style_written,
"style_path": style_path,
})
return results
# -----------------------------
# ------------------------------------------------------------------ #
# Lade Layer ins Projekt
# ------------------------------------------------------------------ #
@@ -288,18 +280,33 @@ class Datenschreiber:
self.pruefmanager.verarbeite(pe_err)
continue
try:
apply_style_fn = getattr(qgiscore, "apply_default_style_from_gpkg", None)
if callable(apply_style_fn):
apply_style_fn(self.gpkg_path, layer)
except Exception:
pe_warn = pruef_ergebnis(
ok=True,
meldung=f"Style konnte für {thema} nicht automatisch angewendet werden",
aktion="stil_not_implemented",
kontext={"thema": thema},
)
self.pruefmanager.verarbeite(pe_warn)
style_path = info.get("style_path")
resolved_style_path = self._resolve_style_path(style_path)
if resolved_style_path:
try:
layer.loadNamedStyle(resolved_style_path)
layer.triggerRepaint()
except Exception as exc:
pe_warn = pruef_ergebnis(
ok=True,
meldung=f"Style konnte für {thema} nicht geladen werden: {exc}",
aktion="stil_laden_fehlgeschlagen",
kontext={"thema": thema, "style_path": resolved_style_path},
)
self.pruefmanager.verarbeite(pe_warn)
else:
try:
apply_style_fn = getattr(qgiscore, "apply_default_style_from_gpkg", None)
if callable(apply_style_fn):
apply_style_fn(self.gpkg_path, layer)
except Exception:
pe_warn = pruef_ergebnis(
ok=True,
meldung=f"Style konnte für {thema} nicht automatisch angewendet werden",
aktion="stil_not_implemented",
kontext={"thema": thema},
)
self.pruefmanager.verarbeite(pe_warn)
try:
# qgisui wrapper wird hier nicht direkt für die Abfrage verwendet;
@@ -374,62 +381,67 @@ class Datenschreiber:
# ------------------------------------------------------------------ #
# Hilfsfunktionen intern
# ------------------------------------------------------------------ #
def _write_layer_to_gpkg(self, layer_name: str, features: List[Any], mode: str = "create") -> Optional[str]:
def _write_layer_to_gpkg(
self,
layer_name: str,
layer: Any,
) -> Optional[str]:
"""
Interne Hilfsfunktion zum Schreiben eines Layers in das GPKG.
Schreibt einen QgsVectorLayer in die Ziel-GPKG.
Erwartete qgiscore-Funktion:
qgiscore.write_features_to_gpkg(gpkg_path, layer_name, features, mode)
Voraussetzungen:
- self.gpkg_path ist ein str
- layer ist ein gültiger QgsVectorLayer
"""
write_fn = getattr(qgiscore, "write_features_to_gpkg", None)
if callable(write_fn):
try:
write_fn(self.gpkg_path, layer_name, features, mode)
return None
except Exception as exc:
return str(exc)
# Fallback: Verwende QgsVectorFileWriter, falls QGIS verfügbar
if getattr(qgiscore, "QGIS_AVAILABLE", False) and getattr(qgiscore, "QgsVectorFileWriter", None) is not None:
try:
# Minimaler Fallback: erwarte, dass 'features' eine Liste von QgsFeature ist
if not features:
# Erstelle leeren Layer-Eintrag (GPKG erlaubt leere Layer)
# Hier vereinfachen wir: writeAsVectorFormatV3 benötigt ein Layer-Objekt.
return None
if layer is None or not hasattr(layer, "isValid") or not layer.isValid():
return "Ungültiger Layer zum Schreiben übergeben"
# Versuche, ein Memory-Layer aus dem ersten Feature zu ermitteln
first = features[0]
mem_layer = None
if hasattr(first, "fields") and hasattr(first, "geometry"):
# Wenn Features QgsFeature sind, versuchen wir, das zugehörige Layer zu nutzen
try:
mem_layer = first.layer() if hasattr(first, "layer") else None
except Exception:
mem_layer = None
try:
opts = qgiscore.QgsVectorFileWriter.SaveVectorOptions()
opts.driverName = "GPKG"
opts.layerName = layer_name
opts.fileEncoding = "UTF-8"
if mem_layer is None:
return "Keine Feld-/Geometrie-Informationen zum Schreiben vorhanden"
# Style in der GPKG speichern, wenn möglich
if hasattr(opts, "symbologyExport"):
try:
# QGIS: SymbologyExport-Wert z.B. QgsVectorFileWriter.SaveVectorOptions.Symbology
saveOpts = qgiscore.QgsVectorFileWriter.SaveVectorOptions
sym_val = getattr(saveOpts, "Symbology", None)
if sym_val is None:
sym_val = getattr(saveOpts, "SymbologyExport", None)
if sym_val is not None:
opts.symbologyExport = sym_val
except Exception:
pass
opts = qgiscore.QgsVectorFileWriter.SaveVectorOptions()
opts.driverName = "GPKG"
opts.layerName = layer_name
opts.fileEncoding = "UTF-8"
if mode == "overwrite":
opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteFile
else:
opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteLayer
# Datei existiert → Layer überschreiben
# Datei existiert nicht → neue GPKG anlegen
if not os.path.exists(self.gpkg_path):
opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteFile
else:
opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteLayer
err = qgiscore.QgsVectorFileWriter.writeAsVectorFormatV3(
mem_layer,
self.gpkg_path,
qgiscore.QgsProject.instance().transformContext(),
opts
)
if err != qgiscore.QgsVectorFileWriter.NoError:
return f"Fehler beim Schreiben (Code {err})"
return None
except Exception as exc:
return str(exc)
err = qgiscore.QgsVectorFileWriter.writeAsVectorFormatV3(
layer,
self.gpkg_path,
qgiscore.QgsProject.instance().transformContext(),
opts,
)
return "Keine Schreib-Funktion verfügbar (Wrapper nicht implementiert)"
# QGIS ≥3 liefert ein Tupel: (error_code, error_message, new_filename, new_layer_name)
if isinstance(err, tuple):
error_code = err[0]
error_msg = err[1] if len(err) > 1 else ""
else:
error_code = err
error_msg = ""
if error_code != qgiscore.QgsVectorFileWriter.NoError:
return f"Fehler beim Schreiben (Code {error_code}, msg='{error_msg}')"
return None
except Exception as exc:
return str(exc)
+395
View File
@@ -0,0 +1,395 @@
"""sn_basis/modules/LayerLoader.py
Kapselt Layer-Erstellung, Raumfilter und Stil-Logik.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
import time
from sn_basis.functions.os_wrapper import normalize_path, is_absolute_path
from sn_basis.functions.qgiscore_wrapper import (
QgsVectorLayer,
QgsRasterLayer,
QgsFeatureRequest,
QgsProject,
QgsNetworkAccessManager,
QgsCoordinateTransform,
)
from sn_basis.functions.sys_wrapper import get_plugin_root, join_path, file_exists
from sn_basis.modules.stilpruefer import Stilpruefer
from sn_basis.modules.layerpruefer import Layerpruefer
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
from sn_basis.functions import qt_wrapper as qt
class LayerLoader:
"""Lädt und filtert Layer aus Dienst-/Datenquellen."""
def __init__(
self,
pruefmanager: Any,
stil_pruefer: Optional[Stilpruefer] = None,
layer_pruefer: Optional[Layerpruefer] = None,
) -> None:
self.pruefmanager = pruefmanager
self.stil_pruefer = stil_pruefer or Stilpruefer()
self.layer_pruefer = layer_pruefer or Layerpruefer()
_LAYER_TIMEOUT_MS = 30_000 # 30 Sekunden
def _was_canceled(self, cancel_callback: Optional[Any]) -> bool:
if not callable(cancel_callback):
return False
try:
return bool(cancel_callback())
except Exception:
return False
def _process_events(self) -> None:
try:
if hasattr(qt, "QCoreApplication") and hasattr(qt.QCoreApplication, "processEvents"):
qt.QCoreApplication.processEvents()
except Exception:
pass
def _transform_geometry_to_layer_crs(self, geometry: Any, source_layer: Any, target_layer: Any) -> Any:
if geometry is None or source_layer is None or target_layer is None:
return geometry
if QgsCoordinateTransform is None or QgsProject is None:
return geometry
try:
source_crs = source_layer.crs() if hasattr(source_layer, "crs") else None
target_crs = target_layer.crs() if hasattr(target_layer, "crs") else None
if source_crs is None or target_crs is None:
return geometry
source_authid = source_crs.authid() if hasattr(source_crs, "authid") else None
target_authid = target_crs.authid() if hasattr(target_crs, "authid") else None
if source_authid and target_authid and source_authid == target_authid:
return geometry
ct = QgsCoordinateTransform(source_crs, target_crs, QgsProject.instance())
if hasattr(geometry, "clone") and callable(getattr(geometry, "clone")):
geom_copy = geometry.clone()
else:
geom_copy = geometry
geom_copy.transform(ct)
return geom_copy
except Exception:
return geometry
def _transform_extent_to_layer_crs(self, extent: Any, source_layer: Any, target_layer: Any) -> Any:
if extent is None or source_layer is None or target_layer is None:
return extent
if QgsCoordinateTransform is None or QgsProject is None:
return extent
try:
source_crs = source_layer.crs() if hasattr(source_layer, "crs") else None
target_crs = target_layer.crs() if hasattr(target_layer, "crs") else None
if source_crs is None or target_crs is None:
return extent
source_authid = source_crs.authid() if hasattr(source_crs, "authid") else None
target_authid = target_crs.authid() if hasattr(target_crs, "authid") else None
if source_authid and target_authid and source_authid == target_authid:
return extent
ct = QgsCoordinateTransform(source_crs, target_crs, QgsProject.instance())
if hasattr(ct, "transformBoundingBox"):
return ct.transformBoundingBox(extent)
return extent
except Exception:
return extent
def create_layer(self, provider: str, link: str, thema: str) -> Optional[QgsVectorLayer]:
provider_lower = provider.lower() if provider else ""
layer = None
# Netzwerk-Timeout für alle netzwerkbasierten Provider setzen
if provider_lower in ("wfs", "wms", "rest"):
try:
nam = QgsNetworkAccessManager.instance()
if hasattr(nam, "setTimeout"):
nam.setTimeout(self._LAYER_TIMEOUT_MS)
except Exception:
pass
try:
if provider_lower == "wfs":
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}"
layer = QgsRasterLayer(uri, thema, "wms")
elif provider_lower in ("ogr", "gpkg", "shp", "geojson"):
layer = QgsVectorLayer(link, thema, "ogr")
elif provider_lower == "rest":
rest_link = link.strip()
if rest_link.lower().endswith("/featureserver"):
rest_link = rest_link.rstrip("/") + "/0"
uri = rest_link if rest_link.lower().startswith("url=") else f"url={rest_link}"
layer = QgsVectorLayer(uri, thema, "arcgisfeatureserver")
else:
layer = QgsVectorLayer(link, thema, "ogr")
except Exception as exc:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Erstellen des Layers {thema}: {exc}",
aktion="layer_nicht_verfuegbar",
kontext={"provider": provider, "link": link},
)
)
return None
if not layer or not layer.isValid():
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Layer {thema} (Provider={provider}) konnte nicht geladen werden."
,aktion="layer_nicht_verfuegbar",
kontext={"provider": provider, "link": link},
)
)
return None
return layer
def apply_style(self, layer: QgsVectorLayer, style_path: Optional[str]) -> None:
if not style_path or layer is None or not layer.isValid():
return
if not style_path.strip():
return
if not is_absolute_path(style_path):
plugin_root = get_plugin_root()
style_path = str(join_path(plugin_root, "sn_plan41", "assets", style_path))
# normalize path for consistency
style_path = str(normalize_path(style_path))
# Debug: welche Stil-Datei wird geprüft?
print(f"[LayerLoader] Überprüfe Stildatei: '{style_path}'")
if file_exists(style_path):
try:
layer.loadNamedStyle(style_path)
layer.triggerRepaint()
except Exception as exc:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Stil-Laden für {layer.name()}: {exc}",
aktion="stil_laden_fehlgeschlagen",
kontext={"thema": layer.name(), "style_path": style_path},
)
)
else:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=True,
meldung=f"Stildatei nicht gefunden (optional): {style_path}",
aktion="stil_nicht_gefunden",
kontext={"thema": layer.name(), "style_path": style_path},
)
)
def filter_by_extent(self, layer: QgsVectorLayer, extent, cancel_callback: Optional[Any] = None, source_layer: Optional[Any] = None) -> Optional[QgsVectorLayer]:
"""Beschneidet <layer> auf die rechteckige Ausdehnung <extent>.
Diese Methode verwendet einen einfachen BBOX-Filter. Für komplexere
Raumeinschränkungen (z.B. Verfahrensgebiet) sollte stattdessen
:meth:`filter_by_layer` verwendet werden, da dort echte Geometrie-Tests
stattfinden.
"""
if not layer or not layer.isValid() or extent is None:
return layer
if layer.type() != QgsVectorLayer.VectorLayer:
return layer
extent_for_layer = self._transform_extent_to_layer_crs(extent, source_layer, layer)
request = QgsFeatureRequest().setFilterRect(extent_for_layer)
if hasattr(request, "setTimeout"):
try:
request.setTimeout(self._LAYER_TIMEOUT_MS)
except Exception:
pass
start = time.monotonic()
features: List[Any] = []
try:
for feat in layer.getFeatures(request):
if self._was_canceled(cancel_callback):
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Abbruch beim Raumfilter (BBOX) für {layer.name()}",
aktion="needs_user_action",
kontext={"thema": layer.name()},
)
)
return None
elapsed_ms = int((time.monotonic() - start) * 1000)
if elapsed_ms >= self._LAYER_TIMEOUT_MS:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Timeout beim Raumfilter (BBOX) für {layer.name()} nach {self._LAYER_TIMEOUT_MS // 1000}s",
aktion="url_nicht_erreichbar",
kontext={"thema": layer.name(), "timeout_s": self._LAYER_TIMEOUT_MS // 1000},
)
)
return None
features.append(feat)
if len(features) % 100 == 0:
self._process_events()
except Exception as exc:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Lesen der Features für {layer.name()}: {exc}",
aktion="layer_nicht_verfuegbar",
kontext={"thema": layer.name()},
)
)
return None
if not features:
return None
geom_type_map = {0: "Point", 1: "LineString", 2: "Polygon"}
geom_type = geom_type_map.get(layer.geometryType(), "Polygon")
uri = f"{geom_type}?crs={layer.crs().authid()}"
filtered_layer = QgsVectorLayer(uri, f"{layer.name()}_bbox", "memory")
if not filtered_layer or not filtered_layer.isValid():
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Erzeugen des Filter-Layers für {layer.name()}",
aktion="filterlayer_nicht_erzeugt",
kontext={"thema": layer.name()},
)
)
return None
provider = filtered_layer.dataProvider()
provider.addAttributes(layer.fields())
filtered_layer.updateFields()
provider.addFeatures(features)
filtered_layer.updateExtents()
return filtered_layer
def filter_by_layer(self, layer: QgsVectorLayer, filter_layer: QgsVectorLayer, cancel_callback: Optional[Any] = None) -> Optional[QgsVectorLayer]:
"""Beschneidet <layer> auf die tatsächliche Geometrie des
<filter_layer>.
Diese Methode wird z.B. für das Verfahrensgebiet verwendet, damit nicht
die gesamte Bounding-Box, sondern nur die echten Flächen als Raumfilter
gelten. Wenn der Filter-Layer mehrere Features enthält, werden deren
Geometrien zu einem Multi-Geom vereinigt.
"""
if not layer or not layer.isValid() or not filter_layer or not filter_layer.isValid():
return layer
if layer.type() != QgsVectorLayer.VectorLayer:
return layer
# vereinigte Geometrie aller Features im Filter-Layer
union_geom = None
for f in filter_layer.getFeatures():
try:
geom = self._transform_geometry_to_layer_crs(f.geometry(), filter_layer, layer)
if union_geom is None:
union_geom = geom
else:
union_geom = union_geom.combine(geom)
except Exception:
# bei einem Fehler einfach weiterfahren
continue
if union_geom is None or union_geom.isEmpty():
return None
# nun alle Features aus <layer> nehmen, deren Geometrie sich schneidet
filtered = []
request = QgsFeatureRequest().setFilterRect(union_geom.boundingBox())
if hasattr(request, "setTimeout"):
try:
request.setTimeout(self._LAYER_TIMEOUT_MS)
except Exception:
pass
start = time.monotonic()
for f in layer.getFeatures(request):
if self._was_canceled(cancel_callback):
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Abbruch beim Raumfilter (Geometrie) für {layer.name()}",
aktion="needs_user_action",
kontext={"thema": layer.name()},
)
)
return None
elapsed_ms = int((time.monotonic() - start) * 1000)
if elapsed_ms >= self._LAYER_TIMEOUT_MS:
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Timeout beim Raumfilter (Geometrie) für {layer.name()} nach {self._LAYER_TIMEOUT_MS // 1000}s",
aktion="url_nicht_erreichbar",
kontext={"thema": layer.name(), "timeout_s": self._LAYER_TIMEOUT_MS // 1000},
)
)
return None
try:
if f.geometry() and f.geometry().intersects(union_geom):
filtered.append(f)
except Exception:
continue
if len(filtered) % 100 == 0:
self._process_events()
if not filtered:
return None
geom_type_map = {0: "Point", 1: "LineString", 2: "Polygon"}
geom_type = geom_type_map.get(layer.geometryType(), "Polygon")
uri = f"{geom_type}?crs={layer.crs().authid()}"
filtered_layer = QgsVectorLayer(uri, f"{layer.name()}_filtered", "memory")
if not filtered_layer or not filtered_layer.isValid():
self.pruefmanager.verarbeite(
pruef_ergebnis(
ok=False,
meldung=f"Fehler beim Erzeugen des Filter-Layers für {layer.name()}",
aktion="filterlayer_nicht_erzeugt",
kontext={"thema": layer.name()},
)
)
return None
provider = filtered_layer.dataProvider()
provider.addAttributes(layer.fields())
filtered_layer.updateFields()
provider.addFeatures(filtered)
filtered_layer.updateExtents()
return filtered_layer
def add_to_project(self, layer: QgsVectorLayer) -> None:
if layer and layer.isValid():
QgsProject.instance().addMapLayer(layer)
+107 -31
View File
@@ -5,9 +5,9 @@ sn_basis/modules/Pruefmanager.py
from __future__ import annotations
from typing import Optional, Any
from sn_basis.functions import ask_yes_no, info, warning, error, ask_overwrite_append_cancel_custom
from sn_basis.functions import ask_yes_no, info, warning, error, ask_overwrite_append_cancel_custom, ask_ergaenzen_entfernen_ersetzen_abbrechen
from sn_basis.functions.ly_visibility_wrapper import set_layer_visible
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion
print("DEBUG: Pruefmanager DATEI GELADEN:", __file__)
class Pruefmanager:
def __init__(self, ui_modus: str = "qgis", parent: Optional[Any] = None) -> None:
@@ -56,6 +56,27 @@ class Pruefmanager:
)
info("DataGrabber Zusammenfassung", message)
# ------------------------------------------------------------------
# Allgemeine Nutzerinteraktionen
# ------------------------------------------------------------------
def zeige_hinweis(self, titel: str, meldung: str) -> None:
"""Zeigt eine modale Hinweismeldung mit OK-Button."""
from sn_basis.functions.dialog_wrapper import show_info_dialog
show_info_dialog(titel, meldung, parent=self.parent)
def frage_ja_nein(self, titel: str, meldung: str, default: bool = True) -> bool:
"""Stellt eine Ja/Nein-Frage. Gibt True zurück, wenn der Nutzer Ja wählt."""
if self.ui_modus != "qgis":
return default
return ask_yes_no(titel, meldung, default=default, parent=self.parent)
def frage_text(self, titel: str, meldung: str, default_text: str = "") -> tuple[str, bool]:
"""Fragt einen Textwert ab und gibt Text + OK-Status zurück."""
from sn_basis.functions.dialog_wrapper import ask_text
if self.ui_modus != "qgis":
return default_text, True
return ask_text(titel, meldung, default_text=default_text, parent=self.parent)
# ------------------------------------------------------------------
# VERFAHRENS-DB-spezifische Entscheidungen
# ------------------------------------------------------------------
@@ -125,10 +146,7 @@ class Pruefmanager:
# ------------------------------------------------------------------
def _handle_basic_decision(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis:
"""Basis-Entscheidung für einfache Ja/Nein-Fragen."""
print(f"DEBUG _handle_basic_decision: aktion='{ergebnis.aktion}', ui_modus='{self.ui_modus}'")
if self.ui_modus != "qgis":
print("DEBUG: Nicht QGIS → ergebnis unverändert")
return ergebnis
title_map = {
@@ -142,27 +160,32 @@ class Pruefmanager:
meldung = ergebnis.meldung or ""
try:
print(f"DEBUG ask_yes_no: title='{title}', meldung='{meldung[:50]}...'")
yes = ask_yes_no(title, meldung, default=False, parent=self.parent)
print(f"DEBUG ask_yes_no: yes={yes}")
except Exception as e:
print(f"DEBUG ask_yes_no Exception: {e}")
except Exception:
return ergebnis
if not yes:
print("DEBUG: Nutzer sagte Nein → ok=False")
return ergebnis
# Nutzer sagte Ja
if ergebnis.aktion == "temporaer_erlaubt":
print("DEBUG: temporaer_erlaubt bestätigt → ok=True")
return pruef_ergebnis(
ok=True,
aktion="temporaer_erlaubt",
aktion="temporaer_erzeugen",
kontext=ergebnis.kontext
)
print("DEBUG: Andere Aktion bestätigt → ok=True, aktion='ok'")
if ergebnis.aktion == "layer_unsichtbar":
try:
set_layer_visible(ergebnis.kontext, True)
except Exception:
return ergebnis
return pruef_ergebnis(
ok=True,
aktion="ok",
kontext=ergebnis.kontext,
)
return pruef_ergebnis(
ok=True,
aktion="ok",
@@ -173,46 +196,99 @@ class Pruefmanager:
# Hauptlogik: verarbeite() (KORRIGIERT!)
# ------------------------------------------------------------------
def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis:
print("🔥 verarbeite() START")
print("DEBUG Pruefmanager:", ergebnis.ok, ergebnis.aktion)
print("DEBUG ergebnis.aktion TYPE:", type(ergebnis.aktion), repr(ergebnis.aktion))
# 1. Erfolg → direkt weiter
print("🔍 Schritt 1: Prüfe ergebnis.ok =", ergebnis.ok)
if ergebnis.ok:
print("✅ Schritt 1: ok=True → return")
return ergebnis
# 2. VERFAHRENS-DB: Bestehende Datei
print("🔍 Schritt 2: Prüfe datei_existiert =", ergebnis.aktion == "datei_existiert")
if ergebnis.aktion == "datei_existiert":
print("✅ Schritt 2: _handle_datei_existiert")
return self._handle_datei_existiert(ergebnis)
# 3. Verfahrensgebiet-Editentscheidung
if ergebnis.aktion == "verfahrensgebiet_layer_existiert":
return self._handle_verfahrensgebiet_layer_existiert(ergebnis)
# 3. Basis interaktive Aktionen
print("🔍 Schritt 3: Definiere interactive_actions")
interactive_actions = {
"leereingabe_erlaubt",
"standarddatei_vorschlagen",
"temporaer_erlaubt",
"layer_unsichtbar",
}
print("DEBUG interactive_actions:", repr(interactive_actions))
print("DEBUG ergebnis.aktion in interactive_actions?", ergebnis.aktion in interactive_actions)
if ergebnis.aktion in interactive_actions:
print("✅ Schritt 3: Interaktive Aktion → _handle_basic_decision")
decision = self._handle_basic_decision(ergebnis)
print(f"DEBUG: _handle_basic_decision Ergebnis: ok={decision.ok}, aktion='{decision.aktion}'")
return decision
return self._handle_basic_decision(ergebnis)
# 4. Fehler behandeln
print("❌ Schritt 4: FEHLER BEHANDELN")
self.report_error(
thema=ergebnis.aktion or "pruefung",
meldung=ergebnis.meldung or "",
aktion=ergebnis.aktion,
kontext=ergebnis.kontext,
)
print("🔥 verarbeite() ENDE mit ok=False")
return ergebnis
def _handle_verfahrensgebiet_layer_existiert(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis:
"""Fragt Nutzer, was mit dem bestehenden Verfahrensgebiet-Layer geschehen soll.
Zeigt einen Dialog mit vier Optionen:
- **Ergänzen**: neue Flurstücke hinzufügen
- **Entfernen**: ausgewählte Flurstücke entfernen
- **Ersetzen**: Verfahrensgebiet vollständig ersetzen
- **Abbrechen**: Vorgang beenden
Returns
-------
pruef_ergebnis
ok=True mit aktion ``verfahrensgebiet_ergaenzen`` / ``verfahrensgebiet_entfernen`` /
``verfahrensgebiet_ersetzen``, oder ok=False mit ``verfahrensgebiet_abbrechen``.
"""
if self.ui_modus != "qgis":
return ergebnis
titel = "Verfahrensgebiet bereits vorhanden"
meldung = (
ergebnis.meldung
or "Ein Verfahrensgebiet-Layer existiert bereits.\nWas soll geschehen?"
)
entscheidung = ask_ergaenzen_entfernen_ersetzen_abbrechen(
parent=self.parent,
title=titel,
message=meldung,
)
aktion_map = {
"ergaenzen": "verfahrensgebiet_ergaenzen",
"entfernen": "verfahrensgebiet_entfernen",
"ersetzen": "verfahrensgebiet_ersetzen",
"abbrechen": "verfahrensgebiet_abbrechen",
}
return pruef_ergebnis(
ok=(entscheidung != "abbrechen"),
aktion=aktion_map[entscheidung],
kontext=ergebnis.kontext,
)
def _ask_use_or_replace_pufferlayer(self) -> str:
"""
Fragt den Nutzer, ob ein vorhandener Pufferlayer verwendet
oder ersetzt werden soll.
Returns
-------
str
"verwenden", "ersetzen" oder "abbrechen"
"""
ergebnis = pruef_ergebnis(
ok=False,
aktion="layer_existiert",
meldung="Ein Pufferlayer ist bereits vorhanden.",
)
ergebnis = self.pruefmanager.verarbeite(ergebnis)
if not ergebnis.ok:
return "abbrechen"
return "verwenden" if ergebnis.aktion == "ok" else "ersetzen"
+2 -1
View File
@@ -61,7 +61,8 @@ class Linkpruefer:
aktion="leer",
kontext=None,
)
#evtl. Pfad-Objekte in string umwandeln
eingabe = str(eingabe)
# -----------------------------------------------------
# 1. Fall: URL
# -----------------------------------------------------
File diff suppressed because it is too large Load Diff
+402
View File
@@ -0,0 +1,402 @@
"""
sn_basis/modules/print_layout_baufreigabe.py - Layout-Erstellung fuer Baufreigabe-Vorlagen.
Baufreigabe-Layouts verwenden immer feste DIN A3/A4 Seitengroessen im Querformat,
ein 120 mm breites Kartenschild (statt 180 mm) und erbenerben die Standard-Logik
fuer Hauptkarte, Quellenangabe und Legende vom PrintLayout.
Atlas-Integration erfolgt nach Layouterstellung durch externe Konfiguration
in der PrintLogic.
"""
from __future__ import annotations
from typing import Any
from sn_basis.functions.qt_wrapper import QFont
from sn_basis.functions.qgiscore_wrapper import (
QgsLayoutItem,
QgsLayoutItemLabel,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsLayoutPoint,
QgsLayoutSize,
QgsPrintLayout,
QgsProject,
QgsUnitTypes,
)
from sn_basis.functions.qgisui_wrapper import open_layout_designer
from sn_basis.modules.print_layout import PrintLayout
MM = QgsUnitTypes.LayoutMillimeters
class PrintLayoutBaufreigabe(PrintLayout):
"""Erzeugt QGIS-Einzelblatt-Layouts mit fester DIN A3/A4 Querformat-Groesse und 80mm Kartenschild."""
_FRAME_STROKE_WIDTH_MM = 0.5
_KARTENSCHILD_WIDTH_MM = 80.0
_KARTENSCHILD_HEIGHT_MM = 58.0
_ATLAS_FRAME_MARGIN_MM = 10.0
_GRID_INTERVAL_MAP_UNITS = 500.0
# DIN-Formate: nur A3 und A4 im Querformat
_DIN_A3_LANDSCAPE = (420, 297) # Breite x Hoehe (Querformat)
_DIN_A4_LANDSCAPE = (297, 210) # Querformat
_TEXT_FORMAT_TEMPLATES = {
"Format_1": {"font_family": "Arial", "font_size": 11, "font_style": "normal"},
"Format_2": {"font_family": "Arial", "font_size": 13, "font_style": "bold"},
"Format_3": {"font_family": "Arial", "font_size": 16, "font_style": "bold"},
}
_OBJECT_TEMPLATES = {
"hauptkarte": {"id": "Hauptkarte", "object_name": "Hauptkarte"},
"quellenangabe": {
"id": "Quellenangabe",
"object_name": "Quellenangabe",
"size_mm": (80.0, 100.0),
"format": "Format_1",
},
"textfeld": {
"id": "Textfeld",
"object_name": "Textfeld",
"size_mm": (60.0, 8.0),
"format": "Format_1",
"html_mode": True,
"text": "",
},
"legende": {
"id": "Legende",
"object_name": "Legende",
"size_mm": (80.0, 60.0),
},
}
_QUELLENANGABE_TEXT = (
"Quelle Geobasisdaten: GeoSN, "
"<a href=\"https://www.govdata.de/dl-de/by-2-0\">dl-de/by-2-0</a><br>"
"Quelle Fachdaten: Darstellung auf der Grundlage von Daten und mit Erlaubnis des "
"Sächsischen Landesamtes für Umwelt, Landwirtschaft und Geologie<br>"
"Basemap:"
"© GeoBasis-DE / <a href=\"https://www.bkg.bund.de\">BKG</a> ([%year($now)%]) "
"<a href=\"https://creativecommons.org/licences/by/4.0/\">CC BY 4.0</a> "
"mit teilweise angepasster Signatur<br>"
)
def __init__(self, project: Any | None = None) -> None:
super().__init__(project)
def create_baufreigabe_layout(
self,
name: str,
din_format: str,
map_width_mm: float,
map_height_mm: float,
extent: Any,
plotmassstab: float,
thema: str = "",
fit_extent_to_map: bool = True,
atlas_driven: bool = True,
) -> Any:
"""Erstellt ein Baufreigabe-Layout mit fester DIN A3/A4 Querformat-Groesse.
Args:
name: Name des Layouts
din_format: "DIN A3" oder "DIN A4"
map_width_mm: Breite der Hauptkarte
map_height_mm: Hoehe der Hauptkarte
extent: QgsRectangle der Kartenausdehnung
plotmassstab: Plotmassstab (z.B. 5000 fuer 1:5000)
thema: Name des Visibility-Presets (optional)
fit_extent_to_map: Extent bei Bedarf auf das Karten-Seitenverhaeltnis erweitern
Returns:
QgsPrintLayout objektInstance (wird im Layout-Dialog geoeffnet)
Raises:
ValueError: Falls Name bereits existiert oder Format ungueltig ist
"""
# Seitenformat auswaehlen (nur A3/A4 im Querformat)
if din_format == "DIN A3":
page_width_mm, page_height_mm = self._DIN_A3_LANDSCAPE
elif din_format == "DIN A4":
page_width_mm, page_height_mm = self._DIN_A4_LANDSCAPE
else:
raise ValueError(f"Baufreigabe: Format {din_format} ist nicht unterstützt. Nur DIN A3/A4 Querformat.")
layout_manager = self.project.layoutManager()
existing_layout = layout_manager.layoutByName(name)
if existing_layout is not None:
raise ValueError(f"Eine Vorlage mit der Bezeichnung '{name}' existiert bereits.")
layout = QgsPrintLayout(self.project)
layout.initializeDefaults()
layout.setName(name)
self._current_layout = layout
page = layout.pageCollection().page(0)
page.setPageSize(QgsLayoutSize(page_width_mm, page_height_mm, MM))
map_left_mm = 10.0
map_top_mm = 10.0
map_right_mm = map_left_mm + map_width_mm
map_bottom_mm = map_top_mm + map_height_mm
kartenfenster_untere_rechte_ecke_mm = (map_right_mm, map_bottom_mm)
hauptkarte = self._create_map_item(
layout,
"hauptkarte",
map_left_mm,
map_top_mm,
map_width_mm,
map_height_mm,
)
fitted_extent = extent
if fit_extent_to_map:
fitted_extent = self._fit_extent_to_map_item(extent, map_width_mm, map_height_mm)
if fitted_extent is not None and hasattr(fitted_extent, "isNull") and callable(fitted_extent.isNull) and not fitted_extent.isNull():
try:
set_extent = getattr(hauptkarte, "setExtent", None)
if callable(set_extent):
set_extent(fitted_extent)
else:
hauptkarte.zoomToExtent(fitted_extent)
except Exception:
pass
if isinstance(plotmassstab, (int, float)) and plotmassstab > 0:
try:
hauptkarte.setScale(plotmassstab)
except Exception:
pass
# Atlas-Steuerung nur aktivieren, wenn Atlas tatsaechlich genutzt wird.
# Andernfalls bleiben Extent/Scale unveraendert (z.B. Canvas-Ausschnitt).
if atlas_driven:
set_atlas_driven_fn = getattr(hauptkarte, "setAtlasDriven", None)
if callable(set_atlas_driven_fn):
try:
set_atlas_driven_fn(True)
except Exception:
pass
set_atlas_scaling_mode = getattr(hauptkarte, "setAtlasScalingMode", None)
atlas_mode_auto = getattr(getattr(QgsLayoutItemMap, "AtlasScalingMode", object), "Auto", None)
if callable(set_atlas_scaling_mode) and atlas_mode_auto is not None:
try:
set_atlas_scaling_mode(atlas_mode_auto)
except Exception:
pass
set_atlas_margin = getattr(hauptkarte, "setAtlasMargin", None)
if callable(set_atlas_margin):
try:
set_atlas_margin(self._calc_atlas_margin_percent(map_width_mm, map_height_mm))
except Exception:
pass
set_frame_enabled = getattr(hauptkarte, "setFrameEnabled", None)
if callable(set_frame_enabled):
try:
set_frame_enabled(True)
except Exception:
pass
set_frame_stroke_width = getattr(hauptkarte, "setFrameStrokeWidth", None)
if callable(set_frame_stroke_width):
try:
self._set_item_frame_stroke_width(hauptkarte, self._FRAME_STROKE_WIDTH_MM)
except Exception:
pass
if thema and thema != "aktuell":
follow_theme = getattr(hauptkarte, "setFollowVisibilityPreset", None)
set_theme_name = getattr(hauptkarte, "setFollowVisibilityPresetName", None)
if callable(follow_theme):
try:
follow_theme(True)
except Exception:
pass
if callable(set_theme_name):
try:
set_theme_name(thema)
except Exception:
pass
set_keep_layer_set = getattr(hauptkarte, "setKeepLayerSet", None)
if callable(set_keep_layer_set):
try:
set_keep_layer_set(True)
except Exception:
pass
set_refresh_strategy = getattr(hauptkarte, "setRefreshStrategy", None)
if callable(set_refresh_strategy):
refresh_cache = (
getattr(hauptkarte, "RefreshLaterOnly", None)
or getattr(hauptkarte, "RefreshWhenRequested", None)
or 0
)
try:
set_refresh_strategy(refresh_cache)
except Exception:
pass
self._configure_hauptkarte_grids(hauptkarte)
self._setup_layout_objects_baufreigabe(
kartenfenster_untere_rechte_ecke_mm,
hauptkarte,
map_top_mm,
map_height_mm,
)
layout_manager.addLayout(layout)
self._current_layout = None
open_layout_designer(layout)
return layout
def _setup_layout_objects_baufreigabe(
self,
kartenfenster_untere_rechte_ecke_mm: tuple,
hauptkarte: Any,
map_top_mm: float,
map_height_mm: float,
) -> None:
"""Erstellt Legende, Quellenangabe und Kartenschild fuer Baufreigabe-Layout.
Angepasst fuer 80mm Kartenschild statt 180mm.
"""
if self._current_layout is None:
raise RuntimeError("Kein Layout aktiv.")
def _coords_rel_untere_rechte_ecke(rel_x_mm: float, rel_y_mm: float) -> tuple[float, float]:
return (
kartenfenster_untere_rechte_ecke_mm[0] + rel_x_mm,
kartenfenster_untere_rechte_ecke_mm[1] + rel_y_mm,
)
# Legende: 80mm Breite, hoehe adaptive wie Standard
legende_x = kartenfenster_untere_rechte_ecke_mm[0] + 10.0
legende_height_mm = max(map_height_mm - 122.0, 20.0)
legende_y = map_top_mm
legende = self._create_legend_item(
self._current_layout,
"legende",
hauptkarte,
legende_x,
legende_y,
width_mm=self._KARTENSCHILD_WIDTH_MM,
height_mm=legende_height_mm,
object_name="Legende",
)
legende_hoehe_mm = self._configure_legend_size(
legende,
width_limit_mm=self._KARTENSCHILD_WIDTH_MM,
column_count=1,
equal_column_width=False,
)
# Quellenangabe: 80mm Breite
quellenangabe_x, quellenangabe_y = _coords_rel_untere_rechte_ecke(10.0, -102.0)
quellenangabe_hoehe_mm = 20.0
quellenangabe_obere_y = quellenangabe_y - quellenangabe_hoehe_mm
legende_untere_y = legende_y + legende_hoehe_mm
if legende_untere_y > quellenangabe_obere_y:
self._configure_legend_size(
legende,
width_limit_mm=self._KARTENSCHILD_WIDTH_MM,
column_count=2,
equal_column_width=True,
)
self.add_text_label(
self._QUELLENANGABE_TEXT,
quellenangabe_x,
quellenangabe_y,
width_mm=self._KARTENSCHILD_WIDTH_MM,
height_mm=20.0,
text_format="Format_1",
html_mode=True,
expression_enabled=True,
object_name="Quellenangabe",
)
# Kartenschild: 80mm Breite
kartenschild_x, kartenschild_y = _coords_rel_untere_rechte_ecke(10.0, 0.0)
self._create_rectangle_box_item(
self._current_layout,
"Kartenschild",
kartenschild_x,
kartenschild_y,
self._KARTENSCHILD_WIDTH_MM,
self._KARTENSCHILD_HEIGHT_MM,
stroke_width_mm=self._FRAME_STROKE_WIDTH_MM,
)
# Kartenschild-Linien: 6 Trennlinien (Positionen gleich)
kartenschild_line_x, _ = _coords_rel_untere_rechte_ecke(10.0, 0.0)
kartenschild_line_offsets_y = (-10.0, -20.0, -30.0, -46.0)
for index, offset_y in enumerate(kartenschild_line_offsets_y, start=1):
_, line_y = _coords_rel_untere_rechte_ecke(0.0, offset_y)
self._create_rectangle_box_item(
self._current_layout,
f"Kartenschild_Linie_{index}",
kartenschild_line_x,
line_y,
self._KARTENSCHILD_WIDTH_MM,
0.0,
stroke_width_mm=0.1,
)
# Textfelder im Kartenschild ohne Skalierungslogik.
# Positionen werden bewusst als feste Werte gesetzt und bei Bedarf manuell angepasst.
labels = [
("Teilnehmergemeinschaft [%@sn_bezeichnung %] [%@sn_name %]", 14.0, -48.0, 74.0, 8.0, "Format_1", "Herausgeber","Bottom"),
("[% @sn_bezeichnung %]", 14.0, -41.0, 74.0, 4.0, "Format_1", "Bezeichnung_Flurbereinigung"),
("[% @sn_name %]", 14.0, -32.0, 74.0, 5.0, "Format_2", "Name_Flurbereinigung"),
("[% @sn_kartenname %]", 14.0, -23.0, 74.0, 6.0, "Format_3", "Kartenname"),
("Ausgabedatum: [%format_date(now(), 'dd.MM.yyyy')%]", 14.0, -13.0, 74.0, 4.0, "Format_1", "Ausgabedatum"),
(
"Maßstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
14.0,
-3.0,
74.0,
4.0,
"Format_1",
"Massstab",
),
]
for entry in labels:
text, rx, ry, w, h, fmt, obj_name = entry[:7]
v_align = entry[7] if len(entry) > 7 else "top"
x, y = _coords_rel_untere_rechte_ecke(rx, ry)
self.add_text_label(
text,
x,
y,
width_mm=w,
height_mm=h,
text_format=fmt,
expression_enabled=True,
object_name=obj_name,
v_align=v_align,
)
def _calc_atlas_margin_percent(self, map_width_mm: float, map_height_mm: float) -> float:
"""Berechnet Atlas-Margin in Prozent, sodass etwa 10 mm Rand sichtbar bleiben."""
margin_mm = self._ATLAS_FRAME_MARGIN_MM
if map_width_mm <= 2 * margin_mm or map_height_mm <= 2 * margin_mm:
return 5.0
p_width = (100.0 * margin_mm) / (map_width_mm - 2.0 * margin_mm)
p_height = (100.0 * margin_mm) / (map_height_mm - 2.0 * margin_mm)
return max(p_width, p_height)
File diff suppressed because it is too large Load Diff
+16
View File
@@ -43,6 +43,13 @@ PruefAktion = Literal[
# Dateiendung/Format
"falsche_endung",
"pflichtfelder_fehlen",
"unbekannter_dateityp",
"Datenbank",
"dienst",
"excel",
"unbekannte_quelle",
# Excel/Import
"kein_header",
@@ -50,6 +57,8 @@ PruefAktion = Literal[
"read_error",
"open_error",
"datenabruf",
# 🆕 VERFAHRENS-DB SPEZIFISCH (deine Anforderungen 2.d, 2.e)
"datei_wird_erzeugt", # 2.d: Pfad gültig, Datei fehlt → weiter
@@ -65,6 +74,13 @@ PruefAktion = Literal[
"stil_not_implemented",
"datei_unbekannt",
"needs_user_action",
# Verfahrensgebiet-Editoperationen
"verfahrensgebiet_layer_existiert", # bestehender Layer → Nutzerentscheidung
"verfahrensgebiet_ergaenzen", # Flurstücke hinzufügen
"verfahrensgebiet_entfernen", # Flurstücke entfernen
"verfahrensgebiet_ersetzen", # Verfahrensgebiet vollständig ersetzen
"verfahrensgebiet_abbrechen", # Nutzer hat abgebrochen
]
+12 -6
View File
@@ -4,9 +4,11 @@ Prüft ausschließlich, ob ein Stilpfad gültig ist.
Die Anwendung erfolgt später über eine Aktion.
"""
import os
from pathlib import Path
from sn_basis.functions.sys_wrapper import file_exists
from sn_basis.functions.os_wrapper import is_absolute_path
from sn_basis.functions.sys_wrapper import get_plugin_root, file_exists, join_path
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
@@ -40,7 +42,11 @@ class Stilpruefer:
kontext=None,
)
pfad = Path(stil_pfad)
pfad = str(stil_pfad)
if not is_absolute_path(pfad) and not pfad.startswith(("/", "\\")):
plugin_root = get_plugin_root()
pfad = str(join_path(plugin_root, "sn_plan41", "assets", pfad))
# -----------------------------------------------------
# 2. Datei existiert nicht
@@ -50,18 +56,18 @@ class Stilpruefer:
ok=False,
meldung=f"Die Stil-Datei '{stil_pfad}' wurde nicht gefunden.",
aktion="datei_nicht_gefunden",
kontext=pfad,
kontext=Path(pfad),
)
# -----------------------------------------------------
# 3. Falsche Endung
# -----------------------------------------------------
if pfad.suffix.lower() != ".qml":
if os.path.splitext(pfad)[1].lower() != ".qml":
return pruef_ergebnis(
ok=False,
meldung="Die Stil-Datei muss die Endung '.qml' haben.",
aktion="falsche_endung",
kontext=pfad,
kontext=Path(pfad),
)
# -----------------------------------------------------
@@ -71,5 +77,5 @@ class Stilpruefer:
ok=True,
meldung="Stil-Datei ist gültig.",
aktion="stil_anwendbar",
kontext=pfad,
kontext=Path(pfad),
)
+4 -1
View File
@@ -1 +1,4 @@
#Testordner
"""
sn_basis/tests Test-Suite für das sn_basis Plugin.
Enthält Unit-Tests und Integrationstests für alle Module.
"""
+127
View File
@@ -0,0 +1,127 @@
import unittest
from typing import Any, cast
from unittest.mock import patch
from sn_basis.modules.DataGrabber import DataGrabber
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
class _DummyPruefmanager:
def verarbeite(self, ergebnis: Any):
return ergebnis
class TestDataGrabber(unittest.TestCase):
def _make_grabber(self) -> DataGrabber:
return DataGrabber(pruefmanager=cast(Any, _DummyPruefmanager()))
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_detects_excel(self, mock_dateipruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=True,
meldung="ok",
aktion="ok",
kontext="C:/tmp/list.xlsx",
)
grabber = self._make_grabber()
source_type, result = grabber.analyze_source_type("C:/tmp/list.xlsx")
self.assertEqual(source_type, "excel")
self.assertTrue(result.ok)
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_returns_unknown_filetype(self, mock_dateipruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=True,
meldung="ok",
aktion="ok",
kontext="C:/tmp/file.txt",
)
grabber = self._make_grabber()
source_type, _result = grabber.analyze_source_type("C:/tmp/file.txt")
self.assertEqual(source_type, "unbekannter_dateityp")
@patch("sn_basis.modules.DataGrabber.Linkpruefer")
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_detects_service_when_file_check_fails(self, mock_dateipruefer, mock_linkpruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=False,
meldung="missing",
aktion="datei_nicht_gefunden",
kontext=None,
)
mock_linkpruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=True,
meldung="ok",
aktion="url_ok",
kontext="https://example.test",
)
grabber = self._make_grabber()
source_type, result = grabber.analyze_source_type("https://example.test")
self.assertEqual(source_type, "dienst")
self.assertTrue(result.ok)
@patch("sn_basis.modules.DataGrabber.Linkpruefer")
@patch("sn_basis.modules.DataGrabber.Dateipruefer")
def test_analyze_source_type_returns_unbekannte_quelle_on_full_failure(self, mock_dateipruefer, mock_linkpruefer):
mock_dateipruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=False,
meldung="missing",
aktion="datei_nicht_gefunden",
kontext=None,
)
mock_linkpruefer.return_value.pruefe.return_value = pruef_ergebnis(
ok=False,
meldung="bad",
aktion="url_nicht_erreichbar",
kontext=None,
)
grabber = self._make_grabber()
source_type, result = grabber.analyze_source_type("broken-input")
self.assertEqual(source_type, "unbekannte_quelle")
self.assertFalse(result.ok)
@patch.object(DataGrabber, "analyze_source_type")
@patch.object(DataGrabber, "_process_excel_source")
def test_run_returns_success_summary_with_valid_rows(self, mock_process_excel, mock_analyze):
mock_analyze.return_value = (
"excel",
pruef_ergebnis(ok=True, meldung="ok", aktion="ok", kontext=None),
)
mock_process_excel.return_value = (
{"rows": [{"ident": "A1"}]},
[],
)
grabber = self._make_grabber()
source_dict, summary = grabber.run("C:/tmp/list.xlsx")
self.assertEqual(len(source_dict["rows"]), 1)
self.assertTrue(summary.ok)
self.assertEqual(summary.aktion, "ok")
@patch.object(DataGrabber, "analyze_source_type")
def test_run_returns_failure_summary_without_rows(self, mock_analyze):
mock_analyze.return_value = (
"unbekannte_quelle",
pruef_ergebnis(ok=False, meldung="bad", aktion="unbekannte_quelle", kontext=None),
)
grabber = self._make_grabber()
source_dict, summary = grabber.run("???")
self.assertEqual(source_dict, {})
self.assertFalse(summary.ok)
self.assertEqual(summary.aktion, "keine_validen_eintraege")
if __name__ == "__main__":
unittest.main()
+182
View File
@@ -0,0 +1,182 @@
import unittest
from unittest.mock import patch
from sn_basis.functions.qgiscore_wrapper import (
MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL,
MAP_GRID_ANNOTATION_DISPLAY_SHOW_X,
MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y,
MAP_GRID_BORDER_BOTTOM,
MAP_GRID_BORDER_LEFT,
MAP_GRID_BORDER_RIGHT,
MAP_GRID_BORDER_TOP,
MAP_GRID_FRAME_STYLE_INTERIOR_TICKS,
MAP_GRID_STYLE_FRAME_ANNOTATIONS,
TEXT_BACKGROUND_SHAPE_RECTANGLE,
TEXT_BACKGROUND_SIZE_BUFFER,
TEXT_RENDER_UNIT_MILLIMETERS,
TEXT_RENDER_UNIT_POINTS,
QgsLayoutItemLegend,
QgsLayoutItemMap,
QgsPrintLayout,
QgsProject,
)
from sn_basis.modules.print_layout import PrintLayout
class _FakeExtent:
def isNull(self) -> bool:
return False
class _FakeLegendForResize:
def __init__(self) -> None:
self.last_resize = None
def setLocked(self, _enabled: bool) -> None:
pass
def setColumnCount(self, _count: int) -> None:
pass
def setEqualColumnWidth(self, _enabled: bool) -> None:
pass
def refresh(self) -> None:
pass
def adjustBoxSize(self) -> None:
pass
def attemptResize(self, size) -> None:
self.last_resize = size
class TestPrintLayout(unittest.TestCase):
def test_create_legend_item_links_map_and_enables_map_filtering(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = QgsPrintLayout(project)
linked_map = QgsLayoutItemMap(layout)
legend = print_layout._create_legend_item(
layout,
"legende",
linked_map,
10.0,
20.0,
width_mm=50.0,
height_mm=30.0,
)
self.assertIs(legend.linked_map, linked_map)
self.assertTrue(legend.auto_update_model)
self.assertTrue(legend.legend_filter_by_map_enabled)
def test_create_legend_item_does_not_copy_project_layers_into_legend_model(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = QgsPrintLayout(project)
linked_map = QgsLayoutItemMap(layout)
legend = print_layout._create_legend_item(
layout,
"legende",
linked_map,
10.0,
20.0,
)
root_group = legend.model().rootGroup()
self.assertEqual(root_group.layers, [])
def test_configure_legend_size_uses_content_adaptive_size(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
legend = _FakeLegendForResize()
with patch.object(print_layout, "_get_item_size_mm", return_value=(95.0, 33.0)):
height = print_layout._configure_legend_size(
legend,
width_limit_mm=180.0,
column_count=2,
equal_column_width=True,
)
self.assertIsNone(legend.last_resize)
self.assertEqual(height, 33.0)
def test_configure_hauptkarte_grids_uses_wrapper_constants(self):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = QgsPrintLayout(project)
hauptkarte = QgsLayoutItemMap(layout)
print_layout._configure_hauptkarte_grids(hauptkarte)
grid_stack = hauptkarte.grids()
grids = grid_stack.asList()
self.assertEqual(len(grids), 1)
frame_grid = grids[0]
self.assertEqual(frame_grid.style, MAP_GRID_STYLE_FRAME_ANNOTATIONS)
self.assertEqual(frame_grid.frame_style, MAP_GRID_FRAME_STYLE_INTERIOR_TICKS)
self.assertTrue(frame_grid.annotation_enabled)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_LEFT], MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_TOP], MAP_GRID_ANNOTATION_DISPLAY_HIDE_ALL)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_RIGHT], MAP_GRID_ANNOTATION_DISPLAY_SHOW_Y)
self.assertEqual(frame_grid.annotation_display[MAP_GRID_BORDER_BOTTOM], MAP_GRID_ANNOTATION_DISPLAY_SHOW_X)
text_format = frame_grid.annotation_text_format
self.assertIsNotNone(text_format)
self.assertEqual(getattr(text_format, "size", None), 10)
self.assertEqual(getattr(text_format, "size_unit", None), TEXT_RENDER_UNIT_POINTS)
background = getattr(text_format, "background", None)
self.assertIsNotNone(background)
self.assertTrue(getattr(background, "enabled", False))
self.assertEqual(getattr(background, "shape_type", None), TEXT_BACKGROUND_SHAPE_RECTANGLE)
self.assertEqual(getattr(background, "size_type", None), TEXT_BACKGROUND_SIZE_BUFFER)
self.assertEqual(getattr(background, "size_unit", None), TEXT_RENDER_UNIT_MILLIMETERS)
self.assertEqual(getattr(background, "offset_unit", None), TEXT_RENDER_UNIT_MILLIMETERS)
offset = getattr(background, "offset", None)
self.assertIsNotNone(offset)
if isinstance(offset, tuple):
self.assertEqual(offset[1], -1.0)
else:
y_value = getattr(offset, "y", None)
if callable(y_value):
self.assertEqual(y_value(), -1.0)
else:
self.assertEqual(getattr(offset, "y", None), -1.0)
@patch("sn_basis.modules.print_layout.open_layout_designer")
def test_create_single_page_layout_keeps_legend_linked_to_hauptkarte_and_theme(self, _mock_open_designer):
project = QgsProject.instance()
print_layout = PrintLayout(project=project)
layout = print_layout.create_single_page_layout(
name="Testlayout",
page_width_mm=420.0,
page_height_mm=297.0,
map_width_mm=200.0,
map_height_mm=150.0,
extent=_FakeExtent(),
plotmassstab=5000.0,
thema="Thema A",
)
hauptkarte = next(item for item in layout.items if isinstance(item, QgsLayoutItemMap))
legende = next(item for item in layout.items if isinstance(item, QgsLayoutItemLegend))
self.assertTrue(hauptkarte.follow_visibility_preset)
self.assertEqual(hauptkarte.follow_visibility_preset_name, "Thema A")
self.assertTrue(hauptkarte.keep_layer_set)
self.assertIs(legende.linked_map, hauptkarte)
self.assertTrue(legende.auto_update_model)
self.assertTrue(legende.legend_filter_by_map_enabled)
if __name__ == "__main__":
unittest.main()
+191
View File
@@ -0,0 +1,191 @@
import unittest
from unittest.mock import MagicMock, patch
from sn_basis.modules.print_logic import (
PrintLogic,
DRUCKBEREICH_RECHTECK,
DRUCKBEREICH_WIE_KARTENFENSTER,
ENDLOSROLLE_MAX_BREITE_MM,
)
class _FakeExtent:
def __init__(self, width: float = 100.0, height: float = 80.0) -> None:
self._width = width
self._height = height
def width(self) -> float:
return self._width
def height(self) -> float:
return self._height
def isNull(self) -> bool:
return False
class _FakeCanvas:
def __init__(self, extent: object | None) -> None:
self._extent = extent
def extent(self) -> object | None:
return self._extent
class TestPrintLogic(unittest.TestCase):
def setUp(self) -> None:
self.logic = PrintLogic(pruefmanager=MagicMock())
def test_get_baufreigabe_page_dimensions_prefers_a4_for_small_extent(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(80.0, 120.0)
self.assertEqual(din_format, "DIN A4")
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (187.0, 190.0))
self.assertFalse(fallback)
def test_get_baufreigabe_page_dimensions_escalates_to_a3(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(200.0, 180.0)
self.assertEqual(din_format, "DIN A3")
self.assertEqual((page_w, page_h), (420.0, 297.0))
self.assertEqual((map_w, map_h), (310.0, 277.0))
self.assertFalse(fallback)
def test_get_baufreigabe_page_dimensions_respects_a4_as_maximum_with_fallback(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(
200.0,
180.0,
max_format="DIN A4",
)
self.assertEqual(din_format, "DIN A4")
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (187.0, 190.0))
self.assertTrue(fallback)
def test_get_baufreigabe_page_dimensions_falls_back_to_a3_for_oversize_extent(self):
din_format, page_w, page_h, map_w, map_h, fallback = self.logic._get_baufreigabe_page_dimensions(400.0, 300.0)
self.assertEqual(din_format, "DIN A3")
self.assertEqual((page_w, page_h), (420.0, 297.0))
self.assertEqual((map_w, map_h), (310.0, 277.0))
self.assertTrue(fallback)
def test_select_standard_page_layout_uses_a4_as_minimum_sheet(self):
selected = self.logic._select_standard_page_layout(60.0, 100.0, max_format="DIN A3")
self.assertEqual(selected, (297.0, 210.0, 87.0, 190.0))
def test_select_standard_page_layout_limits_hauptkarte_to_a3(self):
selected = self.logic._select_standard_page_layout(120.0, 240.0, max_format="DIN A3")
self.assertEqual(selected, (420.0, 297.0, 210.0, 277.0))
def test_select_standard_page_layout_returns_none_if_a3_is_too_small(self):
selected = self.logic._select_standard_page_layout(260.0, 280.0, max_format="DIN A3")
self.assertIsNone(selected)
def test_get_standard_layout_dimensions_uses_a4_and_forces_fit_for_small_extent(self):
page_w, page_h, map_w, map_h, force_fit = self.logic._get_standard_layout_dimensions(
60.0,
100.0,
max_format="DIN A3",
formfaktor=False,
)
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (87.0, 190.0))
self.assertTrue(force_fit)
def test_get_standard_layout_dimensions_caps_endlosrolle_width_at_2000_mm(self):
page_w, page_h, map_w, map_h, force_fit = self.logic._get_standard_layout_dimensions(
2500.0,
100.0,
max_format="DIN A0",
formfaktor=True,
)
self.assertEqual(page_w, ENDLOSROLLE_MAX_BREITE_MM)
self.assertEqual(page_h, 210.0)
self.assertEqual((map_w, map_h), (2500.0, 190.0))
self.assertTrue(force_fit)
def test_apply_standard_minimum_a4_fallback_for_small_page(self):
page_w, page_h, map_w, map_h, forced = self.logic._apply_standard_minimum_a4_fallback(
250.0,
180.0,
40.0,
20.0,
)
self.assertTrue(forced)
self.assertEqual((page_w, page_h), (297.0, 210.0))
self.assertEqual((map_w, map_h), (87.0, 190.0))
def test_apply_standard_minimum_a4_fallback_keeps_large_page(self):
page_w, page_h, map_w, map_h, forced = self.logic._apply_standard_minimum_a4_fallback(
420.0,
297.0,
210.0,
277.0,
)
self.assertFalse(forced)
self.assertEqual((page_w, page_h), (420.0, 297.0))
self.assertEqual((map_w, map_h), (210.0, 277.0))
def test_does_plot_fit_page_true_for_standard_a4(self):
fits = self.logic._does_plot_fit_page(297.0, 210.0, 87.0, 190.0, 210.0, 20.0)
self.assertTrue(fits)
def test_does_plot_fit_page_false_if_objects_exceed_page(self):
fits = self.logic._does_plot_fit_page(297.0, 210.0, 120.0, 190.0, 210.0, 20.0)
self.assertFalse(fits)
@patch("sn_basis.modules.print_logic.set_variable")
def test_set_druckbereich_accepts_wie_kartenfenster(self, mock_set_variable):
self.logic.set_druckbereich_for_auswahl(DRUCKBEREICH_WIE_KARTENFENSTER)
mock_set_variable.assert_called_once_with("druckbereich", DRUCKBEREICH_WIE_KARTENFENSTER, scope="project")
@patch("sn_basis.modules.print_logic.iface")
def test_get_canvas_extent_returns_valid_extent(self, mock_iface):
extent = _FakeExtent(120.0, 90.0)
mock_iface.mapCanvas.return_value = _FakeCanvas(extent)
result = self.logic._get_canvas_extent()
self.assertIs(result, extent)
@patch.object(PrintLogic, "_buffer_extent")
@patch.object(PrintLogic, "_get_canvas_extent")
def test_resolve_extent_uses_canvas_extent_without_buffer(self, mock_get_canvas_extent, mock_buffer_extent):
extent = _FakeExtent(120.0, 90.0)
mock_get_canvas_extent.return_value = extent
result_extent, result_layer = self.logic._resolve_extent(None, DRUCKBEREICH_WIE_KARTENFENSTER, 5000.0)
self.assertIs(result_extent, extent)
self.assertIsNone(result_layer)
mock_buffer_extent.assert_not_called()
@patch.object(PrintLogic, "_buffer_extent")
@patch.object(PrintLogic, "_get_druckrechteck_extent")
def test_resolve_extent_uses_rectangle_extent_without_buffer(self, mock_get_rect_extent, mock_buffer_extent):
extent = _FakeExtent(120.0, 90.0)
mock_get_rect_extent.return_value = extent
result_extent, result_layer = self.logic._resolve_extent(None, DRUCKBEREICH_RECHTECK, 5000.0)
self.assertIs(result_extent, extent)
self.assertIsNone(result_layer)
mock_buffer_extent.assert_not_called()
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -141,6 +141,40 @@ class TestPruefmanager(unittest.TestCase):
self.assertFalse(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "unbekannt")
@patch("sn_basis.modules.Pruefmanager.ask_overwrite_append_cancel_custom", return_value="overwrite")
def test_datei_existiert_overwrite(self, _mock_choice):
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = self.manager.verarbeite(ergebnis)
self.assertTrue(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert_ueberschreiben")
self.assertEqual(entscheidung.kontext, "C:/tmp/v.gpkg")
@patch("sn_basis.modules.Pruefmanager.ask_overwrite_append_cancel_custom", return_value="append")
def test_datei_existiert_append(self, _mock_choice):
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = self.manager.verarbeite(ergebnis)
self.assertTrue(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert_anhaengen")
@patch("sn_basis.modules.Pruefmanager.ask_overwrite_append_cancel_custom", return_value="cancel")
def test_datei_existiert_cancel(self, _mock_choice):
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = self.manager.verarbeite(ergebnis)
self.assertTrue(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert_ueberspringen")
def test_datei_existiert_in_non_qgis_mode_returns_input(self):
manager = Pruefmanager(ui_modus="test")
ergebnis = pruef_ergebnis(False, "Datei existiert", "datei_existiert", "C:/tmp/v.gpkg")
entscheidung = manager.verarbeite(ergebnis)
self.assertFalse(entscheidung.ok)
self.assertEqual(entscheidung.aktion, "datei_existiert")
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -55,6 +55,32 @@ class TestSettingsLogic(unittest.TestCase):
self.assertIn("gemeinden", saved_keys)
self.assertNotIn("unbekannt", saved_keys)
@patch("sn_basis.functions.settings_logic.get_variable")
def test_load_handles_partial_values_with_empty_fallback(self, mock_get):
values = {
"amt": "A1",
"behoerde": "",
"landkreis_user": "L1",
}
mock_get.side_effect = lambda key, scope="project": values.get(key, "")
logic = SettingsLogic()
daten = logic.load()
self.assertEqual(daten["amt"], "A1")
self.assertEqual(daten["behoerde"], "")
self.assertEqual(daten["landkreis_user"], "L1")
self.assertEqual(daten["sachgebiet"], "")
@patch("sn_basis.functions.settings_logic.set_variable")
def test_save_empty_string_value_is_persisted(self, mock_set):
logic = SettingsLogic()
logic.save({"amt": "", "behoerde": "B2"})
mock_set.assert_any_call("amt", "", scope="project")
mock_set.assert_any_call("behoerde", "B2", scope="project")
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
import unittest
from unittest.mock import patch
from sn_basis.functions import variable_wrapper
class _Scope:
def __init__(self, value: str):
self._value = value
def variable(self, _name: str) -> str:
return self._value
class TestVariableWrapper(unittest.TestCase):
@patch("sn_basis.functions.variable_wrapper.QgsProject.instance")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setProjectVariable")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.projectScope")
def test_set_variable_project_noop_write_is_skipped(
self,
mock_project_scope,
mock_set_project_variable,
mock_project_instance,
):
mock_project_instance.return_value = object()
mock_project_scope.return_value = _Scope("layer_1")
variable_wrapper.set_variable("verfahrensgebiet_layer", "layer_1", scope="project")
mock_set_project_variable.assert_not_called()
@patch("sn_basis.functions.variable_wrapper.QgsProject.instance")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setProjectVariable")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.projectScope")
def test_set_variable_project_changed_value_is_written(
self,
mock_project_scope,
mock_set_project_variable,
mock_project_instance,
):
fake_project = object()
mock_project_instance.return_value = fake_project
mock_project_scope.return_value = _Scope("old_value")
variable_wrapper.set_variable("verfahrensgebiet_layer", "new_value", scope="project")
mock_set_project_variable.assert_called_once_with(
fake_project,
"sn_verfahrensgebiet_layer",
"new_value",
)
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setGlobalVariable")
def test_set_variable_global_writes_prefixed_name(self, mock_set_global):
variable_wrapper.set_variable("verfahrensnummer", "123", scope="global")
mock_set_global.assert_called_once_with("sn_verfahrensnummer", "123")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.globalScope")
def test_get_variable_global_returns_empty_string_fallback(self, mock_global_scope):
mock_global_scope.return_value = _Scope("")
value = variable_wrapper.get_variable("nicht_vorhanden", scope="global")
self.assertEqual(value, "")
def test_get_variable_invalid_scope_raises(self):
with self.assertRaises(ValueError):
variable_wrapper.get_variable("foo", scope="invalid")
def test_set_variable_invalid_scope_raises(self):
with self.assertRaises(ValueError):
variable_wrapper.set_variable("foo", "bar", scope="invalid")
if __name__ == "__main__":
unittest.main()
+68
View File
@@ -0,0 +1,68 @@
import unittest
from unittest.mock import patch
from sn_basis.functions import verfahrensgebiet_manager
class _Scope:
def __init__(self, value: str):
self._value = value
def variable(self, _name: str) -> str:
return self._value
class TestVerfahrensgebietManager(unittest.TestCase):
@patch("sn_basis.functions.verfahrensgebiet_manager.get_variable", return_value="layer_123")
def test_get_verfahrensgebiet_layer_id_returns_value(self, _mock_get_variable):
result = verfahrensgebiet_manager.get_verfahrensgebiet_layer_id()
self.assertEqual(result, "layer_123")
@patch("sn_basis.functions.verfahrensgebiet_manager.get_variable", return_value="")
def test_get_verfahrensgebiet_layer_id_returns_none_for_empty_value(self, _mock_get_variable):
result = verfahrensgebiet_manager.get_verfahrensgebiet_layer_id()
self.assertIsNone(result)
@patch("sn_basis.functions.verfahrensgebiet_manager.set_variable")
def test_set_verfahrensgebiet_layer_id_writes_project_variable(self, mock_set_variable):
verfahrensgebiet_manager.set_verfahrensgebiet_layer_id("layer_abc")
mock_set_variable.assert_called_once_with("verfahrensgebiet_layer", "layer_abc", scope="project")
@patch("sn_basis.functions.verfahrensgebiet_manager._find_verfahrensgebiet_layer")
@patch("sn_basis.functions.variable_wrapper.QgsProject.instance")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.setProjectVariable")
@patch("sn_basis.functions.variable_wrapper.QgsExpressionContextUtils.projectScope")
def test_update_without_layer_does_not_write_when_already_empty(
self,
mock_project_scope,
mock_set_project_variable,
mock_project_instance,
mock_find_layer,
):
mock_find_layer.return_value = (None, None)
mock_project_instance.return_value = object()
mock_project_scope.return_value = _Scope("")
result = verfahrensgebiet_manager._update_verfahrensgebiet_from_project()
self.assertFalse(result)
mock_set_project_variable.assert_not_called()
@patch("sn_basis.functions.verfahrensgebiet_manager._find_verfahrensgebiet_layer")
@patch("sn_basis.functions.verfahrensgebiet_manager.set_variable")
def test_update_with_layer_writes_variable(self, mock_set_variable, mock_find_layer):
mock_layer = object()
mock_find_layer.return_value = (mock_layer, "layer_99")
result = verfahrensgebiet_manager._update_verfahrensgebiet_from_project()
self.assertTrue(result)
mock_set_variable.assert_called_once_with("verfahrensgebiet_layer", "layer_99", scope="project")
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -0,0 +1,4 @@
"""
sn_basis/ui Benutzeroberflächen-Module für sn_basis.
Enthält Dock-Manager, Navigation und einzelne Tabs.
"""
+14
View File
@@ -99,6 +99,20 @@ class BaseDockWidget(QDockWidget):
Wird aufgerufen, wenn das Dock geschlossen wird.
Setzt die zugehörige Toolbar-Action zurück.
"""
try:
tab_widget = self.widget()
if tab_widget and hasattr(tab_widget, "count") and hasattr(tab_widget, "widget"):
for index in range(tab_widget.count()):
tab = tab_widget.widget(index)
cleanup = getattr(tab, "cleanup", None)
if callable(cleanup):
try:
cleanup()
except Exception:
pass
except Exception:
pass
try:
if self.action:
self.action.setChecked(False)
+9
View File
@@ -0,0 +1,9 @@
"""
sn_basis/ui/tabs Tab-Widgets für die sn_basis Benutzeroberfläche.
Stellt print_tab und settings_tab bereit.
"""
# from sn_basis.ui.tabs.print_tab import PrintTab
# from sn_basis.ui.tabs.settings_tab import SettingsTab
# __all__ = ["PrintTab", "SettingsTab"]
+708
View File
@@ -0,0 +1,708 @@
"""
sn_basis/ui/tabs/print_tab.py - Zentraler Druck-Tab fuer sn-Plugins.
"""
from __future__ import annotations
from typing import Any, Optional
from sn_basis.functions.qt_wrapper import (
QWidget,
QVBoxLayout,
QLabel,
QComboBox,
QCheckBox,
QHBoxLayout,
QPushButton,
QToolButton,
ToolButtonTextBesideIcon,
ArrowDown,
ArrowRight,
)
from sn_basis.functions.qgiscore_wrapper import QgsProject, QgsMapLayerProxyModel
from sn_basis.functions.qgisui_wrapper import iface, QgsMapLayerComboBox
from sn_basis.functions.variable_wrapper import get_variable, set_variable
from sn_basis.modules.Pruefmanager import Pruefmanager
from sn_basis.modules.DataGrabber import DataGrabber
from sn_basis.modules.print_logic import (
PrintLogic,
MASSSTAB_WIE_KARTENFENSTER,
PLOTMASSSTAB_BY_AUSWAHL,
THEMA_WIE_KARTENFENSTER,
DIN_GROESSEN,
DIN_STANDARD,
DRUCKBEREICH_VAR,
DRUCKPUFFER_VAR,
DRUCKPUFFER_STANDARD_CM,
DRUCKBEREICH_VERFAHRENSGEBIET,
DRUCKBEREICH_WIE_KARTENFENSTER,
DRUCKBEREICH_AKTUELLE_AUSWAHL,
DRUCKBEREICH_RECHTECK,
)
KARTENNAME_VAR = "tab_b_kartenname"
KARTENNAME_PLACEHOLDER = "Kartenname waehlen"
KARTENNAME_38 = "§38"
KARTENNAME_41 = "§41"
KARTENNAME_BAUFREIGABE = "Anlage zur Baufreigabe"
MASSSTAB_VAR = "tab_b_massstab"
THEMA_VAR = "tab_b_thema"
ZIELGROESSE_UI_VAR = "tab_b_zielgroesse"
FORMFAKTOR_UI_VAR = "tab_b_formfaktor"
ATLAS_ERZEUGEN_VAR = "tab_b_atlas_erzeugen"
DRUCKBEREICH_OPTIONEN = [
DRUCKBEREICH_VERFAHRENSGEBIET,
DRUCKBEREICH_WIE_KARTENFENSTER,
DRUCKBEREICH_AKTUELLE_AUSWAHL,
DRUCKBEREICH_RECHTECK,
]
class PrintTab(QWidget):
"""UI-Klasse fuer den zentralen Druck-Tab."""
tab_title = "Druck"
def __init__(self, parent: Optional[Any] = None):
super().__init__(parent)
self.pruefmanager: Optional[Pruefmanager] = None
self.logic = PrintLogic(self.pruefmanager)
#self.logic: Optional[PrintLogic] = None
self._kartenname_combo: Optional[Any] = None
self._massstab_combo: Optional[Any] = None
self._thema_combo: Optional[Any] = None
self._theme_signal_connected = False
self._connected_theme_collection: object = None
self._zielgroesse_combo: Optional[Any] = None
self._druckbereich_combo: Optional[Any] = None
self._endlosrolle_cb: Optional[Any] = None
self._druckpuffer_button: Optional[Any] = None
self._btn_vorlage_erstellen: Optional[Any] = None
self._atlas_layer_combo: Optional[Any] = None
self._btn_auswahl_as_atlas: Optional[Any] = None
self._atlas_erzeugen_cb: Optional[Any] = None
self._atlas_button: Optional[Any] = None
self._atlas_content: Optional[Any] = None
self._build_ui()
self._restore_state()
self._connect_theme_collection_signals()
self._connect_project_signals()
def set_services(self, pruefmanager: Pruefmanager, data_grabber: DataGrabber) -> None:
_ = data_grabber
self.pruefmanager = pruefmanager
self.logic = PrintLogic(pruefmanager=self.pruefmanager)
if self._kartenname_combo:
self.logic.set_kartenname_for_auswahl(self._kartenname_combo.currentText())
if self._massstab_combo:
self.logic.set_plotmassstab_for_auswahl(
self._massstab_combo.currentText(),
self._get_current_canvas_scale(),
)
if self._thema_combo:
self.logic.set_view_for_auswahl(self._thema_combo.currentText())
if self._zielgroesse_combo:
self.logic.set_zielgroesse_for_auswahl(self._zielgroesse_combo.currentText())
if self._druckbereich_combo:
self.logic.set_druckbereich_for_auswahl(self._druckbereich_combo.currentText())
if self._endlosrolle_cb:
self.logic.set_formfaktor(self._endlosrolle_cb.isChecked())
def _build_ui(self) -> None:
main_layout = QVBoxLayout()
main_layout.setSpacing(4)
main_layout.setContentsMargins(4, 4, 4, 4)
kartenname_label = QLabel("Kartenname")
kartenname_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
main_layout.addWidget(kartenname_label)
kartenname_combo = QComboBox(self)
kartenname_combo.addItem(KARTENNAME_PLACEHOLDER)
kartenname_combo.addItem(KARTENNAME_38)
kartenname_combo.addItem(KARTENNAME_41)
kartenname_combo.addItem(KARTENNAME_BAUFREIGABE)
kartenname_combo.currentTextChanged.connect(self._on_kartenname_changed)
self._kartenname_combo = kartenname_combo
main_layout.addWidget(kartenname_combo)
massstab_label = QLabel("Massstab")
massstab_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
main_layout.addWidget(massstab_label)
massstab_combo = QComboBox(self)
massstab_combo.addItem(MASSSTAB_WIE_KARTENFENSTER)
massstab_combo.addItems(list(PLOTMASSSTAB_BY_AUSWAHL.keys()))
massstab_combo.currentTextChanged.connect(self._on_massstab_changed)
self._massstab_combo = massstab_combo
main_layout.addWidget(massstab_combo)
thema_label = QLabel("Thema")
thema_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
main_layout.addWidget(thema_label)
thema_combo = QComboBox(self)
thema_combo.addItem(THEMA_WIE_KARTENFENSTER)
thema_combo.addItems(self._get_gespeicherte_themen())
thema_combo.currentTextChanged.connect(self._on_thema_changed)
self._thema_combo = thema_combo
main_layout.addWidget(thema_combo)
zielgroesse_label = QLabel("max. Blattgroesse")
zielgroesse_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
main_layout.addWidget(zielgroesse_label)
zielgroesse_row = QHBoxLayout()
zielgroesse_row.setSpacing(6)
zielgroesse_combo = QComboBox(self)
zielgroesse_combo.addItems(list(DIN_GROESSEN.keys()))
zielgroesse_combo.setCurrentText(DIN_STANDARD)
zielgroesse_combo.currentTextChanged.connect(self._on_zielgroesse_changed)
self._zielgroesse_combo = zielgroesse_combo
zielgroesse_row.addWidget(zielgroesse_combo)
endlosrolle_cb = QCheckBox("Endlosrolle", self)
endlosrolle_cb.setChecked(False)
endlosrolle_cb.stateChanged.connect(self._on_formfaktor_changed)
self._endlosrolle_cb = endlosrolle_cb
zielgroesse_row.addWidget(endlosrolle_cb)
main_layout.addLayout(zielgroesse_row)
druckbereich_row = QHBoxLayout()
druckbereich_row.setSpacing(6)
druckbereich_column = QVBoxLayout()
druckbereich_column.setSpacing(4)
druckbereich_column.setContentsMargins(0, 0, 0, 0)
druckbereich_label = QLabel("Druckbereich")
druckbereich_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
druckbereich_column.addWidget(druckbereich_label)
druckbereich_combo = QComboBox(self)
druckbereich_combo.addItems(DRUCKBEREICH_OPTIONEN)
druckbereich_combo.currentTextChanged.connect(self._on_druckbereich_changed)
self._druckbereich_combo = druckbereich_combo
druckbereich_column.addWidget(druckbereich_combo)
druckbereich_row.addLayout(druckbereich_column, 1)
druckpuffer_column = QVBoxLayout()
druckpuffer_column.setSpacing(4)
druckpuffer_column.setContentsMargins(0, 0, 0, 0)
druckpuffer_label = QLabel("Kartenpuffer")
druckpuffer_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
druckpuffer_column.addWidget(druckpuffer_label)
druckpuffer_button = QPushButton(self._format_druckpuffer_label(DRUCKPUFFER_STANDARD_CM), self)
druckpuffer_button.setToolTip("Puffer")
druckpuffer_button.clicked.connect(self._on_druckpuffer_clicked)
self._druckpuffer_button = druckpuffer_button
druckpuffer_column.addWidget(druckpuffer_button)
druckbereich_row.addLayout(druckpuffer_column)
main_layout.addLayout(druckbereich_row)
# Atlas-Bereich (einklappbar, analog Tab A)
atlas_button = QToolButton()
atlas_button.setText("Atlas")
atlas_button.setCheckable(True)
atlas_button.setChecked(False)
atlas_button.setToolButtonStyle(ToolButtonTextBesideIcon)
atlas_button.setArrowType(ArrowRight)
atlas_button.setStyleSheet("font-weight: bold; margin-top: 6px;")
atlas_button.toggled.connect(self._toggle_atlas)
self._atlas_button = atlas_button
main_layout.addWidget(atlas_button)
atlas_content = QWidget()
atlas_layout = QVBoxLayout()
atlas_layout.setSpacing(4)
atlas_layout.setContentsMargins(10, 4, 4, 4)
atlas_erzeugen_cb = QCheckBox("Atlas erzeugen", self)
atlas_erzeugen_cb.setChecked(True)
atlas_erzeugen_cb.stateChanged.connect(self._on_atlas_erzeugen_changed)
self._atlas_erzeugen_cb = atlas_erzeugen_cb
atlas_layout.addWidget(atlas_erzeugen_cb)
atlas_layer_label = QLabel("Abdeckungslayer", self)
atlas_layout.addWidget(atlas_layer_label)
atlas_layer_combo = QgsMapLayerComboBox(self)
set_filters = getattr(atlas_layer_combo, "setFilters", None)
if callable(set_filters):
set_filters(QgsMapLayerProxyModel.VectorLayer)
layer_changed = getattr(atlas_layer_combo, "layerChanged", None)
if layer_changed is not None:
try:
layer_changed.connect(self._on_atlas_layer_changed)
except Exception:
pass
self._atlas_layer_combo = atlas_layer_combo
atlas_layout.addWidget(atlas_layer_combo)
btn_auswahl_as_atlas = QPushButton("Auswahl als Atlas anlegen", self)
btn_auswahl_as_atlas.clicked.connect(self._on_auswahl_as_atlas_clicked)
self._btn_auswahl_as_atlas = btn_auswahl_as_atlas
atlas_layout.addWidget(btn_auswahl_as_atlas)
atlas_content.setLayout(atlas_layout)
atlas_content.setVisible(False)
self._atlas_content = atlas_content
main_layout.addWidget(atlas_content)
btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self)
btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen)
self._btn_vorlage_erstellen = btn_vorlage_erstellen
main_layout.addWidget(btn_vorlage_erstellen)
main_layout.addStretch(1)
self.setLayout(main_layout)
def _restore_state(self) -> None:
if not self._kartenname_combo or not self._massstab_combo or not self._thema_combo:
return
if not self._zielgroesse_combo or not self._endlosrolle_cb:
return
if not self._druckbereich_combo or not self._druckpuffer_button:
return
saved_kartenname = get_variable(KARTENNAME_VAR, scope="project")
if saved_kartenname in (KARTENNAME_38, KARTENNAME_41, KARTENNAME_BAUFREIGABE):
self._kartenname_combo.setCurrentText(saved_kartenname)
else:
self._kartenname_combo.setCurrentText(KARTENNAME_PLACEHOLDER)
saved_massstab = get_variable(MASSSTAB_VAR, scope="project")
valid_massstaebe = [MASSSTAB_WIE_KARTENFENSTER, *PLOTMASSSTAB_BY_AUSWAHL.keys()]
if saved_massstab in valid_massstaebe:
self._massstab_combo.setCurrentText(saved_massstab)
else:
self._massstab_combo.setCurrentText(MASSSTAB_WIE_KARTENFENSTER)
aktuelle_themen = [THEMA_WIE_KARTENFENSTER, *self._get_gespeicherte_themen()]
self._thema_combo.clear()
self._thema_combo.addItems(aktuelle_themen)
saved_thema = get_variable(THEMA_VAR, scope="project")
if saved_thema in aktuelle_themen:
self._thema_combo.setCurrentText(saved_thema)
else:
self._thema_combo.setCurrentText(THEMA_WIE_KARTENFENSTER)
saved_zielgroesse = get_variable(ZIELGROESSE_UI_VAR, scope="project")
if saved_zielgroesse in DIN_GROESSEN:
self._zielgroesse_combo.setCurrentText(saved_zielgroesse)
else:
self._zielgroesse_combo.setCurrentText(DIN_STANDARD)
saved_formfaktor = get_variable(FORMFAKTOR_UI_VAR, scope="project")
self._endlosrolle_cb.setChecked(saved_formfaktor == "Endlosrolle")
saved_druckbereich = get_variable(DRUCKBEREICH_VAR, scope="project")
if saved_druckbereich in DRUCKBEREICH_OPTIONEN:
self._druckbereich_combo.setCurrentText(saved_druckbereich)
else:
self._druckbereich_combo.setCurrentText(DRUCKBEREICH_VERFAHRENSGEBIET)
saved_druckpuffer = get_variable(DRUCKPUFFER_VAR, scope="project")
try:
druckpuffer_cm = int(str(saved_druckpuffer).strip())
except (TypeError, ValueError):
druckpuffer_cm = DRUCKPUFFER_STANDARD_CM
self._druckpuffer_button.setText(self._format_druckpuffer_label(max(0, druckpuffer_cm)))
if self._atlas_erzeugen_cb:
atlas_raw = get_variable(ATLAS_ERZEUGEN_VAR, scope="project")
atlas_checked = True if atlas_raw in (None, "") else str(atlas_raw).strip().lower() in {
"1",
"true",
"ja",
"yes",
"on",
}
self._atlas_erzeugen_cb.setChecked(atlas_checked)
self._on_atlas_erzeugen_changed(1 if atlas_checked else 0)
self._restore_atlas_layer_selection()
def _on_kartenname_changed(self, value: str) -> None:
if value in (KARTENNAME_38, KARTENNAME_41, KARTENNAME_BAUFREIGABE):
set_variable(KARTENNAME_VAR, value, scope="project")
else:
set_variable(KARTENNAME_VAR, "", scope="project")
# Atlas-Bereich automatisch ein-/ausklappen je nach Kartenname.
if self._atlas_button:
self._atlas_button.setChecked(value == KARTENNAME_BAUFREIGABE)
if self.logic:
self.logic.set_kartenname_for_auswahl(value)
def _on_massstab_changed(self, value: str) -> None:
set_variable(MASSSTAB_VAR, value, scope="project")
if self.logic:
self.logic.set_plotmassstab_for_auswahl(value, self._get_current_canvas_scale())
def _on_thema_changed(self, value: str) -> None:
set_variable(THEMA_VAR, value, scope="project")
if self.logic:
self.logic.set_view_for_auswahl(value)
def _on_zielgroesse_changed(self, value: str) -> None:
set_variable(ZIELGROESSE_UI_VAR, value, scope="project")
if self.logic:
self.logic.set_zielgroesse_for_auswahl(value)
def _on_formfaktor_changed(self, state: int) -> None:
checked = bool(state)
set_variable(FORMFAKTOR_UI_VAR, "Endlosrolle" if checked else "Blatt", scope="project")
if self.logic:
self.logic.set_formfaktor(checked)
def _on_druckbereich_changed(self, value: str) -> None:
if value not in DRUCKBEREICH_OPTIONEN:
value = DRUCKBEREICH_VERFAHRENSGEBIET
set_variable(DRUCKBEREICH_VAR, value, scope="project")
if self.logic:
self.logic.set_druckbereich_for_auswahl(value)
if value == DRUCKBEREICH_RECHTECK:
self.logic.activate_druckrechteck_capture()
def _on_atlas_erzeugen_changed(self, state: int) -> None:
enabled = bool(state)
set_variable(ATLAS_ERZEUGEN_VAR, "1" if enabled else "0", scope="project")
if self._atlas_layer_combo:
set_enabled = getattr(self._atlas_layer_combo, "setEnabled", None)
if callable(set_enabled):
try:
set_enabled(enabled)
except Exception:
pass
if self._btn_auswahl_as_atlas:
self._btn_auswahl_as_atlas.setEnabled(enabled)
def _on_druckpuffer_clicked(self) -> None:
if not self.pruefmanager:
return
current_value = get_variable(DRUCKPUFFER_VAR, scope="project") or str(DRUCKPUFFER_STANDARD_CM)
text_value, bestaetigt = self.pruefmanager.frage_text(
"Puffer um den Druckbereich",
"Puffer um den Druckbereich",
default_text=str(current_value),
)
if not bestaetigt:
return
try:
puffer_value = int(str(text_value).strip())
except (TypeError, ValueError):
self.pruefmanager.zeige_hinweis(
"Puffer um den Druckbereich",
"Der Puffer muss als ganze Zahl eingegeben werden.",
)
return
if puffer_value < 0:
self.pruefmanager.zeige_hinweis(
"Puffer um den Druckbereich",
"Der Puffer darf nicht negativ sein.",
)
return
set_variable(DRUCKPUFFER_VAR, str(puffer_value), scope="project")
if self._druckpuffer_button:
self._druckpuffer_button.setText(self._format_druckpuffer_label(puffer_value))
def _format_druckpuffer_label(self, value_cm: int) -> str:
return f"{value_cm} cm"
def _on_vorlage_erstellen(self) -> None:
if not self.logic or not self.pruefmanager:
return
self.logic.druckvorlage_anlegen(
layer=None,
kartenname_auswahl=self._kartenname_combo.currentText() if self._kartenname_combo else "",
massstab_auswahl=self._massstab_combo.currentText() if self._massstab_combo else "",
zielgroesse=self._zielgroesse_combo.currentText() if self._zielgroesse_combo else DIN_STANDARD,
formfaktor=self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else False,
druckbereich_auswahl=self._druckbereich_combo.currentText() if self._druckbereich_combo else DRUCKBEREICH_VERFAHRENSGEBIET,
)
def _disconnect_project_signals(self) -> None:
try:
project = QgsProject.instance()
except Exception:
return
if project is None:
return
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is None:
continue
try:
signal.disconnect(self._on_project_changed)
except Exception:
pass
def cleanup(self) -> None:
"""Trennt Signalverbindungen vor dem Schliessen des Tabs."""
self._disconnect_theme_collection_signals()
self._disconnect_project_signals()
def _connect_project_signals(self) -> None:
try:
project = QgsProject.instance()
except Exception:
return
if project is None:
return
for signal_name in ("readProject", "newProjectCreated", "cleared"):
signal = getattr(project, signal_name, None)
if signal is None:
continue
try:
signal.connect(self._on_project_changed)
except Exception:
pass
def _on_project_changed(self, *args) -> None:
_ = args
self._disconnect_theme_collection_signals()
self._theme_signal_connected = False
self._connect_theme_collection_signals()
self._restore_state()
def _toggle_atlas(self, checked: bool) -> None:
if self._atlas_button:
self._atlas_button.setArrowType(ArrowDown if checked else ArrowRight)
if self._atlas_content:
self._atlas_content.setVisible(checked)
def _restore_atlas_layer_selection(self) -> None:
if not self._atlas_layer_combo:
return
layer_id = (get_variable("sn_abdeckungslayer", scope="project") or "").strip()
if not layer_id:
return
try:
layer = QgsProject.instance().mapLayer(layer_id)
if layer is None:
return
set_current_layer = getattr(self._atlas_layer_combo, "setLayer", None)
if callable(set_current_layer):
set_current_layer(layer)
return
set_current_layer = getattr(self._atlas_layer_combo, "setCurrentLayer", None)
if callable(set_current_layer):
set_current_layer(layer)
except Exception:
pass
def _disconnect_theme_collection_signals(self) -> None:
collection = self._connected_theme_collection
if collection is not None:
for signal_name in ("mapThemesChanged", "changed", "themeChanged"):
try:
signal = getattr(collection, signal_name, None)
except RuntimeError:
break
except Exception:
continue
if signal is None:
continue
try:
signal.disconnect(self._refresh_thema_combo_live)
except Exception:
pass
self._connected_theme_collection = None
self._theme_signal_connected = False
def _connect_theme_collection_signals(self) -> None:
if self._theme_signal_connected:
return
try:
theme_collection = QgsProject.instance().mapThemeCollection()
except Exception:
return
if theme_collection is None:
return
connected_any = False
for signal_name in ("mapThemesChanged", "changed", "themeChanged"):
signal = getattr(theme_collection, signal_name, None)
if signal is None:
continue
try:
signal.connect(self._refresh_thema_combo_live)
connected_any = True
except Exception:
pass
if connected_any:
self._connected_theme_collection = theme_collection
self._theme_signal_connected = connected_any
def _refresh_thema_combo_live(self, *args) -> None:
_ = args
if not self._thema_combo:
return
vorherige_auswahl = self._thema_combo.currentText() or THEMA_WIE_KARTENFENSTER
eintraege = [THEMA_WIE_KARTENFENSTER, *self._get_gespeicherte_themen()]
self._thema_combo.blockSignals(True)
self._thema_combo.clear()
self._thema_combo.addItems(eintraege)
if vorherige_auswahl in eintraege:
self._thema_combo.setCurrentText(vorherige_auswahl)
else:
self._thema_combo.setCurrentText(THEMA_WIE_KARTENFENSTER)
self._thema_combo.blockSignals(False)
self._on_thema_changed(self._thema_combo.currentText())
def _get_gespeicherte_themen(self) -> list[str]:
try:
theme_collection = QgsProject.instance().mapThemeCollection()
if theme_collection is None:
return []
themes = theme_collection.mapThemes()
except Exception:
return []
namen: list[str] = []
for theme_name in themes:
name = str(theme_name or "").strip()
if name and name not in namen:
namen.append(name)
return namen
def _get_current_canvas_scale(self) -> float | None:
try:
canvas = iface.mapCanvas()
if canvas is None:
return None
scale = canvas.scale()
return float(scale) if scale else None
except Exception:
return None
def _on_atlas_layer_changed(self, layer_or_name: Any) -> None:
"""Speichert den ausgewaehlten Atlas-Layer als Layer-ID in Projektvariablen."""
if layer_or_name is None or layer_or_name == "":
set_variable("sn_abdeckungslayer", "", scope="project")
return
# Standardfall QgsMapLayerComboBox: echtes Layer-Objekt
layer_id_getter = getattr(layer_or_name, "id", None)
if callable(layer_id_getter):
layer_id = layer_id_getter() or ""
set_variable("sn_abdeckungslayer", layer_id, scope="project")
return
# Fallback: ggf. Name aus Mock/anderen Widgets
value = str(layer_or_name).strip()
if not value:
set_variable("sn_abdeckungslayer", "", scope="project")
return
try:
project = QgsProject.instance()
map_layers = project.mapLayers()
for layer_id, layer in map_layers.items():
if getattr(layer, "name", lambda: "")() == value:
set_variable("sn_abdeckungslayer", layer_id, scope="project")
return
except Exception:
pass
def _on_auswahl_as_atlas_clicked(self) -> None:
"""Handler fuer Button 'Auswahl als Atlas anlegen'.
Erstellt einen neuen Layer 'Atlaslayer' aus der aktuellen Feature-Auswahl
und traegt ihn ins Atlas-Dropdown ein.
"""
if not self.logic or not self.pruefmanager:
return
atlas_layer, layer_id = self.logic.create_atlas_layer_from_selection()
if atlas_layer is None or not layer_id:
return
if self._atlas_button and self._atlas_content:
self._atlas_button.setChecked(True)
self._toggle_atlas(True)
# Neu erstellten Layer im Layer-Combo setzen
if self._atlas_layer_combo:
set_current_layer = getattr(self._atlas_layer_combo, "setLayer", None)
if callable(set_current_layer):
try:
set_current_layer(atlas_layer)
return
except Exception:
pass
set_current_layer = getattr(self._atlas_layer_combo, "setCurrentLayer", None)
if callable(set_current_layer):
try:
set_current_layer(atlas_layer)
return
except Exception:
pass
# Fallback nur fuer Mock/klassische ComboBox
self._populate_atlas_layer_combo()
def _populate_atlas_layer_combo(self) -> None:
"""Fallback-Befuellung, falls kein echtes QgsMapLayerComboBox Verhalten verfuegbar ist."""
if not self._atlas_layer_combo:
return
clear_fn = getattr(self._atlas_layer_combo, "clear", None)
add_item_fn = getattr(self._atlas_layer_combo, "addItem", None)
if not callable(clear_fn) or not callable(add_item_fn):
return
self._atlas_layer_combo.blockSignals(True)
clear_fn()
add_item_fn("")
try:
project = QgsProject.instance()
map_layers = getattr(project, "mapLayers", None)
if not callable(map_layers):
self._atlas_layer_combo.blockSignals(False)
return
layers = map_layers().values() if hasattr(map_layers(), "values") else map_layers()
for layer in layers:
layer_name = getattr(layer, "name", lambda: None)()
if layer_name and layer_name not in ("", "Druckrechteck"):
add_item_fn(layer_name)
except Exception:
pass
self._atlas_layer_combo.blockSignals(False)
+20 -1
View File
@@ -1,4 +1,8 @@
#sn_basis/ui/tabs/settings_tab.py
"""
sn_basis/ui/tabs/settings_tab.py Einstellungen-Tab für Projekteinformationen.
Verwaltet benutzerspezifische und projektspezifische Einstellungen.
"""
from sn_basis.functions.qt_wrapper import (
QWidget,
QGridLayout,
@@ -12,9 +16,23 @@ from sn_basis.functions.settings_logic import SettingsLogic
class SettingsTab(QWidget):
"""
Tab-Widget für die Verwaltung von Projekt- und Benutzereinstellungen.
Attributes:
tab_title: Anzeigetitel des Tabs ("Projekteigenschaften")
logic: SettingsLogic-Instanz für persistente Einstellungen
"""
tab_title = "Projekteigenschaften"
def __init__(self, parent=None):
"""
Initialisiert den Settings-Tab mit Eingabefeldern.
Args:
parent: Übergeordnetes Widget (Standard: None)
"""
super().__init__(parent)
self.logic = SettingsLogic()
@@ -32,6 +50,7 @@ class SettingsTab(QWidget):
self.project_fields = {
"bezeichnung": "Bezeichnung:",
"name": "Name:",
"verfahrensnummer": "Verfahrensnummer:",
"gemeinden": "Gemeinde(n):",
"landkreise_proj": "Landkreis(e):",