Compare commits

...
Author SHA1 Message Date
DanielandClaude Sonnet 5 272bad3866 Weitere fehlende Umlaute in Docstrings/Kommentaren behoben (#42)
Gefunden ueber das neue Skript scripts/pruefe_umlaute.py, das genau dieses
Muster (ASCII-Ersatzschreibweise statt Umlaut in String-Inhalten und
Kommentaren mit Leerzeichen) erkennt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MneDjzrjmbpQt8udFYxN9e
2026-09-14 12:21:01 +02:00
DanielandClaude Sonnet 5 72844cd257 Fehlende Umlaute in Drucklayout-Hinweisen und -Beschriftungen behoben (#42)
Weitere Fundstellen aus der gezielten Umlaut-Pruefung: MessageBox-/Label-Texte
in print_logic.py, print_layout.py, print_layout_baufreigabe.py und
print_tab.py, die wortwoertlich als ASCII generiert wurden ('gueltig',
'verfuegbar', 'waehlen', 'Massstab' als Anzeigetext, 'Geprueft: '-Label).
Interne Bezeichner (aktion-Codes, Projektvariablen-Keys, Item-IDs) bewusst
unveraendert gelassen, um bestehende Log-/Vergleichslogik nicht zu brechen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MneDjzrjmbpQt8udFYxN9e
2026-09-14 12:09:30 +02:00
DanielandClaude Sonnet 5 a754fcfe34 Umlaute/Sonderzeichen in Bezeichnern zentral behandeln (#42)
Regex zur Bezeichner-Bereinigung aus Datenschreiber.py in
functions/text_wrapper.py (sanitize_bezeichner) ausgelagert und um
Bindestrich ergänzt, der bisher fehlte. Neue Tests decken Umlaute, ß,
Unterstrich, Bindestrich, eigenes Ersatzzeichen und Leerfälle ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MneDjzrjmbpQt8udFYxN9e
2026-09-14 11:09:41 +02:00
22ottomi 7755e3e8cb Merge pull request 'Fix: DataGrabber lädt ohne sn_plan41 (ModuleNotFoundError beim Laden von sn_verfahrensgebiet)' (#57) from bugfix/35-sn-plan41-dependency into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #57
2026-08-31 09:53:48 +02:00
Michael Otto bc9bb20597 Fix: DataGrabber lädt ohne sn_plan41 (ModuleNotFoundError beim Laden von sn_verfahrensgebiet)
DataGrabber.py hatte einen harten Top-Level-Import von
sn_plan41.modules.listenauswerter.Listenauswerter, obwohl DataGrabber sich
selbst als generischer, plugin-übergreifender Orchestrator dokumentiert.
sn_verfahrensgebiet importiert DataGrabber und instanziiert ihn beim
Plugin-Start – dadurch schlug classFactory() fehl, sobald sn_plan41 nicht
installiert war (ModuleNotFoundError: No module named 'sn_plan41').

Import in die einzige Stelle verschoben, die ihn tatsächlich braucht
(_process_excel_source, nur für Excel-Quellen relevant), mit klarer
Fehlermeldung falls doch mal ohne sn_plan41 eine Excel-Quelle verarbeitet
werden soll.

Fixes sn_verfahrensgebiet#35
2026-08-31 09:52:48 +02:00
22ottomi 113fda7d04 Merge pull request 'Feature/mehrfachauswahl alkis' (#55) from feature/mehrfachauswahl-alkis into unstable
Release Plugin / release (push) Successful in 3s
Reviewed-on: #55
2026-08-31 09:15:26 +02:00
Michael Otto 8545152317 Merge branch 'unstable' into aus-zip-laden
Konflikt in changelog.txt (beide Seiten hatten oben neue, unabhängige
Einträge ergänzt) von Hand aufgelöst – beide Blöcke bleiben erhalten.
2026-08-31 08:52:24 +02:00
Michael OttoandClaude Sonnet 5 8cea9022f0 Länderkennung-Mapping portiert (Voraussetzung für Beteiligungsprüfung)
Portiert aus origin/unstable: sn_basis/functions/laenderkennung.py bildet
Plugin-Präfix -> zweistellige ALKIS-Länderkennung ab (aktuell nur Sachsen).
Wird von der neuen Beteiligungsprüfung in sn_verfahrensgebiet für die
Flurstückskennzeichen-Bildung genutzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 06:33:16 +02:00
Michael OttoandClaude Sonnet 5 1b5a715c70 Landkreisliste als JSON auslagern; Verfahrensgebiet-Dialoge nach sn_verfahrensgebiet verschoben
- Landkreisliste liegt jetzt in data/landkreise.json statt hartcodiert in
  landkreise.py, damit sie ohne Codeänderung pflegbar und auch aus anderen
  Modulen nutzbar ist (Sentinel ALLE_LANDKREISE für "Alle Landkreise").
- ask_bror_kombinationen, ask_landkreis_objekte und ask_landkreis_auswahl aus
  dialog_wrapper.py entfernt: sie sind sn_verfahrensgebiet-spezifisches
  Vokabular (BROR/VKZ/Landkreis-Fallback) und gehören nicht in den
  domänenfreien sn_basis-Hub. Sie leben jetzt in
  sn_verfahrensgebiet/functions/verfahrensgebiet_dialoge.py.
- qt_wrapper.py: QTableWidget/QTableWidgetItem/QTableWidgetSelectionRange,
  SelectRows, NoEditTriggers, AscendingOrder ergänzt (für die neue
  Tabellen-Auswahl im Landkreis-Objektdialog).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 13:54:43 +02:00
Michael Otto 749d6d6f52 Landkreis-Dropdown, BROR-Mehrfachauswahl-Dialog und UX-Fixes
- Landkreis-Feld in den Projekteigenschaften als Dropdown statt Freitext,
  Landkreiskennzahl zusätzlich als eigene Projektvariable verfügbar.
- ask_bror_kombination(en) auf echte Mehrfachauswahl umgestellt, inkl.
  Titel, Wappen-Icon, kompakter/anpassbarer Größe und Scrollbar-Fix.
- Neuer Dialog ask_landkreis_objekte für die landkreisweite Objektsuche
  (Mehrfachauswahl, "Alle auswählen", "Nur laden ohne weiteren Ablauf").
- qt_wrapper um QDialog/QDialogButtonBox/QListWidget/QListWidgetItem/
  QAbstractItemView/QIcon sowie QComboBox-userData/currentData erweitert.
2026-08-27 15:13:43 +02:00
Daniel 29fea7c18f changelog.txt aktualisiert
Release Plugin / release (push) Successful in 4s
2026-08-26 16:18:25 +02:00
Daniel d857343d36 changelog.txt aktualisiert 2026-08-26 16:17:52 +02:00
Daniel 7bcd0132e9 Merge pull request 'länderkennungs-dict angelegt, Vorbereitung CSV-Beteiligungsprüfung' (#54) from beteiligung_pruefen into unstable
Reviewed-on: #54
2026-08-26 16:15:23 +02:00
Daniel 3489a08638 länderkennungs-dict angelegt 2026-08-26 12:20:10 +02:00
Daniel 7a5ed12797 Merge pull request 'wfs-abruf-fix' (#53) from wfs-abruf-fix into unstable
Release Plugin / release (push) Successful in 4s
Reviewed-on: #53
2026-07-22 11:14:08 +02:00
Daniel 021747ae06 wfs-abruf-fix 2026-07-22 11:12:43 +02:00
Daniel 91bafe632f changelog.txt aktualisiert
Release Plugin / release (push) Successful in 4s
2026-07-15 16:04:55 +02:00
Daniel 7ed22fb23e Merge pull request 'Shape verfahrensgebiet' (#52) from shape-Verfahrensgebiet into unstable
Reviewed-on: #52
2026-07-15 16:03:18 +02:00
Daniel 3bbb66ec32 shape-Ladefunktion Verfahrensgebiet 2026-07-15 15:27:24 +02:00
Daniel 9862386646 changelog.txt aktualisiert 2026-07-07 10:04:22 +02:00
22 changed files with 685 additions and 151 deletions
+14 -1
View File
@@ -1,5 +1,18 @@
- Bezeichner-Bereinigung (Layernamen) um Bindestrich ergänzt und in eine zentrale Funktion `sanitize_bezeichner` (`functions/text_wrapper.py`) ausgelagert, damit Umlaute/Sonderzeichen nicht mehr pro Aufrufstelle vergessen werden (#42)
- Landkreis-Dropdown (statt Freitext) in den Projekteigenschaften ergänzt, inkl. Landkreiskennzahl als eigene Projektvariable
- Landkreisliste liegt jetzt als JSON (sn_basis/data/landkreise.json) statt hartcodiert vor, damit auch andere Module und Nicht-Entwickler sie pflegen können
- Auswahldialog für mehrere BauRaumOderBodenordnungsrecht-Treffer auf Mehrfachauswahl umgestellt (mehrere Objekte gemeinsam laden/verschmelzen)
- Landkreisweiter Objekt-Auswahldialog jetzt als Tabelle (Spalten "Nummer"/"Name", per Spaltenkopf sortierbar, Standard aufsteigend nach Nummer) mit Suchfeld (Filter nach Nummer/Name); "Alle auswählen" berücksichtigt die Filterung; Checkbox-Text zu "Nur Objekte laden (ohne weitere Prüfungen)" geändert
- Dialoge: Titel, Wappen-Icon, kompaktere Größe, Scrollbar-Fix und größenveränderbare Liste ergänzt
- Die drei Verfahrensgebiet-spezifischen ALKIS-Dialoge (BROR-Auswahl, Landkreis-Objektauswahl, Landkreis-Auswahl) sind aus sn_basis nach sn_verfahrensgebiet verschoben (functions/verfahrensgebiet_dialoge.py), da sie fachlich dorthin gehören
---
Version 26.6.1-unstable:
Version 26.8.1-unstable:
-Vergleich einer CSV aus dem BI-Server mit den Beteiligten Flurstücken lt. ALKIS
---
Version 26.7.1-unstable:
- Verfahrensgebiet als shape laden, inkl. Abgleich mit BROBOR-Objekt
---
Version 26.7.1-testing:
- VLN-API zum Laden der Plan41-Maßnahmen integriert
---
Version 26.5.2-testing:
+15
View File
@@ -0,0 +1,15 @@
[
{"kennzahl": "11", "name": "Chemnitz, Stadt"},
{"kennzahl": "12", "name": "Dresden, Stadt"},
{"kennzahl": "13", "name": "Leipzig, Stadt"},
{"kennzahl": "21", "name": "Erzgebirgskreis"},
{"kennzahl": "22", "name": "Mittelsachsen"},
{"kennzahl": "23", "name": "Vogtlandkreis"},
{"kennzahl": "24", "name": "Zwickau"},
{"kennzahl": "25", "name": "Bautzen"},
{"kennzahl": "26", "name": "Görlitz"},
{"kennzahl": "27", "name": "Meißen"},
{"kennzahl": "28", "name": "Sächsische Schweiz-Osterzgebirge"},
{"kennzahl": "29", "name": "Leipzig"},
{"kennzahl": "30", "name": "Nordsachsen"}
]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+2
View File
@@ -21,6 +21,8 @@ from .ly_metadata_wrapper import (
)
from .ly_style_wrapper import apply_style
from .dialog_wrapper import ask_yes_no, ask_overwrite_append_cancel_custom, ask_ergaenzen_entfernen_ersetzen_abbrechen, ask_detailpruefung_oder_vg_laden
from .laenderkennung import get_laenderkennung, LAENDERKENNUNGEN
from .text_wrapper import sanitize_bezeichner
from .message_wrapper import (
_get_message_bar,
+46 -50
View File
@@ -232,6 +232,52 @@ def ask_detailpruefung_oder_vg_laden(
return "abbrechen"
def ask_detailpruefung_oder_abbrechen(
parent: Any,
title: str,
message: str,
) -> Literal["detailpruefung", "abbrechen"]:
"""Zeigt Dialog mit zwei Optionen nach erkannter Flächenabweichung (Shape-Workflow).
Optionen:
- **Detailprüfung starten**: Knickpunktbasierte Prüfung des Shape-VG-Umrings
- **Abbrechen**: Vorgang beenden, Shape-Verfahrensgebiet unverändert behalten
Parameters
----------
parent :
Eltern-Widget oder None.
title : str
Dialog-Titel.
message : str
Hauptmeldung mit Flächen-Informationen.
Returns
-------
Literal["detailpruefung", "abbrechen"]
Genaue Entscheidung des Nutzers.
"""
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_detailpruefung_oder_abbrechen('{title}') → abbrechen")
return "abbrechen"
msg = QMessageBox(parent)
msg.setIcon(ICON_QUESTION)
msg.setWindowTitle(title)
msg.setText(message)
detail_btn = msg.addButton("Detailprüfung starten", QMessageBox.ButtonRole.AcceptRole)
msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole)
exec_dialog(msg)
clicked = msg.clickedButton()
if clicked == detail_btn:
return "detailpruefung"
else:
return "abbrechen"
class ProgressDialog:
def __init__(self, total: int, title: str = "Fortschritt", label: str = "Verarbeite..."):
self.total = max(total, 1)
@@ -344,53 +390,3 @@ class ProgressDialog:
def create_progress_dialog(total: int, title: str = "Fortschritt", label: str = "Verarbeite...") -> ProgressDialog:
return ProgressDialog(total, title, label)
def ask_bror_kombination(
parent: Any,
kombinationen: list[tuple[str, str]],
) -> Optional[tuple[str, str]]:
"""Zeigt Dialog zur Auswahl einer BROR-Kombination (adv_name + bezeichnung).
Wird aufgerufen, wenn der WFS mehrere verschiedene Kombinationen aus
``adv_name`` und ``bezeichnung`` zurückliefert, sodass der Nutzer
entscheiden muss, welche Objekte in die Pipeline einfließen sollen.
Parameters
----------
parent :
Eltern-Widget oder None.
kombinationen :
Sortierte Liste von ``(adv_name, bezeichnung)``-Tupeln.
Returns
-------
Optional[tuple[str, str]]
Das gewählte ``(adv_name, bezeichnung)``-Tupel oder ``None`` bei
Abbruch bzw. leerer Eingabe.
"""
if not kombinationen:
return None
if QT_VERSION == 0: # Mock-Modus
print(f"Mock-Modus: ask_bror_kombination → {kombinationen[0]}")
return kombinationen[0]
items = [f"{adv} | {bez}" for adv, bez in kombinationen]
item, ok = QInputDialog.getItem(
parent,
"BROR-Objekt auswählen",
"Mehrere BauRaumOderBodenordnungsrecht-Objekte gefunden.\n"
"Bitte wählen Sie die gewünschte Kombination\n"
"aus ADV-Name und Bezeichnung:",
items,
0,
False,
)
if not ok:
return None
try:
idx = items.index(item)
return kombinationen[idx]
except ValueError:
return None
+51
View File
@@ -0,0 +1,51 @@
"""
sn_basis/functions/laenderkennung.py ALKIS-Länderkennung je Plugin-Präfix.
AGLB-länderspezifische Funktionen (z.B. Flurstückskennzeichen-Bildung) sollen
nicht hart auf Sachsen ("14") verdrahtet sein, sondern die Länderkennung über
dieses generische Mapping ermitteln. Für neue Bundesländer genügt ein weiterer
Eintrag in ``LAENDERKENNUNGEN`` die aufrufende Logik bleibt unverändert.
"""
#: Mapping von sn_*-Plugin-Präfix (ohne Unterstrich) auf die zweistellige
#: ALKIS-Länderkennung. Aktuell nur Sachsen befüllt; weitere Bundesländer
#: können hier ergänzt werden, sobald Schwesterplugins dafür existieren.
LAENDERKENNUNGEN: dict[str, str] = {
"sn": "14", # Sachsen
}
def get_laenderkennung(plugin_praefix: str | None = None) -> str:
"""Ermittelt die ALKIS-Länderkennung für einen Plugin-Präfix.
Parameters
----------
plugin_praefix : str, optional
Plugin-Präfix ohne Unterstrich, z.B. ``"sn"``. Wird kein Präfix
übergeben, wird er aus dem Package-Namen des aufrufenden Moduls
abgeleitet (``__name__`` des Aufrufers, erstes ``_``-Segment).
Returns
-------
str
Zweistellige Länderkennung, z.B. ``"14"``.
Raises
------
KeyError
Wenn für den ermittelten Präfix keine Länderkennung hinterlegt ist.
"""
if not plugin_praefix:
import inspect
frame = inspect.stack()[1]
caller_module = frame.frame.f_globals.get("__name__", "")
plugin_praefix = caller_module.split(".")[0].split("_")[0]
plugin_praefix = plugin_praefix.lower()
if plugin_praefix not in LAENDERKENNUNGEN:
raise KeyError(
f"Keine Länderkennung für Plugin-Präfix '{plugin_praefix}' hinterlegt. "
f"Bekannte Präfixe: {sorted(LAENDERKENNUNGEN)}"
)
return LAENDERKENNUNGEN[plugin_praefix]
+47
View File
@@ -0,0 +1,47 @@
"""sn_basis/functions/landkreise.py Sächsische Landkreise/Helfer mit Kennzahl.
Daten liegen in sn_basis/data/landkreise.json (auch von anderen sn_*-Modulen
lesbar, z.B. per ``from sn_basis.functions.landkreise import LANDKREISE``).
"""
import json
import os
try:
from qgis.core import Qgis, QgsMessageLog
except Exception: # Mock-Modus / außerhalb QGIS
Qgis = None
QgsMessageLog = None
# .../sn_basis/data/landkreise.json (eine Ebene über dieser Datei, dann data/)
_DATA_DATEI = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "landkreise.json")
def _lade_landkreise() -> "list[tuple[str, str]]":
try:
with open(_DATA_DATEI, "r", encoding="utf-8") as f:
rohdaten = json.load(f)
return [(eintrag["kennzahl"], eintrag["name"]) for eintrag in rohdaten]
except Exception as exc:
if QgsMessageLog is not None:
QgsMessageLog.logMessage(
f"landkreise.json konnte nicht geladen werden ({_DATA_DATEI}): {exc}",
"sn_basis",
Qgis.Warning,
)
else:
print(f"⚠️ landkreise.json konnte nicht geladen werden ({_DATA_DATEI}): {exc}")
return []
#: Liste von (Landkreiskennzahl, Landkreis/Helfer)-Tupeln, in Datei-Reihenfolge.
LANDKREISE: "list[tuple[str, str]]" = _lade_landkreise()
#: Kennzahl -> Name
KENNZAHL_ZU_NAME: "dict[str, str]" = dict(LANDKREISE)
#: Name -> Kennzahl
NAME_ZU_KENNZAHL: "dict[str, str]" = {name: kennzahl for kennzahl, name in LANDKREISE}
#: Sentinel-"Kennzahl" für die Auswahl "Alle Landkreise" (kein Landkreis-Filter).
ALLE_LANDKREISE = "ALLE"
+193 -10
View File
@@ -46,6 +46,15 @@ QCheckBox: Type[Any] = object
QHBoxLayout: Type[Any] = object
QFont: Type[Any] = object
QSettings: Type[Any] = object
QDialog: Type[Any] = object
QDialogButtonBox: Type[Any] = object
QListWidget: Type[Any] = object
QListWidgetItem: Type[Any] = object
QAbstractItemView: Type[Any] = object
QTableWidget: Type[Any] = object
QTableWidgetItem: Type[Any] = object
QTableWidgetSelectionRange: Type[Any] = object
QIcon: Type[Any] = object
def exec_dialog(dialog: Any) -> Any:
@@ -92,8 +101,16 @@ try:
QComboBox as _QComboBox,
QCheckBox as _QCheckBox,
QHBoxLayout as _QHBoxLayout,
QDialog as _QDialog,
QDialogButtonBox as _QDialogButtonBox,
QListWidget as _QListWidget,
QListWidgetItem as _QListWidgetItem,
QAbstractItemView as _QAbstractItemView,
QTableWidget as _QTableWidget,
QTableWidgetItem as _QTableWidgetItem,
QTableWidgetSelectionRange as _QTableWidgetSelectionRange,
)
from qgis.PyQt.QtGui import QFont as _QFont
from qgis.PyQt.QtGui import QFont as _QFont, QIcon as _QIcon
from qgis.PyQt.QtCore import (
QEventLoop as _QEventLoop,
QTimer as _QTimer,
@@ -143,6 +160,15 @@ try:
QHBoxLayout = _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
QDialog = _QDialog
QDialogButtonBox = _QDialogButtonBox
QListWidget = _QListWidget
QListWidgetItem = _QListWidgetItem
QAbstractItemView = _QAbstractItemView
QTableWidget = _QTableWidget
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
# ✅ QT6 ENUMS
YES = QMessageBox.StandardButton.Yes
NO = QMessageBox.StandardButton.No
@@ -163,7 +189,12 @@ try:
DockWidgetClosable = QDockWidget.DockWidgetFeature.DockWidgetClosable
DockAreaLeft = Qt.DockWidgetArea.LeftDockWidgetArea
DockAreaRight = Qt.DockWidgetArea.RightDockWidgetArea
DialogAccepted = QDialog.DialogCode.Accepted
ExtendedSelection = QAbstractItemView.SelectionMode.ExtendedSelection
SelectRows = QAbstractItemView.SelectionBehavior.SelectRows
NoEditTriggers = QAbstractItemView.EditTrigger.NoEditTriggers
AscendingOrder = Qt.SortOrder.AscendingOrder
def exec_dialog(dialog: Any) -> Any:
return dialog.exec()
@@ -194,8 +225,16 @@ except (ImportError, AttributeError):
QComboBox as _QComboBox,
QCheckBox as _QCheckBox,
QHBoxLayout as _QHBoxLayout,
QDialog as _QDialog,
QDialogButtonBox as _QDialogButtonBox,
QListWidget as _QListWidget,
QListWidgetItem as _QListWidgetItem,
QAbstractItemView as _QAbstractItemView,
QTableWidget as _QTableWidget,
QTableWidgetItem as _QTableWidgetItem,
QTableWidgetSelectionRange as _QTableWidgetSelectionRange,
)
from PyQt5.QtGui import QFont as _QFont
from PyQt5.QtGui import QFont as _QFont, QIcon as _QIcon
from PyQt5.QtCore import (
QEventLoop as _QEventLoop,
QTimer as _QTimer,
@@ -243,7 +282,16 @@ except (ImportError, AttributeError):
QHBoxLayout= _QHBoxLayout
QFont = _QFont
QSettings = _QSettings
QDialog = _QDialog
QDialogButtonBox = _QDialogButtonBox
QListWidget = _QListWidget
QListWidgetItem = _QListWidgetItem
QAbstractItemView = _QAbstractItemView
QTableWidget = _QTableWidget
QTableWidgetItem = _QTableWidgetItem
QTableWidgetSelectionRange = _QTableWidgetSelectionRange
QIcon = _QIcon
# ✅ PYQT5 ENUMS
YES = QMessageBox.Yes
NO = QMessageBox.No
@@ -265,7 +313,12 @@ except (ImportError, AttributeError):
DockWidgetClosable = QDockWidget.DockWidgetClosable
DockAreaLeft = Qt.LeftDockWidgetArea
DockAreaRight = Qt.RightDockWidgetArea
DialogAccepted = QDialog.Accepted
ExtendedSelection = QAbstractItemView.ExtendedSelection
SelectRows = QAbstractItemView.SelectRows
NoEditTriggers = QAbstractItemView.NoEditTriggers
AscendingOrder = Qt.AscendingOrder
def exec_dialog(dialog: Any) -> Any:
return dialog.exec_()
@@ -319,6 +372,10 @@ except (ImportError, AttributeError):
def getText(parent, title, label, mode=None, text=""):
return text, True
@staticmethod
def getItem(parent, title, label, items, current=0, editable=False):
return (items[current] if items else ""), True
QInputDialog = _MockQInputDialog
class _MockQEventLoop:
@@ -482,13 +539,19 @@ except (ImportError, AttributeError):
class _MockComboBox:
def __init__(self, parent=None):
self._items = []
self._items = [] # Liste von (text, userData)
self._index = -1
self.currentTextChanged = type('Signal', (), {'connect': lambda s, cb: None, 'emit': lambda s, v: None})()
def addItem(self, text: str) -> None: self._items.append(text)
def addItem(self, text: str, userData=None) -> None:
self._items.append((text, userData))
if self._index < 0:
self._index = 0
def addItems(self, items): [self.addItem(it) for it in items]
def findText(self, text: str) -> int:
return self._items.index(text) if text in self._items else -1
def findText(self, text: str) -> int:
for i, (t, _) in enumerate(self._items):
if t == text:
return i
return -1
def setCurrentIndex(self, idx: int) -> None:
if 0 <= idx < len(self._items):
self._index = idx
@@ -497,7 +560,11 @@ except (ImportError, AttributeError):
idx = self.findText(text)
if idx >= 0: self.setCurrentIndex(idx)
def currentText(self) -> str:
return self._items[self._index] if 0 <= self._index < len(self._items) else ""
return self._items[self._index][0] if 0 <= self._index < len(self._items) else ""
def currentData(self):
return self._items[self._index][1] if 0 <= self._index < len(self._items) else None
def count(self) -> int:
return len(self._items)
QComboBox = _MockComboBox
@@ -605,6 +672,122 @@ except (ImportError, AttributeError):
self._checked = checked
QCheckBox = _MockQCheckBox
DialogAccepted = 1
ExtendedSelection = 3
SelectRows = 1
NoEditTriggers = 0
AscendingOrder = 0
class _MockQDialog(_MockWidget):
DialogCode = type("DialogCode", (), {"Accepted": DialogAccepted, "Rejected": 0})
def __init__(self, *args, **kwargs):
self._layout = None
def setWindowTitle(self, title: str) -> None: pass
def setWindowIcon(self, icon) -> None: pass
def exec(self) -> int: return DialogAccepted
def exec_(self) -> int: return DialogAccepted
def accept(self) -> None: pass
def reject(self) -> None: pass
def resize(self, *args, **kwargs) -> None: pass
def adjustSize(self) -> None: pass
def sizeHint(self):
return type("Size", (), {"width": lambda self=None: 200, "height": lambda self=None: 100})()
QDialog = _MockQDialog
class _MockQDialogButtonBox(_MockWidget):
StandardButton = type("StandardButton", (), {"Ok": 1, "Cancel": 2})
Ok = 1
Cancel = 2
def __init__(self, *args, **kwargs):
self.accepted = type('Signal', (), {'connect': lambda s, cb: None})()
self.rejected = type('Signal', (), {'connect': lambda s, cb: None})()
QDialogButtonBox = _MockQDialogButtonBox
class _MockQListWidgetItem:
def __init__(self, text: str = ""):
self._text = text
self._selected = False
def setSelected(self, value: bool) -> None: self._selected = value
def isSelected(self) -> bool: return self._selected
QListWidgetItem = _MockQListWidgetItem
class _MockQListWidget(_MockWidget):
def __init__(self, *args, **kwargs):
self._items = []
def addItem(self, item) -> None: self._items.append(item)
def item(self, index: int): return self._items[index] if 0 <= index < len(self._items) else None
def count(self) -> int: return len(self._items)
def row(self, item) -> int: return self._items.index(item) if item in self._items else -1
def selectedItems(self): return [i for i in self._items if i.isSelected()]
def setSelectionMode(self, *args, **kwargs) -> None: pass
def sizeHintForRow(self, index: int) -> int: return 20
def setFixedHeight(self, height: int) -> None: pass
def frameWidth(self) -> int: return 1
QListWidget = _MockQListWidget
class _MockQAbstractItemView:
SelectionMode = type("SelectionMode", (), {"ExtendedSelection": ExtendedSelection})
QAbstractItemView = _MockQAbstractItemView
class _MockQTableWidgetItem:
def __init__(self, text: str = ""):
self._text = text
def text(self) -> str: return self._text
def setText(self, text: str) -> None: self._text = text
QTableWidgetItem = _MockQTableWidgetItem
class _MockQTableWidget(_MockWidget):
def __init__(self, rows: int = 0, cols: int = 0, *args, **kwargs):
self._items = {}
self._row_count = rows
self._col_count = cols
self._hidden_rows = set()
def setItem(self, row: int, col: int, item) -> None: self._items[(row, col)] = item
def item(self, row: int, col: int): return self._items.get((row, col))
def rowCount(self) -> int: return self._row_count
def setRowCount(self, rows: int) -> None: self._row_count = rows
def setHorizontalHeaderLabels(self, labels) -> None: pass
def setSelectionMode(self, *args, **kwargs) -> None: pass
def setSelectionBehavior(self, *args, **kwargs) -> None: pass
def setEditTriggers(self, *args, **kwargs) -> None: pass
def setSortingEnabled(self, *args, **kwargs) -> None: pass
def setRowHidden(self, row: int, hidden: bool) -> None:
if hidden: self._hidden_rows.add(row)
else: self._hidden_rows.discard(row)
def isRowHidden(self, row: int) -> bool: return row in self._hidden_rows
def selectRow(self, row: int) -> None: pass
def selectedItems(self): return []
def setRangeSelected(self, *args, **kwargs) -> None: pass
def sortByColumn(self, *args, **kwargs) -> None: pass
def resizeColumnToContents(self, col: int) -> None: pass
def setColumnWidth(self, col: int, width: int) -> None: pass
def columnWidth(self, col: int) -> int: return 100
def columnCount(self) -> int: return self._col_count
def verticalHeader(self): return _MockWidget()
def horizontalHeader(self): return _MockWidget()
def frameWidth(self) -> int: return 1
QTableWidget = _MockQTableWidget
class _MockQTableWidgetSelectionRange:
def __init__(self, *args, **kwargs): pass
QTableWidgetSelectionRange = _MockQTableWidgetSelectionRange
class _MockQIcon:
def __init__(self, *args, **kwargs): pass
QIcon = _MockQIcon
def exec_dialog(dialog: Any) -> Any:
return YES
# --------------------------- TEST ---------------------------
+1
View File
@@ -20,6 +20,7 @@ class SettingsLogic:
"amt",
"behoerde",
"landkreis_user",
"landkreis_kennzahl",
"sachgebiet",
"bezeichnung",
"name",
+28
View File
@@ -0,0 +1,28 @@
"""
sn_basis/functions/text_wrapper.py Bezeichner-Hilfsfunktionen.
Zentrale Stelle, um Freitext (z. B. ein Thema oder ein Schriftfeldinhalt) in einen
technischen Bezeichner (Layername, Dateiname) umzuwandeln. Nicht pro Aufrufstelle
ein eigenes Regex schreiben Umlaute, Unterstrich und Bindestrich werden hier an
einer Stelle gepflegt (siehe docs/wissen/fallstricke.md, Issue Plugin_SN_Basis #42).
"""
import re
# Buchstaben, Ziffern, deutsche Umlaute/ß, Unterstrich und Bindestrich gelten immer
# als zulässig. Wurde in der Vergangenheit wiederholt vergessen, wenn ein neues
# Regex direkt im Aufrufer entstand statt diese Funktion zu nutzen.
_BEZEICHNER_ERLAUBT = r"A-Za-z0-9_\-ÄäÖöÜüß"
def sanitize_bezeichner(text: str, ersatz: str = "_") -> str:
"""Wandelt `text` in einen technischen Bezeichner um.
Alle Zeichen außerhalb von Buchstaben/Ziffern/Umlauten/ß/Unterstrich/Bindestrich
werden durch `ersatz` ersetzt, anschließend werden führende und folgende
Vorkommen von `ersatz` entfernt. Gedacht für Layer- und Dateinamen nicht für
Freitext in Schriftfeldern oder der Legende, wo auch andere Zeichen sichtbar
bleiben sollen.
"""
bereinigt = re.sub(rf"[^{_BEZEICHNER_ERLAUBT}]+", ersatz, text)
return bereinigt.strip(ersatz)
+13
View File
@@ -0,0 +1,13 @@
[general]
version=26.7.2-testing
name=LNO Sachsen | Plugin Basisfunktionen
description=Plugin mit Basisfunktionen
author=Daniel Helbig, Michael Otto
homepage=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis
tracker=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/issues
repository=https://entwicklung.flurneuordnung-sachsen.de/AG_QGIS/Plugin_SN_Basis/src/branch/testing/
QGISMaximumVersion=4.99
qgisMinimumVersion=3.40
experimental=true
deprecated=false
supportsQt6=true
+13 -2
View File
@@ -30,7 +30,6 @@ 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"]
@@ -286,7 +285,19 @@ class DataGrabber:
}
source_dict["rows"].append(result_row)
# Validierung über Listenauswerter
# Validierung über Listenauswerter Import erst hier (statt am Modul-
# anfang), da DataGrabber generisch für alle sn_*-Plugins nutzbar sein
# soll und nicht bei jedem Aufruf zwingend sn_plan41 installiert ist;
# nur die Excel-Quellenverarbeitung braucht Listenauswerter.
try:
from sn_plan41.modules.listenauswerter import Listenauswerter
except ImportError as exc:
raise ImportError(
"Die Verarbeitung von Excel-Quellen benötigt das Plugin "
"sn_plan41 (sn_plan41.modules.listenauswerter.Listenauswerter), "
"das hier nicht installiert ist."
) from exc
listenauswerter = Listenauswerter(self.pruefmanager, self.stil_pruefer or Stilpruefer())
validated, validation_results = listenauswerter.validate_rows(source_dict)
results.extend(validation_results)
+55 -12
View File
@@ -19,7 +19,7 @@ Designprinzipien
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse
from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse, quote
import json
import time
@@ -132,6 +132,7 @@ class Datenabruf:
# außer bei WMS (WMS bleibt unverändert).
use_bbox = (raumfilter != "ohne") and (str(provider).upper() != "WMS")
url = self._build_provider_url(link=link, provider=str(provider), bbox_geom=bbox_geom if use_bbox else None)
print(f"[Datenabruf] URL für {thema!r} ({provider}): {url}")
# 2b) Fachdaten abrufen
features, error_msg = self._fetch_features(
@@ -309,8 +310,11 @@ class Datenabruf:
if callable(extent_to_bbox):
bbox_str = extent_to_bbox(extent_obj)
else:
# Fallback: einfache xmin/ymin/xmax/ymax-Extraktion (duck-typing)
if hasattr(extent_obj, "xmin") and callable(getattr(extent_obj, "xmin")):
# Fallback: xmin/ymin/xmax/ymax-Extraktion (duck-typing)
# QgsRectangle verwendet xMinimum()/yMinimum()/xMaximum()/yMaximum()
if hasattr(extent_obj, "xMinimum") and callable(getattr(extent_obj, "xMinimum")):
bbox_str = f"{extent_obj.xMinimum()},{extent_obj.yMinimum()},{extent_obj.xMaximum()},{extent_obj.yMaximum()}"
elif hasattr(extent_obj, "xmin") and callable(getattr(extent_obj, "xmin")):
bbox_str = f"{extent_obj.xmin()},{extent_obj.ymin()},{extent_obj.xmax()},{extent_obj.ymax()}"
elif isinstance(extent_obj, (tuple, list)) and len(extent_obj) == 4:
bbox_str = f"{extent_obj[0]},{extent_obj[1]},{extent_obj[2]},{extent_obj[3]}"
@@ -325,12 +329,26 @@ class Datenabruf:
if provider_norm == "WFS":
query_params.setdefault("service", "WFS")
query_params.setdefault("request", "GetFeature")
query_params.setdefault("outputFormat", "application/json")
if bbox_str:
query_params.setdefault("BBOX", bbox_str)
query_params.setdefault("VERSION", "2.0.0")
# WFS 2.0: 'typename' → 'TYPENAMES'
if "typename" in query_params and "TYPENAMES" not in query_params:
query_params["TYPENAMES"] = query_params.pop("typename")
# resultType=hits: nur Anzahl vom Server abfragen, kein GML-Download
query_params.setdefault("resultType", "hits")
# CRS in OGC-URN-Format (wie QGIS Datenquellenverwaltung)
srsname_urn: Optional[str] = None
if crs_authid:
query_params.setdefault("SRSNAME", crs_authid)
new_query = urlencode(query_params, doseq=True)
if crs_authid.upper().startswith("EPSG:"):
srsname_urn = f"urn:ogc:def:crs:EPSG::{crs_authid.split(':', 1)[1]}"
else:
srsname_urn = crs_authid
if bbox_str:
# 5. Komponente: CRS-URN anhängen (Standard für WFS 2.0)
bbox_with_crs = f"{bbox_str},{srsname_urn}" if srsname_urn else bbox_str
query_params.setdefault("BBOX", bbox_with_crs)
if srsname_urn:
query_params.setdefault("SRSNAME", srsname_urn)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
@@ -389,14 +407,14 @@ class Datenabruf:
query_params.setdefault("inSR", srid)
query_params.setdefault("outSR", srid)
new_query = urlencode(query_params, doseq=True)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
rebuilt = parsed_rest._replace(query=new_query)
return urlunparse(rebuilt)
# Default: generischer bbox-Parameter (nur wenn vorhanden)
if bbox_str:
query_params.setdefault("bbox", bbox_str)
new_query = urlencode(query_params, doseq=True)
new_query = urlencode(query_params, doseq=True, quote_via=quote, safe=':/')
rebuilt = parsed._replace(query=new_query)
return urlunparse(rebuilt)
@@ -573,7 +591,33 @@ class Datenabruf:
# Sonst: gib das gesamte JSON als einzelnes Objekt zurück
return [parsed], None
except json.JSONDecodeError:
# Nicht-JSON-Antwort (z. B. GML). Wenn QGIS verfügbar, versuche GML via temporärer Datei + OGR
# Nicht-JSON-Antwort (GML/XML). Für WFS resultType=hits: Anzahl aus XML lesen.
if prov == "WFS":
try:
import xml.etree.ElementTree as ET
root = ET.fromstring(response_text)
# WFS 2.0: numberOfMatched; WFS 1.x: numberOfFeatures
n_str = (
root.get("numberOfMatched")
or root.get("numberMatched")
or root.get("numberOfFeatures")
or root.get("numberOfReturned")
)
if n_str is not None:
try:
count = int(n_str)
return [{}] * count, None
except (ValueError, TypeError):
pass
# Fallback: wfs:member-Elemente zählen
members = [
c for c in root
if c.tag.lower().endswith(("member", "featuremember"))
]
return [{}] * len(members), None
except Exception as exc:
return [], f"WFS-XML-Antwort konnte nicht geparst werden: {exc}"
# Nicht-WFS, nicht-JSON: GML via temporärer Datei + OGR
if getattr(qgiscore, "QGIS_AVAILABLE", False):
try:
import tempfile
@@ -588,5 +632,4 @@ class Datenabruf:
return [], "GML-Antwort konnte nicht als Layer geladen werden"
except Exception as exc:
return [], f"Fehler beim Parsen von GML: {exc}"
# Wenn alles fehlschlägt:
return [], "Antwort konnte nicht als JSON oder GML geparst werden"
+2 -2
View File
@@ -30,13 +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.functions.text_wrapper import sanitize_bezeichner
from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
@@ -209,7 +209,7 @@ class Datenschreiber:
continue
layer_name_raw = thema or str(ident)
layer_name = re.sub(r"[^A-Za-z0-9_ÄäÖöÜüß]+", "_", layer_name_raw).strip("_")
layer_name = sanitize_bezeichner(layer_name_raw)
if not layer_name:
layer_name = f"layer_{ident}"
+27 -1
View File
@@ -122,7 +122,33 @@ class LayerLoader:
try:
if provider_lower == "wfs":
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
raw_link = link.strip()
if raw_link.lower().startswith("url="):
raw_link = raw_link[4:]
# QGIS WFS-Provider erwartet die Basis-URL und 'typename' als
# separaten URI-Parameter. GetFeature-spezifische Parameter
# (request, outputFormat, resultType, BBOX, …) werden entfernt.
try:
from urllib.parse import urlparse as _up, parse_qsl as _pqsl, urlunparse as _uu, urlencode as _ue
_parsed = _up(raw_link)
_params = dict(_pqsl(_parsed.query, keep_blank_values=True))
_typename = (
_params.pop("typename", None)
or _params.pop("TYPENAME", None)
or _params.pop("TYPENAMES", None)
)
for _k in ("request", "REQUEST", "outputFormat", "OUTPUTFORMAT",
"resultType", "RESULTTYPE", "BBOX", "bbox",
"STARTINDEX", "COUNT"):
_params.pop(_k, None)
_params.setdefault("service", "WFS")
_params.setdefault("version", "2.0.0")
_base_url = _uu(_parsed._replace(query=_ue(_params)))
uri = f"url={_base_url}"
if _typename:
uri += f" typename={_typename}"
except Exception:
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
layer = QgsVectorLayer(uri, thema, "WFS")
elif provider_lower == "wms":
uri = link if link.strip().lower().startswith("url=") else f"url={link}"
+2 -2
View File
@@ -45,7 +45,7 @@ MM = QgsUnitTypes.LayoutMillimeters
class PrintLayout:
"""Erzeugt ein QGIS-Einzelblatt-Layout fuer den Druck."""
"""Erzeugt ein QGIS-Einzelblatt-Layout für den Druck."""
_FRAME_STROKE_WIDTH_MM = 0.5
_LEGEND_WIDTH_MM = 180.0
@@ -340,7 +340,7 @@ class PrintLayout:
("[% @sn_landkreise_proj %]", 16.0, -48.0, 171.0, 4.0, "Format_1", "Landkreis"),
("[% @sn_kartenname %]", 16.0, -32.0, 137.0, 12.0, "Format_3", "Kartenname"),
("Gefertigt: ", 16.0, -23.0, 81.0, 4.0, "Format_1", "Gefertigt"),
("Geprueft: ", 101.0, -23.0, 81.0, 4.0, "Format_1", "Geprüft"),
("Geprüft: ", 101.0, -23.0, 81.0, 4.0, "Format_1", "Geprüft"),
(
"Maßstab: 1 : [%round(map_get(item_variables('Hauptkarte'),'map_scale'),0)%]",
16.0,
+16 -16
View File
@@ -1,9 +1,9 @@
"""
sn_basis/modules/print_layout_baufreigabe.py - Layout-Erstellung fuer Baufreigabe-Vorlagen.
sn_basis/modules/print_layout_baufreigabe.py - Layout-Erstellung für 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.
Baufreigabe-Layouts verwenden immer feste DIN A3/A4 Seitengrößen im Querformat,
ein 120 mm breites Kartenschild (statt 180 mm) und erben die Standard-Logik
für Hauptkarte, Quellenangabe und Legende vom PrintLayout.
Atlas-Integration erfolgt nach Layouterstellung durch externe Konfiguration
in der PrintLogic.
@@ -32,7 +32,7 @@ MM = QgsUnitTypes.LayoutMillimeters
class PrintLayoutBaufreigabe(PrintLayout):
"""Erzeugt QGIS-Einzelblatt-Layouts mit fester DIN A3/A4 Querformat-Groesse und 80mm Kartenschild."""
"""Erzeugt QGIS-Einzelblatt-Layouts mit fester DIN A3/A4 Querformat-Größe und 80mm Kartenschild."""
_FRAME_STROKE_WIDTH_MM = 0.5
_KARTENSCHILD_WIDTH_MM = 80.0
@@ -41,7 +41,7 @@ class PrintLayoutBaufreigabe(PrintLayout):
_GRID_INTERVAL_MAP_UNITS = 500.0
# DIN-Formate: nur A3 und A4 im Querformat
_DIN_A3_LANDSCAPE = (420, 297) # Breite x Hoehe (Querformat)
_DIN_A3_LANDSCAPE = (420, 297) # Breite x Höhe (Querformat)
_DIN_A4_LANDSCAPE = (297, 210) # Querformat
_TEXT_FORMAT_TEMPLATES = {
@@ -99,25 +99,25 @@ class PrintLayoutBaufreigabe(PrintLayout):
fit_extent_to_map: bool = True,
atlas_driven: bool = True,
) -> Any:
"""Erstellt ein Baufreigabe-Layout mit fester DIN A3/A4 Querformat-Groesse.
"""Erstellt ein Baufreigabe-Layout mit fester DIN A3/A4 Querformat-Größe.
Args:
name: Name des Layouts
din_format: "DIN A3" oder "DIN A4"
map_width_mm: Breite der Hauptkarte
map_height_mm: Hoehe der Hauptkarte
map_height_mm: Höhe der Hauptkarte
extent: QgsRectangle der Kartenausdehnung
plotmassstab: Plotmassstab (z.B. 5000 fuer 1:5000)
plotmassstab: Plotmaßstab (z.B. 5000 für 1:5000)
thema: Name des Visibility-Presets (optional)
fit_extent_to_map: Extent bei Bedarf auf das Karten-Seitenverhaeltnis erweitern
fit_extent_to_map: Extent bei Bedarf auf das Karten-Seitenverhältnis erweitern
Returns:
QgsPrintLayout objektInstance (wird im Layout-Dialog geoeffnet)
QgsPrintLayout objektInstance (wird im Layout-Dialog geöffnet)
Raises:
ValueError: Falls Name bereits existiert oder Format ungueltig ist
ValueError: Falls Name bereits existiert oder Format ungültig ist
"""
# Seitenformat auswaehlen (nur A3/A4 im Querformat)
# Seitenformat auswählen (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":
@@ -266,9 +266,9 @@ class PrintLayoutBaufreigabe(PrintLayout):
map_top_mm: float,
map_height_mm: float,
) -> None:
"""Erstellt Legende, Quellenangabe und Kartenschild fuer Baufreigabe-Layout.
"""Erstellt Legende, Quellenangabe und Kartenschild für Baufreigabe-Layout.
Angepasst fuer 80mm Kartenschild statt 180mm.
Angepasst für 80mm Kartenschild statt 180mm.
"""
if self._current_layout is None:
raise RuntimeError("Kein Layout aktiv.")
@@ -279,7 +279,7 @@ class PrintLayoutBaufreigabe(PrintLayout):
kartenfenster_untere_rechte_ecke_mm[1] + rel_y_mm,
)
# Legende: 80mm Breite, hoehe adaptive wie Standard
# Legende: 80mm Breite, he adaptiv 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
+33 -33
View File
@@ -1,5 +1,5 @@
"""
sn_basis/modules/print_logic.py - Fachlogik fuer den Druck-Tab (nur Einzelblatt).
sn_basis/modules/print_logic.py - Fachlogik für den Druck-Tab (nur Einzelblatt).
"""
from __future__ import annotations
@@ -16,7 +16,7 @@ from sn_basis.modules.print_layout_baufreigabe import PrintLayoutBaufreigabe
class AtlasConfig(TypedDict, total=False):
"""Strukturierte Konfiguration fuer Atlas-Optionen.
"""Strukturierte Konfiguration für Atlas-Optionen.
Alle Felder sind optional (total=False) und haben vordefinierte Standartwerte.
"""
@@ -113,7 +113,7 @@ STANDARD_A4_LANDSCAPE_H_MM = 210.0
class PrintLogic:
"""Kapselt die Fachlogik fuer den Druck-Tab (ohne Atlas)."""
"""Kapselt die Fachlogik für den Druck-Tab (ohne Atlas)."""
def __init__(self, pruefmanager: Pruefmanager) -> None:
self.pruefmanager = pruefmanager
@@ -125,8 +125,8 @@ class PrintLogic:
def _show_target_size_too_small(self) -> dict:
self.pruefmanager.zeige_hinweis(
"Zielgroesse zu klein",
"Die gewaehlte Zielgroesse reicht fuer den Einzelblatt-Druck nicht aus.",
"Zielgröße zu klein",
"Die gewählte Zielgröße reicht für den Einzelblatt-Druck nicht aus.",
)
return self._failed_result()
@@ -348,13 +348,13 @@ class PrintLogic:
if int(cast(Any, selected_count())) == 0:
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
"Auf dem Layer sind keine Features ausgewaehlt.",
"Auf dem Layer sind keine Features ausgewählt.",
)
return None, ""
except Exception:
pass
# Bestehenden Atlaslayer loeschen (optional Wiederverwedung)
# Bestehenden Atlaslayer löschen (optional Wiederverwendung)
existing_atlas = self._get_existing_layer_by_name("Atlaslayer")
if existing_atlas is not None:
try:
@@ -367,7 +367,7 @@ class PrintLogic:
if qgs_vector_layer is None:
self.pruefmanager.zeige_hinweis(
"Atlas Layer erstellen",
"QgsVectorLayer ist nicht verfuegbar.",
"QgsVectorLayer ist nicht verfügbar.",
)
return None, ""
@@ -392,7 +392,7 @@ class PrintLogic:
)
return None, ""
# Ausgewaehlte Features kopieren
# Ausgewählte Features kopieren
try:
get_selected = getattr(source_layer, "selectedFeatures", None)
if not callable(get_selected):
@@ -400,15 +400,15 @@ class PrintLogic:
selected_features = list(cast(Any, get_selected()))
if not selected_features:
raise Exception("Keine Features ausgewaehlt")
raise Exception("Keine Features ausgewählt")
qgs_feature = self._import_qgis_symbol("qgis.core", "QgsFeature")
if qgs_feature is None:
raise Exception("QgsFeature Symbol nicht verfuegbar")
raise Exception("QgsFeature Symbol nicht verfügbar")
data_provider = getattr(atlas_layer, "dataProvider", lambda: None)()
if data_provider is None:
raise Exception("dataProvider nicht verfuegbar")
raise Exception("dataProvider nicht verfügbar")
new_features = []
for source_feature in selected_features:
@@ -470,7 +470,7 @@ class PrintLogic:
layout_name: str = "",
atlas_config: AtlasConfig | None = None,
) -> bool:
"""Konfiguriert den Atlas fuer ein Baufreigabe-Layout mit erweiterten Optionen.
"""Konfiguriert den Atlas für ein Baufreigabe-Layout mit erweiterten Optionen.
Wird nach erfolgreicher Baufreigabe-Layout-Erstellung aufgerufen.
Setzt den Coverage-Layer (aus sn_abdeckungslayer Variable) und konfiguriert den Atlas
@@ -483,7 +483,7 @@ class PrintLogic:
project = QgsProject.instance()
layout_manager = getattr(project, "layoutManager", lambda: None)()
if layout_manager is None:
self.pruefmanager.zeige_hinweis("Atlas konfigurieren", "Layout Manager nicht verfuegbar.")
self.pruefmanager.zeige_hinweis("Atlas konfigurieren", "Layout Manager nicht verfügbar.")
return False
if not layout_name:
@@ -672,7 +672,7 @@ class PrintLogic:
if map_canvas is None:
self.pruefmanager.zeige_hinweis(
"Druckrechteck",
"Das Kartenfenster ist nicht verfuegbar.",
"Das Kartenfenster ist nicht verfügbar.",
)
return False
@@ -684,7 +684,7 @@ class PrintLogic:
if None in (qgs_geometry, qgs_rectangle, qgs_wkb_types, qgs_map_tool_emit_point, qgs_rubber_band):
self.pruefmanager.zeige_hinweis(
"Druckrechteck",
"Die Rechteck-Erfassung ist in dieser QGIS-Umgebung nicht verfuegbar.",
"Die Rechteck-Erfassung ist in dieser QGIS-Umgebung nicht verfügbar.",
)
return False
@@ -736,13 +736,13 @@ class PrintLogic:
if width <= 0 or height <= 0:
logic.pruefmanager.zeige_hinweis(
"Druckrechteck",
"Das gezeichnete Rechteck ist ungueltig.",
"Das gezeichnete Rechteck ist ungültig.",
)
else:
if logic._store_druckrechteck(layer, rectangle):
logic.pruefmanager.zeige_hinweis(
"Druckrechteck",
"Das Druckrechteck wurde uebernommen.",
"Das Druckrechteck wurde übernommen.",
)
try:
self.rubber_band.reset(polygon_geometry)
@@ -920,7 +920,7 @@ class PrintLogic:
if extent is None:
self.pruefmanager.zeige_hinweis(
"Druckbereich",
"Das aktuelle Kartenfenster liefert keine gueltige Ausdehnung.",
"Das aktuelle Kartenfenster liefert keine gültige Ausdehnung.",
)
return None, None
return extent, None
@@ -931,7 +931,7 @@ class PrintLogic:
if extent is None:
self.pruefmanager.zeige_hinweis(
"Druckbereich",
"Auf dem aktuellen Layer sind keine gueltigen Objekte ausgewaehlt.",
"Auf dem aktuellen Layer sind keine gültigen Objekte ausgewählt.",
)
return None, None
return self._buffer_extent(extent, plotmassstab), None
@@ -948,7 +948,7 @@ class PrintLogic:
if not ergebnis.ok:
self.pruefmanager.zeige_hinweis(
"Verfahrensgebiets-Layer angeben",
"Kein gueltiger Verfahrensgebiets-Layer gefunden. Bitte Layerauswahl im jeweiligen Plugin setzen.",
"Kein gültiger Verfahrensgebiets-Layer gefunden. Bitte Layerauswahl im jeweiligen Plugin setzen.",
)
return None, None
@@ -1001,12 +1001,12 @@ class PrintLogic:
required_map_height_mm: float,
max_format: str = "DIN A3",
) -> tuple[str, float, float, float, float, bool]:
"""Waehlt fuer Baufreigabe ein Blatt im Querformat.
"""Wählt für Baufreigabe ein Blatt im Querformat.
Args:
required_map_width_mm: benoetigte Breite der Hauptkarte in mm
required_map_height_mm: benoetigte Hoehe der Hauptkarte in mm
max_format: maximale Blattgroesse, standardmaessig DIN A3
required_map_width_mm: benötigte Breite der Hauptkarte in mm
required_map_height_mm: benötigte Höhe der Hauptkarte in mm
max_format: maximale Blattgröße, standardmäßig DIN A3
Returns:
(din_format, page_width_mm, page_height_mm, map_w_mm, map_h_mm, fallback_applied)
@@ -1029,8 +1029,8 @@ class PrintLogic:
if map_w >= required_map_width_mm and map_h >= required_map_height_mm:
return din_format, page_w, page_h, map_w, map_h, False
# Wenn der gewuenschte Ausschnitt nicht passt, verwende das maximal erlaubte Blatt
# und erweitere die Hauptkarte auf die verfuegbare Kartenflaeche (zentriert per fit_extent_to_map).
# Wenn der gewünschte Ausschnitt nicht passt, verwende das maximal erlaubte Blatt
# und erweitere die Hauptkarte auf die verfügbare Kartenfläche (zentriert per fit_extent_to_map).
din_format, page_w, page_h = limited_candidates[-1]
map_w = max(page_w - BAUFREIGABE_LAYOUT_FIXBREITE_MM, 50.0)
map_h = max(page_h - BAUFREIGABE_LAYOUT_FIXHOEHE_MM, 50.0)
@@ -1059,7 +1059,7 @@ class PrintLogic:
return map_width_mm + fixed_width_mm, map_height_mm + fixed_height_mm
def _get_required_map_dimensions_mm(self, extent: Any, plotmassstab: float) -> tuple[float, float]:
"""Berechnet die benoetigte Kartenfenstergroesse in mm fuer Extent und Massstab."""
"""Berechnet die benötigte Kartenfenstergröße in mm für Extent und Maßstab."""
if extent is None or not isinstance(plotmassstab, (int, float)) or plotmassstab <= 0:
return 0.0, 0.0
@@ -1077,7 +1077,7 @@ class PrintLogic:
required_map_height_mm: float,
max_format: str,
) -> tuple[float, float, float, float] | None:
"""Waehlt das kleinste passende DIN-Blatt fuer das Standardlayout."""
"""Wählt das kleinste passende DIN-Blatt für das Standardlayout."""
try:
max_index = DIN_AUTO_REIHENFOLGE.index(max_format)
except ValueError:
@@ -1170,7 +1170,7 @@ class PrintLogic:
resolved_layer = layer or self._resolve_layer_from_project()
if kartenname_auswahl not in (KARTENNAME_38, KARTENNAME_41, KARTENNAME_BAUFREIGABE):
self.pruefmanager.zeige_hinweis("Kartennamen waehlen", "Kartennamen waehlen")
self.pruefmanager.zeige_hinweis("Kartennamen wählen", "Kartennamen wählen")
return self._failed_result()
if massstab_auswahl == MASSSTAB_WIE_KARTENFENSTER:
@@ -1191,7 +1191,7 @@ class PrintLogic:
massstab_zahl = float(PLOTMASSSTAB_BY_AUSWAHL.get(massstab_auswahl, 0))
if massstab_zahl <= 0:
self.pruefmanager.zeige_hinweis("Massstab fehlt", "Kein gueltiger Massstab angegeben.")
self.pruefmanager.zeige_hinweis("Maßstab fehlt", "Kein gültiger Maßstab angegeben.")
return self._failed_result()
extent, layer_for_extent = self._resolve_extent(resolved_layer, druckbereich_auswahl, massstab_zahl)
@@ -1262,7 +1262,7 @@ class PrintLogic:
vorlage_name = (vorlage_name or "").strip() or kartenname
# Atlas-Status vorab bestimmen, damit er sowohl an create_ als auch configure_ uebergeben wird.
# Atlas-Status vorab bestimmen, damit er sowohl an create_ als auch configure_ übergeben wird.
atlas_raw_bf = get_variable(ATLAS_ERZEUGEN_UI_VAR, scope="project")
atlas_erzeugen_bf = self._is_enabled_flag(atlas_raw_bf)
@@ -1283,7 +1283,7 @@ class PrintLogic:
atlas_driven=atlas_erzeugen_bf,
)
else:
# Standard-Layout: dynamische Groessenanpassung
# Standard-Layout: dynamische Größenanpassung
PrintLayout().create_single_page_layout(
name=vorlage_name,
page_width_mm=auto_page_w,
+31
View File
@@ -0,0 +1,31 @@
"""sn_basis/tests/test_laenderkennung.py
Unit-Tests für :mod:`sn_basis.functions.laenderkennung`.
"""
import unittest
from sn_basis.functions.laenderkennung import get_laenderkennung, LAENDERKENNUNGEN
class TestGetLaenderkennung(unittest.TestCase):
def test_bekannter_praefix_gibt_kennung_zurueck(self):
self.assertEqual(get_laenderkennung("sn"), "14")
def test_praefix_gross_klein_unabhaengig(self):
self.assertEqual(get_laenderkennung("SN"), "14")
def test_unbekannter_praefix_wirft_keyerror(self):
with self.assertRaises(KeyError):
get_laenderkennung("xx")
def test_ohne_argument_wird_aus_aufrufendem_modul_abgeleitet(self):
# __name__ dieses Testmoduls beginnt mit "sn_basis" -> Präfix "sn"
self.assertEqual(get_laenderkennung(), "14")
def test_mapping_enthaelt_sachsen(self):
self.assertEqual(LAENDERKENNUNGEN.get("sn"), "14")
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
"""sn_basis/tests/test_text_wrapper.py
Unit-Tests für :mod:`sn_basis.functions.text_wrapper`.
"""
import unittest
from sn_basis.functions.text_wrapper import sanitize_bezeichner
class TestSanitizeBezeichner(unittest.TestCase):
def test_umlaute_bleiben_erhalten(self):
"""Prüft, dass Umlaute und ß nicht durch den Ersatz entfernt werden."""
self.assertEqual(sanitize_bezeichner("Grünfläche"), "Grünfläche")
self.assertEqual(sanitize_bezeichner("Straße"), "Straße")
def test_bindestrich_bleibt_erhalten(self):
"""Prüft, dass ein Bindestrich im Bezeichner erhalten bleibt (Issue #42)."""
self.assertEqual(sanitize_bezeichner("Wege-Plan"), "Wege-Plan")
def test_unterstrich_bleibt_erhalten(self):
"""Prüft, dass ein Unterstrich im Bezeichner erhalten bleibt."""
self.assertEqual(sanitize_bezeichner("thema_flur_1"), "thema_flur_1")
def test_leerzeichen_wird_durch_ersatz_zusammengefasst(self):
"""Prüft, dass Leerzeichen (kein zulässiges Zeichen) zu einem Ersatzzeichen werden."""
self.assertEqual(sanitize_bezeichner("Flurstücke Gemarkung"), "Flurstücke_Gemarkung")
def test_fuehrender_und_folgender_ersatz_wird_entfernt(self):
"""Prüft, dass ein Ersatzzeichen am Anfang oder Ende des Ergebnisses entfernt wird."""
self.assertEqual(sanitize_bezeichner("!Thema!"), "Thema")
def test_eigenes_ersatzzeichen_wird_verwendet(self):
"""Prüft, dass ein abweichendes Ersatzzeichen anstelle von Unterstrich genutzt wird."""
self.assertEqual(sanitize_bezeichner("a b", ersatz="-"), "a-b")
def test_nur_unzulaessige_zeichen_ergibt_leeren_string(self):
"""Prüft, dass ein Bezeichner ganz aus unzulässigen Zeichen zu einem leeren String wird."""
self.assertEqual(sanitize_bezeichner("!!!"), "")
if __name__ == "__main__":
unittest.main()
+11 -11
View File
@@ -1,5 +1,5 @@
"""
sn_basis/ui/tabs/print_tab.py - Zentraler Druck-Tab fuer sn-Plugins.
sn_basis/ui/tabs/print_tab.py - Zentraler Druck-Tab für sn-Plugins.
"""
from __future__ import annotations
@@ -41,7 +41,7 @@ from sn_basis.modules.print_logic import (
KARTENNAME_VAR = "tab_b_kartenname"
KARTENNAME_PLACEHOLDER = "Kartenname waehlen"
KARTENNAME_PLACEHOLDER = "Kartenname wählen"
KARTENNAME_38 = "§38"
KARTENNAME_41 = "§41"
KARTENNAME_BAUFREIGABE = "Anlage zur Baufreigabe"
@@ -59,7 +59,7 @@ DRUCKBEREICH_OPTIONEN = [
class PrintTab(QWidget):
"""UI-Klasse fuer den zentralen Druck-Tab."""
"""UI-Klasse für den zentralen Druck-Tab."""
tab_title = "Druck"
@@ -130,7 +130,7 @@ class PrintTab(QWidget):
self._kartenname_combo = kartenname_combo
main_layout.addWidget(kartenname_combo)
massstab_label = QLabel("Massstab")
massstab_label = QLabel("Maßstab")
massstab_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
main_layout.addWidget(massstab_label)
@@ -152,7 +152,7 @@ class PrintTab(QWidget):
self._thema_combo = thema_combo
main_layout.addWidget(thema_combo)
zielgroesse_label = QLabel("max. Blattgroesse")
zielgroesse_label = QLabel("max. Blattgröße")
zielgroesse_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
main_layout.addWidget(zielgroesse_label)
@@ -610,7 +610,7 @@ class PrintTab(QWidget):
return None
def _on_atlas_layer_changed(self, layer_or_name: Any) -> None:
"""Speichert den ausgewaehlten Atlas-Layer als Layer-ID in Projektvariablen."""
"""Speichert den ausgewählten Atlas-Layer als Layer-ID in Projektvariablen."""
if layer_or_name is None or layer_or_name == "":
set_variable("sn_abdeckungslayer", "", scope="project")
return
@@ -639,10 +639,10 @@ class PrintTab(QWidget):
pass
def _on_auswahl_as_atlas_clicked(self) -> None:
"""Handler fuer Button 'Auswahl als Atlas anlegen'.
"""Handler für Button 'Auswahl als Atlas anlegen'.
Erstellt einen neuen Layer 'Atlaslayer' aus der aktuellen Feature-Auswahl
und traegt ihn ins Atlas-Dropdown ein.
und trägt ihn ins Atlas-Dropdown ein.
"""
if not self.logic or not self.pruefmanager:
return
@@ -673,11 +673,11 @@ class PrintTab(QWidget):
except Exception:
pass
# Fallback nur fuer Mock/klassische ComboBox
# Fallback nur für Mock/klassische ComboBox
self._populate_atlas_layer_combo()
def _populate_atlas_layer_combo(self) -> None:
"""Fallback-Befuellung, falls kein echtes QgsMapLayerComboBox Verhalten verfuegbar ist."""
"""Fallback-Befüllung, falls kein echtes QgsMapLayerComboBox Verhalten verfügbar ist."""
if not self._atlas_layer_combo:
return
+42 -11
View File
@@ -1,5 +1,5 @@
"""
sn_basis/ui/tabs/settings_tab.py Einstellungen-Tab für Projekteinformationen.
sn_basis/ui/tabs/settings_tab.py Einstellungen-Tab für Projektinformationen.
Verwaltet benutzerspezifische und projektspezifische Einstellungen.
"""
@@ -8,28 +8,33 @@ from sn_basis.functions.qt_wrapper import (
QGridLayout,
QLabel,
QLineEdit,
QComboBox,
QGroupBox,
QVBoxLayout,
QPushButton,
)
from sn_basis.functions.settings_logic import SettingsLogic
from sn_basis.functions.landkreise import LANDKREISE
#: Feldschlüssel, die als Landkreis-Dropdown (statt Freitext) dargestellt werden.
_LANDKREIS_DROPDOWN_FELDER = {"landkreis_user"}
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)
"""
@@ -64,7 +69,7 @@ class SettingsTab(QWidget):
self.user_inputs = {}
for row, (key, label) in enumerate(self.user_fields.items()):
input_widget = QLineEdit()
input_widget = self._build_input(key)
self.user_inputs[key] = input_widget
user_layout.addWidget(QLabel(label), row, 0)
@@ -80,7 +85,7 @@ class SettingsTab(QWidget):
self.project_inputs = {}
for row, (key, label) in enumerate(self.project_fields.items()):
input_widget = QLineEdit()
input_widget = self._build_input(key)
self.project_inputs[key] = input_widget
project_layout.addWidget(QLabel(label), row, 0)
@@ -107,14 +112,35 @@ class SettingsTab(QWidget):
# Daten laden
self.load_data()
# ---------------------------------------------------------
# Eingabewidget je Feld erzeugen
# ---------------------------------------------------------
def _build_input(self, key: str):
if key in _LANDKREIS_DROPDOWN_FELDER:
combo = QComboBox()
combo.addItem("", None) # Platzhalter: keine Vorbelegung bei leerem Projekt
for kennzahl, name in LANDKREISE:
combo.addItem(name, kennzahl)
return combo
return QLineEdit()
# ---------------------------------------------------------
# Speichern
# ---------------------------------------------------------
def save_data(self):
fields = {
key: widget.text()
for key, widget in {**self.user_inputs, **self.project_inputs}.items()
}
fields = {}
for key, widget in {**self.user_inputs, **self.project_inputs}.items():
if isinstance(widget, QComboBox):
fields[key] = widget.currentText()
else:
fields[key] = widget.text()
# Landkreiskennzahl aus der Dropdown-Auswahl zusätzlich als eigene
# Variable bereitstellen (siehe landkreise.py).
landkreis_widget = self.user_inputs.get("landkreis_user")
if isinstance(landkreis_widget, QComboBox):
fields["landkreis_kennzahl"] = landkreis_widget.currentData() or ""
self.logic.save(fields)
# ---------------------------------------------------------
@@ -123,4 +149,9 @@ class SettingsTab(QWidget):
def load_data(self):
data = self.logic.load()
for key, widget in {**self.user_inputs, **self.project_inputs}.items():
widget.setText(data.get(key, ""))
if isinstance(widget, QComboBox):
gespeicherter_name = data.get(key, "")
index = widget.findText(gespeicherter_name)
widget.setCurrentIndex(index if index >= 0 else 0)
else:
widget.setText(data.get(key, ""))