Pamatovat filtry ve formuláři a přidat jejich reset (#84) (#85)

* Pamatovat filtry ve formuláři a přidat jejich reset (#84)

Filtrační dialog si do konce běhu QGIS pamatuje poslední filtry
potvrzené tlačítkem OK, zvlášť pro akce, lokality a samostatné
nálezy. Storno ani odmítnuté obrácené rozmezí uložený stav nemění.

- tlačítko „Obnovit výchozí“ vrátí formulář do výchozího stavu
  (bbox zapnutý, PIAN se třemi úrovněmi); uložený stav změní až OK
- tlačítko ✕ u každého výběru vymaže jen tento filtr
- upozornění nahoře hlásí obnovené filtry a jejich počet
- kódy, které po aktualizaci heslářů zmizely, se zahodí; popisky
  se skládají z aktuálních heslářů
- výchozí hodnoty jsou definované na jednom místě, PIAN už není
  natvrdo v setup_picker()
- smoke test, README a changelog v2.2.0

OpenSpec: openspec/changes/add-filter-memory-and-reset/
Připraveno s pomocí AI (implementace subagent, revize a ověření
Claude), ručně zkontrolovat v QGIS.

* Křížek vrací filtr do výchozího stavu, oznámení bez ikony (#84)

Úpravy po ručním testu v QGIS:
- ✕ u výběru vrací filtr do jeho výchozího stavu; u PIAN – přesnost
  obnoví tři předvolené úrovně, místo aby výběr vymazal. Tlačítko je
  aktivní, jen když se výběr od výchozího liší. PIAN bez omezení jde
  dál nastavit odškrtnutím všech úrovní ve výběru.
- oznámení o obnovených filtrech bez úvodního „ℹ“

Spec, design, tasks, README, changelog a smoke test upraveny.
Připraveno s pomocí AI.

* Archivovat OpenSpec změnu add-filter-memory-and-reset (#84)

Ruční test v QGIS (akce) zapsán k úkolu 4.3, změna archivována
do openspec/changes/archive/2026-10-02-add-filter-memory-and-reset/.
Připraveno s pomocí AI.

* Upřesnit záznam ručního testu u úkolu 4.3 (#84)

Vzhled i funkce ověřeny u všech tří typů dat, poslední oprava
(✕ u PIAN, oznámení) jen u akcí.
Připraveno s pomocí AI.
This commit is contained in:
david-spacil authored and GitHub committed 2026-10-02 19:04:53 +02:00
1 parent 6e95973296
commit 46fd8da045
9 files changed
+975 -23

No files matched your search

+28 -1
View File
@@ -123,6 +123,30 @@ Filters of different categories are combined with **AND**; multiple values
inside one filter are combined with **OR**. A filter left empty means "no inside one filter are combined with **OR**. A filter left empty means "no
restriction". Click *Vybrat…* to open a searchable, checkable list. restriction". Click *Vybrat…* to open a searchable, checkable list.
#### Remembered filters, reset, clearing one filter
* The dialog **remembers the filters you confirmed with OK** — separately
for Fieldwork events, Sites and Individual finds — for the rest of the
QGIS session. Reopening the dialog restores all selections, checkboxes
and date ranges, so refining a query ("same area, one more period") does
not mean re-entering everything. Nothing is written to disk: after a QGIS
restart (or a plugin reload) every dialog starts from its defaults again.
*Cancel* leaves the remembered state untouched.
* When the reopened dialog contains filters that differ from the defaults,
a green notice at the top says so and counts them, so a forgotten filter
further down the scrollable form is not missed.
* **Obnovit výchozí** (left of OK/Cancel) resets the whole form to its
defaults: the map-extent restriction checked, *PIAN – přesnost* back to
its three pre-selected levels (where the data type has it), everything
else empty. The reset applies to the form only — the remembered state
changes when you confirm with OK.
* Each picker has a small **✕** (*Vymazat výběr*; *Vrátit výchozí výběr*
for *PIAN – přesnost*) that returns just that
filter to its default — empty for almost all filters, the three
pre-selected levels for *PIAN – přesnost*; it is disabled while the
filter already is at its default. To drop the *PIAN – přesnost*
restriction entirely, uncheck all levels in its *Vybrat…* dialog.
#### Availability per entity #### Availability per entity
| Filter (Czech UI label) | Events | Sites | Ind. finds | API parameter | | Filter (Czech UI label) | Events | Sites | Ind. finds | API parameter |
@@ -167,7 +191,10 @@ filter in place, otherwise you will hit the record cap (see 4.5).
> *odchylka desítky metrů* and *odchylka stovky metrů* checked, so an > *odchylka desítky metrů* and *odchylka stovky metrů* checked, so an
> otherwise untouched dialog already sends `f_pian_presnost`. Records > otherwise untouched dialog already sends `f_pian_presnost`. Records
> localised only to a cadastral territory are excluded until you open the > localised only to a cadastral territory are excluded until you open the
> picker and add that level yourself. > picker and add that level yourself. *Obnovit výchozí* brings the three
> levels back; the picker's ✕ returns them too (it restores the filter's
> default). To have no accuracy restriction at all, uncheck all levels
> in the picker's *Vybrat…* dialog.
#### Date ranges #### Date ranges
+214 -22
View File
@@ -7,7 +7,7 @@ from qgis.core import (
QgsTask, QgsTask,
) )
from qgis.gui import QgsDateEdit from qgis.gui import QgsDateEdit
from qgis.PyQt.QtCore import QSettings, Qt from qgis.PyQt.QtCore import QDate, QSettings, Qt
from qgis.PyQt.QtWidgets import ( from qgis.PyQt.QtWidgets import (
QCheckBox, QCheckBox,
QDialog, QDialog,
@@ -24,6 +24,7 @@ from qgis.PyQt.QtWidgets import (
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -63,6 +64,19 @@ DATE_OPEN_TO = "9999-12-31"
# Shown by a date picker that is left empty # Shown by a date picker that is left empty
DATE_NULL_TEXT = "neomezeno" DATE_NULL_TEXT = "neomezeno"
# The non-empty defaults of the form: PIAN – přesnost pre-selects the
# three levels better than a cadastral territory, and the map-extent
# restriction is on. Everything else starts empty.
DEFAULT_CODES = {
"pian_presnost": ["HES-000861", "HES-000862", "HES-000863"],
}
DEFAULT_CHECKS = {"bbox": True}
# The last OK-confirmed form state per data type, alive for the QGIS
# run only: re-importing the plugin (a restart, Plugin Reloader) drops
# the dict, which is exactly the required lifetime.
_REMEMBERED_STATE = {}
# Keep Python references to running tasks. QgsTaskManager only holds the # Keep Python references to running tasks. QgsTaskManager only holds the
# C++ object; without a Python-side reference the wrapper can be garbage # C++ object; without a Python-side reference the wrapper can be garbage
@@ -234,15 +248,29 @@ class AmcrFilterDialog(QDialog):
'nalezce': [], 'nalezce': [],
} }
# Pickers registered by setup_picker():
# cache_key -> (data_source, display_field, clear button)
self.pickers = {}
# Date range pickers, filled by setup_date_range(): # Date range pickers, filled by setup_date_range():
# (API field, label for messages, 'from' widget, 'to' widget) # (API field, label for messages, 'from' widget, 'to' widget)
self.date_ranges = [] self.date_ranges = []
layout = QVBoxLayout() layout = QVBoxLayout()
# Notice shown when a remembered state was restored
self.lbl_notice = QLabel()
self.lbl_notice.setWordWrap(True)
self.lbl_notice.setStyleSheet(
"color: #1b5e20; background-color: #e8f5e9; "
"border: 1px solid #a5d6a7; border-radius: 4px; padding: 6px;"
)
self.lbl_notice.setVisible(False)
layout.addWidget(self.lbl_notice)
# Filter by current map canvas extent # Filter by current map canvas extent
self.chk_bbox = QCheckBox("Omezit vyhledávání rozsahem okna") self.chk_bbox = QCheckBox("Omezit vyhledávání rozsahem okna")
self.chk_bbox.setChecked(True) self.chk_bbox.setChecked(DEFAULT_CHECKS["bbox"])
layout.addWidget(self.chk_bbox) layout.addWidget(self.chk_bbox)
# Positive/negative evidence – valid for Akce # Positive/negative evidence – valid for Akce
@@ -457,6 +485,15 @@ class AmcrFilterDialog(QDialog):
self.btn_update, self.btn_update,
QDialogButtonBox.ButtonRole.ActionRole QDialogButtonBox.ButtonRole.ActionRole
) )
# Reset the form to its defaults; the remembered state changes
# only on OK. ResetRole puts the button left of the OK/Cancel pair.
self.btn_reset = buttons.addButton(
QDialogButtonBox.StandardButton.RestoreDefaults
)
self.btn_reset.setText("Obnovit výchozí")
self.btn_reset.clicked.connect(self.action_reset)
buttons.addButton(QDialogButtonBox.StandardButton.Ok) buttons.addButton(QDialogButtonBox.StandardButton.Ok)
buttons.addButton(QDialogButtonBox.StandardButton.Cancel) buttons.addButton(QDialogButtonBox.StandardButton.Cancel)
@@ -466,6 +503,126 @@ class AmcrFilterDialog(QDialog):
self.setLayout(outer) self.setLayout(outer)
# Restore the remembered state (defaults when there is none)
restored = _REMEMBERED_STATE.get(self.typ_dat)
if restored is None:
self._apply_state(self._default_state())
else:
self._apply_state(restored)
diff = self._diff_from_default(restored)
if diff:
self.lbl_notice.setText(
"Načteny filtry z minulého hledání "
f"(aktivní filtry: {diff})."
)
self.lbl_notice.setVisible(True)
def _default_state(self):
"""A full snapshot of the default form state for this typ_dat."""
# Every checkbox defaults to off, except the ones DEFAULT_CHECKS
# turns on (the map-extent restriction)
checks = {name: False for name, _ in self._check_widgets()}
for name, value in DEFAULT_CHECKS.items():
if name in checks:
checks[name] = value
return {
"codes": {
key: (list(DEFAULT_CODES[key])
if key in DEFAULT_CODES else [])
for key in self.pickers
},
"checks": checks,
"dates": {
api_field: (None, None)
for api_field, _, _, _ in self.date_ranges
},
}
def _check_widgets(self):
"""(name, checkbox) of every checkbox, in a stable order."""
widgets = []
for name, attr in (
("bbox", "chk_bbox"),
("posevidence", "chk_posevidence"),
("proj_akce", "chk_proj_akce"),
("komponenty", "chk_komponenty"),
):
if hasattr(self, attr):
widgets.append((name, getattr(self, attr)))
return widgets
def _snapshot(self):
"""The whole form state as plain Python (codes/checks/dates)."""
return {
"codes": {key: list(codes)
for key, codes in self.selection_cache.items()},
"checks": {name: chk.isChecked()
for name, chk in self._check_widgets()},
"dates": {
api_field: (
None if date_from.isNull()
else date_from.date().toString("yyyy-MM-dd"),
None if date_to.isNull()
else date_to.date().toString("yyyy-MM-dd"),
)
for api_field, _, date_from, date_to in self.date_ranges
},
}
def _apply_state(self, state):
"""Applies a snapshot; codes unknown to the codelists are
dropped and the picker texts are rebuilt from them."""
for key, codes in state.get("codes", {}).items():
if key in self.pickers:
self._set_picker(key, codes)
for name, chk in self._check_widgets():
chk.setChecked(state.get("checks", {}).get(name, False))
stored = state.get("dates", {})
for api_field, _, date_from, date_to in self.date_ranges:
iso_from, iso_to = stored.get(api_field, (None, None))
# clear(), not setEmpty(): an empty picker must stay
# isNull() so no filter is sent (see _date_edit)
if iso_from:
date_from.setDate(QDate.fromString(
iso_from, "yyyy-MM-dd"))
else:
date_from.clear()
if iso_to:
date_to.setDate(QDate.fromString(
iso_to, "yyyy-MM-dd"))
else:
date_to.clear()
def _diff_from_default(self, state):
"""Number of form items in the snapshot that differ from
_default_state(): one per picker, checkbox and date row."""
diff = 0
defaults = self._default_state()
for key, codes in state.get("codes", {}).items():
if (key in defaults["codes"]
and codes != defaults["codes"][key]):
diff += 1
for name, value in state.get("checks", {}).items():
if defaults["checks"].get(name) != value:
diff += 1
for api_field, bounds in state.get("dates", {}).items():
if defaults["dates"].get(api_field) != bounds:
diff += 1
return diff
def action_reset(self):
"""Resets the form to its defaults (the remembered state is
changed only by OK, so Cancel after reset reverts it)."""
self._apply_state(self._default_state())
self.lbl_notice.setVisible(False)
def setup_picker(self, label_text, cache_key, data_source, extra_btn=None): def setup_picker(self, label_text, cache_key, data_source, extra_btn=None):
""" """
Creates a reusable UI component consisting of a label, a read-only Creates a reusable UI component consisting of a label, a read-only
@@ -485,6 +642,20 @@ class AmcrFilterDialog(QDialog):
btn = QPushButton("Vybrat...") btn = QPushButton("Vybrat...")
btn.setFixedWidth(80) btn.setFixedWidth(80)
# Returns this one filter to its default; enabled only while
# the selection differs from it (open_dialog re-evaluates it
# through _set_picker)
clear_btn = QToolButton()
clear_btn.setText("✕")
default_codes = DEFAULT_CODES.get(cache_key, [])
clear_btn.setToolTip(
"Vrátit výchozí výběr" if default_codes else "Vymazat výběr"
)
clear_btn.setEnabled(False)
clear_btn.clicked.connect(
lambda: self._set_picker(cache_key, list(default_codes))
)
# Nested handler: opens the selection dialog and saves the result # Nested handler: opens the selection dialog and saves the result
def open_dialog(): def open_dialog():
dlg = FilterableSelectionDialog( dlg = FilterableSelectionDialog(
@@ -494,39 +665,57 @@ class AmcrFilterDialog(QDialog):
self self
) )
if dlg.exec() == QDialog.DialogCode.Accepted: if dlg.exec() == QDialog.DialogCode.Accepted:
codes, labels = dlg.get_selected_codes() codes, _labels = dlg.get_selected_codes()
# Update the local cache with selected IDs self._set_picker(cache_key, codes)
self.selection_cache[cache_key] = codes
# Update the display field with the selected item names
if labels:
display_field.setText(", ".join(labels))
else:
display_field.clear()
# Special case: pre-select default PIAN accuracy levels
if cache_key == 'pian_presnost':
display_field.setText(
"odchylka jednotky metrů, odchylka desítky metrů, "
"odchylka stovky metrů"
)
self.selection_cache[cache_key] = [
'HES-000861',
'HES-000862',
'HES-000863',
]
btn.clicked.connect(open_dialog) btn.clicked.connect(open_dialog)
row_layout.addWidget(display_field) row_layout.addWidget(display_field)
row_layout.addWidget(btn) row_layout.addWidget(btn)
row_layout.addWidget(clear_btn)
# Optionally append an extra button (e.g. a refresh button) # Optionally append an extra button (e.g. a refresh button)
if extra_btn: if extra_btn:
row_layout.addWidget(extra_btn) row_layout.addWidget(extra_btn)
row_widget.setLayout(row_layout) row_widget.setLayout(row_layout)
# One place knows the widgets behind every picker, so the cache
# and the display text can never drift apart
self.pickers[cache_key] = (data_source, display_field, clear_btn)
return row_widget return row_widget
def _set_picker(self, cache_key, codes):
"""
Sets one picker: stores the codes, rebuilds the display text from
the current codelist (a label renamed by Aktualizovat hesláře
shows its new name, an unknown code is dropped) and enables the
clear button only while the selection differs from the picker's
default.
"""
data_source, display_field, clear_btn = self.pickers[cache_key]
# Keep the order the user picked, drop codes the current
# codelist no longer knows
code_to_label = {v: k for k, v in data_source.items()}
valid_codes = [code for code in codes if code in code_to_label]
# Rebuild the labels the same way the selection dialog shows
# them: sorted by name
labels = sorted(code_to_label[code] for code in valid_codes)
self.selection_cache[cache_key] = valid_codes
if labels:
display_field.setText(", ".join(labels))
else:
display_field.clear()
# Order-insensitive: a reordered default is still the default
default = DEFAULT_CODES.get(cache_key, [])
clear_btn.setEnabled(
sorted(valid_codes) != sorted(default)
)
def setup_date_range(self, title, rows): def setup_date_range(self, title, rows):
""" """
Creates a compact date range block: one row per API date field, Creates a compact date range block: one row per API date field,
@@ -613,6 +802,9 @@ class AmcrFilterDialog(QDialog):
) )
return return
# Remember the confirmed state only after the date check passed
_REMEMBERED_STATE[self.typ_dat] = self._snapshot()
super().accept() super().accept()
def action_update_heslare(self): def action_update_heslare(self):
+3
View File
@@ -31,6 +31,9 @@ changelog=
* The login state is verified before each download via /api/user/islogged; an expired session is renewed automatically * 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) * 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 * Removing the stored credentials also logs the user out of the Digital Archive
* The filter dialog remembers the last confirmed filters per data type for the QGIS run and restores them on reopening
* New Obnovit výchozí button resets the filter form to its defaults, including the pre-selected PIAN accuracy levels
* Each codelist filter has a ✕ button that returns that single filter to its default (empty, or the three pre-selected levels for PIAN – přesnost); a notice announces restored filters and their count
v2.1.4 (2026-10-01) 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 * 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) v2.1.3 (2026-10-01)
@@ -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
+291
View File
@@ -360,10 +360,301 @@ def vaha_komponent():
return "4×0.25; 1/4 → 1.0; 2/3 → 2×0.5; prázdné → 1" return "4×0.25; 1/4 → 1.0; 2/3 → 2×0.5; prázdné → 1"
def vychozi_filtry():
"""
A fresh dialog starts from the defaults: the map-extent restriction
checked, PIAN – přesnost pre-selected for akce and lokalita, and
nothing (not even PIAN) sent for samostatny_nalez.
"""
_stubuj_varovani()
try:
pian = ["HES-000861", "HES-000862", "HES-000863"]
for typ, ma_pian in (("akce", True), ("lokalita", True),
("samostatny_nalez", False)):
okno = dialog.AmcrFilterDialog(typ)
assert okno.get_bbox() == "true", typ
assert okno.get_komponenty() == "false", typ
if ma_pian:
assert okno.selection_cache["pian_presnost"] == pian, typ
assert okno.get_filters()["f_pian_presnost"] == pian, typ
else:
assert "f_pian_presnost" not in okno.get_filters(), typ
okno.close()
return ("PIAN 861/862/863 u akcí a lokalit, u nálezů bez "
"omezení, bbox zapnutý")
finally:
dialog._REMEMBERED_STATE.clear()
def _stubuj_varovani():
# A modal warning would block the offscreen run forever
dialog.QMessageBox.warning = staticmethod(lambda *a, **k: None)
def _kody(codelist, n=1):
"""First n real codes of a codelist (label -> code dict)."""
return [code for code in codelist.values() if code][:n]
def _napln(okno, typ):
"""Fills a dialog with a non-default state for round-trip tests."""
okno._set_picker("kraj", _kody(dialog.KRAJE))
okno._set_picker("obdobi", _kody(dialog.OBDOBI, 2))
okno.chk_bbox.setChecked(False)
if typ == "akce":
okno.chk_posevidence.setChecked(True)
okno.chk_proj_akce.setChecked(True)
okno._set_picker("typ_akce", _kody(dialog.TYP_AKCE))
okno.date_ranges[0][2].setDate(QDate(2016, 1, 1))
okno.date_ranges[0][3].setDate(QDate(2017, 12, 31))
if typ == "lokalita":
okno.chk_komponenty.setChecked(True)
okno._set_picker("typ_lokality", _kody(dialog.TYP_LOKALITY))
if typ == "samostatny_nalez":
okno._set_picker("druh_nalezu", _kody(dialog.DRUH_NALEZU))
okno.date_ranges[0][3].setDate(QDate(2020, 6, 30))
def pamet_snapshotu():
"""
Snapshot -> apply on a fresh dialog keeps get_filters(),
get_bbox() and get_komponenty() identical; a code unknown to the
current codelist is dropped on the way.
"""
_stubuj_varovani()
try:
for typ in ("akce", "lokalita", "samostatny_nalez"):
okno = dialog.AmcrFilterDialog(typ)
_napln(okno, typ)
stav = okno.get_filters()
snapshot = okno._snapshot()
okno.close()
obnovene = dialog.AmcrFilterDialog(typ)
obnovene._apply_state(snapshot)
assert obnovene._snapshot() == snapshot, typ
assert obnovene.get_filters() == stav, typ
assert obnovene.get_bbox() == okno.get_bbox(), typ
assert obnovene.get_komponenty() == okno.get_komponenty(), typ
obnovene.close()
# Unknown code: dropped, not sent
okno = dialog.AmcrFilterDialog("akce")
kraj = _kody(dialog.KRAJE)
okno._set_picker("kraj", kraj + ["XX-NEEXISTUJE"])
assert okno.selection_cache["kraj"] == kraj
assert okno.get_filters()["f_kraj"] == kraj
okno.close()
# The same through a remembered state, as after a codelist
# update removed the code: dropped on opening, the picker text
# is the current codelist label
stitek = next(k for k, v in dialog.KRAJE.items() if v == kraj[0])
stav = dialog.AmcrFilterDialog("akce")._snapshot()
stav["codes"]["kraj"] = kraj + ["XX-NEEXISTUJE"]
dialog._REMEMBERED_STATE["akce"] = stav
okno = dialog.AmcrFilterDialog("akce")
assert okno.get_filters()["f_kraj"] == kraj
assert okno.pickers["kraj"][1].text() == stitek
okno.close()
return "round-trip všech tří typů, neznámý kód zahozen"
finally:
dialog._REMEMBERED_STATE.clear()
def pamet_potvrzeni():
"""
OK remembers the form state for the next opening of the same data
type; Cancel and a refused reversed date range do not; another
data type starts from the defaults.
"""
_stubuj_varovani()
try:
okno = dialog.AmcrFilterDialog("akce")
_napln(okno, "akce")
potvrzene = okno.get_filters()
okno.accept()
okno.close()
# OK -> reopen restores the same filters
znovu = dialog.AmcrFilterDialog("akce")
assert znovu.get_filters() == potvrzene
assert znovu.get_bbox() == "false"
# Another data type starts from the defaults
lokalita = dialog.AmcrFilterDialog("lokalita")
assert "f_kraj" not in lokalita.get_filters()
assert lokalita.get_bbox() == "true"
lokalita.close()
# Cancel keeps the remembered state
znovu._set_picker("kraj", [])
znovu.chk_bbox.setChecked(True)
znovu.reject()
znovu.close()
po_zruseni = dialog.AmcrFilterDialog("akce")
assert po_zruseni.get_filters() == potvrzene
# A reversed range is refused and the state stays unchanged
zapamatovano = dialog._REMEMBERED_STATE["akce"]
po_zruseni.date_ranges[0][2].setDate(QDate(2018, 1, 1))
po_zruseni.date_ranges[0][3].setDate(QDate(2017, 1, 1))
po_zruseni.accept()
assert po_zruseni.result() == 0, "obrácené rozmezí přijato"
assert dialog._REMEMBERED_STATE["akce"] == zapamatovano
po_zruseni.close()
return ("OK obnoví, Cancel i obrácené rozmezí ne, jiný typ "
"od výchozích")
finally:
dialog._REMEMBERED_STATE.clear()
def obnoveni_vychozich():
"""
Obnovit výchozí resets the form only: reset + OK behaves like a
fresh dialog, reset + Cancel keeps the remembered state.
"""
_stubuj_varovani()
try:
# Default output, captured before anything is remembered
okno = dialog.AmcrFilterDialog("akce")
vychozi = okno.get_filters()
vychozi_bbox = okno.get_bbox()
vychozi_komponenty = okno.get_komponenty()
okno.close()
# A remembered non-default state
okno = dialog.AmcrFilterDialog("akce")
_napln(okno, "akce")
potvrzene = okno.get_filters()
okno.accept()
okno.close()
# Reset + Cancel: reopening shows the remembered state
okno = dialog.AmcrFilterDialog("akce")
okno.action_reset()
assert okno.get_filters() == vychozi
okno.reject()
okno.close()
okno = dialog.AmcrFilterDialog("akce")
assert okno.get_filters() == potvrzene
okno.close()
# Reset + OK: the sent filters and the next opening are default
okno = dialog.AmcrFilterDialog("akce")
okno.action_reset()
assert okno.get_filters() == vychozi
assert okno.get_bbox() == vychozi_bbox
assert okno.get_komponenty() == vychozi_komponenty
okno.accept()
okno.close()
okno = dialog.AmcrFilterDialog("akce")
assert okno.get_filters() == vychozi
okno.close()
return "reset + OK = čerstvý dialog, reset + Cancel zachová"
finally:
dialog._REMEMBERED_STATE.clear()
def vymazani_vyberu():
"""
The ✕ button returns only its own filter to its default (empty,
or the three pre-selected levels for PIAN – přesnost) and is
disabled while the filter already is at that default.
"""
_stubuj_varovani()
try:
okno = dialog.AmcrFilterDialog("lokalita")
okno._set_picker("kraj", _kody(dialog.KRAJE))
okno._set_picker("obdobi", _kody(dialog.OBDOBI, 2))
pred = okno.get_filters()
assert "f_kraj" in pred and "f_obdobi" in pred
okno.pickers["kraj"][2].click()
assert okno.selection_cache["kraj"] == []
po = okno.get_filters()
assert "f_kraj" not in po
assert po["f_obdobi"] == pred["f_obdobi"]
assert not okno.pickers["kraj"][2].isEnabled()
okno.close()
okno = dialog.AmcrFilterDialog("akce")
assert "f_pian_presnost" in okno.get_filters()
# A fresh dialog is at the default, so ✕ is disabled
assert not okno.pickers["pian_presnost"][2].isEnabled()
# A reordered default list still counts as the default
vychozi = dialog.DEFAULT_CODES["pian_presnost"]
okno._set_picker("pian_presnost", list(reversed(vychozi)))
assert not okno.pickers["pian_presnost"][2].isEnabled()
assert "f_pian_presnost" in okno.get_filters()
# Emptied PIAN means no restriction and enables ✕
okno._set_picker("pian_presnost", [])
assert "f_pian_presnost" not in okno.get_filters()
assert okno.pickers["pian_presnost"][2].isEnabled()
# ✕ restores the three default levels and disables itself
okno.pickers["pian_presnost"][2].click()
assert sorted(okno.get_filters()["f_pian_presnost"]) == sorted(
vychozi
)
assert not okno.pickers["pian_presnost"][2].isEnabled()
okno.close()
return ("✕ vrací filtr na výchozí hodnotu, PIAN na tři "
"úrovně, jinak prázdné")
finally:
dialog._REMEMBERED_STATE.clear()
def upozorneni_obnovy():
"""
The notice is hidden for a fresh dialog and for a remembered
default state; with two filters set it is shown with the count 2
and hidden again by Obnovit výchozí.
"""
_stubuj_varovani()
try:
okno = dialog.AmcrFilterDialog("akce")
assert okno.lbl_notice.isHidden()
okno.close()
# A remembered default state is not worth a notice
okno = dialog.AmcrFilterDialog("akce")
okno.accept()
okno.close()
okno = dialog.AmcrFilterDialog("akce")
assert okno.lbl_notice.isHidden()
okno.close()
# Two non-default filters -> a notice with the count 2
okno = dialog.AmcrFilterDialog("akce")
okno._set_picker("kraj", _kody(dialog.KRAJE))
okno._set_picker("obdobi", _kody(dialog.OBDOBI))
okno.accept()
okno.close()
okno = dialog.AmcrFilterDialog("akce")
assert not okno.lbl_notice.isHidden()
assert okno.lbl_notice.text().startswith("Načteny filtry")
assert "aktivní filtry: 2" in okno.lbl_notice.text()
okno.action_reset()
assert okno.lbl_notice.isHidden()
okno.close()
return "skryté pro výchozí, viditelné s počtem 2, reset skryje"
finally:
dialog._REMEMBERED_STATE.clear()
zkouska("scoped enumy", enumy) zkouska("scoped enumy", enumy)
zkouska("UpdateCodelistsTask", uloha) zkouska("UpdateCodelistsTask", uloha)
zkouska("filtrační dialogy", dialogy) zkouska("filtrační dialogy", dialogy)
zkouska("filtr podle data", filtr_datumu) zkouska("filtr podle data", filtr_datumu)
zkouska("výchozí filtry", vychozi_filtry)
zkouska("paměť snapshotu", pamet_snapshotu)
zkouska("paměť potvrzení", pamet_potvrzeni)
zkouska("obnovení výchozích", obnoveni_vychozich)
zkouska("vymazání výběru", vymazani_vyberu)
zkouska("upozornění obnovy", upozorneni_obnovy)
zkouska("stav přihlášení", prihlasovaci_stav) zkouska("stav přihlášení", prihlasovaci_stav)
zkouska("odhlášení", odhlaseni) zkouska("odhlášení", odhlaseni)
zkouska("váha komponent", vaha_komponent) zkouska("váha komponent", vaha_komponent)