feat: add tenant semantic documentation lifecycle
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillRequest,
|
||||
)
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION,
|
||||
SemanticDocumentationSubjectDescriptor,
|
||||
SemanticDocumentationSubjectPage,
|
||||
SemanticDocumentationSubjectReference,
|
||||
SemanticDocumentationSubjectResolution,
|
||||
semantic_documentation_fingerprint,
|
||||
semantic_documentation_subject_capability,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
|
||||
|
||||
from govoplan_docs.backend.dsar_provider import DocsDsarProvider
|
||||
from govoplan_docs.backend.search_source import SemanticDocumentationSearchSource
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
SemanticDocumentationAuthorizationError,
|
||||
SemanticDocumentationConflictError,
|
||||
create_semantic_entry,
|
||||
publication_policy,
|
||||
publish_semantic_entry,
|
||||
select_locale_entries,
|
||||
semantic_entry_payload,
|
||||
set_publication_policy,
|
||||
update_semantic_entry,
|
||||
)
|
||||
from govoplan_core.core.dsar import DsarSubjectRef
|
||||
|
||||
|
||||
class _SubjectProvider:
|
||||
provider_id = "forms.semantic_subjects"
|
||||
module_id = "forms"
|
||||
contract_version = SEMANTIC_DOCUMENTATION_SUBJECT_CONTRACT_VERSION
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.revision = "7"
|
||||
self.denied_accounts: set[str] = set()
|
||||
|
||||
def reference(self, tenant_id: str) -> SemanticDocumentationSubjectReference:
|
||||
fingerprint = semantic_documentation_fingerprint(
|
||||
{"id": "form-1", "revision": self.revision}
|
||||
)
|
||||
return SemanticDocumentationSubjectReference(
|
||||
module_id="forms",
|
||||
tenant_id=tenant_id,
|
||||
subject_kind="form",
|
||||
subject_id="form-1",
|
||||
observed_revision=self.revision,
|
||||
observed_fingerprint=fingerprint,
|
||||
)
|
||||
|
||||
def descriptor(self, tenant_id: str) -> SemanticDocumentationSubjectDescriptor:
|
||||
return SemanticDocumentationSubjectDescriptor(
|
||||
reference=self.reference(tenant_id),
|
||||
labels={"de": "Anwohnerparkausweis", "en": "Resident permit"},
|
||||
descriptions={"de": "Konfiguriertes Formular"},
|
||||
route="/forms/form-1",
|
||||
required_scopes=("forms:form:read",),
|
||||
)
|
||||
|
||||
def list_subjects(self, session, principal, *, request):
|
||||
del session
|
||||
if principal.account_id in self.denied_accounts:
|
||||
return SemanticDocumentationSubjectPage()
|
||||
return SemanticDocumentationSubjectPage(
|
||||
subjects=(self.descriptor(request.tenant_id),)
|
||||
)
|
||||
|
||||
def resolve_subject(self, session, principal, *, reference):
|
||||
del session
|
||||
if principal.account_id in self.denied_accounts:
|
||||
return None
|
||||
descriptor = self.descriptor(reference.tenant_id)
|
||||
availability = (
|
||||
"changed"
|
||||
if reference.observed_revision
|
||||
and reference.observed_revision != descriptor.reference.observed_revision
|
||||
else "available"
|
||||
)
|
||||
return SemanticDocumentationSubjectResolution(
|
||||
requested_reference=reference,
|
||||
availability=availability,
|
||||
subject=descriptor,
|
||||
)
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, provider: _SubjectProvider) -> None:
|
||||
self.provider = provider
|
||||
self.name = semantic_documentation_subject_capability("forms")
|
||||
|
||||
def capability_names(self):
|
||||
return (self.name,)
|
||||
|
||||
def has_capability(self, name):
|
||||
return name == self.name
|
||||
|
||||
def capability(self, name):
|
||||
return self.provider if name == self.name else None
|
||||
|
||||
|
||||
def _principal(
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str = "tenant-1",
|
||||
scopes: frozenset[str] | None = None,
|
||||
) -> ApiPrincipal:
|
||||
principal = PrincipalRef(
|
||||
account_id=account_id,
|
||||
membership_id=f"membership-{account_id}",
|
||||
tenant_id=tenant_id,
|
||||
scopes=scopes or frozenset({"docs:documentation:read", "forms:form:read"}),
|
||||
)
|
||||
return ApiPrincipal(
|
||||
principal=principal,
|
||||
account=SimpleNamespace(id=account_id),
|
||||
user=SimpleNamespace(id=account_id),
|
||||
)
|
||||
|
||||
|
||||
def _content(title: str = "Permit form") -> dict[str, object]:
|
||||
return {
|
||||
"title": title,
|
||||
"summary": "Tenant meaning",
|
||||
"body": "Use this form for residents.",
|
||||
"meaning": "Configured resident permit request.",
|
||||
"intended_use": "Resident permits",
|
||||
"non_intended_use": "Visitor permits",
|
||||
"examples": [],
|
||||
"owner_account_id": "author",
|
||||
"steward_account_id": "reviewer",
|
||||
"audience": [],
|
||||
"classification": "internal",
|
||||
"links": [],
|
||||
}
|
||||
|
||||
|
||||
class SemanticDocumentationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
create_scope_tables(self.engine)
|
||||
Base.metadata.create_all(self.engine)
|
||||
self.session = Session(self.engine)
|
||||
self.session.add(
|
||||
Tenant(id="tenant-1", slug="tenant-1", name="Tenant One", settings={})
|
||||
)
|
||||
self.session.commit()
|
||||
self.provider = _SubjectProvider()
|
||||
self.registry = _Registry(self.provider)
|
||||
self.author = _principal("author")
|
||||
self.reviewer = _principal("reviewer")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.session.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_revision_review_policy_visibility_and_concurrency(self) -> None:
|
||||
entry = create_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
subject=self.provider.reference("tenant-1"),
|
||||
locale="de",
|
||||
content=_content(),
|
||||
change_reason="Initial tenant semantics",
|
||||
)
|
||||
self.assertEqual("reviewer_required", publication_policy(self.session, "tenant-1"))
|
||||
with self.assertRaises(SemanticDocumentationAuthorizationError):
|
||||
publish_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry_id=entry.id,
|
||||
expected_revision=1,
|
||||
change_reason="Self publish",
|
||||
)
|
||||
|
||||
entry = publish_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.reviewer,
|
||||
entry_id=entry.id,
|
||||
expected_revision=1,
|
||||
change_reason="Independent review",
|
||||
)
|
||||
self.assertEqual(2, entry.current_revision)
|
||||
reader_payload = semantic_entry_payload(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
self.assertEqual("Permit form", reader_payload["content"]["title"])
|
||||
|
||||
update_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry_id=entry.id,
|
||||
expected_revision=2,
|
||||
content=_content("Updated permit form"),
|
||||
change_reason="Clarify meaning",
|
||||
)
|
||||
with self.assertRaises(SemanticDocumentationConflictError):
|
||||
update_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry_id=entry.id,
|
||||
expected_revision=2,
|
||||
content=_content("Stale edit"),
|
||||
change_reason="Stale",
|
||||
)
|
||||
reader_payload = semantic_entry_payload(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
self.assertEqual("Permit form", reader_payload["content"]["title"])
|
||||
self.assertTrue(reader_payload["pending_draft"])
|
||||
|
||||
def test_subject_reauthorization_tenant_and_changed_state(self) -> None:
|
||||
entry = create_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
subject=self.provider.reference("tenant-1"),
|
||||
locale="de",
|
||||
content=_content(),
|
||||
change_reason="Initial",
|
||||
)
|
||||
publish_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.reviewer,
|
||||
entry_id=entry.id,
|
||||
expected_revision=1,
|
||||
change_reason="Review",
|
||||
)
|
||||
self.provider.revision = "8"
|
||||
payload = semantic_entry_payload(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
self.assertEqual("changed", payload["subject_resolution"]["availability"])
|
||||
self.provider.denied_accounts.add("author")
|
||||
self.assertIsNone(
|
||||
semantic_entry_payload(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
create_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
_principal("other", tenant_id="tenant-2"),
|
||||
subject=self.provider.reference("tenant-1"),
|
||||
locale="de",
|
||||
content=_content(),
|
||||
change_reason="Foreign",
|
||||
)
|
||||
|
||||
def test_locale_search_and_dsar_projection(self) -> None:
|
||||
entries = []
|
||||
for locale, title in (("de", "Deutsch"), ("en", "English")):
|
||||
entry = create_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
subject=self.provider.reference("tenant-1"),
|
||||
locale=locale,
|
||||
content=_content(title),
|
||||
change_reason="Initial",
|
||||
)
|
||||
publish_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.reviewer,
|
||||
entry_id=entry.id,
|
||||
expected_revision=1,
|
||||
change_reason="Review",
|
||||
)
|
||||
entries.append(entry)
|
||||
self.assertEqual("de", select_locale_entries(entries, locale="de-AT")[0].locale)
|
||||
|
||||
source = SemanticDocumentationSearchSource(self.registry)
|
||||
page = source.backfill(
|
||||
self.session,
|
||||
request=SearchBackfillRequest(
|
||||
tenant_id="tenant-1",
|
||||
provider_id="docs.semantic_documentation",
|
||||
resource_type="semantic_documentation",
|
||||
rebuild_id="rebuild-1",
|
||||
),
|
||||
)
|
||||
self.assertEqual(2, len(page.documents))
|
||||
request = SearchAuthorizationRequest(
|
||||
reference=page.documents[0].reference,
|
||||
source_revision=page.documents[0].source_revision,
|
||||
)
|
||||
self.assertTrue(source.authorize(self.session, self.author, requests=(request,))[request.reference.key])
|
||||
self.provider.denied_accounts.add("author")
|
||||
self.assertFalse(source.authorize(self.session, self.author, requests=(request,))[request.reference.key])
|
||||
|
||||
records = DocsDsarProvider().search_subject(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="author"),
|
||||
)
|
||||
self.assertGreaterEqual(len(records), 2)
|
||||
self.assertNotIn("body", records[0].data)
|
||||
actions = DocsDsarProvider().plan_erasure(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
subject=DsarSubjectRef(account_id="author"),
|
||||
records=records,
|
||||
)
|
||||
self.assertTrue(all(not action.executable for action in actions))
|
||||
|
||||
def test_direct_publication_policy_is_explicit(self) -> None:
|
||||
self.assertEqual(
|
||||
"direct",
|
||||
set_publication_policy(
|
||||
self.session,
|
||||
tenant_id="tenant-1",
|
||||
mode="direct",
|
||||
),
|
||||
)
|
||||
entry = create_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
subject=self.provider.reference("tenant-1"),
|
||||
locale="de",
|
||||
content=_content(),
|
||||
change_reason="Initial",
|
||||
)
|
||||
published = publish_semantic_entry(
|
||||
self.session,
|
||||
self.registry,
|
||||
self.author,
|
||||
entry_id=entry.id,
|
||||
expected_revision=1,
|
||||
change_reason="Direct publication",
|
||||
)
|
||||
self.assertEqual("published", published.lifecycle_state)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user