9 Commits

Author SHA1 Message Date
david ee558aa718 Merge pull request #49 from ARUP-CAS/agents/claude/fix-optim
Opravy chyb a optimalizace napříč pluginem

Souhrn revize celého kódu pluginu:

- Opravy pádů: chybějící locale při startu QGIS; životní cyklus
  úlohy aktualizace heslářů (GC reference, zákaz souběhu, message
  boxy nad hlavním oknem).
- Datová korektnost: transformace fallback geometrií WGS-84 do
  S-JTSK, oprava nevalidních polygonů přes makeValid(), normalizace
  hodnot pian_presnost/pian_typ, heslář přístupnosti bez záznamů
  s kódem None, ošetření null hodnot u komponent.
- Přihlašování a stahování: ověření údajů před uložením do správce
  autentizace, zřetelné hlášení síťových chyb a neúplných výsledků,
  jedno parsování JSON odpovědí, úspornější stránkování, guard proti
  souběžným stahováním, logování do QgsMessageLog.
- Metadata a CI: oprava ikony pluginu, akce ve workflow povýšeny na
  aktuální verze a připnuty na commit hash, poznámka o závislosti
  na requests v README.

Otestováno ručně v QGIS dle checklistu v PR (včetně hraničních
případů: výpadek sítě, expirovaná session, špatné heslo, zrušení
úlohy, prázdné výsledky, limit záznamů).
2026-06-12 13:59:46 +02:00
Claude eebd7668a5 Workflow: připnutí GitHub Actions na commit hash + aktualizace verzí
Reakce na CodeQL upozornění 'Unpinned tag for a non-immutable Action'.
Tag (v4/v2) může vlastník akce kdykoli přepsat na jiný commit; připnutí
na hash chrání release pipeline před podvržením závislosti.

Zároveň povýšeno na nejnovější verze: actions/checkout v6.0.3
a softprops/action-gh-release v3.0.0 (jen přechod na Node 24 runtime,
bez změn API; hashe ověřeny přes git ls-remote).
2026-06-12 13:46:47 +02:00
Claude fd11bee274 Ověření přihlašovacích údajů před uložením do správce autentizace
Špatné heslo zadané v přihlašovacím dialogu se dosud uložilo do QGIS
Authentication Manageru a chyba se ukázala až po zavření dialogu.

- Dialog nyní před uložením zkusí přihlášení k API; při neplatných
  údajích se nic neuloží a uživatel může údaje rovnou opravit.
- Pokud je server nedostupný (údaje nelze ověřit), uživatel si může
  zvolit, zda je uložit neověřené – změna hesla na webu u dříve
  uložených údajů tím není dotčena.
- login_to_api nově rozlišuje důvod selhání (LAST_LOGIN_ERROR:
  'auth' × 'network'), aby dialog uměl oba případy odlišit.
- Ověřuje se i změna samotného e-mailu (proti uloženému heslu).
2026-06-12 12:53:28 +02:00
Claude 93ed0ca810 Zřetelné hlášení síťových chyb během stahování
Při výpadku sítě uprostřed stahování se dosud chyba jen zapsala do logu:
stránkování se tiše ukončilo s částečnými daty a smyčka PIAN zkoušela
marně všechny zbývající dávky, takže uživatel skončil u hlášky 'Žádná
data k zobrazení' bez vysvětlení.

- Síťové chyby (requests.RequestException) se v obou smyčkách chytají
  zvlášť a stahování PIAN se po první z nich ihned ukončí.
- Pokud selže už stahování metadat, zobrazí se červená lišta
  'Stahování selhalo: chyba sítě'.
- Pokud se stihla vykreslit část dat, zobrazí se varování, že výsledek
  je neúplný a je třeba stahování zopakovat.
2026-06-12 12:35:51 +02:00
Claude 64ec1ea7fd Metadata, CI a dokumentace
- metadata.txt: icon ukazoval na neexistující download.png – plugin se
  ve správci zásuvných modulů zobrazoval bez ikony; nastaveno na akce.png.
- Workflow: actions/checkout v2→v4 (v2 je deprecated, Node 12)
  a softprops/action-gh-release v1→v2.
- README: poznámka, že knihovna requests nemusí být na Linuxu součástí
  systémové instalace QGIS.
2026-06-12 10:50:04 +02:00
Claude 27e5fe02ac Heslář pristupnost: přeskočení záznamů bez platného kódu
Pokud žádný titulek záznamu neprojde filtrem na jednopísmenný kód,
next() vrátil None a do heslar.csv se zapsal prázdný/None kód, který
se mohl dostat do API filtru jako hodnota 'None:or'. Takové záznamy
se nyní přeskakují.
2026-06-12 10:50:04 +02:00
Claude 46c09c4a09 Robustnější síťová vrstva a stahování dat
- login_to_api: ošetřena výjimka při parsování ne-JSON odpovědi
  (např. HTML chybová stránka za proxy) – dříve propadla až do QGIS.
- _api_get přejmenováno na _api_get_json: tělo odpovědi se parsuje jen
  jednou (kontrola expirované session ho znovu využije), místo dvojího
  json() na každé dávce.
- Stránkování porovnává počet stažených (ne unikátních) záznamů proti
  numFound – stránky plné duplicit už nevyvolávají zbytečné requesty.
- Přidán re-entrancy guard: během běžícího stahování (processEvents
  pumpuje event loop) nelze spustit druhé stahování.
- print() nahrazeno QgsMessageLog (záložka AMČR v panelu Zprávy).
2026-06-12 10:50:03 +02:00
Claude 444d1c4826 Oprava geometrií a filtrování PIAN
- Fallback geometrie z geom_wkt (WGS-84) se nyní transformuje do S-JTSK;
  dříve se souřadnice ve stupních vkládaly přímo do vrstvy EPSG:5514
  a prvek se vykreslil úplně mimo.
- Nevalidní geometrie (např. samoprůniky polygonů) se před zahozením
  zkusí opravit přes makeValid().
- Filtr přesnosti PIAN normalizuje hodnotu pian_presnost (API ji může
  vracet jako jednoprvkový seznam) – dříve mohl tiše zahazovat záznamy.
  Stejná normalizace pro pian_typ kvůli překladu kódu.
- Přístupy ke komponenta_areal/komponenta_obdobi a az_chranene_udaje
  ošetřeny proti hodnotě None (klíč existuje, ale je null).
2026-06-12 10:50:03 +02:00
Claude d9f5d2ae6e Oprava pádů: chybějící locale při startu a životní cyklus úlohy aktualizace heslářů
- amcr_viewer.py: QSettings().value('locale/userLocale') může být None na
  čisté instalaci QGIS – ošetřen fallback na 'en', jinak plugin spadne
  na TypeError hned při načtení.
- amcr_viewer.py: odstraněn nepoužívaný hvězdičkový import resources.
- amcr_dialog.py: úloha aktualizace heslářů se drží v modulové referenci,
  aby GC neuklidil Python wrapper QgsTask před dokončením (známá příčina
  pádů QGIS).
- amcr_dialog.py: tlačítko aktualizace se po spuštění zakáže – nelze už
  spustit několik stahování paralelně přepisujících heslar.csv.
- amcr_dialog.py: závěrečné QMessageBoxy se parentují na hlavní okno QGIS
  místo dialogu, který může být v době dokončení úlohy už zavřený
  (smazaný C++ objekt → pád).
2026-06-12 10:50:03 +02:00
7 changed files with 272 additions and 62 deletions
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
steps: steps:
# 1. Stáhne kód z repozitáře # 1. Stáhne kód z repozitáře
- name: Checkout code - name: Checkout code
uses: actions/checkout@v2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# 2. Vytvoří ZIP (předpokládá, že kód je ve složce 'amcr_viewer') # 2. Vytvoří ZIP (předpokládá, že kód je ve složce 'amcr_viewer')
- name: Zip Plugin - name: Zip Plugin
@@ -22,7 +22,7 @@ jobs:
# 3. Nahraje ZIP k Releasu # 3. Nahraje ZIP k Releasu
- name: Upload Release Asset - name: Upload Release Asset
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
with: with:
files: amcr_viewer.zip files: amcr_viewer.zip
+2
View File
@@ -131,6 +131,8 @@ Layers are only created if the query returns features of the corresponding geome
The plugin is developed in **Python 3** using the **PyQt6** 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.
> **Note:** The `requests` library is bundled with the QGIS installers for Windows and macOS. On Linux (distribution packages), it may need to be installed separately (e.g. `python3-requests`).
### 4.1 File Structure ### 4.1 File Structure
* `amcr_viewer.py`: Entry point; handles GUI integration, toolbar/menu setup, and login flow. * `amcr_viewer.py`: Entry point; handles GUI integration, toolbar/menu setup, and login flow.
+5
View File
@@ -158,6 +158,11 @@ def fetch_set(internal_name, api_set, task=None):
), ),
None None
) )
# Skip records without a valid one-letter code
# a None code would end up in the CSV and later
# in the API filter as the string "None"
if not kod:
continue
dataset.append({ dataset.append({
'Název': nazev, 'Název': nazev,
+78 -10
View File
@@ -7,6 +7,7 @@ from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout,
from qgis.PyQt.QtCore import Qt, QSettings from qgis.PyQt.QtCore import Qt, QSettings
from qgis.core import (QgsTask, QgsApplication, from qgis.core import (QgsTask, QgsApplication,
QgsMessageLog, Qgis, QgsAuthMethodConfig) QgsMessageLog, Qgis, QgsAuthMethodConfig)
from qgis.utils import iface
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, OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST,
TYP_LOKALITY, DRUH_LOKALITY, JISTOTA, TYP_LOKALITY, DRUH_LOKALITY, JISTOTA,
@@ -14,6 +15,12 @@ from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE,
download_heslare, refresh_globals) download_heslare, refresh_globals)
# Keep Python references to running tasks. QgsTaskManager only holds the
# C++ object; without a Python-side reference the wrapper can be garbage
# collected before the task finishes, which crashes QGIS.
_ACTIVE_TASKS = []
class UpdateCodelistsTask(QgsTask): class UpdateCodelistsTask(QgsTask):
def __init__(self, description): def __init__(self, description):
super().__init__(description, QgsTask.CanCancel) super().__init__(description, QgsTask.CanCancel)
@@ -370,21 +377,38 @@ class AmcrFilterDialog(QDialog):
return row_widget return row_widget
def action_update_heslare(self): def action_update_heslare(self):
# Create the task instance # Create the task instance and keep a reference so the Python
# wrapper survives until the task finishes
task = UpdateCodelistsTask("Aktualizace heslářů AMČR") task = UpdateCodelistsTask("Aktualizace heslářů AMČR")
_ACTIVE_TASKS.append(task)
# Re-enable the button regardless of the outcome # Prevent parallel downloads overwriting heslar.csv
task.taskCompleted.connect(lambda: self.btn_update.setEnabled(True)) self.btn_update.setEnabled(False)
task.taskTerminated.connect(lambda: self.btn_update.setEnabled(True))
task.taskCompleted.connect(lambda: QMessageBox.information( # Message boxes are parented to the main window, not to this dialog
self, # the dialog may already be closed (and its C++ object deleted)
"Hotovo", # by the time the minute-long task finishes.
"Hesláře byly úspěšně aktualizovány." parent_win = iface.mainWindow() if iface else None
))
def _cleanup():
if task in _ACTIVE_TASKS:
_ACTIVE_TASKS.remove(task)
try:
self.btn_update.setEnabled(True)
except RuntimeError:
pass # dialog already closed
def on_completed():
_cleanup()
QMessageBox.information(
parent_win,
"Hotovo",
"Hesláře byly úspěšně aktualizovány."
)
# Show the exact error if the task fails # Show the exact error if the task fails
def on_error(): def on_error():
_cleanup()
if task.exception: if task.exception:
# This will show exactly what went wrong (e.g. PermissionError) # This will show exactly what went wrong (e.g. PermissionError)
msg = ( msg = (
@@ -393,8 +417,9 @@ class AmcrFilterDialog(QDialog):
) )
else: else:
msg = "Aktualizace byla zrušena uživatelem." msg = "Aktualizace byla zrušena uživatelem."
QMessageBox.warning(self, "Chyba / Zrušeno", msg) QMessageBox.warning(parent_win, "Chyba / Zrušeno", msg)
task.taskCompleted.connect(on_completed)
task.taskTerminated.connect(on_error) task.taskTerminated.connect(on_error)
QgsApplication.taskManager().addTask(task) QgsApplication.taskManager().addTask(task)
@@ -602,6 +627,39 @@ class LoginDialog(QDialog):
return True return True
def _verify_credentials(self, username: str, password: str) -> bool:
"""
Verify the credentials against the API before saving them.
Returns True if they should be stored: either the login succeeded,
or the server was unreachable and the user chose to keep them
unverified. Wrong credentials are never stored.
"""
# Lazy import to avoid an import cycle
# (amcr_tools imports LoginDialog lazily as well)
from . import amcr_tools
if amcr_tools.login_to_api(username, password):
return True
if amcr_tools.LAST_LOGIN_ERROR == 'network':
answer = QMessageBox.question(
self,
"Server nedostupný",
"Přihlašovací údaje se nepodařilo ověřit server AMČR "
"je nedostupný.\nChcete je přesto uložit (neověřené)?",
QMessageBox.StandardButton.Yes
| QMessageBox.StandardButton.No
)
return answer == QMessageBox.StandardButton.Yes
QMessageBox.warning(
self,
"Neplatné přihlašovací údaje",
"Přihlášení se nezdařilo zkontrolujte e-mail a heslo.\n"
"Údaje nebyly uloženy."
)
return False
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Button actions # Button actions
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -628,6 +686,11 @@ class LoginDialog(QDialog):
if ok: if ok:
if not self._ensure_master_password(): if not self._ensure_master_password():
return return
# Verify the new username against the stored password
if not self._verify_credentials(
username, cfg.config("password", "")
):
return
cfg.setConfig("username", username) cfg.setConfig("username", username)
auth_mgr.updateAuthenticationConfig(cfg) auth_mgr.updateAuthenticationConfig(cfg)
self.accept() self.accept()
@@ -637,6 +700,11 @@ class LoginDialog(QDialog):
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím heslo.") QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím heslo.")
return return
# Verify before prompting for the master password wrong
# credentials must never reach the Authentication Manager
if not self._verify_credentials(username, password):
return
if not self._ensure_master_password(): if not self._ensure_master_password():
return return
+181 -46
View File
@@ -17,6 +17,15 @@ TRANSLATIONS = {}
# None = not logged in (anonymous access) # None = not logged in (anonymous access)
AMCR_SESSION: requests.Session | None = None AMCR_SESSION: requests.Session | None = None
# Reason of the last failed login: 'auth' (wrong credentials),
# 'network' (server unreachable / invalid response) or None
LAST_LOGIN_ERROR: str | None = None
# Re-entrancy guard: the download runs in the main thread and pumps the
# event loop via processEvents(), so the user could otherwise start
# a second download while the first one is still running
_LOADING = False
def _log(msg: str, level=Qgis.MessageLevel.Info): def _log(msg: str, level=Qgis.MessageLevel.Info):
""" """
@@ -33,6 +42,9 @@ def login_to_api(username: str, password: str):
""" """
login_url = "https://digiarchiv.aiscr.cz/api/user/login" login_url = "https://digiarchiv.aiscr.cz/api/user/login"
global LAST_LOGIN_ERROR
LAST_LOGIN_ERROR = None
_log(f"Přihlašuji uživatele: '{username}'") _log(f"Přihlašuji uživatele: '{username}'")
if not username or not password: if not username or not password:
@@ -40,6 +52,7 @@ def login_to_api(username: str, password: str):
"CHYBA: username nebo heslo je prázdné.", "CHYBA: username nebo heslo je prázdné.",
Qgis.MessageLevel.Critical Qgis.MessageLevel.Critical
) )
LAST_LOGIN_ERROR = 'auth'
return None return None
session = requests.Session() session = requests.Session()
@@ -67,6 +80,7 @@ def login_to_api(username: str, password: str):
f"CHYBA přihlášení (API): {body['error']}", f"CHYBA přihlášení (API): {body['error']}",
Qgis.MessageLevel.Critical Qgis.MessageLevel.Critical
) )
LAST_LOGIN_ERROR = 'auth'
return None return None
_log("Přihlášení proběhlo úspěšně.") _log("Přihlášení proběhlo úspěšně.")
@@ -75,12 +89,22 @@ def login_to_api(username: str, password: str):
return session return session
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
_log(f"CHYBA HTTP {e.response.status_code if e.response else '?'}: " status = e.response.status_code if e.response is not None else None
f"{e.response.text[:300] if e.response else 'žádná odpověď'}", _log(f"CHYBA HTTP {status if status else '?'}: "
f"{e.response.text[:300] if e.response is not None else 'žádná odpověď'}",
Qgis.MessageLevel.Critical) Qgis.MessageLevel.Critical)
LAST_LOGIN_ERROR = 'auth' if status in (401, 403) else 'network'
return None return None
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
_log(f"CHYBA sítě: {e}", Qgis.MessageLevel.Critical) _log(f"CHYBA sítě: {e}", Qgis.MessageLevel.Critical)
LAST_LOGIN_ERROR = 'network'
return None
except ValueError:
# Server returned non-JSON (e.g. an HTML error page behind a proxy)
_log("CHYBA: server nevrátil platný JSON: "
f"{response.text[:300]}",
Qgis.MessageLevel.Critical)
LAST_LOGIN_ERROR = 'network'
return None return None
@@ -104,33 +128,40 @@ def _get_session() -> requests.Session | None:
return AMCR_SESSION return AMCR_SESSION
def _api_get(url, params, timeout=30) -> requests.Response: def _api_get_json(url, params, timeout=30) -> dict:
""" """
Performs a GET request. If the API signals an expired login, Performs a GET request and returns the parsed JSON body.
re-authenticates once and retries. If the API signals an expired login, re-authenticates once and retries.
The body is parsed exactly once (the auth check reuses it).
Raises ValueError if the server does not return valid JSON.
""" """
global AMCR_SESSION global AMCR_SESSION
def _is_auth_error(resp: requests.Response) -> bool: def _is_auth_error(resp: requests.Response, body) -> bool:
"""The API returns auth errors with status 200 """The API returns auth errors with status 200
the body must be checked.""" the body must be checked."""
if resp.status_code == 401: if resp.status_code == 401:
return True return True
try: if not isinstance(body, dict):
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 return False
err = str(body.get("error", "")).lower()
return (
"unauthorized" in err
or "not logged" in err
or "session" in err
)
def _parse(resp):
try:
return resp.json()
except ValueError:
return None
session = _get_session() session = _get_session()
resp = (session or requests).get(url, params=params, timeout=timeout) resp = (session or requests).get(url, params=params, timeout=timeout)
body = _parse(resp)
if _is_auth_error(resp): if _is_auth_error(resp, body):
_log("Session vypršela během stahování obnovuji přihlášení...", _log("Session vypršela během stahování obnovuji přihlášení...",
Qgis.MessageLevel.Warning) Qgis.MessageLevel.Warning)
AMCR_SESSION = None # Invalidate the old session AMCR_SESSION = None # Invalidate the old session
@@ -140,6 +171,7 @@ def _api_get(url, params, timeout=30) -> requests.Response:
AMCR_SESSION = login_to_api(username, password) AMCR_SESSION = login_to_api(username, password)
if AMCR_SESSION: if AMCR_SESSION:
resp = AMCR_SESSION.get(url, params=params, timeout=timeout) resp = AMCR_SESSION.get(url, params=params, timeout=timeout)
body = _parse(resp)
else: else:
_log("Opakované přihlášení selhalo.", _log("Opakované přihlášení selhalo.",
Qgis.MessageLevel.Critical) Qgis.MessageLevel.Critical)
@@ -147,7 +179,11 @@ def _api_get(url, params, timeout=30) -> requests.Response:
_log("Přihlašovací údaje nejsou uloženy pokračuji anonymně.", _log("Přihlašovací údaje nejsou uloženy pokračuji anonymně.",
Qgis.MessageLevel.Warning) Qgis.MessageLevel.Warning)
return resp if body is None:
raise ValueError(
f"API nevrátilo platný JSON (HTTP {resp.status_code})"
)
return body
def load_translations(): def load_translations():
@@ -165,7 +201,10 @@ def load_translations():
if r.status_code == 200: if r.status_code == 200:
TRANSLATIONS = r.json() TRANSLATIONS = r.json()
except Exception as e: except Exception as e:
print(f"Error downloading vocabulary: {e}") QgsMessageLog.logMessage(
f"Error downloading vocabulary: {e}",
"AMČR", Qgis.MessageLevel.Warning
)
def tr_code(code): def tr_code(code):
@@ -179,11 +218,12 @@ def tr_code(code):
def komp_projde_filtrem(komp, filter_areal, filter_datace, filters): def komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
areal_id = komp.get('komponenta_areal', {}).get('id', "") # 'or {}' the key may be present with a None value
areal_id = (komp.get('komponenta_areal') or {}).get('id', "")
if filter_areal and areal_id not in filters.get('f_areal', []): if filter_areal and areal_id not in filters.get('f_areal', []):
return False return False
obdobi_id = komp.get('komponenta_obdobi', {}).get('id', "") obdobi_id = (komp.get('komponenta_obdobi') or {}).get('id', "")
if filter_datace and obdobi_id not in filters.get('f_obdobi', []): if filter_datace and obdobi_id not in filters.get('f_obdobi', []):
return False return False
@@ -198,6 +238,16 @@ def load_amcr_data(canvas, bb, filters=None,
2. Fetches metadata and geometries from API 2. Fetches metadata and geometries from API
3. Creates QGIS memory layers and populates them with features 3. Creates QGIS memory layers and populates them with features
""" """
global _LOADING
if _LOADING:
iface.messageBar().pushMessage(
"AMCR",
"Stahování již probíhá, počkejte na jeho dokončení.",
level=Qgis.MessageLevel.Warning
)
return
_LOADING = True
load_translations() load_translations()
# --- 1. COORDINATE TRANSFORMATION --- # --- 1. COORDINATE TRANSFORMATION ---
@@ -257,6 +307,7 @@ def load_amcr_data(canvas, bb, filters=None,
MAX_LIMIT = 20000 # Safety limit to prevent QGIS from freezing MAX_LIMIT = 20000 # Safety limit to prevent QGIS from freezing
seen_ids = set() seen_ids = set()
fetched_total = 0 # All downloaded records incl. duplicates
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
@@ -270,6 +321,10 @@ def load_amcr_data(canvas, bb, filters=None,
filter_areal = "f_areal" in filters if filters else False filter_areal = "f_areal" in filters if filters else False
filter_datace = "f_obdobi" in filters if filters else False filter_datace = "f_obdobi" in filters if filters else False
# Set when a network error interrupts the download the user
# gets an explicit error/warning instead of a silent partial result
network_error = False
# --- API PAGINATION LOOP --- # --- API PAGINATION LOOP ---
while True: while True:
base_params['rows'] = BATCH_DOCS base_params['rows'] = BATCH_DOCS
@@ -279,8 +334,7 @@ def load_amcr_data(canvas, bb, filters=None,
del base_params['page'] del base_params['page']
try: try:
resp_docs = _api_get(url, params=base_params, timeout=30) resp_json = _api_get_json(url, params=base_params, timeout=30)
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', [])
num_found = data.get('numFound', 0) num_found = data.get('numFound', 0)
@@ -288,6 +342,8 @@ def load_amcr_data(canvas, bb, filters=None,
if not batch_docs: if not batch_docs:
break break
fetched_total += len(batch_docs)
# Filter out duplicates and append to main list # Filter out duplicates and append to main list
new_docs = [] new_docs = []
for d in batch_docs: for d in batch_docs:
@@ -297,12 +353,16 @@ def load_amcr_data(canvas, bb, filters=None,
new_docs.append(d) new_docs.append(d)
docs.extend(new_docs) docs.extend(new_docs)
print( QgsMessageLog.logMessage(
f"Strana {current_page} stažena. " f"Strana {current_page} stažena. "
f"Celkem záznamů: {len(docs)} / {num_found}" f"Celkem záznamů: {len(docs)} / {num_found}",
"AMČR", Qgis.MessageLevel.Info
) )
if len(docs) >= num_found: # Compare downloaded (not unique) records against numFound
# pages full of duplicates would otherwise trigger
# needless extra requests
if fetched_total >= num_found:
break break
if len(docs) >= MAX_LIMIT: if len(docs) >= MAX_LIMIT:
iface.messageBar().pushMessage( iface.messageBar().pushMessage(
@@ -315,9 +375,29 @@ def load_amcr_data(canvas, bb, filters=None,
current_page += 1 current_page += 1
QApplication.processEvents() # Keep UI responsive QApplication.processEvents() # Keep UI responsive
except Exception as e: except requests.exceptions.RequestException as e:
print(f"Chyba při stránkování na straně {current_page}: {e}") network_error = True
QgsMessageLog.logMessage(
f"Chyba sítě při stránkování na straně "
f"{current_page}: {e}",
"AMČR", Qgis.MessageLevel.Critical
)
break break
except Exception as e:
QgsMessageLog.logMessage(
f"Chyba při stránkování na straně {current_page}: {e}",
"AMČR", Qgis.MessageLevel.Warning
)
break
if network_error and not docs:
iface.messageBar().pushMessage(
"AMCR",
"Stahování selhalo: chyba sítě. "
"Zkontrolujte připojení k internetu.",
level=Qgis.MessageLevel.Critical
)
return
if not docs: if not docs:
iface.messageBar().pushMessage( iface.messageBar().pushMessage(
@@ -361,8 +441,8 @@ def load_amcr_data(canvas, bb, filters=None,
actions_with_geom += 1 actions_with_geom += 1
# Extract protected fields # Extract protected fields ('or {}' key may hold None)
az_chranene = doc.get('az_chranene_udaje', {}) az_chranene = doc.get('az_chranene_udaje') or {}
chranene = ( chranene = (
doc.get('akce_chranene_udaje') doc.get('akce_chranene_udaje')
or doc.get('lokalita_chranene_udaje') or doc.get('lokalita_chranene_udaje')
@@ -518,13 +598,13 @@ def load_amcr_data(canvas, bb, filters=None,
'ident_cely', 'ident_cely',
"" ""
), ),
'komponenta_areal': komp.get( 'komponenta_areal': (
'komponenta_areal', komp.get('komponenta_areal')
{} or {}
).get('value', ""), ).get('value', ""),
'komponenta_obdobi': komp.get( 'komponenta_obdobi': (
'komponenta_obdobi', komp.get('komponenta_obdobi')
{} or {}
).get('value', ""), ).get('value', ""),
} }
pian_lookup[dj_pian_value].append(komp_meta) pian_lookup[dj_pian_value].append(komp_meta)
@@ -592,11 +672,22 @@ def load_amcr_data(canvas, bb, filters=None,
} }
try: try:
QApplication.processEvents() QApplication.processEvents()
r_pian = _api_get(url, params=params_pian, timeout=15) r_json = _api_get_json(url, params=params_pian, timeout=15)
batch_docs = r_pian.json().get('response', {}).get('docs', []) docs_pian.extend(r_json.get('response', {}).get('docs', []))
docs_pian.extend(batch_docs) except requests.exceptions.RequestException as e:
# Network is down stop immediately instead of
# uselessly retrying every remaining batch
network_error = True
QgsMessageLog.logMessage(
f"Chyba sítě při stahování geometrií PIAN: {e}",
"AMČR", Qgis.MessageLevel.Critical
)
break
except Exception as e: except Exception as e:
print(f"Chyba PIAN: {e}") QgsMessageLog.logMessage(
f"Chyba PIAN: {e}",
"AMČR", Qgis.MessageLevel.Warning
)
# ========================================== # ==========================================
# D) LAYER CREATION (QGIS Memory Layers) # D) LAYER CREATION (QGIS Memory Layers)
@@ -713,6 +804,14 @@ def load_amcr_data(canvas, bb, filters=None,
# Lists to hold features before batch-adding to layers # Lists to hold features before batch-adding to layers
feats_p, feats_l, feats_pt = [], [], [] feats_p, feats_l, feats_pt = [], [], []
# Transform for PIANs that only provide WGS-84 geometry (geom_wkt)
# the target layers are in S-JTSK (EPSG:5514)
xform_wgs_to_sjtsk = QgsCoordinateTransform(
QgsCoordinateReferenceSystem("EPSG:4326"),
QgsCoordinateReferenceSystem("EPSG:5514"),
QgsProject.instance()
)
# --- FEATURE POPULATION --- # --- FEATURE POPULATION ---
for doc in docs_pian: for doc in docs_pian:
try: try:
@@ -733,25 +832,46 @@ def load_amcr_data(canvas, bb, filters=None,
) )
wkt = None wkt = None
wkt_is_wgs = False
if jdata.get('geom_sjtsk_wkt'): if jdata.get('geom_sjtsk_wkt'):
wkt = jdata.get('geom_sjtsk_wkt', {}).get('value') wkt = jdata.get('geom_sjtsk_wkt', {}).get('value')
elif jdata.get('geom_wkt'): elif jdata.get('geom_wkt'):
# Fallback geometry is in WGS-84 and must be
# transformed to S-JTSK before use
wkt = jdata.get('geom_wkt', {}).get('value') wkt = jdata.get('geom_wkt', {}).get('value')
wkt_is_wgs = True
pian_presnost = tr_code(str(doc.get('pian_presnost', ''))) # The API may return the value as a single-item list
pian_typ = tr_code(str(doc.get('pian_typ', ''))) # normalize before comparing against filter codes
raw_presnost = doc.get('pian_presnost', '')
if isinstance(raw_presnost, list):
raw_presnost = raw_presnost[0] if raw_presnost else ''
raw_typ = doc.get('pian_typ', '')
if isinstance(raw_typ, list):
raw_typ = raw_typ[0] if raw_typ else ''
pian_presnost = tr_code(str(raw_presnost))
pian_typ = tr_code(str(raw_typ))
# Final precision filter check # Final precision filter check
if ( if (
filters filters
and filters.get('f_pian_presnost') and filters.get('f_pian_presnost')
and doc.get('pian_presnost') and str(raw_presnost)
not in filters.get('f_pian_presnost') not in filters.get('f_pian_presnost')
): ):
continue continue
if wkt: if wkt:
geom = QgsGeometry.fromWkt(wkt) geom = QgsGeometry.fromWkt(wkt)
if geom.isNull():
continue
if wkt_is_wgs:
geom.transform(xform_wgs_to_sjtsk)
if not geom.isGeosValid():
# Try to repair (e.g. self-intersections)
# instead of silently dropping the feature
geom = geom.makeValid()
if geom.isGeosValid(): if geom.isGeosValid():
t = geom.type() t = geom.type()
target_list = None target_list = None
@@ -821,8 +941,10 @@ def load_amcr_data(canvas, bb, filters=None,
target_list.append(feat) target_list.append(feat)
except Exception as ex: except Exception as ex:
print(f"Chyba při tvorbě feature: {ex}") QgsMessageLog.logMessage(
pass f"Chyba při tvorbě feature: {ex}",
"AMČR", Qgis.MessageLevel.Warning
)
# --- ADDING TO QGIS INTERFACE --- # --- ADDING TO QGIS INTERFACE ---
proj = QgsProject.instance() proj = QgsProject.instance()
@@ -841,7 +963,19 @@ def load_amcr_data(canvas, bb, filters=None,
proj.addMapLayer(l) proj.addMapLayer(l)
added += len(f) added += len(f)
if added > 0: if network_error:
iface.messageBar().pushMessage(
"AMCR",
"Stahování bylo přerušeno chybou sítě "
f"výsledek je neúplný (vykresleno {added} prvků). "
"Zkontrolujte připojení a spusťte stahování znovu.",
level=(
Qgis.MessageLevel.Warning
if added > 0
else Qgis.MessageLevel.Critical
)
)
elif added > 0:
iface.messageBar().pushMessage( iface.messageBar().pushMessage(
"AMCR", "AMCR",
f"Hotovo. Záznamů: {len(docs)} (s geom: {actions_with_geom}). " f"Hotovo. Záznamů: {len(docs)} (s geom: {actions_with_geom}). "
@@ -862,5 +996,6 @@ def load_amcr_data(canvas, bb, filters=None,
level=Qgis.MessageLevel.Critical level=Qgis.MessageLevel.Critical
) )
finally: finally:
# Always restore cursor, even after failure # Always restore cursor and release the guard, even after failure
_LOADING = False
QApplication.restoreOverrideCursor() QApplication.restoreOverrideCursor()
+3 -3
View File
@@ -6,7 +6,6 @@ from qgis.core import Qgis
from .amcr_tools import load_amcr_data, login_to_api from .amcr_tools import load_amcr_data, login_to_api
from .amcr_dialog import AmcrFilterDialog, LoginDialog from .amcr_dialog import AmcrFilterDialog, LoginDialog
from .resources import *
import os.path import os.path
@@ -24,8 +23,9 @@ class AmcrViewer:
self.iface = iface self.iface = iface
self.plugin_dir = os.path.dirname(__file__) self.plugin_dir = os.path.dirname(__file__)
# Determine the user's locale to load appropriate translation files # Determine the user's locale to load appropriate translation files.
locale = QSettings().value('locale/userLocale')[0:2] # The setting may be missing (None) on a fresh QGIS install.
locale = str(QSettings().value('locale/userLocale') or 'en')[0:2]
locale_path = os.path.join( locale_path = os.path.join(
self.plugin_dir, self.plugin_dir,
'i18n', 'i18n',
+1 -1
View File
@@ -47,7 +47,7 @@ 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
icon=download.png icon=akce.png
# experimental flag # experimental flag
experimental=False experimental=False