diff --git a/amcr_viewer/amcr_codelists.py b/amcr_viewer/amcr_codelists.py index dd22064..93c0b34 100644 --- a/amcr_viewer/amcr_codelists.py +++ b/amcr_viewer/amcr_codelists.py @@ -13,20 +13,20 @@ BASE_URL = "https://api.aiscr.cz/2.2/oai" OUTPUT_FILE = os.path.join(CODELISTS_DIR, 'heslar.csv') slovnicek = { - 'obdobi' : 'heslo:obdobi', - 'typ_akce' : 'heslo:akce_typ', - 'areal' : 'heslo:areal', - 'kraj' : 'ruian_kraj', - 'organizace' : 'organizace', - 'okres' : 'ruian_okres', - 'katastr' : 'ruian_katastr', - 'vedouci' : 'osoba', - 'pian_presnost' : 'heslo:pian_presnost', - 'typ_lokality' : 'heslo:lokalita_typ', - 'druh_lokality' : 'heslo:lokalita_druh', - 'jistota' : 'heslo:jistota_urceni', - 'lokalita_zachovalost' : 'heslo:stav_dochovani', - 'pristupnost' : 'heslo:pristupnost' + 'obdobi': 'heslo:obdobi', + 'typ_akce': 'heslo:akce_typ', + 'areal': 'heslo:areal', + 'kraj': 'ruian_kraj', + 'organizace': 'organizace', + 'okres': 'ruian_okres', + 'katastr': 'ruian_katastr', + 'vedouci': 'osoba', + 'pian_presnost': 'heslo:pian_presnost', + 'typ_lokality': 'heslo:lokalita_typ', + 'druh_lokality': 'heslo:lokalita_druh', + 'jistota': 'heslo:jistota_urceni', + 'lokalita_zachovalost': 'heslo:stav_dochovani', + 'pristupnost': 'heslo:pristupnost' } NS = { @@ -35,30 +35,35 @@ NS = { 'oai_dc': 'http://www.openarchives.org/OAI/2.0/oai_dc/' } + def ensure_codelists_dir(): """Creates the codelists directory if it does not exist.""" if not os.path.exists(CODELISTS_DIR): os.makedirs(CODELISTS_DIR) + def parse_codelist_file(filename, target_dict=None): - """Reads a CSV codelist file and populates the target dictionary grouped by categories.""" + """ + Reads a CSV codelist file and populates + the target dictionary grouped by categories. + """ if target_dict is None: target_dict = {} - + path = os.path.join(CODELISTS_DIR, filename) - + # Return early if the file doesn't exist to avoid missing file errors - if not os.path.exists(path): + if not os.path.exists(path): return target_dict - + try: # Open the file using standard UTF-8 encoding with open(path, 'r', encoding='utf-8') as f: reader = csv.reader(f, delimiter=';') - + # Skip the CSV header row - next(reader, None) - + next(reader, None) + # Iterate through rows and extract label, code, and category for row in reader: if len(row) >= 3: @@ -66,18 +71,24 @@ def parse_codelist_file(filename, target_dict=None): code = row[1].strip() cat = row[2].strip() clean = code if code else None - - # Initialize a new dictionary for a category if encountered for the first time + + # Initialize a new dictionary for a category if encountered + # for the first time if cat not in target_dict: target_dict[cat] = {} - - # Assign the extracted code to the corresponding label within the category + + # Assign the extracted code to the corresponding label + # within the category target_dict[cat][label] = clean + except Exception as e: - QgsMessageLog.logMessage(f"AMČR Codelist Read Error for {filename}: {e}", "AMČR", Qgis.Critical) - + QgsMessageLog.logMessage( + f"AMČR Codelist Read Error for {filename}: {e}", + "AMČR", Qgis.Critical) + return target_dict + def load_all_data(): """Loads the codelist during plugin startup.""" ensure_codelists_dir() @@ -85,6 +96,7 @@ def load_all_data(): parse_codelist_file('heslar.csv', categorized_data) return categorized_data + def fetch_set(internal_name, api_set, task=None): dataset = [] params = { @@ -92,42 +104,60 @@ def fetch_set(internal_name, api_set, task=None): "metadataPrefix": "oai_dc", "set": api_set } - + while True: - # Kontrola zrušení v každém kroku + # Check for cancellation at each iteration if task and task.isCanceled(): return None try: response = requests.get(BASE_URL, params=params, timeout=30) response.raise_for_status() - root = ET.fromstring(response.content) # nosec - + root = ET.fromstring(response.content) # nosec + records = root.findall('.//oai:record', NS) for rec in records: metadata = rec.find('.//oai_dc:dc', NS) if metadata is not None: - # Kód (identifier) - kod = metadata.find('dc:identifier', NS).text if metadata.find('dc:identifier', NS) is not None else "" - - # Název (title) - filtrujeme systémové popisky "AMČR - ..." + # Code (identifier) + identifier_el = metadata.find('dc:identifier', NS) + kod = ( + identifier_el.text + if identifier_el is not None + else "" + ) + + # Title – filter out system labels "AMČR - ..." titles = metadata.findall('dc:title', NS) nazev = "" for t in titles: - if t.text and not t.text.startswith("AMČR -") and not t.text.startswith(" AMČR -"): + if ( + t.text + and not t.text.startswith("AMČR -") + and not t.text.startswith(" AMČR -") + ): nazev = t.text break - # Pokud by náhodou žádný title neprošel filtrem, vezmeme první dostupný + # If no title passed the filter, fall back + # to the first available one if not nazev and titles: nazev = titles[0].text - + specialni_pripady = ['okres', 'katastr'] 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) + 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, @@ -135,7 +165,7 @@ def fetch_set(internal_name, api_set, task=None): 'Kategorie': internal_name }) - # Stránkování + # Pagination token = root.find('.//oai:resumptionToken', NS) if token is not None and token.text: params = { @@ -145,40 +175,45 @@ def fetch_set(internal_name, api_set, task=None): time.sleep(0.5) else: break - + except Exception as e: - QgsMessageLog.logMessage(f"Chyba u setu {api_set}: {e}", "AMČR", Qgis.Warning) + QgsMessageLog.logMessage( + f"Chyba u setu {api_set}: {e}", + "AMČR", Qgis.Warning) break - + return dataset + def download_heslare(task=None): """Fetches the codelists from the AMČR API and saves it to a CSV file.""" ensure_codelists_dir() all_data = [] total_sets = len(slovnicek) - + for index, (interni, api_nazev) in enumerate(slovnicek.items()): - # Pokud uživatel task zrušil v liště QGISu + # Check if the user cancelled the task via the QGIS taskbar if task and task.isCanceled(): return False - QgsMessageLog.logMessage(f"Zpracovávám kategorii: {interni}...", "AMČR", Qgis.Info) - - # Nyní předáváme task správně do upravené funkce + QgsMessageLog.logMessage( + f"Zpracovávám kategorii: {interni}...", + "AMČR", Qgis.Info) + + # Pass the task correctly to the updated fetch function data = fetch_set(interni, api_nazev, task=task) - + if data is None: - return False # Bylo zrušeno uprostřed stahování + return False # Cancelled mid-download all_data.extend(data) - # Reportování postupu (0-100) + # Report progress (0-100) if task: progress = (index + 1) / total_sets * 100 task.setProgress(progress) - # Uložení do CSV + # Save to CSV with open(OUTPUT_FILE, 'w', newline='', encoding='utf-8-sig') as f: fieldnames = ['Název', 'Kód', 'Kategorie'] writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=';') @@ -187,13 +222,11 @@ def download_heslare(task=None): return True + 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, PRISTUPNOST - + """Reloads data from files into the global variables.""" data = load_all_data() - + OBDOBI.clear() OBDOBI.update(data.get('obdobi', {})) TYP_AKCE.clear() @@ -224,7 +257,7 @@ def refresh_globals(): PRISTUPNOST.update(data.get('pristupnost', {})) -# Inicializace prázdných diktů, které se naplní hned pod tím +# Initialize empty dicts that will be populated immediately below OBDOBI = {} TYP_AKCE = {} AREAL = {} @@ -240,4 +273,4 @@ JISTOTA = {} LOKALITA_ZACHOVALOST = {} PRISTUPNOST = {} -refresh_globals() \ No newline at end of file +refresh_globals() diff --git a/amcr_viewer/amcr_dialog.py b/amcr_viewer/amcr_dialog.py index a48cb19..8c14455 100644 --- a/amcr_viewer/amcr_dialog.py +++ b/amcr_viewer/amcr_dialog.py @@ -1,16 +1,19 @@ # -*- coding: utf-8 -*- -from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout, - QLineEdit, QDialogButtonBox, +from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout, + QLineEdit, QDialogButtonBox, QCheckBox, QGroupBox, QPushButton, QListWidget, QListWidgetItem, QHBoxLayout, QMessageBox, QLabel, QFormLayout) from qgis.PyQt.QtCore import Qt, QSettings -from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis, QgsAuthMethodConfig -from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE, - OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY, - DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST, +from qgis.core import (QgsTask, QgsApplication, + QgsMessageLog, Qgis, QgsAuthMethodConfig) +from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE, + OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, + TYP_LOKALITY, DRUH_LOKALITY, JISTOTA, + LOKALITA_ZACHOVALOST, PRISTUPNOST, download_heslare, refresh_globals) + class UpdateCodelistsTask(QgsTask): def __init__(self, description): super().__init__(description, QgsTask.CanCancel) @@ -18,9 +21,9 @@ class UpdateCodelistsTask(QgsTask): self.exception = None def run(self): - """Tato část běží ve vedlejším vlákně.""" + """Runs in a background thread.""" try: - # Voláme upravenou funkci + # Call the download function with the task reference self.success = download_heslare(task=self) return self.success except Exception as e: @@ -28,52 +31,61 @@ class UpdateCodelistsTask(QgsTask): return False def finished(self, result): - """Tato část běží v hlavním vlákně po skončení run().""" + """Runs in the main thread after run() completes.""" if result: - # Teď bezpečně aktualizujeme globální proměnné v hlavním vlákně + # Safely update the global variables in the main thread refresh_globals() - QgsMessageLog.logMessage("Hesláře AMČR byly úspěšně aktualizovány.", "AMČR", Qgis.Info) + QgsMessageLog.logMessage( + "Hesláře AMČR byly úspěšně aktualizovány.", + "AMČR", Qgis.Info) else: if self.isCanceled(): - QgsMessageLog.logMessage("Aktualizace heslářů byla zrušena.", "AMČR", Qgis.Warning) + QgsMessageLog.logMessage( + "Aktualizace heslářů byla zrušena.", + "AMČR", Qgis.Warning) else: - QgsMessageLog.logMessage(f"Chyba aktualizace: {self.exception}", "AMČR", Qgis.Critical) + QgsMessageLog.logMessage( + f"Chyba aktualizace: {self.exception}", + "AMČR", Qgis.Critical) + class FilterableSelectionDialog(QDialog): """ - A custom dialog for selecting multiple items from a list with a search filter. + 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 + 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): @@ -82,19 +94,19 @@ class FilterableSelectionDialog(QDialog): 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): @@ -125,20 +137,21 @@ class AmcrFilterDialog(QDialog): super().__init__(parent) self.setWindowTitle("Filtr AMČR") self.resize(500, 750) - - # Determines if we are fetching 'akce' (projects) or 'lokalita' (locations) + + # 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': [] + 'organizace': [], 'kraj': [], 'obdobi': [], 'areal': [], + 'typ_akce': [], 'okres': [], 'katastr': [], 'vedouci': [], + 'pian_presnost': [], 'pristupnost': [], 'typ_lokality': [], + 'druh_lokality': [], 'jistota': [], 'lokalita_zachovalost': [] } - + layout = QVBoxLayout() - + # Filter by current map canvas extent self.chk_bbox = QCheckBox("Omezit vyhledávání rozsahem okna") self.chk_bbox.setChecked(True) @@ -149,9 +162,9 @@ class AmcrFilterDialog(QDialog): if self.typ_dat == "akce": self.chk_posevidence = QCheckBox("Pouze pozitivní zjištění") layout.addWidget(self.chk_posevidence) - + layout.addSpacing(10) - + # Spatial information – valid for all self.picker_kraj = self.setup_picker("Kraj", 'kraj', KRAJE) @@ -160,156 +173,223 @@ class AmcrFilterDialog(QDialog): self.picker_okres = self.setup_picker("Okres", 'okres', OKRESY) layout.addWidget(self.picker_okres) - self.picker_katastr = self.setup_picker("Katastr", 'katastr', KATASTRY) + self.picker_katastr = self.setup_picker( + "Katastr", + 'katastr', + KATASTRY + ) layout.addWidget(self.picker_katastr) - self.picker_presnost = self.setup_picker("PIAN – přesnost", 'pian_presnost', PIAN_PRESNOST) + 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) + + self.picker_pristupnost = self.setup_picker( + "Přístupnost", + 'pristupnost', + PRISTUPNOST + ) layout.addWidget(self.picker_pristupnost) # Filters valid for Akce if self.typ_dat == "akce": - self.picker_org = self.setup_picker("Organizace", 'organizace', ORGANIZACE) + self.picker_org = self.setup_picker( + "Organizace", + 'organizace', + ORGANIZACE + ) layout.addWidget(self.picker_org) - - self.picker_vedouci = self.setup_picker("Vedoucí výzkumu", 'vedouci', VEDOUCI) + + 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) + self.picker_typ = self.setup_picker( + "Typ výzkumu", + 'typ_akce', + TYP_AKCE + ) layout.addWidget(self.picker_typ) # Filters valid for Lokality if self.typ_dat == "lokalita": - self.picker_typ_lokality = self.setup_picker("Lokalita – typ", 'typ_lokality', TYP_LOKALITY) + 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) + 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) + 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) + + 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) - + 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) - + # Pushes everything above to the top layout.addStretch(1) # 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.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) + buttons.addButton( + self.btn_update, + QDialogButtonBox.ButtonRole.ActionRole + ) buttons.addButton(QDialogButtonBox.StandardButton.Ok) buttons.addButton(QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) - + self.setLayout(layout) 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. + 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_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) - - # Nested function that handles opening the dialog and saving results + + # 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) + dlg = FilterableSelectionDialog( + label_text, + data_source, + self.selection_cache[cache_key], + self + ) if dlg.exec() == QDialog.DialogCode.Accepted: codes, labels = dlg.get_selected_codes() - # Update local cache with selected IDs + # Update the local cache with selected IDs self.selection_cache[cache_key] = codes - # Update the UI text field with selected names + # Update the display field with the selected item names if labels: display_field.setText(", ".join(labels)) else: display_field.clear() - - # Special case: Pre-fill specific accuracy levels by default + + # Special case: pre-select default PIAN accuracy levels if cache_key == 'pian_presnost': - display_field.setText("odchylka jednotky metrů, odchylka desítky metrů, odchylka stovky metrů") - self.selection_cache[cache_key] = ['HES-000861', 'HES-000862', 'HES-000863'] + display_field.setText( + "odchylka jednotky metrů, odchylka desítky metrů, " + "odchylka stovky metrů" + ) + self.selection_cache[cache_key] = [ + 'HES-000861', + 'HES-000862', + 'HES-000863' + ] btn.clicked.connect(open_dialog) - + row_layout.addWidget(display_field) row_layout.addWidget(btn) - - # Add an optional extra button (e.g., the refresh button for leaders) + + # Optionally append an extra button (e.g. a refresh button) if extra_btn: row_layout.addWidget(extra_btn) - + row_widget.setLayout(row_layout) return row_widget def action_update_heslare(self): - # Vytvoření instance tasku + # Create the task instance task = UpdateCodelistsTask("Aktualizace heslářů AMČR") - - # Povolíme tlačítko zpět bez ohledu na výsledek + + # Re-enable the button regardless of the outcome task.taskCompleted.connect(lambda: self.btn_update.setEnabled(True)) task.taskTerminated.connect(lambda: self.btn_update.setEnabled(True)) - - task.taskCompleted.connect(lambda: QMessageBox.information(self, "Hotovo", "Hesláře byly úspěšně aktualizovány.")) - - # Ošetření, aby se přesně ukázala případná chyba + + task.taskCompleted.connect(lambda: QMessageBox.information( + self, + "Hotovo", + "Hesláře byly úspěšně aktualizovány." + )) + + # Show the exact error if the task fails def on_error(): if task.exception: - # Tohle ti přesně řekne, na čem to teď padá (např. PermissionError) - msg = f"Aktualizace selhala z důvodu chyby:\n{str(task.exception)}" + # This will show exactly what went wrong (e.g. PermissionError) + msg = ( + "Aktualizace selhala z důvodu chyby:\n" + f"{str(task.exception)}" + ) else: msg = "Aktualizace byla zrušena uživatelem." QMessageBox.warning(self, "Chyba / Zrušeno", msg) - + 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): return "true" if self.chk_komponenty.isChecked() else "false" - + def get_filters(self): - """Compiles the user selections from the cache into API-ready filter parameters.""" + """Compiles the user selections from the cache into + API-ready filter parameters.""" filters = {} if self.selection_cache['kraj']: @@ -323,10 +403,10 @@ class AmcrFilterDialog(QDialog): 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'] + filters['f_pian_presnost'] = self.selection_cache['pian_presnost'] if self.selection_cache['pristupnost']: - filters['pristupnost'] = self.selection_cache['pristupnost'] - + filters['pristupnost'] = self.selection_cache['pristupnost'] + if self.typ_dat == "akce": if self.chk_posevidence.isChecked(): filters['posevidence'] = 'true' @@ -346,12 +426,14 @@ class AmcrFilterDialog(QDialog): filters['f_jistota'] = self.selection_cache['jistota'] if self.selection_cache['lokalita_zachovalost']: filters['f_lokalita_zachovalost'] = self.selection_cache['lokalita_zachovalost'] - + return filters + class LoginDialog(QDialog): """ - Dialog for saving AMČR login credentials securely in the QGIS Authentication Manager. + 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). @@ -365,10 +447,13 @@ class LoginDialog(QDialog): - 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" + CONFIG_NAME = "AMČR Viewer" def __init__(self, parent=None): super().__init__(parent) @@ -379,17 +464,29 @@ class LoginDialog(QDialog): # 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). + # 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, "") - self._has_saved = bool(existing_id) and bool(self._load_username_from_config(existing_id)) + 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 = 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 = 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) @@ -406,7 +503,9 @@ class LoginDialog(QDialog): 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" + "ponechte prázdné pro zachování stávajícího hesla" + if self._has_saved + else "heslo" ) form.addRow("Heslo:", self.txt_pass) @@ -422,7 +521,8 @@ class LoginDialog(QDialog): layout.addStretch(1) buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel + QDialogButtonBox.StandardButton.Ok + | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(self._save_and_accept) buttons.rejected.connect(self.reject) @@ -438,7 +538,8 @@ class LoginDialog(QDialog): 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. + Returns (ok, cfg). Never raises; returns (False, empty cfg) + on any error. full=True decrypts and includes the password. """ try: @@ -453,8 +554,10 @@ class LoginDialog(QDialog): return False, QgsAuthMethodConfig() def _load_username_from_config(self, config_id: str) -> str: - """Load just the username from a stored config (no password decryption).""" - ok, cfg = self._load_config(config_id, full=False) + """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: @@ -469,11 +572,13 @@ class LoginDialog(QDialog): 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." + "Zkuste obnovit databázi: " + "Nastavení → Možnosti → Autentizace → Pomůcky." ) return False - # setMasterPassword(True) shows the QGIS master password dialog if needed + # setMasterPassword(True) shows the QGIS + # master password dialog if needed if not auth_mgr.setMasterPassword(True): return False # User cancelled the master password dialog @@ -488,7 +593,11 @@ class LoginDialog(QDialog): password = self.txt_pass.text() if not username: - QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím e-mailovou adresu.") + QMessageBox.warning( + self, + "Chybí údaje", + "Vyplňte prosím e-mailovou adresu." + ) return existing_id = QSettings().value(self.SETTINGS_KEY, "") @@ -521,13 +630,19 @@ class LoginDialog(QDialog): 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 + # 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, existing_cfg = self._load_config(existing_id, full=False) if existing_id else (False, None) + ok_load, existing_cfg = ( + self._load_config(existing_id, full=False) + if existing_id + else (False, None) + ) if ok_load: cfg.setId(existing_id) - ok, cfg = auth_mgr.updateAuthenticationConfig(cfg) + ok = auth_mgr.updateAuthenticationConfig(cfg) else: ok, cfg = auth_mgr.storeAuthenticationConfig(cfg) @@ -536,7 +651,8 @@ class LoginDialog(QDialog): 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" + "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 @@ -550,7 +666,11 @@ class LoginDialog(QDialog): if existing_id: QgsApplication.authManager().removeAuthenticationConfig(existing_id) settings.remove(self.SETTINGS_KEY) - QMessageBox.information(self, "Hotovo", "Uložené přihlašovací údaje byly odebrány.") + QMessageBox.information( + self, + "Hotovo", + "Uložené přihlašovací údaje byly odebrány." + ) self.reject() # ------------------------------------------------------------------ @@ -565,8 +685,9 @@ class LoginDialog(QDialog): 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. + 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, "") @@ -578,4 +699,4 @@ class LoginDialog(QDialog): if not ok: return "", "" - return cfg.config("username", ""), cfg.config("password", "") # nosec B106 \ No newline at end of file + return cfg.config("username", ""), cfg.config("password", "") # nosec B106 diff --git a/amcr_viewer/amcr_tools.py b/amcr_viewer/amcr_tools.py index d4b9070..724e699 100644 --- a/amcr_viewer/amcr_tools.py +++ b/amcr_viewer/amcr_tools.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -from qgis.core import (QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry, - QgsField, QgsCoordinateReferenceSystem, QgsCoordinateTransform, - QgsWkbTypes, Qgis, QgsApplication, QgsAuthMethodConfig, QgsMessageLog) +from qgis.core import (QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry, + QgsField, QgsCoordinateReferenceSystem, + QgsCoordinateTransform, QgsWkbTypes, Qgis, + QgsMessageLog) from qgis.utils import iface from qgis.PyQt.QtCore import Qt, QMetaType from qgis.PyQt.QtWidgets import QApplication @@ -12,25 +13,33 @@ 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) +# Session with authentication cookie after login; +# None = not logged in (anonymous access) 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).""" + """ + Shortcut: writes a message to the QGIS log + (Messages panel → AMČR tab). + """ 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ě. + Logs in to the Digiarchiv API using a username and password. + Returns a requests.Session with the session cookie set, or None on error. """ 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) + _log( + "CHYBA: username nebo heslo je prázdné.", + Qgis.MessageLevel.Critical + ) return None session = requests.Session() @@ -42,14 +51,22 @@ def login_to_api(username: str, password: str): try: _log(f"Odesílám POST na {login_url} ...") - response = session.post(login_url, json={"user": username, "pwd": password}, timeout=10) + 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 + # The API returns errors with status code 200 – + # the response body must be checked body = response.json() if "error" in body: - _log(f"CHYBA přihlášení (API): {body['error']}", Qgis.MessageLevel.Critical) + _log( + f"CHYBA přihlášení (API): {body['error']}", + Qgis.MessageLevel.Critical + ) return None _log("Přihlášení proběhlo úspěšně.") @@ -59,23 +76,25 @@ def login_to_api(username: str, password: str): 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) + 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. + Returns the active session. If none exists (e.g. after a QGIS restart), + attempts automatic login using stored credentials. + Returns None if no credentials are stored. """ global AMCR_SESSION if AMCR_SESSION is not None: return AMCR_SESSION - # Zkusit auto-login pomocí uložených údajů + # Attempt auto-login using stored credentials from .amcr_dialog import LoginDialog username, password = LoginDialog.get_credentials() if username and password: @@ -87,19 +106,24 @@ def _get_session() -> requests.Session | None: 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. + Performs a GET request. If the API signals an expired login, + re-authenticates once and retries. """ global AMCR_SESSION def _is_auth_error(resp: requests.Response) -> bool: - """API vrací auth chyby se status 200 – je nutné zkontrolovat tělo.""" + """The API returns auth errors with status 200 – + the body must be checked.""" 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 + return ( + "unauthorized" in err + or "not logged" in err + or "session" in err + ) except Exception: return False @@ -107,8 +131,9 @@ def _api_get(url, params, timeout=30) -> requests.Response: 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 + _log("Session vypršela během stahování – obnovuji přihlášení...", + Qgis.MessageLevel.Warning) + AMCR_SESSION = None # Invalidate the old session from .amcr_dialog import LoginDialog username, password = LoginDialog.get_credentials() if username and password: @@ -116,18 +141,24 @@ def _api_get(url, params, timeout=30) -> requests.Response: if AMCR_SESSION: resp = AMCR_SESSION.get(url, params=params, timeout=timeout) else: - _log("Opakované přihlášení selhalo.", Qgis.MessageLevel.Critical) + _log("Opakované přihlášení selhalo.", + Qgis.MessageLevel.Critical) else: - _log("Přihlašovací údaje nejsou uloženy – pokračuji anonymně.", Qgis.MessageLevel.Warning) + _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.""" + """ + Fetches the official Czech translation dictionary + from the Digiarchive API. + """ global TRANSLATIONS if TRANSLATIONS: - return - + return + url = "https://digiarchiv.aiscr.cz/api/assets/i18n/cs.json" try: r = requests.get(url, timeout=10) @@ -136,20 +167,31 @@ def load_translations(): except Exception as e: print(f"Error downloading vocabulary: {e}") + def tr_code(code): - """Translates a technical code into a human-readable string using the global cache.""" - if not code: + """ + Translates a technical code into a human-readable string + using the global cache. + """ + if not code: return "" return TRANSLATIONS.get(code, code) + def komp_projde_filtrem(komp, filter_areal, filter_datace, filters): - if filter_areal and komp.get('komponenta_areal', {}).get('id', "") not in filters.get('f_areal', []): + areal_id = komp.get('komponenta_areal', {}).get('id', "") + if filter_areal and areal_id not in filters.get('f_areal', []): return False - if filter_datace and komp.get('komponenta_obdobi', {}).get('id', "") not in filters.get('f_obdobi', []): + + obdobi_id = komp.get('komponenta_obdobi', {}).get('id', "") + if filter_datace and obdobi_id not in filters.get('f_obdobi', []): return False + return True -def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false"): + +def load_amcr_data(canvas, bb, filters=None, + typ_dat="akce", komponenty="false"): """ Main processing function: 1. Determines search area (Bounding Box) @@ -159,26 +201,35 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") load_translations() # --- 1. COORDINATE TRANSFORMATION --- - # Get current map extent and transform it from project CRS (usually S-JTSK) to WGS-84 for the API + # Get current map extent and transform it + # from project CRS (usually S-JTSK) to WGS-84 for the API extent = canvas.extent() crs_src = canvas.mapSettings().destinationCrs() crs_dest = QgsCoordinateReferenceSystem("EPSG:4326") xform = QgsCoordinateTransform(crs_src, crs_dest, QgsProject.instance()) extent_wgs = xform.transformBoundingBox(extent) - - # Format the bounding box string as required by the API: minLat,minLon,maxLat,maxLon - bbox_str = f"{extent_wgs.yMinimum()},{extent_wgs.xMinimum()},{extent_wgs.yMaximum()},{extent_wgs.xMaximum()}" - + + # Format the bounding box string as required by the API: + # minLat,minLon,maxLat,maxLon + bbox_str = ( + f"{extent_wgs.yMinimum()},{extent_wgs.xMinimum()}," + f"{extent_wgs.yMaximum()},{extent_wgs.xMaximum()}" + ) + url = "https://digiarchiv.aiscr.cz/api/search/query" - - iface.messageBar().pushMessage("AMCR", "Hledám záznamy...", level=Qgis.MessageLevel.Info) + + iface.messageBar().pushMessage( + "AMCR", + "Hledám záznamy...", + level=Qgis.MessageLevel.Info + ) QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) - + try: # ========================================== # A) METADATA FETCHING (Fieldwork/Site) # ========================================== - + base_params = { "mapa": "true", "sort": "ident_cely asc", @@ -186,10 +237,11 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") } # Restrict search to map window if requested - if bb == "true": + if bb == "true": base_params["loc_rpt"] = bbox_str - # Apply multi-select filters from the dialog using the ':or' syntax required by the API + # Apply multi-select filters from the dialog using + # the ':or' syntax required by the API if filters: for key, value in filters.items(): if not value: @@ -200,21 +252,24 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") base_params[key] = str(value).strip() docs = [] - current_page = 0 + current_page = 0 BATCH_DOCS = 500 # Records per API request MAX_LIMIT = 20000 # Safety limit to prevent QGIS from freezing - seen_ids = set() target_pian_ids_count = 0 # Check if we should skip negative results based on filter - skip_negativni = filters.get('posevidence') == 'true' if filters else False + skip_negativni = ( + filters.get('posevidence') == 'true' + if filters + else False + ) # Check whether we should filter results based on component filters filter_areal = "f_areal" in filters if filters else False filter_datace = "f_obdobi" in filters if filters else False - + # --- API PAGINATION LOOP --- while True: base_params['rows'] = BATCH_DOCS @@ -222,17 +277,17 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") base_params['page'] = current_page elif 'page' in base_params: del base_params['page'] - + try: 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', []) - num_found = data.get('numFound', 0) - + num_found = data.get('numFound', 0) + if not batch_docs: break - + # Filter out duplicates and append to main list new_docs = [] for d in batch_docs: @@ -240,44 +295,56 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") if ident and ident not in seen_ids: seen_ids.add(ident) new_docs.append(d) - + docs.extend(new_docs) - print(f"Strana {current_page} stažena. Celkem záznamů: {len(docs)} / {num_found}") + print( + f"Strana {current_page} stažena. " + f"Celkem záznamů: {len(docs)} / {num_found}" + ) if len(docs) >= num_found: break if len(docs) >= MAX_LIMIT: - iface.messageBar().pushMessage("AMCR", f"Limit {MAX_LIMIT} záznamů dosažen.", level=Qgis.MessageLevel.Warning) + iface.messageBar().pushMessage( + "AMCR", + f"Limit {MAX_LIMIT} záznamů dosažen.", + level=Qgis.MessageLevel.Warning + ) break - + current_page += 1 - QApplication.processEvents() # Keep UI responsive - + QApplication.processEvents() # Keep UI responsive + except Exception as e: print(f"Chyba při stránkování na straně {current_page}: {e}") break if not docs: - iface.messageBar().pushMessage("AMCR", "Žádné záznamy nenalezeny.", level=Qgis.MessageLevel.Warning) - return + iface.messageBar().pushMessage( + "AMCR", + "Žádné záznamy nenalezeny.", + level=Qgis.MessageLevel.Warning + ) + return # ========================================== # B) ATTRIBUTE PARSING # ========================================== - - # pian_lookup maps a Geometry ID (PIAN) to a list of its associated metadata + + # pian_lookup maps a Geometry ID (PIAN) + # to a list of its associated metadata pian_lookup = {} target_pian_ids = set() actions_with_geom = 0 - - # Helper function for safe single-value extraction - def g(doc, key, default=""): + + # Helper: safely extract a single value + def g(doc, key, default=""): val = doc.get(key) if isinstance(val, list): return str(val[0]) if val else default return str(val) if val is not None else default - # Helper function for safe list-value extraction and joining + # Helper: safely extract and join a list of values def g_list(doc, key, translate=False): val = doc.get(key, []) if not isinstance(val, list): @@ -293,16 +360,23 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") continue actions_with_geom += 1 - - # Extract protected data (fields not available in public Solr index) + + # Extract protected fields az_chranene = doc.get('az_chranene_udaje', {}) - chranene = doc.get('akce_chranene_udaje') or doc.get('lokalita_chranene_udaje') or {} - - # Format additional cadastral areas from dictionaries + chranene = ( + doc.get('akce_chranene_udaje') + or doc.get('lokalita_chranene_udaje') + or {} + ) + + # Format additional cadastral areas from nested dicts dalsi_kat = az_chranene.get('dalsi_katastr', []) dalsi_kat_str = "" if isinstance(dalsi_kat, list): - items = [x.get('value', '') if isinstance(x, dict) else str(x) for x in dalsi_kat] + items = [ + x.get('value', '') if isinstance(x, dict) else str(x) + for x in dalsi_kat + ] dalsi_kat_str = ", ".join([i for i in items if i]) lokalizace = chranene.get('lokalizace_okolnosti', "") @@ -314,7 +388,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") "ident_cely": doc.get('ident_cely', ''), "az_okres": g(doc, 'az_okres'), "katastr": g_list(doc, 'katastr'), - "dalsi_katastr": dalsi_kat_str, + "dalsi_katastr": dalsi_kat_str, "pristupnost": g(doc, 'pristupnost'), "loc": g_list(doc, 'loc') } @@ -322,53 +396,102 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") # Add entity-specific metadata if typ_dat == "akce": meta.update({ - "akce_hlavni_vedouci": g(doc, 'akce_hlavni_vedouci'), - "akce_organizace": tr_code(g(doc, 'akce_organizace')), - "akce_specifikace_data": tr_code(g(doc, 'akce_specifikace_data')), - "akce_datum_zahajeni": g(doc, 'akce_datum_zahajeni'), - "akce_datum_ukonceni": g(doc, 'akce_datum_ukonceni'), - "akce_hlavni_typ": tr_code(g(doc, 'akce_hlavni_typ')), - "akce_vedlejsi_typ": g_list(doc, 'akce_vedlejsi_typ', translate=True), - "lokalizace_okolnosti": str(lokalizace) if lokalizace else "", - "akce_je_nz": "Ano" if doc.get('akce_je_nz') is True else "Ne", + "akce_hlavni_vedouci": g( + doc, + 'akce_hlavni_vedouci' + ), + "akce_organizace": tr_code(g( + doc, + 'akce_organizace' + )), + "akce_specifikace_data": tr_code(g( + doc, + 'akce_specifikace_data' + )), + "akce_datum_zahajeni": g( + doc, + 'akce_datum_zahajeni' + ), + "akce_datum_ukonceni": g( + doc, + 'akce_datum_ukonceni' + ), + "akce_hlavni_typ": tr_code(g( + doc, + 'akce_hlavni_typ' + )), + "akce_vedlejsi_typ": g_list( + doc, + 'akce_vedlejsi_typ', + translate=True + ), + "lokalizace_okolnosti": ( + str(lokalizace) + if lokalizace + else "" + ), + "akce_je_nz": ( + "Ano" + if doc.get('akce_je_nz') is True + else "Ne" + ), }) elif typ_dat == "lokalita": meta.update({ "lokalita_nazev": lokalita_nazev, "lokalita_popis": lokalita_popis, - "lokalita_zachovalost": tr_code(g(doc, 'lokalita_zachovalost')), - "lokalita_druh": tr_code(g(doc, 'lokalita_druh')), - "lokalita_typ": tr_code(g(doc, 'lokalita_typ_lokality')), + "lokalita_zachovalost": tr_code(g( + doc, + 'lokalita_zachovalost' + )), + "lokalita_druh": tr_code(g( + doc, + 'lokalita_druh' + )), + "lokalita_typ": tr_code(g( + doc, + 'lokalita_typ_lokality' + )), }) - + # Documentation units (DJ) within the record djs = doc.get('az_dokumentacni_jednotka', []) for dj in djs: - # Filter out negative evidence units if requested + # Skip negative evidence units if requested if skip_negativni and dj.get('dj_negativni_jednotka') is True: continue komps = dj.get('dj_komponenta', []) - + if filter_areal or filter_datace: if not komps: continue - if not any(komp_projde_filtrem(komp, filter_areal, filter_datace, filters) for komp in komps): + if not any( + komp_projde_filtrem( + komp, filter_areal, + filter_datace, filters + ) + for komp in komps + ): continue dj_id = dj.get('ident_cely') dj_typ = dj.get('dj_typ') - # Merge general meta with documentation unit specific data + # Merge shared metadata with documentation unit-specific fields dj_meta = { **meta, 'dj_id': dj_id, 'dj_typ_value': dj_typ.get('value') if dj_typ else "", - 'dj_negativni': "Negativní" if dj.get('dj_negativni_jednotka') is True else "Pozitivní" + 'dj_negativni': ( + "Negativní" + if dj.get('dj_negativni_jednotka') is True + else "Pozitivní" + ) } - + # Link Documentation Unit to Geometry (PIAN) dj_pian = dj.get('dj_pian') if dj_pian: @@ -379,25 +502,39 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") pian_lookup[dj_pian_value] = [] if komponenty == "true": - # One feature per component — all data on a single row, no relations needed + # One feature per component – + # all data on a single row, no relations needed if komps: for komp in komps: - if not komp_projde_filtrem(komp, filter_areal, filter_datace, filters): + if not komp_projde_filtrem( + komp, filter_areal, + filter_datace, filters + ): continue komp_meta = { **dj_meta, - 'komponenta_id': komp.get('ident_cely', ""), - 'komponenta_areal': komp.get('komponenta_areal', {}).get('value', ""), - 'komponenta_obdobi': komp.get('komponenta_obdobi', {}).get('value', ""), + 'komponenta_id': komp.get( + 'ident_cely', + "" + ), + 'komponenta_areal': komp.get( + 'komponenta_areal', + {} + ).get('value', ""), + 'komponenta_obdobi': komp.get( + 'komponenta_obdobi', + {} + ).get('value', ""), } pian_lookup[dj_pian_value].append(komp_meta) target_pian_ids_count += 1 else: - # DJ without components — still include with empty component fields + # DJ without components — still include + # with empty component fields if filter_areal or filter_datace: continue - + empty_meta = { **dj_meta, 'komponenta_id': "", @@ -410,11 +547,13 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") target_pian_ids_count += 1 pian_lookup[dj_pian_value].append(dj_meta) - if not target_pian_ids: - iface.messageBar().pushMessage("AMCR", f"Nalezeno {len(docs)} záznamů, ale žádný nemá geometrii.", level=Qgis.MessageLevel.Warning) - return - + iface.messageBar().pushMessage( + "AMCR", + f"Nalezeno {len(docs)} záznamů, ale žádný nemá geometrii.", + level=Qgis.MessageLevel.Warning + ) + return # ========================================== # C) GEOMETRY FETCHING (PIAN) @@ -422,17 +561,28 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") ids_list = list(target_pian_ids) total_pians = len(ids_list) docs_pian = [] - BATCH_PIAN = 200 # Geometry requests are batch-processed to stay under URL length limits - - iface.messageBar().pushMessage("AMCR", f"Záznamů: {len(docs)} (z toho {actions_with_geom} s mapou). Stahuji {total_pians} unikátních geometrií, vykresluji {target_pian_ids_count} geometrií...", level=Qgis.MessageLevel.Info) - - fl_pian = ["ident_cely", "pian_typ", "pian_chranene_udaje", "pian_presnost"] + # Geometry requests are batch-processed + # to stay under URL length limits: + BATCH_PIAN = 200 + + iface.messageBar().pushMessage( + "AMCR", + f"Záznamů: {len(docs)} (z toho {actions_with_geom} s mapou). " + f"Stahuji {total_pians} unikátních geometrií, " + f"vykresluji {target_pian_ids_count} geometrií...", + level=Qgis.MessageLevel.Info + ) + + fl_pian = [ + "ident_cely", "pian_typ", + "pian_chranene_udaje", "pian_presnost" + ] for i in range(0, total_pians, BATCH_PIAN): - batch = ids_list[i : i + BATCH_PIAN] + batch = ids_list[i: i + BATCH_PIAN] or_query = " OR ".join(batch) fq_pian = f"ident_cely:({or_query})" - + params_pian = { "mapa": "true", "entity": "pian", @@ -441,7 +591,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") "fl": ",".join(fl_pian) } try: - QApplication.processEvents() + QApplication.processEvents() r_pian = _api_get(url, params=params_pian, timeout=15) batch_docs = r_pian.json().get('response', {}).get('docs', []) docs_pian.extend(batch_docs) @@ -451,13 +601,25 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") # ========================================== # D) LAYER CREATION (QGIS Memory Layers) # ========================================== - + archeologicky_zaznam = "Akce" if typ_dat == "akce" else "Lokalita" # Initialize three layers for different geometry types (S-JTSK CRS) - vl_poly = QgsVectorLayer("Polygon?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Polygony", "memory") - vl_line = QgsVectorLayer("LineString?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Linie", "memory") - vl_point = QgsVectorLayer("Point?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Body", "memory") + vl_poly = QgsVectorLayer( + "Polygon?crs=epsg:5514", + f"AMCR_{archeologicky_zaznam}_Polygony", + "memory" + ) + vl_line = QgsVectorLayer( + "LineString?crs=epsg:5514", + f"AMCR_{archeologicky_zaznam}_Linie", + "memory" + ) + vl_point = QgsVectorLayer( + "Point?crs=epsg:5514", + f"AMCR_{archeologicky_zaznam}_Body", + "memory" + ) layers = [vl_poly, vl_line, vl_point] # Define attribute table structure @@ -486,7 +648,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") QgsField("ukonceni", QMetaType.Type.QString), QgsField("hlavni_typ", QMetaType.Type.QString), QgsField("vedlejsi_typ", QMetaType.Type.QString), - QgsField("zjisteni", QMetaType.Type.QString), + QgsField("zjisteni", QMetaType.Type.QString), QgsField("nahrazuje_NZ", QMetaType.Type.QString), ] elif typ_dat == "lokalita": @@ -497,7 +659,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") QgsField("druh_lokality", QMetaType.Type.QString), QgsField("zachovalost", QMetaType.Type.QString) ] - + cols.append(QgsField("Přístupnost", QMetaType.Type.QString)) # Use aliases for technical field names @@ -531,7 +693,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") "komponenta": "Komponenta", "komponenta_areal": "Areál", "komponenta_obdobi": "Období", - } + } if komponenty == "true": cols += [ @@ -547,36 +709,45 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") idx = vl.fields().lookupField(tech_name) if idx != -1: vl.setFieldAlias(idx, alias) - + # Lists to hold features before batch-adding to layers feats_p, feats_l, feats_pt = [], [], [] - + # --- FEATURE POPULATION --- for doc in docs_pian: try: pid = doc.get('ident_cely', '') if pid not in pian_lookup: - continue - + continue + metas = pian_lookup[pid] - + # Extract WKT geometry from protected JSON data raw = doc.get('pian_chranene_udaje') if isinstance(raw, list) and raw: raw = raw[0] - jdata = json.loads(raw) if isinstance(raw, str) else (raw or {}) - + jdata = ( + json.loads(raw) + if isinstance(raw, str) + else (raw or {}) + ) + wkt = None if jdata.get('geom_sjtsk_wkt'): wkt = jdata.get('geom_sjtsk_wkt', {}).get('value') elif jdata.get('geom_wkt'): wkt = jdata.get('geom_wkt', {}).get('value') - + pian_presnost = tr_code(str(doc.get('pian_presnost', ''))) pian_typ = tr_code(str(doc.get('pian_typ', ''))) # Final precision filter check - if filters and filters.get('f_pian_presnost') and doc.get('pian_presnost') not in filters.get('f_pian_presnost'): + if ( + filters + and filters.get('f_pian_presnost') + and doc.get('pian_presnost') + not in filters.get('f_pian_presnost') + ): continue if wkt: @@ -586,41 +757,54 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") target_list = None if t == QgsWkbTypes.PolygonGeometry: target_list = feats_p - referenced_layer = vl_poly elif t == QgsWkbTypes.LineGeometry: target_list = feats_l - referenced_layer = vl_line elif t == QgsWkbTypes.PointGeometry: target_list = feats_pt - referenced_layer = vl_point - + if target_list is None: continue is_akce = (typ_dat == "akce") - # Create a QGIS feature for each documentation unit associated with this geometry + # Create a QGIS feature for each documentation unit + # associated with this geometry for meta in metas: feat = QgsFeature() feat.setGeometry(geom) atributy = [ - pid, pian_presnost, pian_typ, meta['dj_id'], - meta['dj_typ_value'], meta['loc'], meta['ident_cely'], - "https://digiarchiv.aiscr.cz/id/" + meta['ident_cely'], - meta['az_okres'], meta['katastr'], meta['dalsi_katastr'] + pid, + pian_presnost, + pian_typ, + meta['dj_id'], + meta['dj_typ_value'], + meta['loc'], + meta['ident_cely'], + "https://digiarchiv.aiscr.cz/id/" + + meta['ident_cely'], + meta['az_okres'], + meta['katastr'], + meta['dalsi_katastr'] ] if is_akce: atributy.extend([ - meta['lokalizace_okolnosti'], meta['akce_hlavni_vedouci'], - meta['akce_organizace'], meta['akce_specifikace_data'], - meta['akce_datum_zahajeni'], meta['akce_datum_ukonceni'], - meta['akce_hlavni_typ'], meta['akce_vedlejsi_typ'], - meta['dj_negativni'], meta['akce_je_nz'] + meta['lokalizace_okolnosti'], + meta['akce_hlavni_vedouci'], + meta['akce_organizace'], + meta['akce_specifikace_data'], + meta['akce_datum_zahajeni'], + meta['akce_datum_ukonceni'], + meta['akce_hlavni_typ'], + meta['akce_vedlejsi_typ'], + meta['dj_negativni'], + meta['akce_je_nz'] ]) else: atributy.extend([ - meta['lokalita_nazev'], meta['lokalita_popis'], - meta['lokalita_typ'], meta['lokalita_druh'], + meta['lokalita_nazev'], + meta['lokalita_popis'], + meta['lokalita_typ'], + meta['lokalita_druh'], meta['lokalita_zachovalost'] ]) @@ -631,21 +815,21 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") meta.get('komponenta_id', ""), meta.get('komponenta_areal', ""), meta.get('komponenta_obdobi', ""), - ]) - + ]) + feat.setAttributes(atributy) target_list.append(feat) - + except Exception as ex: print(f"Chyba při tvorbě feature: {ex}") pass - + # --- ADDING TO QGIS INTERFACE --- proj = QgsProject.instance() added = 0 layers_to_process = [ - (feats_p, vl_poly, "Polygony"), - (feats_l, vl_line, "Linie"), + (feats_p, vl_poly, "Polygony"), + (feats_l, vl_line, "Linie"), (feats_pt, vl_point, "Body"), ] @@ -653,17 +837,30 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false") if f: l.dataProvider().addFeatures(f) l.updateExtents() - l.setName(f"AMCR_{archeologicky_zaznam}_{n}") + l.setName(f"AMCR_{archeologicky_zaznam}_{n}") proj.addMapLayer(l) added += len(f) - + if added > 0: - iface.messageBar().pushMessage("AMCR", f"Hotovo. Záznamů: {len(docs)} (s geom: {actions_with_geom}). Vykresleno: {added} prvků.", level=Qgis.MessageLevel.Success) + iface.messageBar().pushMessage( + "AMCR", + f"Hotovo. Záznamů: {len(docs)} (s geom: {actions_with_geom}). " + f"Vykresleno: {added} prvků.", + level=Qgis.MessageLevel.Success + ) else: - iface.messageBar().pushMessage("AMCR", "Žádná data k zobrazení.", level=Qgis.MessageLevel.Info) + iface.messageBar().pushMessage( + "AMCR", + "Žádná data k zobrazení.", + level=Qgis.MessageLevel.Info + ) except Exception as e: - iface.messageBar().pushMessage("Chyba", str(e), level=Qgis.MessageLevel.Critical) + iface.messageBar().pushMessage( + "Chyba", + str(e), + level=Qgis.MessageLevel.Critical + ) finally: # Always restore cursor, even after failure - QApplication.restoreOverrideCursor() \ No newline at end of file + QApplication.restoreOverrideCursor() diff --git a/amcr_viewer/amcr_viewer.py b/amcr_viewer/amcr_viewer.py index 3a715ff..589f6d0 100644 --- a/amcr_viewer/amcr_viewer.py +++ b/amcr_viewer/amcr_viewer.py @@ -3,35 +3,37 @@ 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, login_to_api from .amcr_dialog import AmcrFilterDialog, LoginDialog from .resources import * import os.path + class AmcrViewer: """ - Main plugin class that manages the GUI elements, menu entries, + Main plugin class that manages the GUI elements, menu entries, and coordinates the flow between user input and data processing. """ - + def __init__(self, iface): """ - Constructor initializes the connection to QGIS interface and sets up + Constructor initializes the connection to QGIS interface and sets up internationalization (i18n). """ self.iface = iface self.plugin_dir = os.path.dirname(__file__) - + # Determine the user's locale to load appropriate translation files locale = QSettings().value('locale/userLocale')[0:2] locale_path = os.path.join( self.plugin_dir, 'i18n', - 'AmcrViewer_{}.qm'.format(locale)) + 'AmcrViewer_{}.qm'.format(locale) + ) - # Install the translator if a translation file for the current locale exists + # Install the translator if a translation file + # for the current locale exists if os.path.exists(locale_path): self.translator = QTranslator() self.translator.load(locale_path) @@ -43,14 +45,17 @@ class AmcrViewer: self.first_start = None def tr(self, message): - """Helper method for translating strings within the AmcrViewer context.""" + """ + Helper method for translating strings within + the AmcrViewer context. + """ return QCoreApplication.translate('AmcrViewer', message) def add_action(self, icon_path, text, callback, enabled_flag=True, add_to_menu=True, add_to_toolbar=True, status_tip=None, whats_this=None, parent=None): """ - Helper method to create QActions and automatically register them + Helper method to create QActions and automatically register them into the QGIS Menu and Toolbar. """ icon = QIcon(icon_path) @@ -71,15 +76,16 @@ class AmcrViewer: if add_to_menu: self.iface.addPluginToMenu(self.menu, action) - # Store only actions that are directly attached to the QGIS UI for later cleanup + # Store only actions that are directly attached + # to the QGIS UI for later cleanup if add_to_toolbar or add_to_menu: self.actions.append(action) - + return action def initGui(self): """ - Called when the plugin is loaded. Creates the menu structure, + Called when the plugin is loaded. Creates the menu structure, sub-actions, and the dropdown tool button in the toolbar. """ # Define paths for action-specific icons @@ -90,7 +96,8 @@ class AmcrViewer: self.plugin_menu = QMenu() # 2. Create sub-actions (Download Projects / Download Sites) - # add_to_menu/toolbar is False because these go into our custom dropdown menu + # add_to_menu/toolbar is False because these go into our + # custom dropdown menu self.action_download_akce = self.add_action( icon_path=icon_akce_path, text=self.tr(u'Stáhnout data akcí | AMČR Viewer'), @@ -104,7 +111,7 @@ class AmcrViewer: self.action_download_lokality = self.add_action( icon_path=icon_lokality_path, text=self.tr(u'Stáhnout data lokalit | AMČR Viewer'), - callback=lambda checked=False: self.run_download('lokalita'), + callback=lambda checked=False: self.run_download('lokalita'), parent=self.iface.mainWindow(), add_to_menu=False, add_to_toolbar=False @@ -114,7 +121,7 @@ class AmcrViewer: 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(), + callback=lambda checked=False: self.login(), parent=self.iface.mainWindow(), add_to_menu=False, add_to_toolbar=False @@ -123,7 +130,11 @@ class AmcrViewer: # 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()) + self.main_action = QAction( + main_icon, + 'AMČR Viewer', + self.iface.mainWindow() + ) self.main_action.setMenu(self.plugin_menu) self.iface.addPluginToMenu(self.menu, self.main_action) @@ -132,16 +143,19 @@ class AmcrViewer: self.tool_button = QToolButton() self.tool_button.setMenu(self.plugin_menu) self.tool_button.setDefaultAction(self.action_download_akce) - self.tool_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) - - # Add the widget directly to the toolbar and store the reference for cleanup + self.tool_button.setPopupMode( + QToolButton.ToolButtonPopupMode.MenuButtonPopup + ) + + # Add the widget directly to the toolbar + # and store the reference for cleanup self.toolbar_action = self.iface.addToolBarWidget(self.tool_button) - + self.first_start = True def unload(self): """ - Called when the plugin is disabled or removed. + Called when the plugin is disabled or removed. Ensures all GUI elements are removed from QGIS to avoid ghost icons. """ # 1. Remove the custom entry from the main 'Plugins' menu @@ -157,7 +171,7 @@ class AmcrViewer: self.iface.removePluginMenu(self.menu, action) self.iface.removeToolBarIcon(action) self.actions.clear() - + # 4. Reset map tools if currently active if hasattr(self, 'tool'): self.iface.mapCanvas().unsetMapTool(self.tool) @@ -165,20 +179,22 @@ class AmcrViewer: # --- Data downloading --- def run_download(self, typ_dat): """ - Triggered by menu/toolbar actions. Opens the filter dialog and + Triggered by menu/toolbar actions. Opens the filter dialog and hands off the parameters to the data loader. """ # Open the specific filter dialog (Projects vs Sites) dlg = AmcrFilterDialog(typ_dat) result = dlg.exec() - - # If user confirmed the dialog (OK button), gather filters and load data + + # If user confirmed the dialog (OK button), + # gather filters and load data if result == QDialog.DialogCode.Accepted: filters = dlg.get_filters() bbox = dlg.get_bbox() komponenty = dlg.get_komponenty() - - # Access the map canvas and start the fetch/render process from amcr_tools + + # 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) @@ -190,9 +206,14 @@ class AmcrViewer: 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 + "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 + "AMČR", + "Přihlášení se nezdařilo – viz záložka AMČR login " + "v panelu Zprávy.", + level=Qgis.MessageLevel.Critical + )