mirror of
https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer.git
synced 2026-10-08 20:07:36 +02:00
* Pamatovat filtry ve formuláři a přidat jejich reset (#84) Filtrační dialog si do konce běhu QGIS pamatuje poslední filtry potvrzené tlačítkem OK, zvlášť pro akce, lokality a samostatné nálezy. Storno ani odmítnuté obrácené rozmezí uložený stav nemění. - tlačítko „Obnovit výchozí“ vrátí formulář do výchozího stavu (bbox zapnutý, PIAN se třemi úrovněmi); uložený stav změní až OK - tlačítko ✕ u každého výběru vymaže jen tento filtr - upozornění nahoře hlásí obnovené filtry a jejich počet - kódy, které po aktualizaci heslářů zmizely, se zahodí; popisky se skládají z aktuálních heslářů - výchozí hodnoty jsou definované na jednom místě, PIAN už není natvrdo v setup_picker() - smoke test, README a changelog v2.2.0 OpenSpec: openspec/changes/add-filter-memory-and-reset/ Připraveno s pomocí AI (implementace subagent, revize a ověření Claude), ručně zkontrolovat v QGIS. * Křížek vrací filtr do výchozího stavu, oznámení bez ikony (#84) Úpravy po ručním testu v QGIS: - ✕ u výběru vrací filtr do jeho výchozího stavu; u PIAN – přesnost obnoví tři předvolené úrovně, místo aby výběr vymazal. Tlačítko je aktivní, jen když se výběr od výchozího liší. PIAN bez omezení jde dál nastavit odškrtnutím všech úrovní ve výběru. - oznámení o obnovených filtrech bez úvodního „ℹ“ Spec, design, tasks, README, changelog a smoke test upraveny. Připraveno s pomocí AI. * Archivovat OpenSpec změnu add-filter-memory-and-reset (#84) Ruční test v QGIS (akce) zapsán k úkolu 4.3, změna archivována do openspec/changes/archive/2026-10-02-add-filter-memory-and-reset/. Připraveno s pomocí AI. * Upřesnit záznam ručního testu u úkolu 4.3 (#84) Vzhled i funkce ověřeny u všech tří typů dat, poslední oprava (✕ u PIAN, oznámení) jen u akcí. Připraveno s pomocí AI.
1280 lines
46 KiB
Python
1280 lines
46 KiB
Python
# -*- coding: utf-8 -*-
|
||
from qgis.core import (
|
||
Qgis,
|
||
QgsApplication,
|
||
QgsAuthMethodConfig,
|
||
QgsMessageLog,
|
||
QgsTask,
|
||
)
|
||
from qgis.gui import QgsDateEdit
|
||
from qgis.PyQt.QtCore import QDate, QSettings, Qt
|
||
from qgis.PyQt.QtWidgets import (
|
||
QCheckBox,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QFormLayout,
|
||
QFrame,
|
||
QGridLayout,
|
||
QGroupBox,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListWidget,
|
||
QListWidgetItem,
|
||
QMessageBox,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QToolButton,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
from qgis.utils import iface
|
||
|
||
from .amcr_codelists import (
|
||
AREAL,
|
||
DRUH_LOKALITY,
|
||
DRUH_NALEZU,
|
||
JISTOTA,
|
||
KATASTRY,
|
||
KRAJE,
|
||
LOKALITA_ZACHOVALOST,
|
||
NALEZ_KATEGORIE,
|
||
NALEZCE,
|
||
NALEZOVE_OKOLNOSTI,
|
||
OBDOBI,
|
||
OKRESY,
|
||
ORGANIZACE,
|
||
PIAN_PRESNOST,
|
||
PRISTUPNOST,
|
||
SPECIFIKACE,
|
||
TYP_AKCE,
|
||
TYP_LOKALITY,
|
||
VEDOUCI,
|
||
download_heslare,
|
||
refresh_globals,
|
||
)
|
||
|
||
# The date filter of the API requires both bounds; a one-sided range makes
|
||
# the server fail with an ArrayIndexOutOfBoundsException and '*' is not
|
||
# accepted either. An empty picker is therefore replaced by these sentinels,
|
||
# which are also the default limits of QgsDateEdit.
|
||
DATE_OPEN_FROM = "0001-01-01"
|
||
DATE_OPEN_TO = "9999-12-31"
|
||
|
||
# Shown by a date picker that is left empty
|
||
DATE_NULL_TEXT = "neomezeno"
|
||
|
||
# The non-empty defaults of the form: PIAN – přesnost pre-selects the
|
||
# three levels better than a cadastral territory, and the map-extent
|
||
# restriction is on. Everything else starts empty.
|
||
DEFAULT_CODES = {
|
||
"pian_presnost": ["HES-000861", "HES-000862", "HES-000863"],
|
||
}
|
||
DEFAULT_CHECKS = {"bbox": True}
|
||
|
||
# The last OK-confirmed form state per data type, alive for the QGIS
|
||
# run only: re-importing the plugin (a restart, Plugin Reloader) drops
|
||
# the dict, which is exactly the required lifetime.
|
||
_REMEMBERED_STATE = {}
|
||
|
||
|
||
# Keep Python references to running tasks. QgsTaskManager only holds the
|
||
# C++ object; without a Python-side reference the wrapper can be garbage
|
||
# collected before the task finishes, which crashes QGIS.
|
||
_ACTIVE_TASKS = []
|
||
|
||
|
||
class UpdateCodelistsTask(QgsTask):
|
||
def __init__(self, description):
|
||
super().__init__(description, QgsTask.Flag.CanCancel)
|
||
self.success = False
|
||
self.exception = None
|
||
# Codelists that failed to download and kept their previous values
|
||
self.failed_sets = []
|
||
|
||
def run(self):
|
||
"""Runs in a background thread."""
|
||
try:
|
||
# Call the download function with the task reference
|
||
self.success = download_heslare(
|
||
task=self, failed=self.failed_sets)
|
||
return self.success
|
||
except Exception as e:
|
||
self.exception = e
|
||
return False
|
||
|
||
def finished(self, result):
|
||
"""Runs in the main thread after run() completes."""
|
||
if result:
|
||
# Safely update the global variables in the main thread
|
||
refresh_globals()
|
||
if self.failed_sets:
|
||
QgsMessageLog.logMessage(
|
||
"Hesláře AMČR aktualizovány částečně, beze změny "
|
||
f"zůstaly: {', '.join(self.failed_sets)}",
|
||
"AMČR", Qgis.MessageLevel.Warning
|
||
)
|
||
else:
|
||
QgsMessageLog.logMessage(
|
||
"Hesláře AMČR byly úspěšně aktualizovány.",
|
||
"AMČR", Qgis.MessageLevel.Info
|
||
)
|
||
else:
|
||
if self.isCanceled():
|
||
QgsMessageLog.logMessage(
|
||
"Aktualizace heslářů byla zrušena.",
|
||
"AMČR", Qgis.MessageLevel.Warning
|
||
)
|
||
else:
|
||
QgsMessageLog.logMessage(
|
||
f"Chyba aktualizace: {self.exception}",
|
||
"AMČR", Qgis.MessageLevel.Critical
|
||
)
|
||
|
||
|
||
class FilterableSelectionDialog(QDialog):
|
||
"""
|
||
A custom dialog for selecting multiple items from
|
||
a list with a search filter.
|
||
Updated for PyQt6/Qt6 compatibility.
|
||
"""
|
||
def __init__(self, title, data_dict, preselected_codes, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle(f"Výběr: {title}")
|
||
self.resize(400, 500)
|
||
|
||
# Store the source data and previously selected items
|
||
self.data_dict = data_dict
|
||
self.preselected = preselected_codes if preselected_codes else []
|
||
|
||
layout = QVBoxLayout()
|
||
|
||
# Setup search input for filtering items
|
||
self.search_bar = QLineEdit()
|
||
self.search_bar.setPlaceholderText("Hledat v seznamu...")
|
||
self.search_bar.textChanged.connect(self.filter_list)
|
||
layout.addWidget(self.search_bar)
|
||
|
||
# Main list widget for displaying selectable items
|
||
self.list_widget = QListWidget()
|
||
self.populate_list()
|
||
layout.addWidget(self.list_widget)
|
||
|
||
# Standard OK/Cancel dialog buttons
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok
|
||
| QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
self.setLayout(layout)
|
||
|
||
def populate_list(self):
|
||
# Sort items alphabetically by their display name
|
||
sorted_names = sorted(self.data_dict.keys())
|
||
for name in sorted_names:
|
||
code = self.data_dict[name]
|
||
item = QListWidgetItem(name)
|
||
|
||
# Store the actual code (ID) hidden in the UserRole
|
||
item.setData(Qt.ItemDataRole.UserRole, code)
|
||
|
||
# Make the item checkable (adds a checkbox)
|
||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||
|
||
# Restore previous selection state
|
||
if code in self.preselected:
|
||
item.setCheckState(Qt.CheckState.Checked)
|
||
else:
|
||
item.setCheckState(Qt.CheckState.Unchecked)
|
||
|
||
self.list_widget.addItem(item)
|
||
|
||
def filter_list(self, text):
|
||
# Hide items that don't match the search text (case-insensitive)
|
||
search_text = text.lower()
|
||
for i in range(self.list_widget.count()):
|
||
item = self.list_widget.item(i)
|
||
item.setHidden(search_text not in item.text().lower())
|
||
|
||
def get_selected_codes(self):
|
||
"""Returns the hidden codes and display labels of all checked items."""
|
||
codes = []
|
||
labels = []
|
||
for i in range(self.list_widget.count()):
|
||
item = self.list_widget.item(i)
|
||
if item.checkState() == Qt.CheckState.Checked:
|
||
codes.append(item.data(Qt.ItemDataRole.UserRole))
|
||
labels.append(item.text())
|
||
return codes, labels
|
||
|
||
|
||
# --- Main window ---
|
||
class AmcrFilterDialog(QDialog):
|
||
"""
|
||
The main filtering UI where users set criteria before downloading data.
|
||
"""
|
||
def __init__(self, typ_dat, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Filtr AMČR")
|
||
self.resize(500, 750)
|
||
|
||
# Determines if we are fetching 'akce' (events)
|
||
# or 'lokalita' (sites)
|
||
self.typ_dat = typ_dat
|
||
|
||
# Cache dictionary to store selected codes for each category
|
||
self.selection_cache = {
|
||
'organizace': [],
|
||
'kraj': [],
|
||
'obdobi': [],
|
||
'areal': [],
|
||
'typ_akce': [],
|
||
'okres': [],
|
||
'katastr': [],
|
||
'vedouci': [],
|
||
'pian_presnost': [],
|
||
'pristupnost': [],
|
||
'typ_lokality': [],
|
||
'druh_lokality': [],
|
||
'jistota': [],
|
||
'lokalita_zachovalost': [],
|
||
'nalez_kategorie': [],
|
||
'druh_nalezu': [],
|
||
'specifikace': [],
|
||
'nalezove_okolnosti': [],
|
||
'nalezce': [],
|
||
}
|
||
|
||
# Pickers registered by setup_picker():
|
||
# cache_key -> (data_source, display_field, clear button)
|
||
self.pickers = {}
|
||
|
||
# Date range pickers, filled by setup_date_range():
|
||
# (API field, label for messages, 'from' widget, 'to' widget)
|
||
self.date_ranges = []
|
||
|
||
layout = QVBoxLayout()
|
||
|
||
# Notice shown when a remembered state was restored
|
||
self.lbl_notice = QLabel()
|
||
self.lbl_notice.setWordWrap(True)
|
||
self.lbl_notice.setStyleSheet(
|
||
"color: #1b5e20; background-color: #e8f5e9; "
|
||
"border: 1px solid #a5d6a7; border-radius: 4px; padding: 6px;"
|
||
)
|
||
self.lbl_notice.setVisible(False)
|
||
layout.addWidget(self.lbl_notice)
|
||
|
||
# Filter by current map canvas extent
|
||
self.chk_bbox = QCheckBox("Omezit vyhledávání rozsahem okna")
|
||
self.chk_bbox.setChecked(DEFAULT_CHECKS["bbox"])
|
||
layout.addWidget(self.chk_bbox)
|
||
|
||
# Positive/negative evidence – valid for Akce
|
||
|
||
if self.typ_dat == "akce":
|
||
self.chk_posevidence = QCheckBox("Pouze pozitivní zjištění")
|
||
layout.addWidget(self.chk_posevidence)
|
||
self.chk_proj_akce = QCheckBox("Pouze projektové akce")
|
||
layout.addWidget(self.chk_proj_akce)
|
||
|
||
layout.addSpacing(10)
|
||
|
||
# Spatial information – valid for all
|
||
|
||
self.picker_kraj = self.setup_picker("Kraj", 'kraj', KRAJE)
|
||
layout.addWidget(self.picker_kraj)
|
||
|
||
self.picker_okres = self.setup_picker("Okres", 'okres', OKRESY)
|
||
layout.addWidget(self.picker_okres)
|
||
|
||
self.picker_katastr = self.setup_picker(
|
||
"Katastr",
|
||
'katastr',
|
||
KATASTRY
|
||
)
|
||
layout.addWidget(self.picker_katastr)
|
||
|
||
self.picker_pristupnost = self.setup_picker(
|
||
"Přístupnost",
|
||
'pristupnost',
|
||
PRISTUPNOST
|
||
)
|
||
layout.addWidget(self.picker_pristupnost)
|
||
|
||
# Filters valid for Akce
|
||
|
||
if self.typ_dat in ["lokalita", "akce"]:
|
||
self.picker_presnost = self.setup_picker(
|
||
"PIAN – přesnost",
|
||
'pian_presnost',
|
||
PIAN_PRESNOST
|
||
)
|
||
layout.addWidget(self.picker_presnost)
|
||
|
||
if self.typ_dat in ["samostatny_nalez", "akce"]:
|
||
self.picker_org = self.setup_picker(
|
||
"Organizace",
|
||
'organizace',
|
||
ORGANIZACE
|
||
)
|
||
layout.addWidget(self.picker_org)
|
||
|
||
if self.typ_dat == "akce":
|
||
self.picker_vedouci = self.setup_picker(
|
||
"Vedoucí výzkumu",
|
||
'vedouci',
|
||
VEDOUCI
|
||
)
|
||
layout.addWidget(self.picker_vedouci)
|
||
|
||
# Type of event
|
||
|
||
self.picker_typ = self.setup_picker(
|
||
"Typ výzkumu",
|
||
'typ_akce',
|
||
TYP_AKCE
|
||
)
|
||
layout.addWidget(self.picker_typ)
|
||
|
||
self.box_datum = self.setup_date_range("Datum", [
|
||
("akce_datum_zahajeni", "Zahájení", "Datum zahájení"),
|
||
("akce_datum_ukonceni", "Ukončení", "Datum ukončení"),
|
||
])
|
||
layout.addWidget(self.box_datum)
|
||
|
||
# Filters valid for Lokality
|
||
|
||
if self.typ_dat == "lokalita":
|
||
self.picker_typ_lokality = self.setup_picker(
|
||
"Lokalita – typ",
|
||
'typ_lokality',
|
||
TYP_LOKALITY
|
||
)
|
||
layout.addWidget(self.picker_typ_lokality)
|
||
|
||
self.picker_druh_lokality = self.setup_picker(
|
||
"Lokalita – druh",
|
||
'druh_lokality',
|
||
DRUH_LOKALITY
|
||
)
|
||
layout.addWidget(self.picker_druh_lokality)
|
||
|
||
self.picker_jistota = self.setup_picker(
|
||
"Lokalita – jistota určení",
|
||
'jistota',
|
||
JISTOTA
|
||
)
|
||
layout.addWidget(self.picker_jistota)
|
||
|
||
self.picker_lokalita_zachovalost = self.setup_picker(
|
||
"Lokalita – stav dochování",
|
||
'lokalita_zachovalost',
|
||
LOKALITA_ZACHOVALOST
|
||
)
|
||
layout.addWidget(self.picker_lokalita_zachovalost)
|
||
|
||
# Contextual information
|
||
|
||
self.picker_obdobi = self.setup_picker("Období", 'obdobi', OBDOBI)
|
||
layout.addWidget(self.picker_obdobi)
|
||
|
||
if self.typ_dat == "samostatny_nalez":
|
||
self.picker_nalez_kategorie = self.setup_picker(
|
||
"Kategorie nálezu",
|
||
'nalez_kategorie',
|
||
NALEZ_KATEGORIE
|
||
)
|
||
layout.addWidget(self.picker_nalez_kategorie)
|
||
|
||
self.picker_druh_nalezu = self.setup_picker(
|
||
"Druh nálezu",
|
||
'druh_nalezu',
|
||
DRUH_NALEZU
|
||
)
|
||
layout.addWidget(self.picker_druh_nalezu)
|
||
|
||
self.picker_specifikace = self.setup_picker(
|
||
"Materiál",
|
||
'specifikace',
|
||
SPECIFIKACE
|
||
)
|
||
layout.addWidget(self.picker_specifikace)
|
||
|
||
self.picker_nalezove_okolnosti = self.setup_picker(
|
||
"Okolnosti nálezu",
|
||
'nalezove_okolnosti',
|
||
NALEZOVE_OKOLNOSTI
|
||
)
|
||
layout.addWidget(self.picker_nalezove_okolnosti)
|
||
|
||
self.picker_nalezce = self.setup_picker(
|
||
"Nálezce",
|
||
'nalezce',
|
||
NALEZCE
|
||
)
|
||
layout.addWidget(self.picker_nalezce)
|
||
|
||
# Lokalita has no date field in the index at all, so the block
|
||
# is built only for the two entities that do
|
||
self.box_datum = self.setup_date_range("Datum nálezu", [
|
||
("samostatny_nalez_datum_nalezu", "", "Datum nálezu"),
|
||
])
|
||
layout.addWidget(self.box_datum)
|
||
|
||
if self.typ_dat != "samostatny_nalez":
|
||
self.picker_areal = self.setup_picker("Areál", 'areal', AREAL)
|
||
layout.addWidget(self.picker_areal)
|
||
|
||
# Option to download related components table
|
||
self.chk_komponenty = QCheckBox("Načíst komponenty")
|
||
layout.addWidget(self.chk_komponenty)
|
||
|
||
# Warning label
|
||
self.lbl_komponenty_warning = QLabel(
|
||
"⚠ Při načtení komponent jsou prostorové prvky duplikovány — "
|
||
"každý prvek odpovídá jedné komponentě. "
|
||
"Prostorové analýzy (plochy, počty) mohou být zkreslené."
|
||
)
|
||
self.lbl_komponenty_warning.setWordWrap(True)
|
||
self.lbl_komponenty_warning.setStyleSheet(
|
||
"color: #8a6d00; background-color: #fff8e1; "
|
||
"border: 1px solid #ffe082; border-radius: 4px; padding: 6px;"
|
||
)
|
||
self.lbl_komponenty_warning.setVisible(False)
|
||
layout.addWidget(self.lbl_komponenty_warning)
|
||
|
||
if self.typ_dat != "samostatny_nalez":
|
||
self.chk_komponenty.toggled.connect(
|
||
self.lbl_komponenty_warning.setVisible
|
||
)
|
||
|
||
# Pushes everything above to the top
|
||
layout.addStretch(1)
|
||
|
||
# The filter stack is taller than the window on every entity
|
||
# (over 1000 px for 'akce'), so it scrolls. The buttons stay
|
||
# outside the scroll area, otherwise the user would have to
|
||
# scroll to the bottom just to confirm the dialog.
|
||
content = QWidget()
|
||
content.setLayout(layout)
|
||
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
scroll.setWidget(content)
|
||
|
||
outer = QVBoxLayout()
|
||
outer.addWidget(scroll)
|
||
|
||
# Main dialog OK/Cancel/Update buttons
|
||
|
||
buttons = QDialogButtonBox()
|
||
|
||
self.btn_update = QPushButton("Aktualizovat hesláře 🔄")
|
||
self.btn_update.setToolTip(
|
||
"Provede kompletní aktualizaci heslářů AMČR. "
|
||
"Toto bude trvat pár minut."
|
||
)
|
||
self.btn_update.clicked.connect(self.action_update_heslare)
|
||
|
||
buttons.addButton(
|
||
self.btn_update,
|
||
QDialogButtonBox.ButtonRole.ActionRole
|
||
)
|
||
|
||
# Reset the form to its defaults; the remembered state changes
|
||
# only on OK. ResetRole puts the button left of the OK/Cancel pair.
|
||
self.btn_reset = buttons.addButton(
|
||
QDialogButtonBox.StandardButton.RestoreDefaults
|
||
)
|
||
self.btn_reset.setText("Obnovit výchozí")
|
||
self.btn_reset.clicked.connect(self.action_reset)
|
||
|
||
buttons.addButton(QDialogButtonBox.StandardButton.Ok)
|
||
buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
|
||
|
||
buttons.accepted.connect(self.accept)
|
||
buttons.rejected.connect(self.reject)
|
||
outer.addWidget(buttons)
|
||
|
||
self.setLayout(outer)
|
||
|
||
# Restore the remembered state (defaults when there is none)
|
||
restored = _REMEMBERED_STATE.get(self.typ_dat)
|
||
if restored is None:
|
||
self._apply_state(self._default_state())
|
||
else:
|
||
self._apply_state(restored)
|
||
diff = self._diff_from_default(restored)
|
||
if diff:
|
||
self.lbl_notice.setText(
|
||
"Načteny filtry z minulého hledání "
|
||
f"(aktivní filtry: {diff})."
|
||
)
|
||
self.lbl_notice.setVisible(True)
|
||
|
||
def _default_state(self):
|
||
"""A full snapshot of the default form state for this typ_dat."""
|
||
# Every checkbox defaults to off, except the ones DEFAULT_CHECKS
|
||
# turns on (the map-extent restriction)
|
||
checks = {name: False for name, _ in self._check_widgets()}
|
||
for name, value in DEFAULT_CHECKS.items():
|
||
if name in checks:
|
||
checks[name] = value
|
||
|
||
return {
|
||
"codes": {
|
||
key: (list(DEFAULT_CODES[key])
|
||
if key in DEFAULT_CODES else [])
|
||
for key in self.pickers
|
||
},
|
||
"checks": checks,
|
||
"dates": {
|
||
api_field: (None, None)
|
||
for api_field, _, _, _ in self.date_ranges
|
||
},
|
||
}
|
||
|
||
def _check_widgets(self):
|
||
"""(name, checkbox) of every checkbox, in a stable order."""
|
||
widgets = []
|
||
for name, attr in (
|
||
("bbox", "chk_bbox"),
|
||
("posevidence", "chk_posevidence"),
|
||
("proj_akce", "chk_proj_akce"),
|
||
("komponenty", "chk_komponenty"),
|
||
):
|
||
if hasattr(self, attr):
|
||
widgets.append((name, getattr(self, attr)))
|
||
return widgets
|
||
|
||
def _snapshot(self):
|
||
"""The whole form state as plain Python (codes/checks/dates)."""
|
||
return {
|
||
"codes": {key: list(codes)
|
||
for key, codes in self.selection_cache.items()},
|
||
"checks": {name: chk.isChecked()
|
||
for name, chk in self._check_widgets()},
|
||
"dates": {
|
||
api_field: (
|
||
None if date_from.isNull()
|
||
else date_from.date().toString("yyyy-MM-dd"),
|
||
None if date_to.isNull()
|
||
else date_to.date().toString("yyyy-MM-dd"),
|
||
)
|
||
for api_field, _, date_from, date_to in self.date_ranges
|
||
},
|
||
}
|
||
|
||
def _apply_state(self, state):
|
||
"""Applies a snapshot; codes unknown to the codelists are
|
||
dropped and the picker texts are rebuilt from them."""
|
||
for key, codes in state.get("codes", {}).items():
|
||
if key in self.pickers:
|
||
self._set_picker(key, codes)
|
||
|
||
for name, chk in self._check_widgets():
|
||
chk.setChecked(state.get("checks", {}).get(name, False))
|
||
|
||
stored = state.get("dates", {})
|
||
for api_field, _, date_from, date_to in self.date_ranges:
|
||
iso_from, iso_to = stored.get(api_field, (None, None))
|
||
# clear(), not setEmpty(): an empty picker must stay
|
||
# isNull() so no filter is sent (see _date_edit)
|
||
if iso_from:
|
||
date_from.setDate(QDate.fromString(
|
||
iso_from, "yyyy-MM-dd"))
|
||
else:
|
||
date_from.clear()
|
||
if iso_to:
|
||
date_to.setDate(QDate.fromString(
|
||
iso_to, "yyyy-MM-dd"))
|
||
else:
|
||
date_to.clear()
|
||
|
||
def _diff_from_default(self, state):
|
||
"""Number of form items in the snapshot that differ from
|
||
_default_state(): one per picker, checkbox and date row."""
|
||
diff = 0
|
||
defaults = self._default_state()
|
||
|
||
for key, codes in state.get("codes", {}).items():
|
||
if (key in defaults["codes"]
|
||
and codes != defaults["codes"][key]):
|
||
diff += 1
|
||
|
||
for name, value in state.get("checks", {}).items():
|
||
if defaults["checks"].get(name) != value:
|
||
diff += 1
|
||
|
||
for api_field, bounds in state.get("dates", {}).items():
|
||
if defaults["dates"].get(api_field) != bounds:
|
||
diff += 1
|
||
|
||
return diff
|
||
|
||
def action_reset(self):
|
||
"""Resets the form to its defaults (the remembered state is
|
||
changed only by OK, so Cancel after reset reverts it)."""
|
||
self._apply_state(self._default_state())
|
||
self.lbl_notice.setVisible(False)
|
||
|
||
def setup_picker(self, label_text, cache_key, data_source, extra_btn=None):
|
||
"""
|
||
Creates a reusable UI component consisting of a label, a read-only
|
||
text field showing selected items, and a button to open
|
||
the selection dialog.
|
||
"""
|
||
row_widget = QGroupBox(label_text)
|
||
row_layout = QHBoxLayout()
|
||
row_layout.setContentsMargins(5, 5, 5, 5)
|
||
|
||
# Read-only field displaying the names of selected items
|
||
display_field = QLineEdit()
|
||
display_field.setReadOnly(True)
|
||
display_field.setPlaceholderText("Nic nevybráno (vše)")
|
||
display_field.setStyleSheet("background-color: #f0f0f0; color: #333;")
|
||
|
||
btn = QPushButton("Vybrat...")
|
||
btn.setFixedWidth(80)
|
||
|
||
# Returns this one filter to its default; enabled only while
|
||
# the selection differs from it (open_dialog re-evaluates it
|
||
# through _set_picker)
|
||
clear_btn = QToolButton()
|
||
clear_btn.setText("✕")
|
||
default_codes = DEFAULT_CODES.get(cache_key, [])
|
||
clear_btn.setToolTip(
|
||
"Vrátit výchozí výběr" if default_codes else "Vymazat výběr"
|
||
)
|
||
clear_btn.setEnabled(False)
|
||
clear_btn.clicked.connect(
|
||
lambda: self._set_picker(cache_key, list(default_codes))
|
||
)
|
||
|
||
# Nested handler: opens the selection dialog and saves the result
|
||
def open_dialog():
|
||
dlg = FilterableSelectionDialog(
|
||
label_text,
|
||
data_source,
|
||
self.selection_cache[cache_key],
|
||
self
|
||
)
|
||
if dlg.exec() == QDialog.DialogCode.Accepted:
|
||
codes, _labels = dlg.get_selected_codes()
|
||
self._set_picker(cache_key, codes)
|
||
|
||
btn.clicked.connect(open_dialog)
|
||
|
||
row_layout.addWidget(display_field)
|
||
row_layout.addWidget(btn)
|
||
row_layout.addWidget(clear_btn)
|
||
|
||
# Optionally append an extra button (e.g. a refresh button)
|
||
if extra_btn:
|
||
row_layout.addWidget(extra_btn)
|
||
|
||
row_widget.setLayout(row_layout)
|
||
|
||
# One place knows the widgets behind every picker, so the cache
|
||
# and the display text can never drift apart
|
||
self.pickers[cache_key] = (data_source, display_field, clear_btn)
|
||
|
||
return row_widget
|
||
|
||
def _set_picker(self, cache_key, codes):
|
||
"""
|
||
Sets one picker: stores the codes, rebuilds the display text from
|
||
the current codelist (a label renamed by Aktualizovat hesláře
|
||
shows its new name, an unknown code is dropped) and enables the
|
||
clear button only while the selection differs from the picker's
|
||
default.
|
||
"""
|
||
data_source, display_field, clear_btn = self.pickers[cache_key]
|
||
|
||
# Keep the order the user picked, drop codes the current
|
||
# codelist no longer knows
|
||
code_to_label = {v: k for k, v in data_source.items()}
|
||
valid_codes = [code for code in codes if code in code_to_label]
|
||
|
||
# Rebuild the labels the same way the selection dialog shows
|
||
# them: sorted by name
|
||
labels = sorted(code_to_label[code] for code in valid_codes)
|
||
|
||
self.selection_cache[cache_key] = valid_codes
|
||
if labels:
|
||
display_field.setText(", ".join(labels))
|
||
else:
|
||
display_field.clear()
|
||
# Order-insensitive: a reordered default is still the default
|
||
default = DEFAULT_CODES.get(cache_key, [])
|
||
clear_btn.setEnabled(
|
||
sorted(valid_codes) != sorted(default)
|
||
)
|
||
|
||
def setup_date_range(self, title, rows):
|
||
"""
|
||
Creates a compact date range block: one row per API date field,
|
||
each with a 'from' and a 'to' picker.
|
||
|
||
rows is a list of (api_field, row_label, name_for_messages).
|
||
An empty row_label is used when the group box title already names
|
||
the field, which keeps the single-row variant from repeating itself.
|
||
|
||
A picker left empty means an open bound; the sentinel is
|
||
substituted in get_filters(), not here, so that an untouched
|
||
block adds no filter at all.
|
||
"""
|
||
row_widget = QGroupBox(title)
|
||
grid = QGridLayout()
|
||
grid.setContentsMargins(5, 5, 5, 5)
|
||
grid.setVerticalSpacing(3)
|
||
grid.setHorizontalSpacing(6)
|
||
|
||
for row, (api_field, row_label, name) in enumerate(rows):
|
||
if row_label:
|
||
grid.addWidget(QLabel(row_label), row, 0)
|
||
|
||
date_from = self._date_edit(
|
||
f"{name} – od (prázdné = bez dolní meze)"
|
||
)
|
||
date_to = self._date_edit(
|
||
f"{name} – do (prázdné = bez horní meze)"
|
||
)
|
||
|
||
separator = QLabel("–")
|
||
separator.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
|
||
grid.addWidget(date_from, row, 1)
|
||
grid.addWidget(separator, row, 2)
|
||
grid.addWidget(date_to, row, 3)
|
||
|
||
self.date_ranges.append((api_field, name, date_from, date_to))
|
||
|
||
grid.setColumnStretch(1, 1)
|
||
grid.setColumnStretch(3, 1)
|
||
|
||
row_widget.setLayout(grid)
|
||
return row_widget
|
||
|
||
@staticmethod
|
||
def _date_edit(tooltip):
|
||
"""
|
||
A date picker that may stay empty.
|
||
|
||
clear() is essential here – setEmpty() looks empty but leaves
|
||
isNull() False with today's date, which would silently apply
|
||
a filter the user never set.
|
||
"""
|
||
widget = QgsDateEdit()
|
||
widget.setAllowNull(True)
|
||
widget.setNullRepresentation(DATE_NULL_TEXT)
|
||
widget.setDisplayFormat("d. M. yyyy")
|
||
widget.setCalendarPopup(True)
|
||
widget.clear()
|
||
widget.setToolTip(tooltip)
|
||
return widget
|
||
|
||
def accept(self):
|
||
"""
|
||
Blocks the dialog on a reversed date range. The API answers such
|
||
a query with zero records and no error, which is indistinguishable
|
||
from a genuinely empty result.
|
||
"""
|
||
reversed_ranges = [
|
||
name for _, name, date_from, date_to in self.date_ranges
|
||
if not date_from.isNull() and not date_to.isNull()
|
||
and date_from.date() > date_to.date()
|
||
]
|
||
|
||
if reversed_ranges:
|
||
QMessageBox.warning(
|
||
self,
|
||
"Neplatné rozmezí",
|
||
"U těchto filtrů je počáteční datum novější než koncové:\n"
|
||
+ "\n".join(f"• {name}" for name in reversed_ranges)
|
||
+ "\n\nDotaz by nevrátil žádný záznam. Opravte rozmezí, "
|
||
"nebo jedno z polí vyprázdněte."
|
||
)
|
||
return
|
||
|
||
# Remember the confirmed state only after the date check passed
|
||
_REMEMBERED_STATE[self.typ_dat] = self._snapshot()
|
||
|
||
super().accept()
|
||
|
||
def action_update_heslare(self):
|
||
# Create the task instance and keep a reference so the Python
|
||
# wrapper survives until the task finishes
|
||
task = UpdateCodelistsTask("Aktualizace heslářů AMČR")
|
||
_ACTIVE_TASKS.append(task)
|
||
|
||
# Prevent parallel downloads overwriting heslar.csv
|
||
self.btn_update.setEnabled(False)
|
||
|
||
# Message boxes are parented to the main window, not to this dialog –
|
||
# the dialog may already be closed (and its C++ object deleted)
|
||
# by the time the minute-long task finishes.
|
||
parent_win = iface.mainWindow() if iface else None
|
||
|
||
def _cleanup():
|
||
if task in _ACTIVE_TASKS:
|
||
_ACTIVE_TASKS.remove(task)
|
||
try:
|
||
self.btn_update.setEnabled(True)
|
||
except RuntimeError:
|
||
pass # dialog already closed
|
||
|
||
def on_completed():
|
||
_cleanup()
|
||
if task.failed_sets:
|
||
QMessageBox.warning(
|
||
parent_win,
|
||
"Hesláře aktualizovány částečně",
|
||
"Některé hesláře se nepodařilo stáhnout, "
|
||
"ponechány byly jejich předchozí hodnoty:\n"
|
||
+ "\n".join(f"• {name}" for name in task.failed_sets)
|
||
+ "\n\nPodrobnosti jsou v panelu Zprávy, záložka AMČR."
|
||
)
|
||
return
|
||
QMessageBox.information(
|
||
parent_win,
|
||
"Hotovo",
|
||
"Hesláře byly úspěšně aktualizovány."
|
||
)
|
||
|
||
# Show the exact error if the task fails
|
||
def on_error():
|
||
_cleanup()
|
||
if task.exception:
|
||
# This will show exactly what went wrong (e.g. PermissionError)
|
||
msg = (
|
||
"Aktualizace selhala z důvodu chyby:\n"
|
||
f"{task.exception!s}"
|
||
)
|
||
else:
|
||
msg = "Aktualizace byla zrušena uživatelem."
|
||
QMessageBox.warning(parent_win, "Chyba / Zrušeno", msg)
|
||
|
||
task.taskCompleted.connect(on_completed)
|
||
task.taskTerminated.connect(on_error)
|
||
|
||
QgsApplication.taskManager().addTask(task)
|
||
|
||
def get_bbox(self):
|
||
return "true" if self.chk_bbox.isChecked() else "false"
|
||
|
||
def get_komponenty(self):
|
||
if self.typ_dat in ["akce", "lokalita"]:
|
||
return "true" if self.chk_komponenty.isChecked() else "false"
|
||
return "false"
|
||
|
||
def get_filters(self):
|
||
"""Compiles the user selections from the cache into
|
||
API-ready filter parameters."""
|
||
filters = {}
|
||
|
||
if self.selection_cache['kraj']:
|
||
filters['f_kraj'] = self.selection_cache['kraj']
|
||
if self.selection_cache['okres']:
|
||
filters['f_okres'] = self.selection_cache['okres']
|
||
if self.selection_cache['katastr']:
|
||
filters['f_katastr'] = self.selection_cache['katastr']
|
||
if self.selection_cache['obdobi']:
|
||
filters['f_obdobi'] = self.selection_cache['obdobi']
|
||
if self.selection_cache['areal']:
|
||
filters['f_areal'] = self.selection_cache['areal']
|
||
if self.selection_cache['pian_presnost']:
|
||
filters['f_pian_presnost'] = self.selection_cache['pian_presnost']
|
||
if self.selection_cache['pristupnost']:
|
||
filters['pristupnost'] = self.selection_cache['pristupnost']
|
||
|
||
if self.typ_dat == "akce":
|
||
if self.chk_posevidence.isChecked():
|
||
filters['posevidence'] = 'true'
|
||
if self.chk_proj_akce.isChecked():
|
||
filters['proj_akce'] = 'true'
|
||
|
||
if self.selection_cache['typ_akce']:
|
||
filters['f_typ_vyzkumu'] = self.selection_cache['typ_akce']
|
||
if self.selection_cache['vedouci']:
|
||
filters['f_vedouci'] = self.selection_cache['vedouci']
|
||
|
||
if self.selection_cache['organizace']:
|
||
filters['f_organizace'] = self.selection_cache['organizace']
|
||
|
||
if self.selection_cache['typ_lokality']:
|
||
filters['f_typ_lokality'] = self.selection_cache['typ_lokality']
|
||
if self.selection_cache['druh_lokality']:
|
||
filters['f_druh_lokality'] = self.selection_cache['druh_lokality']
|
||
if self.selection_cache['jistota']:
|
||
filters['f_jistota'] = self.selection_cache['jistota']
|
||
if self.selection_cache['lokalita_zachovalost']:
|
||
filters['f_lokalita_zachovalost'] = (
|
||
self.selection_cache['lokalita_zachovalost']
|
||
)
|
||
|
||
# Samostatné nálezy
|
||
if self.selection_cache['nalez_kategorie']:
|
||
filters['f_kategorie'] = self.selection_cache['nalez_kategorie']
|
||
if self.selection_cache['druh_nalezu']:
|
||
filters['f_druh_nalezu'] = self.selection_cache['druh_nalezu']
|
||
if self.selection_cache['specifikace']:
|
||
filters['f_specifikace'] = self.selection_cache['specifikace']
|
||
if self.selection_cache['nalezove_okolnosti']:
|
||
filters['f_nalezove_okolnosti'] = (
|
||
self.selection_cache['nalezove_okolnosti']
|
||
)
|
||
if self.selection_cache['nalezce']:
|
||
filters['f_nalezce'] = self.selection_cache['nalezce']
|
||
|
||
# Date ranges – the API needs both bounds, so an empty picker is
|
||
# replaced by a sentinel. A block with both pickers empty adds no
|
||
# filter at all; sending the full 0001–9999 range would only
|
||
# clutter the log without narrowing anything.
|
||
for api_field, _, date_from, date_to in self.date_ranges:
|
||
if date_from.isNull() and date_to.isNull():
|
||
continue
|
||
|
||
od = (DATE_OPEN_FROM if date_from.isNull()
|
||
else date_from.date().toString("yyyy-MM-dd"))
|
||
do = (DATE_OPEN_TO if date_to.isNull()
|
||
else date_to.date().toString("yyyy-MM-dd"))
|
||
|
||
filters[api_field] = f"{od},{do}"
|
||
|
||
return filters
|
||
|
||
|
||
class LoginDialog(QDialog):
|
||
"""
|
||
Dialog for saving AMČR login credentials securely in the
|
||
QGIS Authentication Manager.
|
||
|
||
Credentials are encrypted by the platform's native secret storage
|
||
(DPAPI on Windows, Keychain on macOS, encrypted SQLite on Linux).
|
||
The auth config ID is persisted in QSettings so the session can be
|
||
restored automatically after a QGIS restart.
|
||
|
||
Note on QgsAuthManager quirks (QGIS 4 / Python bindings):
|
||
- hasConfigId() is unreliable – it checks an in-memory cache that may not
|
||
be populated yet. We never use it as a hard gate; we skip it and call
|
||
loadAuthenticationConfig() directly instead.
|
||
- storeAuthenticationConfig() and loadAuthenticationConfig() both have
|
||
SIP_INOUT on their config parameter, so Python bindings return a tuple
|
||
(bool, QgsAuthMethodConfig) rather than just bool. Always unpack both.
|
||
- loadAuthenticationConfig() with full=False loads only metadata
|
||
(name, method,
|
||
id) but NOT the config() values like username/password. Use full=True to
|
||
access those.
|
||
"""
|
||
|
||
SETTINGS_KEY = "amcr_viewer/auth_config_id"
|
||
CONFIG_NAME = "AMČR Viewer"
|
||
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Přihlášení do AMČR")
|
||
self.setMinimumWidth(360)
|
||
|
||
layout = QVBoxLayout()
|
||
|
||
# Check whether a config ID is already stored from a previous session.
|
||
# We attempt a lightweight load (full=False) to confirm it is readable,
|
||
# since hasConfigId() may return False even for valid configs
|
||
# (cache lag).
|
||
# The Auth Manager must be unlocked before we attempt to read from it;
|
||
# otherwise loadAuthenticationConfig() returns ok=False even for valid
|
||
# configs, causing _has_saved to be incorrectly set to False.
|
||
existing_id = QSettings().value(self.SETTINGS_KEY, "")
|
||
if existing_id:
|
||
QgsApplication.authManager().setMasterPassword(True)
|
||
username = self._load_username_from_config(existing_id)
|
||
self._has_saved = bool(existing_id) and bool(username)
|
||
|
||
if self._has_saved:
|
||
info = QLabel(
|
||
"✔ Přihlašovací údaje jsou bezpečně uloženy "
|
||
"ve správci autentizace QGIS.\n"
|
||
"Vyplňte pole níže pouze pokud je chcete změnit."
|
||
)
|
||
info.setStyleSheet("color: green; font-style: italic;")
|
||
else:
|
||
info = QLabel(
|
||
"Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\n"
|
||
"Budou zašifrovaně uloženy ve správci autentizace QGIS."
|
||
)
|
||
info.setWordWrap(True)
|
||
layout.addWidget(info)
|
||
layout.addSpacing(8)
|
||
|
||
form = QFormLayout()
|
||
|
||
self.txt_user = QLineEdit()
|
||
self.txt_user.setPlaceholderText("např. jan.novak@email.cz")
|
||
# Pre-fill the stored username (not sensitive) for convenience
|
||
if self._has_saved:
|
||
self.txt_user.setText(self._load_username_from_config(existing_id))
|
||
form.addRow("E-mail:", self.txt_user)
|
||
|
||
self.txt_pass = QLineEdit()
|
||
self.txt_pass.setEchoMode(QLineEdit.EchoMode.Password)
|
||
self.txt_pass.setPlaceholderText(
|
||
"ponechte prázdné pro zachování stávajícího hesla"
|
||
if self._has_saved
|
||
else "heslo"
|
||
)
|
||
form.addRow("Heslo:", self.txt_pass)
|
||
|
||
layout.addLayout(form)
|
||
layout.addSpacing(8)
|
||
|
||
if self._has_saved:
|
||
btn_forget = QPushButton("Odebrat uložené přihlašovací údaje")
|
||
btn_forget.setStyleSheet("color: #c0392b;")
|
||
btn_forget.clicked.connect(self._forget_credentials)
|
||
layout.addWidget(btn_forget)
|
||
|
||
layout.addStretch(1)
|
||
|
||
buttons = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok
|
||
| QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
buttons.accepted.connect(self._save_and_accept)
|
||
buttons.rejected.connect(self.reject)
|
||
layout.addWidget(buttons)
|
||
|
||
self.setLayout(layout)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Private helpers
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def _load_config(config_id: str, full: bool = False):
|
||
"""
|
||
Attempt to load a QgsAuthMethodConfig by ID.
|
||
Returns (ok, cfg). Never raises; returns (False, empty cfg)
|
||
on any error.
|
||
full=True decrypts and includes the password.
|
||
"""
|
||
try:
|
||
auth_mgr = QgsApplication.authManager()
|
||
cfg = QgsAuthMethodConfig()
|
||
result = auth_mgr.loadAuthenticationConfig(config_id, cfg, full)
|
||
# Python bindings return (bool, cfg) due to SIP_INOUT parameter
|
||
if isinstance(result, tuple):
|
||
return result
|
||
return result, cfg
|
||
except Exception:
|
||
return False, QgsAuthMethodConfig()
|
||
|
||
def _load_username_from_config(self, config_id: str) -> str:
|
||
"""Load the username from a stored config.
|
||
Requires full=True since config() values are only populated
|
||
when the config is fully decrypted."""
|
||
ok, cfg = self._load_config(config_id, full=True)
|
||
return cfg.config("username", "") if ok else ""
|
||
|
||
def _ensure_master_password(self) -> bool:
|
||
"""
|
||
Ensure the Auth Manager is unlocked before writing.
|
||
Prompts the user to set or enter the master password if needed.
|
||
Returns True if the manager is ready, False if the user cancelled.
|
||
"""
|
||
auth_mgr = QgsApplication.authManager()
|
||
|
||
if auth_mgr.isDisabled():
|
||
QMessageBox.critical(
|
||
self, "Správce autentizace nedostupný",
|
||
"Správce autentizace QGIS je zakázán nebo poškozený.\n"
|
||
"Zkuste obnovit databázi: "
|
||
"Nastavení → Možnosti → Autentizace → Pomůcky."
|
||
)
|
||
return False
|
||
|
||
# setMasterPassword(True) shows the QGIS
|
||
# master password dialog if needed
|
||
if not auth_mgr.setMasterPassword(True):
|
||
return False # User cancelled the master password dialog
|
||
|
||
return True
|
||
|
||
def _verify_credentials(self, username: str, password: str) -> bool:
|
||
"""
|
||
Verify the credentials against the API before saving them.
|
||
Returns True if they should be stored: either the login succeeded,
|
||
or the server was unreachable and the user chose to keep them
|
||
unverified. Wrong credentials are never stored.
|
||
"""
|
||
# Lazy import to avoid an import cycle
|
||
# (amcr_tools imports LoginDialog lazily as well)
|
||
from . import amcr_tools
|
||
|
||
if amcr_tools.login_to_api(username, password):
|
||
return True
|
||
|
||
if amcr_tools.LAST_LOGIN_ERROR == 'network':
|
||
answer = QMessageBox.question(
|
||
self,
|
||
"Server nedostupný",
|
||
"Přihlašovací údaje se nepodařilo ověřit – server AMČR "
|
||
"je nedostupný.\nChcete je přesto uložit (neověřené)?",
|
||
QMessageBox.StandardButton.Yes
|
||
| QMessageBox.StandardButton.No
|
||
)
|
||
return answer == QMessageBox.StandardButton.Yes
|
||
|
||
QMessageBox.warning(
|
||
self,
|
||
"Neplatné přihlašovací údaje",
|
||
"Přihlášení se nezdařilo – zkontrolujte e-mail a heslo.\n"
|
||
"Údaje nebyly uloženy."
|
||
)
|
||
return False
|
||
|
||
# ------------------------------------------------------------------
|
||
# Button actions
|
||
# ------------------------------------------------------------------
|
||
|
||
def _save_and_accept(self):
|
||
username = self.txt_user.text().strip()
|
||
password = self.txt_pass.text()
|
||
|
||
if not username:
|
||
QMessageBox.warning(
|
||
self,
|
||
"Chybí údaje",
|
||
"Vyplňte prosím e-mailovou adresu."
|
||
)
|
||
return
|
||
|
||
existing_id = QSettings().value(self.SETTINGS_KEY, "")
|
||
auth_mgr = QgsApplication.authManager()
|
||
|
||
# If a config already exists and the password field is blank,
|
||
# update only the username and keep the existing encrypted password.
|
||
if not password and existing_id:
|
||
ok, cfg = self._load_config(existing_id, full=True)
|
||
if ok:
|
||
if not self._ensure_master_password():
|
||
return
|
||
# Verify the new username against the stored password
|
||
if not self._verify_credentials(
|
||
username, cfg.config("password", "")
|
||
):
|
||
return
|
||
cfg.setConfig("username", username)
|
||
auth_mgr.updateAuthenticationConfig(cfg)
|
||
self.accept()
|
||
return
|
||
|
||
if not password:
|
||
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím heslo.")
|
||
return
|
||
|
||
# Verify before prompting for the master password – wrong
|
||
# credentials must never reach the Authentication Manager
|
||
if not self._verify_credentials(username, password):
|
||
return
|
||
|
||
if not self._ensure_master_password():
|
||
return
|
||
|
||
cfg = QgsAuthMethodConfig()
|
||
cfg.setName(self.CONFIG_NAME)
|
||
cfg.setMethod("Basic")
|
||
cfg.setConfig("username", username)
|
||
cfg.setConfig("password", password) # nosec B106
|
||
|
||
settings = QSettings()
|
||
|
||
# Try to update an existing config first;
|
||
# fall back to creating a new one.
|
||
# We skip hasConfigId() as it may return False
|
||
# despite the config existing
|
||
# (in-memory cache may not be populated yet in QGIS 4).
|
||
ok_load, _ = (
|
||
self._load_config(existing_id, full=False)
|
||
if existing_id
|
||
else (False, None)
|
||
)
|
||
if ok_load:
|
||
cfg.setId(existing_id)
|
||
ok = auth_mgr.updateAuthenticationConfig(cfg)
|
||
else:
|
||
ok, cfg = auth_mgr.storeAuthenticationConfig(cfg)
|
||
|
||
config_id = cfg.id() if cfg else ""
|
||
|
||
if not ok or not config_id:
|
||
QMessageBox.critical(
|
||
self, "Chyba uložení",
|
||
"Přihlašovací údaje se nepodařilo "
|
||
"uložit do správce autentizace QGIS.\n"
|
||
"Zkuste restartovat QGIS a přihlásit se znovu."
|
||
)
|
||
return
|
||
|
||
settings.setValue(self.SETTINGS_KEY, config_id)
|
||
self.accept()
|
||
|
||
def _forget_credentials(self):
|
||
# Lazy import to avoid an import cycle
|
||
# (amcr_tools imports LoginDialog lazily as well)
|
||
from . import amcr_tools
|
||
|
||
settings = QSettings()
|
||
existing_id = settings.value(self.SETTINGS_KEY, "")
|
||
if existing_id:
|
||
QgsApplication.authManager().removeAuthenticationConfig(
|
||
existing_id
|
||
)
|
||
settings.remove(self.SETTINGS_KEY)
|
||
|
||
# Without credentials the session in memory would otherwise stay
|
||
# logged in until QGIS is restarted
|
||
if amcr_tools.logout_from_api():
|
||
zprava = ("Uložené přihlašovací údaje byly odebrány "
|
||
"a uživatel byl odhlášen.")
|
||
else:
|
||
zprava = ("Uložené přihlašovací údaje byly odebrány. Server "
|
||
"se nepodařilo kontaktovat, další stahování ale "
|
||
"proběhne anonymně.")
|
||
QMessageBox.information(self, "Hotovo", zprava)
|
||
self.reject()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Public static API – call this anywhere in the plugin to get credentials
|
||
# ------------------------------------------------------------------
|
||
|
||
@staticmethod
|
||
def get_credentials() -> tuple[str, str]:
|
||
"""
|
||
Retrieve (username, password) from the QGIS Authentication Manager.
|
||
Returns ('', '') if no credentials are stored or the manager is locked.
|
||
|
||
Note: hasConfigId() is intentionally skipped here – it checks an
|
||
in-memory cache that may lag behind the actual database contents,
|
||
causing false negatives (see class docstring).
|
||
loadAuthenticationConfig() is called directly and its return value is
|
||
used as the authoritative result.
|
||
"""
|
||
settings = QSettings()
|
||
config_id = settings.value(LoginDialog.SETTINGS_KEY, "")
|
||
|
||
if not config_id:
|
||
return "", ""
|
||
|
||
ok, cfg = LoginDialog._load_config(config_id, full=True)
|
||
if not ok:
|
||
return "", ""
|
||
|
||
return (cfg.config("username", ""),
|
||
cfg.config("password", "")) # nosec B106
|