""" sn_plan41/ui/tab_a_ui.py – UI für Tab A (Daten) """ from __future__ import annotations from typing import Optional from sn_basis.functions.qt_wrapper import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QToolButton, QFileDialog, QMessageBox, ToolButtonTextBesideIcon, ArrowDown, ArrowRight, SizePolicyPreferred, SizePolicyMaximum, QComboBox, ) from sn_basis.functions.qgisui_wrapper import QgsFileWidget, QgsMapLayerComboBox from sn_basis.functions.qgiscore_wrapper import QgsProject, QgsMapLayerProxyModel from sn_basis.functions.variable_wrapper import get_variable, set_variable from sn_basis.modules.pruef_ergebnis import pruef_ergebnis # Services (werden von DockWidget injiziert) from sn_basis.modules.Pruefmanager import Pruefmanager from sn_basis.modules.DataGrabber import DataGrabber from sn_basis.modules.Dateipruefer import Dateipruefer from sn_plan41.ui.tab_a_logic import TabALogic from sn_basis.modules.linkpruefer import Linkpruefer from sn_basis.modules.stilpruefer import Stilpruefer from sn_plan41.modules.vln_api_logic import VlnApiLogic # Konstanten RAUMFILTER_VAR = "Raumfilter" RAUMFILTER_OPTIONS = ("Verfahrensgebiet", "Pufferlayer", "ohne") RAUMFILTER_DEFAULT = "Pufferlayer" class TabA(QWidget): """ UI-Klasse für Tab A (Daten) des Plan41-Plugins. Zuständig für: - Anzeige und Auswahl von Verfahrens-DB, Linklisten und Layern - Steuerung der Pipeline über "Fachdaten laden" - Persistierung von UI-States via Projektvariablen Services (Pruefmanager, DataGrabber) werden zur Laufzeit vom DockWidget injiziert. Alle fachlichen Prüfungen laufen über den zentralen Pruefmanager. """ tab_title = "Daten" #: Tab-Titel für BaseDockWidget def __init__(self, parent: Optional[QWidget] = None): """ Initialisiert die UI-Struktur. Services werden später über :meth:`set_services` injiziert. :param parent: Parent-Widget (typischerweise DockWidget) """ super().__init__(parent) # Services (werden von DockWidget gesetzt) self.pruefmanager: Optional[Pruefmanager] = None self.logic: Optional[TabALogic] = None # UI-State self.verfahrens_db: Optional[str] = None self.lokale_linkliste: Optional[str] = None self._pufferlayer = None self._attributes_list = [] # UI-Referenzen self._raumfilter_combo: Optional[QComboBox] = None # VLN-API self.vln_logic: Optional[VlnApiLogic] = None self._vln_restoring: bool = False self._build_ui() self._restore_state() def set_services(self, pruefmanager: Pruefmanager, data_grabber: DataGrabber) -> None: """ Injiziert Services vom übergeordneten DockWidget. :param pruefmanager: Zentrale Prüfmanager-Instanz :param data_grabber: DataGrabber für Quellenprüfung/Abruf """ self.pruefmanager = pruefmanager self.data_grabber = data_grabber self.logic = TabALogic( pruefmanager=self.pruefmanager, link_pruefer=Linkpruefer(), stil_pruefer=Stilpruefer(), ) self.logic.data_grabber = self.data_grabber # VLN-API-Logik initialisieren self.vln_logic = VlnApiLogic(self.pruefmanager) self._update_vln_ui_state() self._restore_vln_state() def _build_ui(self) -> None: """Erstellt die komplette UI-Hierarchie mit allen Gruppen.""" main_layout = QVBoxLayout() main_layout.setSpacing(4) main_layout.setContentsMargins(4, 4, 4, 4) # === VERFAHRENS-DATENBANK === self.group_button = QToolButton() self.group_button.setText("Verfahrens-Datenbank") self.group_button.setCheckable(True) self.group_button.setChecked(True) self.group_button.setToolButtonStyle(ToolButtonTextBesideIcon) self.group_button.setArrowType(ArrowDown) self.group_button.setStyleSheet("font-weight: bold;") self.group_button.toggled.connect(self._toggle_group) main_layout.addWidget(self.group_button) self.group_content = QWidget() self.group_content.setSizePolicy(SizePolicyPreferred, SizePolicyMaximum) group_layout = QVBoxLayout() group_layout.setSpacing(2) group_layout.setContentsMargins(10, 4, 4, 4) group_layout.addWidget(QLabel("bestehende Datei auswählen")) self.file_widget = QgsFileWidget() self.file_widget.setStorageMode(QgsFileWidget.GetFile) self.file_widget.setFilter("Geopackage (*.gpkg)") self.file_widget.fileChanged.connect(self._on_verfahrens_db_changed) group_layout.addWidget(self.file_widget) group_layout.addWidget(QLabel("-oder-")) self.btn_new = QPushButton("Neue Verfahrens-DB anlegen") self.btn_new.clicked.connect(self._create_new_gpkg) group_layout.addWidget(self.btn_new) self.group_content.setLayout(group_layout) main_layout.addWidget(self.group_content) # === OPTIONALE LINKLISTE === self.optional_button = QToolButton() self.optional_button.setText("Optional: Lokale Linkliste") self.optional_button.setCheckable(True) self.optional_button.setChecked(False) self.optional_button.setToolButtonStyle(ToolButtonTextBesideIcon) self.optional_button.setArrowType(ArrowRight) self.optional_button.setStyleSheet("font-weight: bold; margin-top: 6px;") self.optional_button.toggled.connect(self._toggle_optional) main_layout.addWidget(self.optional_button) self.optional_content = QWidget() self.optional_content.setSizePolicy(SizePolicyPreferred, SizePolicyMaximum) optional_layout = QVBoxLayout() optional_layout.setSpacing(2) optional_layout.setContentsMargins(10, 4, 4, 20) optional_layout.addWidget(QLabel("(frei lassen für globale Linkliste)")) self.linkliste_widget = QgsFileWidget() self.linkliste_widget.setStorageMode(QgsFileWidget.GetFile) self.linkliste_widget.setFilter("Excelliste (*.xlsx)") self.linkliste_widget.fileChanged.connect(self._on_linkliste_changed) optional_layout.addWidget(self.linkliste_widget) self.optional_content.setLayout(optional_layout) self.optional_content.setVisible(False) main_layout.addWidget(self.optional_content) # === RAUMFILTER === main_layout.addWidget(QLabel("Raumfilter")) self._raumfilter_combo = QComboBox(self) self._raumfilter_combo.setToolTip("Wählt die räumliche Bezugsfläche für die Datenextraktion.") self._raumfilter_combo.addItems(RAUMFILTER_OPTIONS) self._raumfilter_combo.currentTextChanged.connect(self._on_raumfilter_changed) main_layout.addWidget(self._raumfilter_combo) # === PIPELINE-STEUERUNG === self.btn_pipeline = QPushButton("Fachdaten laden") self.btn_pipeline.setToolTip("Starte Pipeline: Linkliste → DataGrabber → Datenschreiber → Log") self.btn_pipeline.clicked.connect(self._on_load_fachdaten) main_layout.addWidget(self.btn_pipeline) # === VLN API / PLAN 41 === self._vln_group_button = QToolButton() self._vln_group_button.setText("VLN API / Plan 41") self._vln_group_button.setCheckable(True) self._vln_group_button.setChecked(False) self._vln_group_button.setToolButtonStyle(ToolButtonTextBesideIcon) self._vln_group_button.setArrowType(ArrowRight) self._vln_group_button.setStyleSheet("font-weight: bold; margin-top: 8px;") self._vln_group_button.toggled.connect(self._toggle_vln_group) main_layout.addWidget(self._vln_group_button) self._vln_group_content = QWidget() self._vln_group_content.setSizePolicy(SizePolicyPreferred, SizePolicyMaximum) vln_layout = QVBoxLayout() vln_layout.setSpacing(4) vln_layout.setContentsMargins(10, 4, 4, 8) # Status self._vln_status_label = QLabel("Nicht angemeldet") self._vln_status_label.setStyleSheet("color: gray; font-style: italic;") vln_layout.addWidget(self._vln_status_label) # E-Mail und Passwort vln_layout.addWidget(QLabel("E-Mail:")) self._vln_mail_edit = QLineEdit() self._vln_mail_edit.setPlaceholderText("mail@beispiel.de") vln_layout.addWidget(self._vln_mail_edit) vln_layout.addWidget(QLabel("Passwort:")) self._vln_pass_edit = QLineEdit() try: self._vln_pass_edit.setEchoMode(QLineEdit.EchoMode.Password) except AttributeError: self._vln_pass_edit.setEchoMode(QLineEdit.Password) vln_layout.addWidget(self._vln_pass_edit) # Login / Logout Buttons btn_row = QHBoxLayout() self._btn_vln_login = QPushButton("Anmelden") self._btn_vln_login.clicked.connect(self._on_vln_login) self._btn_vln_logout = QPushButton("Abmelden") self._btn_vln_logout.clicked.connect(self._on_vln_logout) btn_row.addWidget(self._btn_vln_login) btn_row.addWidget(self._btn_vln_logout) vln_layout.addLayout(btn_row) # Verfahren vln_layout.addWidget(QLabel("Verfahren:")) self._vln_verfahren_combo = QComboBox() self._vln_verfahren_combo.setToolTip("Verfahren (Teilnehmergemeinschaft) wählen") self._vln_verfahren_combo.setEnabled(False) self._vln_verfahren_combo.currentIndexChanged.connect( self._on_vln_verfahren_changed ) vln_layout.addWidget(self._vln_verfahren_combo) # Plan 41 laden self._btn_p41_laden = QPushButton("Plan 41 (Wege- und Gewässerplan) laden") self._btn_p41_laden.setToolTip( "Lädt Flächen- und Linienlayer für das gewählte Verfahren vom Server" ) self._btn_p41_laden.setEnabled(False) self._btn_p41_laden.clicked.connect(self._on_p41_laden) vln_layout.addWidget(self._btn_p41_laden) # Upload self._btn_upload = QPushButton("Aktiven Layer hochladen") self._btn_upload.setToolTip( "Lädt alle Plan-41-Layer des aktiven Verfahrens zum Server hoch" ) self._btn_upload.setEnabled(False) self._btn_upload.clicked.connect(self._on_upload_active) vln_layout.addWidget(self._btn_upload) self._vln_group_content.setLayout(vln_layout) self._vln_group_content.setVisible(False) main_layout.addWidget(self._vln_group_content) main_layout.addStretch(1) self.setLayout(main_layout) def _restore_state(self) -> None: """Stellt UI-State aus Projektvariablen/Persistenz wieder her.""" # Verfahrens-DB try: db = get_variable("tab_a_verfahrens_db", scope="project") if db and self.file_widget: self.file_widget.setFilePath(db) self.verfahrens_db = db self._update_group_color() except Exception: pass # Linkliste try: link = get_variable("tab_a_linkliste", scope="project") if link and self.linkliste_widget: self.linkliste_widget.setFilePath(link) self.lokale_linkliste = link except Exception: pass # Raumfilter (schon im _build_ui behandelt) def _restore_vln_state(self) -> None: """Stellt die zuletzt gewählte VKZ aus der Projektdatei wieder her.""" if self.vln_logic is None or not self.vln_logic.is_authenticated: return try: self._populate_verfahren_combo(restore_vkz=True) except Exception: pass # === UI CALLBACKS === def _toggle_vln_group(self, checked: bool) -> None: self._vln_group_button.setArrowType(ArrowDown if checked else ArrowRight) self._vln_group_content.setVisible(checked) def _update_vln_ui_state(self) -> None: """Passt alle VLN-Controls an den aktuellen Authentifizierungsstatus an.""" if self.vln_logic is None: authenticated = False available = False else: authenticated = self.vln_logic.is_authenticated available = self.vln_logic.is_available() if not available: self._vln_status_label.setText( "Plugin 'vln_karten' nicht installiert" ) self._vln_status_label.setStyleSheet("color: red; font-style: italic;") self._btn_vln_login.setEnabled(False) elif authenticated: mail = (self.vln_logic.mail or "API-Key") if self.vln_logic else "" self._vln_status_label.setText("Angemeldet: %s" % mail) self._vln_status_label.setStyleSheet( "color: green; font-style: italic;" ) self._btn_vln_login.setEnabled(False) else: self._vln_status_label.setText("Nicht angemeldet") self._vln_status_label.setStyleSheet("color: gray; font-style: italic;") self._btn_vln_login.setEnabled(True) self._btn_vln_logout.setEnabled(authenticated) self._vln_verfahren_combo.setEnabled(authenticated) vkz_chosen = ( authenticated and self._vln_verfahren_combo.currentData() is not None ) self._btn_p41_laden.setEnabled(vkz_chosen) self._btn_upload.setEnabled(authenticated) def _populate_verfahren_combo(self, restore_vkz: bool = False) -> None: """Füllt die Verfahren-Combo und wählt ggf. die gespeicherte VKZ.""" if self.vln_logic is None: return verfahren = self.vln_logic.get_verfahren() self._vln_restoring = True self._vln_verfahren_combo.clear() self._vln_verfahren_combo.addItem("— Verfahren wählen —", None) for v in verfahren: label = ( "%s — %s" % (v["vkz"], v["name"]) if v["name"] else v["vkz"] ) self._vln_verfahren_combo.addItem(label, v["vkz"]) self._vln_restoring = False if restore_vkz: stored_vkz = self.vln_logic.load_stored_vkz() if stored_vkz: idx = self._vln_verfahren_combo.findData(stored_vkz) if idx >= 0: self._vln_verfahren_combo.setCurrentIndex(idx) self._vln_verfahren_combo.setEnabled(True) self._update_vln_ui_state() def _on_vln_login(self) -> None: """Führt den API-Login durch.""" if self.vln_logic is None: return mail = self._vln_mail_edit.text().strip() password = self._vln_pass_edit.text() if not mail or not password: QMessageBox.warning( self, "Anmeldung", "Bitte E-Mail-Adresse und Passwort eingeben.", ) return try: self.vln_logic.login(mail, password) except Exception as exc: QMessageBox.warning(self, "Anmeldung fehlgeschlagen", str(exc)) return self._vln_pass_edit.clear() try: self._populate_verfahren_combo(restore_vkz=True) except Exception as exc: QMessageBox.warning( self, "Verfahrensliste", "Anmeldung erfolgreich, aber Verfahrensliste konnte nicht " "geladen werden: %s" % exc, ) self._update_vln_ui_state() def _on_vln_logout(self) -> None: """Meldet den Benutzer lokal ab.""" if self.vln_logic is None: return self.vln_logic.logout() self._vln_restoring = True self._vln_verfahren_combo.clear() self._vln_verfahren_combo.setEnabled(False) self._vln_restoring = False self._update_vln_ui_state() def _on_vln_verfahren_changed(self, _index: int) -> None: """Persistiert die gewählte VKZ.""" if self._vln_restoring or self.vln_logic is None: return vkz = self._vln_verfahren_combo.currentData() self.vln_logic.save_vkz(vkz) self._update_vln_ui_state() def _on_p41_laden(self) -> None: """Lädt den Plan-41-Datensatz für die gewählte VKZ.""" if self.vln_logic is None: return vkz = self._vln_verfahren_combo.currentData() if not vkz: QMessageBox.warning( self, "Plan 41 laden", "Bitte zuerst ein Verfahren wählen." ) return # Bereits geladene Layer prüfen existing = self.vln_logic.get_p41_layers(vkz) if existing: try: answer = QMessageBox.question( self, "Layer ersetzen", "Plan 41 für VKZ %s ist bereits geladen.\n" "Beim Neuladen werden die bestehenden Layer ersetzt. Fortfahren?" % vkz, ) # Qt6: StandardButton.Yes, Qt5: QMessageBox.Yes yes = getattr( getattr(QMessageBox, "StandardButton", QMessageBox), "Yes", QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None, ) if answer != yes: return except Exception: pass self.vln_logic.remove_p41_layers(vkz) try: layers = self.vln_logic.load_p41(vkz) except Exception as exc: # AuthError: Sitzung abgelaufen try: from sn_plan41.modules.vln_api_client import AuthError as _AuthError if isinstance(exc, _AuthError): self.vln_logic.handle_session_expired() self._on_vln_logout() QMessageBox.warning( self, "Sitzung abgelaufen", "Der API-Key ist nicht mehr gültig — bitte neu anmelden.", ) return except ImportError: pass QMessageBox.warning(self, "Laden fehlgeschlagen", str(exc)) return total = sum(l.featureCount() for l in layers) detail = ", ".join( "%s: %d" % (l.name(), l.featureCount()) for l in layers ) if total == 0: QMessageBox.warning( self, "Plan 41 laden", "Keine Objekte vorhanden — ein leerer Layer wurde angelegt.", ) else: try: from sn_basis.functions.message_wrapper import info info( "Plan 41 geladen", "%d Layer geladen (%s)." % (len(layers), detail), ) except Exception: pass def _on_upload_active(self) -> None: """Lädt den aktiven Layer (und Geschwister-Layer) zum Server hoch.""" if self.vln_logic is None: return # Zusammenfassung für Bestätigungs-Dialog ermitteln try: from sn_basis.functions.qgisui_wrapper import iface as _iface active_layer = _iface.activeLayer() if _iface else None except Exception: active_layer = None summary = self.vln_logic.upload_summary(active_layer) if summary is None: QMessageBox.warning( self, "Hochladen nicht möglich", "Der aktive Layer wurde nicht über 'vln_karten' geladen.", ) return try: answer = QMessageBox.question( self, "Zum Server hochladen", "%s für VKZ %s übertragen?\n\n%s\n\n" "%d Objekte; der bisherige Bestand dieser VKZ wird ersetzt." % ( summary["dataset_label"], summary["vkz"], summary["listing"], summary["total"], ), ) yes = getattr( getattr(QMessageBox, "StandardButton", QMessageBox), "Yes", QMessageBox.Yes if hasattr(QMessageBox, "Yes") else None, ) if answer != yes: return except Exception: return try: success, message = self.vln_logic.upload_active_layer(active_layer) except Exception as exc: try: from sn_plan41.modules.vln_api_client import AuthError as _AuthError if isinstance(exc, _AuthError): self.vln_logic.handle_session_expired() self._on_vln_logout() QMessageBox.warning( self, "Sitzung abgelaufen", "Der API-Key ist nicht mehr gültig — bitte neu anmelden.", ) return except ImportError: pass QMessageBox.warning(self, "Hochladen fehlgeschlagen", str(exc)) return if success: try: from sn_basis.functions.message_wrapper import info info("Upload erfolgreich", message) except Exception: pass else: QMessageBox.warning(self, "Hochladen fehlgeschlagen", message) def _toggle_group(self, checked: bool) -> None: """Zeigt/verbirgt Verfahrens-DB-Gruppe.""" self.group_button.setArrowType(ArrowDown if checked else ArrowRight) self.group_content.setVisible(checked) def _toggle_optional(self, checked: bool) -> None: """Zeigt/verbirgt optionale Linkliste.""" self.optional_button.setArrowType(ArrowDown if checked else ArrowRight) self.optional_content.setVisible(checked) def _on_verfahrens_db_changed(self, path: str) -> None: """Persistieret Verfahrens-DB-Pfad.""" self.verfahrens_db = path set_variable("tab_a_verfahrens_db", path, scope="project") self._update_group_color() def _on_linkliste_changed(self, path: str) -> None: """Persistieret lokale Linkliste.""" self.lokale_linkliste = path set_variable("tab_a_linkliste", path, scope="project") def _on_raumfilter_changed(self, value: str) -> None: """Persistieret Raumfilter-Auswahl.""" set_variable(RAUMFILTER_VAR, value, scope="project") def _create_new_gpkg(self) -> None: """Delegiert GPKG-Erstellung (Prüfungen über Services).""" file_path, _ = QFileDialog.getSaveFileName( self, "Neue Verfahrens-Datenbank", "", "Geopackage (*.gpkg)" ) if file_path: if not file_path.lower().endswith(".gpkg"): file_path += ".gpkg" self.verfahrens_db = file_path self.file_widget.setFilePath(file_path) set_variable("tab_a_verfahrens_db", file_path, scope="project") self._update_group_color() def _update_group_color(self) -> None: """Visuelles Feedback für Verfahrens-DB-Status.""" if self.verfahrens_db: self.group_button.setStyleSheet("font-weight: bold; background-color: ##d7a8ff;") else: self.group_button.setStyleSheet("font-weight: bold;") def _on_load_fachdaten(self) -> None: """Kompatibilitäts-Handler → neue Pipeline.""" source = self.file_widget.filePath() raumfilter = self._raumfilter_combo.currentText() linkliste = self.linkliste_widget.filePath() if self.logic: try: self.logic._on_run_pipeline(source, linkliste, raumfilter) except Exception as exc: QMessageBox.warning( self, "Fehler beim Laden", f"Fehler beim Ausführen der Pipeline: {exc}", QMessageBox.StandardButton.Ok, )