forked from AG_QGIS/Plugin_SN_Basis
Compare commits
91
Commits
main
...
26.5.2-testing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e11f84e89b | ||
|
|
171ebcbe4f | ||
|
|
b7dfac68f1 | ||
|
|
62e9bcee1e | ||
|
|
e9d9a2294d | ||
|
|
c8fde119b4 | ||
|
|
692f690fea | ||
|
|
94b957bb73 | ||
|
|
00023c632f | ||
|
|
fba89478a0 | ||
|
|
3c1d8fc4e5 | ||
|
|
44eee228a8 | ||
|
|
34e2ff4f66 | ||
|
|
91669b34d6 | ||
|
|
d2035139d9 | ||
|
|
30bfcc597d | ||
|
|
88761eab22 | ||
|
|
d762f2d94f | ||
|
|
9f7e81e8b2 | ||
|
|
b4433222f3 | ||
|
|
779a1ed63f | ||
|
|
4049090e92 | ||
|
|
4b842bf635 | ||
|
|
943aab7509 | ||
|
|
5564c06d5c | ||
|
|
5b4cbc3127 | ||
|
|
f0944aab99 | ||
|
|
9306028df8 | ||
|
|
abf5808117 | ||
|
|
7ad8ba054f | ||
|
|
0900c6d3b0 | ||
|
|
40c224959d | ||
|
|
9d949d158f | ||
|
|
dbccb5322f | ||
|
|
fef72b7e53 | ||
|
|
8e80ffb02e | ||
|
|
5dce7c4d96 | ||
|
|
ffc56f5bc4 | ||
|
|
c01bf241db | ||
|
|
9ec465e276 | ||
|
|
d5b7482ebd | ||
|
|
8e4031a884 | ||
|
|
f1f104a464 | ||
|
|
f34e4ce458 | ||
|
|
30b17e0b83 | ||
|
|
a96560b21b | ||
|
|
bb95e0d536 | ||
|
|
4f0d3c12ba | ||
|
|
493e14c67f | ||
|
|
5303a44ce8 | ||
|
|
b014b92c19 | ||
|
|
3d8c0aff5d | ||
|
|
e397ccde71 | ||
|
|
81c80f4e79 | ||
|
|
095767b8c7 | ||
|
|
5ca70b9318 | ||
|
|
d3971df28c | ||
|
|
eb05fb260f | ||
|
|
295f9f1db7 | ||
|
|
eebb909080 | ||
|
|
2ff465b86d | ||
|
|
f19fe71bfa | ||
|
|
ae5f88c5b8 | ||
|
|
7cd6e3ef24 | ||
|
|
1be1420f66 | ||
|
|
f25e30c489 | ||
|
|
0eb32453d6 | ||
|
|
841b529ad8 | ||
|
|
ae5725cd03 | ||
|
|
ac5a3993c8 | ||
|
|
22b45fe19a | ||
|
|
24c2137dc2 | ||
|
|
c0c0387b1d | ||
|
|
663ca770a1 | ||
|
|
04319b6f7b | ||
|
|
1c70d62739 | ||
|
|
3971bd3408 | ||
|
|
fa04fc80e3 | ||
|
|
04bdfbe9d8 | ||
|
|
b6b791e5bd | ||
|
|
82be564c29 | ||
|
|
f42260b66c | ||
|
|
327c25388f | ||
|
|
c6c9613120 | ||
|
|
6e1f4c615b | ||
|
|
f876218134 | ||
|
|
9829ac9c81 | ||
|
|
ae956b0046 | ||
|
|
0ec24029d8 | ||
|
|
948041da52 | ||
|
|
439de5527a |
@@ -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"
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
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 git zip curl jq rsync bash
|
||||
git config --global http.sslVerify false
|
||||
|
||||
- name: Code holen
|
||||
run: |
|
||||
# Tag aus GitHub Actions Kontext extrahieren
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
# Repo-URL dynamisch aus vars und github.repository bauen
|
||||
REPO_URL="https://${RELEASE_TOKEN}:x-oauth-basic@${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}.git"
|
||||
|
||||
# Repository klonen
|
||||
git clone "$REPO_URL" repo
|
||||
cd repo
|
||||
|
||||
git checkout "$TAG"
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Version und Kanal bestimmen
|
||||
id: releaseinfo
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
case "$TAG" in
|
||||
*-unstable*)
|
||||
CHANNEL="unstable"
|
||||
DRAFT="false"
|
||||
PRERELEASE="true"
|
||||
;;
|
||||
*-testing*)
|
||||
CHANNEL="testing"
|
||||
DRAFT="false"
|
||||
PRERELEASE="true"
|
||||
;;
|
||||
*)
|
||||
CHANNEL="stable"
|
||||
DRAFT="false"
|
||||
PRERELEASE="false"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
|
||||
echo "draft=$DRAFT" >> $GITHUB_OUTPUT
|
||||
echo "prerelease=$PRERELEASE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: plugin.info einlesen
|
||||
id: info
|
||||
run: |
|
||||
cd repo
|
||||
while IFS='=' read -r key value; do
|
||||
echo "$key=$value" >> $GITHUB_OUTPUT
|
||||
done < plugin.info
|
||||
|
||||
- name: Changelog einlesen
|
||||
id: changelog
|
||||
run: |
|
||||
cd repo
|
||||
|
||||
# Aktueller Block = alles vor dem ersten ---
|
||||
CURRENT=$(awk '/^---/{exit} {print}' changelog.txt)
|
||||
|
||||
# Vollständige Historie = alles nach dem ersten ---
|
||||
HISTORY=$(awk 'found{print} /^---/{found=1}' changelog.txt)
|
||||
|
||||
# Gitea Release Body zusammenbauen
|
||||
VERSION="${{ steps.releaseinfo.outputs.version }}"
|
||||
FULL=$(printf "## %s\n%s\n\n%s" "$VERSION" "$CURRENT" "$HISTORY")
|
||||
|
||||
echo "DEBUG | Aktueller Changelog:"
|
||||
echo "$CURRENT"
|
||||
|
||||
# Für GITHUB_OUTPUT: Multiline via EOF-Marker
|
||||
echo "current<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CURRENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "full<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$FULL" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: metadata.txt erzeugen
|
||||
run: |
|
||||
cd repo
|
||||
|
||||
# ------------------------ GEÄNDERT ------------------------
|
||||
# Temporär die Vorlage aus dem hidden/templates Branch holen
|
||||
git fetch origin hidden/templates
|
||||
git checkout origin/hidden/templates -- metadata.template
|
||||
TEMPLATE="metadata.template"
|
||||
# -----------------------------------------------------------
|
||||
|
||||
# TEMPLATE="templates/metadata.template"
|
||||
OUT="metadata.txt"
|
||||
|
||||
CONTENT=$(cat "$TEMPLATE")
|
||||
|
||||
CONTENT="${CONTENT//\{\{NAME\}\}/${{ steps.info.outputs.name }}}"
|
||||
CONTENT="${CONTENT//\{\{QGIS_MIN\}\}/${{ steps.info.outputs.qgisMinimumVersion }}}"
|
||||
CONTENT="${CONTENT//\{\{QGIS_MAX\}\}/${{ steps.info.outputs.qgisMaximumVersion }}}"
|
||||
CONTENT="${CONTENT//\{\{DESCRIPTION\}\}/${{ steps.info.outputs.description }}}"
|
||||
CONTENT="${CONTENT//\{\{VERSION\}\}/${{ steps.releaseinfo.outputs.version }}}"
|
||||
CONTENT="${CONTENT//\{\{AUTHOR\}\}/${{ steps.info.outputs.author }}}"
|
||||
CONTENT="${CONTENT//\{\{EMAIL\}\}/${{ steps.info.outputs.email }}}"
|
||||
CONTENT="${CONTENT//\{\{HOMEPAGE\}\}/${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}}"
|
||||
CONTENT="${CONTENT//\{\{TRACKER\}\}/${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}}"
|
||||
CONTENT="${CONTENT//\{\{REPOSITORY\}\}/${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}}"
|
||||
CONTENT="${CONTENT//\{\{EXPERIMENTAL\}\}/${{ steps.info.outputs.experimental }}}"
|
||||
CONTENT="${CONTENT//\{\{DEPRECATED\}\}/${{ steps.info.outputs.deprecated }}}"
|
||||
CONTENT="${CONTENT//\{\{QT6\}\}/${{ steps.info.outputs.supportsQt6 }}}"
|
||||
|
||||
printf "%s\n" "$CONTENT" > "$OUT"
|
||||
rm $TEMPLATE
|
||||
|
||||
- name: ZIP-Datei erstellen
|
||||
id: zip
|
||||
run: |
|
||||
cd repo
|
||||
|
||||
ZIP_FOLDER="${{ steps.info.outputs.zip_folder }}"
|
||||
ZIP_FILE="${ZIP_FOLDER}.zip"
|
||||
|
||||
VERSION="${{ steps.releaseinfo.outputs.version }}"
|
||||
REPO_NAME="${GITHUB_REPOSITORY##*/}"
|
||||
#ZIP_NAME="${REPO_NAME}-${VERSION}.zip"
|
||||
|
||||
|
||||
mkdir -p dist/${ZIP_FOLDER}
|
||||
|
||||
rsync -a \
|
||||
--exclude='.git' \
|
||||
--exclude='.gitea' \
|
||||
--exclude='.plugin' \
|
||||
--exclude='dist' \
|
||||
./ dist/${ZIP_FOLDER}/
|
||||
|
||||
cd dist
|
||||
zip -r "${ZIP_FILE}" "${ZIP_FOLDER}/" \
|
||||
-x "*.pyc" -x "*/__pycache__/*"
|
||||
cd ..
|
||||
|
||||
echo "zip_file=${ZIP_FILE}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Gitea-Release erstellen
|
||||
id: create_release
|
||||
run: |
|
||||
TAG="${{ github.ref_name }}"
|
||||
VERSION="${{ steps.releaseinfo.outputs.version }}"
|
||||
CHANNEL="${{ steps.releaseinfo.outputs.channel }}"
|
||||
|
||||
API_URL="https://${{ vars.RELEASE_URL }}/api/v1/repos/${GITHUB_REPOSITORY}/releases"
|
||||
|
||||
JSON=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "Version $VERSION" \
|
||||
--arg body "${{ steps.changelog.outputs.current }}" \
|
||||
--argjson draft "${{ steps.releaseinfo.outputs.draft }}" \
|
||||
--argjson prerelease "${{ steps.releaseinfo.outputs.prerelease }}" \
|
||||
'{tag_name: $tag, name: $name, body: $body, draft: $draft, prerelease: $prerelease}')
|
||||
|
||||
API_RESPONSE=$(curl -s -X POST "$API_URL" \
|
||||
-H "accept: application/json" \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON")
|
||||
|
||||
RELEASE_ID=$(echo "$API_RESPONSE" | jq -r '.id')
|
||||
|
||||
if [ "$RELEASE_ID" = "null" ] || [ -z "$RELEASE_ID" ]; then
|
||||
echo "Fehler beim Erstellen des Releases!"
|
||||
echo "$API_RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "release_id=$RELEASE_ID" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: ZIP-Datei hochladen
|
||||
run: |
|
||||
RELEASE_ID="${{ steps.create_release.outputs.release_id }}"
|
||||
ZIP_FILE="${{ steps.zip.outputs.zip_file }}"
|
||||
|
||||
API_URL="https://${{ vars.RELEASE_URL }}/api/v1/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?name=${ZIP_FILE}"
|
||||
|
||||
curl -s -X POST "$API_URL" \
|
||||
-H "Authorization: token ${{ secrets.RELEASE_TOKEN }}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary "@repo/dist/${ZIP_FILE}" \
|
||||
-o upload_response.json
|
||||
|
||||
# Optional: Fehlerprüfung
|
||||
if jq -e '.id' upload_response.json >/dev/null 2>&1; then
|
||||
echo "ZIP erfolgreich hochgeladen."
|
||||
else
|
||||
echo "Fehler beim Hochladen der ZIP!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Payload erzeugen
|
||||
run: |
|
||||
cd repo
|
||||
|
||||
VERSION="${{ steps.releaseinfo.outputs.version }}"
|
||||
CHANNEL="${{ steps.releaseinfo.outputs.channel }}"
|
||||
ZIP_FILE="${{ steps.zip.outputs.zip_file }}"
|
||||
|
||||
DOWNLOAD_URL="https://${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}/releases/download/${{ github.ref_name }}/${ZIP_FILE}"
|
||||
|
||||
jq -n \
|
||||
--arg name "${{ steps.info.outputs.name }}" \
|
||||
--arg version "$VERSION" \
|
||||
--arg channel "$CHANNEL" \
|
||||
--arg description "${{ steps.info.outputs.description }}" \
|
||||
--arg author "${{ steps.info.outputs.author }}" \
|
||||
--arg email "${{ steps.info.outputs.email }}" \
|
||||
--arg qgis_min "${{ steps.info.outputs.qgisMinimumVersion }}" \
|
||||
--arg qgis_max "${{ steps.info.outputs.qgisMaximumVersion }}" \
|
||||
--arg homepage "${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}" \
|
||||
--arg tracker "${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}" \
|
||||
--arg repository "${{ vars.RELEASE_URL }}/${GITHUB_REPOSITORY}" \
|
||||
--arg experimental "${{ steps.info.outputs.experimental }}" \
|
||||
--arg deprecated "${{ steps.info.outputs.deprecated }}" \
|
||||
--arg qt6 "${{ steps.info.outputs.supportsQt6 }}" \
|
||||
--arg id "${{ steps.info.outputs.zip_folder }}" \
|
||||
--arg url "$DOWNLOAD_URL" \
|
||||
--arg changelog "${{ steps.changelog.outputs.current }}" \
|
||||
'{
|
||||
name: $name,
|
||||
version: $version,
|
||||
channel: $channel,
|
||||
description: $description,
|
||||
author: $author,
|
||||
email: $email,
|
||||
qgis_min: $qgis_min,
|
||||
qgis_max: $qgis_max,
|
||||
homepage: $homepage,
|
||||
tracker: $tracker,
|
||||
repository: $repository,
|
||||
experimental: $experimental,
|
||||
deprecated: $deprecated,
|
||||
qt6: $qt6,
|
||||
id: $id,
|
||||
url: $url,
|
||||
changelog: $changelog
|
||||
}' > payload.json
|
||||
|
||||
- name: Repository aktualisieren
|
||||
run: |
|
||||
OWNER="AG_QGIS"
|
||||
WORKFLOW="update.yml"
|
||||
|
||||
PAYLOAD_B64=$(base64 -w0 repo/payload.json)
|
||||
|
||||
FULL_NAME="${{ steps.info.outputs.name }}"
|
||||
NAME=$(echo "$FULL_NAME" | awk -F'|' '{gsub(/^ +| +$/,"",$2); print $2}')
|
||||
TAG="${{ steps.releaseinfo.outputs.version }}"
|
||||
|
||||
JSON="{\"ref\":\"hidden/workflows\",\"inputs\":{\"payload\":\"$PAYLOAD_B64\",\"name\":\"$NAME\",\"tag\":\"$TAG\"}}"
|
||||
|
||||
#JSON="{\"ref\":\"hidden/workflows\",\"inputs\":{\"payload\":\"$PAYLOAD_B64\"}}"
|
||||
|
||||
echo "DEBUG | Sende JSON:"
|
||||
echo "$JSON"
|
||||
|
||||
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"
|
||||
+16
@@ -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 |
@@ -1,188 +0,0 @@
|
||||
# Wrapper‑Architektur – Übersicht
|
||||
Die Wrapper‑Architektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGIS‑Plugin‑Entwicklung.
|
||||
Sie kapselt sämtliche QGIS‑ und Qt‑Abhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch:
|
||||
|
||||
Mock‑fähige Unit‑Tests ohne QGIS
|
||||
|
||||
PyQt5/6‑Kompatibilität ohne Code‑Änderungen
|
||||
|
||||
saubere Trennung von UI, Logik und Infrastruktur
|
||||
|
||||
stabile APIs, die unabhängig von QGIS‑Versionen bleiben
|
||||
|
||||
klare Erweiterbarkeit für zukünftige Module und Plugins
|
||||
|
||||
Die Wrapper‑Schicht ist das zentrale Bindeglied zwischen der Plugin‑Logik und der QGIS‑/Qt‑Umgebung.
|
||||
|
||||
## Ziele der Wrapper‑Architektur
|
||||
🎯 1. Entkopplung von QGIS und Qt
|
||||
Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der Plugin‑Logik.
|
||||
Stattdessen werden sie über Wrapper‑Module abstrahiert.
|
||||
|
||||
🎯 2. Testbarkeit ohne QGIS
|
||||
Im Mock‑Modus liefern die Wrapper:
|
||||
|
||||
Dummy‑Objekte
|
||||
|
||||
simulierte Rückgabewerte
|
||||
|
||||
speicherbare Zustände (z. B. Variablen, Layer, Nachrichten)
|
||||
|
||||
Damit laufen Tests in jeder CI‑Umgebung.
|
||||
|
||||
🎯 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 Qt‑API, wird nur der Wrapper angepasst, nicht jedes Plugin.
|
||||
|
||||
## Architekturüberblick
|
||||
Die Wrapper‑Schicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben.
|
||||
|
||||
### 1. qt_wrapper – Qt‑Abstraktion
|
||||
Kapselt alle Qt‑Widgets, Dialoge und Konstanten:
|
||||
|
||||
QWidget, QDialog, QMessageBox, QToolBar, QMenu, …
|
||||
|
||||
Layouts, Buttons, Labels, LineEdits
|
||||
|
||||
Qt‑Konstanten wie YES, NO, Dock‑Areas
|
||||
|
||||
Mock‑Modus:
|
||||
Stellt Dummy‑Widgets bereit, die keine UI öffnen.
|
||||
|
||||
### 2. qgiscore_wrapper – QGIS‑Core‑Abstraktion
|
||||
Abstraktion für:
|
||||
|
||||
QgsProject
|
||||
|
||||
Layer‑Zugriff
|
||||
|
||||
Projekt‑Metadaten
|
||||
|
||||
Pfade, CRS, Feature‑Zugriff
|
||||
|
||||
Mock‑Modus:
|
||||
Simuliert ein Projekt und Layer‑Container.
|
||||
|
||||
### 3. qgisui_wrapper – QGIS‑UI‑Abstraktion
|
||||
Kapselt UI‑bezogene QGIS‑Funktionen:
|
||||
|
||||
Zugriff auf iface
|
||||
|
||||
Dock‑Management
|
||||
|
||||
Menü‑ und Toolbar‑Integration
|
||||
|
||||
Hauptfenster‑Zugriff
|
||||
|
||||
Mock‑Modus:
|
||||
Stellt ein Dummy‑Interface bereit.
|
||||
|
||||
### 4. variable_wrapper – QGIS‑Variablen
|
||||
Abstraktion für:
|
||||
|
||||
Projektvariablen (projectScope)
|
||||
|
||||
globale Variablen (globalScope)
|
||||
|
||||
Mock‑Speicher für Tests
|
||||
|
||||
Vorteile:
|
||||
|
||||
keine QGIS‑Abhängigkeit in der Logik
|
||||
|
||||
testbare Variablenverwaltung
|
||||
|
||||
einheitliches API
|
||||
|
||||
### 5. message_wrapper – Meldungen & Logging
|
||||
Einheitliche Schnittstelle für:
|
||||
|
||||
Fehlermeldungen
|
||||
|
||||
Warnungen
|
||||
|
||||
Info‑Meldungen
|
||||
|
||||
Logging
|
||||
|
||||
Mock‑Modus:
|
||||
Speichert Nachrichten statt sie an QGIS zu senden.
|
||||
|
||||
### 6. dialog_wrapper – Benutzer‑Dialoge
|
||||
Abstraktion für:
|
||||
|
||||
Ja/Nein‑Dialoge
|
||||
|
||||
spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.)
|
||||
|
||||
Mock‑Modus:
|
||||
Gibt Default‑Werte zurück, öffnet keine UI.
|
||||
|
||||
### 7. DockManager & Navigation
|
||||
Diese Module nutzen die Wrapper‑Schicht, um:
|
||||
|
||||
DockWidgets sicher zu verwalten
|
||||
|
||||
Toolbars und Menüs zu erzeugen
|
||||
|
||||
Reload‑sichere UI‑Strukturen aufzubauen
|
||||
|
||||
Sie sind keine Wrapper, sondern Wrapper‑Konsumenten.
|
||||
|
||||
## Designprinzipien
|
||||
🧱 1. Single Source of Truth
|
||||
Jede QGIS‑ oder Qt‑Funktionalität wird nur an einer Stelle implementiert.
|
||||
|
||||
🔄 2. Austauschbarkeit
|
||||
Mock‑Modus und Echtmodus sind vollständig austauschbar.
|
||||
|
||||
🧪 3. Testbarkeit
|
||||
Jede Funktion kann ohne QGIS getestet werden.
|
||||
|
||||
🧼 4. Saubere Trennung
|
||||
UI → qt_wrapper
|
||||
|
||||
QGIS‑Core → qgiscore_wrapper
|
||||
|
||||
QGIS‑UI → 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 QGIS‑Abhängigkeiten in der Logik
|
||||
|
||||
IDE‑freundlich (Pylance, Autocomplete, Typing)
|
||||
|
||||
CI‑fähig (Tests ohne QGIS)
|
||||
|
||||
saubere Architektur
|
||||
|
||||
leichte Wartbarkeit
|
||||
|
||||
klare Dokumentation
|
||||
|
||||
## Fazit
|
||||
Die Wrapper‑Architektur ist das Herzstück von sn_basis.
|
||||
Sie ermöglicht eine moderne, modulare und testbare QGIS‑Plugin‑Entwicklung, die unabhängig von QGIS‑Versionen, Qt‑Versionen und Entwicklungsumgebungen funktioniert.
|
||||
|
||||
Sie bildet die Grundlage für:
|
||||
|
||||
stabile APIs
|
||||
|
||||
saubere UI‑Abstraktion
|
||||
|
||||
automatisierte Tests
|
||||
|
||||
nachhaltige Weiterentwicklung
|
||||
@@ -1,144 +0,0 @@
|
||||
# Wrapper‑Architektur – Übersicht
|
||||
Die Wrapper‑Architektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGIS‑Plugin‑Entwicklung.
|
||||
Sie kapselt sämtliche QGIS‑ und Qt‑Abhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch:
|
||||
|
||||
- Mock‑fähige Unit‑Tests ohne QGIS
|
||||
- PyQt5/6‑Kompatibilität ohne Code‑Änderungen
|
||||
- saubere Trennung von UI, Logik und Infrastruktur
|
||||
- stabile APIs, die unabhängig von QGIS‑Versionen bleiben
|
||||
- klare Erweiterbarkeit für zukünftige Module und Plugins
|
||||
|
||||
Die Wrapper‑Schicht ist das zentrale Bindeglied zwischen der Plugin‑Logik und der QGIS‑/Qt‑Umgebung.
|
||||
|
||||
## Ziele der Wrapper‑Architektur
|
||||
1. Entkopplung von QGIS und Qt
|
||||
Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der Plugin‑Logik.
|
||||
Stattdessen werden sie über Wrapper‑Module abstrahiert.
|
||||
|
||||
2. Testbarkeit ohne QGIS
|
||||
Im Mock‑Modus liefern die Wrapper:
|
||||
|
||||
- Dummy‑Objekte
|
||||
- simulierte Rückgabewerte
|
||||
- speicherbare Zustände (z. B. Variablen, Layer, Nachrichten)
|
||||
|
||||
Damit laufen Tests in jeder CI‑Umgebung.
|
||||
|
||||
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 Qt‑API, wird nur der Wrapper angepasst, nicht jedes Plugin.
|
||||
|
||||
## Architekturüberblick
|
||||
Die Wrapper‑Schicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben.
|
||||
|
||||
### 1. qt_wrapper – Qt‑Abstraktion
|
||||
Kapselt alle Qt‑Widgets, Dialoge und Konstanten:
|
||||
|
||||
- QWidget, QDialog, QMessageBox, QToolBar, QMenu, …
|
||||
- Layouts, Buttons, Labels, LineEdits
|
||||
- Qt‑Konstanten wie YES, NO, Dock‑Areas
|
||||
|
||||
Mock‑Modus:
|
||||
Stellt Dummy‑Widgets bereit, die keine UI öffnen.
|
||||
|
||||
### 2. qgiscore_wrapper – QGIS‑Core‑Abstraktion
|
||||
Abstraktion für:
|
||||
|
||||
- QgsProject
|
||||
- Layer‑Zugriff
|
||||
- Projekt‑Metadaten
|
||||
- Pfade, CRS, Feature‑Zugriff
|
||||
|
||||
Mock‑Modus:
|
||||
Simuliert ein Projekt und Layer‑Container.
|
||||
|
||||
### 3. qgisui_wrapper – QGIS‑UI‑Abstraktion
|
||||
Kapselt UI‑bezogene QGIS‑Funktionen:
|
||||
|
||||
- Zugriff auf iface
|
||||
- Dock‑Management
|
||||
- Menü‑ und Toolbar‑Integration
|
||||
- Hauptfenster‑Zugriff
|
||||
|
||||
Mock‑Modus:
|
||||
Stellt ein Dummy‑Interface bereit.
|
||||
|
||||
### 4. variable_wrapper – QGIS‑Variablen
|
||||
Abstraktion für:
|
||||
|
||||
- Projektvariablen (projectScope)
|
||||
- globale Variablen (globalScope)
|
||||
- Mock‑Speicher für Tests
|
||||
|
||||
Vorteile:
|
||||
|
||||
- keine QGIS‑Abhängigkeit in der Logik
|
||||
- testbare Variablenverwaltung
|
||||
- einheitliches API
|
||||
|
||||
### 5. message_wrapper – Meldungen & Logging
|
||||
Einheitliche Schnittstelle für:
|
||||
|
||||
- Fehlermeldungen
|
||||
- Warnungen
|
||||
- Info‑Meldungen
|
||||
- Logging
|
||||
|
||||
Mock‑Modus:
|
||||
Speichert Nachrichten statt sie an QGIS zu senden.
|
||||
|
||||
### 6. dialog_wrapper – Benutzer‑Dialoge
|
||||
Abstraktion für:
|
||||
|
||||
- Ja/Nein‑Dialoge
|
||||
- spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.)
|
||||
|
||||
Mock‑Modus:
|
||||
Gibt Default‑Werte zurück, öffnet keine UI.
|
||||
|
||||
### 7. DockManager & Navigation
|
||||
Diese Module nutzen die Wrapper‑Schicht, um:
|
||||
|
||||
- DockWidgets sicher zu verwalten
|
||||
- Toolbars und Menüs zu erzeugen
|
||||
- Reload‑sichere UI‑Strukturen aufzubauen
|
||||
|
||||
Sie sind keine Wrapper, sondern Wrapper‑Konsumenten. Alle Fach-Plugins nutzen den Dockmanager des Basisplugins.
|
||||
|
||||
## Designprinzipien
|
||||
1. Single Source of Truth
|
||||
Jede QGIS‑ oder Qt‑Funktionalität wird nur an einer Stelle implementiert.
|
||||
|
||||
2. Austauschbarkeit
|
||||
Mock‑Modus und Echtmodus sind vollständig austauschbar.
|
||||
|
||||
3. Testbarkeit
|
||||
Jede Funktion kann ohne QGIS getestet werden.
|
||||
|
||||
4. Saubere Trennung
|
||||
- UI → qt_wrapper
|
||||
- QGIS‑Core → qgiscore_wrapper
|
||||
- QGIS‑UI → 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 |
@@ -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.
@@ -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
|
||||
```
|
||||
@@ -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 1–4 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.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
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
|
||||
@@ -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,
|
||||
|
||||
+247
-1
@@ -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,205 @@ 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 VG 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"
|
||||
|
||||
|
||||
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 create_progress_dialog(total: int, title: str = "Fortschritt", label: str = "Verarbeite...") -> ProgressDialog:
|
||||
return ProgressDialog(total, title, label)
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -20,6 +20,43 @@ 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]
|
||||
|
||||
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 +73,24 @@ 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,
|
||||
)
|
||||
|
||||
QgsProject = _QgsProject
|
||||
@@ -50,7 +102,114 @@ 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
|
||||
|
||||
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 +220,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 +239,9 @@ except Exception:
|
||||
def read(self) -> bool:
|
||||
return True
|
||||
|
||||
def layoutManager(self):
|
||||
return self._layout_manager
|
||||
|
||||
QgsProject = _MockQgsProject
|
||||
|
||||
class _MockQgsVectorLayer:
|
||||
@@ -122,6 +292,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:
|
||||
@@ -382,3 +954,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
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------
|
||||
|
||||
+71
-3
@@ -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,9 @@ 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
|
||||
|
||||
|
||||
def exec_dialog(dialog: Any) -> Any:
|
||||
@@ -64,10 +71,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,10 +89,13 @@ 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,
|
||||
@@ -98,7 +110,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 +124,7 @@ try:
|
||||
QGridLayout = _QGridLayout
|
||||
QLabel = _QLabel
|
||||
QLineEdit = _QLineEdit
|
||||
QInputDialog = _QInputDialog
|
||||
QGroupBox = _QGroupBox
|
||||
QVBoxLayout = _QVBoxLayout
|
||||
QPushButton = _QPushButton
|
||||
@@ -120,8 +136,10 @@ try:
|
||||
QToolButton = _QToolButton
|
||||
QSizePolicy = _QSizePolicy
|
||||
QComboBox = _QComboBox
|
||||
QCheckBox = _QCheckBox
|
||||
QVariant = _QVariant
|
||||
QHBoxLayout= _QHBoxLayout
|
||||
QHBoxLayout = _QHBoxLayout
|
||||
QFont = _QFont
|
||||
# ✅ QT6 ENUMS
|
||||
YES = QMessageBox.StandardButton.Yes
|
||||
NO = QMessageBox.StandardButton.No
|
||||
@@ -158,6 +176,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,10 +189,13 @@ 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,
|
||||
@@ -189,6 +211,7 @@ except (ImportError, AttributeError):
|
||||
QMessageBox = _QMessageBox
|
||||
QFileDialog = _QFileDialog
|
||||
QEventLoop = _QEventLoop
|
||||
QTimer = _QTimer
|
||||
QUrl = _QUrl
|
||||
QNetworkRequest = _QNetworkRequest
|
||||
QNetworkReply = _QNetworkReply
|
||||
@@ -199,6 +222,7 @@ except (ImportError, AttributeError):
|
||||
QGridLayout = _QGridLayout
|
||||
QLabel = _QLabel
|
||||
QLineEdit = _QLineEdit
|
||||
QInputDialog = _QInputDialog
|
||||
QGroupBox = _QGroupBox
|
||||
QVBoxLayout = _QVBoxLayout
|
||||
QPushButton = _QPushButton
|
||||
@@ -210,8 +234,10 @@ except (ImportError, AttributeError):
|
||||
QToolButton = _QToolButton
|
||||
QSizePolicy = _QSizePolicy
|
||||
QComboBox = _QComboBox
|
||||
QCheckBox = _QCheckBox
|
||||
QVariant = _QVariant
|
||||
QHBoxLayout = _QHBoxLayout
|
||||
QHBoxLayout= _QHBoxLayout
|
||||
QFont = _QFont
|
||||
|
||||
# ✅ PYQT5 ENUMS
|
||||
YES = QMessageBox.Yes
|
||||
@@ -283,12 +309,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 +363,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 +384,7 @@ except (ImportError, AttributeError):
|
||||
QGroupBox = _MockWidget
|
||||
QVBoxLayout = _MockLayout
|
||||
QPushButton = _MockButton
|
||||
QFont = _MockFont
|
||||
QCoreApplication = object()
|
||||
|
||||
class _MockQt:
|
||||
@@ -507,6 +559,22 @@ except (ImportError, AttributeError):
|
||||
def setContentsMargins(self, *args, **kwargs):
|
||||
pass
|
||||
QHBoxLayout = _MockQHBoxLayout
|
||||
|
||||
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 ---------------------------
|
||||
|
||||
@@ -22,6 +22,7 @@ class SettingsLogic:
|
||||
"landkreis_user",
|
||||
"sachgebiet",
|
||||
"bezeichnung",
|
||||
"name",
|
||||
"verfahrensnummer",
|
||||
"gemeinden",
|
||||
"landkreise_proj",
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[general]
|
||||
version=26.5.1-unstable
|
||||
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
|
||||
+212
-33
@@ -2,14 +2,14 @@
|
||||
DataGrabber module
|
||||
==================
|
||||
|
||||
UI‑freier 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 UI‑Interaktion 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 **UI‑frei**. 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 **UI‑frei**. 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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Datenbank‑Quellen
|
||||
@@ -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 UI‑Interaktion.**
|
||||
**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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
)
|
||||
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
name=LNO Sachsen | Basisfunktionen
|
||||
description=Plugin mit Basisfunktionen
|
||||
author=Daniel Helbig
|
||||
email=daniel.helbig@kreis-meissen.de
|
||||
qgisMinimumVersion=3.0
|
||||
qgisMaximumVersion=3.99
|
||||
deprecated=False
|
||||
experimental=False
|
||||
supportsQt6=Yes
|
||||
|
||||
zip_folder=sn_basis
|
||||
+4
-1
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
sn_basis/ui – Benutzeroberflächen-Module für sn_basis.
|
||||
Enthält Dock-Manager, Navigation und einzelne Tabs.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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):",
|
||||
|
||||
Reference in New Issue
Block a user