From ba4103946818036b6389d9373c06f4f2d46edf23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Sp=C3=A1=C4=8Dil?= Date: Tue, 19 May 2026 13:05:07 +0200 Subject: [PATCH] Feature/login (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * přidáno nové tlačítko do toolbaru * přihlašovací dialog * funkce pro přihlašování * aplikace získávání dat z API přes session * zastřešující akce v main skriptu * ošetření prodlužování session/opětovné přihlášení * přidání přístupnosti do hesláře * aktualizace přibaleného hesláře * oprava stahování hesláře (pristupnost vyžaduje písmeno [A/B/C/D], ne heslo; aktualizace hesláře * aplikace přístupnosti do filtračního dialogu * úpravy přihlašovacího dialogu --- amcr_viewer/amcr_codelists.py | 14 +++- amcr_viewer/amcr_dialog.py | 110 +++++++++++++++++++++++++++-- amcr_viewer/amcr_tools.py | 116 ++++++++++++++++++++++++++++++- amcr_viewer/amcr_viewer.py | 33 ++++++++- amcr_viewer/codelists/heslar.csv | 12 +++- 5 files changed, 268 insertions(+), 17 deletions(-) diff --git a/amcr_viewer/amcr_codelists.py b/amcr_viewer/amcr_codelists.py index 73b2058..a9421c6 100644 --- a/amcr_viewer/amcr_codelists.py +++ b/amcr_viewer/amcr_codelists.py @@ -25,7 +25,8 @@ slovnicek = { 'typ_lokality' : 'heslo:lokalita_typ', 'druh_lokality' : 'heslo:lokalita_druh', 'jistota' : 'heslo:jistota_urceni', - 'lokalita_zachovalost' : 'heslo:stav_dochovani' + 'lokalita_zachovalost' : 'heslo:stav_dochovani', + 'pristupnost' : 'heslo:pristupnost' } NS = { @@ -78,7 +79,7 @@ def parse_codelist_file(filename, target_dict=None): return target_dict def load_all_data(): - """Loads all static and dynamic codelists during plugin startup.""" + """Loads the codelist during plugin startup.""" ensure_codelists_dir() categorized_data = {k: {} for k in slovnicek.keys()} parse_codelist_file('heslar.csv', categorized_data) @@ -125,6 +126,9 @@ def fetch_set(internal_name, api_set, task=None): if internal_name in specialni_pripady: kod = nazev + if internal_name == 'pristupnost': + kod = next((t.text for t in titles if t.text and len(t.text) == 1 and t.text.isalpha()), None) + dataset.append({ 'Název': nazev, 'Kód': kod, @@ -186,7 +190,7 @@ def download_heslare(task=None): def refresh_globals(): """Znovu načte data ze souborů do globálních proměnných.""" global OBDOBI, TYP_AKCE, AREAL, KRAJE, ORGANIZACE, OKRESY, KATASTRY - global VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY, DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST + global VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY, DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST data = load_all_data() @@ -216,6 +220,9 @@ def refresh_globals(): JISTOTA.update(data.get('jistota', {})) LOKALITA_ZACHOVALOST.clear() LOKALITA_ZACHOVALOST.update(data.get('lokalita_zachovalost', {})) + PRISTUPNOST.clear() + PRISTUPNOST.update(data.get('pristupnost', {})) + # Inicializace prázdných diktů, které se naplní hned pod tím OBDOBI = {} @@ -231,5 +238,6 @@ TYP_LOKALITY = {} DRUH_LOKALITY = {} JISTOTA = {} LOKALITA_ZACHOVALOST = {} +PRISTUPNOST = {} refresh_globals() \ No newline at end of file diff --git a/amcr_viewer/amcr_dialog.py b/amcr_viewer/amcr_dialog.py index 3a9a61c..dca1e39 100644 --- a/amcr_viewer/amcr_dialog.py +++ b/amcr_viewer/amcr_dialog.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- +import base64 from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout, QLineEdit, QDialogButtonBox, QCheckBox, QGroupBox, QPushButton, QListWidget, QListWidgetItem, QHBoxLayout, - QMessageBox) -from qgis.PyQt.QtCore import Qt + QMessageBox, QLabel, QFormLayout) +from qgis.PyQt.QtCore import Qt, QSettings from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE, OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY, - DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, + DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST, download_heslare, refresh_globals) class UpdateCodelistsTask(QgsTask): @@ -133,7 +134,7 @@ class AmcrFilterDialog(QDialog): # Cache dictionary to store selected codes for each category self.selection_cache = { 'organizace': [], 'kraj': [], 'obdobi': [], 'areal': [], - 'typ_akce': [], 'okres': [], 'katastr': [], 'vedouci': [], 'pian_presnost': [], + 'typ_akce': [], 'okres': [], 'katastr': [], 'vedouci': [], 'pian_presnost': [], 'pristupnost': [], 'typ_lokality': [], 'druh_lokality': [], 'jistota': [], 'lokalita_zachovalost': [] } @@ -165,6 +166,9 @@ class AmcrFilterDialog(QDialog): self.picker_presnost = self.setup_picker("PIAN – přesnost", 'pian_presnost', PIAN_PRESNOST) layout.addWidget(self.picker_presnost) + + self.picker_pristupnost = self.setup_picker("Přístupnost", 'pristupnost', PRISTUPNOST) + layout.addWidget(self.picker_pristupnost) # Filters valid for Akce @@ -321,6 +325,8 @@ class AmcrFilterDialog(QDialog): 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(): @@ -342,4 +348,98 @@ class AmcrFilterDialog(QDialog): if self.selection_cache['lokalita_zachovalost']: filters['f_lokalita_zachovalost'] = self.selection_cache['lokalita_zachovalost'] - return filters \ No newline at end of file + return filters + +class LoginDialog(QDialog): + """ + Dialog pro uložení přihlašovacích údajů do AMČR. + Ukládá do QSettings (username plaintext, heslo base64). + """ + + KEY_USER = "amcr_viewer/username" + KEY_PASS = "amcr_viewer/password" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Přihlášení do AMČR") + self.setMinimumWidth(360) + + layout = QVBoxLayout() + + settings = QSettings() + has_saved = bool(settings.value(self.KEY_USER, "")) + + if has_saved: + info = QLabel("✔ Přihlašovací údaje jsou uloženy. Vyplňte pole níže pro jejich změnu.") + info.setStyleSheet("color: green; font-style: italic;") + else: + info = QLabel("Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\nVarování: přihlašovací údaje budou nezašifrovaně uloženy v registrech systému.") + info.setWordWrap(True) + layout.addWidget(info) + layout.addSpacing(8) + + form = QFormLayout() + + self.txt_user = QLineEdit() + self.txt_user.setPlaceholderText("např. jan.novak@email.cz") + # Předvyplnit uložené jméno pro pohodlí + self.txt_user.setText(settings.value(self.KEY_USER, "")) + form.addRow("E-mail:", self.txt_user) + + self.txt_pass = QLineEdit() + self.txt_pass.setEchoMode(QLineEdit.EchoMode.Password) + self.txt_pass.setPlaceholderText("heslo") + form.addRow("Heslo:", self.txt_pass) + + layout.addLayout(form) + layout.addSpacing(8) + + if has_saved: + btn_forget = QPushButton("Zapomenout 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) + + def _save_and_accept(self): + username = self.txt_user.text().strip() + password = self.txt_pass.text() + + if not username or not password: + QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím uživatelské jméno i heslo.") + return + + settings = QSettings() + settings.setValue(self.KEY_USER, username) + # base64 není šifrování, ale heslo aspoň neleží v plaintextu v registru + settings.setValue(self.KEY_PASS, base64.b64encode(password.encode()).decode()) + self.accept() + + def _forget_credentials(self): + settings = QSettings() + settings.remove(self.KEY_USER) + settings.remove(self.KEY_PASS) + QMessageBox.information(self, "Hotovo", "Přihlašovací údaje byly odstraněny.") + self.reject() + + @staticmethod + def get_credentials() -> tuple[str, str]: + """Vrátí (username, password) z QSettings, nebo ('', '') pokud nejsou uloženy.""" + settings = QSettings() + username = settings.value(LoginDialog.KEY_USER, "") + encoded = settings.value(LoginDialog.KEY_PASS, "") + try: + password = base64.b64decode(encoded.encode()).decode() if encoded else "" + except Exception: + password = "" + return username, password \ No newline at end of file diff --git a/amcr_viewer/amcr_tools.py b/amcr_viewer/amcr_tools.py index c58585a..6638a1b 100644 --- a/amcr_viewer/amcr_tools.py +++ b/amcr_viewer/amcr_tools.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- from qgis.core import (QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry, QgsField, QgsCoordinateReferenceSystem, QgsCoordinateTransform, - QgsWkbTypes, Qgis) + QgsWkbTypes, Qgis, QgsApplication, QgsAuthMethodConfig, QgsMessageLog) from qgis.utils import iface from qgis.PyQt.QtCore import Qt, QMetaType from qgis.PyQt.QtWidgets import QApplication @@ -12,6 +12,116 @@ import json # Global cache to store translated terms from the Digital Archive TRANSLATIONS = {} +# Session s autentizační cookie po přihlášení; None = nepřihlášen (anonymní přístup) +AMCR_SESSION: requests.Session | None = None + +def _log(msg: str, level=Qgis.MessageLevel.Info): + """Shortcut: zapíše zprávu do QGIS logu (panel Zprávy → záložka AMČR).""" + QgsMessageLog.logMessage(msg, "AMČR login", level) + + +def login_to_api(username: str, password: str): + """ + Přihlásí se do Digiarchiv API pomocí username a hesla. + Vrátí requests.Session s nastavenou session cookie, nebo None při chybě. + """ + login_url = "https://digiarchiv.aiscr.cz/api/user/login" + + _log(f"Přihlašuji uživatele: '{username}'") + + if not username or not password: + _log("CHYBA: username nebo heslo je prázdné.", Qgis.MessageLevel.Critical) + return None + + session = requests.Session() + session.headers.update({ + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "User-Agent": "QGIS-Plugin/1.0 (AISCR Data Fetcher)" + }) + + try: + _log(f"Odesílám POST na {login_url} ...") + response = session.post(login_url, json={"user": username, "pwd": password}, timeout=10) + _log(f"HTTP status: {response.status_code}") + response.raise_for_status() + + # API vrací chyby se status kódem 200 – je nutné zkontrolovat tělo odpovědi + body = response.json() + if "error" in body: + _log(f"CHYBA přihlášení (API): {body['error']}", Qgis.MessageLevel.Critical) + return None + + _log("Přihlášení proběhlo úspěšně.") + global AMCR_SESSION + AMCR_SESSION = session + return session + + except requests.exceptions.HTTPError as e: + _log(f"CHYBA HTTP {e.response.status_code if e.response else '?'}: " + f"{e.response.text[:300] if e.response else 'žádná odpověď'}", Qgis.MessageLevel.Critical) + return None + except requests.exceptions.RequestException as e: + _log(f"CHYBA sítě: {e}", Qgis.MessageLevel.Critical) + return None + +def _get_session() -> requests.Session | None: + """ + Vrátí aktivní session. Pokud žádná není (restart QGIS), pokusí se + automaticky přihlásit pomocí uložených přihlašovacích údajů. + Vrátí None pokud přihlašovací údaje nejsou uloženy. + """ + global AMCR_SESSION + if AMCR_SESSION is not None: + return AMCR_SESSION + + # Zkusit auto-login pomocí uložených údajů + from .amcr_dialog import LoginDialog + username, password = LoginDialog.get_credentials() + if username and password: + _log("Session vypršela nebo chybí – automatické přihlášení...") + AMCR_SESSION = login_to_api(username, password) + + return AMCR_SESSION + + +def _api_get(url, params, timeout=30) -> requests.Response: + """ + Provede GET request. Pokud API signalizuje vypršení přihlášení, + provede jedno opakované přihlášení a zkusí znovu. + """ + global AMCR_SESSION + + def _is_auth_error(resp: requests.Response) -> bool: + """API vrací auth chyby se status 200 – je nutné zkontrolovat tělo.""" + if resp.status_code == 401: + return True + try: + body = resp.json() + err = str(body.get("error", "")).lower() + return "unauthorized" in err or "not logged" in err or "session" in err + except Exception: + return False + + session = _get_session() + resp = (session or requests).get(url, params=params, timeout=timeout) + + if _is_auth_error(resp): + _log("Session vypršela během stahování – obnovuji přihlášení...", Qgis.MessageLevel.Warning) + AMCR_SESSION = None # Zrušit starou session + from .amcr_dialog import LoginDialog + username, password = LoginDialog.get_credentials() + if username and password: + AMCR_SESSION = login_to_api(username, password) + if AMCR_SESSION: + resp = AMCR_SESSION.get(url, params=params, timeout=timeout) + else: + _log("Opakované přihlášení selhalo.", Qgis.MessageLevel.Critical) + else: + _log("Přihlašovací údaje nejsou uloženy – pokračuji anonymně.", Qgis.MessageLevel.Warning) + + return resp + def load_translations(): """Fetches the official Czech translation dictionary from the AISCR API.""" global TRANSLATIONS @@ -103,7 +213,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") del base_params['page'] try: - resp_docs = requests.get(url, params=base_params, timeout=30) + resp_docs = _api_get(url, params=base_params, timeout=30) resp_json = resp_docs.json() data = resp_json.get('response', {}) batch_docs = data.get('docs', []) @@ -308,7 +418,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") } try: QApplication.processEvents() - r_pian = requests.get(url, params=params_pian, timeout=15) + r_pian = _api_get(url, params=params_pian, timeout=15) batch_docs = r_pian.json().get('response', {}).get('docs', []) docs_pian.extend(batch_docs) except Exception as e: diff --git a/amcr_viewer/amcr_viewer.py b/amcr_viewer/amcr_viewer.py index fe91bc0..3a715ff 100644 --- a/amcr_viewer/amcr_viewer.py +++ b/amcr_viewer/amcr_viewer.py @@ -2,9 +2,11 @@ from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtWidgets import QMenu, QAction, QToolButton, QDialog +from qgis.core import Qgis +from qgis.utils import iface -from .amcr_tools import load_amcr_data -from .amcr_dialog import AmcrFilterDialog +from .amcr_tools import load_amcr_data, login_to_api +from .amcr_dialog import AmcrFilterDialog, LoginDialog from .resources import * import os.path @@ -109,6 +111,16 @@ class AmcrViewer: ) self.plugin_menu.addAction(self.action_download_lokality) + self.action_login_dialog = self.add_action( + icon_path=icon_akce_path, + text=self.tr(u'Přihlásit se | AMČR Viewer'), + callback=lambda checked=False: self.login(), + parent=self.iface.mainWindow(), + add_to_menu=False, + add_to_toolbar=False + ) + self.plugin_menu.addAction(self.action_login_dialog) + # 3. Create the main project action and attach the menu to it main_icon = QIcon(icon_akce_path) self.main_action = QAction(main_icon, 'AMČR Viewer', self.iface.mainWindow()) @@ -168,4 +180,19 @@ class AmcrViewer: # Access the map canvas and start the fetch/render process from amcr_tools canvas = self.iface.mapCanvas() - load_amcr_data(canvas, bbox, filters, typ_dat, komponenty) \ No newline at end of file + load_amcr_data(canvas, bbox, filters, typ_dat, komponenty) + + def login(self): + dlg = LoginDialog(parent=self.iface.mainWindow()) + result = dlg.exec() + if result == QDialog.DialogCode.Accepted: + username, password = LoginDialog.get_credentials() + session = login_to_api(username, password) + if session: + self.iface.messageBar().pushMessage( + "AMČR", "Přihlášení proběhlo úspěšně.", level=Qgis.MessageLevel.Success + ) + else: + self.iface.messageBar().pushMessage( + "AMČR", "Přihlášení se nezdařilo – viz záložka AMČR login v panelu Zprávy.", level=Qgis.MessageLevel.Critical + ) \ No newline at end of file diff --git a/amcr_viewer/codelists/heslar.csv b/amcr_viewer/codelists/heslar.csv index 0a41d02..0d169e7 100644 --- a/amcr_viewer/codelists/heslar.csv +++ b/amcr_viewer/codelists/heslar.csv @@ -523,6 +523,9 @@ Děčín;Děčín;okres Česká Lípa;Česká Lípa;okres Beroun;Beroun;okres Benešov;Benešov;okres +Kohoutovice;Kohoutovice;katastr +Nový Lískovec;Nový Lískovec;katastr +Bohunice;Bohunice;katastr Starý Lískovec;Starý Lískovec;katastr Horní Heršpice;Horní Heršpice;katastr Královo Pole;Královo Pole;katastr @@ -9952,7 +9955,6 @@ Jestřabice;Jestřabice;katastr Jeníkovec;Jeníkovec;katastr Týnec nad Labem;Týnec nad Labem;katastr Horní Čepí;Horní Čepí;katastr -Kohoutovice;Kohoutovice;katastr Pikárec;Pikárec;katastr Horní Slověnice;Horní Slověnice;katastr Porešín;Porešín;katastr @@ -10586,7 +10588,6 @@ Němčice nad Labem;Němčice nad Labem;katastr Kozojídky;Kozojídky;katastr Šemanovice;Šemanovice;katastr Smolov;Smolov;katastr -Bohunice;Bohunice;katastr Podolí u Mohelnice;Podolí u Mohelnice;katastr Běšiny;Běšiny;katastr Bělice;Bělice;katastr @@ -11885,7 +11886,6 @@ Hřivínův Újezd;Hřivínův Újezd;katastr Radonice u Hradiště;Radonice u Hradiště;katastr Chabičovice;Chabičovice;katastr Košín;Košín;katastr -Nový Lískovec;Nový Lískovec;katastr Pavlovice;Pavlovice;katastr Kněžičky;Kněžičky;katastr Občov;Občov;katastr @@ -13614,6 +13614,8 @@ Kameničná;Kameničná;katastr Hrušová;Hrušová;katastr Javornice u Dubu;Javornice u Dubu;katastr Veleslavín;Veleslavín;katastr +Tolar, Jan;OS-008490;vedouci +Šimurda, Petr;OS-008489;vedouci Mrázek, Michal;OS-008488;vedouci Hendrych, Marek;OS-008487;vedouci Haydamakha, Ruslan;OS-008486;vedouci @@ -21865,3 +21867,7 @@ ruina;HES-001456;lokalita_zachovalost nadzemní relikty;HES-001455;lokalita_zachovalost lokalita pod zástavbou;HES-001454;lokalita_zachovalost zaniklá lokalita;HES-001453;lokalita_zachovalost +archivář;D;pristupnost +archeolog;C;pristupnost +badatel;B;pristupnost +anonym;A;pristupnost