18 Commits

Author SHA1 Message Date
david 89e596802f Update metadata.txt 2026-06-05 13:44:36 +02:00
david 4ea679ec9e Přidat odkaz na nápovědu do menu pluginu (#47)
* ikona amcr-help

* přidán kód pro otevření nápovědy
2026-06-05 13:27:01 +02:00
david 830537f1a4 readme update v2.0.0 (#45) 2026-06-05 11:40:15 +02:00
david 785b83c9c5 přidána varovná hláška do amcr_dialog (#44) 2026-06-05 10:45:32 +02:00
david 493696c67b Chore/code cleanup (#43)
* čištění kódu podle flake8

* Update .gitignore

* Update .gitignore (#41)

* oprava komentářů a překlad do angličtiny

* oprava přihlašování
2026-06-04 17:32:32 +02:00
david b313fc6db0 Update .gitignore 2026-06-04 16:20:11 +02:00
david 9a935261e6 Update metadata.txt 2026-06-03 13:42:46 +02:00
david a4e30bf334 hardcoded AND filtering when filtering by period AND activity area (#39) 2026-06-02 22:25:29 +02:00
david 56389e27d7 nosec update (#38) 2026-05-19 15:44:36 +02:00
David Spáčil c8d42e2459 metadata.txt version update 2026-05-19 15:42:25 +02:00
david a6ebbce4cf Update metadata.txt 2026-05-19 15:25:38 +02:00
david 88149fbb30 Hotfix/security (#37)
* vyměnit xml.etree.ElementTree za defusedxml.ElementTree

* zpátky xml.etree.ElementTree, ale přidáno # nosec; jde o false positive

* switch z ukládání hesel v registrech na používání QGIS Auth Manager
2026-05-19 15:22:35 +02:00
david c0d054d22a Update metadata.txt
v2.0.0-alpha.1
experimental flag true
2026-05-19 13:12:32 +02:00
david ba41039468 Feature/login (#36)
* 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
2026-05-19 13:05:07 +02:00
david 499b3b3f0a Fix/komponenta jako zaklad (#35)
* čištění amcr_tools.py od zbytečných prvků souvisejících se standalone tabulkou komponent

* aplikace nové logiky přípravy dat v případě požadavku na komponenty

* nové aliasy + přidání dat komponent do finálních tabulek

* oprava pořadí polí v tabulce
2026-05-16 14:18:17 +02:00
david 54f154b264 Feature/aktualizace heslaru (#32)
* přechod od statického hesláře k dynamickému načítání z OAI-PMH API AMČR

* aplikace načítání heslářů a task management (backend)

* frontend + debugging

* aktualizace přibaleného hesláře

* kosmetické drobnosti

* ošetření speciální případů při stahování hesláře (katastr, okres) + s tím spojená aktualizace přiloženého hesláře
2026-05-14 14:02:18 +02:00
david c679e776df Přejmenování názvů atributových polí (#31)
* changed attribute field names

* added aliases
2026-04-16 09:39:49 +02:00
david a5604dfaa8 Update README.md (#30)
* update README.md (v1.3.2)
2026-04-13 12:49:13 +02:00
9 changed files with 23323 additions and 14106 deletions
+1
View File
@@ -211,3 +211,4 @@ __marimo__/
README_files/ README_files/
README.html README.html
amcr_viewer.zip amcr_viewer.zip
pyrefly.toml
+109 -52
View File
@@ -2,7 +2,7 @@
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
**Platform:** QGIS 3.4.x **Platform:** QGIS 3.4.04.99.0
**Module Type:** Data Acquisition & Visualization **Module Type:** Data Acquisition & Visualization
@@ -12,8 +12,7 @@
## 1. Overview ## 1. Overview
**AMCR Viewer** is a QGIS plugin designed to facilitate direct access to the Digital Archive of the Archaeological Map of the Czech Republic (AMČR). It allows researchers to **query, retrieve, and visualize *Fieldwork events* and *Sites* data (metadata and geometry) directly within the GIS environment**, eliminating the need to manually export data from the web interface. Both *Fieldwork events* and *Sites* layers may be accompanied by a *Components* layer with additional information. **Only publicly accessible data are supported at the time** (accessibility = anonymous). **AMCR Viewer** is a QGIS plugin designed to facilitate direct access to the Digital Archive of the Archaeological Map of the Czech Republic (AMČR). It allows researchers to **query, retrieve, and visualize *Fieldwork events* and *Sites* data (metadata and geometry) directly within the GIS environment**, eliminating the need to manually export data from the web interface. Both *Fieldwork events* and *Sites* layers may optionally include component-level data (period and activity area) embedded directly in the attribute table. The plugin supports both **anonymous (public) access** and **authenticated access** for users with an AMČR account.
### Key Features ### Key Features
@@ -21,8 +20,7 @@
* **Advanced Attribute Filtering:** Supports multi-criteria filtering using controlled vocabularies. * **Advanced Attribute Filtering:** Supports multi-criteria filtering using controlled vocabularies.
* **Dynamic Geometry Retrieval:** Automatically downloads and categorizes spatial data into Point, Line, and Polygon layers. * **Dynamic Geometry Retrieval:** Automatically downloads and categorizes spatial data into Point, Line, and Polygon layers.
* **Semantic Interoperability:** Automatically translates internal system codes into human-readable labels using the AIS CR API. * **Semantic Interoperability:** Automatically translates internal system codes into human-readable labels using the AIS CR API.
* **Authenticated Access:** Users with an AMČR account can log in to access non-public records.
---
## 2. Installation Guide ## 2. Installation Guide
@@ -30,94 +28,153 @@
**OR** **OR**
*1. Obtain the [plugin distribution package](https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases) (ZIP archive containing the `amcr_viewer` directory).* 1. *Obtain the [plugin distribution package](https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases) (ZIP archive containing the `amcr_viewer` directory).*
*2. Launch QGIS.* 2. *Launch QGIS.*
*3. Navigate to Plugins → Manage and Install Plugins...* 3. *Navigate to Plugins → Manage and Install Plugins...*
*4. Select the Install from ZIP tab.* 4. *Select the Install from ZIP tab.*
*5. Locate the source ZIP file and click Install Plugin.* 5. *Locate the source ZIP file and click Install Plugin.*
*6. Upon successful installation, the AMCR download button (load AMCR data) will appear in the interface.* 6. *Upon successful installation, the AMCR Viewer button will appear in the toolbar.*
---
## 3. User Manual ## 3. User Manual
### 3.1 Data Retrieval ### 3.1 Authentication (Optional)
To initiate a search query, click either the **Stáhnout data akcí** or the **Stáhnout data lokalit** icon from the dropdown menu. The filter dialog provides the following options. Shown options vary based on the choosed tool. By default, the plugin accesses only publicly available records (accessibility = anonymous). To access non-public data, log in using your AMČR account:
* **Spatial Filter:** *Checkbox "Limit search to current map extent":* If checked, the query is restricted to the geographical area currently visible in the QGIS canvas. If unchecked, the query searches the entire database (use with caution regarding data volume). * Click the dropdown arrow on the AMCR Viewer toolbar button and select **Přihlásit se**.
* It is possible to view only those Fieldwork events with positive outcome, if "Positive findings only" is checked. Only *PIANs* marked as (or rather *PIANs* belonging to Documentation units marked as) "Type of evidence" = "positive" are rendered. * Enter your e-mail and password. Credentials are encrypted and stored securely in the **QGIS Authentication Manager** (DPAPI on Windows, Keychain on macOS, encrypted SQLite on Linux).
* Stored credentials are reused automatically across sessions. To update or remove them, open the login dialog again.
### 3.2 Data Retrieval
To initiate a search query, click either the **Stáhnout data akcí** or the **Stáhnout data lokalit** option from the dropdown menu. The filter dialog provides the following options. Shown options vary based on the chosen tool.
* **Spatial Filter:** *Checkbox "Omezit vyhledávání rozsahem okna":* If checked, the query is restricted to the geographical area currently visible in the QGIS canvas. If unchecked, the query searches the entire database (use with caution regarding data volume).
* **Positive findings only:** If checked, only *PIANs* belonging to Documentation units marked as "Type of evidence" = "positive" are included. *(Fieldwork events only.)*
* **Attribute Filters:** * **Attribute Filters:**
* The dialog utilizes "Picker" widgets for controlled vocabularies (common: Region, District, Cadastral area, Period, Activity Area, *PIAN* accuracy; *events* related: Organisation, Researcher, Event type; *sites* related: Site type and class, Level of confidence, State of preservation). * The dialog uses "Picker" widgets for controlled vocabularies (common: Region, District, Cadastral area, Period, Activity Area, *PIAN* accuracy, Accessibility; *events* related: Organisation, Researcher, Event type; *sites* related: Site type and class, Level of confidence, State of preservation).
* Click **Select...** to open a searchable selection window. Multiple values can be selected simultaneously (Logic: OR). * Click **Vybrat...** to open a searchable selection window. Multiple values can be selected simultaneously (Logic: OR).
* **Codelists (Hesláře):**
* Controlled vocabularies are downloaded from the AMČR OAI-PMH API and cached locally in `codelists/heslar.csv`.
* To refresh all codelists, click the **Aktualizovat hesláře 🔄** button in the filter dialog. This runs as a background task and may take a few minutes.
* **Fieldwork Manager (Dynamic List):** * **Components:** Check **Načíst komponenty** to include period and activity area data directly in the output layers.
* Due to the dynamic nature of the persons database, the list of Fieldwork Managers is retrieved from the AIS CR servers and needs to be updated the first time (and subsequently, if there is need). > ⚠ When components are loaded, spatial features are duplicated — each feature corresponds to one component. Spatial analyses (areas, counts) may be inaccurate.
* To refresh the list from the server, click the **Refresh (🔄)** button next to the selection field. This downloads the latest list of researchers from the API.
* **Components:** The *components* data are downloaded as well upon checking the corresponding check box. This enriches the main (*Events* and *Sites*) layers with additional information (period and activity area). * If no filter is used, all accessible Fieldwork events/PIANs are returned (the number of records is capped at 20 000; it is advisable to set at least one filter).
* If no filter is used, all accessible Fieldwork events/PIANs are returned (although the number of Fieldwork events to be loaded is capped at 20000 records; it is advisable to set at least one filter).
For a more in-depth tutorial refer to the [AMČR Documentation](https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html) (only in Czech). For a more in-depth tutorial refer to the [AMČR Documentation](https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html) (only in Czech).
### 3.3 Layer Structure & Attributes
Upon successful retrieval, the plugin generates up to three temporary memory layers:
### 3.2 Layer Structure & Attributes 1. **AMCR\_[Akce|Lokalita]\_Polygony**
2. **AMCR\_[Akce|Lokalita]\_Linie**
3. **AMCR\_[Akce|Lokalita]\_Body**
Upon successful retrieval, the plugin generates four temporary memory layers: Layers are only created if the query returns features of the corresponding geometry type. All layers share the same attribute schema.
1. **AMCR Plochy (Polygons)** #### 3.3.1 Common fields
2. **AMČR Linie (Lines)**
3. **AMČR Body (Points)**
4. **AMČR Komponenty (*Components*/no geometry)**
The Attribute Table includes standardized fields with important metadata. The components layer has no geometry on its own and depend solely on a relation with the other three layers. | Field | Description |
| --- | --- |
| pian | PIAN (spatial identifier) ID |
| presnost | Spatial deviation \[units/tens/hundreds of meters/defined by cadastre\] |
| pian\_typ | \[point/line/polygon\] |
| dj | Documentation unit ID |
| typ\_dj | \[trench/event part/whole event/cadastral territory\] |
| definicni\_body | Feature centroid in WGS-84 coordinate system |
| akce / lokalita | Fieldwork event / Site ID |
| odkaz\_do\_digiarchivu | Link to the record in the Digital Archive |
| okres | District |
| katastr | Main cadastral area |
| dalsi\_katastry | Other cadastral areas, if the event extends beyond the main cadastre |
| Přístupnost | Record accessibility \[A/B/C/D\] |
--- #### 3.3.2 Fields related to *Fieldwork events*
| Field | Description |
| --- | --- |
| akce\_lokalizace | Verbal description of the event location |
| vedouci | Main fieldwork manager |
| organizace | Organisation conducting the research |
| specifikace\_data | \[exact date/exact years/sometime in years\] |
| zahajeni | Event start date |
| ukonceni | Event end date |
| hlavni\_typ | Primary research method |
| vedlejsi\_typ | Secondary research method |
| zjisteni | Did the research reveal archaeological contexts? \[positive/negative\] |
| nahrazuje\_NZ | Replaces a fieldwork report? \[yes/no\] |
#### 3.3.3 Fields related to *Sites*
| Field | Description |
| --- | --- |
| nazev\_lokality | Site name |
| popis\_lokality | Site description |
| typ\_lokality | Site classification by definition method |
| druh\_lokality | Site classification by the nature of identified field relics |
| zachovalost | Site preservation state |
#### 3.3.4 Component fields (only when *Načíst komponenty* is checked)
| Field | Description |
| --- | --- |
| komponenta | Component ID |
| komponenta\_areal | Activity area \[settlement/burial area/field/…\] |
| komponenta\_obdobi | Period \[Neolithic/High Middle AgesModern Period/…\] |
## 4. Technical Architecture ## 4. Technical Architecture
The plugin is developed in **Python 3** using the **PyQt5** framework for the GUI and the **Requests** library for HTTP communication. The plugin is developed in **Python 3** using the **PyQt6** framework for the GUI and the **Requests** library for HTTP communication.
### 4.1 File Structure ### 4.1 File Structure
* `amcr_viewer.py`: Entry point; handles GUI integration and initialization. * `amcr_viewer.py`: Entry point; handles GUI integration, toolbar/menu setup, and login flow.
* `amcr_dialog.py`: Manages the UI logic, including the custom `FilterableSelectionDialog` for handling large vocabularies. * `amcr_dialog.py`: Manages the UI logic, including `AmcrFilterDialog`, `FilterableSelectionDialog`, and `LoginDialog`.
* `amcr_tools.py`: Core logic module. Handles API requests, pagination, data parsing, and vector layer generation. * `amcr_tools.py`: Core logic module. Handles authentication, API requests, pagination, data parsing, and vector layer generation.
* `amcr_codelists.py`: Manages local caching of controlled vocabularies (`codelists/*.csv`). * `amcr_codelists.py`: Manages local caching of controlled vocabularies (`codelists/heslar.csv`) downloaded via OAI-PMH.
### 4.2 Data Flow & API Integration ### 4.2 Data Flow & API Integration
The plugin interacts with three primary endpoints of the AIS CR infrastructure: The plugin interacts with the following endpoints:
1. **Search API (Solr):** 1. **Login API:**
* Endpoint: `https://digiarchiv.aiscr.cz/api/search/query` * Endpoint: `https://digiarchiv.aiscr.cz/api/user/login`
* Method: `GET` * Method: `POST`
* Parameters: `entity=akce`, `rows/page` (pagination). * Returns a session cookie used for subsequent authenticated requests.
* Logic: The plugin implements a `while True` loop to handle pagination, processing data in batches of 500 records to ensure stability. * Credentials are stored in the QGIS Authentication Manager; the session is restored automatically if it expires mid-download.
2. **Search API (Solr):**
* Endpoint: `https://digiarchiv.aiscr.cz/api/search/query`
* Method: `GET`
* Parameters: `entity=akce|lokalita|pian`, `rows/page` (pagination), `mapa=true`.
* Logic: Paginated in batches of 500 records (metadata) and 200 records (geometries). A safety cap of 20 000 records is enforced.
2. **Translation API:** 3. **Translation API:**
* Endpoint: `https://digiarchiv.aiscr.cz/api/assets/i18n/cs.json` * Endpoint: `https://digiarchiv.aiscr.cz/api/assets/i18n/cs.json`
* Function: Retrieves the mapping between system codes (e.g., `HES-xxxx`) and Czech labels. This dictionary is cached in memory during the session. * Function: Retrieves the mapping between system codes (e.g. `HES-xxxx`) and Czech labels. Cached in memory for the session.
4. **Codelists API (OAI-PMH):**
* Endpoint: `https://api.aiscr.cz/2.2/oai`
* Used for downloading controlled vocabularies (periods, regions, organisations, etc.) on demand.
### 4.3 Data Persistence ### 4.3 Data Persistence
* **Vocabularies:** Static vocabularies (e.g., Periods, Regions) are stored in `codelists/heslar.csv`. * **Vocabularies:** Stored in `codelists/heslar.csv`; updated on user request via the background task.
* **Dynamic Data:** The list of researchers is downloaded on-demand and cached in `codelists/vedouci.csv`.
* **Layers:** Output layers are created as `memory` layers. They are non-persistent and will be lost if QGIS is closed without saving. * **Layers:** Output layers are created as `memory` layers. They are non-persistent and will be lost if QGIS is closed without saving.
### 4.4 Constraints ### 4.4 Constraints
* **Record Limit:** A safety cap of 20,000 records is enforced. * **Record Limit:** A safety cap of 20 000 records is enforced.
* **Batch Processing:** Geometry fetching is batched (50 IDs per request) to comply with URL length limitations and server load balancing. * **Batch Processing:** Geometry fetching is batched (200 IDs per request) to comply with URL length limitations and server load balancing.
* **Component duplication:** When components are loaded, each output feature corresponds to one component rather than one documentation unit. A single PIAN may therefore appear multiple times in the layer.
## 6. Links and resources ## 5. Links and resources
* [AMCR/Digiarchive Documentation](https://amcr-help.aiscr.cz/) (only in Czech). * [AMCR/Digiarchive Documentation](https://amcr-help.aiscr.cz/) (only in Czech).
* [AMCR Viewer tutorial](https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html) (only in Czech). * [AMCR Viewer tutorial](https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html) (only in Czech).
* [Import/Export. Pluginy propojující QGIS s AMČR \[poster\]](https://zenodo.org/records/20504909) (only in Czech; valid for v1.3.2).
Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

+209 -79
View File
@@ -2,18 +2,51 @@
import os import os
import csv import csv
import requests import requests
import xml.etree.ElementTree as ET # nosec
import time
from qgis.core import QgsMessageLog, Qgis
# Define paths for the plugin and its codelists directory # Define paths for the plugin and its codelists directory
PLUGIN_DIR = os.path.dirname(__file__) PLUGIN_DIR = os.path.dirname(__file__)
CODELISTS_DIR = os.path.join(PLUGIN_DIR, 'codelists') CODELISTS_DIR = os.path.join(PLUGIN_DIR, 'codelists')
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'
}
NS = {
'oai': 'http://www.openarchives.org/OAI/2.0/',
'dc': 'http://purl.org/dc/elements/1.1/',
'oai_dc': 'http://www.openarchives.org/OAI/2.0/oai_dc/'
}
def ensure_codelists_dir(): def ensure_codelists_dir():
"""Creates the codelists directory if it does not exist.""" """Creates the codelists directory if it does not exist."""
if not os.path.exists(CODELISTS_DIR): if not os.path.exists(CODELISTS_DIR):
os.makedirs(CODELISTS_DIR) os.makedirs(CODELISTS_DIR)
def parse_codelist_file(filename, target_dict=None): 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: if target_dict is None:
target_dict = {} target_dict = {}
@@ -39,108 +72,205 @@ def parse_codelist_file(filename, target_dict=None):
cat = row[2].strip() cat = row[2].strip()
clean = code if code else None 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: if cat not in target_dict:
target_dict[cat] = {} 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 target_dict[cat][label] = clean
except Exception as e: except Exception as e:
print(f"AMČR Codelist Read Error for {filename}: {e}") QgsMessageLog.logMessage(
f"AMČR Codelist Read Error for {filename}: {e}",
"AMČR", Qgis.Critical)
return target_dict return target_dict
def load_all_data(): def load_all_data():
"""Loads all static and dynamic codelists during plugin startup.""" """Loads the codelist during plugin startup."""
ensure_codelists_dir() ensure_codelists_dir()
categorized_data = {k: {} for k in slovnicek.keys()}
# Initialize the base structure with empty dictionaries for all expected categories
categorized_data = {
'obdobi': {}, 'typ_akce': {}, 'areal': {},
'kraj': {}, 'organizace': {}, 'okres': {}, 'katastr': {},
'vedouci': {}, 'pian_presnost': {}, 'typ_lokality': {}, 'druh_lokality': {},
'jistota': {}, 'lokalita_zachovalost': {}
}
# Parse the default static codelist and the dynamically generated leaders codelist
parse_codelist_file('heslar.csv', categorized_data) parse_codelist_file('heslar.csv', categorized_data)
parse_codelist_file('vedouci.csv', categorized_data)
return categorized_data return categorized_data
def download_vedouci():
"""Fetches the list of leaders from the AMČR API and saves it to a CSV file.""" def fetch_set(internal_name, api_set, task=None):
dataset = []
params = {
"verb": "ListRecords",
"metadataPrefix": "oai_dc",
"set": api_set
}
while True:
# 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
records = root.findall('.//oai:record', NS)
for rec in records:
metadata = rec.find('.//oai_dc:dc', NS)
if metadata is not None:
# 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 -")
):
nazev = t.text
break
# 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
)
dataset.append({
'Název': nazev,
'Kód': kod,
'Kategorie': internal_name
})
# Pagination
token = root.find('.//oai:resumptionToken', NS)
if token is not None and token.text:
params = {
"verb": "ListRecords",
"resumptionToken": token.text
}
time.sleep(0.5)
else:
break
except Exception as e:
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() ensure_codelists_dir()
all_data = []
total_sets = len(slovnicek)
# API endpoint for fetching facet data for leaders for index, (interni, api_nazev) in enumerate(slovnicek.items()):
url = "https://digiarchiv.aiscr.cz/api/search/query?entity=akce&sort=datestamp%20desc&page=0&onlyFacets=True&rows=0" # Check if the user cancelled the task via the QGIS taskbar
if task and task.isCanceled():
return False
try: QgsMessageLog.logMessage(
# Execute the GET request with a 20-second timeout f"Zpracovávám kategorii: {interni}...",
r = requests.get(url, timeout=20) "AMČR", Qgis.Info)
r.raise_for_status()
data = r.json()
# Extract the leaders list from the JSON response using safe dict getters # Pass the task correctly to the updated fetch function
vedouci_list = data.get('facet_counts', {}).get('f_vedouci', []) data = fetch_set(interni, api_nazev, task=task)
if not vedouci_list:
vedouci_list = data.get('facet_counts', {}).get('facet_fields', {}).get('f_vedouci', [])
csv_path = os.path.join(CODELISTS_DIR, 'vedouci.csv') if data is None:
return False # Cancelled mid-download
count = 0 all_data.extend(data)
# Open the target CSV file for writing without extra blank lines # Report progress (0-100)
with open(csv_path, 'w', encoding='utf-8', newline='') as f: if task:
writer = csv.writer(f, delimiter=';') progress = (index + 1) / total_sets * 100
task.setProgress(progress)
# Write the standard header required by the parser function # Save to CSV
writer.writerow(['Název', 'Kód', 'Kategorie']) 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=';')
writer.writeheader()
writer.writerows(all_data)
# Iterate through the API results and format them for the CSV return True
for item in vedouci_list:
name = None
if isinstance(item, dict):
name = item.get('name')
elif isinstance(item, str):
name = item
# Ignore pure numbers (which are usually counts) and write valid names
if name and not str(name).isdigit():
writer.writerow([name, name, 'vedouci'])
count += 1
return True, f"Staženo {count} jmen." def refresh_globals():
"""Reloads data from files into the global variables."""
data = load_all_data()
except Exception as e: OBDOBI.clear()
return False, str(e) OBDOBI.update(data.get('obdobi', {}))
TYP_AKCE.clear()
# Initialize global codelist data when the module is imported TYP_AKCE.update(data.get('typ_akce', {}))
_DATA = load_all_data() AREAL.clear()
AREAL.update(data.get('areal', {}))
# Safely extract individual categories into global variables for easy access across the plugin KRAJE.clear()
OBDOBI = _DATA.get('obdobi', {}) KRAJE.update(data.get('kraj', {}))
TYP_AKCE = _DATA.get('typ_akce', {}) ORGANIZACE.clear()
AREAL = _DATA.get('areal', {}) ORGANIZACE.update(data.get('organizace', {}))
KRAJE = _DATA.get('kraj', {}) OKRESY.clear()
ORGANIZACE = _DATA.get('organizace', {}) OKRESY.update(data.get('okres', {}))
OKRESY = _DATA.get('okres', {}) KATASTRY.clear()
KATASTRY = _DATA.get('katastr', {}) KATASTRY.update(data.get('katastr', {}))
VEDOUCI = _DATA.get('vedouci', {})
PIAN_PRESNOST = _DATA.get('pian_presnost', {})
TYP_LOKALITY = _DATA.get('typ_lokality', {})
DRUH_LOKALITY = _DATA.get('druh_lokality', {})
JISTOTA = _DATA.get('jistota', {})
LOKALITA_ZACHOVALOST = _DATA.get('lokalita_zachovalost', {})
def refresh_vedouci_cache():
"""Reloads only the 'vedouci.csv' file to quickly update the cache without full initialization."""
# Parse only the targeted file containing the updated leaders
temp_data = parse_codelist_file('vedouci.csv')
new_vedouci = temp_data.get('vedouci', {})
# Clear the existing global dictionary and update it with the fresh data
VEDOUCI.clear() VEDOUCI.clear()
VEDOUCI.update(new_vedouci) VEDOUCI.update(data.get('vedouci', {}))
PIAN_PRESNOST.clear()
PIAN_PRESNOST.update(data.get('pian_presnost', {}))
TYP_LOKALITY.clear()
TYP_LOKALITY.update(data.get('typ_lokality', {}))
DRUH_LOKALITY.clear()
DRUH_LOKALITY.update(data.get('druh_lokality', {}))
JISTOTA.clear()
JISTOTA.update(data.get('jistota', {}))
LOKALITA_ZACHOVALOST.clear()
LOKALITA_ZACHOVALOST.update(data.get('lokalita_zachovalost', {}))
PRISTUPNOST.clear()
PRISTUPNOST.update(data.get('pristupnost', {}))
return len(VEDOUCI)
# Initialize empty dicts that will be populated immediately below
OBDOBI = {}
TYP_AKCE = {}
AREAL = {}
KRAJE = {}
ORGANIZACE = {}
OKRESY = {}
KATASTRY = {}
VEDOUCI = {}
PIAN_PRESNOST = {}
TYP_LOKALITY = {}
DRUH_LOKALITY = {}
JISTOTA = {}
LOKALITA_ZACHOVALOST = {}
PRISTUPNOST = {}
refresh_globals()
+468 -56
View File
@@ -1,18 +1,58 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout, QFormLayout, from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout,
QLineEdit, QDialogButtonBox, QLineEdit, QDialogButtonBox,
QCheckBox, QGroupBox, QPushButton, QCheckBox, QGroupBox, QPushButton,
QListWidget, QListWidgetItem, QHBoxLayout, QListWidget, QListWidgetItem, QHBoxLayout,
QLabel, QMessageBox, QApplication, QWidget) QMessageBox, QLabel, QFormLayout)
from qgis.PyQt.QtCore import Qt 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, from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE,
OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY, OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST,
DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, TYP_LOKALITY, DRUH_LOKALITY, JISTOTA,
download_vedouci, refresh_vedouci_cache) LOKALITA_ZACHOVALOST, PRISTUPNOST,
download_heslare, refresh_globals)
class UpdateCodelistsTask(QgsTask):
def __init__(self, description):
super().__init__(description, QgsTask.CanCancel)
self.success = False
self.exception = None
def run(self):
"""Runs in a background thread."""
try:
# Call the download function with the task reference
self.success = download_heslare(task=self)
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()
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)
else:
QgsMessageLog.logMessage(
f"Chyba aktualizace: {self.exception}",
"AMČR", Qgis.Critical)
class FilterableSelectionDialog(QDialog): 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. Updated for PyQt6/Qt6 compatibility.
""" """
def __init__(self, title, data_dict, preselected_codes, parent=None): def __init__(self, title, data_dict, preselected_codes, parent=None):
@@ -39,7 +79,8 @@ class FilterableSelectionDialog(QDialog):
# Standard OK/Cancel dialog buttons # Standard OK/Cancel dialog buttons
buttons = QDialogButtonBox( buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel
) )
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
@@ -97,16 +138,16 @@ class AmcrFilterDialog(QDialog):
self.setWindowTitle("Filtr AMČR") self.setWindowTitle("Filtr AMČR")
self.resize(500, 750) 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 self.typ_dat = typ_dat
# Cache dictionary to store selected codes for each category # Cache dictionary to store selected codes for each category
self.selection_cache = { self.selection_cache = {
'organizace': [], 'kraj': [], 'obdobi': [], 'areal': [], 'organizace': [], 'kraj': [], 'obdobi': [], 'areal': [],
'typ_akce': [], 'okres': [], 'katastr': [], 'vedouci': [], 'pian_presnost': [], 'typ_akce': [], 'okres': [], 'katastr': [], 'vedouci': [],
'typ_lokality': [], 'druh_lokality': [], 'jistota': [], 'lokalita_zachovalost': [] 'pian_presnost': [], 'pristupnost': [], 'typ_lokality': [],
'druh_lokality': [], 'jistota': [], 'lokalita_zachovalost': []
} }
layout = QVBoxLayout() layout = QVBoxLayout()
@@ -132,45 +173,82 @@ class AmcrFilterDialog(QDialog):
self.picker_okres = self.setup_picker("Okres", 'okres', OKRESY) self.picker_okres = self.setup_picker("Okres", 'okres', OKRESY)
layout.addWidget(self.picker_okres) 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) 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) layout.addWidget(self.picker_presnost)
self.picker_pristupnost = self.setup_picker(
"Přístupnost",
'pristupnost',
PRISTUPNOST
)
layout.addWidget(self.picker_pristupnost)
# Filters valid for Akce # Filters valid for Akce
if self.typ_dat == "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) layout.addWidget(self.picker_org)
# Button to fetch fresh project leaders from the API self.picker_vedouci = self.setup_picker(
self.btn_update_vedouci = QPushButton("🔄") "Vedoucí výzkumu",
self.btn_update_vedouci.setToolTip("Aktualizovat seznam vedoucích z API") 'vedouci',
self.btn_update_vedouci.setFixedWidth(30) VEDOUCI
self.btn_update_vedouci.clicked.connect(self.action_update_vedouci) )
self.picker_vedouci = self.setup_picker("Vedoucí výzkumu", 'vedouci', VEDOUCI, extra_btn=self.btn_update_vedouci)
layout.addWidget(self.picker_vedouci) layout.addWidget(self.picker_vedouci)
# Type of event # 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) layout.addWidget(self.picker_typ)
# Filters valid for Lokality # Filters valid for Lokality
if self.typ_dat == "lokalita": 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) 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) 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) 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) layout.addWidget(self.picker_lokalita_zachovalost)
# Contextual information # Contextual information
@@ -185,13 +263,45 @@ class AmcrFilterDialog(QDialog):
self.chk_komponenty = QCheckBox("Načíst komponenty") self.chk_komponenty = QCheckBox("Načíst komponenty")
layout.addWidget(self.chk_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)
self.chk_komponenty.toggled.connect(
self.lbl_komponenty_warning.setVisible
)
# Pushes everything above to the top # Pushes everything above to the top
layout.addStretch(1) layout.addStretch(1)
# Main dialog OK/Cancel buttons # Main dialog OK/Cancel/Update buttons
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel 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
)
buttons.addButton(QDialogButtonBox.StandardButton.Ok)
buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
layout.addWidget(buttons) layout.addWidget(buttons)
@@ -201,7 +311,8 @@ class AmcrFilterDialog(QDialog):
def setup_picker(self, label_text, cache_key, data_source, extra_btn=None): 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 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. 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 = QHBoxLayout()
@@ -216,51 +327,77 @@ class AmcrFilterDialog(QDialog):
btn = QPushButton("Vybrat...") btn = QPushButton("Vybrat...")
btn.setFixedWidth(80) 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(): def open_dialog():
dlg = FilterableSelectionDialog(label_text, data_source, self.selection_cache[cache_key], self) dlg = FilterableSelectionDialog(
if dlg.exec() == QDialog.DialogCode.Accepted: # PyQt6: DialogCode label_text,
data_source,
self.selection_cache[cache_key],
self
)
if dlg.exec() == QDialog.DialogCode.Accepted:
codes, labels = dlg.get_selected_codes() 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 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: if labels:
display_field.setText(", ".join(labels)) display_field.setText(", ".join(labels))
else: else:
display_field.clear() 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': if cache_key == 'pian_presnost':
display_field.setText("odchylka jednotky metrů, odchylka desítky metrů, odchylka stovky metrů") display_field.setText(
self.selection_cache[cache_key] = ['HES-000861', 'HES-000862', 'HES-000863'] "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) btn.clicked.connect(open_dialog)
row_layout.addWidget(display_field) row_layout.addWidget(display_field)
row_layout.addWidget(btn) 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: if extra_btn:
row_layout.addWidget(extra_btn) row_layout.addWidget(extra_btn)
row_widget.setLayout(row_layout) row_widget.setLayout(row_layout)
return row_widget return row_widget
def action_update_vedouci(self): def action_update_heslare(self):
# Change cursor to loading state to indicate background task # Create the task instance
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) task = UpdateCodelistsTask("Aktualizace heslářů AMČR")
try:
success, msg = download_vedouci() # Re-enable the button regardless of the outcome
if success: task.taskCompleted.connect(lambda: self.btn_update.setEnabled(True))
count = refresh_vedouci_cache() task.taskTerminated.connect(lambda: self.btn_update.setEnabled(True))
QMessageBox.information(self, "Úspěch", f"{msg}\nNyní je v paměti {count} osob.")
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:
# This will show exactly what went wrong (e.g. PermissionError)
msg = (
"Aktualizace selhala z důvodu chyby:\n"
f"{str(task.exception)}"
)
else: else:
QMessageBox.warning(self, "Chyba", f"Nepodařilo se stáhnout data:\n{msg}") msg = "Aktualizace byla zrušena uživatelem."
except Exception as e: QMessageBox.warning(self, "Chyba / Zrušeno", msg)
QMessageBox.critical(self, "Chyba", str(e))
finally: task.taskTerminated.connect(on_error)
# Safely restore the normal cursor even if an error occurs
QApplication.restoreOverrideCursor() QgsApplication.taskManager().addTask(task)
def get_bbox(self): def get_bbox(self):
return "true" if self.chk_bbox.isChecked() else "false" return "true" if self.chk_bbox.isChecked() else "false"
@@ -269,7 +406,8 @@ class AmcrFilterDialog(QDialog):
return "true" if self.chk_komponenty.isChecked() else "false" return "true" if self.chk_komponenty.isChecked() else "false"
def get_filters(self): 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 = {} filters = {}
if self.selection_cache['kraj']: if self.selection_cache['kraj']:
@@ -284,6 +422,8 @@ class AmcrFilterDialog(QDialog):
filters['f_areal'] = self.selection_cache['areal'] filters['f_areal'] = self.selection_cache['areal']
if self.selection_cache['pian_presnost']: 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']
if self.typ_dat == "akce": if self.typ_dat == "akce":
if self.chk_posevidence.isChecked(): if self.chk_posevidence.isChecked():
@@ -306,3 +446,275 @@ class AmcrFilterDialog(QDialog):
filters['f_lokalita_zachovalost'] = self.selection_cache['lokalita_zachovalost'] filters['f_lokalita_zachovalost'] = self.selection_cache['lokalita_zachovalost']
return filters 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
# ------------------------------------------------------------------
# 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
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
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, existing_cfg = (
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):
settings = QSettings()
existing_id = settings.value(self.SETTINGS_KEY, "")
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."
)
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
+484 -169
View File
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from qgis.core import (QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry, from qgis.core import (QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry,
QgsField, QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsField, QgsCoordinateReferenceSystem,
QgsWkbTypes, QgsPolymorphicRelation, QgsEditorWidgetSetup, Qgis) QgsCoordinateTransform, QgsWkbTypes, Qgis,
QgsMessageLog)
from qgis.utils import iface from qgis.utils import iface
from qgis.PyQt.QtCore import Qt, QMetaType from qgis.PyQt.QtCore import Qt, QMetaType
from qgis.PyQt.QtWidgets import QApplication from qgis.PyQt.QtWidgets import QApplication
@@ -12,11 +13,151 @@ import json
# Global cache to store translated terms from the Digital Archive # Global cache to store translated terms from the Digital Archive
TRANSLATIONS = {} TRANSLATIONS = {}
# 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: 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):
"""
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
)
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()
# 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
)
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:
"""
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
# Attempt auto-login using stored credentials
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:
"""
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:
"""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
)
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 # Invalidate the old 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(): 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 global TRANSLATIONS
if TRANSLATIONS: if TRANSLATIONS:
return return
url = "https://digiarchiv.aiscr.cz/api/assets/i18n/cs.json" url = "https://digiarchiv.aiscr.cz/api/assets/i18n/cs.json"
try: try:
@@ -26,13 +167,31 @@ def load_translations():
except Exception as e: except Exception as e:
print(f"Error downloading vocabulary: {e}") print(f"Error downloading vocabulary: {e}")
def tr_code(code): def tr_code(code):
"""Translates a technical code into a human-readable string using the global cache.""" """
Translates a technical code into a human-readable string
using the global cache.
"""
if not code: if not code:
return "" return ""
return TRANSLATIONS.get(code, code) return TRANSLATIONS.get(code, code)
def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false"):
def komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
areal_id = komp.get('komponenta_areal', {}).get('id', "")
if filter_areal and areal_id not in filters.get('f_areal', []):
return False
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"):
""" """
Main processing function: Main processing function:
1. Determines search area (Bounding Box) 1. Determines search area (Bounding Box)
@@ -42,19 +201,28 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
load_translations() load_translations()
# --- 1. COORDINATE TRANSFORMATION --- # --- 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() extent = canvas.extent()
crs_src = canvas.mapSettings().destinationCrs() crs_src = canvas.mapSettings().destinationCrs()
crs_dest = QgsCoordinateReferenceSystem("EPSG:4326") crs_dest = QgsCoordinateReferenceSystem("EPSG:4326")
xform = QgsCoordinateTransform(crs_src, crs_dest, QgsProject.instance()) xform = QgsCoordinateTransform(crs_src, crs_dest, QgsProject.instance())
extent_wgs = xform.transformBoundingBox(extent) extent_wgs = xform.transformBoundingBox(extent)
# Format the bounding box string as required by the API: minLat,minLon,maxLat,maxLon # Format the bounding box string as required by the API:
bbox_str = f"{extent_wgs.yMinimum()},{extent_wgs.xMinimum()},{extent_wgs.yMaximum()},{extent_wgs.xMaximum()}" # 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" 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)) QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor))
try: try:
@@ -72,7 +240,8 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
if bb == "true": if bb == "true":
base_params["loc_rpt"] = bbox_str 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: if filters:
for key, value in filters.items(): for key, value in filters.items():
if not value: if not value:
@@ -86,13 +255,20 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
current_page = 0 current_page = 0
BATCH_DOCS = 500 # Records per API request BATCH_DOCS = 500 # Records per API request
MAX_LIMIT = 20000 # Safety limit to prevent QGIS from freezing MAX_LIMIT = 20000 # Safety limit to prevent QGIS from freezing
feats_k = [] # List for component features (non-spatial)
seen_ids = set() seen_ids = set()
target_pian_ids_count = 0 target_pian_ids_count = 0
# Check if we should skip negative results based on filter # 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 --- # --- API PAGINATION LOOP ---
while True: while True:
@@ -103,7 +279,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
del base_params['page'] del base_params['page']
try: 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() resp_json = resp_docs.json()
data = resp_json.get('response', {}) data = resp_json.get('response', {})
batch_docs = data.get('docs', []) batch_docs = data.get('docs', [])
@@ -121,43 +297,54 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
new_docs.append(d) new_docs.append(d)
docs.extend(new_docs) 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: if len(docs) >= num_found:
break break
if len(docs) >= MAX_LIMIT: 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 break
current_page += 1 current_page += 1
QApplication.processEvents() # Keep UI responsive QApplication.processEvents() # Keep UI responsive
except Exception as e: except Exception as e:
print(f"Chyba při stránkování na straně {current_page}: {e}") print(f"Chyba při stránkování na straně {current_page}: {e}")
break break
if not docs: if not docs:
iface.messageBar().pushMessage("AMCR", "Žádné záznamy nenalezeny.", level=Qgis.MessageLevel.Warning) iface.messageBar().pushMessage(
return "AMCR",
"Žádné záznamy nenalezeny.",
level=Qgis.MessageLevel.Warning
)
return
# ========================================== # ==========================================
# B) ATTRIBUTE PARSING # 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 = {} pian_lookup = {}
komponenty_lookup = {}
target_pian_ids = set() target_pian_ids = set()
actions_with_geom = 0 actions_with_geom = 0
# Helper function for safe single-value extraction # Helper: safely extract a single value
def g(doc, key, default=""): def g(doc, key, default=""):
val = doc.get(key) val = doc.get(key)
if isinstance(val, list): if isinstance(val, list):
return str(val[0]) if val else default return str(val[0]) if val else default
return str(val) if val is not None 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): def g_list(doc, key, translate=False):
val = doc.get(key, []) val = doc.get(key, [])
if not isinstance(val, list): if not isinstance(val, list):
@@ -174,15 +361,22 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
actions_with_geom += 1 actions_with_geom += 1
# Extract protected data (fields not available in public Solr index) # Extract protected fields
az_chranene = doc.get('az_chranene_udaje', {}) az_chranene = doc.get('az_chranene_udaje', {})
chranene = doc.get('akce_chranene_udaje') or doc.get('lokalita_chranene_udaje') or {} chranene = (
doc.get('akce_chranene_udaje')
or doc.get('lokalita_chranene_udaje')
or {}
)
# Format additional cadastral areas from dictionaries # Format additional cadastral areas from nested dicts
dalsi_kat = az_chranene.get('dalsi_katastr', []) dalsi_kat = az_chranene.get('dalsi_katastr', [])
dalsi_kat_str = "" dalsi_kat_str = ""
if isinstance(dalsi_kat, list): 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]) dalsi_kat_str = ", ".join([i for i in items if i])
lokalizace = chranene.get('lokalizace_okolnosti', "") lokalizace = chranene.get('lokalizace_okolnosti', "")
@@ -202,43 +396,100 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
# Add entity-specific metadata # Add entity-specific metadata
if typ_dat == "akce": if typ_dat == "akce":
meta.update({ meta.update({
"akce_hlavni_vedouci": g(doc, 'akce_hlavni_vedouci'), "akce_hlavni_vedouci": g(
"akce_organizace": tr_code(g(doc, 'akce_organizace')), doc,
"akce_specifikace_data": tr_code(g(doc, 'akce_specifikace_data')), 'akce_hlavni_vedouci'
"akce_datum_zahajeni": g(doc, 'akce_datum_zahajeni'), ),
"akce_datum_ukonceni": g(doc, 'akce_datum_ukonceni'), "akce_organizace": tr_code(g(
"akce_hlavni_typ": tr_code(g(doc, 'akce_hlavni_typ')), doc,
"akce_vedlejsi_typ": g_list(doc, 'akce_vedlejsi_typ', translate=True), 'akce_organizace'
"lokalizace_okolnosti": str(lokalizace) if lokalizace else "", )),
"akce_je_nz": "Ano" if doc.get('akce_je_nz') is True else "Ne", "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": elif typ_dat == "lokalita":
meta.update({ meta.update({
"lokalita_nazev": lokalita_nazev, "lokalita_nazev": lokalita_nazev,
"lokalita_popis": lokalita_popis, "lokalita_popis": lokalita_popis,
"lokalita_zachovalost": tr_code(g(doc, 'lokalita_zachovalost')), "lokalita_zachovalost": tr_code(g(
"lokalita_druh": tr_code(g(doc, 'lokalita_druh')), doc,
"lokalita_typ": tr_code(g(doc, 'lokalita_typ_lokality')), '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 # Documentation units (DJ) within the record
djs = doc.get('az_dokumentacni_jednotka', []) djs = doc.get('az_dokumentacni_jednotka', [])
for dj in djs: 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: if skip_negativni and dj.get('dj_negativni_jednotka') is True:
continue 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
):
continue
dj_id = dj.get('ident_cely') dj_id = dj.get('ident_cely')
dj_typ = dj.get('dj_typ') dj_typ = dj.get('dj_typ')
# Merge general meta with documentation unit specific data # Merge shared metadata with documentation unit-specific fields
dj_meta = { dj_meta = {
**meta, **meta,
'dj_id': dj_id, 'dj_id': dj_id,
'dj_typ_value': dj_typ.get('value') if dj_typ else "", '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) # Link Documentation Unit to Geometry (PIAN)
@@ -247,44 +498,88 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
dj_pian_value = dj_pian.get('id') dj_pian_value = dj_pian.get('id')
if dj_pian_value: if dj_pian_value:
target_pian_ids.add(dj_pian_value) target_pian_ids.add(dj_pian_value)
target_pian_ids_count += 1
if dj_pian_value not in pian_lookup: if dj_pian_value not in pian_lookup:
pian_lookup[dj_pian_value] = [] pian_lookup[dj_pian_value] = []
pian_lookup[dj_pian_value].append(dj_meta)
# Parse non-spatial components if requested (for relational tables)
if komponenty == "true": if komponenty == "true":
komps = dj.get('dj_komponenta', []) # One feature per component
for komp in komps: # all data on a single row, no relations needed
komp_temp = [ if komps:
komp.get('ident_cely', ""), for komp in komps:
komp.get('komponenta_areal', {}).get('value', ""), if not komp_projde_filtrem(
komp.get('komponenta_obdobi', {}).get('value', "") komp, filter_areal,
] filter_datace, filters
if dj_id not in komponenty_lookup: ):
komponenty_lookup[dj_id] = [] continue
komponenty_lookup[dj_id].append(komp_temp)
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', ""),
}
pian_lookup[dj_pian_value].append(komp_meta)
target_pian_ids_count += 1
else:
# DJ without components — still include
# with empty component fields
if filter_areal or filter_datace:
continue
empty_meta = {
**dj_meta,
'komponenta_id': "",
'komponenta_areal': "",
'komponenta_obdobi': "",
}
pian_lookup[dj_pian_value].append(empty_meta)
target_pian_ids_count += 1
else:
target_pian_ids_count += 1
pian_lookup[dj_pian_value].append(dj_meta)
if not target_pian_ids: if not target_pian_ids:
iface.messageBar().pushMessage("AMCR", f"Nalezeno {len(docs)} záznamů, ale žádný nemá geometrii.", level=Qgis.MessageLevel.Warning) iface.messageBar().pushMessage(
"AMCR",
f"Nalezeno {len(docs)} záznamů, ale žádný nemá geometrii.",
level=Qgis.MessageLevel.Warning
)
return return
# ========================================== # ==========================================
# C) GEOMETRY FETCHING (PIAN) # C) GEOMETRY FETCHING (PIAN)
# ========================================== # ==========================================
ids_list = list(target_pian_ids) ids_list = list(target_pian_ids)
total_pians = len(ids_list) total_pians = len(ids_list)
docs_pian = [] docs_pian = []
BATCH_PIAN = 200 # Geometry requests are batch-processed to stay under URL length limits # 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). Stahuji {total_pians} unikátních geometrií, vykresluji {target_pian_ids_count} geometrií...", level=Qgis.MessageLevel.Info) 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"] fl_pian = [
"ident_cely", "pian_typ",
"pian_chranene_udaje", "pian_presnost"
]
for i in range(0, total_pians, BATCH_PIAN): 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) or_query = " OR ".join(batch)
fq_pian = f"ident_cely:({or_query})" fq_pian = f"ident_cely:({or_query})"
@@ -297,7 +592,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
} }
try: try:
QApplication.processEvents() 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', []) batch_docs = r_pian.json().get('response', {}).get('docs', [])
docs_pian.extend(batch_docs) docs_pian.extend(batch_docs)
except Exception as e: except Exception as e:
@@ -310,39 +605,51 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
archeologicky_zaznam = "Akce" if typ_dat == "akce" else "Lokalita" archeologicky_zaznam = "Akce" if typ_dat == "akce" else "Lokalita"
# Initialize three layers for different geometry types (S-JTSK CRS) # Initialize three layers for different geometry types (S-JTSK CRS)
vl_poly = QgsVectorLayer("Polygon?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Polygony", "memory") vl_poly = QgsVectorLayer(
vl_line = QgsVectorLayer("LineString?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Linie", "memory") "Polygon?crs=epsg:5514",
vl_point = QgsVectorLayer("Point?crs=epsg:5514", f"AMCR_{archeologicky_zaznam}_Body", "memory") 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] layers = [vl_poly, vl_line, vl_point]
# Define attribute table structure # Define attribute table structure
cols = [ cols = [
QgsField("PIAN", QMetaType.Type.QString), QgsField("pian", QMetaType.Type.QString),
QgsField("Přesnost", QMetaType.Type.QString), QgsField("presnost", QMetaType.Type.QString),
QgsField("PIAN typ", QMetaType.Type.QString), QgsField("pian_typ", QMetaType.Type.QString),
QgsField("Dokumentační jednotka", QMetaType.Type.QString), QgsField("dj", QMetaType.Type.QString),
QgsField("Typ dokumentační jednotky", QMetaType.Type.QString), QgsField("typ_dj", QMetaType.Type.QString),
QgsField("Definiční bod(y) (WGS-84)", QMetaType.Type.QString), QgsField("definicni_body", QMetaType.Type.QString),
QgsField(archeologicky_zaznam, QMetaType.Type.QString), QgsField(typ_dat, QMetaType.Type.QString),
QgsField("Odkaz do Digitálního archivu AMČR", QMetaType.Type.QString), QgsField("odkaz_do_digiarchivu", QMetaType.Type.QString),
QgsField("Okres", QMetaType.Type.QString), QgsField("okres", QMetaType.Type.QString),
QgsField("Katastr", QMetaType.Type.QString), QgsField("katastr", QMetaType.Type.QString),
QgsField("Další katastry", QMetaType.Type.QString) QgsField("dalsi_katastry", QMetaType.Type.QString)
] ]
# Extend table based on data type # Extend table based on data type
if typ_dat == "akce": if typ_dat == "akce":
cols += [ cols += [
QgsField("Akce lokalizace", QMetaType.Type.QString), QgsField("akce_lokalizace", QMetaType.Type.QString),
QgsField("Vedoucí akce", QMetaType.Type.QString), QgsField("vedouci", QMetaType.Type.QString),
QgsField("Organizace", QMetaType.Type.QString), QgsField("organizace", QMetaType.Type.QString),
QgsField("Specifikace data", QMetaType.Type.QString), QgsField("specifikace_data", QMetaType.Type.QString),
QgsField("Datum zahájeni", QMetaType.Type.QString), QgsField("zahajeni", QMetaType.Type.QString),
QgsField("Datum ukončení", QMetaType.Type.QString), QgsField("ukonceni", QMetaType.Type.QString),
QgsField("Hlavní typ", QMetaType.Type.QString), QgsField("hlavni_typ", QMetaType.Type.QString),
QgsField("Vedlejší typ", QMetaType.Type.QString), QgsField("vedlejsi_typ", QMetaType.Type.QString),
QgsField("Zjištění", QMetaType.Type.QString), QgsField("zjisteni", QMetaType.Type.QString),
QgsField("Akce nahrazuje NZ", QMetaType.Type.QString), QgsField("nahrazuje_NZ", QMetaType.Type.QString),
] ]
elif typ_dat == "lokalita": elif typ_dat == "lokalita":
cols += [ cols += [
@@ -357,29 +664,43 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
# Use aliases for technical field names # Use aliases for technical field names
alias_map = { alias_map = {
"pian": "PIAN",
"presnost": "Přesnost",
"pian_typ": "PIAN typ",
"dj": "Dokumentační jednotka",
"typ_dj": "Typ dokumentační jednotky",
"definicni_body": "Definiční bod(y) (WGS-84)",
typ_dat: archeologicky_zaznam,
"odkaz_do_digiarchivu": "Odkaz do Digitálního archivu AMČR",
"okres": "Okres",
"katastr": "Katastr",
"dalsi_katastry": "Další katastry",
"akce_lokalizace": "Akce lokalizace",
"vedouci": "Vedoucí akce",
"organizace": "Organizace",
"specifikace_data": "Specifikace data",
"zahajeni": "Datum zahájeni",
"ukonceni": "Datum ukončení",
"hlavni_typ": "Hlavní typ",
"vedlejsi_typ": "Vedlejší typ",
"zjisteni": "Zjištění",
"nahrazuje_NZ": "Akce nahrazuje NZ",
"nazev_lokality": "Název lokality", "nazev_lokality": "Název lokality",
"popis_lokality": "Popis lokality", "popis_lokality": "Popis lokality",
"typ_lokality": "Typ lokality", "typ_lokality": "Typ lokality",
"druh_lokality": "Druh lokality", "druh_lokality": "Druh lokality",
"zachovalost": "Zachovalost" "zachovalost": "Zachovalost",
"komponenta": "Komponenta",
"komponenta_areal": "Areál",
"komponenta_obdobi": "Období",
} }
# Create a non-spatial table for components if requested
if komponenty == "true": if komponenty == "true":
vl_komponenty = QgsVectorLayer("None", "AMCR Komponenty", "memory") cols += [
pr = vl_komponenty.dataProvider()
komponenty_cols = [
QgsField("komponenta", QMetaType.Type.QString), QgsField("komponenta", QMetaType.Type.QString),
QgsField("dj_id", QMetaType.Type.QString),
QgsField("komponenta_areal", QMetaType.Type.QString), QgsField("komponenta_areal", QMetaType.Type.QString),
QgsField("komponenta_obdobi", QMetaType.Type.QString), QgsField("komponenta_obdobi", QMetaType.Type.QString),
QgsField("vrstva", QMetaType.Type.QString)
] ]
pr.addAttributes(komponenty_cols)
vl_komponenty.updateFields()
idx_vrstva = vl_komponenty.fields().indexOf("vrstva")
vl_komponenty.setEditorWidgetSetup(idx_vrstva, QgsEditorWidgetSetup("Hidden", {}))
for vl in layers: for vl in layers:
vl.dataProvider().addAttributes(cols) vl.dataProvider().addAttributes(cols)
@@ -405,7 +726,11 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
raw = doc.get('pian_chranene_udaje') raw = doc.get('pian_chranene_udaje')
if isinstance(raw, list) and raw: if isinstance(raw, list) and raw:
raw = raw[0] 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 wkt = None
if jdata.get('geom_sjtsk_wkt'): if jdata.get('geom_sjtsk_wkt'):
@@ -417,7 +742,12 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
pian_typ = tr_code(str(doc.get('pian_typ', ''))) pian_typ = tr_code(str(doc.get('pian_typ', '')))
# Final precision filter check # 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 continue
if wkt: if wkt:
@@ -427,50 +757,66 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
target_list = None target_list = None
if t == QgsWkbTypes.PolygonGeometry: if t == QgsWkbTypes.PolygonGeometry:
target_list = feats_p target_list = feats_p
referenced_layer = vl_poly
elif t == QgsWkbTypes.LineGeometry: elif t == QgsWkbTypes.LineGeometry:
target_list = feats_l target_list = feats_l
referenced_layer = vl_line
elif t == QgsWkbTypes.PointGeometry: elif t == QgsWkbTypes.PointGeometry:
target_list = feats_pt target_list = feats_pt
referenced_layer = vl_point
if target_list is None: if target_list is None:
continue continue
is_akce = (typ_dat == "akce") 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: for meta in metas:
feat = QgsFeature() feat = QgsFeature()
feat.setGeometry(geom) feat.setGeometry(geom)
atributy = [ atributy = [
pid, pian_presnost, pian_typ, meta['dj_id'], pid,
meta['dj_typ_value'], meta['loc'], meta['ident_cely'], pian_presnost,
"https://digiarchiv.aiscr.cz/id/" + meta['ident_cely'], pian_typ,
meta['az_okres'], meta['katastr'], meta['dalsi_katastr'] 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: if is_akce:
atributy.extend([ atributy.extend([
meta['lokalizace_okolnosti'], meta['akce_hlavni_vedouci'], meta['lokalizace_okolnosti'],
meta['akce_organizace'], meta['akce_specifikace_data'], meta['akce_hlavni_vedouci'],
meta['akce_datum_zahajeni'], meta['akce_datum_ukonceni'], meta['akce_organizace'],
meta['akce_hlavni_typ'], meta['akce_vedlejsi_typ'], meta['akce_specifikace_data'],
meta['dj_negativni'], meta['akce_je_nz'] meta['akce_datum_zahajeni'],
meta['akce_datum_ukonceni'],
meta['akce_hlavni_typ'],
meta['akce_vedlejsi_typ'],
meta['dj_negativni'],
meta['akce_je_nz']
]) ])
else: else:
atributy.extend([ atributy.extend([
meta['lokalita_nazev'], meta['lokalita_popis'], meta['lokalita_nazev'],
meta['lokalita_typ'], meta['lokalita_druh'], meta['lokalita_popis'],
meta['lokalita_typ'],
meta['lokalita_druh'],
meta['lokalita_zachovalost'] meta['lokalita_zachovalost']
]) ])
if komponenty == "true" and meta['dj_id'] in komponenty_lookup:
for k in komponenty_lookup[meta['dj_id']]:
if len(k) == 3:
k.append(referenced_layer.id())
atributy.append(meta['pristupnost']) atributy.append(meta['pristupnost'])
if komponenty == "true":
atributy.extend([
meta.get('komponenta_id', ""),
meta.get('komponenta_areal', ""),
meta.get('komponenta_obdobi', ""),
])
feat.setAttributes(atributy) feat.setAttributes(atributy)
target_list.append(feat) target_list.append(feat)
@@ -478,18 +824,7 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
print(f"Chyba při tvorbě feature: {ex}") print(f"Chyba při tvorbě feature: {ex}")
pass pass
if komponenty == "true": # --- ADDING TO QGIS INTERFACE ---
for k in komponenty_lookup:
for komp in komponenty_lookup[k]:
if len(komp) == 4:
feat = QgsFeature()
atributy = [
komp[0], k, komp[1], komp[2], komp[3]
]
feat.setAttributes(atributy)
feats_k.append(feat)
# --- ADDING TO QGIS INTERFACE ---
proj = QgsProject.instance() proj = QgsProject.instance()
added = 0 added = 0
layers_to_process = [ layers_to_process = [
@@ -498,54 +833,34 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
(feats_pt, vl_point, "Body"), (feats_pt, vl_point, "Body"),
] ]
if komponenty == "true":
layers_to_process.append((feats_k, vl_komponenty, "Komponenty"))
for f, l, n in layers_to_process: for f, l, n in layers_to_process:
if f: if f:
l.dataProvider().addFeatures(f) l.dataProvider().addFeatures(f)
l.updateExtents() l.updateExtents()
l.setName(f"AMCR_{archeologicky_zaznam}_{n}") l.setName(f"AMCR_{archeologicky_zaznam}_{n}")
proj.addMapLayer(l) proj.addMapLayer(l)
if n != "Komponenty": added += len(f)
added += len(f)
if added > 0: 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",
# --- RELATIONSHIP MANAGEMENT --- f"Hotovo. Záznamů: {len(docs)} (s geom: {actions_with_geom}). "
# Set up automatic links between spatial layers and the component table f"Vykresleno: {added} prvků.",
if komponenty == "true": level=Qgis.MessageLevel.Success
parent_layers_ids = [] )
if feats_p:
parent_layers_ids.append(vl_poly.id())
if feats_l:
parent_layers_ids.append(vl_line.id())
if feats_pt:
parent_layers_ids.append(vl_point.id())
rel_manager = proj.relationManager()
rel = QgsPolymorphicRelation()
# rel.setId(f"rel_komponenty_{archeologicky_zaznam}")
rel.setName("Komponenty")
rel.setReferencingLayer(vl_komponenty.id())
rel.setReferencedLayerExpression("@layer_id")
rel.setReferencedLayerField("vrstva")
rel.setReferencedLayerIds(parent_layers_ids)
rel.addFieldPair("dj_id", "Dokumentační jednotka")
rel.generateId()
if rel.isValid():
rel_manager.addPolymorphicRelation(rel)
else:
print("Relace Komponenty není validní!")
else: 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: 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: finally:
# Always restore cursor, even after failure # Always restore cursor, even after failure
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
+77 -14
View File
@@ -1,13 +1,15 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication, QUrl
from qgis.PyQt.QtGui import QIcon from qgis.PyQt.QtGui import QIcon, QDesktopServices
from qgis.PyQt.QtWidgets import QMenu, QAction, QToolButton, QDialog from qgis.PyQt.QtWidgets import QMenu, QAction, QToolButton, QDialog
from qgis.core import Qgis
from .amcr_tools import load_amcr_data from .amcr_tools import load_amcr_data, login_to_api
from .amcr_dialog import AmcrFilterDialog from .amcr_dialog import AmcrFilterDialog, LoginDialog
from .resources import * from .resources import *
import os.path import os.path
class AmcrViewer: class AmcrViewer:
""" """
Main plugin class that manages the GUI elements, menu entries, Main plugin class that manages the GUI elements, menu entries,
@@ -27,9 +29,11 @@ class AmcrViewer:
locale_path = os.path.join( locale_path = os.path.join(
self.plugin_dir, self.plugin_dir,
'i18n', '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): if os.path.exists(locale_path):
self.translator = QTranslator() self.translator = QTranslator()
self.translator.load(locale_path) self.translator.load(locale_path)
@@ -41,7 +45,10 @@ class AmcrViewer:
self.first_start = None self.first_start = None
def tr(self, message): 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) return QCoreApplication.translate('AmcrViewer', message)
def add_action(self, icon_path, text, callback, enabled_flag=True, def add_action(self, icon_path, text, callback, enabled_flag=True,
@@ -69,7 +76,8 @@ class AmcrViewer:
if add_to_menu: if add_to_menu:
self.iface.addPluginToMenu(self.menu, action) 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: if add_to_toolbar or add_to_menu:
self.actions.append(action) self.actions.append(action)
@@ -83,12 +91,14 @@ class AmcrViewer:
# Define paths for action-specific icons # Define paths for action-specific icons
icon_akce_path = os.path.join(self.plugin_dir, 'akce.png') icon_akce_path = os.path.join(self.plugin_dir, 'akce.png')
icon_lokality_path = os.path.join(self.plugin_dir, 'lokality.png') icon_lokality_path = os.path.join(self.plugin_dir, 'lokality.png')
icon_amcr_help_path = os.path.join(self.plugin_dir, 'amcr-help.png')
# 1. Create a container menu for the plugin # 1. Create a container menu for the plugin
self.plugin_menu = QMenu() self.plugin_menu = QMenu()
# 2. Create sub-actions (Download Projects / Download Sites) # 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( self.action_download_akce = self.add_action(
icon_path=icon_akce_path, icon_path=icon_akce_path,
text=self.tr(u'Stáhnout data akcí | AMČR Viewer'), text=self.tr(u'Stáhnout data akcí | AMČR Viewer'),
@@ -109,9 +119,33 @@ class AmcrViewer:
) )
self.plugin_menu.addAction(self.action_download_lokality) 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)
self.action_amcr_help = self.add_action(
icon_path=icon_amcr_help_path,
text=self.tr(u'Nápověda AMČR Help | AMČR Viewer'),
callback=lambda checked=False: self.open_help(),
parent=self.iface.mainWindow(),
add_to_menu=False,
add_to_toolbar=False
)
self.plugin_menu.addAction(self.action_amcr_help)
# 3. Create the main project action and attach the menu to it # 3. Create the main project action and attach the menu to it
main_icon = QIcon(icon_akce_path) 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.main_action.setMenu(self.plugin_menu)
self.iface.addPluginToMenu(self.menu, self.main_action) self.iface.addPluginToMenu(self.menu, self.main_action)
@@ -120,9 +154,12 @@ class AmcrViewer:
self.tool_button = QToolButton() self.tool_button = QToolButton()
self.tool_button.setMenu(self.plugin_menu) self.tool_button.setMenu(self.plugin_menu)
self.tool_button.setDefaultAction(self.action_download_akce) self.tool_button.setDefaultAction(self.action_download_akce)
self.tool_button.setPopupMode(QToolButton.ToolButtonPopupMode.MenuButtonPopup) self.tool_button.setPopupMode(
QToolButton.ToolButtonPopupMode.MenuButtonPopup
)
# Add the widget directly to the toolbar and store the reference for cleanup # Add the widget directly to the toolbar
# and store the reference for cleanup
self.toolbar_action = self.iface.addToolBarWidget(self.tool_button) self.toolbar_action = self.iface.addToolBarWidget(self.tool_button)
self.first_start = True self.first_start = True
@@ -160,12 +197,38 @@ class AmcrViewer:
dlg = AmcrFilterDialog(typ_dat) dlg = AmcrFilterDialog(typ_dat)
result = dlg.exec() 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: if result == QDialog.DialogCode.Accepted:
filters = dlg.get_filters() filters = dlg.get_filters()
bbox = dlg.get_bbox() bbox = dlg.get_bbox()
komponenty = dlg.get_komponenty() 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() canvas = self.iface.mapCanvas()
load_amcr_data(canvas, bbox, filters, typ_dat, komponenty) 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
)
def open_help(self):
help_url = "https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html"
QDesktopServices.openUrl(QUrl(help_url))
+21832 -13606
View File
File diff suppressed because it is too large Load Diff
+20 -7
View File
@@ -8,11 +8,11 @@ name=AMČR Viewer
qgisMinimumVersion=3.4.0 qgisMinimumVersion=3.4.0
qgisMaximumVersion=4.99.0 qgisMaximumVersion=4.99.0
description=Viewing and downloading the AMČR data. description=Viewing and downloading the AMČR data.
version=1.3.2 version=2.0.0
author=David Spáčil author=David Spáčil
email=spacil@arub.cz email=spacil@arub.cz
about=This plugin is intended for downloading the data (Fieldwork events, Sites and their Components) from the Digiarchive of the Archaeological Map of the Czech Republic (https://digiarchiv.aiscr.cz/). As of now, only publicly accessible data can be downloaded. about=This plugin is intended for downloading the data (Fieldwork events, Sites, and their Components) from the Digital archive of the Archaeological Map of the Czech Republic (https://digiarchiv.aiscr.cz/).
tracker=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/issues tracker=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/issues
repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer
@@ -22,10 +22,26 @@ repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer
hasProcessingProvider=no hasProcessingProvider=no
# Uncomment the following line and add your changelog: # Uncomment the following line and add your changelog:
# changelog= changelog=
Aktualizace na verzi 2.0.0 výrazně mění chování pluginu. Před updatem je doporučeno přečíst si seznam změn níže.
Version 2.0.0 changes the plugin behavior dramatically. It is advised to check the changelog below.
Plný seznam změn v češtině je dostupný zde: https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases/tag/v2.0.0
v2.0.0 (2026-06-05)
* Added warning regarding the feature duplication when loading components to the dialog
* Added Help button to the plugin menu
* Code clean-up
v2.0.0-alpha.4 (2026-06-03)
* Backend filtering of the results based on the component-related filters improvement (plugin not only loads the results from API, it filters them further)
v2.0.0-alpha.23 (2026-05-19)
* Security vulnerabilities fix
v2.0.0-alpha.1 (2026-05-19):
* Attribute fields renamed to be ASCII compliant
* Codelist update; codelist can be recompiled from AMČR API
* Base element changed from Documentation Unit to Component if user asks for components to simplify result filtering
* Plugin now supports logging with an AMČR account and enables the downloading of Events and Sites available to the roles Researcher and higher
# Tags are comma separated with spaces allowed # Tags are comma separated with spaces allowed
tags=python,AMCR,AIS CR,archaeology,PIAN,AMČR tags=python,AMCR,AIS CR,archaeology,PIAN,AMČR,archeologie
homepage=https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html homepage=https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html
category=Vector category=Vector
@@ -41,9 +57,6 @@ deprecated=False
# Check the documentation for more information. # Check the documentation for more information.
# plugin_dependencies= # plugin_dependencies=
# Category of the plugin: Raster, Vector, Database or Web
# category=
# If the plugin can run on QGIS Server. # If the plugin can run on QGIS Server.
server=False server=False