feat: add tenant semantic documentation lifecycle
This commit is contained in:
@@ -32,12 +32,19 @@ from govoplan_core.core.versioning import (
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
from govoplan_docs.backend.manifest import DOCS_ADMIN_READ_SCOPES, DOCS_READ_SCOPES
|
||||
from govoplan_docs.backend.api.v1.semantic_routes import router as semantic_router
|
||||
from govoplan_docs.backend.sources import (
|
||||
RegisteredDocumentationSource,
|
||||
build_documentation_source_registry,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
list_semantic_entries,
|
||||
select_locale_entries,
|
||||
semantic_entry_payload,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/docs", tags=["docs"])
|
||||
router.include_router(semantic_router)
|
||||
|
||||
TOPIC_KINDS = ("workflow", "reference", "pattern", "system")
|
||||
_CONFIGURATION_ACTIVE_STATES = frozenset({"enabled", "inherited"})
|
||||
@@ -958,9 +965,149 @@ def _collect_documentation_topics(
|
||||
for topic in provided_topics:
|
||||
if not user_workflow_scope_condition_issues(topic):
|
||||
topics.append((manifest.id, topic))
|
||||
if session is not None:
|
||||
try:
|
||||
topics.extend(
|
||||
("docs", topic)
|
||||
for topic in _semantic_documentation_topics(
|
||||
registry,
|
||||
principal,
|
||||
session=session,
|
||||
locale=locale,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
if documentation_type == "admin":
|
||||
topics.append(
|
||||
(
|
||||
"docs",
|
||||
DocumentationTopic(
|
||||
id="docs.semantic-runtime-unavailable",
|
||||
title="Tenant semantic documentation unavailable",
|
||||
summary="Stored tenant semantics could not be projected for this request.",
|
||||
body="Static module documentation remains available. Check the Docs database migration, subject providers, and application logs.",
|
||||
layer="evidence",
|
||||
documentation_types=("admin",),
|
||||
source_module_id="docs",
|
||||
metadata={
|
||||
"kind": "system",
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
return topics
|
||||
|
||||
|
||||
def _semantic_documentation_topics(
|
||||
registry: PlatformRegistry,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
session: object,
|
||||
locale: str,
|
||||
) -> tuple[DocumentationTopic, ...]:
|
||||
entries = select_locale_entries(
|
||||
list_semantic_entries(session, principal),
|
||||
locale=locale,
|
||||
)
|
||||
topics: list[DocumentationTopic] = []
|
||||
for entry in entries:
|
||||
payload = semantic_entry_payload(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
requested_locale=locale,
|
||||
)
|
||||
if payload is None:
|
||||
continue
|
||||
content = payload["content"]
|
||||
resolution = payload["subject_resolution"]
|
||||
subject = resolution.get("subject") if isinstance(resolution, Mapping) else None
|
||||
route = subject.get("route") if isinstance(subject, Mapping) else None
|
||||
route_anchor = (
|
||||
subject.get("route_anchor") if isinstance(subject, Mapping) else None
|
||||
)
|
||||
links = [
|
||||
DocumentationLink(
|
||||
label=str(item["label"]),
|
||||
href=str(item["href"]),
|
||||
kind="runtime" if str(item["href"]).startswith("/") else "external",
|
||||
)
|
||||
for item in content.get("links", ())
|
||||
if isinstance(item, Mapping) and item.get("label") and item.get("href")
|
||||
]
|
||||
if isinstance(route, str) and route.startswith("/"):
|
||||
links.insert(
|
||||
0,
|
||||
DocumentationLink(
|
||||
label="Open configured subject",
|
||||
href=(f"{route}#{route_anchor}" if route_anchor else route),
|
||||
kind="runtime",
|
||||
),
|
||||
)
|
||||
body_parts = [
|
||||
str(content.get(key) or "").strip()
|
||||
for key in (
|
||||
"meaning",
|
||||
"body",
|
||||
"intended_use",
|
||||
"non_intended_use",
|
||||
)
|
||||
]
|
||||
topics.append(
|
||||
DocumentationTopic(
|
||||
id=f"docs.semantic.{entry.id}",
|
||||
title=str(content.get("title") or "Semantic documentation"),
|
||||
summary=str(content.get("summary") or "Tenant semantic guidance"),
|
||||
body="\n\n".join(part for part in body_parts if part),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
source_module_id="docs",
|
||||
order=200,
|
||||
links=tuple(links),
|
||||
related_modules=(entry.subject_module_id,),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"source_badge": "tenant_semantic",
|
||||
"semantic_entry_id": entry.id,
|
||||
"semantic_subject": payload["subject"],
|
||||
"semantic_subject_stable_key": entry.subject_stable_key,
|
||||
"subject_availability": resolution.get("availability"),
|
||||
"locale": payload["locale"],
|
||||
"requested_locale": payload["requested_locale"],
|
||||
"locale_fallback": payload["locale_fallback"],
|
||||
"lifecycle_state": payload["lifecycle_state"],
|
||||
"pending_draft": payload["pending_draft"],
|
||||
"route": route,
|
||||
"route_anchor": route_anchor,
|
||||
"help_contexts": [
|
||||
_semantic_help_context(entry),
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
return tuple(topics)
|
||||
|
||||
|
||||
def _semantic_help_context(entry: object) -> str:
|
||||
values = [
|
||||
"semantic",
|
||||
str(getattr(entry, "subject_module_id")),
|
||||
str(getattr(entry, "subject_kind")),
|
||||
str(getattr(entry, "subject_id")),
|
||||
]
|
||||
if getattr(entry, "anchor_kind", None) and getattr(entry, "anchor_id", None):
|
||||
values.extend(
|
||||
(
|
||||
str(getattr(entry, "anchor_kind")),
|
||||
str(getattr(entry, "anchor_id")),
|
||||
)
|
||||
)
|
||||
return ".".join(values)
|
||||
|
||||
|
||||
def _topic_matches_documentation_type(topic: DocumentationTopic, documentation_type: DocumentationType) -> bool:
|
||||
return documentation_type in (topic.documentation_types or ("admin",))
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SemanticDocumentationContractError,
|
||||
SemanticDocumentationSubjectQuery,
|
||||
SemanticDocumentationSubjectReference,
|
||||
list_semantic_documentation_subjects,
|
||||
)
|
||||
from govoplan_core.db.session import get_session
|
||||
|
||||
from govoplan_docs.backend.manifest import (
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
DOCS_READ_SCOPE,
|
||||
DOCS_SEMANTIC_CREATE_SCOPE,
|
||||
DOCS_SEMANTIC_EDIT_SCOPE,
|
||||
DOCS_SEMANTIC_EXPORT_SCOPE,
|
||||
DOCS_SEMANTIC_POLICY_SCOPE,
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE,
|
||||
DOCS_SEMANTIC_RETIRE_SCOPE,
|
||||
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_schemas import (
|
||||
SemanticDocumentationCreateRequest,
|
||||
SemanticDocumentationPolicyUpdateRequest,
|
||||
SemanticDocumentationSupersedeRequest,
|
||||
SemanticDocumentationTransitionRequest,
|
||||
SemanticDocumentationUpdateRequest,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import (
|
||||
SemanticDocumentationAuthorizationError,
|
||||
SemanticDocumentationConflictError,
|
||||
SemanticDocumentationError,
|
||||
SemanticDocumentationNotFoundError,
|
||||
content_visible_to_principal,
|
||||
create_semantic_entry,
|
||||
get_semantic_entry,
|
||||
list_semantic_entries,
|
||||
publication_policy,
|
||||
publish_semantic_entry,
|
||||
retire_semantic_entry,
|
||||
revision_payload,
|
||||
select_locale_entries,
|
||||
semantic_entry_history,
|
||||
semantic_entry_payload,
|
||||
set_publication_policy,
|
||||
supersede_semantic_entry,
|
||||
update_semantic_entry,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/semantic", tags=["docs-semantic"])
|
||||
READ_SCOPES = (
|
||||
DOCS_READ_SCOPE,
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
DOCS_SEMANTIC_CREATE_SCOPE,
|
||||
DOCS_SEMANTIC_EDIT_SCOPE,
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE,
|
||||
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
|
||||
DOCS_SEMANTIC_RETIRE_SCOPE,
|
||||
)
|
||||
EDITOR_SCOPES = READ_SCOPES[2:]
|
||||
|
||||
SessionDep = Annotated[Session, Depends(get_session)]
|
||||
ReadPrincipal = Annotated[ApiPrincipal, Depends(require_any_scope(*READ_SCOPES))]
|
||||
|
||||
|
||||
@router.get("/policy")
|
||||
def get_policy(
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: ReadPrincipal,
|
||||
) -> dict[str, str]:
|
||||
_private(response)
|
||||
return {"mode": publication_policy(session, principal.tenant_id)}
|
||||
|
||||
|
||||
@router.put("/policy")
|
||||
def update_policy(
|
||||
payload: SemanticDocumentationPolicyUpdateRequest,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[
|
||||
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_POLICY_SCOPE))
|
||||
],
|
||||
) -> dict[str, str]:
|
||||
_private(response)
|
||||
try:
|
||||
mode = set_publication_policy(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
mode=payload.mode,
|
||||
)
|
||||
_audit(session, principal, "docs.semantic.policy.updated", None, {"mode": mode})
|
||||
session.commit()
|
||||
return {"mode": mode}
|
||||
except SemanticDocumentationError as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/subjects")
|
||||
def list_subjects(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: ReadPrincipal,
|
||||
query: str = Query(default="", max_length=300),
|
||||
subject_kind: list[str] | None = Query(default=None, max_length=120),
|
||||
module_id: str | None = Query(default=None, max_length=80),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
cursor: str | None = Query(default=None, max_length=1000),
|
||||
) -> dict[str, object]:
|
||||
_private(response)
|
||||
try:
|
||||
pages = list_semantic_documentation_subjects(
|
||||
_registry(request),
|
||||
session,
|
||||
principal,
|
||||
request=SemanticDocumentationSubjectQuery(
|
||||
tenant_id=principal.tenant_id,
|
||||
query=query,
|
||||
subject_kinds=tuple(subject_kind or ()),
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
),
|
||||
)
|
||||
except (SemanticDocumentationContractError, TypeError) as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
items = [
|
||||
{
|
||||
"module_id": provider_module_id,
|
||||
"subjects": [item.to_dict() for item in page.subjects],
|
||||
"next_cursor": page.next_cursor,
|
||||
"has_more": page.has_more,
|
||||
}
|
||||
for provider_module_id, page in pages
|
||||
if module_id is None or provider_module_id == module_id
|
||||
]
|
||||
return {"providers": items, "total": sum(len(item["subjects"]) for item in items)}
|
||||
|
||||
|
||||
@router.get("/entries")
|
||||
def list_entries(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: ReadPrincipal,
|
||||
locale: str = Query(default="de", min_length=2, max_length=20),
|
||||
module_id: str | None = Query(default=None, max_length=80),
|
||||
subject_kind: str | None = Query(default=None, max_length=120),
|
||||
include_drafts: bool = Query(default=False),
|
||||
) -> dict[str, object]:
|
||||
_private(response)
|
||||
editor = include_drafts and _has_any(principal, EDITOR_SCOPES)
|
||||
entries = list_semantic_entries(
|
||||
session,
|
||||
principal,
|
||||
module_id=module_id,
|
||||
subject_kind=subject_kind,
|
||||
)
|
||||
selected = entries if editor else select_locale_entries(entries, locale=locale)
|
||||
items = [
|
||||
item
|
||||
for entry in selected
|
||||
if (
|
||||
item := semantic_entry_payload(
|
||||
session,
|
||||
_registry(request),
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=editor,
|
||||
requested_locale=locale,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
]
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
@router.get("/entries/{entry_id}")
|
||||
def inspect_entry(
|
||||
entry_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: ReadPrincipal,
|
||||
include_draft: bool = Query(default=False),
|
||||
) -> dict[str, object]:
|
||||
_private(response)
|
||||
try:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
item = semantic_entry_payload(
|
||||
session,
|
||||
_registry(request),
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=include_draft and _has_any(principal, EDITOR_SCOPES),
|
||||
)
|
||||
if item is None:
|
||||
raise SemanticDocumentationNotFoundError("Entry not found.")
|
||||
return item
|
||||
except SemanticDocumentationError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.get("/entries/{entry_id}/history")
|
||||
def inspect_history(
|
||||
entry_id: str,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[ApiPrincipal, Depends(require_any_scope(*EDITOR_SCOPES))],
|
||||
) -> dict[str, object]:
|
||||
_private(response)
|
||||
try:
|
||||
items = semantic_entry_history(session, principal, entry_id=entry_id)
|
||||
return {"items": [revision_payload(item) for item in items], "total": len(items)}
|
||||
except SemanticDocumentationError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
@router.post("/entries", status_code=status.HTTP_201_CREATED)
|
||||
def create_entry(
|
||||
payload: SemanticDocumentationCreateRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[
|
||||
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_CREATE_SCOPE))
|
||||
],
|
||||
) -> dict[str, object]:
|
||||
return _mutate(
|
||||
response,
|
||||
session,
|
||||
principal,
|
||||
action="created",
|
||||
callback=lambda: create_semantic_entry(
|
||||
session,
|
||||
_registry(request),
|
||||
principal,
|
||||
subject=SemanticDocumentationSubjectReference.from_mapping(
|
||||
payload.subject.model_dump(mode="json")
|
||||
),
|
||||
locale=payload.locale,
|
||||
content=payload.content.model_dump(mode="json"),
|
||||
change_reason=payload.change_reason,
|
||||
),
|
||||
registry=_registry(request),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/entries/{entry_id}")
|
||||
def update_entry(
|
||||
entry_id: str,
|
||||
payload: SemanticDocumentationUpdateRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[
|
||||
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_EDIT_SCOPE))
|
||||
],
|
||||
) -> dict[str, object]:
|
||||
return _mutate(
|
||||
response,
|
||||
session,
|
||||
principal,
|
||||
action="updated",
|
||||
callback=lambda: update_semantic_entry(
|
||||
session,
|
||||
_registry(request),
|
||||
principal,
|
||||
entry_id=entry_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
content=payload.content.model_dump(mode="json"),
|
||||
change_reason=payload.change_reason,
|
||||
),
|
||||
registry=_registry(request),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entries/{entry_id}/publish")
|
||||
def publish_entry(
|
||||
entry_id: str,
|
||||
payload: SemanticDocumentationTransitionRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[
|
||||
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_PUBLISH_SCOPE))
|
||||
],
|
||||
) -> dict[str, object]:
|
||||
return _mutate(
|
||||
response,
|
||||
session,
|
||||
principal,
|
||||
action="published",
|
||||
callback=lambda: publish_semantic_entry(
|
||||
session,
|
||||
_registry(request),
|
||||
principal,
|
||||
entry_id=entry_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
change_reason=payload.change_reason,
|
||||
),
|
||||
registry=_registry(request),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entries/{entry_id}/supersede")
|
||||
def supersede_entry(
|
||||
entry_id: str,
|
||||
payload: SemanticDocumentationSupersedeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[
|
||||
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_SUPERSEDE_SCOPE))
|
||||
],
|
||||
) -> dict[str, object]:
|
||||
return _mutate(
|
||||
response,
|
||||
session,
|
||||
principal,
|
||||
action="superseded",
|
||||
callback=lambda: supersede_semantic_entry(
|
||||
session,
|
||||
principal,
|
||||
entry_id=entry_id,
|
||||
replacement_entry_id=payload.replacement_entry_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
change_reason=payload.change_reason,
|
||||
),
|
||||
registry=_registry(request),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entries/{entry_id}/retire")
|
||||
def retire_entry(
|
||||
entry_id: str,
|
||||
payload: SemanticDocumentationTransitionRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[
|
||||
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_RETIRE_SCOPE))
|
||||
],
|
||||
) -> dict[str, object]:
|
||||
return _mutate(
|
||||
response,
|
||||
session,
|
||||
principal,
|
||||
action="retired",
|
||||
callback=lambda: retire_semantic_entry(
|
||||
session,
|
||||
principal,
|
||||
entry_id=entry_id,
|
||||
expected_revision=payload.expected_revision,
|
||||
change_reason=payload.change_reason,
|
||||
),
|
||||
registry=_registry(request),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_tenant_semantics(
|
||||
request: Request,
|
||||
response: Response,
|
||||
session: SessionDep,
|
||||
principal: Annotated[
|
||||
ApiPrincipal, Depends(require_any_scope(DOCS_SEMANTIC_EXPORT_SCOPE))
|
||||
],
|
||||
) -> dict[str, object]:
|
||||
_private(response)
|
||||
entries = list_semantic_entries(session, principal)
|
||||
response.headers["Content-Disposition"] = (
|
||||
'attachment; filename="govoplan-semantic-documentation.json"'
|
||||
)
|
||||
exported: list[dict[str, object]] = []
|
||||
for entry in entries:
|
||||
payload = semantic_entry_payload(
|
||||
session,
|
||||
_registry(request),
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=True,
|
||||
)
|
||||
if payload is None or payload.get("content_redacted"):
|
||||
continue
|
||||
exported.append(
|
||||
{
|
||||
"entry": payload,
|
||||
"history": [
|
||||
revision_payload(item)
|
||||
for item in semantic_entry_history(
|
||||
session, principal, entry_id=entry.id
|
||||
)
|
||||
if content_visible_to_principal(item.content, principal)
|
||||
],
|
||||
}
|
||||
)
|
||||
return {
|
||||
"schema_version": "1",
|
||||
"tenant_id": principal.tenant_id,
|
||||
"entries": exported,
|
||||
}
|
||||
|
||||
|
||||
def _mutate(
|
||||
response: Response,
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
action: str,
|
||||
callback: Callable[[], Any],
|
||||
registry: object,
|
||||
) -> dict[str, object]:
|
||||
_private(response)
|
||||
try:
|
||||
entry = callback()
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
f"docs.semantic.{action}",
|
||||
entry.id,
|
||||
{
|
||||
"revision": entry.current_revision,
|
||||
"subject_stable_key": entry.subject_stable_key,
|
||||
"locale": entry.locale,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
result = semantic_entry_payload(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=True,
|
||||
)
|
||||
if result is None:
|
||||
raise SemanticDocumentationNotFoundError("Entry not found.")
|
||||
return result
|
||||
except (SemanticDocumentationError, SemanticDocumentationContractError) as exc:
|
||||
session.rollback()
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
|
||||
def _audit(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
action: str,
|
||||
entry_id: str | None,
|
||||
details: dict[str, object],
|
||||
) -> None:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action=action,
|
||||
object_type="semantic_documentation",
|
||||
object_id=entry_id,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _private(response: Response) -> None:
|
||||
response.headers["Cache-Control"] = "private, no-store"
|
||||
|
||||
|
||||
def _registry(request: Request) -> object:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if registry is None or not hasattr(registry, "capability_names"):
|
||||
raise HTTPException(status_code=500, detail="Module registry is unavailable.")
|
||||
return registry
|
||||
|
||||
|
||||
def _has_any(principal: ApiPrincipal, scopes: tuple[str, ...]) -> bool:
|
||||
return any(principal.has(scope) for scope in scopes)
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, SemanticDocumentationNotFoundError):
|
||||
return HTTPException(status_code=404, detail=str(exc))
|
||||
if isinstance(exc, SemanticDocumentationAuthorizationError):
|
||||
return HTTPException(status_code=403, detail=str(exc))
|
||||
if isinstance(exc, SemanticDocumentationConflictError):
|
||||
return HTTPException(status_code=409, detail=str(exc))
|
||||
return HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Docs-owned persistence models."""
|
||||
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SemanticDocumentationEntry",
|
||||
"SemanticDocumentationRevision",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from govoplan_core.db.base import Base, TimestampMixin
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class SemanticDocumentationEntry(Base, TimestampMixin):
|
||||
__tablename__ = "docs_semantic_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subject_stable_key",
|
||||
"locale",
|
||||
name="uq_docs_semantic_entry_subject_locale",
|
||||
),
|
||||
Index(
|
||||
"ix_docs_semantic_entries_tenant_state",
|
||||
"tenant_id",
|
||||
"lifecycle_state",
|
||||
),
|
||||
Index(
|
||||
"ix_docs_semantic_entries_subject",
|
||||
"tenant_id",
|
||||
"subject_module_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
subject_stable_key: Mapped[str] = mapped_column(
|
||||
String(80), nullable=False, index=True
|
||||
)
|
||||
subject_module_id: Mapped[str] = mapped_column(
|
||||
String(80), nullable=False, index=True
|
||||
)
|
||||
subject_kind: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
|
||||
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
anchor_kind: Mapped[str | None] = mapped_column(
|
||||
String(120), nullable=True, index=True
|
||||
)
|
||||
anchor_id: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
locale: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||
lifecycle_state: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, default="draft", index=True
|
||||
)
|
||||
current_revision: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
current_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
published_revision_id: Mapped[str | None] = mapped_column(
|
||||
String(36), nullable=True, index=True
|
||||
)
|
||||
superseded_by_entry_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("docs_semantic_entries.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
created_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
updated_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
published_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
retired_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
retired_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
|
||||
revisions: Mapped[list["SemanticDocumentationRevision"]] = relationship(
|
||||
back_populates="entry",
|
||||
cascade="all, delete-orphan",
|
||||
foreign_keys="SemanticDocumentationRevision.entry_id",
|
||||
order_by="SemanticDocumentationRevision.revision",
|
||||
)
|
||||
superseded_by: Mapped["SemanticDocumentationEntry | None"] = relationship(
|
||||
remote_side="SemanticDocumentationEntry.id",
|
||||
foreign_keys=[superseded_by_entry_id],
|
||||
)
|
||||
|
||||
|
||||
class SemanticDocumentationRevision(Base, TimestampMixin):
|
||||
__tablename__ = "docs_semantic_revisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"entry_id",
|
||||
"revision",
|
||||
name="uq_docs_semantic_revision_number",
|
||||
),
|
||||
Index(
|
||||
"ix_docs_semantic_revisions_entry",
|
||||
"entry_id",
|
||||
"revision",
|
||||
),
|
||||
Index(
|
||||
"ix_docs_semantic_revisions_tenant_state",
|
||||
"tenant_id",
|
||||
"lifecycle_state",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
entry_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("docs_semantic_entries.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
lifecycle_state: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
change_reason: Mapped[str] = mapped_column(String(1000), nullable=False)
|
||||
content: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
content_hash: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
|
||||
subject_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
subject_fingerprint: Mapped[str | None] = mapped_column(
|
||||
String(80), nullable=True, index=True
|
||||
)
|
||||
authored_by: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
reviewed_by: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True, index=True
|
||||
)
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
provenance: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
recoverable: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
search_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
|
||||
entry: Mapped[SemanticDocumentationEntry] = relationship(
|
||||
back_populates="revisions",
|
||||
foreign_keys=[entry_id],
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SemanticDocumentationEntry",
|
||||
"SemanticDocumentationRevision",
|
||||
"new_uuid",
|
||||
]
|
||||
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.dsar import (
|
||||
DsarErasureActionRef,
|
||||
DsarExecutionResultRef,
|
||||
DsarRecordRef,
|
||||
DsarSubjectRef,
|
||||
dsar_capability_name,
|
||||
)
|
||||
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
|
||||
|
||||
DOCS_DSAR_CAPABILITY = dsar_capability_name("docs")
|
||||
_MAX_REVISIONS = 5_000
|
||||
|
||||
|
||||
class DocsDsarProvider:
|
||||
provider_id = "docs"
|
||||
module_id = "docs"
|
||||
|
||||
def search_subject(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
) -> Sequence[DsarRecordRef]:
|
||||
db = _session(session)
|
||||
identifiers = _identifiers(subject)
|
||||
if not identifiers:
|
||||
return ()
|
||||
revisions = (
|
||||
db.query(SemanticDocumentationRevision)
|
||||
.filter(SemanticDocumentationRevision.tenant_id == tenant_id)
|
||||
.order_by(SemanticDocumentationRevision.entry_id, SemanticDocumentationRevision.revision)
|
||||
.limit(_MAX_REVISIONS + 1)
|
||||
.all()
|
||||
)
|
||||
if len(revisions) > _MAX_REVISIONS:
|
||||
raise ValueError(
|
||||
"Docs DSAR revision limit exceeded; use an exact semantic-entry reference."
|
||||
)
|
||||
explicit_entry = subject.external_references.get("docs.semantic_entry")
|
||||
entries = {
|
||||
row.id: row
|
||||
for row in db.query(SemanticDocumentationEntry).filter(
|
||||
SemanticDocumentationEntry.tenant_id == tenant_id
|
||||
)
|
||||
}
|
||||
records: list[DsarRecordRef] = []
|
||||
for revision in revisions:
|
||||
entry = entries.get(revision.entry_id)
|
||||
if entry is None or (explicit_entry and entry.id != explicit_entry):
|
||||
continue
|
||||
matches = _matches(entry, revision, identifiers)
|
||||
if not matches and not explicit_entry:
|
||||
continue
|
||||
immutable = revision.lifecycle_state in {
|
||||
"published",
|
||||
"superseded",
|
||||
"retired",
|
||||
}
|
||||
records.append(
|
||||
DsarRecordRef(
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
resource_type="semantic_documentation_revision",
|
||||
resource_id=revision.id,
|
||||
category="configured_semantic_documentation_attribution",
|
||||
title="Semantic documentation attribution",
|
||||
data={
|
||||
"entry_id": entry.id,
|
||||
"revision": revision.revision,
|
||||
"lifecycle_state": revision.lifecycle_state,
|
||||
"subject_module_id": entry.subject_module_id,
|
||||
"subject_kind": entry.subject_kind,
|
||||
"subject_id": entry.subject_id,
|
||||
"locale": entry.locale,
|
||||
"matching_reference_fields": matches,
|
||||
},
|
||||
observed_at=_aware(revision.created_at),
|
||||
immutable_evidence=immutable,
|
||||
retention_reason=(
|
||||
"Published semantic-documentation authorship and review history "
|
||||
"is retained as configuration-governance evidence."
|
||||
if immutable
|
||||
else None
|
||||
),
|
||||
source_path=f"/docs/semantic?entryId={entry.id}",
|
||||
)
|
||||
)
|
||||
return tuple(records)
|
||||
|
||||
def plan_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
records: Sequence[DsarRecordRef],
|
||||
) -> Sequence[DsarErasureActionRef]:
|
||||
del session, tenant_id, subject
|
||||
return tuple(
|
||||
DsarErasureActionRef(
|
||||
action_id=f"docs:retain:{record.resource_id}",
|
||||
provider_id=self.provider_id,
|
||||
module_id=self.module_id,
|
||||
kind="retain" if record.immutable_evidence else "manual_review",
|
||||
resource_type=record.resource_type,
|
||||
resource_id=record.resource_id,
|
||||
title="Review semantic documentation attribution",
|
||||
rationale=(
|
||||
record.retention_reason
|
||||
or "Draft attribution may be anonymized only after a configurator "
|
||||
"confirms that ownership and stewardship remain accountable."
|
||||
),
|
||||
executable=False,
|
||||
metadata={"immutable_evidence": record.immutable_evidence},
|
||||
)
|
||||
for record in records
|
||||
)
|
||||
|
||||
def execute_erasure(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject: DsarSubjectRef,
|
||||
actions: Sequence[DsarErasureActionRef],
|
||||
request_id: str,
|
||||
) -> Sequence[DsarExecutionResultRef]:
|
||||
del session, tenant_id, subject
|
||||
return tuple(
|
||||
DsarExecutionResultRef(
|
||||
action_id=action.action_id,
|
||||
status="blocked",
|
||||
summary=(
|
||||
"Docs semantic attribution requires governed manual review and "
|
||||
"was not changed automatically."
|
||||
),
|
||||
evidence={"request_id": request_id},
|
||||
)
|
||||
for action in actions
|
||||
)
|
||||
|
||||
|
||||
def _identifiers(subject: DsarSubjectRef) -> frozenset[str]:
|
||||
return frozenset(
|
||||
str(value).strip()
|
||||
for value in (
|
||||
subject.account_id,
|
||||
subject.identity_id,
|
||||
subject.membership_id,
|
||||
subject.external_references.get("access.account"),
|
||||
)
|
||||
if str(value or "").strip()
|
||||
)
|
||||
|
||||
|
||||
def _matches(
|
||||
entry: SemanticDocumentationEntry,
|
||||
revision: SemanticDocumentationRevision,
|
||||
identifiers: frozenset[str],
|
||||
) -> list[str]:
|
||||
fields = {
|
||||
"entry.created_by": entry.created_by,
|
||||
"entry.updated_by": entry.updated_by,
|
||||
"entry.published_by": entry.published_by,
|
||||
"entry.retired_by": entry.retired_by,
|
||||
"revision.authored_by": revision.authored_by,
|
||||
"revision.reviewed_by": revision.reviewed_by,
|
||||
"content.owner_account_id": revision.content.get("owner_account_id"),
|
||||
"content.steward_account_id": revision.content.get("steward_account_id"),
|
||||
}
|
||||
return sorted(
|
||||
field
|
||||
for field, value in fields.items()
|
||||
if str(value or "").strip() in identifiers
|
||||
)
|
||||
|
||||
|
||||
def _aware(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Docs DSAR requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = ["DOCS_DSAR_CAPABILITY", "DocsDsarProvider"]
|
||||
@@ -1,30 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
)
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityDocumentation,
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationSourceDefinition,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
ModuleInterfaceProvider,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.module_guards import (
|
||||
drop_table_retirement_provider,
|
||||
persistent_table_uninstall_guard,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import (
|
||||
ModuleArchitectureDeclaration,
|
||||
ModuleArchitectureDocumentation,
|
||||
ModuleMaturityEvidence,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||
from govoplan_docs.backend.db import models as docs_models
|
||||
from govoplan_docs.backend.dsar_provider import DOCS_DSAR_CAPABILITY, DocsDsarProvider
|
||||
from govoplan_docs.backend.search_source import (
|
||||
create_semantic_documentation_search_source,
|
||||
)
|
||||
|
||||
DOCS_READ_SCOPE = "docs:documentation:read"
|
||||
DOCS_ADMIN_READ_SCOPE = "docs:documentation:admin"
|
||||
DOCS_SEMANTIC_CREATE_SCOPE = "docs:semantic:create"
|
||||
DOCS_SEMANTIC_EDIT_SCOPE = "docs:semantic:edit"
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE = "docs:semantic:publish"
|
||||
DOCS_SEMANTIC_SUPERSEDE_SCOPE = "docs:semantic:supersede"
|
||||
DOCS_SEMANTIC_RETIRE_SCOPE = "docs:semantic:retire"
|
||||
DOCS_SEMANTIC_EXPORT_SCOPE = "docs:semantic:export"
|
||||
DOCS_SEMANTIC_POLICY_SCOPE = "docs:semantic:policy"
|
||||
DOCS_ADMIN_READ_SCOPES = (
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
"system:settings:read",
|
||||
@@ -42,6 +65,11 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
reference="tests/test_docs_context.py",
|
||||
summary="Tests audience-safe configured documentation and architecture projections.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="test",
|
||||
reference="tests/test_semantic_documentation.py",
|
||||
summary="Tests tenant isolation, immutable revision lifecycle, publication policy, subject reauthorization, search, localization, and DSAR projection.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/DOCUMENTATION_LAYER_CONCEPT.md",
|
||||
@@ -52,11 +80,21 @@ ARCHITECTURE = ModuleArchitectureDeclaration(
|
||||
reference="docs/INTERFACE_PATTERN_MIGRATION.md",
|
||||
summary="Records the Core-owned Docs workspace and page-layout boundary.",
|
||||
),
|
||||
ModuleMaturityEvidence(
|
||||
kind="documentation",
|
||||
reference="docs/SEMANTIC_DOCUMENTATION.md",
|
||||
summary="Defines semantic authoring, authorization, lifecycle, export, and recovery behavior.",
|
||||
),
|
||||
),
|
||||
known_limits=(
|
||||
"Architecture declarations are in staged adoption, so undeclared modules remain visible as pending.",
|
||||
),
|
||||
owned_concepts=("configured documentation projection", "documentation audience filtering"),
|
||||
owned_concepts=(
|
||||
"configured documentation projection",
|
||||
"documentation audience filtering",
|
||||
"tenant semantic documentation revisions",
|
||||
"semantic documentation publication lifecycle",
|
||||
),
|
||||
non_owned_concepts=("module feature behavior", "module evidence generation"),
|
||||
documentation=ModuleArchitectureDocumentation(
|
||||
security=("docs/DOCUMENTATION_LAYER_CONCEPT.md",),
|
||||
@@ -86,21 +124,82 @@ def _route_factory(context: ModuleContext):
|
||||
return router
|
||||
|
||||
|
||||
def _dsar_provider(_context: ModuleContext) -> DocsDsarProvider:
|
||||
return DocsDsarProvider()
|
||||
|
||||
|
||||
manifest = ModuleManifest(
|
||||
id="docs",
|
||||
name="Docs",
|
||||
version="0.1.18",
|
||||
version="0.1.19",
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
),
|
||||
optional_dependencies=("policy", "audit", "ops", "workflow_engine", "search"),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name=DOCS_DSAR_CAPABILITY, version="0.1.0"),
|
||||
),
|
||||
capability_factories={DOCS_DSAR_CAPABILITY: _dsar_provider},
|
||||
capability_documentation={
|
||||
DOCS_DSAR_CAPABILITY: CapabilityDocumentation(
|
||||
label="Docs data-subject request provider",
|
||||
summary=(
|
||||
"Exports minimized tenant semantic-documentation authorship, "
|
||||
"review, ownership, and stewardship references while retaining "
|
||||
"published configuration-governance evidence."
|
||||
),
|
||||
contract_version="0.1.0",
|
||||
),
|
||||
},
|
||||
optional_dependencies=(
|
||||
"policy",
|
||||
"audit",
|
||||
"ops",
|
||||
"workflow",
|
||||
"forms",
|
||||
"search",
|
||||
),
|
||||
permissions=(
|
||||
_permission(
|
||||
DOCS_READ_SCOPE,
|
||||
"View configured documentation",
|
||||
"Read user documentation generated for the current actor from installed modules and effective configuration.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_SEMANTIC_CREATE_SCOPE,
|
||||
"Create semantic documentation",
|
||||
"Create tenant-owned semantic documentation for configured subjects.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_SEMANTIC_EDIT_SCOPE,
|
||||
"Edit semantic documentation",
|
||||
"Edit drafts using immutable revisions and optimistic concurrency.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE,
|
||||
"Publish semantic documentation",
|
||||
"Review and publish semantic documentation under tenant policy.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
|
||||
"Supersede semantic documentation",
|
||||
"Replace semantic documentation with another published entry.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_SEMANTIC_RETIRE_SCOPE,
|
||||
"Retire semantic documentation",
|
||||
"Retire semantic documentation while retaining its revision history.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_SEMANTIC_EXPORT_SCOPE,
|
||||
"Export tenant semantic documentation",
|
||||
"Export tenant-owned semantic entries and immutable history.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_SEMANTIC_POLICY_SCOPE,
|
||||
"Configure semantic publication policy",
|
||||
"Choose direct publication or independent reviewer publication.",
|
||||
),
|
||||
_permission(
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
"View administrative documentation",
|
||||
@@ -115,6 +214,43 @@ manifest = ModuleManifest(
|
||||
permissions=(DOCS_READ_SCOPE,),
|
||||
default_authenticated=True,
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="semantic_documentation_author",
|
||||
name="Semantic documentation author",
|
||||
description="Discover configured subjects and create or revise their semantic documentation.",
|
||||
permissions=(
|
||||
DOCS_READ_SCOPE,
|
||||
DOCS_SEMANTIC_CREATE_SCOPE,
|
||||
DOCS_SEMANTIC_EDIT_SCOPE,
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="semantic_documentation_reviewer",
|
||||
name="Semantic documentation reviewer",
|
||||
description="Review, publish, supersede, and retire semantic documentation.",
|
||||
permissions=(
|
||||
DOCS_READ_SCOPE,
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE,
|
||||
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
|
||||
DOCS_SEMANTIC_RETIRE_SCOPE,
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="semantic_documentation_manager",
|
||||
name="Semantic documentation manager",
|
||||
description="Administer semantic authoring, review, lifecycle, policy, and tenant export.",
|
||||
permissions=(
|
||||
DOCS_READ_SCOPE,
|
||||
DOCS_ADMIN_READ_SCOPE,
|
||||
DOCS_SEMANTIC_CREATE_SCOPE,
|
||||
DOCS_SEMANTIC_EDIT_SCOPE,
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE,
|
||||
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
|
||||
DOCS_SEMANTIC_RETIRE_SCOPE,
|
||||
DOCS_SEMANTIC_EXPORT_SCOPE,
|
||||
DOCS_SEMANTIC_POLICY_SCOPE,
|
||||
),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="docs_admin",
|
||||
name="Documentation administrator",
|
||||
@@ -142,6 +278,18 @@ manifest = ModuleManifest(
|
||||
required_any=DOCS_READ_SCOPES,
|
||||
order=880,
|
||||
),
|
||||
FrontendRoute(
|
||||
path="/docs/semantic",
|
||||
component="SemanticDocumentationPage",
|
||||
required_any=(
|
||||
DOCS_SEMANTIC_CREATE_SCOPE,
|
||||
DOCS_SEMANTIC_EDIT_SCOPE,
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE,
|
||||
DOCS_SEMANTIC_SUPERSEDE_SCOPE,
|
||||
DOCS_SEMANTIC_RETIRE_SCOPE,
|
||||
),
|
||||
order=881,
|
||||
),
|
||||
),
|
||||
nav_items=(
|
||||
NavItem(
|
||||
@@ -154,6 +302,88 @@ manifest = ModuleManifest(
|
||||
),
|
||||
),
|
||||
documentation=(
|
||||
DocumentationTopic(
|
||||
id="docs.semantic-documentation",
|
||||
title="Tenant semantic documentation",
|
||||
summary="Explain what configured forms, fields, workflows, steps, and other stable subjects mean in this tenant.",
|
||||
body=(
|
||||
"Authors select an authorized subject supplied by its owning module and create locale-specific plain-text guidance. "
|
||||
"Every save creates an immutable revision. Tenant policy chooses direct publication or an independent reviewer. "
|
||||
"Published content remains subject to the subject's current authorization, the documentation audience and classification, tenant isolation, and locale selection. "
|
||||
"Changed, missing, superseded, or temporarily unavailable subjects are shown explicitly; direct links, contextual help, search, caches, and tenant exports apply the same read-time authorization. "
|
||||
"Retirement and supersession preserve history. Generic public documentation exports never include tenant semantic entries; administrators use the separately authorized tenant export."
|
||||
),
|
||||
layer="always",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "module_admin", "documentation_author"),
|
||||
order=11,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
any_scopes=(
|
||||
DOCS_SEMANTIC_CREATE_SCOPE,
|
||||
DOCS_SEMANTIC_EDIT_SCOPE,
|
||||
DOCS_SEMANTIC_PUBLISH_SCOPE,
|
||||
DOCS_SEMANTIC_POLICY_SCOPE,
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Semantic documentation administration",
|
||||
href="/docs/semantic",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Semantic documentation operations",
|
||||
href="govoplan-docs/docs/SEMANTIC_DOCUMENTATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
metadata={"kind": "workflow"},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.data-subject-requests",
|
||||
title="Review Docs semantic attribution in a data-subject request",
|
||||
summary="Export minimized author, reviewer, owner, and steward references without disclosing unrelated tenant-authored guidance.",
|
||||
body=(
|
||||
"Docs matches exact account and namespaced semantic-entry references within the active tenant. "
|
||||
"The projection identifies the entry, revision, subject, locale, lifecycle state, and fields that matched, but excludes authored body content. "
|
||||
"Published, superseded, and retired attribution is immutable configuration-governance evidence and is retained with a reason. "
|
||||
"Draft attribution requires manual governance review so ownership or stewardship can be reassigned before any anonymization; Docs performs no automatic erasure."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("privacy_officer", "documentation_administrator", "operator"),
|
||||
order=12,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("docs", "access"),
|
||||
any_scopes=(
|
||||
"access:privacy:read",
|
||||
"access:privacy:manage",
|
||||
"access:privacy:erase",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
label="Data-subject requests",
|
||||
href="/admin?section=tenant-data-subject-requests",
|
||||
kind="runtime",
|
||||
),
|
||||
DocumentationLink(
|
||||
label="Semantic documentation operations",
|
||||
href="govoplan-docs/docs/SEMANTIC_DOCUMENTATION.md",
|
||||
kind="repository",
|
||||
),
|
||||
),
|
||||
related_modules=("access", "audit", "policy"),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"route": "/admin?section=tenant-data-subject-requests",
|
||||
"help_contexts": ["admin.privacy.data-subject-requests"],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="docs.configured-system-documentation",
|
||||
title="Configured system documentation",
|
||||
@@ -434,6 +664,34 @@ manifest = ModuleManifest(
|
||||
},
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="docs",
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
retirement_supported=True,
|
||||
retirement_provider=drop_table_retirement_provider(
|
||||
docs_models.SemanticDocumentationRevision,
|
||||
docs_models.SemanticDocumentationEntry,
|
||||
label="Docs semantic documentation",
|
||||
),
|
||||
retirement_notes=(
|
||||
"Destructive retirement removes tenant semantic entries and immutable "
|
||||
"revision history after the installer captures a database snapshot."
|
||||
),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
docs_models.SemanticDocumentationEntry,
|
||||
docs_models.SemanticDocumentationRevision,
|
||||
label="Docs semantic documentation",
|
||||
),
|
||||
),
|
||||
search_sources=(
|
||||
SearchSourceProviderRegistration(
|
||||
id="docs.semantic_documentation",
|
||||
factory=create_semantic_documentation_search_source,
|
||||
),
|
||||
),
|
||||
architecture=ARCHITECTURE,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Docs-owned database migrations."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Docs migration revisions."""
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Add tenant semantic documentation and immutable revisions.
|
||||
|
||||
Revision ID: d3e7a1c5f9b2
|
||||
Revises: None
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "d3e7a1c5f9b2"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = "4f2a9c8e7b6d"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"docs_semantic_entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("subject_stable_key", sa.String(length=80), nullable=False),
|
||||
sa.Column("subject_module_id", sa.String(length=80), nullable=False),
|
||||
sa.Column("subject_kind", sa.String(length=120), nullable=False),
|
||||
sa.Column("subject_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("anchor_kind", sa.String(length=120), nullable=True),
|
||||
sa.Column("anchor_id", sa.String(length=255), nullable=True),
|
||||
sa.Column("locale", sa.String(length=20), nullable=False),
|
||||
sa.Column("lifecycle_state", sa.String(length=30), nullable=False),
|
||||
sa.Column("current_revision", sa.Integer(), nullable=False),
|
||||
sa.Column("current_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("published_revision_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("superseded_by_entry_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("updated_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("published_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("retired_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["superseded_by_entry_id"],
|
||||
["docs_semantic_entries.id"],
|
||||
name=op.f(
|
||||
"fk_docs_semantic_entries_superseded_by_entry_id_docs_semantic_entries"
|
||||
),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_docs_semantic_entries")),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"subject_stable_key",
|
||||
"locale",
|
||||
name="uq_docs_semantic_entry_subject_locale",
|
||||
),
|
||||
)
|
||||
_entry_indexes()
|
||||
op.create_table(
|
||||
"docs_semantic_revisions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=255), nullable=False),
|
||||
sa.Column("entry_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.Column("lifecycle_state", sa.String(length=30), nullable=False),
|
||||
sa.Column("action", sa.String(length=30), nullable=False),
|
||||
sa.Column("change_reason", sa.String(length=1000), nullable=False),
|
||||
sa.Column("content", sa.JSON(), nullable=False),
|
||||
sa.Column("content_hash", sa.String(length=80), nullable=False),
|
||||
sa.Column("subject_revision", sa.String(length=255), nullable=True),
|
||||
sa.Column("subject_fingerprint", sa.String(length=80), nullable=True),
|
||||
sa.Column("authored_by", sa.String(length=255), nullable=False),
|
||||
sa.Column("reviewed_by", sa.String(length=255), nullable=True),
|
||||
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("provenance", sa.JSON(), nullable=False),
|
||||
sa.Column("recoverable", sa.Boolean(), nullable=False),
|
||||
sa.Column("search_text", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["entry_id"],
|
||||
["docs_semantic_entries.id"],
|
||||
name=op.f("fk_docs_semantic_revisions_entry_id_docs_semantic_entries"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_docs_semantic_revisions")),
|
||||
sa.UniqueConstraint(
|
||||
"entry_id",
|
||||
"revision",
|
||||
name="uq_docs_semantic_revision_number",
|
||||
),
|
||||
)
|
||||
_revision_indexes()
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("docs_semantic_revisions")
|
||||
op.drop_table("docs_semantic_entries")
|
||||
|
||||
|
||||
def _entry_indexes() -> None:
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"subject_stable_key",
|
||||
"subject_module_id",
|
||||
"subject_kind",
|
||||
"subject_id",
|
||||
"anchor_kind",
|
||||
"anchor_id",
|
||||
"locale",
|
||||
"lifecycle_state",
|
||||
"current_revision_id",
|
||||
"published_revision_id",
|
||||
"superseded_by_entry_id",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"published_by",
|
||||
"published_at",
|
||||
"retired_by",
|
||||
"retired_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_docs_semantic_entries_{column}"),
|
||||
"docs_semantic_entries",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_docs_semantic_entries_tenant_state",
|
||||
"docs_semantic_entries",
|
||||
["tenant_id", "lifecycle_state"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_docs_semantic_entries_subject",
|
||||
"docs_semantic_entries",
|
||||
["tenant_id", "subject_module_id", "subject_kind", "subject_id"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def _revision_indexes() -> None:
|
||||
for column in (
|
||||
"tenant_id",
|
||||
"entry_id",
|
||||
"content_hash",
|
||||
"subject_fingerprint",
|
||||
"authored_by",
|
||||
"reviewed_by",
|
||||
"published_at",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_docs_semantic_revisions_{column}"),
|
||||
"docs_semantic_revisions",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_docs_semantic_revisions_entry",
|
||||
"docs_semantic_revisions",
|
||||
["entry_id", "revision"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_docs_semantic_revisions_tenant_state",
|
||||
"docs_semantic_revisions",
|
||||
["tenant_id", "lifecycle_state"],
|
||||
unique=False,
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from urllib.parse import quote
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.events import PlatformEvent
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.core.search import (
|
||||
SearchAuthorizationRequest,
|
||||
SearchBackfillPage,
|
||||
SearchBackfillRequest,
|
||||
SearchDocument,
|
||||
SearchIndexChange,
|
||||
SearchResourceReference,
|
||||
SearchResourceType,
|
||||
)
|
||||
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
from govoplan_docs.backend.semantic_service import semantic_entry_payload
|
||||
|
||||
|
||||
PROVIDER_ID = "docs.semantic_documentation"
|
||||
RESOURCE_TYPE = "semantic_documentation"
|
||||
DOCS_READ_SCOPE = "docs:documentation:read"
|
||||
|
||||
|
||||
class SemanticDocumentationSearchSource:
|
||||
def __init__(self, registry: object) -> None:
|
||||
self._registry = registry
|
||||
|
||||
def resource_types(self) -> Sequence[SearchResourceType]:
|
||||
return (
|
||||
SearchResourceType(
|
||||
provider_id=PROVIDER_ID,
|
||||
module_id="docs",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
label="Semantic documentation",
|
||||
requires_authorization_recheck=True,
|
||||
),
|
||||
)
|
||||
|
||||
def backfill(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
request: SearchBackfillRequest,
|
||||
) -> SearchBackfillPage:
|
||||
_assert_source(request.provider_id, request.resource_type)
|
||||
db = _session(session)
|
||||
statement = (
|
||||
select(SemanticDocumentationEntry, SemanticDocumentationRevision)
|
||||
.join(
|
||||
SemanticDocumentationRevision,
|
||||
SemanticDocumentationRevision.id
|
||||
== SemanticDocumentationEntry.published_revision_id,
|
||||
)
|
||||
.where(
|
||||
SemanticDocumentationEntry.tenant_id == request.tenant_id,
|
||||
SemanticDocumentationEntry.lifecycle_state.in_(("draft", "published")),
|
||||
)
|
||||
)
|
||||
if request.cursor:
|
||||
statement = statement.where(
|
||||
SemanticDocumentationEntry.id > request.cursor
|
||||
)
|
||||
rows = list(
|
||||
db.execute(
|
||||
statement.order_by(SemanticDocumentationEntry.id).limit(
|
||||
request.limit + 1
|
||||
)
|
||||
).all()
|
||||
)
|
||||
has_more = len(rows) > request.limit
|
||||
selected = rows[: request.limit]
|
||||
high_watermark = db.scalar(
|
||||
select(func.max(SemanticDocumentationEntry.updated_at)).where(
|
||||
SemanticDocumentationEntry.tenant_id == request.tenant_id,
|
||||
SemanticDocumentationEntry.published_revision_id.is_not(None),
|
||||
)
|
||||
)
|
||||
return SearchBackfillPage(
|
||||
documents=tuple(_document(entry, revision) for entry, revision in selected),
|
||||
next_cursor=selected[-1][0].id if has_more and selected else None,
|
||||
complete=not has_more,
|
||||
high_watermark=(
|
||||
high_watermark.isoformat() if high_watermark is not None else None
|
||||
),
|
||||
)
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
requests: Sequence[SearchAuthorizationRequest],
|
||||
) -> Mapping[str, bool]:
|
||||
decisions = {item.reference.key: False for item in requests}
|
||||
if not isinstance(principal, ApiPrincipal) or not principal.has(DOCS_READ_SCOPE):
|
||||
return decisions
|
||||
db = _session(session)
|
||||
for item in requests:
|
||||
reference = item.reference
|
||||
if (
|
||||
reference.tenant_id != principal.tenant_id
|
||||
or reference.module_id != "docs"
|
||||
or reference.resource_type != RESOURCE_TYPE
|
||||
):
|
||||
continue
|
||||
entry = db.get(SemanticDocumentationEntry, reference.resource_id)
|
||||
if (
|
||||
entry is None
|
||||
or entry.tenant_id != principal.tenant_id
|
||||
or entry.published_revision_id != item.source_revision
|
||||
or entry.lifecycle_state not in {"draft", "published"}
|
||||
):
|
||||
continue
|
||||
payload = semantic_entry_payload(
|
||||
db,
|
||||
self._registry,
|
||||
principal,
|
||||
entry=entry,
|
||||
editor=False,
|
||||
)
|
||||
decisions[reference.key] = bool(
|
||||
payload
|
||||
and payload["subject_resolution"]["availability"]
|
||||
in {"available", "changed"}
|
||||
)
|
||||
return decisions
|
||||
|
||||
def index_changes_for_event(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
event: PlatformEvent,
|
||||
delivery_key: str,
|
||||
) -> Sequence[SearchIndexChange]:
|
||||
if (
|
||||
event.module_id != "docs"
|
||||
or event.tenant is None
|
||||
or event.resource is None
|
||||
or event.resource.type != RESOURCE_TYPE
|
||||
or event.resource.id is None
|
||||
):
|
||||
return ()
|
||||
db = _session(session)
|
||||
entry = db.get(SemanticDocumentationEntry, event.resource.id)
|
||||
revision = (
|
||||
db.get(SemanticDocumentationRevision, entry.published_revision_id)
|
||||
if entry is not None and entry.published_revision_id
|
||||
else None
|
||||
)
|
||||
visible = bool(
|
||||
entry is not None
|
||||
and entry.tenant_id == event.tenant.id
|
||||
and entry.lifecycle_state in {"draft", "published"}
|
||||
and revision is not None
|
||||
)
|
||||
cursor = event.event_id
|
||||
document = _document(entry, revision, change_cursor=cursor) if visible else None
|
||||
reference = SearchResourceReference(
|
||||
tenant_id=event.tenant.id,
|
||||
module_id="docs",
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=event.resource.id,
|
||||
)
|
||||
return (
|
||||
SearchIndexChange(
|
||||
change_id=f"{delivery_key}:{PROVIDER_ID}",
|
||||
provider_id=PROVIDER_ID,
|
||||
kind="upsert" if document is not None else "delete",
|
||||
reference=reference,
|
||||
source_revision=(
|
||||
document.source_revision if document is not None else cursor
|
||||
),
|
||||
cursor=cursor,
|
||||
document=document,
|
||||
occurred_at=event.occurred_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_semantic_documentation_search_source(
|
||||
context: ModuleContext,
|
||||
) -> SemanticDocumentationSearchSource:
|
||||
return SemanticDocumentationSearchSource(context.registry)
|
||||
|
||||
|
||||
def _document(
|
||||
entry: SemanticDocumentationEntry,
|
||||
revision: SemanticDocumentationRevision,
|
||||
*,
|
||||
change_cursor: str | None = None,
|
||||
) -> SearchDocument:
|
||||
content = revision.content
|
||||
title = str(content.get("title") or "Semantic documentation")
|
||||
summary = str(content.get("summary") or "")[:4000] or None
|
||||
keywords = tuple(
|
||||
str(value)[:200]
|
||||
for value in (
|
||||
entry.subject_module_id,
|
||||
entry.subject_kind,
|
||||
entry.locale,
|
||||
"semantic documentation",
|
||||
)
|
||||
)
|
||||
return SearchDocument(
|
||||
tenant_id=entry.tenant_id,
|
||||
module_id="docs",
|
||||
provider_id=PROVIDER_ID,
|
||||
resource_type=RESOURCE_TYPE,
|
||||
resource_id=entry.id,
|
||||
title=title,
|
||||
url=(
|
||||
f"/docs/semantic?entryId={quote(entry.id, safe='')}"
|
||||
f"&locale={quote(entry.locale, safe='')}"
|
||||
),
|
||||
summary=summary,
|
||||
body=revision.search_text[:200_000] or None,
|
||||
keywords=keywords,
|
||||
visibility="restricted",
|
||||
acl_tokens=(f"scope:{DOCS_READ_SCOPE}",),
|
||||
metadata={
|
||||
"source_badge": "tenant_semantic",
|
||||
"subject_module_id": entry.subject_module_id,
|
||||
"subject_kind": entry.subject_kind,
|
||||
"subject_id": entry.subject_id,
|
||||
"anchor_kind": entry.anchor_kind,
|
||||
"anchor_id": entry.anchor_id,
|
||||
"locale": entry.locale,
|
||||
"classification": content.get("classification", "internal"),
|
||||
},
|
||||
source_revision=revision.id,
|
||||
change_cursor=change_cursor,
|
||||
source_updated_at=entry.updated_at or entry.created_at,
|
||||
requires_authorization_recheck=True,
|
||||
)
|
||||
|
||||
|
||||
def _assert_source(provider_id: str, resource_type: str) -> None:
|
||||
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
|
||||
raise ValueError("Unsupported Docs search source.")
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Semantic documentation search requires a SQLAlchemy session.")
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROVIDER_ID",
|
||||
"RESOURCE_TYPE",
|
||||
"SemanticDocumentationSearchSource",
|
||||
"create_semantic_documentation_search_source",
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
|
||||
SemanticLifecycleState = Literal["draft", "published", "superseded", "retired"]
|
||||
SemanticSubjectAvailability = Literal[
|
||||
"available",
|
||||
"changed",
|
||||
"superseded",
|
||||
"missing",
|
||||
"temporarily_unavailable",
|
||||
]
|
||||
|
||||
|
||||
class _StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||
|
||||
|
||||
class SemanticSubjectAnchorPayload(_StrictModel):
|
||||
kind: str = Field(min_length=1, max_length=120)
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class SemanticSubjectReferencePayload(_StrictModel):
|
||||
module_id: str = Field(min_length=1, max_length=80)
|
||||
tenant_id: str = Field(min_length=1, max_length=255)
|
||||
subject_kind: str = Field(min_length=1, max_length=120)
|
||||
subject_id: str = Field(min_length=1, max_length=255)
|
||||
anchor: SemanticSubjectAnchorPayload | None = None
|
||||
observed_revision: str | None = Field(default=None, max_length=255)
|
||||
observed_fingerprint: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class SemanticDocumentationLinkPayload(_StrictModel):
|
||||
label: str = Field(min_length=1, max_length=300)
|
||||
href: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
@field_validator("href")
|
||||
@classmethod
|
||||
def validate_href(cls, value: str) -> str:
|
||||
if value.startswith("/") and not value.startswith("//"):
|
||||
return value
|
||||
if value.startswith("https://"):
|
||||
return value
|
||||
raise ValueError("Semantic documentation links must use HTTPS or a local path.")
|
||||
|
||||
|
||||
class SemanticDocumentationContentPayload(_StrictModel):
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
summary: str = Field(default="", max_length=4000)
|
||||
body: str = Field(default="", max_length=200_000)
|
||||
meaning: str = Field(default="", max_length=20_000)
|
||||
intended_use: str = Field(default="", max_length=20_000)
|
||||
non_intended_use: str = Field(default="", max_length=20_000)
|
||||
examples: list[str] = Field(default_factory=list, max_length=50)
|
||||
owner_account_id: str | None = Field(default=None, max_length=255)
|
||||
steward_account_id: str | None = Field(default=None, max_length=255)
|
||||
audience: list[str] = Field(default_factory=list, max_length=100)
|
||||
classification: Literal["internal", "restricted"] = "internal"
|
||||
links: list[SemanticDocumentationLinkPayload] = Field(
|
||||
default_factory=list,
|
||||
max_length=50,
|
||||
)
|
||||
|
||||
@field_validator("examples")
|
||||
@classmethod
|
||||
def validate_examples(cls, values: list[str]) -> list[str]:
|
||||
if any(not value.strip() or len(value) > 4000 for value in values):
|
||||
raise ValueError("Examples must contain bounded non-empty text.")
|
||||
return list(dict.fromkeys(value.strip() for value in values))
|
||||
|
||||
@field_validator("audience")
|
||||
@classmethod
|
||||
def validate_audience(cls, values: list[str]) -> list[str]:
|
||||
prefixes = ("account:", "group:", "role:", "function:", "scope:")
|
||||
normalized = list(dict.fromkeys(value.strip() for value in values))
|
||||
if any(
|
||||
not value
|
||||
or len(value) > 500
|
||||
or (value != "authenticated" and not value.startswith(prefixes))
|
||||
for value in normalized
|
||||
):
|
||||
raise ValueError(
|
||||
"Audience entries must be authenticated or typed account, group, "
|
||||
"role, function, or scope selectors."
|
||||
)
|
||||
return normalized
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_restricted_audience(self):
|
||||
if self.classification == "restricted" and not self.audience:
|
||||
raise ValueError("Restricted semantic documentation requires an audience.")
|
||||
return self
|
||||
|
||||
|
||||
class SemanticDocumentationCreateRequest(_StrictModel):
|
||||
subject: SemanticSubjectReferencePayload
|
||||
locale: str = Field(min_length=2, max_length=20)
|
||||
content: SemanticDocumentationContentPayload
|
||||
change_reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class SemanticDocumentationUpdateRequest(_StrictModel):
|
||||
expected_revision: int = Field(ge=1)
|
||||
content: SemanticDocumentationContentPayload
|
||||
change_reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class SemanticDocumentationTransitionRequest(_StrictModel):
|
||||
expected_revision: int = Field(ge=1)
|
||||
change_reason: str = Field(min_length=1, max_length=1000)
|
||||
|
||||
|
||||
class SemanticDocumentationSupersedeRequest(SemanticDocumentationTransitionRequest):
|
||||
replacement_entry_id: str = Field(min_length=1, max_length=36)
|
||||
|
||||
|
||||
class SemanticDocumentationPolicyUpdateRequest(_StrictModel):
|
||||
mode: Literal["direct", "reviewer_required"]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SemanticDocumentationContentPayload",
|
||||
"SemanticDocumentationCreateRequest",
|
||||
"SemanticDocumentationLinkPayload",
|
||||
"SemanticDocumentationPolicyUpdateRequest",
|
||||
"SemanticDocumentationSupersedeRequest",
|
||||
"SemanticDocumentationTransitionRequest",
|
||||
"SemanticDocumentationUpdateRequest",
|
||||
"SemanticLifecycleState",
|
||||
"SemanticSubjectAnchorPayload",
|
||||
"SemanticSubjectAvailability",
|
||||
"SemanticSubjectReferencePayload",
|
||||
]
|
||||
@@ -0,0 +1,980 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SemanticDocumentationSubjectReference,
|
||||
SemanticDocumentationSubjectResolution,
|
||||
resolve_semantic_documentation_subject,
|
||||
semantic_documentation_fingerprint,
|
||||
)
|
||||
from govoplan_core.tenancy.scope import Tenant
|
||||
|
||||
from govoplan_docs.backend.db.models import (
|
||||
SemanticDocumentationEntry,
|
||||
SemanticDocumentationRevision,
|
||||
)
|
||||
|
||||
|
||||
SEMANTIC_PUBLICATION_POLICY_KEY = "docs.semantic_publication_policy"
|
||||
SEMANTIC_PUBLICATION_MODES = frozenset({"direct", "reviewer_required"})
|
||||
SEMANTIC_LIFECYCLE_STATES = frozenset(
|
||||
{"draft", "published", "superseded", "retired"}
|
||||
)
|
||||
_LOCALE_RE = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$")
|
||||
|
||||
|
||||
class SemanticDocumentationError(ValueError):
|
||||
"""Base semantic-documentation service error."""
|
||||
|
||||
|
||||
class SemanticDocumentationNotFoundError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
class SemanticDocumentationConflictError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
class SemanticDocumentationAuthorizationError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
class SemanticDocumentationSubjectError(SemanticDocumentationError):
|
||||
pass
|
||||
|
||||
|
||||
def publication_policy(session: Session, tenant_id: str) -> str:
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
settings = tenant.settings if tenant is not None else {}
|
||||
raw = settings.get(SEMANTIC_PUBLICATION_POLICY_KEY) if settings else None
|
||||
return str(raw) if raw in SEMANTIC_PUBLICATION_MODES else "reviewer_required"
|
||||
|
||||
|
||||
def set_publication_policy(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
mode: str,
|
||||
) -> str:
|
||||
if mode not in SEMANTIC_PUBLICATION_MODES:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic publication policy must be direct or reviewer_required."
|
||||
)
|
||||
tenant = session.get(Tenant, tenant_id)
|
||||
if tenant is None:
|
||||
raise SemanticDocumentationNotFoundError("The active tenant is unavailable.")
|
||||
settings = dict(tenant.settings or {})
|
||||
settings[SEMANTIC_PUBLICATION_POLICY_KEY] = mode
|
||||
tenant.settings = settings
|
||||
session.flush()
|
||||
return mode
|
||||
|
||||
|
||||
def create_semantic_entry(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
subject: SemanticDocumentationSubjectReference,
|
||||
locale: str,
|
||||
content: Mapping[str, object],
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
_require_tenant(subject, tenant_id)
|
||||
clean_locale = _locale(locale)
|
||||
current_subject = _resolved_current_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
subject,
|
||||
)
|
||||
stable_key = current_subject.stable_key
|
||||
existing = (
|
||||
session.query(SemanticDocumentationEntry)
|
||||
.filter(
|
||||
SemanticDocumentationEntry.tenant_id == tenant_id,
|
||||
SemanticDocumentationEntry.subject_stable_key == stable_key,
|
||||
SemanticDocumentationEntry.locale == clean_locale,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
raise SemanticDocumentationConflictError(
|
||||
"Semantic documentation already exists for this subject and locale."
|
||||
)
|
||||
actor_id = _principal_account_id(principal)
|
||||
anchor = current_subject.anchor
|
||||
entry = SemanticDocumentationEntry(
|
||||
tenant_id=tenant_id,
|
||||
subject_stable_key=stable_key,
|
||||
subject_module_id=current_subject.module_id,
|
||||
subject_kind=current_subject.subject_kind,
|
||||
subject_id=current_subject.subject_id,
|
||||
anchor_kind=anchor.kind if anchor else None,
|
||||
anchor_id=anchor.id if anchor else None,
|
||||
locale=clean_locale,
|
||||
lifecycle_state="draft",
|
||||
current_revision=1,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
session.add(entry)
|
||||
session.flush()
|
||||
revision = _new_revision(
|
||||
entry,
|
||||
revision=1,
|
||||
lifecycle_state="draft",
|
||||
action="create",
|
||||
content=content,
|
||||
subject=current_subject,
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=now,
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision_id = revision.id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def update_semantic_entry(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
expected_revision: int,
|
||||
content: Mapping[str, object],
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
subject = _resolved_current_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry_subject_reference(entry),
|
||||
)
|
||||
actor_id = _principal_account_id(principal)
|
||||
revision = _new_revision(
|
||||
entry,
|
||||
revision=entry.current_revision + 1,
|
||||
lifecycle_state="draft",
|
||||
action="edit",
|
||||
content=content,
|
||||
subject=subject,
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=now,
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.lifecycle_state = "draft"
|
||||
entry.updated_by = actor_id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def publish_semantic_entry(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
expected_revision: int,
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
current = current_revision(session, entry)
|
||||
actor_id = _principal_account_id(principal)
|
||||
mode = publication_policy(session, entry.tenant_id)
|
||||
if mode == "reviewer_required" and current.authored_by == actor_id:
|
||||
raise SemanticDocumentationAuthorizationError(
|
||||
"The configured publication policy requires another reviewer."
|
||||
)
|
||||
subject = _resolved_current_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry_subject_reference(entry, revision=current),
|
||||
)
|
||||
timestamp = _utc(now)
|
||||
revision = _new_revision(
|
||||
entry,
|
||||
revision=entry.current_revision + 1,
|
||||
lifecycle_state="published",
|
||||
action="publish",
|
||||
content=current.content,
|
||||
subject=subject,
|
||||
actor_id=current.authored_by,
|
||||
reviewer_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=timestamp,
|
||||
)
|
||||
revision.published_at = timestamp
|
||||
revision.provenance = {
|
||||
**revision.provenance,
|
||||
"publication_policy": mode,
|
||||
"published_by": actor_id,
|
||||
}
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.published_revision_id = revision.id
|
||||
entry.lifecycle_state = "published"
|
||||
entry.updated_by = actor_id
|
||||
entry.published_by = actor_id
|
||||
entry.published_at = timestamp
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def supersede_semantic_entry(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
replacement_entry_id: str,
|
||||
expected_revision: int,
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
if entry.id == replacement_entry_id:
|
||||
raise SemanticDocumentationError("An entry cannot supersede itself.")
|
||||
replacement = get_semantic_entry(
|
||||
session,
|
||||
principal,
|
||||
entry_id=replacement_entry_id,
|
||||
)
|
||||
if replacement.published_revision_id is None:
|
||||
raise SemanticDocumentationError(
|
||||
"The replacement semantic documentation must be published."
|
||||
)
|
||||
current = current_revision(session, entry)
|
||||
actor_id = _principal_account_id(principal)
|
||||
revision = _lifecycle_revision(
|
||||
entry,
|
||||
current=current,
|
||||
lifecycle_state="superseded",
|
||||
action="supersede",
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=now,
|
||||
provenance={"replacement_entry_id": replacement.id},
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.lifecycle_state = "superseded"
|
||||
entry.superseded_by_entry_id = replacement.id
|
||||
entry.updated_by = actor_id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def retire_semantic_entry(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
expected_revision: int,
|
||||
change_reason: str,
|
||||
now: datetime | None = None,
|
||||
) -> SemanticDocumentationEntry:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
_require_editable(entry)
|
||||
_require_expected_revision(entry, expected_revision)
|
||||
current = current_revision(session, entry)
|
||||
actor_id = _principal_account_id(principal)
|
||||
timestamp = _utc(now)
|
||||
revision = _lifecycle_revision(
|
||||
entry,
|
||||
current=current,
|
||||
lifecycle_state="retired",
|
||||
action="retire",
|
||||
actor_id=actor_id,
|
||||
change_reason=change_reason,
|
||||
now=timestamp,
|
||||
)
|
||||
session.add(revision)
|
||||
session.flush()
|
||||
entry.current_revision += 1
|
||||
entry.current_revision_id = revision.id
|
||||
entry.lifecycle_state = "retired"
|
||||
entry.retired_by = actor_id
|
||||
entry.retired_at = timestamp
|
||||
entry.updated_by = actor_id
|
||||
session.flush()
|
||||
return entry
|
||||
|
||||
|
||||
def get_semantic_entry(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
) -> SemanticDocumentationEntry:
|
||||
tenant_id = _principal_tenant_id(principal)
|
||||
entry = (
|
||||
session.query(SemanticDocumentationEntry)
|
||||
.filter(
|
||||
SemanticDocumentationEntry.id == entry_id,
|
||||
SemanticDocumentationEntry.tenant_id == tenant_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if entry is None:
|
||||
raise SemanticDocumentationNotFoundError(
|
||||
"Semantic documentation entry not found."
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
def list_semantic_entries(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
module_id: str | None = None,
|
||||
subject_kind: str | None = None,
|
||||
) -> tuple[SemanticDocumentationEntry, ...]:
|
||||
query = session.query(SemanticDocumentationEntry).filter(
|
||||
SemanticDocumentationEntry.tenant_id == _principal_tenant_id(principal)
|
||||
)
|
||||
if module_id:
|
||||
query = query.filter(SemanticDocumentationEntry.subject_module_id == module_id)
|
||||
if subject_kind:
|
||||
query = query.filter(SemanticDocumentationEntry.subject_kind == subject_kind)
|
||||
return tuple(
|
||||
query.order_by(
|
||||
SemanticDocumentationEntry.subject_module_id.asc(),
|
||||
SemanticDocumentationEntry.subject_kind.asc(),
|
||||
SemanticDocumentationEntry.subject_id.asc(),
|
||||
SemanticDocumentationEntry.locale.asc(),
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
def semantic_entry_history(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
entry_id: str,
|
||||
) -> tuple[SemanticDocumentationRevision, ...]:
|
||||
entry = get_semantic_entry(session, principal, entry_id=entry_id)
|
||||
return tuple(
|
||||
session.query(SemanticDocumentationRevision)
|
||||
.filter(
|
||||
SemanticDocumentationRevision.entry_id == entry.id,
|
||||
SemanticDocumentationRevision.tenant_id == entry.tenant_id,
|
||||
)
|
||||
.order_by(SemanticDocumentationRevision.revision.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def current_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
) -> SemanticDocumentationRevision:
|
||||
revision = (
|
||||
session.get(SemanticDocumentationRevision, entry.current_revision_id)
|
||||
if entry.current_revision_id
|
||||
else None
|
||||
)
|
||||
if revision is None or revision.entry_id != entry.id:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic documentation current revision is unavailable."
|
||||
)
|
||||
return revision
|
||||
|
||||
|
||||
def published_revision(
|
||||
session: Session,
|
||||
entry: SemanticDocumentationEntry,
|
||||
) -> SemanticDocumentationRevision | None:
|
||||
if not entry.published_revision_id:
|
||||
return None
|
||||
revision = session.get(
|
||||
SemanticDocumentationRevision,
|
||||
entry.published_revision_id,
|
||||
)
|
||||
return revision if revision is not None and revision.entry_id == entry.id else None
|
||||
|
||||
|
||||
def entry_subject_reference(
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revision: SemanticDocumentationRevision | None = None,
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
from govoplan_core.core.semantic_documentation import (
|
||||
SemanticDocumentationSubjectAnchor,
|
||||
)
|
||||
|
||||
return SemanticDocumentationSubjectReference(
|
||||
module_id=entry.subject_module_id,
|
||||
tenant_id=entry.tenant_id,
|
||||
subject_kind=entry.subject_kind,
|
||||
subject_id=entry.subject_id,
|
||||
anchor=(
|
||||
SemanticDocumentationSubjectAnchor(
|
||||
kind=entry.anchor_kind,
|
||||
id=entry.anchor_id,
|
||||
)
|
||||
if entry.anchor_kind and entry.anchor_id
|
||||
else None
|
||||
),
|
||||
observed_revision=revision.subject_revision if revision else None,
|
||||
observed_fingerprint=revision.subject_fingerprint if revision else None,
|
||||
)
|
||||
|
||||
|
||||
def resolve_entry_subject(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry: SemanticDocumentationEntry,
|
||||
revision: SemanticDocumentationRevision,
|
||||
) -> SemanticDocumentationSubjectResolution | None:
|
||||
return resolve_semantic_documentation_subject(
|
||||
registry,
|
||||
session,
|
||||
principal,
|
||||
reference=entry_subject_reference(entry, revision=revision),
|
||||
)
|
||||
|
||||
|
||||
def content_visible_to_principal(
|
||||
content: Mapping[str, object],
|
||||
principal: object,
|
||||
) -> bool:
|
||||
audience = tuple(str(item) for item in content.get("audience", ()) or ())
|
||||
classification = str(content.get("classification") or "internal")
|
||||
if classification == "restricted" and not audience:
|
||||
return False
|
||||
if not audience or "authenticated" in audience:
|
||||
return True
|
||||
tokens = _principal_audience_tokens(principal)
|
||||
return any(selector in tokens for selector in audience)
|
||||
|
||||
|
||||
def semantic_entry_payload(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
*,
|
||||
entry: SemanticDocumentationEntry,
|
||||
editor: bool,
|
||||
requested_locale: str | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
current = current_revision(session, entry)
|
||||
published = published_revision(session, entry)
|
||||
selected = current if editor else published
|
||||
if selected is None:
|
||||
return None
|
||||
resolution = resolve_entry_subject(
|
||||
session,
|
||||
registry,
|
||||
principal,
|
||||
entry=entry,
|
||||
revision=selected,
|
||||
)
|
||||
if resolution is None:
|
||||
return None
|
||||
unavailable = resolution.availability == "temporarily_unavailable"
|
||||
if unavailable and not editor:
|
||||
return None
|
||||
if not content_visible_to_principal(selected.content, principal):
|
||||
return None
|
||||
subject = resolution.subject
|
||||
required_scopes = subject.required_scopes if subject is not None else ()
|
||||
if any(not _principal_has(principal, scope) for scope in required_scopes):
|
||||
return None
|
||||
return {
|
||||
"id": entry.id,
|
||||
"tenant_id": entry.tenant_id,
|
||||
"subject": entry_subject_reference(entry, revision=selected).to_dict(),
|
||||
"subject_stable_key": entry.subject_stable_key,
|
||||
"subject_resolution": resolution.to_dict(),
|
||||
"locale": entry.locale,
|
||||
"requested_locale": requested_locale or entry.locale,
|
||||
"locale_fallback": bool(requested_locale and requested_locale != entry.locale),
|
||||
"lifecycle_state": entry.lifecycle_state,
|
||||
"effective_state": selected.lifecycle_state,
|
||||
"pending_draft": bool(
|
||||
published is not None and current.id != published.id
|
||||
),
|
||||
"current_revision": entry.current_revision,
|
||||
"selected_revision": selected.revision,
|
||||
"published_revision": published.revision if published else None,
|
||||
"content": {} if unavailable else dict(selected.content),
|
||||
"content_redacted": unavailable,
|
||||
"content_hash": selected.content_hash,
|
||||
"subject_revision": selected.subject_revision,
|
||||
"subject_fingerprint": selected.subject_fingerprint,
|
||||
"authorship": {
|
||||
"created_by": entry.created_by,
|
||||
"updated_by": entry.updated_by,
|
||||
"authored_by": selected.authored_by,
|
||||
"reviewed_by": selected.reviewed_by,
|
||||
"published_by": entry.published_by,
|
||||
},
|
||||
"published_at": entry.published_at.isoformat() if entry.published_at else None,
|
||||
"retired_at": entry.retired_at.isoformat() if entry.retired_at else None,
|
||||
"superseded_by_entry_id": entry.superseded_by_entry_id,
|
||||
"created_at": entry.created_at.isoformat(),
|
||||
"updated_at": entry.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def select_locale_entries(
|
||||
entries: Sequence[SemanticDocumentationEntry],
|
||||
*,
|
||||
locale: str,
|
||||
) -> tuple[SemanticDocumentationEntry, ...]:
|
||||
requested = _locale(locale)
|
||||
language = requested.split("-", 1)[0]
|
||||
grouped: dict[str, list[SemanticDocumentationEntry]] = {}
|
||||
for entry in entries:
|
||||
grouped.setdefault(entry.subject_stable_key, []).append(entry)
|
||||
selected: list[SemanticDocumentationEntry] = []
|
||||
for candidates in grouped.values():
|
||||
order = (
|
||||
requested,
|
||||
language,
|
||||
"de",
|
||||
"en",
|
||||
)
|
||||
candidate = next(
|
||||
(
|
||||
item
|
||||
for target in order
|
||||
for item in candidates
|
||||
if item.locale.casefold() == target.casefold()
|
||||
),
|
||||
sorted(candidates, key=lambda item: item.locale)[0],
|
||||
)
|
||||
selected.append(candidate)
|
||||
return tuple(
|
||||
sorted(
|
||||
selected,
|
||||
key=lambda item: (
|
||||
item.subject_module_id,
|
||||
item.subject_kind,
|
||||
item.subject_id,
|
||||
item.anchor_kind or "",
|
||||
item.anchor_id or "",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def revision_payload(revision: SemanticDocumentationRevision) -> dict[str, object]:
|
||||
return {
|
||||
"id": revision.id,
|
||||
"entry_id": revision.entry_id,
|
||||
"revision": revision.revision,
|
||||
"lifecycle_state": revision.lifecycle_state,
|
||||
"action": revision.action,
|
||||
"change_reason": revision.change_reason,
|
||||
"content": dict(revision.content),
|
||||
"content_hash": revision.content_hash,
|
||||
"subject_revision": revision.subject_revision,
|
||||
"subject_fingerprint": revision.subject_fingerprint,
|
||||
"authored_by": revision.authored_by,
|
||||
"reviewed_by": revision.reviewed_by,
|
||||
"published_at": (
|
||||
revision.published_at.isoformat() if revision.published_at else None
|
||||
),
|
||||
"provenance": dict(revision.provenance or {}),
|
||||
"recoverable": revision.recoverable,
|
||||
"created_at": revision.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _new_revision(
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
revision: int,
|
||||
lifecycle_state: str,
|
||||
action: str,
|
||||
content: Mapping[str, object],
|
||||
subject: SemanticDocumentationSubjectReference,
|
||||
actor_id: str,
|
||||
change_reason: str,
|
||||
now: datetime | None,
|
||||
reviewer_id: str | None = None,
|
||||
) -> SemanticDocumentationRevision:
|
||||
normalized = _normalize_content(content)
|
||||
timestamp = _utc(now)
|
||||
return SemanticDocumentationRevision(
|
||||
tenant_id=entry.tenant_id,
|
||||
entry_id=entry.id,
|
||||
revision=revision,
|
||||
lifecycle_state=lifecycle_state,
|
||||
action=action,
|
||||
change_reason=_bounded_required(change_reason, "Change reason", 1000),
|
||||
content=normalized,
|
||||
content_hash=semantic_documentation_fingerprint(normalized),
|
||||
subject_revision=subject.observed_revision,
|
||||
subject_fingerprint=subject.observed_fingerprint,
|
||||
authored_by=actor_id,
|
||||
reviewed_by=reviewer_id,
|
||||
provenance={
|
||||
"subject_stable_key": subject.stable_key,
|
||||
"recorded_at": timestamp.isoformat(),
|
||||
"action_by": reviewer_id or actor_id,
|
||||
},
|
||||
recoverable=True,
|
||||
search_text=_search_text(normalized),
|
||||
)
|
||||
|
||||
|
||||
def _lifecycle_revision(
|
||||
entry: SemanticDocumentationEntry,
|
||||
*,
|
||||
current: SemanticDocumentationRevision,
|
||||
lifecycle_state: Literal["superseded", "retired"],
|
||||
action: str,
|
||||
actor_id: str,
|
||||
change_reason: str,
|
||||
now: datetime | None,
|
||||
provenance: Mapping[str, object] | None = None,
|
||||
) -> SemanticDocumentationRevision:
|
||||
timestamp = _utc(now)
|
||||
return SemanticDocumentationRevision(
|
||||
tenant_id=entry.tenant_id,
|
||||
entry_id=entry.id,
|
||||
revision=entry.current_revision + 1,
|
||||
lifecycle_state=lifecycle_state,
|
||||
action=action,
|
||||
change_reason=_bounded_required(change_reason, "Change reason", 1000),
|
||||
content=dict(current.content),
|
||||
content_hash=current.content_hash,
|
||||
subject_revision=current.subject_revision,
|
||||
subject_fingerprint=current.subject_fingerprint,
|
||||
authored_by=current.authored_by,
|
||||
reviewed_by=actor_id,
|
||||
provenance={
|
||||
**dict(current.provenance or {}),
|
||||
**dict(provenance or {}),
|
||||
"recorded_at": timestamp.isoformat(),
|
||||
"action_by": actor_id,
|
||||
},
|
||||
recoverable=True,
|
||||
search_text=current.search_text,
|
||||
)
|
||||
|
||||
|
||||
def _resolved_current_subject(
|
||||
session: Session,
|
||||
registry: object,
|
||||
principal: object,
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
) -> SemanticDocumentationSubjectReference:
|
||||
resolution = resolve_semantic_documentation_subject(
|
||||
registry,
|
||||
session,
|
||||
principal,
|
||||
reference=reference,
|
||||
)
|
||||
if resolution is None:
|
||||
raise SemanticDocumentationNotFoundError(
|
||||
"Semantic documentation subject not found."
|
||||
)
|
||||
if resolution.availability not in {"available", "changed"} or resolution.subject is None:
|
||||
raise SemanticDocumentationSubjectError(
|
||||
"Semantic documentation subject is not currently available."
|
||||
)
|
||||
return resolution.subject.reference
|
||||
|
||||
|
||||
def _normalize_content(content: Mapping[str, object]) -> dict[str, object]:
|
||||
allowed = {
|
||||
"title",
|
||||
"summary",
|
||||
"body",
|
||||
"meaning",
|
||||
"intended_use",
|
||||
"non_intended_use",
|
||||
"examples",
|
||||
"owner_account_id",
|
||||
"steward_account_id",
|
||||
"audience",
|
||||
"classification",
|
||||
"links",
|
||||
}
|
||||
unknown = sorted(str(key) for key in content if key not in allowed)
|
||||
if unknown:
|
||||
raise SemanticDocumentationError(
|
||||
"Unsupported semantic content fields: " + ", ".join(unknown)
|
||||
)
|
||||
normalized = {
|
||||
"title": _plain_text(content.get("title"), "Title", 500, required=True),
|
||||
"summary": _plain_text(content.get("summary"), "Summary", 4000),
|
||||
"body": _plain_text(content.get("body"), "Body", 200_000),
|
||||
"meaning": _plain_text(content.get("meaning"), "Meaning", 20_000),
|
||||
"intended_use": _plain_text(
|
||||
content.get("intended_use"), "Intended use", 20_000
|
||||
),
|
||||
"non_intended_use": _plain_text(
|
||||
content.get("non_intended_use"), "Non-intended use", 20_000
|
||||
),
|
||||
"examples": _string_list(content.get("examples"), "Examples", 50, 4000),
|
||||
"owner_account_id": _optional_id(content.get("owner_account_id")),
|
||||
"steward_account_id": _optional_id(content.get("steward_account_id")),
|
||||
"audience": _audience(content.get("audience")),
|
||||
"classification": str(content.get("classification") or "internal"),
|
||||
"links": _links(content.get("links")),
|
||||
}
|
||||
if normalized["classification"] not in {"internal", "restricted"}:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic content classification must be internal or restricted."
|
||||
)
|
||||
if normalized["classification"] == "restricted" and not normalized["audience"]:
|
||||
raise SemanticDocumentationError(
|
||||
"Restricted semantic documentation requires an audience."
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _plain_text(
|
||||
value: object,
|
||||
label: str,
|
||||
maximum: int,
|
||||
*,
|
||||
required: bool = False,
|
||||
) -> str:
|
||||
text = str(value or "").strip()
|
||||
if required and not text:
|
||||
raise SemanticDocumentationError(f"{label} is required.")
|
||||
if len(text) > maximum:
|
||||
raise SemanticDocumentationError(
|
||||
f"{label} is limited to {maximum} characters."
|
||||
)
|
||||
if any(ord(character) < 32 and character not in "\n\t" for character in text):
|
||||
raise SemanticDocumentationError(f"{label} contains control characters.")
|
||||
if "<script" in text.casefold() or "javascript:" in text.casefold():
|
||||
raise SemanticDocumentationError(f"{label} contains unsafe active content.")
|
||||
return text
|
||||
|
||||
|
||||
def _string_list(
|
||||
value: object,
|
||||
label: str,
|
||||
maximum_items: int,
|
||||
maximum_length: int,
|
||||
) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise SemanticDocumentationError(f"{label} must be a list.")
|
||||
if len(value) > maximum_items:
|
||||
raise SemanticDocumentationError(
|
||||
f"{label} are limited to {maximum_items} items."
|
||||
)
|
||||
return list(
|
||||
dict.fromkeys(
|
||||
_plain_text(item, label, maximum_length, required=True) for item in value
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _audience(value: object) -> list[str]:
|
||||
selectors = _string_list(value, "Audience", 100, 500)
|
||||
prefixes = ("account:", "group:", "role:", "function:", "scope:")
|
||||
if any(
|
||||
selector != "authenticated" and not selector.startswith(prefixes)
|
||||
for selector in selectors
|
||||
):
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic audience selectors must be typed."
|
||||
)
|
||||
return selectors
|
||||
|
||||
|
||||
def _links(value: object) -> list[dict[str, str]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise SemanticDocumentationError("Links must be a list.")
|
||||
if len(value) > 50:
|
||||
raise SemanticDocumentationError("Links are limited to 50 items.")
|
||||
result: list[dict[str, str]] = []
|
||||
for item in value:
|
||||
if not isinstance(item, Mapping) or set(item) != {"label", "href"}:
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic links require only label and href."
|
||||
)
|
||||
label = _plain_text(item.get("label"), "Link label", 300, required=True)
|
||||
href = _plain_text(item.get("href"), "Link href", 2000, required=True)
|
||||
if not (
|
||||
(href.startswith("/") and not href.startswith("//"))
|
||||
or href.startswith("https://")
|
||||
):
|
||||
raise SemanticDocumentationError(
|
||||
"Semantic links must use HTTPS or a local path."
|
||||
)
|
||||
if href.startswith("https://") and "@" in href.split("/", 3)[2]:
|
||||
raise SemanticDocumentationError("Semantic links cannot contain credentials.")
|
||||
result.append({"label": label, "href": href})
|
||||
return result
|
||||
|
||||
|
||||
def _search_text(content: Mapping[str, object]) -> str:
|
||||
values = [
|
||||
content.get("title"),
|
||||
content.get("summary"),
|
||||
content.get("body"),
|
||||
content.get("meaning"),
|
||||
content.get("intended_use"),
|
||||
content.get("non_intended_use"),
|
||||
*(content.get("examples") or ()),
|
||||
]
|
||||
return "\n".join(str(value) for value in values if value).strip()
|
||||
|
||||
|
||||
def _principal_audience_tokens(principal: object) -> frozenset[str]:
|
||||
return frozenset(
|
||||
{
|
||||
"authenticated",
|
||||
f"account:{_principal_account_id(principal)}",
|
||||
*(f"group:{value}" for value in getattr(principal, "group_ids", ())),
|
||||
*(f"role:{value}" for value in getattr(principal, "role_ids", ())),
|
||||
*(
|
||||
f"function:{value}"
|
||||
for value in getattr(principal, "function_assignment_ids", ())
|
||||
),
|
||||
*(f"scope:{value}" for value in getattr(principal, "scopes", ())),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _principal_has(principal: object, scope: str) -> bool:
|
||||
checker = getattr(principal, "has", None)
|
||||
return bool(checker(scope)) if callable(checker) else scope in getattr(
|
||||
principal, "scopes", ()
|
||||
)
|
||||
|
||||
|
||||
def _principal_tenant_id(principal: object) -> str:
|
||||
tenant_id = str(getattr(principal, "tenant_id", "") or "").strip()
|
||||
if not tenant_id:
|
||||
raise SemanticDocumentationAuthorizationError(
|
||||
"Semantic documentation requires an active tenant."
|
||||
)
|
||||
return tenant_id
|
||||
|
||||
|
||||
def _principal_account_id(principal: object) -> str:
|
||||
account_id = str(getattr(principal, "account_id", "") or "").strip()
|
||||
if not account_id:
|
||||
raise SemanticDocumentationAuthorizationError(
|
||||
"Semantic documentation requires an authenticated account."
|
||||
)
|
||||
return account_id
|
||||
|
||||
|
||||
def _require_tenant(
|
||||
reference: SemanticDocumentationSubjectReference,
|
||||
tenant_id: str,
|
||||
) -> None:
|
||||
if reference.tenant_id != tenant_id:
|
||||
raise SemanticDocumentationNotFoundError(
|
||||
"Semantic documentation subject not found."
|
||||
)
|
||||
|
||||
|
||||
def _require_expected_revision(
|
||||
entry: SemanticDocumentationEntry,
|
||||
expected_revision: int,
|
||||
) -> None:
|
||||
if entry.current_revision != expected_revision:
|
||||
raise SemanticDocumentationConflictError(
|
||||
"Semantic documentation changed; reload before saving."
|
||||
)
|
||||
|
||||
|
||||
def _require_editable(entry: SemanticDocumentationEntry) -> None:
|
||||
if entry.lifecycle_state in {"superseded", "retired"}:
|
||||
raise SemanticDocumentationConflictError(
|
||||
f"{entry.lifecycle_state.title()} semantic documentation cannot be edited."
|
||||
)
|
||||
|
||||
|
||||
def _locale(value: str) -> str:
|
||||
clean = str(value or "").strip()
|
||||
if not _LOCALE_RE.fullmatch(clean):
|
||||
raise SemanticDocumentationError("Semantic documentation locale is invalid.")
|
||||
return clean
|
||||
|
||||
|
||||
def _optional_id(value: object) -> str | None:
|
||||
clean = str(value or "").strip()
|
||||
if not clean:
|
||||
return None
|
||||
if len(clean) > 255 or any(ord(character) < 32 for character in clean):
|
||||
raise SemanticDocumentationError("Account references must be bounded text.")
|
||||
return clean
|
||||
|
||||
|
||||
def _bounded_required(value: object, label: str, maximum: int) -> str:
|
||||
return _plain_text(value, label, maximum, required=True)
|
||||
|
||||
|
||||
def _utc(value: datetime | None) -> datetime:
|
||||
timestamp = value or datetime.now(UTC)
|
||||
if timestamp.tzinfo is None:
|
||||
return timestamp.replace(tzinfo=UTC)
|
||||
return timestamp.astimezone(UTC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SEMANTIC_LIFECYCLE_STATES",
|
||||
"SEMANTIC_PUBLICATION_MODES",
|
||||
"SEMANTIC_PUBLICATION_POLICY_KEY",
|
||||
"SemanticDocumentationAuthorizationError",
|
||||
"SemanticDocumentationConflictError",
|
||||
"SemanticDocumentationError",
|
||||
"SemanticDocumentationNotFoundError",
|
||||
"SemanticDocumentationSubjectError",
|
||||
"content_visible_to_principal",
|
||||
"create_semantic_entry",
|
||||
"current_revision",
|
||||
"entry_subject_reference",
|
||||
"get_semantic_entry",
|
||||
"list_semantic_entries",
|
||||
"publication_policy",
|
||||
"publish_semantic_entry",
|
||||
"published_revision",
|
||||
"resolve_entry_subject",
|
||||
"retire_semantic_entry",
|
||||
"revision_payload",
|
||||
"select_locale_entries",
|
||||
"semantic_entry_history",
|
||||
"semantic_entry_payload",
|
||||
"set_publication_policy",
|
||||
"supersede_semantic_entry",
|
||||
"update_semantic_entry",
|
||||
]
|
||||
Reference in New Issue
Block a user