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

+41
View File
@@ -0,0 +1,41 @@
name: Release PR Checks
# Hlídá bump verze a changelog v okamžiku, kdy se release větev
# version/vX.Y.Z slévá do main – tady poprvé jde skutečně o to, co se
# vydá, a zapomenutý bump by se jinak dostal rovnou k tagu a releasu.
#
# Kontroly viz tests/check_version_bump.py: verze v metadata.txt musí jít
# nahoru, první položka changelogu musí patřit nové verzi, CITATION.cff
# musí mít stejnou verzi (a posunuté date-released) a název větve musí
# odpovídat verzi, kterou nese.
on:
pull_request:
branches:
- main
permissions:
contents: read
jobs:
version-bump:
name: Kontrola verze a changelogu
runs-on: ubuntu-latest
if: startsWith(github.head_ref, 'version/')
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Verze a changelog
run: |
python3 tests/check_version_bump.py \
"${{ github.event.pull_request.base.sha }}" \
"${{ github.head_ref }}"
+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.4'
date-released: '2026-10-01'
version: '2.2.0'
date-released: '2026-10-02'
+51 -9
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
@@ -119,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
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
| Filter (Czech UI label) | Events | Sites | Ind. finds | API parameter |
@@ -138,11 +166,11 @@ restriction". Click *Vybrat…* to open a searchable, checkable list.
| Lokalita – typ | — | ✓ | — | `f_typ_lokality` |
| Lokalita – druh | — | ✓ | — | `f_druh_lokality` |
| Lokalita – jistota určení | — | ✓ | — | `f_jistota` |
| Lokalita - stav dochování | — | ✓ | — | `f_lokalita_zachovalost` |
| Lokalita – stav dochování | — | ✓ | — | `f_lokalita_zachovalost` |
| Období | ✓ | ✓ | ✓ | `f_obdobi` |
| Kategorie nálezu | — | — | ✓ | `f_kategorie` |
| Druh nálezu | — | — | ✓ | `f_druh_nalezu` |
| Specifikace nálezu | — | — | ✓ | `f_specifikace` |
| Materiál | — | — | ✓ | `f_specifikace` |
| Okolnosti nálezu | — | — | ✓ | `f_nalezove_okolnosti` |
| Nálezce | — | — | ✓ | `f_nalezce` |
| Datum nálezu | — | — | ✓ | `samostatny_nalez_datum_nalezu` |
@@ -163,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
> otherwise untouched dialog already sends `f_pian_presnost`. Records
> localised only to a cadastral territory are excluded until you open the
> picker and add that level yourself.
> 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
@@ -181,7 +212,10 @@ from a genuinely empty result.
The controlled vocabularies behind the pickers are cached in
`amcr_viewer/codelists/heslar.csv` and ship with the plugin. Click
**Aktualizovat hesláře 🔄** to rebuild the file from the live APIs; it runs as
a background QGIS task with a progress bar and takes a few minutes.
a background QGIS task with a progress bar and takes a few minutes. A
codelist that fails to download or comes back empty keeps its previous values
instead of being wiped; when the update finishes, a warning lists the affected
codelists.
Most codelists come from the AMČR **OAI-PMH** endpoint. Two are built from
Digiarchiv **search facets** instead, because they are lists of people rather
@@ -195,7 +229,8 @@ activity area of each component into the output layer.
> ⚠ With components loaded, spatial features are **duplicated** — one feature
> per component. Areas and feature counts computed on such a layer are
> misleading.
> misleading. Weight such computations (e.g. a heatmap) by the `prvek_vaha`
> field (see 3.4): the weights of one documentation unit sum to 1.
Note that *Období* and *Areál* also act as component filters even when the
box is unchecked: a documentation unit whose components match nothing is
@@ -294,6 +329,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
@@ -337,6 +373,9 @@ amcr_viewer/ the plugin package (this is what gets zipped)
tests/
check_sources.py source hygiene checks (no QGIS needed)
smoke_test.py loads the plugin in a real, headless QGIS
check_version_bump.py release-PR guard: version bump, changelog entry and
matching versions in metadata.txt, CITATION.cff
and the branch name
.github/workflows/ CI (code quality, release packaging)
pyproject.toml ruff configuration
AGENTS.md contributor and AI-agent guidelines
@@ -347,6 +386,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. |
@@ -394,6 +435,7 @@ not:
| --- | --- |
| **Lint a bezpečnost** | `tests/check_sources.py`, bandit, detect-secrets, flake8, ruff |
| **Kompatibilita s Qt6** | `pyqgis4-checker` in dry-run mode |
| **OpenSpec** | validates change artefacts in `openspec/changes/` |
| **Smoke test** | loads the plugin in headless QGIS — both `ltr` (Qt 5) and `stable` (Qt 6) |
| **Balíček pluginu** | builds `amcr_viewer.zip`, asserts its contents, uploads it as an artifact |
+231 -29
View File
@@ -7,7 +7,7 @@ from qgis.core import (
QgsTask,
)
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 (
QCheckBox,
QDialog,
@@ -24,6 +24,7 @@ from qgis.PyQt.QtWidgets import (
QMessageBox,
QPushButton,
QScrollArea,
QToolButton,
QVBoxLayout,
QWidget,
)
@@ -63,6 +64,19 @@ DATE_OPEN_TO = "9999-12-31"
# Shown by a date picker that is left empty
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
# C++ object; without a Python-side reference the wrapper can be garbage
@@ -234,15 +248,29 @@ class AmcrFilterDialog(QDialog):
'nalezce': [],
}
# Pickers registered by setup_picker():
# cache_key -> (data_source, display_field, clear button)
self.pickers = {}
# Date range pickers, filled by setup_date_range():
# (API field, label for messages, 'from' widget, 'to' widget)
self.date_ranges = []
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
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)
# Positive/negative evidence – valid for Akce
@@ -343,7 +371,7 @@ class AmcrFilterDialog(QDialog):
layout.addWidget(self.picker_jistota)
self.picker_lokalita_zachovalost = self.setup_picker(
"Lokalita - stav dochování",
"Lokalita – stav dochování",
'lokalita_zachovalost',
LOKALITA_ZACHOVALOST
)
@@ -370,7 +398,7 @@ class AmcrFilterDialog(QDialog):
layout.addWidget(self.picker_druh_nalezu)
self.picker_specifikace = self.setup_picker(
"Specifikace nálezu",
"Materiál",
'specifikace',
SPECIFIKACE
)
@@ -457,6 +485,15 @@ class AmcrFilterDialog(QDialog):
self.btn_update,
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.Cancel)
@@ -466,6 +503,126 @@ class AmcrFilterDialog(QDialog):
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):
"""
Creates a reusable UI component consisting of a label, a read-only
@@ -485,6 +642,20 @@ class AmcrFilterDialog(QDialog):
btn = QPushButton("Vybrat...")
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
def open_dialog():
dlg = FilterableSelectionDialog(
@@ -494,39 +665,57 @@ class AmcrFilterDialog(QDialog):
self
)
if dlg.exec() == QDialog.DialogCode.Accepted:
codes, labels = dlg.get_selected_codes()
# Update the local cache with selected IDs
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',
]
codes, _labels = dlg.get_selected_codes()
self._set_picker(cache_key, codes)
btn.clicked.connect(open_dialog)
row_layout.addWidget(display_field)
row_layout.addWidget(btn)
row_layout.addWidget(clear_btn)
# Optionally append an extra button (e.g. a refresh button)
if extra_btn:
row_layout.addWidget(extra_btn)
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
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):
"""
Creates a compact date range block: one row per API date field,
@@ -613,6 +802,9 @@ class AmcrFilterDialog(QDialog):
)
return
# Remember the confirmed state only after the date check passed
_REMEMBERED_STATE[self.typ_dat] = self._snapshot()
super().accept()
def action_update_heslare(self):
@@ -1033,6 +1225,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:
@@ -1040,11 +1236,17 @@ class LoginDialog(QDialog):
existing_id
)
settings.remove(self.SETTINGS_KEY)
QMessageBox.information(
self,
"Hotovo",
"Uložené přihlašovací údaje byly odebrány."
)
# Without credentials the session in memory would otherwise stay
# logged in until QGIS is restarted
if amcr_tools.logout_from_api():
zprava = ("Uložené přihlašovací údaje byly odebrány "
"a uživatel byl odhlášen.")
else:
zprava = ("Uložené přihlašovací údaje byly odebrány. Server "
"se nepodařilo kontaktovat, další stahování ale "
"proběhne anonymně.")
QMessageBox.information(self, "Hotovo", zprava)
self.reject()
# ------------------------------------------------------------------
+208 -31
View File
@@ -157,6 +157,125 @@ def _get_session() -> requests.Session | None:
return AMCR_SESSION
def logout_from_api() -> bool:
"""
Logs the current session out on the server (GET /api/user/logout)
and drops it from memory, so the next download runs anonymously
(or logs in again only if credentials are stored).
The local session is dropped even when the server cannot be
reached. Returns True when the server confirmed the logout or there
was no session at all.
"""
global AMCR_SESSION
session = AMCR_SESSION
AMCR_SESSION = None
if session is None:
return True
url = "https://digiarchiv.aiscr.cz/api/user/logout"
try:
response = session.get(url, timeout=10)
response.raise_for_status()
except requests.exceptions.RequestException as e:
_log(f"Odhlášení na serveru se nezdařilo: {e} – session "
"zahozena jen lokálně.", Qgis.MessageLevel.Warning)
return False
_log("Uživatel odhlášen.")
return True
def _check_islogged(session) -> bool | None:
"""
Asks the server whether the session is logged in
(GET /api/user/islogged; the check does not extend the session).
Returns True when logged in, False when the server reports
'nologged', or None when the check itself failed (network error,
invalid JSON) or the response has an unknown shape.
"""
url = "https://digiarchiv.aiscr.cz/api/user/islogged"
try:
body = session.get(url, timeout=10).json()
except (requests.exceptions.RequestException, ValueError) as e:
_log(f"Stav přihlášení se nepodařilo ověřit: {e}",
Qgis.MessageLevel.Warning)
return None
if not isinstance(body, dict):
_log("Neznámý formát odpovědi islogged – pokračuji dál.",
Qgis.MessageLevel.Warning)
return None
if "remaining" in body:
# Only the remaining seconds are logged, never a user profile
_log(f"Session je přihlášená (zbývá {body['remaining']} s).")
return True
if body.get("error"):
_log(f"Server session neuznává ({body['error']}).",
Qgis.MessageLevel.Warning)
return False
_log("Neznámý formát odpovědi islogged – pokračuji dál.",
Qgis.MessageLevel.Warning)
return None
def _ensure_logged_in() -> str:
"""
Verifies before a download that the user is logged in, whenever
a session exists or credentials are stored; renews the session
once when it has expired. Returns one of:
* "anonymous" – no session and no stored credentials (nothing
to check, no request is sent)
* "logged_in" – the current session is valid
* "relogged" – the session had expired and was renewed
* "fallback" – a login was expected but could not be established;
the download will run anonymously
* "unknown" – the check itself failed; the download proceeds
with the current session
"""
global AMCR_SESSION
session = _get_session()
if session is None:
# _get_session() has already tried the stored credentials;
# their presence therefore means the login failed
from .amcr_dialog import LoginDialog
username, password = LoginDialog.get_credentials()
if username and password:
_log("Přihlášení se nezdařilo – stahuji anonymně.",
Qgis.MessageLevel.Warning)
return "fallback"
return "anonymous"
stav = _check_islogged(session)
if stav is None:
return "unknown"
if stav:
return "logged_in"
# The server no longer accepts the session – drop it and try
# one re-login with the stored credentials
AMCR_SESSION = None
from .amcr_dialog import LoginDialog
username, password = LoginDialog.get_credentials()
if not (username and password):
_log("Session vypršela a přihlašovací údaje nejsou uloženy "
"– stahuji anonymně.", Qgis.MessageLevel.Warning)
return "fallback"
session = login_to_api(username, password)
if session is None:
return "fallback"
stav = _check_islogged(session)
if stav is None:
return "unknown"
if stav:
return "relogged"
_log("Nová session nebyla serverem uznána – stahuji anonymně.",
Qgis.MessageLevel.Warning)
return "fallback"
def _api_get_json(url, params, timeout=30) -> dict:
"""
Performs a GET request and returns the parsed JSON body.
@@ -168,7 +287,13 @@ def _api_get_json(url, params, timeout=30) -> dict:
def _is_auth_error(resp: requests.Response, body) -> bool:
"""The API returns auth errors with status 200 –
the body must be checked."""
the body must be checked.
Fallback only: the current server signals an expired session
by silently answering as anonymous (HTTP 200, no 'error'), so
this check never triggers on expiry today – the login state is
verified upfront by _ensure_logged_in() instead. Kept for the
day the API starts returning 401 or an explicit error text."""
if resp.status_code == 401:
return True
if not isinstance(body, dict):
@@ -261,6 +386,49 @@ def tr_code(code):
return TRANSLATIONS.get(code, code)
def _component_entries(dj_meta, komps, passes):
"""
Builds the feature metadata entries of the "Načíst komponenty"
mode: one entry per component that passes the given predicate,
with the weight 1/n where n is the number of passing components,
so the weights of one documentation unit sum to 1 even when a
period/area filter removes some of them. A documentation unit
without components gets a single entry with empty component
fields and weight 1.
dj_meta: metadata shared by the documentation unit (spread into
every entry); komps: its component documents; passes: predicate
komp -> bool deciding whether a component becomes a feature.
"""
if not komps:
# DJ without components – still one feature, weight 1
return [{
**dj_meta,
'komponenta_id': "",
'komponenta_areal': "",
'komponenta_obdobi': "",
'vaha': 1,
}]
prochazejici = [komp for komp in komps if passes(komp)]
vaha = 1 / len(prochazejici) if prochazejici else 1
return [
{
**dj_meta,
'komponenta_id': komp.get('ident_cely', ""),
'komponenta_areal': (
komp.get('komponenta_areal') or {}
).get('value', ""),
'komponenta_obdobi': (
komp.get('komponenta_obdobi') or {}
).get('value', ""),
'vaha': vaha,
}
for komp in prochazejici
]
def komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
# 'or {}' – the key may be present with a None value
areal_id = (komp.get('komponenta_areal') or {}).get('id', "")
@@ -292,6 +460,27 @@ def load_amcr_data(canvas, bb, filters=None,
return
_LOADING = True
# Login state is verified before the first query: an expired
# session would otherwise silently degrade the result to
# access level A without any error
try:
login_stav = _ensure_logged_in()
except Exception as e:
# The check must never block the download nor leave _LOADING
# stuck – an unexpected error means "proceed as today"
QgsMessageLog.logMessage(
f"Ověření stavu přihlášení selhalo: {e}",
"AMČR", Qgis.MessageLevel.Warning
)
login_stav = "unknown"
if login_stav == "fallback":
iface.messageBar().pushMessage(
"AMCR",
"Přihlášení se nepodařilo obnovit – stahování proběhne "
"anonymně a bude obsahovat jen záznamy s přístupností A.",
level=Qgis.MessageLevel.Warning
)
load_translations()
# --- 1. COORDINATE TRANSFORMATION ---
@@ -680,28 +869,16 @@ def load_amcr_data(canvas, bb, filters=None,
# One feature per component –
# all data on a single row, no relations needed
if komps:
for komp in komps:
if not komp_projde_filtrem(
komp, filter_areal,
# The weight is 1/n of the components
# that pass the period/area filter,
# so one DJ sums to 1
for komp_meta in _component_entries(
dj_meta, komps,
lambda k: komp_projde_filtrem(
k, filter_areal,
filter_datace, filters
):
continue
komp_meta = {
**dj_meta,
'komponenta_id': komp.get(
'ident_cely',
""
),
'komponenta_areal': (
komp.get('komponenta_areal')
or {}
).get('value', ""),
'komponenta_obdobi': (
komp.get('komponenta_obdobi')
or {}
).get('value', ""),
}
)
):
pian_lookup[dj_pian_value].append(
komp_meta)
target_pian_ids_count += 1
@@ -711,15 +888,12 @@ def load_amcr_data(canvas, bb, filters=None,
if filter_areal or filter_datace:
continue
empty_meta = {
**dj_meta,
'komponenta_id': "",
'komponenta_areal': "",
'komponenta_obdobi': "",
}
pian_lookup[dj_pian_value].append(
empty_meta)
target_pian_ids_count += 1
for komp_meta in _component_entries(
dj_meta, [], lambda k: True
):
pian_lookup[dj_pian_value].append(
komp_meta)
target_pian_ids_count += 1
else:
target_pian_ids_count += 1
pian_lookup[dj_pian_value].append(dj_meta)
@@ -1056,6 +1230,7 @@ def load_amcr_data(canvas, bb, filters=None,
"poznamka": "Poznámka/bližší popis",
"pred_org": "Předáno organizaci",
"evidencni": "Evidenční číslo",
"prvek_vaha": "Váha prvku",
}
if komponenty == "true":
@@ -1063,6 +1238,7 @@ def load_amcr_data(canvas, bb, filters=None,
QgsField("komponenta", QMetaType.Type.QString),
QgsField("komponenta_areal", QMetaType.Type.QString),
QgsField("komponenta_obdobi", QMetaType.Type.QString),
QgsField("prvek_vaha", QMetaType.Type.Double),
]
for vl in layers:
@@ -1209,6 +1385,7 @@ def load_amcr_data(canvas, bb, filters=None,
meta.get('komponenta_id', ""),
meta.get('komponenta_areal', ""),
meta.get('komponenta_obdobi', ""),
meta.get('vaha', 1),
])
feat.setAttributes(atributy)
+8 -2
View File
@@ -8,7 +8,7 @@ name=AMČR Viewer
qgisMinimumVersion=3.44.0
qgisMaximumVersion=4.99.0
description=Viewing and downloading the AMČR data.
version=2.1.4
version=2.2.0
author=David Spáčil
email=spacil@arub.cz
@@ -23,7 +23,13 @@ repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer
hasProcessingProvider=no
# Uncomment the following line and add your changelog:
changelog=
Plný seznam změn v češtině je dostupný zde: https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases/tag/v2.1.1
Plný seznam změn v češtině je dostupný zde: https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer/releases/tag/v2.2.0
v2.2.0 (2026-10-02)
* 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
* The filter dialog remembers the last confirmed filters per data type for the QGIS run and restores them on reopening
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)
@@ -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)
+188
View File
@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
"""
Guards the release PR (version/vX.Y.Z -> main).
Checks that the version actually moved forward and that the three places
that name it agree, because plugins.qgis.org and Zenodo both read from
files a human has to remember to touch by hand:
* amcr_viewer/metadata.txt: version= must be higher than on main, and the
first changelog entry must be for that exact version
* CITATION.cff: version must match metadata.txt, and date-released must
not be left pointing at the old release
* the branch name (version/vX.Y.Z) must match metadata.txt, so a stray
push to the wrong release branch is caught before merge
Run from the repository root:
python3 tests/check_version_bump.py <base_sha> <head_ref>
"""
import datetime
import re
import subprocess
import sys
METADATA = "amcr_viewer/metadata.txt"
CITATION = "CITATION.cff"
VERZE_METADATA = re.compile(r"^version=(.+)$", re.MULTILINE)
VERZE_CITATION = re.compile(r"^version:\s*'?([^'\n]+)'?\s*$", re.MULTILINE)
DATUM_CITATION = re.compile(r"^date-released:\s*'?([^'\n]+)'?\s*$",
re.MULTILINE)
# Přeskočí úvodní řádek "Plný seznam změn ... /vX.Y.Z" a najde první
# skutečnou položku changelogu.
POLOZKA_CHANGELOGU = re.compile(r"^\s+v([0-9][^\s(]*)\s*\(", re.MULTILINE)
BRANCH_VERZE = re.compile(r"^version/v(.+)$")
nalezy = []
def nacti_na_base(base_sha, cesta):
vysledek = subprocess.run(
["git", "show", f"{base_sha}:{cesta}"],
capture_output=True, text=True,
)
if vysledek.returncode != 0:
nalezy.append(f"{cesta}: na cílové větvi nejde přečíst "
f"(git show {base_sha}:{cesta} selhalo)")
return None
return vysledek.stdout
def nacti(cesta):
with open(cesta, encoding="utf-8") as f:
return f.read()
def hledej(vzor, text, cesta, popis):
shoda = vzor.search(text)
if not shoda:
nalezy.append(f"{cesta}: {popis} nenalezeno")
return None
return shoda.group(1).strip()
def verze_tuple(verze):
jadro = verze.split("-", 1)[0]
try:
return tuple(int(c) for c in jadro.split("."))
except ValueError:
return None
def je_bump(stara, nova):
if stara == nova:
return False
stara_t, nova_t = verze_tuple(stara), verze_tuple(nova)
if stara_t is None or nova_t is None:
# Nestandardní formát verze – nejde spolehlivě porovnat čísly,
# stačí tedy, že se řetězec liší.
return True
if nova_t != stara_t:
return nova_t > stara_t
# Stejné jádro (např. "-alpha" přípona): bump platí, pokud se text
# liší a nejde o couvnutí z release na prerelease.
return "-" in stara or "-" not in nova
def datum(text):
try:
return datetime.date.fromisoformat(text)
except ValueError:
return None
def main():
if len(sys.argv) != 3:
print("použití: check_version_bump.py <base_sha> <head_ref>")
return 2
base_sha, head_ref = sys.argv[1], sys.argv[2]
base_metadata = nacti_na_base(base_sha, METADATA)
base_citation = nacti_na_base(base_sha, CITATION)
head_metadata = nacti(METADATA)
head_citation = nacti(CITATION)
if base_metadata is None or base_citation is None:
# Bez base souborů nejde nic dalšího smysluplně ověřit.
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
return 1
stara_verze = hledej(VERZE_METADATA, base_metadata, METADATA,
"verze na cílové větvi (version=)")
nova_verze = hledej(VERZE_METADATA, head_metadata, METADATA,
"verze (version=)")
stara_citation_verze = hledej(VERZE_CITATION, base_citation, CITATION,
"verze na cílové větvi (version:)")
nova_citation_verze = hledej(VERZE_CITATION, head_citation, CITATION,
"verze (version:)")
stare_datum = hledej(DATUM_CITATION, base_citation, CITATION,
"date-released na cílové větvi")
nove_datum = hledej(DATUM_CITATION, head_citation, CITATION,
"date-released")
if None in (stara_verze, nova_verze, stara_citation_verze,
nova_citation_verze, stare_datum, nove_datum):
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
return 1
# 1. metadata.txt: verze musí jít dopředu.
if not je_bump(stara_verze, nova_verze):
nalezy.append(
f"{METADATA}: verze nepovýšena ({stara_verze} -> {nova_verze})")
# 2. metadata.txt: první položka changelogu musí patřit nové verzi.
prvni_polozka = POLOZKA_CHANGELOGU.search(head_metadata)
if not prvni_polozka:
nalezy.append(f"{METADATA}: v changelog= nenalezena žádná položka "
f"'vX.Y.Z (...)'")
elif prvni_polozka.group(1) != nova_verze:
nalezy.append(
f"{METADATA}: první položka changelogu je pro "
f"v{prvni_polozka.group(1)}, ale version={nova_verze}")
# 3. CITATION.cff: verze musí souhlasit s metadata.txt.
if nova_citation_verze != nova_verze:
nalezy.append(
f"{CITATION}: version: {nova_citation_verze} neodpovídá "
f"{METADATA} version={nova_verze}")
# 4. CITATION.cff: date-released se musí posunout, a ne dozadu.
stary_datum_obj, novy_datum_obj = datum(stare_datum), datum(nove_datum)
if stary_datum_obj is None or novy_datum_obj is None:
nalezy.append(f"{CITATION}: date-released není platné datum "
f"ISO 8601 ({stare_datum!r} -> {nove_datum!r})")
elif novy_datum_obj < stary_datum_obj:
nalezy.append(
f"{CITATION}: date-released couvlo ({stare_datum} -> "
f"{nove_datum})")
elif (nove_datum == stare_datum
and stara_citation_verze != nova_citation_verze):
nalezy.append(
f"{CITATION}: version se změnila, ale date-released zůstalo "
f"na {stare_datum}")
# 5. Název release větve musí odpovídat verzi, kterou nese.
shoda_branch = BRANCH_VERZE.match(head_ref)
if shoda_branch and shoda_branch.group(1) != nova_verze:
nalezy.append(
f"větev {head_ref!r} neodpovídá {METADATA} "
f"version={nova_verze}")
if nalezy:
print("Nálezy:")
for nalez in nalezy:
print(f" {nalez}")
return 1
print(f"Kontrola verze a changelogu: v{stara_verze} -> v{nova_verze}, "
f"bez nálezů")
return 0
if __name__ == "__main__":
sys.exit(main())
+537
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,545 @@ 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"
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("UpdateCodelistsTask", uloha)
zkouska("filtrační dialogy", dialogy)
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("odhlášení", odhlaseni)
zkouska("váha komponent", vaha_komponent)
qgs.exitQgis()