diff --git a/README.md b/README.md index 15c0267..c978730 100644 --- a/README.md +++ b/README.md @@ -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 @@ -347,6 +351,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": }` 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. | diff --git a/amcr_viewer/amcr_dialog.py b/amcr_viewer/amcr_dialog.py index c873d0a..166d672 100644 --- a/amcr_viewer/amcr_dialog.py +++ b/amcr_viewer/amcr_dialog.py @@ -1033,6 +1033,10 @@ class LoginDialog(QDialog): self.accept() def _forget_credentials(self): + # Lazy import to avoid an import cycle + # (amcr_tools imports LoginDialog lazily as well) + from . import amcr_tools + settings = QSettings() existing_id = settings.value(self.SETTINGS_KEY, "") if existing_id: @@ -1040,11 +1044,17 @@ class LoginDialog(QDialog): existing_id ) settings.remove(self.SETTINGS_KEY) - QMessageBox.information( - self, - "Hotovo", - "Uložené přihlašovací údaje byly odebrány." - ) + + # Without credentials the session in memory would otherwise stay + # logged in until QGIS is restarted + if amcr_tools.logout_from_api(): + zprava = ("Uložené přihlašovací údaje byly odebrány " + "a uživatel byl odhlášen.") + else: + zprava = ("Uložené přihlašovací údaje byly odebrány. Server " + "se nepodařilo kontaktovat, další stahování ale " + "proběhne anonymně.") + QMessageBox.information(self, "Hotovo", zprava) self.reject() # ------------------------------------------------------------------ diff --git a/amcr_viewer/amcr_tools.py b/amcr_viewer/amcr_tools.py index d49361e..78955ff 100644 --- a/amcr_viewer/amcr_tools.py +++ b/amcr_viewer/amcr_tools.py @@ -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): @@ -292,6 +417,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 --- diff --git a/amcr_viewer/metadata.txt b/amcr_viewer/metadata.txt index f9a8940..6ad24f7 100644 --- a/amcr_viewer/metadata.txt +++ b/amcr_viewer/metadata.txt @@ -28,6 +28,9 @@ changelog= * Filter labels unified: "Specifikace nálezu" renamed to "Materiál" to match the attribute alias * README rewritten to match the current state of the code * Tables for Akce and Lokality contain a field with feature weight, when Komponenty rendering is enabled + * The login state is verified before each download via /api/user/islogged; an expired session is renewed automatically + * When a logged-in download falls back to anonymous access, a message bar warning says so (only access level A data) + * Removing the stored credentials also logs the user out of the Digital Archive v2.1.4 (2026-10-01) * Removed unused generated resources.py and the bundled flake8 config, so the plugin passes the plugins.qgis.org scan without custom configuration v2.1.3 (2026-10-01) diff --git a/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/.openspec.yaml b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/.openspec.yaml new file mode 100644 index 0000000..ad94bad --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-02 diff --git a/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/design.md b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/design.md new file mode 100644 index 0000000..053d0ef --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/design.md @@ -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": }` 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. diff --git a/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/proposal.md b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/proposal.md new file mode 100644 index 0000000..0d77bfc --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/proposal.md @@ -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 + + + +## 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. diff --git a/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/specs/amcr-session/spec.md b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/specs/amcr-session/spec.md new file mode 100644 index 0000000..cbb9f4a --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/specs/amcr-session/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/tasks.md b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/tasks.md new file mode 100644 index 0000000..21c2d52 --- /dev/null +++ b/openspec/changes/archive/2026-10-02-fix-session-expiry-islogged/tasks.md @@ -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) diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 8327eaf..5eec5cb 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -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,182 @@ 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" + + zkouska("scoped enumy", enumy) zkouska("UpdateCodelistsTask", uloha) zkouska("filtrační dialogy", dialogy) zkouska("filtr podle data", filtr_datumu) +zkouska("stav přihlášení", prihlasovaci_stav) +zkouska("odhlášení", odhlaseni) qgs.exitQgis()