Compare commits

...
29 Commits
Author SHA1 Message Date
david-spacil 6e95973296 fix: váha prvku komponent počítaná z komponent prošlých filtrem (#55) (#83)
* fix: váha prvku komponent počítaná z komponent prošlých filtrem (#55)

Váha prvku_vaha = 1/n se dosud počítala ze všech komponent dokumentační
jednotky ještě před filtrem na období a areál, takže při aktivním filtru
váhy prvků jedné DJ nedávaly v součtu 1 (DJ se 4 komponentami, filtru
vyhoví 1 → váha 0,25 místo 1).

- nová funkce _component_entries: nejdřív vyfiltruje komponenty, pak
  přidělí váhu 1/n z těch, které prošly; DJ bez komponent má váhu 1
- smoke test vaha_komponent (4×0,25; 1 ze 4 → 1; 2 ze 3 → 2×0,5; bez
  komponent → 1)
- README: pole prvek_vaha v tabulce atributů komponent
- changelog v2.2.0 doplněn (bez povýšení verze)
- OpenSpec změna openspec/changes/fix-component-feature-weight

Ověřeno: živá data (Praha, novověk) – 0 z 1204 DJ se součtem vah ≠ 1,
původní kód 921; check_sources, bandit, detect-secrets, flake8, ruff,
pyqgis4-checker, smoke test v qgis/qgis:ltr i :stable.

Implementace připravena AI (Claude, subagent), ověřena a zkontrolována.

* openspec: archivovat fix-component-feature-weight (#55)

Ruční test v QGIS ověřen správcem: akce i lokality s Načíst komponenty,
bez filtru i s filtrem období – váhy prvků jedné DJ dávají součet 1 a
počítají se jen z vyfiltrovaných komponent. Úkol 3.2 odškrtnut, změna
archivována přes openspec archive --skip-specs.

Připraveno s pomocí AI (Claude).
2026-10-02 17:17:28 +02:00
david-spacil 05e62bd23c Merge pull request #81 from ARUP-CAS/main
Update version/v2.2.0
2026-10-02 12:49:27 +02:00
david-spacil 7c0401c11b Merge pull request #78 from ARUP-CAS/dependabot/github_actions/main/softprops/action-gh-release-3.0.3
ci: bump softprops/action-gh-release from 3.0.0 to 3.0.3
2026-10-02 12:44:51 +02:00
david-spacil b7090b7549 Merge pull request #77 from ARUP-CAS/dependabot/github_actions/main/actions/checkout-7.0.1
ci: bump actions/checkout from 6.0.3 to 7.0.1
2026-10-02 12:40:44 +02:00
david-spacil 74090a80c6 fix: před stahováním ověřit přihlášení přes islogged (#72) (#80)
* fix: před stahováním ověřit přihlášení přes islogged

Server při vypršelé session nevrací chybu, ale tiše odpoví jako
anonymnímu uživateli (jen přístupnost A). Plugin proto před stahováním
volá /api/user/islogged; při 'nologged' se jednou znovu přihlásí
z uložených údajů, a když to nejde, varuje v liště, že stahuje anonymně.

- amcr_tools: _check_islogged, _ensure_logged_in, volání v load_amcr_data
- smoke test: 7 offline scénářů stavu přihlášení
- README; changelog v2.2.0 v metadata.txt
- openspec/changes/fix-session-expiry-islogged: návrh, spec, design, úkoly

Closes #72. Připraveno s pomocí AI (Claude), ručně zkontrolováno.

* feat: odebrání přihlašovacích údajů uživatele i odhlásí

Dosud zůstala přihlášená session v paměti až do restartu QGIS, takže
se po „Odebrat uložené přihlašovací údaje“ dál stahovalo jako přihlášený.
Nově se session odhlásí na serveru (GET /api/user/logout) a zahodí;
když server neodpoví, zahodí se aspoň lokálně.

- amcr_tools: logout_from_api; amcr_dialog: volání v _forget_credentials
- smoke test: odhlášení, chyba sítě, bez session
- README, changelog v2.2.0, OpenSpec (požadavek + úkoly 2b; 3.3 ověřeno)

Připraveno s pomocí AI (Claude), ručně zkontrolováno.

* chore: archivovat OpenSpec změnu fix-session-expiry-islogged

Všechny úkoly hotové (2b.2 – odhlášení – ověřeno ručně v QGIS),
archivováno s --skip-specs (stupeň change-tracked, bez openspec/specs/).

Připraveno s pomocí AI (Claude), ručně zkontrolováno.
2026-10-02 12:37:31 +02:00
david-spacil e5b0716fd6 Merge pull request #79 from ARUP-CAS/agents/claude/sync-main-do-v2.2.0
Srovnat version/v2.2.0 s main + opravy BOM, E501, CITATION
2026-10-02 11:52:05 +02:00
david-spacil 2c5146aa34 fix: BOM v amcr_tools.py, E501 a verze v CITATION.cff
- amcr_tools.py: odstraněn UTF-8 BOM zanesený v 49fbd91 – kvůli němu
  pyqgis4-checker soubor vůbec nezkontroloval a check_sources selhal
- check_version_bump.py: zalomen řádek nad 79 znaků (ruff E501)
- CITATION.cff: version 2.2.0 podle metadata.txt; date-released se
  posune v den vydání

Připraveno s pomocí AI (Claude), ručně zkontrolováno.
2026-10-02 11:23:20 +02:00
david-spacil 3d58168ac9 Merge main do version/v2.2.0
Konflikt jen v amcr_viewer/metadata.txt: ponechána version=2.2.0, do changelogu doplněny položky v2.1.4 a v2.1.3 z main.
2026-10-02 11:22:37 +02:00
dependabot[bot] 1a1ae606c6 ci: bump softprops/action-gh-release from 3.0.0 to 3.0.3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 3.0.0 to 3.0.3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/b4309332981a82ec1c5618f44dd2e27cc8bfbfda...efb35369e0ad2afab669f228072c1b0d510eae64)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 09:15:26 +00:00
dependabot[bot] 25ba50b889 ci: bump actions/checkout from 6.0.3 to 7.0.1
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.3 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/df4cb1c069e1874edd31b4311f1884172cec0e10...3d3c42e5aac5ba805825da76410c181273ba90b1)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-02 09:15:19 +00:00
david-spacil 72f5985f89 chore: přidat .editorconfig a .gitattributes (#76)
- .editorconfig: UTF-8 bez BOM, LF, koncový nový řádek; výjimka pro
  heslar.csv (BOM + CRLF, zapisuje ho plugin)
- .gitattributes: text=auto eol=lf, heslar.csv -text, PNG/ZIP binary

Renormalizace neměnila žádný sledovaný soubor (ověřeno v kopii repa).
Vzor z aiscr-management (quality_baseline/foundations).

Připraveno s pomocí AI (Claude), ručně zkontrolováno.
2026-10-02 11:14:54 +02:00
david-spacil 5117247fb7 ci: Dependabot pro GitHub Actions (#75)
Týdenní kontrola akcí připnutých na SHA; jen ekosystém github-actions
(plugin nemá pip/npm manifest, piny nástrojů se drží ručně).
Vzor z aiscr-management.

Připraveno s pomocí AI (Claude), ručně zkontrolováno.
2026-10-02 11:14:39 +02:00
david-spacil d14b2854f7 chore: zavést OpenSpec ve stupni change-tracked (#74)
- openspec/config.yaml podle seedu z aiscr-management (stupeň
  change-tracked, vestavěné schéma spec-driven, bez openspec/specs/)
- AGENTS.md: sekce OpenSpec (kdy zakládat změnu, postup, archivace
  s --skip-specs), artefakty změny ve stejném PR jako implementace
- CI: job OpenSpec validuje artefakty (CLI připnuté na 1.14.0)

Připraveno s pomocí AI (Claude), ručně zkontrolováno.
2026-10-02 11:14:18 +02:00
david-spacil dfb3a9ef9b chore: odstranit resources.py a konfiguraci flake8 z balíčku (v2.1.4) (#71)
* chore: odstranit resources.py a konfiguraci flake8 z balíčku

Scanner na plugins.qgis.org označil plugin jako „Validated
(configured)“ kvůli amcr_viewer/.flake8. Ten potlačoval jen stylové
nálezy (E302, E305, E501) ve vygenerovaném resources.py, který se
nikde neimportuje – ikony se načítají přímo z PNG. resources.py
repozitář pluginů nevyžaduje (validator.py v QGIS-Django, PyQGIS
cookbook: „optional“).

- Smazán amcr_viewer/resources.py a amcr_viewer/.flake8.
- CI: flake8 běží s --isolated (výchozí pravidla jako scanner);
  kontrola ZIPu místo .flake8 hlídá povinný LICENSE.
- check_sources.py: žádné skryté soubory v balíčku, ani config
  soubory scanneru.
- pyproject.toml, README.md, AGENTS.md: odstraněny odkazy na oba
  soubory, popsán postup bez konfigurace.

Ověřeno: check_sources, flake8 7.3.0, ruff 0.16.5, bandit 1.9.4,
detect-secrets bez nálezů; pyqgis4-checker čistý; smoke test v QGIS
3.44.15 (Qt 5) i 4.2.3 (Qt 6); ZIP bez skrytých souborů.

Připraveno s pomocí AI (Claude).

* Verze 2.1.4

Povýšena verze v metadata.txt (+ changelog) a CITATION.cff po odstranění resources.py a konfigurace flake8.

Připraveno s pomocí AI (Claude).
2026-10-01 18:45:48 +02:00
david-spacil 48e0e2a3b5 docs: bump verze i v CITATION.cff + kontrola v CI (#69)
Verze a datum releasu v CITATION.cff se povyšují ručně spolu
s metadata.txt a snadno se zapomenou (u v2.1.3 dorovnáno dodatečně
v 5841fc1).

- AGENTS.md: pravidlo povýšit CITATION.cff (version, date-released)
  spolu s metadata.txt; doplněn popis jobu Balíček pluginu.
- Šablona PR: bod kontrolního seznamu zahrnuje CITATION.cff.
- CI (Balíček pluginu): krok ověří, že verze v CITATION.cff
  odpovídá metadata.txt.

Připraveno s pomocí AI (Claude).
2026-10-01 15:57:53 +02:00
david-spacil 5841fc15de Update CITATION.cff 2026-10-01 15:34:52 +02:00
david-spacil f8d938e353 fix: hesláře osob se po aktualizaci tiše vyprázdní (#68)
Digiarchiv v4.1.0 (Solr 10, json.nl=arrarr) vrací položky facet jako
dvojice ["hodnota", počet] místo objektů {"name": ...}. fetch_set četl
r["name"], spadl na TypeError a hesláře vedoucích a nálezců se uložily
prázdné.

- _facet_name() přijímá oba formáty facet (starý i nový).
- Selhání setu vrací prázdný seznam i při přerušeném stránkování, ať
  se neuloží jen část hesláře.
- download_heslare() ponechá u selhaného nebo prázdného setu předchozí
  hodnoty z heslar.csv a vrátí seznam selhaných setů.
- Dialog při částečném selhání zobrazí varování místo „Hotovo“.
- Verze 2.1.3 + changelog.

Ověřeno proti produkčnímu API: vedoucí 2497, nálezci 426, ostatní
hesláře beze změny; simulované selhání ponechá předchozí hodnoty.

Refs #67, #66
Připraveno s pomocí AI (Claude).
2026-10-01 15:33:41 +02:00
david-spacilandClaude Sonnet 5 222fe03bb8 ci: kontrola bumpu verze a changelogu při PR z version/* do main
Nová kontrola v release_pr_checks.yml ověří na PR směřujícím do main,
že verze v metadata.txt šla nahoru, první položka changelogu patří
nové verzi, CITATION.cff má stejnou verzi a posunuté date-released,
a že název release větve (version/vX.Y.Z) odpovídá verzi v metadata.txt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jT6Xv6FP2vWgBwLM57sc6
2026-09-02 17:16:54 +02:00
david-spacil 236907b9df metadata update 2026-09-02 17:16:54 +02:00
david-spacil 49fbd91cea feat: přidáno pole s váhou prvku
v případě možnosti stahování komponent bylo přidáno do tabulek s geometrií pole
`Váha prvku`, vypočtené jako 1/*n*, kde *n* představuje počet komponent na jednu
dokumentační jednotku. Změna by měla být užitečná v případě počítání heatmap.
2026-09-02 17:16:54 +02:00
david-spacilandClaude Opus 5 62d9ebd6f0 feat: verze 2.2.0 — sjednocení popisků filtrů (#64)
* fix: sjednocení popisků filtrů ve filtračním dialogu

Dvě nekonzistence, které vyplavalo psaní dokumentace:

- "Lokalita - stav dochování" mělo jako jediné ze čtyř polí lokality
  obyčejný spojovník místo pomlčky.
- Filtr "Specifikace nálezu" plní sloupec, jehož alias je "Materiál",
  takže uživatel filtroval podle jednoho názvu a výsledek dostal pod
  jiným. Sjednoceno na "Materiál". Klíč v cache (specifikace)
  i parametr API (f_specifikace) zůstávají beze změny, mění se jen
  popisek v UI.

Matice filtrů v README je srovnaná ve stejném commitu, aby se
dokumentace a kód nikdy nerozešly.

Ověřeno výpisem group boxů z dialogu v headless QGIS 4.2.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GptqiE8kzeUUsx6h9apwiP

* chore: verze 2.2.0 a changelog

Odkaz "Plný seznam změn" mířil na tag v2.1.1, přestože changelog
pokračuje dál; přesměrován na přehled releasů. Vratná změna.

CITATION.cff zůstává na v2.1.2 — popisuje poslední vydanou verzi
a povyšuje se až při vydání, kdy z něj Zenodo generuje záznam.

Datum 2026-09-02 je datum přípravy; při vydání ho může být potřeba
posunout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GptqiE8kzeUUsx6h9apwiP

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:16:54 +02:00
david-spacilandClaude Opus 5 2b783cd13b docs: CLAUDE.md importuje AGENTS.md přes @
Textový odkaz obsah AGENTS.md do kontextu nenačte, takže pravidla pro AI
agenty (mj. zákaz pushe bez výslovného schválení) v něm nebyla vidět.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013jT6Xv6FP2vWgBwLM57sc6
2026-09-02 17:16:34 +02:00
david-spacil 06036f2af0 Merge pull request #63 from ARUP-CAS/agents/claude/readme
docs: README podle aktuálního stavu kódu
2026-09-02 10:16:36 +02:00
david-spacilandClaude Opus 5 8fa81394f2 docs: README podle aktuálního stavu kódu
README popisovalo stav před v2.1.1 a chyběla v něm celá jedna entita.

- Doplněny samostatné nálezy (PAS) včetně vlastní atributové tabulky,
  filtry podle data a "Pouze projektové akce".
- Filtry rozepsány do matice dostupnosti podle entity — dosud byly
  uvedené jako jeden společný seznam, což neplatí ani pro Organizaci,
  ani pro PIAN – přesnost a Areál.
- Doplněno CRS výstupních vrstev (EPSG:5514), které chybělo úplně.
- Doplněno, že hesláře vznikají i z facet Digiarchivu (f_vedouci,
  f_nalezce), nejen z OAI-PMH.
- Upozornění, že PIAN – přesnost je jediný předvyplněný filtr, takže
  nedotčený dialog už záznamy lokalizované katastrem vynechává.
- Opraveno tvrzení "developed using the PyQt6 framework": kód importuje
  přes qgis.PyQt, což je právě důvod, proč běží na Qt5 i Qt6.
- Nová sekce Development s tabulkou CI jobů a příkazy pro lokální běh.

Popisky filtrů odpovídají stavu na main. Jejich sjednocení ("Materiál",
pomlčka u stavu dochování) jde samostatně do version/v2.2.0, takže se
dokumentace nedostane před kód.

Anglická terminologie převzata ze slovníku Digiarchivu
(api/assets/i18n/en.json), ne z nápovědy — ta o PAS ani datových
filtrech nic neví. Matice filtrů ověřena výpisem dialogu v headless
QGIS, názvy polí a aliasy porovnány skriptem proti amcr_tools.py
(43 názvů a 43 aliasů, všechny doslova sedí).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GptqiE8kzeUUsx6h9apwiP
2026-09-02 09:38:50 +02:00
david-spacilandClaude Opus 5 1d5220ee61 chore: aktualizace CITATION.cff na v2.1.2
Soubor zůstal na v2.0.0 z 5. 6. 2026, ačkoli Zenodo má v2.1.2
z 1. 9. 2026. DOI je koncepční, ukazuje vždy na poslední verzi,
a proto se nemění.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GptqiE8kzeUUsx6h9apwiP
2026-09-02 09:38:50 +02:00
david-spacil 70aba60d12 Merge pull request #62 from ARUP-CAS/agents/claude/ci-kontroly
ci: kontroly kvality kódu při každém PR
2026-09-01 23:49:50 +02:00
david-spacilandClaude Opus 5 3591ac4c53 ci: kontrola kvality kódu při každém PR
Workflow code_quality.yml pouští při každém PR a při pushi do main
kontroly, které se dosud dělaly ručně, ve čtyřech jobech:

- Lint a bezpečnost: check_sources.py, bandit, detect-secrets, flake8,
  ruff. Bandit a detect-secrets jsou tytéž kontroly, které blokují
  schválení na plugins.qgis.org.
- Kompatibilita s Qt6: pyqgis4-checker v dockeru. Skript končí kódem 0,
  i když něco najde, výsledek je jen v logu; job proto ověřuje, že log
  obsahuje pouze hlavičku.
- Smoke test: smoke_test.py v qgis/qgis:ltr (3.44, Qt5) i
  qgis/qgis:stable (4.x, Qt6). Tagy se posouvají schválně, aby bylo
  vidět, že plugin drží krok s aktuálním QGISem.
- Balíček pluginu: sestaví amcr_viewer.zip stejně jako release
  workflow, ověří, že v něm je metadata.txt, __init__.py i .flake8
  a nejsou v něm git soubory, a přiloží ho jako artefakt běhu.
  Recenzent ho nainstaluje přes Install from ZIP bez ručního balení.

detect-secrets se pouští s --all-files. Bez toho prohledá jen soubory
sledované gitem a o nesledovaném souboru mlčí, což vypadá jako čistý
výsledek.

Verze nástrojů jsou napevno. Bez pinu by se výsledek měnil s každým
vydáním ruffu, které rozšíří výchozí sadu pravidel.

CodeQL a GitGuardian běží zvlášť, nastavené na úrovni organizace,
a schválně se tu neduplikují.

AGENTS.md popisuje, jak totéž pustit lokálně, a tři místa, kde kontroly
tiše lžou (výstupní kód pyqgis4-checkeru, detect-secrets bez
--all-files, umístění config souborů pro scanner).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhQyc1uQFcpkezLwM8if6T
2026-09-01 23:43:53 +02:00
david-spacilandClaude Opus 5 f4b1c39e6c test: kontrola zdrojáků a smoke test v QGIS
tests/check_sources.py hlídá pravidla, která nepotřebují QGIS a jejichž
porušení je tiché:

- UTF-8 BOM na začátku .py souboru. Pythonu ani QGISu nevadí, ale
  oficiální pyqgis4-checker na něm spadne a soubor vůbec nezkontroluje,
  takže rozbitý soubor vypadá čistě. Stalo se tady už dvakrát.
- přímý import z PyQt5/PyQt6 mimo shim qgis.PyQt
- spustitelná práva, skryté soubory a podezřelé typy souborů, které
  hlásí analýza souborů na plugins.qgis.org

tests/smoke_test.py načte plugin ve skutečném QGIS, přečte scoped
enumy, vytvoří UpdateCodelistsTask a všechny tři filtrační dialogy
a ověří doplňování hraničního data. Běží offline a bez X serveru
(QT_QPA_PLATFORM=offscreen), takže nezávisí na dostupnosti API.

Očekávaná hodnota filtru je v testu napsaná natvrdo, ne přes
DATE_OPEN_TO. Porovnání proti konstantě z modulu dokazuje jen to, že se
modul shodne sám se sebou, a prošlo i s rozbitým sentinelem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhQyc1uQFcpkezLwM8if6T
2026-09-01 23:43:33 +02:00
david-spacilandClaude Opus 5 bcb0bc2c86 chore: konfigurace lintů
Bez explicitní konfigurace se výsledek lintů mění sám od sebe: výchozí
sada pravidel ruffu se liší verzi od verze a flake8 hlásí generovaný
resources.py, který se ručně neformátuje.

Konfigurace je rozdělená schválně. amcr_viewer/.flake8 leží vedle
metadata.txt, protože scanner na plugins.qgis.org hledá config soubory
jen v kořeni balíčku uvnitř ZIPu; stejná pravidla tak platí v CI,
lokálně i při uploadu. Konfigurace ruffu je v kořenovém pyproject.toml,
ruff se do balíčku pluginu nedistribuuje.

Ignorovaná pravidla mají v pyproject.toml odůvodnění: UP009 (hlavička
utf-8 je konvence šablony Plugin Builderu), BLE001 (except Exception je
záměr, výjimka nesmí propadnout do QGISu), SIM103 a SIM105 (čitelnost).

Dvě opravy, které z konfigurace plynou:
- open(path, 'r', encoding=...) -> open(path, encoding=...)  [UP015]
- zbytečné else: po return v get_komponenty()                [RET505]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WhQyc1uQFcpkezLwM8if6T
2026-09-01 23:43:20 +02:00
32 changed files with 2306 additions and 340 deletions

No files matched your search

+24
View File
@@ -0,0 +1,24 @@
# Jednotné kódování a konce řádků napříč editory.
# Vzor: aiscr-management (quality_baseline/foundations/editorconfig.ini)
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
# Zdrojáky bez BOM – pyqgis4-checker na BOM spadne (viz AGENTS.md)
[*.py]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false
# Heslář zapisuje plugin s BOM a CRLF (kvůli Excelu) – needitovat ručně
[amcr_viewer/codelists/heslar.csv]
charset = utf-8-bom
end_of_line = crlf
insert_final_newline = unset
trim_trailing_whitespace = unset
+10
View File
@@ -0,0 +1,10 @@
# Textové soubory v repozitáři s LF; vzor: aiscr-management
# (quality_baseline/foundations/gitattributes.fragment)
* text=auto eol=lf
# Heslář generuje plugin (BOM + CRLF kvůli Excelu) – ukládat bajt po bajtu
amcr_viewer/codelists/heslar.csv -text
# Binární soubory
*.png binary
*.zip binary
+16
View File
@@ -0,0 +1,16 @@
# Dependabot hlídá jen GitHub Actions: akce jsou ve workflow připnuté
# na SHA s komentářem verze a Dependabot umí obojí povýšit naráz.
# Plugin nemá pip/npm manifest a verze nástrojů (ruff, flake8 …) se
# v workflow drží napevno vědomě – viz AGENTS.md.
# Vzor: aiscr-management/.github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
target-branch: main
commit-message:
prefix: "ci"
+1 -1
View File
@@ -25,7 +25,7 @@ Nepotřebné body můžeš smazat. Komentáře (<!-- ... -->) se v PR nezobrazuj
## Kontrolní seznam
- [ ] Změny jsou v souladu se stylem projektu (viz `AGENTS.md`)
- [ ] Při změně funkcí povýšena verze v `amcr_viewer/metadata.txt` a doplněn `changelog`
- [ ] Při změně funkcí povýšena verze v `amcr_viewer/metadata.txt` (+ `changelog`) a v `CITATION.cff` (`version`, `date-released`)
- [ ] Otestováno v QGIS (min. podporovaná verze 3.44)
- [ ] PR míří do správné cílové větve
- [ ] Větev odpovídá konvenci (`feat/ fix/ docs/ chore/<téma>`, AI `agents/<jméno>/<téma>`)
+203
View File
@@ -0,0 +1,203 @@
name: Code Quality
# Pouští při každém PR tytéž kontroly, které se dosud dělaly ručně:
#
# * co spouští plugins.qgis.org při uploadu (bandit, detect-secrets,
# flake8, analýza souborů) – https://plugins.qgis.org/docs/security-scanning
# * oficiální kontrolu kompatibility s Qt6 (pyqgis4-checker)
# * skutečné načtení pluginu v QGIS 3.44 (Qt5) i v QGIS 4 (Qt6)
# * sestavení ZIPu, který si recenzent stáhne a nainstaluje přímo z PR
#
# CodeQL a GitGuardian běží zvlášť, nastavené na úrovni organizace –
# tady se schválně neduplikují.
on:
pull_request:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
env:
# Verze se drží napevno, aby se výsledek nezměnil sám od sebe. Výchozí
# sada pravidel ruffu se mezi verzemi mění; povýšení je vědomý krok.
BANDIT: bandit==1.9.4
DETECT_SECRETS: detect-secrets==1.5.0
FLAKE8: flake8==7.3.0
RUFF: ruff==0.16.5
OPENSPEC: 1.14.0
jobs:
# --------------------------------------------------------------------
# 1. Statické kontroly – běží první, protože trvají desítky sekund
# --------------------------------------------------------------------
lint:
name: Lint a bezpečnost
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install tools
run: pip install "$BANDIT" "$DETECT_SECRETS" "$FLAKE8" "$RUFF"
# Hygiena repozitáře: BOM, přímé importy z PyQt5/PyQt6, spustitelná
# práva a podezřelé typy souborů. Padá jako první, protože BOM
# zneviditelní soubor pro kontrolu níž.
- name: Source hygiene
run: python3 tests/check_sources.py
# Blokující kontrola na plugins.qgis.org
- name: Bandit
run: bandit -r amcr_viewer/
# Blokující kontrola na plugins.qgis.org.
# --all-files je podstatné: bez něj detect-secrets prohledá jen
# soubory sledované gitem a nesledovaný soubor tiše přeskočí.
- name: detect-secrets
run: |
detect-secrets scan --all-files amcr_viewer/ > vysledek.json
python3 -c "
import json, sys
nalezy = json.load(open('vysledek.json'))['results']
if nalezy:
print(json.dumps(nalezy, indent=2))
sys.exit(1)
print('detect-secrets: bez nálezů')
"
# Na plugins.qgis.org je informativní, tady blokuje. Bez konfigurace,
# tj. se stejnými výchozími pravidly jako scanner.
- name: Flake8
run: flake8 --isolated amcr_viewer/
# Nad rámec plugins.qgis.org; konfigurace v pyproject.toml
- name: Ruff
run: ruff check .
# --------------------------------------------------------------------
# 2. Kompatibilita s Qt6 – oficiální skript z QGISu
# --------------------------------------------------------------------
qt6:
name: Kompatibilita s Qt6
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Pozor: skript končí kódem 0 i když něco najde, výsledek je jen
# v logu. Prázdný log (samotná hlavička) znamená čisto.
- name: pyqgis4-checker
run: |
docker run --rm --user "$(id -u):$(id -g)" \
--workdir /workspace/ -v "$PWD:/workspace/" \
ghcr.io/qgis/pyqgis4-checker:main-ubuntu \
pyqt5_to_pyqt6.py --dry_run --logfile /workspace/pyqt6_checker.log .
echo "--- pyqt6_checker.log ---"
cat pyqt6_checker.log
nalezu=$(grep -v '=== dry_run mode | Start Logs ===' pyqt6_checker.log | wc -l)
if [ "$nalezu" -ne 0 ]; then
echo "::error::pyqgis4-checker nahlásil nálezy, viz log výše"
exit 1
fi
# --------------------------------------------------------------------
# OpenSpec – artefakty změn v openspec/ musí projít validací
# --------------------------------------------------------------------
openspec:
name: OpenSpec
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Verze CLI napevno – formát validace se mezi verzemi mění.
# Node je v ubuntu-latest předinstalovaný.
- name: openspec validate
run: |
npx --yes @fission-ai/openspec@${{ env.OPENSPEC }} \
validate --all --strict --no-interactive
# --------------------------------------------------------------------
# 3. Načtení pluginu ve skutečném QGIS, v obou podporovaných verzích
# --------------------------------------------------------------------
qgis:
name: Smoke test (QGIS ${{ matrix.qgis }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# ltr = 3.44 na Qt5, stable = 4.x na Qt6. Tagy se posouvají
# schválně: chceme vědět, že plugin drží krok s aktuálním QGISem.
qgis: [ltr, stable]
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Smoke test
run: |
docker run --rm -v "$PWD:/work:ro" -w /work \
--user "$(id -u):$(id -g)" -e HOME=/tmp \
"qgis/qgis:${{ matrix.qgis }}" python3 tests/smoke_test.py
# --------------------------------------------------------------------
# 4. ZIP k instalaci – stejný postup jako v release_plugin.yml
# --------------------------------------------------------------------
package:
name: Balíček pluginu
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Zip plugin
run: zip -r amcr_viewer.zip amcr_viewer -x "*.git*"
# Verze v CITATION.cff se povyšuje ručně spolu s metadata.txt a snadno
# se zapomene – tag by pak nesl v citaci jinou verzi než plugin
- name: Verify CITATION.cff version
run: |
plugin=$(sed -n 's/^version=//p' amcr_viewer/metadata.txt \
| tr -d "\r\"' ")
citace=$(sed -n 's/^version://p' CITATION.cff \
| tr -d "\r\"' ")
echo "metadata.txt: '$plugin', CITATION.cff: '$citace'"
if [ -z "$plugin" ] || [ "$plugin" != "$citace" ]; then
echo "::error file=CITATION.cff::verze '$citace' neodpovídá" \
"metadata.txt ('$plugin')"
exit 1
fi
# Kontrola obsahu ZIPu: povinné soubory jsou uvnitř, git soubory ne.
- name: Verify archive contents
run: |
unzip -l amcr_viewer.zip
for soubor in amcr_viewer/metadata.txt amcr_viewer/__init__.py \
amcr_viewer/LICENSE; do
unzip -l amcr_viewer.zip | grep -qF " $soubor" \
|| { echo "::error::v ZIPu chybí $soubor"; exit 1; }
done
if unzip -l amcr_viewer.zip | grep -qE '\.git'; then
echo "::error::v ZIPu jsou git soubory"
exit 1
fi
- name: Upload artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: amcr_viewer-plugin
path: amcr_viewer.zip
if-no-files-found: error
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
steps:
# 1. Stáhne kód z tagu, který běh spustil
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# 2. Vytvoří ZIP (předpokládá, že kód je ve složce 'amcr_viewer')
- name: Zip Plugin
@@ -34,7 +34,7 @@ jobs:
# 3. Založí koncept releasu i s přílohou
# Tagy s pomlčkou (v2.0.0-alpha.1) se označí jako pre-release.
- name: Create draft release with asset
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: amcr_viewer.zip
draft: true
+41
View File
@@ -0,0 +1,41 @@
name: Release PR Checks
# Hlídá bump verze a changelog v okamžiku, kdy se release větev
# version/vX.Y.Z slévá do main – tady poprvé jde skutečně o to, co se
# vydá, a zapomenutý bump by se jinak dostal rovnou k tagu a releasu.
#
# Kontroly viz tests/check_version_bump.py: verze v metadata.txt musí jít
# nahoru, první položka changelogu musí patřit nové verzi, CITATION.cff
# musí mít stejnou verzi (a posunuté date-released) a název větve musí
# odpovídat verzi, kterou nese.
on:
pull_request:
branches:
- main
permissions:
contents: read
jobs:
version-bump:
name: Kontrola verze a changelogu
runs-on: ubuntu-latest
if: startsWith(github.head_ref, 'version/')
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Verze a changelog
run: |
python3 tests/check_version_bump.py \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.head_ref }}"
+91 -6
View File
@@ -17,9 +17,36 @@ Zdroj dat: https://digiarchiv.aiscr.cz/ · Nápověda: https://amcr-help.aiscr.c
Tento repozitář je jedním ze **sourozeneckých repozitářů** ekosystému AIS CR.
Centrální governance a AI konfigurace spravuje hub **`aiscr-management`**; konvence
v tomto souboru jsou s tímto vzorem sladěné a zjednodušené pro potřeby jednoho
QGIS pluginu. Těžkou mašinerii hubu (složka `.agents/`, OpenSpec, sync skripty,
multi-assistant generování) tento repozitář **záměrně nepřebírá**. Při širších
otázkách governance má přednost vzor z `aiscr-management`.
QGIS pluginu. Z hubu přebírá **OpenSpec** ve stupni `change-tracked` (viz
níže). Ostatní mašinerii hubu (složka `.agents/`, sync skripty, vlastní
schémata OpenSpec, multi-assistant generování) tento repozitář **záměrně
nepřebírá**. Při širších otázkách governance má přednost vzor
z `aiscr-management`.
## OpenSpec
Repozitář používá OpenSpec ve stupni **`change-tracked`**: plánovací
artefakty změn (`proposal.md`, delta spec, `design.md`, `tasks.md`) žijí
v `openspec/changes/<slug>/`, trvalé specifikace v `openspec/specs/` se
**neudržují**. Stupeň a kontext pro agenty jsou v `openspec/config.yaml`;
změna stupně se dělá vědomě společně s hubem, ne v rámci rozpracované práce.
- **Kdy založit změnu:** práce, která mění chování (co uživatel vidí,
atributy vrstev, kontrakt s API digiarchivu, uložená nastavení), zasahuje
víc repozitářů nebo mění pravidla / AI konfiguraci / CI.
- **Kdy ne:** překlepy a formátování, bump závislostí či pinů nástrojů bez
změny chování, přegenerování odvozených souborů.
- **Postup:** `openspec new change <slug>` → artefakty → `openspec validate
<slug> --strict` → implementace (až na výslovný pokyn) → po merge
`openspec archive <slug> --skip-specs` (archiv
`openspec/changes/archive/RRRR-MM-DD-<slug>/`).
- Artefakty změny jdou **ve stejném PR** jako implementace; v popisu PR
odkaž na adresář změny.
- Používá se vestavěné schéma `spec-driven`; vlastní schémata hubu se sem
nepřenášejí. CLI: `npx @fission-ai/openspec@1.14.0` (nebo lokálně
nainstalované `openspec`); bez CLI lze artefakty psát i ručně.
- Asistentské povrchy (`.claude/`, `.github/prompts/` …) doručuje sync
z hubu; v tomto repozitáři se ručně nezakládají ani necommitují.
## Struktura repozitáře
@@ -33,7 +60,8 @@ amcr_viewer/ # vlastní kód pluginu (toto se balí do releasu)
metadata.txt # metadata pluginu + verze + changelog
i18n/ # překlady (.ts)
*.png # ikony
.github/workflows/ # CI – release pluginu
.github/workflows/ # CI – kontroly kvality a release pluginu
openspec/ # OpenSpec – konfigurace a plánovací artefakty změn
README.md # uživatelská dokumentace (anglicky)
```
@@ -140,6 +168,9 @@ flatpak run --command=sh org.qgis.qgis -c \
- Verze pluginu žije v **`amcr_viewer/metadata.txt`** (`version=`).
- **Při každé změně chování / nové funkci** povyš verzi a doplň položku do
`changelog=` v `metadata.txt` (formát `vX.Y.Z (RRRR-MM-DD)` + odrážky).
- Současně povyš i **`CITATION.cff`** v kořeni repozitáře: `version:` na
stejnou verzi jako v `metadata.txt` a `date-released:` na datum releasu.
Oba soubory musí mít stejnou verzi, než se založí tag.
- Datum v changelogu ber z **deterministického zdroje**, ne z paměti, např.
`python -c "import datetime; print(datetime.date.today().isoformat())"`.
- Release se spouští **pushnutím tagu `vX.Y.Z`**, ne publikací releasu
@@ -160,6 +191,7 @@ flatpak run --command=sh org.qgis.qgis -c \
- PR musí mířit do správné `version/v2.x.y` větve.
- Před požádáním o review projdi kontrolní seznam v šabloně (zejména bump verze
v `metadata.txt`, pokud měníš chování).
- Mění-li PR chování, obsahuje i odpovídající změnu v `openspec/changes/`.
- V popisu PR uveď **podíl AI** (např. „text navržen AI, ručně zkontrolováno")
a odkaz na související issue, pokud existuje.
@@ -176,5 +208,58 @@ flatpak run --command=sh org.qgis.qgis -c \
Plugin se testuje načtením do QGIS (Plugins → Manage and Install Plugins →
Install from ZIP, nebo nasazením složky `amcr_viewer/` do adresáře pluginů
QGIS). Automatizované testy zatím repozitář neobsahuje – změny ověřuj ručně
v QGIS na podporované verzi.
QGIS). **Ruční test v QGIS nic nenahrazuje** – automatické kontroly ověřují,
že se plugin načte a že projde kontrolami kvality, ne že dělá správnou věc.
### Automatické kontroly
Workflow `.github/workflows/code_quality.yml` pouští při každém PR do `main`
tohle:
| job | co dělá |
|---|---|
| **Lint a bezpečnost** | `check_sources.py`, bandit, detect-secrets, flake8, ruff |
| **Kompatibilita s Qt6** | `pyqgis4-checker` v dockeru |
| **Smoke test** | `smoke_test.py` v `qgis/qgis:ltr` i `qgis/qgis:stable` |
| **Balíček pluginu** | ověří shodu verze v `CITATION.cff` a `metadata.txt`, sestaví ZIP, ověří obsah, přiloží jako artefakt |
Smoke test běží v obou podporovaných řadách: `ltr` je QGIS 3.44 na Qt5,
`stable` je QGIS 4.x na Qt6.
Artefakt z posledního jobu se dá stáhnout ze stránky běhu a rovnou
nainstalovat přes *Install from ZIP* – recenzent nemusí nic balit ručně.
Totéž lokálně:
```sh
pip install bandit detect-secrets flake8 ruff
python3 tests/check_sources.py
bandit -r amcr_viewer/
detect-secrets scan --all-files amcr_viewer/
flake8 --isolated amcr_viewer/
ruff check .
# smoke test v obou verzích QGIS (docker, bez instalace čehokoli)
for tag in ltr stable; do
docker run --rm -v "$PWD:/work:ro" -w /work \
--user "$(id -u):$(id -g)" -e HOME=/tmp \
"qgis/qgis:$tag" python3 tests/smoke_test.py
done
```
Na co si dát pozor:
- **`pyqgis4-checker` končí kódem 0, i když něco najde** – výsledek je jen
v logu. Workflow proto kontroluje, že log obsahuje jen hlavičku.
- **`detect-secrets` bez `--all-files` prohledá jen soubory sledované
gitem** a o nesledovaném souboru mlčí. Vypadá to jako čistý výsledek.
- **Flake8 běží bez konfigurace** (`--isolated`), tedy se stejnými
výchozími pravidly jako scanner na plugins.qgis.org. Do balíčku nepatří
`.flake8`, `.bandit` ani `.secrets.baseline`: scanner by plugin označil
jako „Validated (configured)“ a nález je lepší opravit v kódu.
Konfigurace ruffu je v kořenovém `pyproject.toml` – ruff se do balíčku
pluginu nedistribuuje.
Viz https://plugins.qgis.org/docs/security-scanning/config-files
- **Verze nástrojů jsou v workflow napevno.** Výchozí sada pravidel ruffu se
mezi verzemi mění, takže bez pinu by CI začalo padat samo od sebe.
+2 -2
View File
@@ -25,5 +25,5 @@ abstract: >-
the Digital archive of the Archaeological Map of the
Czech Republic (https://digiarchiv.aiscr.cz/).
license: GPL-3.0
version: '2.0.0'
date-released: '2026-06-05'
version: '2.2.0'
date-released: '2026-10-01'
+1 -6
View File
@@ -1,6 +1 @@
# CLAUDE.md
Pokyny pro tento repozitář jsou v [`AGENTS.md`](./AGENTS.md).
Tento soubor je záměrně jen odkaz, aby existoval jeden zdroj pravdy a obsah se
nerozjel. Cokoli platí pro AI agenty v tomto projektu, najdeš v `AGENTS.md`.
@AGENTS.md
+398 -142
View File
@@ -1,182 +1,438 @@
# AMCR Viewer: QGIS Plugin Documentation
# AMČR Viewer — QGIS plugin
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![QGIS 3.44 – 4.x](https://img.shields.io/badge/QGIS-3.44%20%E2%80%93%204.x-589632.svg)](https://qgis.org/)
[![Code quality](https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/actions/workflows/code_quality.yml/badge.svg)](https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/actions/workflows/code_quality.yml)
[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.18609813.svg)](https://doi.org/10.5281/zenodo.18609813)
**Platform:** QGIS 3.44.0–4.99.0
**AMČR Viewer** queries the Digital Archive of the Archaeological Map of the
Czech Republic (AMČR) and turns the result into ordinary QGIS vector layers.
It removes the manual export/import round trip: you filter the archive from
inside QGIS and the matching records arrive as point, line and polygon layers
with a full attribute table.
**Module Type:** Data Acquisition & Visualization
**Source Data:** Archaeological Map of the Czech Republic (AIS CR)
| | |
| --- | --- |
| **Source data** | [Digital Archive AMČR](https://digiarchiv.aiscr.cz/) (AIS CR) |
| **Supported QGIS** | 3.44.0 – 4.99.0 (Qt 5 and Qt 6) |
| **Output** | temporary `memory` layers, S-JTSK / **EPSG:5514** |
| **Access** | anonymous by default; optional login for non-public records |
| **UI language** | Czech |
| **Licence** | GPL-3.0 |
---
## 1. Overview
## 1. What it can download
**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.
The plugin covers three AMČR record types. Each has its own menu entry, its
own set of filters and its own attribute table.
### Key Features
| Entity | Menu entry | What it is |
| --- | --- | --- |
| **Fieldwork events** (`akce`) | *Stáhnout data akcí* | Records of archaeological finds and observations tied to a place, a responsible body and a time of execution. |
| **Sites** (`lokalita`) | *Stáhnout data lokalit* | Records tied to a site, its characteristic archaeological manifestation and presumed function. |
| **Individual finds** (`samostatny_nalez`) | *Stáhnout data samostatných nálezů* | Records of individual movable finds reported through **AMČR-PAS**, the portal for amateur collaborators. |
* **Spatial Querying:** Option to filter records based on the current map canvas extent (Bounding Box).
* **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.
* **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.
Fieldwork events and Sites can additionally carry **component** data (period
and activity area) directly in the attribute table. Individual finds have no
components — period and dating are attributes of the find itself.
## 2. Installation Guide
### Key features
**Install the latest version of the plugin from the QGIS plugin repository.**
* **Spatial querying** — restrict the query to the current map canvas extent.
* **Multi-criteria attribute filtering** driven by AMČR controlled
vocabularies (*hesláře*), with a searchable multi-select picker per filter.
* **Date range filtering** for fieldwork start/end and for the date of finding.
* **Automatic geometry retrieval**, split into Point, Line and Polygon layers
and reprojected to S-JTSK.
* **Human-readable labels** — internal codes (`HES-xxxxxx`) are translated via
the AIS CR translation dictionary.
* **Authenticated access** — an AMČR account unlocks non-public records;
credentials are stored encrypted in the QGIS Authentication Manager.
**OR** (*in case you need older version*)
---
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.*
3. *Navigate to Plugins → Manage and Install Plugins...*
4. *Select the Install from ZIP tab.*
5. *Locate the source ZIP file and click Install Plugin.*
6. *Upon successful installation, the AMCR Viewer button will appear in the toolbar.*
## 2. Installation
## 3. User Manual
### From the QGIS plugin repository (recommended)
### 3.1 Authentication (Optional)
*Plugins → Manage and Install Plugins… → search for* **AMČR Viewer** *→
Install*.
By default, the plugin accesses only publicly available records (accessibility = anonymous). To access non-public data, log in using your AMČR account:
### From a ZIP archive (older versions, or a build from source)
* Click the dropdown arrow on the AMCR Viewer toolbar button and select **Přihlásit se**.
* 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.
1. Download a [release package](https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases)
(a ZIP containing the `amcr_viewer` directory).
2. In QGIS go to *Plugins → Manage and Install Plugins… → Install from ZIP*.
3. Select the archive and click *Install Plugin*.
### 3.2 Data Retrieval
Every successful CI run also publishes a ready-to-install `amcr_viewer.zip`
as a build artifact, which is handy for testing a branch before release.
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.
After installation the **AMČR Viewer** button appears in the toolbar as a
dropdown.
* **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.)*
### Requirements
* **Attribute Filters:**
* 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 **Vybrat...** to open a searchable selection window. Multiple values can be selected simultaneously (Logic: OR).
The plugin needs the **`requests`** library. It ships with the QGIS installers
for Windows and macOS. On Linux distribution packages it may have to be
installed separately (e.g. `python3-requests`).
* **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.
---
* **Components:** Check **Načíst komponenty** to include period and activity area data directly in the output layers.
> ⚠ When components are loaded, spatial features are duplicated — each feature corresponds to one component. Spatial analyses (areas, counts) may be inaccurate.
## 3. User manual
* 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).
### 3.1 Toolbar and menu
For a more in-depth tutorial refer to the [AMČR Documentation](https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html) (only in Czech).
The toolbar button is a dropdown; the default action is *Stáhnout data akcí*.
### 3.3 Layer Structure & Attributes
Upon successful retrieval, the plugin generates up to three temporary memory layers:
1. **AMCR\_[Akce|Lokalita]\_Polygony**
2. **AMCR\_[Akce|Lokalita]\_Linie**
3. **AMCR\_[Akce|Lokalita]\_Body**
Layers are only created if the query returns features of the corresponding geometry type. All layers share the same attribute schema.
#### 3.3.1 Common fields
| Field | Description |
| Menu entry | Action |
| --- | --- |
| 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\] |
| *Stáhnout data akcí* | Opens the filter dialog for Fieldwork events. |
| *Stáhnout data samostatných nálezů* | Opens the filter dialog for Individual finds. |
| *Stáhnout data lokalit* | Opens the filter dialog for Sites. |
| *Přihlásit se* | Opens the login dialog (see 3.2). |
| *Nápověda AMČR Help* | Opens the online documentation in a browser. |
#### 3.3.2 Fields related to *Fieldwork events*
### 3.2 Authentication (optional)
| Field | Description |
By default the plugin sees only publicly accessible records. Logging in with
an AMČR account extends the result set to everything the account is allowed
to see.
* The credentials are **verified against the API before they are stored** —
a wrong password never reaches the Authentication Manager. If the server is
unreachable, the plugin offers to store them unverified.
* They are then saved encrypted in the **QGIS Authentication Manager** (DPAPI
on Windows, Keychain on macOS, encrypted SQLite on Linux). QGIS will ask for
its master password.
* Stored credentials are reused across QGIS sessions. The plugin checks
the login state before every download (via the `islogged` endpoint)
and, when the session cookie has expired, re-authenticates
automatically. If re-authentication is not possible, a warning in the
message bar says the download runs anonymously (access level A only);
a failed check never blocks the download.
* Reopening the login dialog lets you change the e-mail (leave the password
blank to keep the stored one) or remove the credentials entirely
(*Odebrat uložené přihlašovací údaje*). Removing them also logs you out
of the Digital Archive, so the next download runs anonymously.
### 3.3 The filter dialog
Filters of different categories are combined with **AND**; multiple values
inside one filter are combined with **OR**. A filter left empty means "no
restriction". Click *Vybrat…* to open a searchable, checkable list.
#### Availability per entity
| Filter (Czech UI label) | Events | Sites | Ind. finds | API parameter |
| --- | :---: | :---: | :---: | --- |
| Omezit vyhledávání rozsahem okna | ✓ | ✓ | ✓ | `loc_rpt` |
| Pouze pozitivní zjištění | ✓ | — | — | `posevidence` |
| Pouze projektové akce | ✓ | — | — | `proj_akce` |
| Kraj | ✓ | ✓ | ✓ | `f_kraj` |
| Okres | ✓ | ✓ | ✓ | `f_okres` |
| Katastr | ✓ | ✓ | ✓ | `f_katastr` |
| Přístupnost | ✓ | ✓ | ✓ | `pristupnost` |
| PIAN – přesnost | ✓ | ✓ | — | `f_pian_presnost` |
| Organizace | ✓ | — | ✓ | `f_organizace` |
| Vedoucí výzkumu | ✓ | — | — | `f_vedouci` |
| Typ výzkumu | ✓ | — | — | `f_typ_vyzkumu` |
| Datum — *Zahájení* / *Ukončení* | ✓ | — | — | `akce_datum_zahajeni`, `akce_datum_ukonceni` |
| Lokalita – typ | — | ✓ | — | `f_typ_lokality` |
| Lokalita – druh | — | ✓ | — | `f_druh_lokality` |
| Lokalita – jistota určení | — | ✓ | — | `f_jistota` |
| Lokalita – stav dochování | — | ✓ | — | `f_lokalita_zachovalost` |
| Období | ✓ | ✓ | ✓ | `f_obdobi` |
| Kategorie nálezu | — | — | ✓ | `f_kategorie` |
| Druh nálezu | — | — | ✓ | `f_druh_nalezu` |
| Materiál | — | — | ✓ | `f_specifikace` |
| Okolnosti nálezu | — | — | ✓ | `f_nalezove_okolnosti` |
| Nálezce | — | — | ✓ | `f_nalezce` |
| Datum nálezu | — | — | ✓ | `samostatny_nalez_datum_nalezu` |
| Areál | ✓ | ✓ | — | `f_areal` |
| Načíst komponenty | ✓ | ✓ | — | — |
#### Spatial restriction
*Omezit vyhledávání rozsahem okna* is **checked by default**. The canvas
extent is transformed from the project CRS to WGS-84 and sent as a bounding
box. Unchecking it queries the whole database — do so with an attribute
filter in place, otherwise you will hit the record cap (see 4.5).
#### PIAN accuracy has a non-empty default
> ⚠ *PIAN – přesnost* is the one filter that is **pre-selected**. For
> Fieldwork events and Sites the dialog starts with *odchylka jednotky metrů*,
> *odchylka desítky metrů* and *odchylka stovky metrů* checked, so an
> otherwise untouched dialog already sends `f_pian_presnost`. Records
> localised only to a cadastral territory are excluded until you open the
> picker and add that level yourself.
#### Date ranges
Each date block has a *from* and a *to* picker; an empty picker shows
*neomezeno* and means an open bound. The API rejects a one-sided range, so the
plugin substitutes a sentinel (`0001-01-01` / `9999-12-31`) for the empty
side. A block with **both** pickers empty adds no filter at all.
A reversed range (start later than end) is refused when you confirm the
dialog — such a query would come back empty and would be indistinguishable
from a genuinely empty result.
#### Codelists (hesláře)
The controlled vocabularies behind the pickers are cached in
`amcr_viewer/codelists/heslar.csv` and ship with the plugin. Click
**Aktualizovat hesláře 🔄** to rebuild the file from the live APIs; it runs as
a background QGIS task with a progress bar and takes a few minutes.
Most codelists come from the AMČR **OAI-PMH** endpoint. Two are built from
Digiarchiv **search facets** instead, because they are lists of people rather
than a published vocabulary: *Vedoucí výzkumu* (`f_vedouci`, faceted over
fieldwork events) and *Nálezce* (`f_nalezce`, faceted over individual finds).
#### Components
Check **Načíst komponenty** (Events and Sites only) to bring the period and
activity area of each component into the output layer.
> ⚠ With components loaded, spatial features are **duplicated** — one feature
> per component. Areas and feature counts computed on such a layer are
> misleading.
Note that *Období* and *Areál* also act as component filters even when the
box is unchecked: a documentation unit whose components match nothing is
dropped from the result.
### 3.4 Output layers
Up to three temporary `memory` layers are created per download, in **S-JTSK
(EPSG:5514)**:
* `AMCR_Akce_Body` / `_Linie` / `_Polygony`
* `AMCR_Lokalita_Body` / `_Linie` / `_Polygony`
* `AMCR_Samostatný_nález_Body` / `_Linie` / `_Polygony`
A layer is only created if the query actually returned that geometry type.
All layers of one download share the same attribute schema. Field names are
ASCII; the human-readable names visible in the attribute table are QGIS field
aliases.
> Memory layers are **not persistent** — export them (GeoPackage, Shapefile,
> …) before closing the project.
Geometry is taken from the record's S-JTSK WKT when present; otherwise the
WGS-84 fallback is reprojected. Invalid geometries are repaired rather than
dropped.
The tables below group the fields by meaning. In the layer they appear in the
order *common → entity-specific → `pristupnost` → component fields*.
#### Common fields
| Field | Alias | Description |
| --- | --- | --- |
| `pian` | PIAN | Spatial unit (PIAN) identifier. *Events and Sites only.* |
| `presnost` | Přesnost | Spatial accuracy \[units / tens / hundreds of metres / defined by cadastre\]. *Events and Sites only.* |
| `pian_typ` | PIAN – typ | \[point / line / polygon\]. *Events and Sites only.* |
| `dj` | Dokumentační jednotka | Documentation unit identifier. *Events and Sites only.* |
| `typ_dj` | Typ dokumentační jednotky | \[trench / event part / whole event / cadastral territory\]. *Events and Sites only.* |
| `akce` / `lokalita` / `samostatny_nalez` | Akce / Lokalita / Samostatný nález | Record identifier. |
| `definicni_body` | Definiční bod(y) (WGS-84) | Feature centroid(s) in WGS-84. |
| `odkaz_do_digiarchivu` | Odkaz do Digitálního archivu AMČR | Permalink to the record. |
| `okres` | Okres | District. |
| `katastr` | Katastr | Main cadastral area. |
| `dalsi_katastry` | Další katastry | Other cadastral areas. *Always empty for individual finds.* |
| `pristupnost` | Přístupnost | Record accessibility \[A/B/C/D\]. |
#### Fieldwork event fields
| Field | Alias | Description |
| --- | --- | --- |
| `akce_lokalizace` | Akce – lokalizace | Verbal description of the location. |
| `vedouci` | Vedoucí akce | Main fieldwork manager. |
| `organizace` | Organizace | Organisation conducting the research. |
| `specifikace_data` | Specifikace data | \[exact date / exact years / sometime in years\]. |
| `zahajeni` | Datum zahájeni | Start date. |
| `ukonceni` | Datum ukončení | End date. |
| `hlavni_typ` | Hlavní typ | Primary research method. |
| `vedlejsi_typ` | Vedlejší typ | Secondary research methods. |
| `zjisteni` | Zjištění | Whether the **documentation unit** is positive or negative evidence \[Pozitivní / Negativní\]. |
| `nahrazuje_NZ` | Akce – nahrazuje NZ | Replaces a fieldwork report \[Ano / Ne\]. |
| `projekt` | Projekt | Identifier of the related project, if any. |
#### Site fields
| Field | Alias | Description |
| --- | --- | --- |
| `nazev_lokality` | Název lokality | Site name. |
| `popis_lokality` | Popis lokality | Site description. |
| `typ_lokality` | Typ lokality | Site classification by definition method. |
| `druh_lokality` | Druh lokality | Site classification by the nature of the field relics. |
| `zachovalost` | Zachovalost | State of preservation. |
#### Individual find fields
| Field | Alias | Description |
| --- | --- | --- |
| `projekt` | Projekt | Identifier of the related project. |
| `nalezce` | Nálezce | Finder. |
| `datum` | Datum nálezu | Date of finding. |
| `okolnosti` | Nálezové okolnosti | Finding context. |
| `hloubka_cm` | Hloubka (cm) | Depth below surface. |
| `lokalizace` | Lokalizace | Verbal description of the find spot. |
| `obdobi` | Období | Period. |
| `presna_datace` | Přesná datace | Precise dating, if known. |
| `nalez` | Nález | Find class. |
| `material` | Materiál | Find specification / material. |
| `pocet` | Počet předmětů | Number of objects. |
| `poznamka` | Poznámka/bližší popis | Note or closer description. |
| `pred_org` | Předáno organizaci | Organisation the find was handed over to. |
| `evidencni` | Evidenční číslo | Reference number. |
#### Component fields (only with *Načíst komponenty*)
| Field | Alias | Description |
| --- | --- | --- |
| `komponenta` | Komponenta | Component identifier. |
| `komponenta_areal` | Areál | Activity area \[settlement / burial area / field / …\]. |
| `komponenta_obdobi` | Období | Period \[Neolithic / High Middle Ages–Modern Period / …\]. |
| `prvek_vaha` | Váha prvku | Feature weight: 1/*n*, where *n* is the number of features created from the same documentation unit after the period/area filters, so the weights of one documentation unit sum to 1. |
### 3.5 When a query returns nothing
Progress and errors are written to the QGIS *Messages* panel, tab **AMČR**
(login goes to **AMČR login**). The log contains the **exact request URL**,
so a suspicious query can be replayed in a browser instead of being
reconstructed from the code. Distinct messages tell apart an empty result, an
API error, a network failure and a result without any geometry.
Only one download can run at a time; starting a second one while the first is
still running is refused with a message.
For a step-by-step tutorial see the
[AMČR documentation](https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html)
(Czech only).
---
## 4. Technical notes
The plugin is plain **Python 3** with **`requests`** for HTTP. The GUI is
built through the **`qgis.PyQt`** compatibility layer rather than importing
`PyQt5`/`PyQt6` directly, which is what lets a single source tree run on both
QGIS 3.44 (Qt 5) and QGIS 4 (Qt 6). Every enum is referenced in its scoped
form (`Qt.CheckState.Checked`, …), as required by Qt 6.
### 4.1 Repository layout
```
amcr_viewer/ the plugin package (this is what gets zipped)
__init__.py classFactory() entry point for QGIS
amcr_viewer.py toolbar/menu integration, login flow, dispatch
amcr_dialog.py AmcrFilterDialog, FilterableSelectionDialog,
LoginDialog, UpdateCodelistsTask
amcr_tools.py API access, pagination, parsing, layer building
amcr_codelists.py codelist download and CSV cache
codelists/heslar.csv cached controlled vocabularies
i18n/ Qt translation files
*.png toolbar and menu icons
metadata.txt plugin metadata and changelog
tests/
check_sources.py source hygiene checks (no QGIS needed)
smoke_test.py loads the plugin in a real, headless QGIS
.github/workflows/ CI (code quality, release packaging)
pyproject.toml ruff configuration
AGENTS.md contributor and AI-agent guidelines
```
### 4.2 API endpoints
| Purpose | Endpoint | Notes |
| --- | --- | --- |
| Login | `POST https://digiarchiv.aiscr.cz/api/user/login` | Returns a session cookie. Errors arrive with HTTP 200 and an `error` key. |
| Logout | `GET https://digiarchiv.aiscr.cz/api/user/logout` | Called when the stored credentials are removed. |
| Login state | `GET https://digiarchiv.aiscr.cz/api/user/islogged` | `{"remaining": <s>}` when logged in, `{"error":"nologged"}` otherwise; checked before each download. |
| Search | `GET https://digiarchiv.aiscr.cz/api/search/query` | `entity=akce\|lokalita\|samostatny_nalez\|pian`, `mapa=true`, paginated. |
| Translations | `GET https://digiarchiv.aiscr.cz/api/assets/i18n/cs.json` | Code → Czech label; cached in memory for the session. |
| Codelists | `GET https://api.aiscr.cz/2.2/oai` | OAI-PMH `ListRecords`, with resumption tokens. |
### 4.3 Processing pipeline
1. **Metadata** are paged in batches of **500** records, deduplicated by
`ident_cely`, until the reported `numFound` is reached or the cap is hit.
2. Records **without geometry are skipped**; the rest are expanded into
documentation units and — if requested — into components.
3. **Geometries** (PIAN) are fetched separately in batches of **200**
identifiers, to stay under URL length limits.
4. Features are built, reprojected to EPSG:5514, sorted by geometry type and
added to the project in a single batch per layer.
### 4.4 Data persistence
* **Codelists** — `amcr_viewer/codelists/heslar.csv`, rewritten only when the
user asks for an update.
* **Credentials** — QGIS Authentication Manager; the config ID is kept in
`QSettings` under `amcr_viewer/auth_config_id`.
* **Layers** — `memory` only, lost when QGIS closes.
### 4.5 Limits
* **20 000 records** per query (safety cap; QGIS would otherwise freeze).
* **500** records per metadata request, **200** identifiers per geometry
request.
* With components loaded, one output feature equals one component, so a
single PIAN can appear several times in the layer.
---
## 5. Development
Contributor rules, branch naming and the manual QGIS test checklist live in
[`AGENTS.md`](./AGENTS.md).
Every pull request runs
[`.github/workflows/code_quality.yml`](.github/workflows/code_quality.yml),
which mirrors what plugins.qgis.org checks on upload and adds what it does
not:
| Job | What it does |
| --- | --- |
| 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\] |
| **Lint a bezpečnost** | `tests/check_sources.py`, bandit, detect-secrets, flake8, ruff |
| **Kompatibilita s Qt6** | `pyqgis4-checker` in dry-run mode |
| **Smoke test** | loads the plugin in headless QGIS — both `ltr` (Qt 5) and `stable` (Qt 6) |
| **Balíček pluginu** | builds `amcr_viewer.zip`, asserts its contents, uploads it as an artifact |
#### 3.3.3 Fields related to *Sites*
Reproducing them locally:
| 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 |
```bash
python3 tests/check_sources.py
ruff check .
flake8 --isolated amcr_viewer/
bandit -r amcr_viewer/
docker run --rm -v "$PWD:/work:ro" -w /work --user "$(id -u):$(id -g)" \
-e HOME=/tmp qgis/qgis:stable python3 tests/smoke_test.py
```
#### 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 Ages–Modern Period/…\] |
## 6. Links and resources
## 4. Technical Architecture
* [AMČR / Digiarchiv documentation](https://amcr-help.aiscr.cz/) (Czech only)
* [AMČR Viewer tutorial](https://amcr-help.aiscr.cz/digiarchiv/qgis-viewer.html)
(Czech only)
* [AMČR-PAS](https://amcr-info.aiscr.cz/amcr-pas/) — the amateur collaborator
portal behind the Individual finds records
* [Import/Export. Pluginy propojující QGIS s AMČR \[poster\]](https://zenodo.org/records/20504909)
(Czech only; describes v1.3.2)
The plugin is developed in **Python 3** using the **PyQt6** framework for the GUI and the **Requests** library for HTTP communication.
## Citing
> **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`).
Cite the plugin using [`CITATION.cff`](./CITATION.cff) or the concept DOI
[10.5281/zenodo.18609813](https://doi.org/10.5281/zenodo.18609813), which
always resolves to the latest release.
### 4.1 File Structure
## Licence
* `amcr_viewer.py`: Entry point; handles GUI integration, toolbar/menu setup, and login flow.
* `amcr_dialog.py`: Manages the UI logic, including `AmcrFilterDialog`, `FilterableSelectionDialog`, and `LoginDialog`.
* `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/heslar.csv`) downloaded via OAI-PMH.
### 4.2 Data Flow & API Integration
The plugin interacts with the following endpoints:
1. **Login API:**
* Endpoint: `https://digiarchiv.aiscr.cz/api/user/login`
* Method: `POST`
* Returns a session cookie used for subsequent authenticated requests.
* 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.
3. **Translation API:**
* Endpoint: `https://digiarchiv.aiscr.cz/api/assets/i18n/cs.json`
* 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
* **Vocabularies:** Stored in `codelists/heslar.csv`; updated on user request via the background task.
* **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
* **Record Limit:** A safety cap of 20 000 records is enforced.
* **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.
## 5. Links and resources
* [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).
* [Import/Export. Pluginy propojující QGIS s AMČR \[poster\]](https://zenodo.org/records/20504909) (only in Czech; valid for v1.3.2).
GPL-3.0 — see [`LICENSE`](./LICENSE).
+67 -6
View File
@@ -65,7 +65,7 @@ def parse_codelist_file(filename, target_dict=None):
try:
# Open the file using standard UTF-8 encoding
with open(path, 'r', encoding='utf-8') as f:
with open(path, encoding='utf-8') as f:
reader = csv.reader(f, delimiter=';')
# Skip the CSV header row
@@ -104,6 +104,21 @@ def load_all_data():
return categorized_data
def _facet_name(item):
"""
Returns the value of one facet item from the Digiarchive API.
Digiarchive v4.1.0 (Solr 10, json.nl=arrarr) returns facet items as
["value", count] pairs; older versions returned {"name": "value", ...}
objects. Both shapes are accepted so the plugin works against either.
"""
if isinstance(item, dict):
return item.get("name")
if isinstance(item, (list, tuple)) and item:
return item[0]
return None
def fetch_set(base_url, internal_name, api_set, task=None):
dataset = []
params_amcr = {
@@ -206,7 +221,9 @@ def fetch_set(base_url, internal_name, api_set, task=None):
for r in records:
nazev = r["name"]
nazev = _facet_name(r)
if not nazev:
continue
dataset.append({
'Název': nazev,
@@ -217,17 +234,48 @@ def fetch_set(base_url, internal_name, api_set, task=None):
break
except Exception as e:
# A partial set (e.g. pagination interrupted halfway) would
# silently drop codes – report the whole set as failed instead
# and let the caller keep the previous values
QgsMessageLog.logMessage(
f"Chyba u setu {api_set}: {e}",
"AMČR", Qgis.MessageLevel.Warning)
break
return []
return dataset
def download_heslare(task=None):
"""Fetches the codelists from the AMČR API and saves it to a CSV file."""
def _read_existing_rows():
"""
Returns the rows of the current heslar.csv grouped by category, so a set
that fails to download can keep its previous values.
"""
rows = {}
if not os.path.exists(OUTPUT_FILE):
return rows
try:
with open(OUTPUT_FILE, encoding='utf-8-sig', newline='') as f:
for row in csv.DictReader(f, delimiter=';'):
cat = (row.get('Kategorie') or '').strip()
if cat:
rows.setdefault(cat, []).append(row)
except Exception as e:
QgsMessageLog.logMessage(
f"Nelze načíst stávající hesláře: {e}",
"AMČR", Qgis.MessageLevel.Warning)
return rows
def download_heslare(task=None, failed=None):
"""
Fetches the codelists from the AMČR API and saves it to a CSV file.
A set that fails or comes back empty keeps its rows from the current
heslar.csv instead of being wiped; its name is appended to ``failed``
(if given) so the caller can warn the user.
"""
ensure_codelists_dir()
existing = _read_existing_rows()
all_data = []
total_sets = len(slovnicek)
# index, (interni, api_nazev)
@@ -251,6 +299,18 @@ def download_heslare(task=None):
if data is None:
return False # Cancelled mid-download
if not data:
# Never replace a working codelist with nothing – an API change
# would otherwise silently empty the filter in the dialog
old = existing.get(interni, [])
QgsMessageLog.logMessage(
f"Heslář '{interni}' se nepodařilo stáhnout, "
f"ponechávám předchozí hodnoty ({len(old)} položek).",
"AMČR", Qgis.MessageLevel.Warning)
if failed is not None:
failed.append(interni)
data = old
all_data.extend(data)
# Report progress (0-100)
@@ -261,7 +321,8 @@ def download_heslare(task=None):
# Save to CSV
with open(OUTPUT_FILE, 'w', newline='', encoding='utf-8-sig') as f:
fieldnames = ['Název', 'Kód', 'Kategorie']
writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=';')
writer = csv.DictWriter(f, fieldnames=fieldnames, delimiter=';',
extrasaction='ignore')
writer.writeheader()
writer.writerows(all_data)
+43 -14
View File
@@ -75,12 +75,15 @@ class UpdateCodelistsTask(QgsTask):
super().__init__(description, QgsTask.Flag.CanCancel)
self.success = False
self.exception = None
# Codelists that failed to download and kept their previous values
self.failed_sets = []
def run(self):
"""Runs in a background thread."""
try:
# Call the download function with the task reference
self.success = download_heslare(task=self)
self.success = download_heslare(
task=self, failed=self.failed_sets)
return self.success
except Exception as e:
self.exception = e
@@ -91,10 +94,17 @@ class UpdateCodelistsTask(QgsTask):
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.MessageLevel.Info
)
if self.failed_sets:
QgsMessageLog.logMessage(
"Hesláře AMČR aktualizovány částečně, beze změny "
f"zůstaly: {', '.join(self.failed_sets)}",
"AMČR", Qgis.MessageLevel.Warning
)
else:
QgsMessageLog.logMessage(
"Hesláře AMČR byly úspěšně aktualizovány.",
"AMČR", Qgis.MessageLevel.Info
)
else:
if self.isCanceled():
QgsMessageLog.logMessage(
@@ -333,7 +343,7 @@ class AmcrFilterDialog(QDialog):
layout.addWidget(self.picker_jistota)
self.picker_lokalita_zachovalost = self.setup_picker(
"Lokalita - stav dochování",
"Lokalita – stav dochování",
'lokalita_zachovalost',
LOKALITA_ZACHOVALOST
)
@@ -360,7 +370,7 @@ class AmcrFilterDialog(QDialog):
layout.addWidget(self.picker_druh_nalezu)
self.picker_specifikace = self.setup_picker(
"Specifikace nálezu",
"Materiál",
'specifikace',
SPECIFIKACE
)
@@ -629,6 +639,16 @@ class AmcrFilterDialog(QDialog):
def on_completed():
_cleanup()
if task.failed_sets:
QMessageBox.warning(
parent_win,
"Hesláře aktualizovány částečně",
"Některé hesláře se nepodařilo stáhnout, "
"ponechány byly jejich předchozí hodnoty:\n"
+ "\n".join(f"• {name}" for name in task.failed_sets)
+ "\n\nPodrobnosti jsou v panelu Zprávy, záložka AMČR."
)
return
QMessageBox.information(
parent_win,
"Hotovo",
@@ -659,8 +679,7 @@ class AmcrFilterDialog(QDialog):
def get_komponenty(self):
if self.typ_dat in ["akce", "lokalita"]:
return "true" if self.chk_komponenty.isChecked() else "false"
else:
return "false"
return "false"
def get_filters(self):
"""Compiles the user selections from the cache into
@@ -1014,6 +1033,10 @@ class LoginDialog(QDialog):
self.accept()
def _forget_credentials(self):
# Lazy import to avoid an import cycle
# (amcr_tools imports LoginDialog lazily as well)
from . import amcr_tools
settings = QSettings()
existing_id = settings.value(self.SETTINGS_KEY, "")
if existing_id:
@@ -1021,11 +1044,17 @@ class LoginDialog(QDialog):
existing_id
)
settings.remove(self.SETTINGS_KEY)
QMessageBox.information(
self,
"Hotovo",
"Uložené přihlašovací údaje byly odebrány."
)
# Without credentials the session in memory would otherwise stay
# logged in until QGIS is restarted
if amcr_tools.logout_from_api():
zprava = ("Uložené přihlašovací údaje byly odebrány "
"a uživatel byl odhlášen.")
else:
zprava = ("Uložené přihlašovací údaje byly odebrány. Server "
"se nepodařilo kontaktovat, další stahování ale "
"proběhne anonymně.")
QMessageBox.information(self, "Hotovo", zprava)
self.reject()
# ------------------------------------------------------------------
+208 -31
View File
@@ -157,6 +157,125 @@ def _get_session() -> requests.Session | None:
return AMCR_SESSION
def logout_from_api() -> bool:
"""
Logs the current session out on the server (GET /api/user/logout)
and drops it from memory, so the next download runs anonymously
(or logs in again only if credentials are stored).
The local session is dropped even when the server cannot be
reached. Returns True when the server confirmed the logout or there
was no session at all.
"""
global AMCR_SESSION
session = AMCR_SESSION
AMCR_SESSION = None
if session is None:
return True
url = "https://digiarchiv.aiscr.cz/api/user/logout"
try:
response = session.get(url, timeout=10)
response.raise_for_status()
except requests.exceptions.RequestException as e:
_log(f"Odhlášení na serveru se nezdařilo: {e} – session "
"zahozena jen lokálně.", Qgis.MessageLevel.Warning)
return False
_log("Uživatel odhlášen.")
return True
def _check_islogged(session) -> bool | None:
"""
Asks the server whether the session is logged in
(GET /api/user/islogged; the check does not extend the session).
Returns True when logged in, False when the server reports
'nologged', or None when the check itself failed (network error,
invalid JSON) or the response has an unknown shape.
"""
url = "https://digiarchiv.aiscr.cz/api/user/islogged"
try:
body = session.get(url, timeout=10).json()
except (requests.exceptions.RequestException, ValueError) as e:
_log(f"Stav přihlášení se nepodařilo ověřit: {e}",
Qgis.MessageLevel.Warning)
return None
if not isinstance(body, dict):
_log("Neznámý formát odpovědi islogged – pokračuji dál.",
Qgis.MessageLevel.Warning)
return None
if "remaining" in body:
# Only the remaining seconds are logged, never a user profile
_log(f"Session je přihlášená (zbývá {body['remaining']} s).")
return True
if body.get("error"):
_log(f"Server session neuznává ({body['error']}).",
Qgis.MessageLevel.Warning)
return False
_log("Neznámý formát odpovědi islogged – pokračuji dál.",
Qgis.MessageLevel.Warning)
return None
def _ensure_logged_in() -> str:
"""
Verifies before a download that the user is logged in, whenever
a session exists or credentials are stored; renews the session
once when it has expired. Returns one of:
* "anonymous" – no session and no stored credentials (nothing
to check, no request is sent)
* "logged_in" – the current session is valid
* "relogged" – the session had expired and was renewed
* "fallback" – a login was expected but could not be established;
the download will run anonymously
* "unknown" – the check itself failed; the download proceeds
with the current session
"""
global AMCR_SESSION
session = _get_session()
if session is None:
# _get_session() has already tried the stored credentials;
# their presence therefore means the login failed
from .amcr_dialog import LoginDialog
username, password = LoginDialog.get_credentials()
if username and password:
_log("Přihlášení se nezdařilo – stahuji anonymně.",
Qgis.MessageLevel.Warning)
return "fallback"
return "anonymous"
stav = _check_islogged(session)
if stav is None:
return "unknown"
if stav:
return "logged_in"
# The server no longer accepts the session – drop it and try
# one re-login with the stored credentials
AMCR_SESSION = None
from .amcr_dialog import LoginDialog
username, password = LoginDialog.get_credentials()
if not (username and password):
_log("Session vypršela a přihlašovací údaje nejsou uloženy "
"– stahuji anonymně.", Qgis.MessageLevel.Warning)
return "fallback"
session = login_to_api(username, password)
if session is None:
return "fallback"
stav = _check_islogged(session)
if stav is None:
return "unknown"
if stav:
return "relogged"
_log("Nová session nebyla serverem uznána – stahuji anonymně.",
Qgis.MessageLevel.Warning)
return "fallback"
def _api_get_json(url, params, timeout=30) -> dict:
"""
Performs a GET request and returns the parsed JSON body.
@@ -168,7 +287,13 @@ def _api_get_json(url, params, timeout=30) -> dict:
def _is_auth_error(resp: requests.Response, body) -> bool:
"""The API returns auth errors with status 200 –
the body must be checked."""
the body must be checked.
Fallback only: the current server signals an expired session
by silently answering as anonymous (HTTP 200, no 'error'), so
this check never triggers on expiry today – the login state is
verified upfront by _ensure_logged_in() instead. Kept for the
day the API starts returning 401 or an explicit error text."""
if resp.status_code == 401:
return True
if not isinstance(body, dict):
@@ -261,6 +386,49 @@ def tr_code(code):
return TRANSLATIONS.get(code, code)
def _component_entries(dj_meta, komps, passes):
"""
Builds the feature metadata entries of the "Načíst komponenty"
mode: one entry per component that passes the given predicate,
with the weight 1/n where n is the number of passing components,
so the weights of one documentation unit sum to 1 even when a
period/area filter removes some of them. A documentation unit
without components gets a single entry with empty component
fields and weight 1.
dj_meta: metadata shared by the documentation unit (spread into
every entry); komps: its component documents; passes: predicate
komp -> bool deciding whether a component becomes a feature.
"""
if not komps:
# DJ without components – still one feature, weight 1
return [{
**dj_meta,
'komponenta_id': "",
'komponenta_areal': "",
'komponenta_obdobi': "",
'vaha': 1,
}]
prochazejici = [komp for komp in komps if passes(komp)]
vaha = 1 / len(prochazejici) if prochazejici else 1
return [
{
**dj_meta,
'komponenta_id': komp.get('ident_cely', ""),
'komponenta_areal': (
komp.get('komponenta_areal') or {}
).get('value', ""),
'komponenta_obdobi': (
komp.get('komponenta_obdobi') or {}
).get('value', ""),
'vaha': vaha,
}
for komp in prochazejici
]
def komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
# 'or {}' – the key may be present with a None value
areal_id = (komp.get('komponenta_areal') or {}).get('id', "")
@@ -292,6 +460,27 @@ def load_amcr_data(canvas, bb, filters=None,
return
_LOADING = True
# Login state is verified before the first query: an expired
# session would otherwise silently degrade the result to
# access level A without any error
try:
login_stav = _ensure_logged_in()
except Exception as e:
# The check must never block the download nor leave _LOADING
# stuck – an unexpected error means "proceed as today"
QgsMessageLog.logMessage(
f"Ověření stavu přihlášení selhalo: {e}",
"AMČR", Qgis.MessageLevel.Warning
)
login_stav = "unknown"
if login_stav == "fallback":
iface.messageBar().pushMessage(
"AMCR",
"Přihlášení se nepodařilo obnovit – stahování proběhne "
"anonymně a bude obsahovat jen záznamy s přístupností A.",
level=Qgis.MessageLevel.Warning
)
load_translations()
# --- 1. COORDINATE TRANSFORMATION ---
@@ -680,28 +869,16 @@ def load_amcr_data(canvas, bb, filters=None,
# One feature per component –
# all data on a single row, no relations needed
if komps:
for komp in komps:
if not komp_projde_filtrem(
komp, filter_areal,
# The weight is 1/n of the components
# that pass the period/area filter,
# so one DJ sums to 1
for komp_meta in _component_entries(
dj_meta, komps,
lambda k: komp_projde_filtrem(
k, filter_areal,
filter_datace, filters
):
continue
komp_meta = {
**dj_meta,
'komponenta_id': komp.get(
'ident_cely',
""
),
'komponenta_areal': (
komp.get('komponenta_areal')
or {}
).get('value', ""),
'komponenta_obdobi': (
komp.get('komponenta_obdobi')
or {}
).get('value', ""),
}
)
):
pian_lookup[dj_pian_value].append(
komp_meta)
target_pian_ids_count += 1
@@ -711,15 +888,12 @@ def load_amcr_data(canvas, bb, filters=None,
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
for komp_meta in _component_entries(
dj_meta, [], lambda k: True
):
pian_lookup[dj_pian_value].append(
komp_meta)
target_pian_ids_count += 1
else:
target_pian_ids_count += 1
pian_lookup[dj_pian_value].append(dj_meta)
@@ -1056,6 +1230,7 @@ def load_amcr_data(canvas, bb, filters=None,
"poznamka": "Poznámka/bližší popis",
"pred_org": "Předáno organizaci",
"evidencni": "Evidenční číslo",
"prvek_vaha": "Váha prvku",
}
if komponenty == "true":
@@ -1063,6 +1238,7 @@ def load_amcr_data(canvas, bb, filters=None,
QgsField("komponenta", QMetaType.Type.QString),
QgsField("komponenta_areal", QMetaType.Type.QString),
QgsField("komponenta_obdobi", QMetaType.Type.QString),
QgsField("prvek_vaha", QMetaType.Type.Double),
]
for vl in layers:
@@ -1209,6 +1385,7 @@ def load_amcr_data(canvas, bb, filters=None,
meta.get('komponenta_id', ""),
meta.get('komponenta_areal', ""),
meta.get('komponenta_obdobi', ""),
meta.get('vaha', 1),
])
feat.setAttributes(atributy)
+14 -2
View File
@@ -8,7 +8,7 @@ name=AMČR Viewer
qgisMinimumVersion=3.44.0
qgisMaximumVersion=4.99.0
description=Viewing and downloading the AMČR data.
version=2.1.2
version=2.2.0
author=David Spáčil
email=spacil@arub.cz
@@ -23,7 +23,19 @@ repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer
hasProcessingProvider=no
# Uncomment the following line and add your changelog:
changelog=
Plný seznam změn v češtině je dostupný zde: https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases/tag/v2.1.1
Plný seznam změn v češtině je dostupný zde: https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases/tag/v2.2.0
v2.2.0 (2026-09-02)
* Filter labels unified: "Specifikace nálezu" renamed to "Materiál" to match the attribute alias
* README rewritten to match the current state of the code
* Tables for Akce and Lokality contain a field with feature weight, when Komponenty rendering is enabled; the weights of one documentation unit sum to 1 also with period/area filters
* The login state is verified before each download via /api/user/islogged; an expired session is renewed automatically
* When a logged-in download falls back to anonymous access, a message bar warning says so (only access level A data)
* Removing the stored credentials also logs the user out of the Digital Archive
v2.1.4 (2026-10-01)
* Removed unused generated resources.py and the bundled flake8 config, so the plugin passes the plugins.qgis.org scan without custom configuration
v2.1.3 (2026-10-01)
* Fixed empty person codelists (excavation leaders, finders) after updating codelists against Digiarchive v4.1.0
* A codelist that fails to download keeps its previous values and the user is warned
v2.1.2 (2026-09-01)
* Qt6 compatibility
* Code clean-up
-128
View File
@@ -1,128 +0,0 @@
# -*- coding: utf-8 -*-
# Resource object code
#
# Created by: The Resource Compiler for PyQt5 (Qt v5.15.13)
#
# WARNING! All changes made in this file will be lost!
from qgis.PyQt import QtCore
qt_resource_data = b"\
\x00\x00\x04\x0a\
\x89\
\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\
\x00\x00\x17\x00\x00\x00\x18\x08\x06\x00\x00\x00\x11\x7c\x66\x75\
\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\
\x06\x62\x4b\x47\x44\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\
\x00\x00\x09\x70\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\
\x00\x9a\x9c\x18\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xd9\x02\x15\
\x16\x11\x2c\x9d\x48\x83\xbb\x00\x00\x03\x8a\x49\x44\x41\x54\x48\
\xc7\xad\x95\x4b\x68\x5c\x55\x18\xc7\x7f\xe7\xdc\x7b\x67\xe6\xce\
\x4c\x66\x26\x49\xd3\x24\x26\xa6\xc6\xf8\x40\x21\xa5\x04\xb3\x28\
\xda\x98\x20\xa5\x0b\xad\x55\xa8\x2b\xc5\x50\x1f\xa0\x6e\x34\x2b\
\x45\x30\x14\x02\xba\x52\x69\x15\x17\x66\x63\x45\x97\x95\xa0\xad\
\x0b\xfb\xc0\x06\x25\xb6\x71\x61\x12\x41\x50\xdb\x2a\x21\xd1\xe2\
\x24\xf3\x9e\xc9\xcc\xbd\xe7\x1c\x17\x35\x43\x1e\x33\x21\xb6\xfd\
\x56\x87\xf3\x9d\xfb\xfb\x1e\xf7\xff\x9d\x23\x8c\x31\x43\x95\xf4\
\x85\x1e\x3f\x3b\x35\xac\xfd\xcc\x43\xdc\xa4\x49\x3b\xfe\x9d\x1d\
\xdb\x7b\x22\x90\x78\xf8\xb2\x28\xa7\xbe\x7d\xc1\x4b\x9d\x79\xdf\
\x18\x15\xe5\x16\x99\x10\x56\xde\x69\xdc\x3f\x22\xfd\xec\xd4\xf0\
\xad\x04\x03\x18\xa3\xa2\x7e\x76\x6a\x58\xde\x68\x2b\xb4\x36\xf8\
\xbe\xc6\x18\x53\xdb\xef\xe7\xfa\xec\xed\x67\x63\x10\x42\x00\xf0\
\xfb\xd5\x65\x2a\x15\x45\xc7\x6d\x0d\x00\xc4\xa2\xc1\xaa\x6f\x0d\
\x3e\x6c\xab\xc2\x1c\x56\xa4\x77\x4b\xb0\xf2\x35\x15\x5f\x21\x85\
\xe0\xc8\x6b\x5f\x92\x2d\x37\x33\x39\xf9\x03\x27\x8e\x1f\xa2\xf7\
\xbe\x9d\x04\x1c\x0b\x37\xe4\xac\xff\xa6\x30\x87\xbd\xba\x00\x6a\
\x06\x79\xe5\xf5\xaf\x89\xd9\x92\xc5\xcc\x0a\xd9\x7c\x19\xcf\xe9\
\xe2\xe4\xa9\x2f\x78\x7c\xff\x01\x72\x85\x0a\x2b\x65\x1f\xa5\x4c\
\xb5\xb2\x55\x16\x80\xbd\x31\xda\xda\x20\x1f\x7d\x3e\xcd\xc2\xfd\
\x59\xa6\x93\x39\x92\xd1\x22\xea\x9b\x16\xce\x9d\x3f\xce\xe0\x83\
\x03\x24\x82\x59\x3a\xdb\x7b\x88\xc7\x82\x68\x63\x58\xc9\xcc\x62\
\x8c\x21\x18\xb0\x6a\xc3\x37\x06\x49\x16\xff\x24\x6b\xa5\x49\xbb\
\x25\xbc\xa2\xa6\x21\xbb\x40\x7f\xdf\x00\x83\xbd\x01\x8e\x3c\xd5\
\x45\xd7\x8e\x6b\x9c\x9c\x98\x25\x1a\xb6\xe8\xbe\x3d\xc2\xdd\x77\
\x44\x48\xc4\x1c\x22\xe1\xeb\x58\x59\xaf\xcf\xd3\x33\x29\x2e\x34\
\x2d\x91\x93\x3e\xbe\x34\x78\x01\xc5\xe2\x61\xc5\xae\x72\x8e\x70\
\xc8\xc2\x0d\x5a\xbc\xf5\xee\x2f\x9c\xfa\x3e\x86\x69\x7a\x8e\xcf\
\x26\xe6\xf9\x63\xa1\x44\xa1\xa4\xd0\xda\x6c\x0d\x2f\x15\x7c\xb4\
\x67\x28\x59\x0a\xcf\xd6\x54\xe2\x06\x13\x87\x2b\x6f\x68\xa6\x27\
\xaf\x31\x32\x36\xc7\xb2\x7f\x17\xef\x7d\x7c\x8c\x33\x67\xcf\x12\
\x70\x24\x4a\x69\xd6\x6a\x46\xd6\xd3\x70\x72\xa9\x82\x67\x34\x45\
\xad\x28\xdb\x1a\x15\x34\x98\xff\x46\xed\xef\x37\x0d\x99\xbf\x4a\
\x3c\x30\x38\xc0\xc8\x4b\xaf\x92\x5a\x9c\xe2\xe0\x23\x6d\x74\xb4\
\xba\x84\x5d\x0b\x29\x45\x7d\xb8\x94\x82\x96\xb6\x10\xf3\xc5\x12\
\x2a\xef\x53\x11\x1a\x63\xad\x3f\x93\x19\x85\xf1\xb1\x77\x58\x5a\
\xf8\x99\x97\x9f\xe9\xa6\x75\x47\x90\xc6\xb8\x43\xd8\xb5\xb6\xce\
\xfc\xfa\xfd\x00\xfb\x3e\xf4\xc8\x05\x35\xba\x5e\xeb\x46\x21\xf9\
\xcf\x0a\xa9\x8c\x87\xe3\x48\xdc\x90\xb5\x6e\x98\x6a\xaa\x65\xf2\
\x52\x92\x43\x2f\x5e\xc2\x8c\x02\x1a\x10\xf5\x07\xac\xc3\x75\x70\
\x83\x92\x80\xb3\xf9\xd0\x26\xf8\x8f\xb3\x29\xc6\x3e\xb8\x8c\x19\
\x35\x75\x6b\x7b\x7e\x3c\xca\x45\x0c\x7e\x49\x31\xf4\x58\x3b\xf7\
\xf6\x34\x90\x88\x39\x04\x1c\x59\x1f\xfe\xdb\xd5\x3c\x5f\x9d\x4b\
\x32\xfd\x44\xb2\xba\xd7\xfa\xb6\x60\xcf\xde\x16\xdc\x90\x45\x4c\
\x4a\x2a\x9e\x62\xfe\x4e\xc5\xc8\xc1\x4e\xda\x76\x86\xe8\xe9\x0a\
\xe3\xd8\x92\x58\xd4\xc6\xb2\x44\x6d\x78\x2a\x53\xe1\xca\x7c\x99\
\x63\x5d\xbf\x56\x9d\xbd\x9f\x44\x18\x7a\xba\x95\x27\x0f\xb4\xd3\
\xdc\x18\xc0\xf3\x0d\x52\x40\xd8\xb5\xb0\xa4\x20\x14\xb2\x70\x6c\
\x81\x63\xcb\xaa\x42\xd6\xfd\xb7\xf4\xec\xa3\x06\xa0\x50\x52\xd8\
\x4e\x1b\x7e\x4a\xd3\x31\xf9\x29\xcf\xfe\xd4\x49\x7f\x5f\x13\xfb\
\xfa\x9b\x71\x43\x92\x58\xd4\x21\x18\x90\xac\xde\xb0\x42\x50\x13\
\x58\x33\xf3\x88\x6b\xa1\xfd\x65\x96\xf2\x79\xc6\x43\x7b\xd8\x75\
\x38\xcc\x3d\xdd\xd1\xaa\xcf\x71\xe4\xff\x7f\x91\x56\x33\xaf\xea\
\x37\xe7\xa1\x94\x21\x16\xb5\xd1\x06\x2c\x29\x36\xf5\x72\x9b\x96\
\x95\xc0\xc4\xda\x9d\x78\x83\x43\x53\x22\x80\x65\x09\x1c\xfb\x86\
\xc1\x00\xe7\x25\x70\x14\x48\x6f\x1e\x22\x51\xe3\x75\xd9\xb6\xa5\
\x81\xa3\x32\xb1\xfb\xf4\x0c\x30\xb8\xb1\x82\x9b\xb0\x09\x60\x30\
\xb1\xfb\xf4\xcc\xbf\xa0\xe9\x6e\xae\x5a\xdf\x4b\x81\x00\x00\x00\
\x00\x49\x45\x4e\x44\xae\x42\x60\x82\
"
qt_resource_name = b"\
\x00\x07\
\x07\x3b\xe0\xb3\
\x00\x70\
\x00\x6c\x00\x75\x00\x67\x00\x69\x00\x6e\x00\x73\
\x00\x0b\
\x06\x1f\xb8\xc2\
\x00\x61\
\x00\x6d\x00\x63\x00\x72\x00\x5f\x00\x76\x00\x69\x00\x65\x00\x77\x00\x65\x00\x72\
\x00\x08\
\x0a\x61\x5a\xa7\
\x00\x69\
\x00\x63\x00\x6f\x00\x6e\x00\x2e\x00\x70\x00\x6e\x00\x67\
"
qt_resource_struct_v1 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x14\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\
\x00\x00\x00\x30\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
"
qt_resource_struct_v2 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x14\x00\x02\x00\x00\x00\x01\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x30\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9c\x23\xfd\x16\x70\
"
qt_version = [int(v) for v in QtCore.qVersion().split('.')]
if qt_version < [5, 8, 0]:
rcc_version = 1
qt_resource_struct = qt_resource_struct_v1
else:
rcc_version = 2
qt_resource_struct = qt_resource_struct_v2
def qInitResources():
QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
def qCleanupResources():
QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
qInitResources()
View File
Whitespace-only changes.
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,49 @@
## Context
`load_amcr_data` in `amcr_viewer/amcr_tools.py` (section B) builds, for each
DJ with a PIAN and *Načíst komponenty* on, one metadata dict per component
and appends it to `pian_lookup[pian_id]`. The weight is set as
`'vaha': 1/komps_count` with `komps_count = len(komps)` computed **before**
the loop that skips components failing `komp_projde_filtrem`. The empty-DJ
branch leaves `vaha` out and the feature builder falls back to
`meta.get('vaha', 1)`.
## Goals / Non-Goals
**Goals:** weights of one DJ sum to 1 under any filter; the rule is
testable offline.
**Non-Goals:** weighting across DJs or across records that share one PIAN
(a PIAN shared by several DJs still yields several features – the weight
only de-duplicates components of one DJ, as #55 asked); changing the layer
schema.
## Decisions
1. **Filter first, then weigh.** Build the list of passing components, then
set `vaha = 1/len(passing)`. Alternative – a second counting pass with
`sum(komp_projde_filtrem(...))` – duplicates the filter call and drifts
if the filter changes (#70 replaces it).
2. **Extract a small pure helper** (e.g. `_component_entries(dj_meta, komps,
passes)` returning the list of per-component dicts with `vaha`, where
`passes` is a predicate) so the smoke test can check weights without
QGIS layers or network. The helper must not depend on how components are
selected, so #70 can pass a different predicate.
3. **Explicit weight 1 for a DJ without components** instead of relying on
the `meta.get('vaha', 1)` default – the default stays as a safety net.
## Risks / Trade-offs
- Floating-point: 1/3 weights sum to 0.999…; acceptable for analyses,
test with a tolerance.
- The helper extraction touches a long function; keep the diff limited to
the component branch.
## Verification
- Smoke test cases: 4 components no filter → 4×0.25; filter keeps 1 of 4 →
weight 1; keeps 2 of 3 → 2×0.5; no components → 1 entry, weight 1.
- Full AGENTS.md check set (ltr + stable smoke test, pyqgis4-checker).
- Manual QGIS test by the user: download akce with *Načíst komponenty* and
a period filter, check in the attribute table that `prvek_vaha` sums to 1
per `dj_id`.
@@ -0,0 +1,42 @@
## Why
Issue #55 added the `prvek_vaha` (feature weight) attribute: when *Načíst
komponenty* is on, every component of a documentation unit (DJ) becomes its
own feature on the same PIAN geometry, and the weight 1/*n* lets spatial
analyses count the geometry once. The unreleased implementation on
`version/v2.2.0` takes *n* from **all** components of the DJ, before the
period/area filter. With a component filter active the weights of one DJ no
longer sum to 1 (DJ with 4 components, 1 passes the Neolithic filter → one
feature with weight 0.25 instead of 1), so weighted counts are wrong exactly
when users filter. See the comment on #55.
## What Changes
- *n* in `prvek_vaha = 1/n` is the number of component features actually
created for the DJ, i.e. components that pass the period/area filters.
- The weights of all features created from one DJ sum to 1 with or without
filters.
- A DJ without components keeps its single feature with weight 1 (today the
value comes from a default; it becomes explicit).
- `README.md` documents `prvek_vaha` in the component fields table (it is
missing there today).
- Changelog entry under v2.2.0 in `amcr_viewer/metadata.txt` is extended
(the feature is unreleased, no separate version bump).
## Capabilities
### New Capabilities
- `component-features`: one feature per component of a fieldwork event or
site, and the weight attribute that de-duplicates shared geometries.
### Modified Capabilities
## Impact
- `amcr_viewer/amcr_tools.py` – component feature creation in
`load_amcr_data` (section B, attribute parsing).
- `tests/smoke_test.py` – offline check of the weights.
- `README.md`, `amcr_viewer/metadata.txt` (changelog only).
- No change to the digiarchiv API contract, layer schema or stored settings.
- `filter-components-via-component-endpoint` (#70) changes how components
are selected; it builds on this change and must keep the weight rule.
@@ -0,0 +1,35 @@
# Spec Delta
## Purpose
Describes how components of fieldwork events and sites become map features
and how their weight lets spatial analyses count a shared geometry once.
## ADDED Requirements
### Requirement: Weight of component features sums to one per DJ
When components are loaded as features, each feature SHALL carry the weight
`prvek_vaha = 1/n`, where *n* is the number of features created from the
same documentation unit in this download. Components excluded by the period
or area filter SHALL NOT count towards *n*.
#### Scenario: No component filter
- **WHEN** a documentation unit has 4 components and no period or area filter is set
- **THEN** 4 features are created, each with weight 0.25
#### Scenario: Filter keeps some components
- **WHEN** a documentation unit has 4 components and the period filter matches 1 of them
- **THEN** 1 feature is created with weight 1
#### Scenario: Filter keeps two of three components
- **WHEN** a documentation unit has 3 components and the filter matches 2 of them
- **THEN** 2 features are created, each with weight 0.5, and their weights sum to 1
### Requirement: Documentation unit without components has weight one
When components are loaded and a documentation unit has no component, the
single feature created for it SHALL have weight 1 and empty component
fields.
#### Scenario: DJ without components, no filter
- **WHEN** a documentation unit with a PIAN has no components and no component filter is set
- **THEN** one feature is created with empty component fields and weight 1
@@ -0,0 +1,37 @@
# Tasks
## 1. Weight computed from passing components
- [x] 1.1 In `amcr_viewer/amcr_tools.py` extract the per-component entry
building of the *Načíst komponenty* branch into a pure helper that takes
the DJ metadata, the component list and a pass predicate, filters first
and sets `vaha = 1/len(passing)`; set `vaha = 1` explicitly for a DJ
without components; verify with `python3 tests/check_sources.py`,
`flake8 --isolated amcr_viewer/` and `ruff check .`
- [x] 1.2 Extend `tests/smoke_test.py` with an offline case for the helper
(4 components no filter → 4×0.25; 1 of 4 passes → 1.0; 2 of 3 pass →
2×0.5, sum 1 within tolerance; no components → 1 entry, weight 1);
verify the smoke test passes in `qgis/qgis:ltr` and `qgis/qgis:stable`
## 2. Documentation
- [x] 2.1 Add `prvek_vaha` (alias *Váha prvku*) to the component fields
table in `README.md` with the rule "1/n, n = features created from the
same documentation unit after filters"; verify by reading the rendered
table
- [x] 2.2 Extend the v2.2.0 changelog bullet about the feature weight in
`amcr_viewer/metadata.txt` (weights of one documentation unit sum to 1
also with period/area filters); no version bump – 2.2.0 is unreleased and
`CITATION.cff` already says 2.2.0; verify both versions match
## 3. Verification
- [x] 3.1 Run the full local check set from `AGENTS.md` (check_sources,
bandit, detect-secrets `--all-files`, flake8 `--isolated`, ruff,
pyqgis4-checker log empty, smoke test ltr + stable); verify all clean
- [x] 3.2 Manual test in QGIS (user): akce in a small window with *Načíst
komponenty* and one period filter; verify in the attribute table that
`prvek_vaha` sums to 1 per `dj_id`
- Verified by the maintainer 2026-10-02: akce and lokality with
*Načíst komponenty*, without and with a period filter – weights sum
to 1 per DJ and are computed only from the filtered components
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,81 @@
# Design
## Context
- The session lives only in memory (`amcr_tools.AMCR_SESSION`, a
`requests.Session` with the `JSESSIONID` cookie). `_get_session()` logs in
from stored credentials only when no session object exists, so after QGIS
start the first download always logs in; an expired session object is
reused forever.
- All data requests of a download go through `_api_get_json()` (main query
pages and PIAN batches). `_is_auth_error()` there reacts to HTTP 401 or
error text – neither occurs on expiry (see proposal.md – Why).
- Server behaviour (verified 2026-10-02, live API):
`GET /api/user/islogged` → `{"remaining": <s>}` when logged in,
`{"error": "nologged"}` otherwise, both HTTP 200. It does not extend the
session; any `search/query` does.
- Codelists (`amcr_codelists.py`) use plain `requests.get` without the
session – unaffected by login state.
## Goals / Non-Goals
**Goals:**
- One check at the start of `load_amcr_data`, before the first data request.
- Reuse existing login code (`login_to_api`, `LoginDialog.get_credentials`).
- Testable offline: the check takes its HTTP behaviour from the session
object so the smoke test can inject a fake.
**Non-Goals:**
- Checking before every page / PIAN batch (a download takes seconds to
minutes and every data request renews the sliding timeout).
- Refactoring session handling into a class.
## Decisions
1. **New helper `_ensure_logged_in() -> str`** in `amcr_tools.py`, returning
one of `"anonymous"` (no session, no credentials – nothing to check),
`"logged_in"`, `"relogged"`, `"fallback"` (expected login, ended
anonymous), `"unknown"` (check failed, proceeding).
Flow: get session via `_get_session()` (logs in if needed); if none and no
credentials → `anonymous`; if none but credentials → login failed →
`fallback`; otherwise call `islogged`; `remaining` → `logged_in`;
`nologged` → drop session, re-login once, verify again → `relogged` or
`fallback`; exception / non-JSON → `unknown`.
*Alternative:* re-login unconditionally before each download – simpler,
but one POST with the password per download and no way to distinguish a
real failure; rejected.
*Alternative:* compare `remaining` with a local timestamp of last request
– fragile (server timeout may change); rejected.
2. **Caller decides UI.** `load_amcr_data` pushes the message bar warning on
`fallback`; the helper only logs (keeps it free of `iface` for the test).
3. **Interpretation of the response:** logged in iff the body is a dict with
key `remaining`. Anything else with an `error` key → not logged in. Unknown
shape → `unknown` (do not trigger a re-login loop on a format change).
4. **Keep `_is_auth_error`** as a fallback, with a comment that the current
server never triggers it; removing it brings no benefit and it still
covers a possible future 401.
5. **Never log the response of `islogged?wantsUser=true`** – we do not use
that parameter at all; only `remaining` (number) is logged.
6. **Logout on credential removal** – new `logout_from_api()` in
`amcr_tools.py` called from `LoginDialog._forget_credentials`. The local
session is dropped first and unconditionally; the server call is best
effort (a failure is logged and reported in the dialog text). Without
it, the in-memory session would keep downloading logged-in data until
QGIS restarts even though the user believes he is "forgotten".
## Risks / Trade-offs
- [Extra request per download] → only for logged-in / credential users; cost
~100 ms.
- [Session expires during a very long download] → practically impossible:
each page request renews the 1 h sliding timeout.
- [Re-login prompts for the QGIS master password] → `get_credentials()` is
already called on first download after start; behaviour unchanged.
- [`islogged` endpoint changes shape] → `unknown`, logged warning, download
proceeds as today (no regression).
## Migration Plan
Plain plugin update; no settings or data migration. Rollback = previous
release.
@@ -0,0 +1,59 @@
# Proposal
## Why
Login to digiarchiv expires after 1 h of inactivity (`sessionTimeout: 3600`)
and the server then silently treats the request as anonymous: HTTP 200, no
`error`, only `pristupnost=A` data. The plugin detects expiry only by HTTP 401
or error text, which never arrives, so a logged-in user who downloads again
after a pause gets incomplete data without any warning (issue #72, verified
manually in QGIS and against the live API).
## What Changes
- Before each download the plugin checks the login state with
`GET /api/user/islogged` whenever the user is (or should be) logged in –
i.e. an in-memory session exists or credentials are stored.
- When the server answers `{"error": "nologged"}` and credentials are stored,
the plugin logs in again and continues the download with the new session.
- When the re-login fails (or credentials are missing), the plugin warns in
the QGIS message bar that the download runs anonymously and returns only
records with access level A – not only in the log.
- Removing the stored credentials (*Odebrat uložené přihlašovací údaje*)
also logs the session out on the server (`GET /api/user/logout`) and
drops it from memory; today it stays logged in until QGIS restarts.
- When the check itself cannot be completed (network error, invalid JSON),
the download is not blocked; the plugin logs a warning and proceeds.
- The existing error-text based detection (`_is_auth_error`) stays as
a fallback; it is documented as not triggered by the current server.
- Version bump + changelog (`metadata.txt`, `CITATION.cff`).
Out of scope:
- Keeping the session alive in the background (polling `islogged` does not
extend it anyway).
- Showing the user's access level in the UI (`islogged?wantsUser=true`).
- Codelist updates: `amcr_codelists` calls the API with plain `requests`
without the session, so login state does not affect them today.
## Capabilities
### New Capabilities
- `amcr-session`: login session against digiarchiv – validating the session
before a download, transparent re-login and informing the user when data
are downloaded anonymously.
### Modified Capabilities
<!-- none – openspec/specs/ is empty -->
## Impact
- Code: `amcr_viewer/amcr_dialog.py` (logout when credentials are
removed); `amcr_viewer/amcr_tools.py` (logout helper, new login-state check, call at the start
of `load_amcr_data`, message bar warning); `tests/smoke_test.py` (offline
test of the check with a mocked HTTP session).
- API: one extra `GET /api/user/islogged` per download, only when the user is
logged in or has stored credentials; anonymous users are unaffected.
- No new dependencies; Qt5/Qt6 compatibility rules from `AGENTS.md` apply.
@@ -0,0 +1,70 @@
# Spec Delta
## Purpose
Keeps a logged-in user's data downloads from digiarchiv running under a valid
login, and makes it visible when a download falls back to anonymous access.
## ADDED Requirements
### Requirement: Login state is verified before a download
Before starting a data download, the plugin SHALL ask the server whether the
current session is logged in, whenever an in-memory session exists or login
credentials are stored. Users with neither SHALL download anonymously without
this check.
#### Scenario: Valid session
- **WHEN** a session exists and the server reports it as logged in
- **THEN** the download proceeds with that session and no re-login happens
#### Scenario: Anonymous user without stored credentials
- **WHEN** no session exists and no credentials are stored
- **THEN** no login-state request is sent and the download proceeds anonymously without a warning
### Requirement: Expired login is renewed transparently
When the server reports the session as not logged in and credentials are
stored, the plugin SHALL log in again and run the whole download with the new
session.
#### Scenario: Session expired after inactivity
- **WHEN** the user downloads data more than one hour after the previous download within the same QGIS run
- **THEN** the plugin logs in again with the stored credentials and the download returns the same records as for a fresh login
#### Scenario: No session yet, credentials stored
- **WHEN** the first download after QGIS start is requested and credentials are stored
- **THEN** the plugin logs in and verifies that the new session is logged in before downloading
### Requirement: Anonymous fallback is reported to the user
When the plugin expected to be logged in but cannot obtain a logged-in
session, it SHALL show a warning in the QGIS message bar stating that the
download runs anonymously and contains only records with access level A.
#### Scenario: Re-login fails
- **WHEN** the session has expired and logging in again with stored credentials fails
- **THEN** a warning appears in the message bar and the download continues anonymously
#### Scenario: Session expired and credentials removed
- **WHEN** an in-memory session has expired and no credentials are stored any more
- **THEN** a warning appears in the message bar and the download continues anonymously
### Requirement: Failed state check does not block the download
If the login-state check cannot be completed (network error or a response
that is not valid JSON), the plugin SHALL log a warning and proceed with the
download using the current session.
#### Scenario: Login-state endpoint unreachable
- **WHEN** the login-state request fails with a network error
- **THEN** a warning is written to the log and the download is attempted as usual
### Requirement: Removing stored credentials logs the user out
When the user removes the stored credentials, the plugin SHALL log the
current session out on the server and discard it, so that later downloads
run anonymously without restarting QGIS.
#### Scenario: Credentials removed while logged in
- **WHEN** the user removes the stored credentials while a logged-in session exists
- **THEN** the session is logged out on the server and the next download is anonymous without a warning
#### Scenario: Server unreachable during logout
- **WHEN** the logout request fails with a network error
- **THEN** the session is still discarded locally and the user is told the next download will be anonymous
@@ -0,0 +1,53 @@
# Tasks
## 1. Login-state check
- [x] 1.1 Add `_ensure_logged_in()` to `amcr_viewer/amcr_tools.py` per
design.md (statuses `anonymous` / `logged_in` / `relogged` / `fallback` /
`unknown`, `GET /api/user/islogged` with the current session, one re-login
on `nologged`); verify with `python3 tests/check_sources.py` and
`ruff check .`
- [x] 1.2 Add a comment to `_is_auth_error` that the current server never
returns such an error on expiry and the check is kept as a fallback;
verify by reading the diff
- [x] 1.3 Extend `tests/smoke_test.py` with offline cases using a fake
session object (valid session, `nologged` + successful re-login,
`nologged` + failed re-login, no credentials, network error); verify the
smoke test passes in `qgis/qgis:ltr` and `qgis/qgis:stable`
## 2. Integration into the download
- [x] 2.1 Call `_ensure_logged_in()` in `load_amcr_data` after the
re-entrancy guard, before the first query; on `fallback` push a message
bar warning (Czech, scoped `Qgis.MessageLevel.Warning`) that the download
runs anonymously and contains only access level A; verify by smoke test
and code review
- [x] 2.2 Live check without credentials: anonymous download path sends no
`islogged` request and a made-up `JSESSIONID` yields `nologged`
(curl / probe script in scratch); verify outputs recorded in the PR
- [x] 2.3 Update `README.md` if it describes login/session behaviour; verify
the text matches the new behaviour (or note that nothing needed changing)
## 2b. Logout when credentials are removed
- [x] 2b.1 Add `logout_from_api()` to `amcr_tools.py` and call it from
`LoginDialog._forget_credentials`; extend the smoke test (session
logged out + dropped, network error still drops it, no session = no
request); update README and changelog; verify smoke test ltr + stable
- [x] 2b.2 Manual test in QGIS: log in, download, remove the stored
credentials, download again; verify the log shows "Uživatel odhlášen"
and the count drops to the anonymous one
## 3. Release preparation and verification
- [x] 3.1 Add changelog entries under v2.2.0 in `amcr_viewer/metadata.txt`
(the fix ships with 2.2.0; `CITATION.cff` already says 2.2.0 and
`date-released` moves on release day); verify both versions match
- [x] 3.2 Run the full local check set from `AGENTS.md` (check_sources,
bandit, detect-secrets `--all-files`, flake8 `--isolated`, ruff,
pyqgis4-checker log empty, smoke test ltr + stable); verify all clean
- [x] 3.3 Manual test in QGIS with a researcher account: download SN for
whole CZ, simulate expiry in the Python console with
`amcr_tools.AMCR_SESSION.get("https://digiarchiv.aiscr.cz/api/user/logout")`,
download again; verify log shows re-login and the count matches the
logged-in count (not the anonymous one)
+65
View File
@@ -0,0 +1,65 @@
schema: spec-driven
# Seeded from aiscr-management
# .agents/canonical_configs/templates/openspec/config_seed.yaml;
# from now on the content is owned by this repository.
context: |
Repository: aiscr-qgis-amcr-viewer — QGIS plugin (AMČR Viewer) for
downloading and visualising data from the AMČR Digital Archive
(digiarchiv.aiscr.cz).
OpenSpec posture: change-tracked.
This is the local reading of a posture declared and owned by the
management hub (aiscr-management, .agents/sync/repos.toml):
- change-tracked — change-scoped planning artifacts live under
`openspec/changes/`; no durable capability specs are maintained here.
Do not edit this line to unblock work in progress. Changing posture is
a decision taken deliberately with the hub and then applied here.
When OpenSpec fires: behaviour-changing work (user-visible behaviour,
layer attributes, the digiarchiv API contract the plugin relies on,
stored settings), cross-repo work, and governance-touching work. Not
typo and formatting fixes, dependency or tool-pin bumps that change no
behaviour, or refreshing a generated surface from its source.
Repository conventions: `AGENTS.md` is the single source of truth
(Qt5/Qt6 rules, versioning, branches, checks). Plugin code lives in
`amcr_viewer/` (entry point `amcr_viewer.py`, API and layers in
`amcr_tools.py`, dialogs in `amcr_dialog.py`, codelists in
`amcr_codelists.py`). User documentation is `README.md` (English);
planning artifacts, commits and PRs are in Czech or English as the
author prefers, code and identifiers in English.
rules:
proposal:
- State what changes for a user of the plugin, not only in the code
- Name the affected modules under amcr_viewer/ explicitly
- Say when the change depends on or affects the digiarchiv API or
another AIS CR repository, and where
specs:
- Use RFC 2119 keywords (SHALL/MUST/SHOULD/MAY)
- Use Given/When/Then scenarios for testable contracts
- Describe behaviour the plugin guarantees, not how the code does it
design:
- Record the alternatives considered and why the chosen one won
- Respect the QGIS 3.44 minimum and Qt5/Qt6 rules from AGENTS.md
- Name the verification that will show the change worked
tasks:
- Order tasks so each one is independently verifiable
- Name the command or check that proves each group is done
- Include the version bump (metadata.txt + CITATION.cff) when
behaviour changes
- Include a final verification task that runs the checks from
AGENTS.md (check_sources, bandit, detect-secrets, flake8, ruff,
pyqgis4-checker, smoke test in qgis/qgis:ltr and :stable)
operations:
apply:
guidance:
- Completed artifacts are not approval to implement; wait for an
explicit request to apply the change
archive:
guidance:
- Posture is change-tracked, so archive with --skip-specs (no
openspec/specs/ tree is maintained)
+43
View File
@@ -0,0 +1,43 @@
# Konfigurace lintů pro tento repozitář.
#
# Plugin se nedistribuuje jako Python balíček (do QGISu jde ZIP složky
# amcr_viewer/), takže tenhle soubor nic nebalí ani neinstaluje – slouží
# jen k tomu, aby ruff choval stejně v CI, lokálně i za rok. Bez explicitní
# konfigurace se výchozí sada pravidel mezi verzemi ruffu mění.
#
# Flake8 záměrně žádnou konfiguraci nemá a běží s výchozími pravidly – stejně
# jako scanner na plugins.qgis.org. Config soubor v balíčku by plugin
# označil jako „Validated (configured)“.
[tool.ruff]
line-length = 79
# QGIS 3.44 běží na Pythonu 3.9 a novějším
target-version = "py39"
[tool.ruff.lint]
select = [
"E", # pycodestyle – chyby
"W", # pycodestyle – varování
"F", # pyflakes
"I", # pořadí importů
"UP", # zastaralé konstrukce
"B", # bugbear
"C4", # comprehensions
"SIM", # zjednodušení
"RET", # návratové hodnoty
"BLE", # holé except
]
ignore = [
# Hlavička "# -*- coding: utf-8 -*-" je konvence šablony Plugin
# Builderu a drží se v celém projektu jednotně.
"UP009",
# "except Exception" je v pluginu záměr: výjimka nesmí propadnout do
# QGISu, chyba se uživateli ukáže v liště zpráv.
"BLE001",
# Obě dotčená místa mají ke každé větvi vysvětlující komentář,
# sloučením do jednoho výrazu by se čitelnost zhoršila.
"SIM103",
# contextlib.suppress() by kvůli jednomu místu přidal import a odsunul
# komentář, který vysvětluje, proč tam ta výjimka je.
"SIM105",
]
+83
View File
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""
Repository hygiene rules that need no QGIS and therefore run first.
Each rule guards a mistake that has already happened here at least once,
or one the plugins.qgis.org file analysis reports:
* a UTF-8 BOM makes the official pyqgis4-checker skip the file entirely,
so a broken file looks clean – it is silent, which is what makes it bad
* a direct PyQt5/PyQt6 import breaks the other Qt version
* an executable or hidden file in the package is reported on upload
Run it from the repository root:
python3 tests/check_sources.py
"""
import os
import re
import stat
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BALICEK = os.path.join(ROOT, "amcr_viewer")
# Extensions that have no business inside a plugin package
PODEZRELE = {".exe", ".dll", ".so", ".dylib", ".sh", ".bat", ".cmd",
".pyc", ".pyd", ".jar", ".bin"}
PRIMY_IMPORT = re.compile(r"^\s*(?:from|import)\s+PyQt[56]\b", re.MULTILINE)
nalezy = []
def zdrojaky():
for adresar, _, soubory in os.walk(BALICEK):
for soubor in sorted(soubory):
if soubor.endswith(".py"):
yield os.path.join(adresar, soubor)
def vsechny_soubory():
for adresar, _, soubory in os.walk(BALICEK):
for soubor in sorted(soubory):
yield os.path.join(adresar, soubor)
def zkratka(cesta):
return os.path.relpath(cesta, ROOT)
for cesta in zdrojaky():
with open(cesta, "rb") as f:
zacatek = f.read(3)
if zacatek == b"\xef\xbb\xbf":
nalezy.append(f"{zkratka(cesta)}: UTF-8 BOM na začátku souboru")
with open(cesta, encoding="utf-8-sig") as f:
text = f.read()
for shoda in PRIMY_IMPORT.finditer(text):
radek = text[:shoda.start()].count("\n") + 1
nalezy.append(f"{zkratka(cesta)}:{radek}: přímý import z PyQt5/PyQt6, "
f"použij shim qgis.PyQt")
for cesta in vsechny_soubory():
jmeno = os.path.basename(cesta)
rezim = os.stat(cesta).st_mode
if rezim & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
nalezy.append(f"{zkratka(cesta)}: spustitelná práva "
f"({stat.filemode(rezim)})")
# No exceptions: scanner config files (.flake8, .bandit,
# .secrets.baseline) would mark the upload "Validated (configured)"
if jmeno.startswith("."):
nalezy.append(f"{zkratka(cesta)}: skrytý soubor v balíčku pluginu")
if os.path.splitext(jmeno)[1].lower() in PODEZRELE:
nalezy.append(f"{zkratka(cesta)}: podezřelý typ souboru")
if nalezy:
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
sys.exit(1)
print("Kontrola zdrojáků: bez nálezů")
+188
View File
@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
"""
Guards the release PR (version/vX.Y.Z -> main).
Checks that the version actually moved forward and that the three places
that name it agree, because plugins.qgis.org and Zenodo both read from
files a human has to remember to touch by hand:
* amcr_viewer/metadata.txt: version= must be higher than on main, and the
first changelog entry must be for that exact version
* CITATION.cff: version must match metadata.txt, and date-released must
not be left pointing at the old release
* the branch name (version/vX.Y.Z) must match metadata.txt, so a stray
push to the wrong release branch is caught before merge
Run from the repository root:
python3 tests/check_version_bump.py <base_sha> <head_ref>
"""
import datetime
import re
import subprocess
import sys
METADATA = "amcr_viewer/metadata.txt"
CITATION = "CITATION.cff"
VERZE_METADATA = re.compile(r"^version=(.+)$", re.MULTILINE)
VERZE_CITATION = re.compile(r"^version:\s*'?([^'\n]+)'?\s*$", re.MULTILINE)
DATUM_CITATION = re.compile(r"^date-released:\s*'?([^'\n]+)'?\s*$",
re.MULTILINE)
# Přeskočí úvodní řádek "Plný seznam změn ... /vX.Y.Z" a najde první
# skutečnou položku changelogu.
POLOZKA_CHANGELOGU = re.compile(r"^\s+v([0-9][^\s(]*)\s*\(", re.MULTILINE)
BRANCH_VERZE = re.compile(r"^version/v(.+)$")
nalezy = []
def nacti_na_base(base_sha, cesta):
vysledek = subprocess.run(
["git", "show", f"{base_sha}:{cesta}"],
capture_output=True, text=True,
)
if vysledek.returncode != 0:
nalezy.append(f"{cesta}: na cílové větvi nejde přečíst "
f"(git show {base_sha}:{cesta} selhalo)")
return None
return vysledek.stdout
def nacti(cesta):
with open(cesta, encoding="utf-8") as f:
return f.read()
def hledej(vzor, text, cesta, popis):
shoda = vzor.search(text)
if not shoda:
nalezy.append(f"{cesta}: {popis} nenalezeno")
return None
return shoda.group(1).strip()
def verze_tuple(verze):
jadro = verze.split("-", 1)[0]
try:
return tuple(int(c) for c in jadro.split("."))
except ValueError:
return None
def je_bump(stara, nova):
if stara == nova:
return False
stara_t, nova_t = verze_tuple(stara), verze_tuple(nova)
if stara_t is None or nova_t is None:
# Nestandardní formát verze – nejde spolehlivě porovnat čísly,
# stačí tedy, že se řetězec liší.
return True
if nova_t != stara_t:
return nova_t > stara_t
# Stejné jádro (např. "-alpha" přípona): bump platí, pokud se text
# liší a nejde o couvnutí z release na prerelease.
return "-" in stara or "-" not in nova
def datum(text):
try:
return datetime.date.fromisoformat(text)
except ValueError:
return None
def main():
if len(sys.argv) != 3:
print("použití: check_version_bump.py <base_sha> <head_ref>")
return 2
base_sha, head_ref = sys.argv[1], sys.argv[2]
base_metadata = nacti_na_base(base_sha, METADATA)
base_citation = nacti_na_base(base_sha, CITATION)
head_metadata = nacti(METADATA)
head_citation = nacti(CITATION)
if base_metadata is None or base_citation is None:
# Bez base souborů nejde nic dalšího smysluplně ověřit.
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
return 1
stara_verze = hledej(VERZE_METADATA, base_metadata, METADATA,
"verze na cílové větvi (version=)")
nova_verze = hledej(VERZE_METADATA, head_metadata, METADATA,
"verze (version=)")
stara_citation_verze = hledej(VERZE_CITATION, base_citation, CITATION,
"verze na cílové větvi (version:)")
nova_citation_verze = hledej(VERZE_CITATION, head_citation, CITATION,
"verze (version:)")
stare_datum = hledej(DATUM_CITATION, base_citation, CITATION,
"date-released na cílové větvi")
nove_datum = hledej(DATUM_CITATION, head_citation, CITATION,
"date-released")
if None in (stara_verze, nova_verze, stara_citation_verze,
nova_citation_verze, stare_datum, nove_datum):
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
return 1
# 1. metadata.txt: verze musí jít dopředu.
if not je_bump(stara_verze, nova_verze):
nalezy.append(
f"{METADATA}: verze nepovýšena ({stara_verze} -> {nova_verze})")
# 2. metadata.txt: první položka changelogu musí patřit nové verzi.
prvni_polozka = POLOZKA_CHANGELOGU.search(head_metadata)
if not prvni_polozka:
nalezy.append(f"{METADATA}: v changelog= nenalezena žádná položka "
f"'vX.Y.Z (...)'")
elif prvni_polozka.group(1) != nova_verze:
nalezy.append(
f"{METADATA}: první položka changelogu je pro "
f"v{prvni_polozka.group(1)}, ale version={nova_verze}")
# 3. CITATION.cff: verze musí souhlasit s metadata.txt.
if nova_citation_verze != nova_verze:
nalezy.append(
f"{CITATION}: version: {nova_citation_verze} neodpovídá "
f"{METADATA} version={nova_verze}")
# 4. CITATION.cff: date-released se musí posunout, a ne dozadu.
stary_datum_obj, novy_datum_obj = datum(stare_datum), datum(nove_datum)
if stary_datum_obj is None or novy_datum_obj is None:
nalezy.append(f"{CITATION}: date-released není platné datum "
f"ISO 8601 ({stare_datum!r} -> {nove_datum!r})")
elif novy_datum_obj < stary_datum_obj:
nalezy.append(
f"{CITATION}: date-released couvlo ({stare_datum} -> "
f"{nove_datum})")
elif (nove_datum == stare_datum
and stara_citation_verze != nova_citation_verze):
nalezy.append(
f"{CITATION}: version se změnila, ale date-released zůstalo "
f"na {stare_datum}")
# 5. Název release větve musí odpovídat verzi, kterou nese.
shoda_branch = BRANCH_VERZE.match(head_ref)
if shoda_branch and shoda_branch.group(1) != nova_verze:
nalezy.append(
f"větev {head_ref!r} neodpovídá {METADATA} "
f"version={nova_verze}")
if nalezy:
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
return 1
print(f"Kontrola verze a changelogu: v{stara_verze} -> v{nova_verze}, "
f"bez nálezů")
return 0
if __name__ == "__main__":
sys.exit(main())
+376
View File
@@ -0,0 +1,376 @@
# -*- coding: utf-8 -*-
"""
Smoke test: loads the plugin inside a real QGIS and exercises the parts
that differ between Qt5 and Qt6.
It is deliberately offline – no request ever leaves the machine, so the
test says nothing about the AMCR API, only about the plugin loading and
its widgets being constructible.
Run it from the repository root:
python3 tests/smoke_test.py
QGIS must be importable (inside the qgis/qgis Docker image it already is).
The exit code is 0 when everything passed, 1 otherwise.
"""
import os
import sys
import traceback
import requests
# Offscreen, otherwise the dialogs need an X server
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, ROOT)
selhani = []
def zkouska(nazev, funkce):
"""Runs one check and keeps going even when it raises."""
try:
detail = funkce()
except Exception:
selhani.append(nazev)
print(f" FAIL {nazev}")
print(traceback.format_exc().rstrip())
else:
print(f" OK {nazev}" + (f" – {detail}" if detail else ""))
from qgis.core import ( # noqa: E402
Qgis,
QgsApplication,
QgsTask,
QgsWkbTypes,
)
from qgis.PyQt import QtCore # noqa: E402
from qgis.PyQt.QtCore import QDate # noqa: E402
print(f"QGIS {Qgis.QGIS_VERSION.split('-')[0]} | Qt {QtCore.QT_VERSION_STR} "
f"| PyQt {QtCore.PYQT_VERSION_STR}")
QgsApplication.setPrefixPath(os.environ.get("QGIS_PREFIX_PATH", "/usr"), True)
qgs = QgsApplication([], True)
qgs.initQgis()
import amcr_viewer.amcr_codelists # noqa: E402,F401
import amcr_viewer.amcr_dialog as dialog # noqa: E402
import amcr_viewer.amcr_tools # noqa: E402,F401
import amcr_viewer.amcr_viewer # noqa: E402,F401
print(" OK import všech modulů pluginu")
def enumy():
"""
The scoped enum forms must exist. Unscoped aliases still resolve in
QGIS 4.2, so a plain import proves nothing – these are read explicitly.
"""
return (f"QgsTask.Flag.CanCancel={int(QgsTask.Flag.CanCancel)}, "
f"PointGeometry={int(QgsWkbTypes.GeometryType.PointGeometry)}, "
f"MessageLevel.Info={int(Qgis.MessageLevel.Info)}")
def uloha():
ukol = dialog.UpdateCodelistsTask("smoke")
assert ukol.canCancel() is True
return "canCancel=True"
def dialogy():
# A modal warning would block the offscreen run forever
dialog.QMessageBox.warning = staticmethod(lambda *a, **k: None)
popis = []
for typ in ("akce", "lokalita", "samostatny_nalez"):
okno = dialog.AmcrFilterDialog(typ)
okno.show()
QgsApplication.processEvents()
popis.append(f"{typ}: {len(okno.date_ranges)} rozmezí")
okno.close()
return ", ".join(popis)
def filtr_datumu():
"""
A half-filled range must be completed with the sentinel. The API
rejects a one-sided range, so this is the part worth guarding.
The expected value is written out on purpose – comparing against
dialog.DATE_OPEN_TO would only prove the module agrees with itself.
"""
okno = dialog.AmcrFilterDialog("samostatny_nalez")
pole, _, od, _do = okno.date_ranges[0]
od.setDate(QDate(2016, 1, 1))
hodnota = okno.get_filters()[pole]
assert hodnota == "2016-01-01,9999-12-31", hodnota
# A range left completely empty must add no filter at all
prazdne = dialog.AmcrFilterDialog("samostatny_nalez")
pole_prazdne = prazdne.date_ranges[0][0]
assert pole_prazdne not in prazdne.get_filters()
prazdne.close()
okno.close()
return hodnota
class FalesnaSession:
"""Offline stand-in for requests.Session: returns canned JSON
bodies for GET /api/user/islogged and counts the requests."""
def __init__(self, tela):
# tela: a list of (body, exception) pairs – one per GET call,
# consumed in order; None body means raise the exception
self.tela = list(tela)
self.get_volani = 0
def get(self, url, timeout=0):
self.get_volani += 1
polozka = self.tela.pop(0)
# A bare dict is a plain body; (None, exception) means raise
if isinstance(polozka, tuple):
tela, vyjimka = polozka
else:
tela, vyjimka = polozka, None
if tela is None and vyjimka is not None:
raise vyjimka
class Odpoved:
def __init__(self, tela):
self.telo = tela
self.text = str(tela)
def json(self):
if isinstance(self.telo, Exception):
raise self.telo
return self.telo
return Odpoved(tela)
def prihlasovaci_stav():
"""
_ensure_logged_in with a fake session and monkeypatched login /
credentials / _get_session – everything stays offline.
Each case: (name, expected status, islogged bodies of the current
session, islogged bodies after re-login, fake login result,
stored credentials, expected number of islogged GETs).
"""
pripady = [
# Valid session – no re-login, no extra request
("platná session", "logged_in",
[{"remaining": 3500}], [], None, ("", ""), 1),
# nologged + successful re-login, verified again
("expired + re-login", "relogged",
[{"error": "nologged"}], [{"remaining": 1800}],
"session", ("uzivatel", "heslo"), 1),
# nologged + failed re-login
("expired + selhaný re-login", "fallback",
[{"error": "nologged"}], [], None, ("uzivatel", "heslo"), 1),
# nologged + no stored credentials
("expired bez údajů", "fallback",
[{"error": "nologged"}], [], None, ("", ""), 1),
# No session and no credentials – no request at all
("anonym bez údajů", "anonymous",
[], [], None, ("", ""), 0),
# Network error during the check
("chyba sítě", "unknown",
[(None, requests.exceptions.ConnectionError("probe"))],
[], None, ("", ""), 1),
# 200 but invalid JSON
("neplatný JSON", "unknown",
[(None, ValueError("Invalid JSON"))],
[], None, ("", ""), 1),
]
tools = amcr_viewer.amcr_tools
puvodni = (tools.login_to_api, tools._get_session,
dialog.LoginDialog.__dict__["get_credentials"])
try:
return _prihlasovaci_stav(pripady, tools)
finally:
# Leave the modules as they were found, even when a case fails
(tools.login_to_api, tools._get_session,
dialog.LoginDialog.get_credentials) = puvodni
tools.AMCR_SESSION = None
def _prihlasovaci_stav(pripady, tools):
"""Runs the cases of prihlasovaci_stav()."""
vysledky = []
for (nazev, ocekavano, tela, tela_po_loginu, login_vysledek,
kredity, get_volani) in pripady:
# The current session (None = _get_session returns None);
# a successful fake re-login produces a new fake session
session = FalesnaSession(tela) if tela else None
login_hodnota = FalesnaSession(tela_po_loginu) \
if login_vysledek else None
tools.AMCR_SESSION = session
def fake_login(hodnota):
# Like the real login_to_api: stores the session globally
def login(uzivatel, heslo):
if hodnota is not None:
tools.AMCR_SESSION = hodnota
return hodnota
return login
tools.login_to_api = fake_login(login_hodnota)
tools._get_session = (lambda s: lambda: s)(session) \
if session else (lambda: None)
dialog.LoginDialog.get_credentials = staticmethod(
(lambda k: lambda: k)(kredity)
)
stav = tools._ensure_logged_in()
assert stav == ocekavano, f"{nazev}: {stav} != {ocekavano}"
# The old session object must have been used for the checks
if session is not None:
assert session.get_volani == get_volani, \
f"{nazev}: {session.get_volani} != {get_volani}"
# The returned fake login session must become the global one
# and its login state must have been verified as well
if ocekavano == "relogged":
assert login_hodnota is not None
assert tools.AMCR_SESSION is login_hodnota
assert login_hodnota.get_volani == 1, \
f"{nazev}: nová session nebyla ověřena"
vysledky.append(f"{nazev} → {stav}")
return ", ".join(vysledky)
def odhlaseni():
"""logout_from_api with a fake session – offline."""
tools = amcr_viewer.amcr_tools
puvodni = tools.AMCR_SESSION
try:
# Logged-in session: one GET to /logout, session dropped
session = FalesnaSession([{"msg": "logged out"}])
session_get = session.get
urls = []
def get(url, timeout=0):
urls.append(url)
odpoved = session_get(url, timeout)
odpoved.raise_for_status = lambda: None
return odpoved
session.get = get
tools.AMCR_SESSION = session
assert tools.logout_from_api() is True
assert tools.AMCR_SESSION is None
assert urls and urls[0].endswith("/api/user/logout"), urls
# Network error: session still dropped locally
chyba = FalesnaSession(
[(None, requests.exceptions.ConnectionError("probe"))]
)
tools.AMCR_SESSION = chyba
assert tools.logout_from_api() is False
assert tools.AMCR_SESSION is None
assert chyba.get_volani == 1
# No session: nothing to do, no request
assert tools.logout_from_api() is True
finally:
tools.AMCR_SESSION = puvodni
return "odhlášení, chyba sítě → zahozeno lokálně, bez session → nic"
def vaha_komponent():
"""
_component_entries: the weight is 1/n of the components that pass
the predicate, so the weights of one documentation unit sum to 1
even with a period/area filter active.
The cases come from the spec of the fix (issue #55); the sums are
compared with a tolerance because 1/3 weights add up to 0.999…
"""
tools = amcr_viewer.amcr_tools
def komponenta(ident, areal=None, obdobi=None):
return {
"ident_cely": ident,
"komponenta_areal": ({"id": areal} if areal else None),
"komponenta_obdobi": ({"id": obdobi} if obdobi else None),
}
dj_meta = {"dj_id": "X-M-000001"}
# 4 components, no filter: 4 features, each 0.25
komps = [komponenta(f"K{i}") for i in range(4)]
zaznamy = tools._component_entries(dj_meta, komps, lambda k: True)
assert len(zaznamy) == 4, len(zaznamy)
assert all(z["vaha"] == 0.25 for z in zaznamy), \
[z["vaha"] for z in zaznamy]
# Period filter keeps 1 of 4: single feature with weight 1
komps = [
komponenta("K0", obdobi="neolit"),
komponenta("K1"), komponenta("K2"), komponenta("K3"),
]
zaznamy = tools._component_entries(
dj_meta, komps, lambda k: k["komponenta_obdobi"] is not None
)
assert len(zaznamy) == 1, len(zaznamy)
assert zaznamy[0]["vaha"] == 1.0, zaznamy[0]["vaha"]
# Filter keeps 2 of 3: 2 features, each 0.5, sum 1 within tolerance
komps = [
komponenta("K0", obdobi="neolit"), komponenta("K1", obdobi="bronz"),
komponenta("K2"),
]
zaznamy = tools._component_entries(
dj_meta, komps, lambda k: k["komponenta_obdobi"] is not None
)
assert len(zaznamy) == 2, len(zaznamy)
assert all(z["vaha"] == 0.5 for z in zaznamy), \
[z["vaha"] for z in zaznamy]
assert abs(sum(z["vaha"] for z in zaznamy) - 1) < 1e-9
# Sum with tolerance also for an indivisible split (1/3)
komps = [komponenta(f"K{i}") for i in range(3)]
zaznamy = tools._component_entries(dj_meta, komps, lambda k: True)
assert abs(sum(z["vaha"] for z in zaznamy) - 1) < 1e-9
# No components: one entry with empty component fields, weight 1
zaznamy = tools._component_entries(dj_meta, [], lambda k: True)
assert len(zaznamy) == 1, zaznamy
assert zaznamy[0]["vaha"] == 1, zaznamy[0]["vaha"]
assert zaznamy[0]["komponenta_id"] == ""
# The shared DJ metadata and component fields travel along
komps = [komponenta("K0", areal="sidelni", obdobi="neolit")]
zaznamy = tools._component_entries(dj_meta, komps, lambda k: True)
assert zaznamy[0]["dj_id"] == "X-M-000001"
assert zaznamy[0]["komponenta_id"] == "K0"
return "4×0.25; 1/4 → 1.0; 2/3 → 2×0.5; prázdné → 1"
zkouska("scoped enumy", enumy)
zkouska("UpdateCodelistsTask", uloha)
zkouska("filtrační dialogy", dialogy)
zkouska("filtr podle data", filtr_datumu)
zkouska("stav přihlášení", prihlasovaci_stav)
zkouska("odhlášení", odhlaseni)
zkouska("váha komponent", vaha_komponent)
qgs.exitQgis()
if selhani:
print(f"\nNEPROŠLO: {', '.join(selhani)}")
sys.exit(1)
print("\nVše prošlo")