feat: add tenant semantic documentation lifecycle
This commit is contained in:
@@ -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"]
|
||||
Reference in New Issue
Block a user