2 Commits
Author SHA1 Message Date
zemion 0e03c3e77e fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:29 +02:00
zemion 0337e0cf0b perf(cases): paginate authorized records in SQL
Module Package Release / publish-packages (push) Successful in 13s
Release v0.1.25. Coordinated integrity review: GovOPlaN/govoplan-core#298.
2026-09-08 12:19:37 +02:00
11 changed files with 164 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/cases-webui", "name": "@govoplan/cases-webui",
"version": "0.1.24", "version": "0.1.25",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "webui/src/index.ts", "main": "webui/src/index.ts",
+2 -2
View File
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-cases" name = "govoplan-cases"
version = "0.1.24" version = "0.1.25"
description = "GovOPlaN administrative case context module." description = "GovOPlaN administrative case context module."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.30"] dependencies = ["govoplan-core>=0.1.46"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+38 -1
View File
@@ -76,7 +76,7 @@ from govoplan_core.db.base import Base
MODULE_ID = "cases" MODULE_ID = "cases"
MODULE_VERSION = "0.1.24" MODULE_VERSION = "0.1.25"
READ_SCOPE = "cases:case:read" READ_SCOPE = "cases:case:read"
CREATE_SCOPE = "cases:case:create" CREATE_SCOPE = "cases:case:create"
UPDATE_SCOPE = "cases:case:update" UPDATE_SCOPE = "cases:case:update"
@@ -823,6 +823,38 @@ manifest = ModuleManifest(
"outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.", "outcome": "The eAkte preserves an exact case snapshot reference while Cases retains authority.",
}, },
), ),
DocumentationTopic(
id="cases.authorized-pagination",
title="Authorized case pages and exact totals",
summary="Filter current case access in the database before counting and paging.",
body=(
"Case lists use one current tenant/access/purpose predicate for both exact totals and pages of at most "
"200 records. Restricted access requires an active matching subject grant, sufficient permission, and "
"an exact declared purpose; general administrator scopes do not bypass this rule. Hidden cases never "
"consume page slots or enter totals. The database evaluates grant existence without loading all tenant "
"case IDs or grants into the application. Pages retain recorded-time and stable case-ID ordering; each "
"request rechecks current grants and is not a snapshot across concurrent changes. Exact JSON permission "
"and purpose predicates support SQLite and PostgreSQL, reject malformed element types, and fail closed "
"on unsupported database dialects. Historical reads still use current access."
),
layer="always", documentation_types=("user", "admin"),
audience=("user", "case_manager", "module_admin"), order=17,
translations={"de": {
"title": "Berechtigte Vorgangsseiten und exakte Gesamtzahlen",
"summary": "Aktuellen Vorgangszugriff vor Zählung und Seitenauswahl in der Datenbank filtern.",
"body": (
"Vorgangslisten verwenden denselben aktuellen Mandanten-/Zugriffs-/Zweckfilter für exakte Gesamtzahlen "
"und Seiten mit höchstens 200 Datensätzen. Eingeschränkter Zugriff verlangt eine aktive passende "
"Subjektfreigabe, ausreichende Rechte und einen exakt angegebenen Zweck; allgemeine Administratorrechte "
"umgehen diese Regel nicht. Verborgene Vorgänge belegen keine Seitenplätze und zählen nicht mit. "
"Die Datenbank prüft Freigaben, ohne alle Vorgangskennungen oder Freigaben des Mandanten in die Anwendung "
"zu laden. Die Sortierung nach Erfassungszeit und stabiler Vorgangskennung bleibt bestehen; jede Anfrage "
"prüft aktuelle Freigaben erneut und ist kein Snapshot über parallele Änderungen. Exakte JSON-Rechte- "
"und Zweckfilter unterstützen SQLite und PostgreSQL, verwerfen fehlerhafte Elementtypen und lehnen "
"nicht unterstützte Datenbankdialekte sicher ab. Historische Abrufe verwenden weiterhin aktuelle Rechte."
),
}},
),
DocumentationTopic( DocumentationTopic(
id="cases.governance.purpose-bound-access", id="cases.governance.purpose-bound-access",
title="Purpose-bound access to restricted cases", title="Purpose-bound access to restricted cases",
@@ -919,6 +951,8 @@ manifest = ModuleManifest(
title="Case lifecycle, access, and evidence reference", title="Case lifecycle, access, and evidence reference",
summary="Explains revision, status, access, and reference fields together with their durable consequences.", summary="Explains revision, status, access, and reference fields together with their durable consequences.",
body=( body=(
"Documentation books sit beside the Cases collection heading, the selected case title, and "
"the access or decision dialog title; field help stays with its label. "
"Title and status changes append an immutable case revision guarded by the " "Title and status changes append an immutable case revision guarded by the "
"expected revision and a stable idempotency key. Every accepted change also " "expected revision and a stable idempotency key. Every accepted change also "
"appends a timeline entry with actor, time, and change reason; a terminal " "appends a timeline entry with actor, time, and change reason; a terminal "
@@ -947,6 +981,9 @@ manifest = ModuleManifest(
"Revisions-, Status-, Zugriffs- und Verweisfelder gemeinsam mit ihren dauerhaften Folgen erläutern." "Revisions-, Status-, Zugriffs- und Verweisfelder gemeinsam mit ihren dauerhaften Folgen erläutern."
), ),
"body": ( "body": (
"Dokumentationsbücher stehen neben der Überschrift der Vorgangsliste, dem Titel des "
"ausgewählten Vorgangs und dem Titel des Zugriffs- oder Entscheidungsdialogs; Feldhilfe "
"bleibt bei der Feldbezeichnung. "
"Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und " "Änderungen an Titel und Status ergänzen eine unveränderliche Vorgangsrevision, geschützt durch erwartete Revision und "
"stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, " "stabilen Idempotenzschlüssel. Jede akzeptierte Änderung ergänzt außerdem einen Timeline-Eintrag mit handelnder Person, "
"Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit " "Zeitpunkt und Änderungsgrund; ein abschließender Status verlangt zusätzlich die Berechtigung zum Schließen. Die Sichtbarkeit "
+18 -48
View File
@@ -8,9 +8,10 @@ import json
from typing import Any from typing import Any
import uuid import uuid
from sqlalchemy import func from sqlalchemy import and_, exists, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_core.core.principal_helpers import principal_user_first_actor as _principal_actor
from govoplan_core.core.events import ( from govoplan_core.core.events import (
EventActorRef, EventActorRef,
EventObjectRef, EventObjectRef,
@@ -24,6 +25,7 @@ from govoplan_core.core.institutional import (
InstitutionalReference, InstitutionalReference,
) )
from govoplan_core.security.module_permissions import scopes_grant_compatible from govoplan_core.security.module_permissions import scopes_grant_compatible
from govoplan_core.db.json_predicates import json_array_contains_string
from govoplan_cases.backend.db.models import ( from govoplan_cases.backend.db.models import (
CaseAccessGrant, CaseAccessGrant,
CaseIdentity, CaseIdentity,
@@ -538,13 +540,7 @@ def list_cases(
CaseRecordRevision.tenant_id == tenant_id, CaseRecordRevision.tenant_id == tenant_id,
CaseRecordRevision.superseded_at.is_(None), CaseRecordRevision.superseded_at.is_(None),
) )
eligible = _eligible_case_ids( statement = statement.filter(_case_access_predicate(principal, permission="read", purpose=purpose))
session,
principal,
permission="read",
purpose=purpose,
)
statement = statement.filter(CaseRecordRevision.case_id.in_(eligible))
if status_keys: if status_keys:
statement = statement.filter( statement = statement.filter(
CaseRecordRevision.status_key.in_(tuple(status_keys)) CaseRecordRevision.status_key.in_(tuple(status_keys))
@@ -707,42 +703,30 @@ def can_access_case(
) )
def _eligible_case_ids( def _case_access_predicate(
session: Session,
principal: object, principal: object,
*, *,
permission: str, permission: str,
purpose: str | None, purpose: str | None,
) -> tuple[str, ...]: ):
tenant_id = _principal_tenant(principal) """Current tenant/purpose/grant policy, without materializing tenant IDs."""
current = session.query( tenant_visible = CaseRecordRevision.access_mode == "tenant"
CaseRecordRevision.case_id,
CaseRecordRevision.access_mode,
).filter(
CaseRecordRevision.tenant_id == tenant_id,
CaseRecordRevision.superseded_at.is_(None),
).all()
eligible = {
case_id for case_id, access_mode in current if access_mode == "tenant"
}
declared_purpose = str(purpose or "").strip() declared_purpose = str(purpose or "").strip()
if not declared_purpose: if not declared_purpose:
return tuple(eligible) return tenant_visible
subjects = _principal_subjects(principal) subjects = _principal_subjects(principal)
if not subjects: if not subjects:
return tuple(eligible) return tenant_visible
grants = session.query(CaseAccessGrant).filter( allowed_permissions = ("admin", "read", "update", "share") if permission == "read" else ("admin", permission)
CaseAccessGrant.tenant_id == tenant_id, grant_matches = exists().where(
CaseAccessGrant.tenant_id == CaseRecordRevision.tenant_id,
CaseAccessGrant.case_id == CaseRecordRevision.case_id,
CaseAccessGrant.active.is_(True), CaseAccessGrant.active.is_(True),
).all() or_(*(and_(CaseAccessGrant.subject_kind == kind, CaseAccessGrant.subject_id == subject_id) for kind, subject_id in subjects)),
eligible.update( or_(*(json_array_contains_string(CaseAccessGrant.permissions, value) for value in allowed_permissions)),
grant.case_id json_array_contains_string(CaseAccessGrant.allowed_purposes, declared_purpose),
for grant in grants
if (grant.subject_kind, grant.subject_id) in subjects
and _access_permissions_allow(tuple(grant.permissions or ()), permission)
and declared_purpose in tuple(grant.allowed_purposes or ())
) )
return tuple(eligible) return or_(tenant_visible, grant_matches)
def _sync_access_grants( def _sync_access_grants(
@@ -1366,20 +1350,6 @@ def _principal_tenant(principal: object) -> str:
return tenant_id return tenant_id
def _principal_actor(principal: object) -> str | None:
user = getattr(principal, "user", None)
for value in (
getattr(user, "id", None),
getattr(principal, "account_id", None),
getattr(principal, "identity_id", None),
getattr(principal, "membership_id", None),
):
candidate = str(value or "").strip()
if candidate:
return candidate
return None
def _session(value: object) -> Session: def _session(value: object) -> Session:
if not hasattr(value, "query"): if not hasattr(value, "query"):
raise InstitutionalContextError("Case registry requires a database session.") raise InstitutionalContextError("Case registry requires a database session.")
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from dataclasses import replace
import unittest
from sqlalchemy import event, select
from sqlalchemy.dialects import postgresql
import test_case_lifecycle as fixture
from govoplan_cases.backend import service
from govoplan_cases.backend.db.models import CaseAccessGrant, CaseRecordRevision
class CaseListQueryTests(unittest.TestCase):
def setUp(self):
self.fixture = fixture.CaseLifecycleTests()
self.fixture.setUp()
def tearDown(self):
self.fixture.tearDown()
def seed(self, count=50, *, restricted=False):
for index in range(count):
original = fixture.record()
reference = replace(original.reference, object_id=f"case-{index:03}")
record = replace(
original, reference=reference, case_number=f"CASE-{index:03}",
context=replace(original.context, case_ref=reference),
access_mode="restricted" if restricted else "tenant",
)
service.create_case(self.fixture.session, self.fixture.principal, record=record, idempotency_key=f"fixture-{index}")
self.fixture.session.add(CaseAccessGrant(
tenant_id="tenant-1", case_id=reference.object_id, subject_kind="account", subject_id="other-account",
permissions=["read"], allowed_purposes=["cases.casework"], source="manual", active=True, source_revision=1,
))
self.fixture.session.commit()
self.fixture.session.expunge_all()
def test_filtered_empty_page_does_not_materialize_grants_or_case_ids(self):
self.seed()
loaded = []
queries = []
def row_loaded(target, context):
loaded.append(target.id)
def executed(conn, cursor, statement, parameters, context, executemany):
if statement.lstrip().upper().startswith("SELECT"):
queries.append((statement, len(parameters)))
event.listen(CaseAccessGrant, "load", row_loaded)
event.listen(self.fixture.engine, "before_cursor_execute", executed)
try:
result = service.list_cases(
self.fixture.session, self.fixture.principal,
query="no-fixture-matches-this", purpose="cases.casework", limit=1,
)
self.assertEqual(((), 0), result)
self.assertEqual([], loaded)
self.assertEqual(2, len(queries))
self.assertTrue(all(count < 30 for _, count in queries))
self.assertIn("LIMIT", queries[-1][0])
finally:
event.remove(CaseAccessGrant, "load", row_loaded)
event.remove(self.fixture.engine, "before_cursor_execute", executed)
def test_current_grants_exact_purpose_and_permissions_govern_total(self):
self.seed(4, restricted=True)
reader = fixture.Principal(account_id="other-account")
session = self.fixture.session
self.assertEqual(((), 0), service.list_cases(session, reader, limit=1))
self.assertEqual(((), 0), service.list_cases(session, reader, purpose="cases.case", limit=1))
page, total = service.list_cases(session, reader, purpose="cases.casework", offset=1, limit=1)
self.assertEqual(4, total)
self.assertEqual("case-001", page[0].reference.object_id)
grants = session.query(CaseAccessGrant).filter(CaseAccessGrant.subject_id == "other-account").order_by(CaseAccessGrant.case_id).all()
grants[0].active = False
grants[1].permissions = ["reader"]
grants[2].allowed_purposes = ["cases.casework.extra"]
grants[3].permissions = ["update"]
session.commit()
self.assertEqual(1, service.list_cases(session, reader, purpose="cases.casework", limit=1)[1])
grants[3].allowed_purposes = {"not_an_array": "cases.casework"}
session.commit()
self.assertEqual(((), 0), service.list_cases(session, reader, purpose="cases.casework", limit=1))
self.assertEqual(((), 0), service.list_cases(session, fixture.Principal(tenant_id="tenant-2", account_id="other-account"), purpose="cases.casework", limit=1))
def test_access_predicate_compiles_for_postgresql_without_case_id_lists(self):
compiled = select(CaseRecordRevision.case_id).where(
service._case_access_predicate(self.fixture.principal, permission="read", purpose="cases.casework"),
).compile(dialect=postgresql.dialect())
self.assertIn("EXISTS", str(compiled))
self.assertIn("json_array_elements", str(compiled))
self.assertNotIn("cases.casework", str(compiled))
@@ -92,10 +92,11 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
REPO_ROOT / "webui/src/features/cases/CaseShareDialog.tsx" REPO_ROOT / "webui/src/features/cases/CaseShareDialog.tsx"
).read_text(encoding="utf-8") ).read_text(encoding="utf-8")
self.assertIn("DocumentationHelpLink", list_page) self.assertIn("titleHelp={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}", list_page)
self.assertNotIn("helpAction=", list_page)
for component in ( for component in (
"ActionBlockerHint", "ActionBlockerHint",
"DocumentationHelpLink", 'TextWithHelp as="div" help={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}',
"FormField", "FormField",
"useUnsavedDraftGuard", "useUnsavedDraftGuard",
"QUICK_ACCESS_RESULT_EVENT", "QUICK_ACCESS_RESULT_EVENT",
@@ -105,7 +106,7 @@ class CasesInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn(component, detail_page) self.assertIn(component, detail_page)
for component in ( for component in (
"ConfirmDialog", "ConfirmDialog",
"DocumentationHelpLink", "titleHelp={<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />}",
"ReferenceSelect", "ReferenceSelect",
"useUnsavedDraftGuard", "useUnsavedDraftGuard",
): ):
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/cases-webui", "name": "@govoplan/cases-webui",
"version": "0.1.24", "version": "0.1.25",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -103,6 +103,7 @@ export default function CaseDecisionDialog({
<Dialog <Dialog
open={open} open={open}
title={i18nMessage("i18n:govoplan-cases.decision_title", { value0: record.case_number })} title={i18nMessage("i18n:govoplan-cases.decision_title", { value0: record.case_number })}
titleHelp={<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />}
onClose={onClose} onClose={onClose}
closeDisabled={busy} closeDisabled={busy}
portal portal
@@ -123,7 +124,6 @@ export default function CaseDecisionDialog({
} }
> >
<div className="case-decision-content" data-help-context-id="cases.decision.editor"> <div className="case-decision-content" data-help-context-id="cases.decision.editor">
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null} {error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<p className="case-decision-explanation"> <p className="case-decision-explanation">
The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome. The server verifies your current acting assignment, the effective Mandate, the exact Case revision, evidence, and legal basis before recording the outcome.
+2 -2
View File
@@ -5,6 +5,7 @@ import { ActionToolbar,
ActionBlockerHint, ActionBlockerHint,
Button, Button,
DocumentationHelpLink, DocumentationHelpLink,
TextWithHelp,
DismissibleAlert, DismissibleAlert,
FormField, FormField,
IconButton, IconButton,
@@ -335,7 +336,6 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
/> />
</div> </div>
) : null} ) : null}
<DocumentationHelpLink reference={CASES_DOCUMENTATION} />
</ActionToolbar> </ActionToolbar>
<PageScrollViewport className="case-detail-viewport"> <PageScrollViewport className="case-detail-viewport">
{error && {error &&
@@ -350,7 +350,7 @@ export default function CaseDetailPage({ settings, auth }: PlatformRouteContext)
<div className="case-detail-title-row"> <div className="case-detail-title-row">
<div> <div>
<span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span> <span className="case-detail-eyebrow">{humanize(record.case_type_key)}</span>
<h1>{record.title}</h1> <TextWithHelp as="div" help={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}><h1>{record.title}</h1></TextWithHelp>
</div> </div>
<StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} /> <StatusBadge status={record.closed_at ? "inactive" : "active"} label={humanize(record.status_key)} />
</div> </div>
+1 -1
View File
@@ -173,6 +173,7 @@ export default function CaseShareDialog({
<Dialog <Dialog
open={open} open={open}
title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })} title={i18nMessage("i18n:govoplan-cases.case_access_title", { value0: record.case_number })}
titleHelp={<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />}
onClose={close} onClose={close}
closeDisabled={busy} closeDisabled={busy}
portal portal
@@ -193,7 +194,6 @@ export default function CaseShareDialog({
} }
> >
<div className="case-share-content"> <div className="case-share-content">
<DocumentationHelpLink reference={CASES_FIELDS_DOCUMENTATION} />
{error ? ( {error ? (
<DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>
) : null} ) : null}
+2 -1
View File
@@ -90,6 +90,8 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
<main className="cases-page"> <main className="cases-page">
<WorkspaceFrame className="cases-shell" label="Cases" interfaceId="cases.catalogue" helpContextId="cases.page.catalogue" helpModuleId="cases"> <WorkspaceFrame className="cases-shell" label="Cases" interfaceId="cases.catalogue" helpContextId="cases.page.catalogue" helpModuleId="cases">
<WorkspaceActionBar <WorkspaceActionBar
title="Cases"
titleHelp={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}
scope="collection-pane" scope="collection-pane"
variant="collection" variant="collection"
refreshable refreshable
@@ -131,7 +133,6 @@ export default function CasesPage({ settings }: PlatformRouteContext) {
</label> </label>
<span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span> <span className="cases-count">{i18nMessage("i18n:govoplan-cases.case_count", { value0: total })}</span>
</>} </>}
helpAction={<DocumentationHelpLink reference={CASES_DOCUMENTATION} />}
/> />
<PageScrollViewport className="cases-list-viewport"> <PageScrollViewport className="cases-list-viewport">
{error && {error &&