Merge pull request #87 from ARUP-CAS/version/v2.2.0

Vydání verze 2.2.0: sjednocené popisky filtrů, váha prvku u komponent, ověření přihlášení před stahováním, zapamatované filtry s resetem a aktualizované README.
This commit is contained in:
david-spacil authored and GitHub committed 2026-10-02 22:37:02 +02:00
commit 7ce7b80301
23 files changed
+2135 -73

No files matched your search

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-02
@@ -0,0 +1,172 @@
# Design
## Context
See proposal.md – Why. Current state of `amcr_viewer/amcr_dialog.py`
(branch `version/v2.2.0`):
- `AmcrViewer.run_download()` builds a new `AmcrFilterDialog(typ_dat)` for
every opening (`amcr_viewer.py`), without a parent.
- Form state is spread over:
- `self.selection_cache` – 19 keys, each a list of codelist codes; the
picker's read-only `QLineEdit` text is only set inside the nested
`open_dialog()` closure in `setup_picker()`, which keeps no reference
to the line edit.
- checkboxes `chk_bbox` (default checked), `chk_posevidence`,
`chk_proj_akce` (events only), `chk_komponenty` (events and sites);
- `self.date_ranges` – `(api_field, name, date_from, date_to)` with
nullable `QgsDateEdit`s (`clear()` is the only correct way to empty
them, see `_date_edit`).
- The one non-empty default, *PIAN – přesnost* = `HES-000861/862/863`, is
hard-coded inside `setup_picker()` together with its display text.
- Codelists are module-level dicts in `amcr_codelists.py` mapping
**label → code**; `refresh_globals()` updates them in place after
*Aktualizovat hesláře*, so the dialog always sees the current values.
- `tests/smoke_test.py` already builds the dialog offscreen for all three
data types and checks `get_filters()` for date ranges.
## Goals / Non-Goals
**Goals:**
- One snapshot format that describes the whole form, used for remember,
restore, defaults and "is it default?" comparison.
- No change in `get_filters()` / `get_bbox()` / `get_komponenty()` output
for the same form state.
**Non-Goals:**
- Persisting to `QgsSettings` or the project (variants B and C of #84).
- Remembering the window size or scroll position.
## Decisions
### Snapshot = plain dict kept at module level in `amcr_dialog.py`
`_REMEMBERED_STATE: dict[str, dict]` keyed by `typ_dat`. The snapshot is
```python
{
"codes": {cache_key: [code, ...], ...}, # only pickers of this typ
"checks": {"bbox": bool, "posevidence": bool, ...},
"dates": {api_field: (iso_from | None, iso_to | None), ...},
}
```
Dates are stored as ISO strings (or `None` for an empty picker), never as
`QDate`, so a stored value can never turn an empty picker into "today".
- *Why module level, not on `AmcrViewer`:* `run_download()` stays
untouched and the smoke test can exercise remember/restore just by
creating dialogs. QGIS (and Plugin Reloader) re-imports the plugin
package on reload, which drops the dict – that matches the "QGIS run
only" requirement.
- *Alternative – keep one dialog instance per type and only `hide()` it:*
rejected. Restoring would be free, but Cancel would then keep the
cancelled edits (the requirement says it must not), and long-lived
dialogs would hold stale codelist references after an update.
- *Alternative – pass state in/out through `AmcrViewer`:* works, but adds
plumbing in two files for no behavioural gain.
### Defaults defined once
A module-level `DEFAULT_CODES = {"pian_presnost": ["HES-000861",
"HES-000862", "HES-000863"]}` and `DEFAULT_CHECKS = {"bbox": True}`
replace the hard-coded block in `setup_picker()`. `_default_state()`
builds a full snapshot for the dialog's `typ_dat` from them. It is used
by the constructor (no remembered state), the reset button and the
"differs from defaults" comparison – one definition, three users.
### Pickers keep a handle to their widgets
`setup_picker()` registers each picker in `self.pickers[cache_key] =
(data_source, display_field, clear_btn)`. A single
`_set_picker(cache_key, codes)` sets `selection_cache`, rebuilds the
display text from the current codelist (inverted `code → label`, sorted
like the selection dialog), drops unknown codes and enables/disables the
clear button. `open_dialog()`, restore, reset and clear all go through
it, so the display text can never disagree with the cache.
Display text is rebuilt from codes rather than stored, so a label renamed
by a codelist update shows its new name, and a removed code disappears
(spec: *Restored values follow the current codelists*).
### Remember only in `accept()` after validation
`accept()` already returns early on a reversed date range; the snapshot is
taken just before `super().accept()`. `reject()` is not overridden.
### Reset button
`QDialogButtonBox.StandardButton.RestoreDefaults` with Czech text
*Obnovit výchozí* (the standard button would otherwise show the Qt
translation of "Restore Defaults", which depends on the installed Qt
translations). Clicking applies `_default_state()` to the form and hides
the notice; `_REMEMBERED_STATE` is untouched until OK.
The button box already holds *Aktualizovat hesláře* in `ActionRole`; the
reset button sits next to it on the left, OK/Cancel stay on the right.
### Per-picker clear button
A narrow `QToolButton` with text `✕` next to *Vybrat…*. It calls
`_set_picker(cache_key, DEFAULT_CODES.get(cache_key, []))` – it returns
the filter to its default, which is empty for every picker except
*pian_presnost* (its three pre-selected levels). The tooltip is
*Vymazat výběr* for an empty default and *Vrátit výchozí výběr* for
*pian_presnost*. `_set_picker()` enables the button only while the
current codes differ from the default (compared order-insensitively, so
a reordered default still counts as the default); on a fresh dialog the
PIAN button is therefore disabled. For `pian_presnost`, empty means the
filter is not sent (current `get_filters()` behaviour for an empty
list) – this matches the spec.
Changed after the user's manual test in QGIS: the ✕ on *PIAN – přesnost*
emptied the picker, but the user expected it to restore the default
three levels, so the button now returns each filter to its default
instead of always emptying it.
Checkboxes and date pickers do not get their own clear button: a
checkbox is one click, and `QgsDateEdit` with `setAllowNull(True)`
already has its own clear control.
### Notice about restored filters
A `QLabel` above the bbox checkbox, styled like the existing component
warning (neutral info colours), hidden by default. Shown in the
constructor only when a remembered state exists **and** differs from
`_default_state()`. Text: *Načteny filtry z minulého hledání (aktivní
filtry: N).* (changed after the user's manual test in QGIS: the leading
"ℹ " was removed). N counts form items that differ from the default –
one per picker, checkbox and date row (a date row counts once even
with both bounds set). Hidden again on reset; not updated live on
every edit (it describes what was loaded, not the current form).
### Qt5/Qt6
`QToolButton` from `qgis.PyQt.QtWidgets`; all enums fully scoped
(`QDialogButtonBox.StandardButton.RestoreDefaults`,
`QDialogButtonBox.ButtonRole.ResetRole`); no `exec_()`.
## Risks / Trade-offs
- [Forgotten filter gives a suspiciously small result] → notice at the top
with a count; reset is one click.
- [Restored bbox restriction with a different map extent] → bbox is a
checkbox, the extent itself is read at download time as today; nothing
extent-specific is stored.
- [Codelist update removes a selected code] → dropped silently on
restore. Considered warning about it; not done, because the picker text
already shows what is selected and the case is rare.
- [Plugin reload during development keeps the old dict] → only if the
package is not re-imported; both QGIS and Plugin Reloader do re-import.
## Verification
- Smoke test (offline, `qgis/qgis:ltr` and `qgis/qgis:stable`): OK →
reopen restores codes/checks/dates and `get_filters()` is equal; Cancel
keeps the previous state; reset + OK equals a fresh dialog; clear drops
one key from `get_filters()`; unknown code is dropped; notice visible
only for non-default state; types do not share state.
- Manual test in QGIS 3.44 and QGIS 4: the scenarios from the spec, plus
*Aktualizovat hesláře* between two openings.
@@ -0,0 +1,72 @@
# Proposal
## Why
The filter dialog (`AmcrFilterDialog`) is created from scratch every time
the user opens it, so every selection is lost after each download. Refining
a query ("same area, add one more period") means re-entering every picker
and date by hand. There is also no quick way back to the default state once
many filters are set (issue #84).
## What Changes
- The filter dialog remembers the last confirmed filters **per data type**
(Fieldwork events, Sites, Individual finds) for the rest of the QGIS
run. Reopening the dialog for the same data type restores all pickers,
checkboxes and date ranges. Nothing is written to disk; after a QGIS
restart (or a plugin reload) the dialog starts from the defaults again.
- State is remembered only when the dialog is confirmed with OK (after the
existing date-range validation passes). *Cancel* leaves the remembered
state unchanged.
- A new *Obnovit výchozí* button (`RestoreDefaults` role) in the button
row resets the whole form to its **defaults**, not to an empty form:
*Omezit vyhledávání rozsahem okna* checked, *PIAN – přesnost* with its
three pre-selected levels, everything else empty. The reset is applied
to the form only; the remembered state changes only on OK.
- When the dialog opens with restored filters that differ from the
defaults, a notice at the top says so and how many filters are active,
so a forgotten filter further down the scrollable form is not missed.
- Each picker gets a small clear button (✕) that returns that single
filter to its **default** (empty for almost all pickers, the three
pre-selected accuracy levels for *PIAN – přesnost*); it is available
only while the filter differs from that default. "No PIAN
restriction" is still reachable by unchecking all levels in the
selection dialog.
- Restored codes that are no longer in the current codelists (after
*Aktualizovat hesláře*) are dropped, and picker texts are rebuilt from
the current codelist labels.
- README (section 3.3) and the v2.2.0 changelog entry in `metadata.txt`
describe the new behaviour.
Out of scope:
- Persisting filters across QGIS restarts (`QgsSettings`) or in the QGIS
project – considered in issue #84 as variants B and C, not chosen.
- Sharing filter values between data types.
## Capabilities
### New Capabilities
- `filter-dialog`: state of the filter dialog between openings – remembered
filters per data type, reset to defaults, clearing a single filter and
the notice about restored filters.
### Modified Capabilities
<!-- none – openspec/specs/ is not maintained (change-tracked) -->
## Impact
- Code: `amcr_viewer/amcr_dialog.py` (state capture/restore, defaults in
one place, reset button, per-picker clear button, notice);
`tests/smoke_test.py` (offline cases for restore, cancel, reset, clear
and dropped codes). `amcr_viewer/amcr_viewer.py` is not expected to
change – `run_download` keeps creating the dialog as today.
- No change to the digiarchiv API requests: `get_filters()`, `get_bbox()`
and `get_komponenty()` keep their output for the same form state.
- No change to layer attributes or stored settings (`QSettings` is not
touched).
- Target branch `version/v2.2.0` (unreleased): the change joins the v2.2.0
changelog entry, no separate version bump.
- Qt5/Qt6 rules from `AGENTS.md` apply; no new dependencies.
@@ -0,0 +1,111 @@
# Spec Delta
## Purpose
Keeps the filter dialog's selections between openings within one QGIS run,
so a query can be refined without re-entering it, and gives quick ways back
to the default state.
## ADDED Requirements
### Requirement: Confirmed filters are remembered per data type
When the user confirms the filter dialog with OK, the plugin SHALL remember
the whole form state (all pickers, checkboxes and date ranges) for that
data type, and SHALL restore it the next time the dialog for the same data
type is opened within the same QGIS run. Each data type (Fieldwork events,
Sites, Individual finds) SHALL have its own remembered state.
#### Scenario: Reopening after a download
- **GIVEN** the user opened the Fieldwork events dialog, selected a region and a period, set a start-date range and confirmed with OK
- **WHEN** the user opens the Fieldwork events dialog again
- **THEN** the same region, period and date range are selected and confirming without changes sends the same filters as before
#### Scenario: Data types do not share state
- **GIVEN** filters were confirmed in the Fieldwork events dialog
- **WHEN** the user opens the Sites dialog for the first time
- **THEN** the Sites dialog shows its defaults
#### Scenario: Cancel keeps the previous state
- **GIVEN** a remembered state exists for a data type
- **WHEN** the user changes filters and closes the dialog with Cancel
- **THEN** reopening the dialog shows the remembered state, not the cancelled changes
#### Scenario: Rejected date range is not remembered
- **WHEN** the user confirms a reversed date range and the dialog refuses it
- **THEN** the remembered state is unchanged
### Requirement: Remembered state lives only for the QGIS run
The remembered filters SHALL NOT be written to disk, QGIS settings or the
project; after QGIS is restarted or the plugin is reloaded, every dialog
SHALL open with its defaults.
#### Scenario: QGIS restart
- **GIVEN** filters were confirmed in a previous QGIS run
- **WHEN** the user opens the dialog after restarting QGIS
- **THEN** the dialog shows its defaults
### Requirement: Reset restores the defaults
The filter dialog SHALL offer a reset action that returns every field of
the form to its default: the map-extent restriction checked, *PIAN –
přesnost* with its three pre-selected accuracy levels (where the data type
has it), and every other filter empty. The reset SHALL change only the
form; the remembered state SHALL change only when the dialog is then
confirmed with OK.
#### Scenario: Reset and confirm
- **GIVEN** several filters are set
- **WHEN** the user resets the form and confirms with OK
- **THEN** the sent filters equal those of a dialog opened for the first time, and reopening shows the defaults
#### Scenario: Reset and cancel
- **GIVEN** a remembered state exists
- **WHEN** the user resets the form and closes the dialog with Cancel
- **THEN** reopening the dialog shows the remembered state
### Requirement: A single filter can be returned to its default
Each codelist filter SHALL offer a per-picker action that returns only
that filter to its default value (empty, or the three pre-selected
accuracy levels for *PIAN – přesnost*). The action SHALL be available
only while the filter differs from its default. Returning *PIAN –
přesnost* to its default SHALL restore the three pre-selected accuracy
levels; a completely empty *PIAN – přesnost* (no restriction) SHALL
remain reachable by unchecking all levels in the picker's selection
dialog.
#### Scenario: Clearing one picker
- **GIVEN** a region and a period are selected
- **WHEN** the user clears the region filter
- **THEN** the region filter shows nothing selected, the period stays selected and the region parameter is not sent
#### Scenario: Returning PIAN to its default
- **GIVEN** a Fieldwork events dialog is open with *PIAN – přesnost* at its default three accuracy levels
- **WHEN** the user changes the PIAN selection (for example clears it)
- **THEN** the picker's clear action becomes available and, when used, restores exactly the three pre-selected accuracy levels
- **WHEN** the user unchecks all levels in the *PIAN – přesnost* selection dialog instead
- **THEN** no accuracy restriction is sent
### Requirement: Restored filters are announced
When the dialog opens with a restored state that differs from the
defaults, it SHALL show a notice at the top of the form stating that
filters from the previous search were restored and how many filters
differ from the defaults. The notice SHALL disappear once the form is
reset to the defaults.
#### Scenario: Notice after reopening
- **GIVEN** a region and a period were confirmed
- **WHEN** the dialog is reopened
- **THEN** a notice at the top says filters were restored and that 2 filters are active
#### Scenario: No notice for defaults
- **WHEN** the dialog opens with no remembered state, or with a remembered state equal to the defaults
- **THEN** no notice is shown
### Requirement: Restored values follow the current codelists
When restoring, the plugin SHALL drop selected codes that are no longer
present in the current codelists and SHALL display the remaining
selections with their current codelist labels.
#### Scenario: Code removed by a codelist update
- **GIVEN** a confirmed selection contains a code that a later codelist update removed
- **WHEN** the dialog is reopened
- **THEN** that code is not selected and not sent, and the other selected values remain
@@ -0,0 +1,82 @@
# Tasks
## 1. Form state in one place
- [x] 1.1 In `amcr_viewer/amcr_dialog.py` add `DEFAULT_CODES` /
`DEFAULT_CHECKS` and `_default_state()`; remove the hard-coded
`pian_presnost` block from `setup_picker()` and apply the default
through the new path. Verify: smoke test case "filtrační dialogy"
still passes and a fresh `akce`/`lokalita` dialog still sends
`f_pian_presnost` with the three codes (assert in the new test 1.4)
- [x] 1.2 Register pickers in `self.pickers` and add `_set_picker()`
(cache + display text rebuilt from the current codelist, unknown codes
dropped, clear-button state); route `open_dialog()` through it.
Verify: `python3 tests/check_sources.py`, `ruff check .`
- [x] 1.3 Add `_snapshot()` / `_apply_state()` covering codes, checkboxes
and date ranges (ISO strings or `None`; empty picker via `clear()`).
Verify: smoke test round-trip – snapshot → apply on a fresh dialog →
equal `get_filters()`, `get_bbox()`, `get_komponenty()`
- [x] 1.4 Extend `tests/smoke_test.py` with an offline case for 1.1–1.3
(defaults incl. PIAN, round-trip for all three data types, unknown code
dropped). Verify: smoke test passes in `qgis/qgis:ltr` and
`qgis/qgis:stable`
## 2. Remember, reset, clear, notice
- [x] 2.1 Module-level `_REMEMBERED_STATE` keyed by `typ_dat`; store the
snapshot in `accept()` after the date-range check, restore in the
constructor. Verify (smoke test): OK → reopen restores; Cancel keeps
the previous state; reversed range refused → state unchanged; another
data type starts from defaults
- [x] 2.2 *Obnovit výchozí* button
(`QDialogButtonBox.StandardButton.RestoreDefaults`, Czech text) that
applies `_default_state()` to the form only. Verify (smoke test): reset
+ OK equals a fresh dialog; reset + Cancel keeps the remembered state
- [x] 2.3 `✕` button (`QToolButton`, tooltip *Vymazat výběr*, or
*Vrátit výchozí výběr* for a picker with a non-empty default) per
picker that returns that filter to its default, enabled only while it
differs from the default. Verify (smoke test): clearing one picker
removes only its key from `get_filters()`; the PIAN `✕` is disabled
on a fresh dialog, enabled after a change and restores the three
default levels
- [x] 2.4 Notice label at the top, shown only when a restored state
differs from defaults, with the count of differing items; hidden on
reset. Verify (smoke test): hidden for a fresh dialog and for a
remembered default state, visible with the right count otherwise
- [x] 2.5 Reset `_REMEMBERED_STATE` between smoke-test cases (in
`try/finally`) so cases stay independent; verify by running the smoke
test twice in one container
## 3. Documentation and version
- [x] 3.1 README section 3.3: remembered filters per data type for the
QGIS run, *Obnovit výchozí*, `✕` per filter, the notice; adjust the
PIAN default note (reset restores it, `✕` clears it). Verify by reading
the section against the spec
- [x] 3.2 Add bullets to the existing v2.2.0 entry of `changelog=` in
`amcr_viewer/metadata.txt` (branch `version/v2.2.0` is unreleased, so
no new version; `CITATION.cff` already says 2.2.0). Verify:
`python3 tests/check_version_bump.py` (or the CI package job) passes
## 4. Final verification
- [x] 4.1 Run the AGENTS.md check set: `tests/check_sources.py`, bandit,
detect-secrets `--all-files`, `flake8 --isolated amcr_viewer/`,
`ruff check .`, `pyqgis4-checker` (log contains only the header), smoke
test in `qgis/qgis:ltr` and `qgis/qgis:stable`; delete
`amcr_viewer/__pycache__` afterwards
- [x] 4.2 `openspec validate add-filter-memory-and-reset --strict` passes
- [x] 4.3 Manual test in QGIS 3.44 and QGIS 4 (user): spec scenarios –
reopen after a download, Cancel, reset + OK / Cancel, `✕` on one
picker and on PIAN, notice text, separate state per data type,
*Aktualizovat hesláře* between two openings, defaults after a QGIS
restart
- User: look and function verified on Fieldwork events, Sites and
Individual finds; everything worked except two points – the notice
started with an odd "ℹ" and `✕` on PIAN emptied it instead of
restoring the default. Both fixed (commit d5e520d); the fix was
re-tested by the user on Fieldwork events, for Sites and Individual
finds it is covered by the smoke test.
- [x] 4.4 Archive before merge:
`openspec archive add-filter-memory-and-reset --skip-specs` in the same
PR
@@ -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)