Compare commits

...
17 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
31 changed files with 1224 additions and 235 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>`)
+43 -11
View File
@@ -28,6 +28,7 @@ env:
DETECT_SECRETS: detect-secrets==1.5.0
FLAKE8: flake8==7.3.0
RUFF: ruff==0.16.5
OPENSPEC: 1.14.0
jobs:
# --------------------------------------------------------------------
@@ -39,7 +40,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
@@ -74,10 +75,10 @@ jobs:
print('detect-secrets: bez nálezů')
"
# Na plugins.qgis.org je informativní, tady blokuje – konfigurace
# v amcr_viewer/.flake8 je stejná pro obě místa.
# Na plugins.qgis.org je informativní, tady blokuje. Bez konfigurace,
# tj. se stejnými výchozími pravidly jako scanner.
- name: Flake8
run: flake8 --config amcr_viewer/.flake8 amcr_viewer/
run: flake8 --isolated amcr_viewer/
# Nad rámec plugins.qgis.org; konfigurace v pyproject.toml
- name: Ruff
@@ -92,7 +93,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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.
@@ -110,6 +111,24 @@ jobs:
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
# --------------------------------------------------------------------
@@ -125,7 +144,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Smoke test
run: |
@@ -142,19 +161,32 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Zip plugin
run: zip -r amcr_viewer.zip amcr_viewer -x "*.git*"
# Kontrola obsahu ZIPu. Config soubory pro scanner musí být uvnitř
# vedle metadata.txt, jinak je plugins.qgis.org nenajde – a některé
# nástroje skryté soubory tiše vynechávají.
# 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/.flake8; do
amcr_viewer/LICENSE; do
unzip -l amcr_viewer.zip | grep -qF " $soubor" \
|| { echo "::error::v ZIPu chybí $soubor"; exit 1; }
done
+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
+44 -11
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.
@@ -189,7 +221,7 @@ tohle:
| **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** | sestaví ZIP, ověří obsah, přiloží jako artefakt |
| **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.
@@ -205,7 +237,7 @@ pip install bandit detect-secrets flake8 ruff
python3 tests/check_sources.py
bandit -r amcr_viewer/
detect-secrets scan --all-files amcr_viewer/
flake8 --config amcr_viewer/.flake8 amcr_viewer/
flake8 --isolated amcr_viewer/
ruff check .
# smoke test v obou verzích QGIS (docker, bez instalace čehokoli)
@@ -222,11 +254,12 @@ Na co si dát pozor:
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.
- **Konfigurace lintů 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; díky tomu platí stejná pravidla
v CI, lokálně i při uploadu. Konfigurace ruffu je naopak v kořenovém
`pyproject.toml` – ruff se do balíčku pluginu nedistribuuje.
- **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.1.2'
date-released: '2026-09-01'
version: '2.2.0'
date-released: '2026-10-01'
+12 -7
View File
@@ -106,12 +106,16 @@ to see.
* 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. If the session cookie
expires mid-download, the plugin re-authenticates automatically and repeats
the request.
* 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*).
(*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
@@ -294,6 +298,7 @@ order *common → entity-specific → `pristupnost` → component fields*.
| `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
@@ -333,9 +338,7 @@ amcr_viewer/ the plugin package (this is what gets zipped)
codelists/heslar.csv cached controlled vocabularies
i18n/ Qt translation files
*.png toolbar and menu icons
resources.py generated by pyrcc, currently unused
metadata.txt plugin metadata and changelog
.flake8 lint config, read by the plugins.qgis.org scanner
tests/
check_sources.py source hygiene checks (no QGIS needed)
smoke_test.py loads the plugin in a real, headless QGIS
@@ -349,6 +352,8 @@ AGENTS.md contributor and AI-agent guidelines
| 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. |
@@ -404,7 +409,7 @@ Reproducing them locally:
```bash
python3 tests/check_sources.py
ruff check .
flake8 --config amcr_viewer/.flake8 amcr_viewer/
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
-12
View File
@@ -1,12 +0,0 @@
# Konfigurace flake8 pro plugin AMČR Viewer.
#
# Soubor leží vedle metadata.txt schválně: scanner na plugins.qgis.org
# hledá .flake8 pouze v kořeni balíčku uvnitř ZIPu, takže stejná pravidla
# platí v CI, lokálně i při uploadu.
# https://plugins.qgis.org/docs/security-scanning/config-files
[flake8]
# resources.py je vygenerovaný výstup pyrcc ("All changes made in this
# file will be lost"), není nikde importovaný a zdrojový .qrc v repu není.
# Ručně se neformátuje.
per-file-ignores =
*resources.py: E302,E305,E501
+66 -5
View File
@@ -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)
+40 -10
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(
@@ -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",
@@ -1013,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:
@@ -1020,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()
# ------------------------------------------------------------------
+206 -35
View File
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8 -*-
import json
import requests
@@ -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,31 +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:
komps_count = len(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', ""),
'vaha': 1/komps_count,
}
)
):
pian_lookup[dj_pian_value].append(
komp_meta)
target_pian_ids_count += 1
@@ -714,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)
+9 -1
View File
@@ -27,7 +27,15 @@ changelog=
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
* 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)
+3 -4
View File
@@ -5,15 +5,14 @@
# 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í.
#
# Konfigurace flake8 je záměrně jinde: v amcr_viewer/.flake8, protože ji
# musí najít i scanner na plugins.qgis.org.
# 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"
# Generovaný výstup pyrcc, "All changes made in this file will be lost"
extend-exclude = ["amcr_viewer/resources.py"]
[tool.ruff.lint]
select = [
+3 -5
View File
@@ -23,10 +23,6 @@ import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BALICEK = os.path.join(ROOT, "amcr_viewer")
# Files that belong in the plugin package even though the upload scanner
# would otherwise call them hidden
POVOLENE_SKRYTE = {".flake8", ".bandit", ".secrets.baseline"}
# Extensions that have no business inside a plugin package
PODEZRELE = {".exe", ".dll", ".so", ".dylib", ".sh", ".bat", ".cmd",
".pyc", ".pyd", ".jar", ".bin"}
@@ -72,7 +68,9 @@ for cesta in vsechny_soubory():
if rezim & (stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH):
nalezy.append(f"{zkratka(cesta)}: spustitelná práva "
f"({stat.filemode(rezim)})")
if jmeno.startswith(".") and jmeno not in POVOLENE_SKRYTE:
# 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")
+2 -1
View File
@@ -161,7 +161,8 @@ def main():
nalezy.append(
f"{CITATION}: date-released couvlo ({stare_datum} -> "
f"{nove_datum})")
elif nove_datum == stare_datum and stara_citation_verze != nova_citation_verze:
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}")
+246
View File
@@ -19,6 +19,8 @@ import os
import sys
import traceback
import requests
# Offscreen, otherwise the dialogs need an X server
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@@ -117,10 +119,254 @@ def filtr_datumu():
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()