1275 lines
39 KiB
Python
1275 lines
39 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.audit.logging import audit_event
|
|
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
|
from govoplan_core.core.access import CAPABILITY_ACCESS_DIRECTORY, AccessDirectory
|
|
from govoplan_core.core.policy import (
|
|
DefinitionScopeRef,
|
|
ViewGovernanceRequest,
|
|
view_governance_policy,
|
|
)
|
|
from govoplan_core.core.module_entitlements import tenant_module_entitlement_state
|
|
from govoplan_core.core.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface
|
|
from govoplan_core.db.session import get_session
|
|
from govoplan_core.tenancy.scope import Tenant
|
|
from govoplan_views.backend.manifest import (
|
|
ASSIGNMENT_READ_SCOPE,
|
|
ASSIGNMENT_WRITE_SCOPE,
|
|
DEFINITION_READ_SCOPE,
|
|
DEFINITION_WRITE_SCOPE,
|
|
GROUP_DEFINITION_READ_SCOPE,
|
|
GROUP_DEFINITION_WRITE_SCOPE,
|
|
PERSONAL_DEFINITION_READ_SCOPE,
|
|
PERSONAL_DEFINITION_WRITE_SCOPE,
|
|
SELECTION_READ_SCOPE,
|
|
SELECTION_WRITE_SCOPE,
|
|
SYSTEM_ASSIGNMENT_READ_SCOPE,
|
|
SYSTEM_ASSIGNMENT_WRITE_SCOPE,
|
|
SYSTEM_DEFINITION_READ_SCOPE,
|
|
SYSTEM_DEFINITION_WRITE_SCOPE,
|
|
)
|
|
from govoplan_views.backend.runtime import get_registry
|
|
from govoplan_views.backend.schemas import (
|
|
EffectiveViewOptionResponse,
|
|
EffectiveViewResponse,
|
|
ViewDiagnosticResponse,
|
|
ViewAssignmentCreateRequest,
|
|
ViewAssignmentListResponse,
|
|
ViewAssignmentResponse,
|
|
ViewAssignmentTargetListResponse,
|
|
ViewAssignmentTargetResponse,
|
|
ViewAssignmentUpdateRequest,
|
|
ViewDefinitionCreateRequest,
|
|
ViewDefinitionListResponse,
|
|
ViewDefinitionResponse,
|
|
ViewDefinitionUpdateRequest,
|
|
ViewProvenanceResponse,
|
|
ViewRevisionCreateRequest,
|
|
ViewRevisionResponse,
|
|
ViewSelectionRequest,
|
|
ViewSurfaceCatalogueResponse,
|
|
ViewSurfaceResponse,
|
|
WorkflowViewResolutionRequest,
|
|
)
|
|
from govoplan_views.backend.service import (
|
|
EffectiveViewState,
|
|
ViewsConflictError,
|
|
ViewsError,
|
|
ViewsNotFoundError,
|
|
ViewsValidationError,
|
|
archive_definition,
|
|
assignment_payload,
|
|
create_assignment,
|
|
create_definition,
|
|
create_revision,
|
|
definition_payload,
|
|
definition_revisions,
|
|
delete_assignment,
|
|
get_assignment,
|
|
get_definition,
|
|
get_revision,
|
|
list_assignments,
|
|
list_definitions,
|
|
publish_revision,
|
|
resolve_effective_view,
|
|
select_view,
|
|
update_assignment,
|
|
update_definition,
|
|
)
|
|
|
|
|
|
router = APIRouter(prefix="/views", tags=["views"])
|
|
|
|
|
|
def _catalogue(
|
|
session: Session | None = None,
|
|
principal: ApiPrincipal | None = None,
|
|
) -> tuple[ViewSurface, ...]:
|
|
registry = get_registry()
|
|
surfaces = registry.view_surfaces()
|
|
if session is None or principal is None or principal.tenant_id is None:
|
|
return surfaces
|
|
|
|
tenant = session.get(Tenant, principal.tenant_id)
|
|
if tenant is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="The active tenant is unavailable.",
|
|
)
|
|
manifests = {manifest.id: manifest for manifest in registry.manifests()}
|
|
entitlement = tenant_module_entitlement_state(
|
|
tenant.settings or {},
|
|
manifests,
|
|
runtime_active_modules=manifests,
|
|
)
|
|
effective_modules = set(entitlement.effective_modules)
|
|
return tuple(
|
|
surface for surface in surfaces if surface.module_id in effective_modules
|
|
)
|
|
|
|
|
|
def _product_area_ids(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
) -> frozenset[str]:
|
|
active_module_ids = {
|
|
surface.module_id for surface in _catalogue(session, principal)
|
|
}
|
|
return frozenset(
|
|
area.id
|
|
for manifest in get_registry().manifests()
|
|
if manifest.id in active_module_ids and manifest.frontend is not None
|
|
for area in manifest.frontend.product_areas
|
|
)
|
|
|
|
|
|
def _view_governance_policy():
|
|
return view_governance_policy(get_registry())
|
|
|
|
|
|
def _enforce_view_policy_action(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
action: str,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
view_id: str | None = None,
|
|
surface_ids: tuple[str, ...] = (),
|
|
) -> None:
|
|
policy = _view_governance_policy()
|
|
if policy is None:
|
|
return
|
|
target_id = scope_id
|
|
if scope_type == "tenant":
|
|
target_id = target_id or principal.tenant_id
|
|
decision = policy.resolve_view_action(
|
|
session,
|
|
request=ViewGovernanceRequest(
|
|
tenant_id=principal.tenant_id,
|
|
action=action, # type: ignore[arg-type]
|
|
actor=principal.principal,
|
|
target_scope=DefinitionScopeRef(
|
|
scope_type, # type: ignore[arg-type]
|
|
target_id,
|
|
),
|
|
view_id=view_id,
|
|
candidate_view_ids=(view_id,) if view_id is not None else (),
|
|
candidate_surface_ids=tuple(
|
|
surface.id for surface in _catalogue(session, principal)
|
|
),
|
|
requested_surface_ids=surface_ids,
|
|
),
|
|
)
|
|
if not decision.allowed:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=decision.reason or f"Policy denied View action: {action}",
|
|
)
|
|
|
|
|
|
def _optional_access_directory() -> AccessDirectory | None:
|
|
registry = get_registry()
|
|
if not registry.has_capability(CAPABILITY_ACCESS_DIRECTORY):
|
|
return None
|
|
capability = registry.capability(CAPABILITY_ACCESS_DIRECTORY)
|
|
if not isinstance(capability, AccessDirectory):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Access directory capability is invalid.",
|
|
)
|
|
return capability
|
|
|
|
|
|
def _all_assignment_targets(
|
|
directory: AccessDirectory,
|
|
*,
|
|
tenant_id: str,
|
|
scope_type: str,
|
|
) -> list[ViewAssignmentTargetResponse]:
|
|
if scope_type == "user":
|
|
by_account_id: dict[str, ViewAssignmentTargetResponse] = {}
|
|
for user in directory.users_for_tenant(tenant_id):
|
|
label = (user.display_name or user.email or user.account_id).strip()
|
|
detail = user.email if user.email and user.email != label else None
|
|
target = ViewAssignmentTargetResponse(
|
|
id=user.account_id,
|
|
scope_type="user",
|
|
label=label,
|
|
detail=detail,
|
|
disabled=user.status != "active",
|
|
disabled_reason=(
|
|
"This user is inactive." if user.status != "active" else None
|
|
),
|
|
)
|
|
current = by_account_id.get(user.account_id)
|
|
if current is None or (current.disabled and not target.disabled):
|
|
by_account_id[user.account_id] = target
|
|
targets = list(by_account_id.values())
|
|
elif scope_type == "group":
|
|
targets = [
|
|
ViewAssignmentTargetResponse(
|
|
id=group.id,
|
|
scope_type="group",
|
|
label=group.name,
|
|
disabled=group.status != "active",
|
|
disabled_reason=(
|
|
"This group is inactive." if group.status != "active" else None
|
|
),
|
|
)
|
|
for group in directory.groups_for_tenant(tenant_id)
|
|
]
|
|
else:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"Unsupported View assignment target scope: {scope_type}",
|
|
)
|
|
return sorted(
|
|
targets,
|
|
key=lambda item: (item.disabled, item.label.casefold(), item.id),
|
|
)
|
|
|
|
|
|
def _search_assignment_targets(
|
|
directory: AccessDirectory,
|
|
*,
|
|
tenant_id: str,
|
|
scope_type: str,
|
|
query: str,
|
|
limit: int,
|
|
) -> list[ViewAssignmentTargetResponse]:
|
|
needle = query.strip().casefold()
|
|
targets = _all_assignment_targets(
|
|
directory,
|
|
tenant_id=tenant_id,
|
|
scope_type=scope_type,
|
|
)
|
|
if needle:
|
|
targets = [
|
|
target
|
|
for target in targets
|
|
if needle
|
|
in " ".join(
|
|
(
|
|
target.label,
|
|
target.detail or "",
|
|
target.id,
|
|
)
|
|
).casefold()
|
|
]
|
|
return targets[:limit]
|
|
|
|
|
|
def _assignment_target_lookup(
|
|
directory: AccessDirectory | None,
|
|
*,
|
|
tenant_id: str | None,
|
|
assignments: list[object],
|
|
) -> dict[tuple[str, str], ViewAssignmentTargetResponse]:
|
|
if directory is None or tenant_id is None:
|
|
return {}
|
|
scope_types = {
|
|
str(getattr(assignment, "scope_type", ""))
|
|
for assignment in assignments
|
|
if getattr(assignment, "scope_id", None)
|
|
}
|
|
result: dict[tuple[str, str], ViewAssignmentTargetResponse] = {}
|
|
for scope_type in scope_types.intersection({"group", "user"}):
|
|
for target in _all_assignment_targets(
|
|
directory,
|
|
tenant_id=tenant_id,
|
|
scope_type=scope_type,
|
|
):
|
|
result[(scope_type, target.id)] = target
|
|
return result
|
|
|
|
|
|
def _assignment_response(
|
|
assignment: object,
|
|
*,
|
|
targets: dict[tuple[str, str], ViewAssignmentTargetResponse],
|
|
) -> ViewAssignmentResponse:
|
|
payload = assignment_payload(assignment)
|
|
scope_type = str(payload.get("scope_type") or "")
|
|
scope_id = str(payload.get("scope_id") or "")
|
|
target = targets.get((scope_type, scope_id))
|
|
if target is not None:
|
|
payload["scope_label"] = target.label
|
|
payload["scope_detail"] = target.detail
|
|
return ViewAssignmentResponse.model_validate(payload)
|
|
|
|
|
|
def _validate_assignment_target(
|
|
directory: AccessDirectory | None,
|
|
*,
|
|
tenant_id: str | None,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
) -> None:
|
|
if directory is None or tenant_id is None or scope_type not in {"group", "user"}:
|
|
return
|
|
target = next(
|
|
(
|
|
candidate
|
|
for candidate in _all_assignment_targets(
|
|
directory,
|
|
tenant_id=tenant_id,
|
|
scope_type=scope_type,
|
|
)
|
|
if candidate.id == scope_id
|
|
),
|
|
None,
|
|
)
|
|
if target is None:
|
|
raise ViewsValidationError(
|
|
f"The selected {scope_type} does not exist in the current tenant."
|
|
)
|
|
if target.disabled:
|
|
raise ViewsValidationError(
|
|
f"The selected {scope_type} is inactive and cannot receive a View assignment."
|
|
)
|
|
|
|
|
|
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
|
|
if any(has_scope(principal, scope) for scope in scopes):
|
|
return
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"Requires one of: {', '.join(scopes)}",
|
|
)
|
|
|
|
|
|
def _has_any_scope(principal: ApiPrincipal, *scopes: str) -> bool:
|
|
return any(has_scope(principal, scope) for scope in scopes)
|
|
|
|
|
|
def _require_definition_read(
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None = None,
|
|
) -> None:
|
|
if scope_type == "system":
|
|
_require_any_scope(
|
|
principal,
|
|
SYSTEM_DEFINITION_READ_SCOPE,
|
|
SYSTEM_DEFINITION_WRITE_SCOPE,
|
|
)
|
|
return
|
|
if scope_type == "tenant":
|
|
_require_any_scope(
|
|
principal,
|
|
DEFINITION_READ_SCOPE,
|
|
DEFINITION_WRITE_SCOPE,
|
|
)
|
|
return
|
|
if _has_any_scope(principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE):
|
|
return
|
|
if scope_type == "group":
|
|
if not scope_id or scope_id not in principal.group_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Group Views can only be managed by members of that group.",
|
|
)
|
|
_require_any_scope(
|
|
principal,
|
|
GROUP_DEFINITION_READ_SCOPE,
|
|
GROUP_DEFINITION_WRITE_SCOPE,
|
|
)
|
|
return
|
|
if scope_type == "user":
|
|
if scope_id != principal.account_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Personal Views can only be managed by their owner.",
|
|
)
|
|
_require_any_scope(
|
|
principal,
|
|
PERSONAL_DEFINITION_READ_SCOPE,
|
|
PERSONAL_DEFINITION_WRITE_SCOPE,
|
|
)
|
|
return
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"Unsupported View definition scope: {scope_type}",
|
|
)
|
|
|
|
|
|
def _require_definition_write(
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None = None,
|
|
) -> None:
|
|
if scope_type == "system":
|
|
_require_any_scope(principal, SYSTEM_DEFINITION_WRITE_SCOPE)
|
|
return
|
|
if scope_type == "tenant":
|
|
_require_any_scope(principal, DEFINITION_WRITE_SCOPE)
|
|
return
|
|
if has_scope(principal, DEFINITION_WRITE_SCOPE):
|
|
return
|
|
if scope_type == "group":
|
|
if not scope_id or scope_id not in principal.group_ids:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Group Views can only be managed by members of that group.",
|
|
)
|
|
_require_any_scope(principal, GROUP_DEFINITION_WRITE_SCOPE)
|
|
return
|
|
if scope_type == "user":
|
|
if scope_id != principal.account_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Personal Views can only be managed by their owner.",
|
|
)
|
|
_require_any_scope(principal, PERSONAL_DEFINITION_WRITE_SCOPE)
|
|
return
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=f"Unsupported View definition scope: {scope_type}",
|
|
)
|
|
|
|
|
|
def _definition_scope_id(
|
|
principal: ApiPrincipal,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
) -> str | None:
|
|
if scope_type in {"system", "tenant"}:
|
|
return scope_id
|
|
if scope_type == "user":
|
|
return (scope_id or principal.account_id).strip()
|
|
target_id = (scope_id or "").strip()
|
|
if not target_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="Group View definitions require a target id.",
|
|
)
|
|
return target_id
|
|
|
|
|
|
def _require_assignment_read(principal: ApiPrincipal, scope_type: str) -> None:
|
|
if scope_type == "system":
|
|
_require_any_scope(
|
|
principal,
|
|
SYSTEM_ASSIGNMENT_READ_SCOPE,
|
|
SYSTEM_ASSIGNMENT_WRITE_SCOPE,
|
|
)
|
|
return
|
|
_require_any_scope(
|
|
principal,
|
|
ASSIGNMENT_READ_SCOPE,
|
|
ASSIGNMENT_WRITE_SCOPE,
|
|
)
|
|
|
|
|
|
def _require_assignment_write(principal: ApiPrincipal, scope_type: str) -> None:
|
|
_require_any_scope(
|
|
principal,
|
|
SYSTEM_ASSIGNMENT_WRITE_SCOPE
|
|
if scope_type == "system"
|
|
else ASSIGNMENT_WRITE_SCOPE,
|
|
)
|
|
|
|
|
|
def _actor_id(principal: ApiPrincipal) -> str:
|
|
return principal.account_id
|
|
|
|
|
|
def _http_error(exc: ViewsError) -> HTTPException:
|
|
if isinstance(exc, ViewsNotFoundError):
|
|
return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc))
|
|
if isinstance(exc, ViewsConflictError):
|
|
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
|
if isinstance(exc, ViewsValidationError):
|
|
return HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail=str(exc),
|
|
)
|
|
return HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(exc),
|
|
)
|
|
|
|
|
|
def _audit(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
*,
|
|
action: str,
|
|
object_type: str,
|
|
object_id: str,
|
|
details: dict[str, object],
|
|
) -> None:
|
|
audit_event(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
user_id=getattr(principal.user, "id", None),
|
|
api_key_id=principal.api_key_id,
|
|
action=action,
|
|
object_type=object_type,
|
|
object_id=object_id,
|
|
details=details,
|
|
)
|
|
|
|
|
|
def _effective_response(state: EffectiveViewState) -> EffectiveViewResponse:
|
|
effective = state.effective
|
|
return EffectiveViewResponse(
|
|
active_view_id=effective.view_id,
|
|
active_revision_id=effective.revision_id,
|
|
active_view_name=effective.name,
|
|
visible_surface_ids=sorted(effective.visible_surface_ids),
|
|
presentation=dict(effective.presentation),
|
|
projection_active=effective.projection_active,
|
|
locked=effective.locked,
|
|
available_views=[
|
|
EffectiveViewOptionResponse(
|
|
id=option.id,
|
|
name=option.name,
|
|
description=option.description,
|
|
revision_id=option.revision_id,
|
|
)
|
|
for option in state.available_views
|
|
],
|
|
provenance=[
|
|
ViewProvenanceResponse.model_validate(item) for item in effective.provenance
|
|
],
|
|
diagnostics=[
|
|
ViewDiagnosticResponse(
|
|
severity=item.severity,
|
|
code=item.code,
|
|
message=item.message,
|
|
surface_ids=list(item.surface_ids),
|
|
)
|
|
for item in state.diagnostics
|
|
],
|
|
)
|
|
|
|
|
|
def _definition_response(
|
|
session: Session,
|
|
principal: ApiPrincipal,
|
|
definition,
|
|
*,
|
|
readonly: bool,
|
|
) -> ViewDefinitionResponse:
|
|
return ViewDefinitionResponse.model_validate(
|
|
definition_payload(
|
|
session,
|
|
definition,
|
|
readonly=readonly,
|
|
catalogue=_catalogue(session, principal),
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("/effective", response_model=EffectiveViewResponse)
|
|
def api_effective_view(
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> EffectiveViewResponse:
|
|
_require_any_scope(
|
|
principal,
|
|
SELECTION_READ_SCOPE,
|
|
SELECTION_WRITE_SCOPE,
|
|
)
|
|
return _effective_response(
|
|
resolve_effective_view(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
account_id=principal.account_id,
|
|
group_ids=principal.group_ids,
|
|
catalogue=_catalogue(session, principal),
|
|
governance_policy=_view_governance_policy(),
|
|
)
|
|
)
|
|
|
|
|
|
@router.post("/effective/workflow", response_model=EffectiveViewResponse)
|
|
def api_workflow_effective_view(
|
|
payload: WorkflowViewResolutionRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> EffectiveViewResponse:
|
|
_require_any_scope(
|
|
principal,
|
|
SELECTION_READ_SCOPE,
|
|
SELECTION_WRITE_SCOPE,
|
|
)
|
|
return _effective_response(
|
|
resolve_effective_view(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
account_id=principal.account_id,
|
|
group_ids=principal.group_ids,
|
|
catalogue=_catalogue(session, principal),
|
|
workflow_view_id=payload.view_id,
|
|
workflow_revision_id=payload.revision_id,
|
|
workflow_surface_ids=payload.visible_surface_ids,
|
|
governance_policy=_view_governance_policy(),
|
|
)
|
|
)
|
|
|
|
|
|
@router.put("/selection", response_model=EffectiveViewResponse)
|
|
def api_select_view(
|
|
payload: ViewSelectionRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> EffectiveViewResponse:
|
|
_require_any_scope(principal, SELECTION_WRITE_SCOPE)
|
|
try:
|
|
state = select_view(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
account_id=principal.account_id,
|
|
group_ids=principal.group_ids,
|
|
view_id=payload.view_id,
|
|
catalogue=_catalogue(session, principal),
|
|
governance_policy=_view_governance_policy(),
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.selection.update",
|
|
object_type="view_preference",
|
|
object_id=principal.account_id,
|
|
details={"view_id": payload.view_id},
|
|
)
|
|
session.commit()
|
|
return _effective_response(state)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.get("/surfaces", response_model=ViewSurfaceCatalogueResponse)
|
|
def api_view_surfaces(
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewSurfaceCatalogueResponse:
|
|
_require_any_scope(
|
|
principal,
|
|
DEFINITION_READ_SCOPE,
|
|
DEFINITION_WRITE_SCOPE,
|
|
GROUP_DEFINITION_READ_SCOPE,
|
|
GROUP_DEFINITION_WRITE_SCOPE,
|
|
PERSONAL_DEFINITION_READ_SCOPE,
|
|
PERSONAL_DEFINITION_WRITE_SCOPE,
|
|
SYSTEM_DEFINITION_READ_SCOPE,
|
|
SYSTEM_DEFINITION_WRITE_SCOPE,
|
|
)
|
|
lockout_ids = {
|
|
"access.module",
|
|
"access.nav.admin",
|
|
"access.route.admin",
|
|
"views.module",
|
|
"views.selector",
|
|
"views.admin.system",
|
|
"views.admin.tenant",
|
|
}
|
|
return ViewSurfaceCatalogueResponse(
|
|
contract_version=VIEW_SURFACE_CONTRACT_VERSION,
|
|
surfaces=[
|
|
ViewSurfaceResponse(
|
|
id=surface.id,
|
|
module_id=surface.module_id,
|
|
kind=surface.kind,
|
|
label=surface.label,
|
|
parent_id=surface.parent_id,
|
|
description=surface.description,
|
|
order=surface.order,
|
|
default_visible=surface.default_visible,
|
|
required=surface.required,
|
|
required_for_locked_view=surface.id in lockout_ids,
|
|
)
|
|
for surface in _catalogue(session, principal)
|
|
],
|
|
)
|
|
|
|
|
|
@router.get("/definitions", response_model=ViewDefinitionListResponse)
|
|
def api_list_definitions(
|
|
scope_type: str = Query(
|
|
default="tenant",
|
|
pattern="^(system|tenant|group|user)$",
|
|
),
|
|
scope_id: str | None = Query(default=None, max_length=255),
|
|
include_inherited: bool = True,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewDefinitionListResponse:
|
|
target_id = _definition_scope_id(principal, scope_type, scope_id)
|
|
_require_definition_read(principal, scope_type, target_id)
|
|
definitions = list_definitions(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=scope_type,
|
|
scope_id=target_id,
|
|
include_inherited=include_inherited,
|
|
)
|
|
return ViewDefinitionListResponse(
|
|
definitions=[
|
|
_definition_response(
|
|
session,
|
|
principal,
|
|
definition,
|
|
readonly=(
|
|
(scope_type == "tenant" and definition.scope_type == "system")
|
|
or (
|
|
scope_type in {"group", "user"}
|
|
and (
|
|
definition.scope_type != scope_type
|
|
or definition.scope_id != target_id
|
|
)
|
|
)
|
|
),
|
|
)
|
|
for definition in definitions
|
|
]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/definitions",
|
|
response_model=ViewDefinitionResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_definition(
|
|
payload: ViewDefinitionCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewDefinitionResponse:
|
|
target_id = _definition_scope_id(
|
|
principal,
|
|
payload.scope_type,
|
|
payload.scope_id,
|
|
)
|
|
_require_definition_write(principal, payload.scope_type, target_id)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="derive",
|
|
scope_type=payload.scope_type,
|
|
scope_id=target_id,
|
|
surface_ids=tuple(payload.visible_surface_ids),
|
|
)
|
|
try:
|
|
definition = create_definition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=payload.scope_type,
|
|
scope_id=target_id,
|
|
definition_key=payload.definition_key,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
visible_surface_ids=payload.visible_surface_ids,
|
|
catalogue=_catalogue(session, principal),
|
|
actor_id=_actor_id(principal),
|
|
presentation=payload.presentation,
|
|
available_product_area_ids=_product_area_ids(session, principal),
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.definition.create",
|
|
object_type="view_definition",
|
|
object_id=definition.id,
|
|
details={"scope_type": definition.scope_type},
|
|
)
|
|
session.commit()
|
|
return _definition_response(
|
|
session, principal, definition, readonly=False
|
|
)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.patch(
|
|
"/definitions/{definition_id}",
|
|
response_model=ViewDefinitionResponse,
|
|
)
|
|
def api_update_definition(
|
|
definition_id: str,
|
|
payload: ViewDefinitionUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewDefinitionResponse:
|
|
try:
|
|
definition = get_definition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
definition_id=definition_id,
|
|
)
|
|
_require_definition_write(
|
|
principal,
|
|
definition.scope_type,
|
|
definition.scope_id,
|
|
)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="edit",
|
|
scope_type=definition.scope_type,
|
|
scope_id=definition.scope_id,
|
|
view_id=definition.id,
|
|
)
|
|
update_definition(
|
|
session,
|
|
definition,
|
|
name=payload.name,
|
|
description=payload.description,
|
|
actor_id=_actor_id(principal),
|
|
fields_set=payload.model_fields_set,
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.definition.update",
|
|
object_type="view_definition",
|
|
object_id=definition.id,
|
|
details={"fields": sorted(payload.model_fields_set)},
|
|
)
|
|
session.commit()
|
|
return _definition_response(
|
|
session, principal, definition, readonly=False
|
|
)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.get(
|
|
"/definitions/{definition_id}/revisions",
|
|
response_model=list[ViewRevisionResponse],
|
|
)
|
|
def api_list_revisions(
|
|
definition_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> list[ViewRevisionResponse]:
|
|
try:
|
|
definition = get_definition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
definition_id=definition_id,
|
|
)
|
|
_require_definition_read(
|
|
principal,
|
|
definition.scope_type,
|
|
definition.scope_id,
|
|
)
|
|
return [
|
|
ViewRevisionResponse.model_validate(revision)
|
|
for revision in definition_revisions(
|
|
session,
|
|
definition_id=definition.id,
|
|
)
|
|
]
|
|
except ViewsError as exc:
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/definitions/{definition_id}/revisions",
|
|
response_model=ViewDefinitionResponse,
|
|
)
|
|
def api_create_revision(
|
|
definition_id: str,
|
|
payload: ViewRevisionCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewDefinitionResponse:
|
|
try:
|
|
definition = get_definition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
definition_id=definition_id,
|
|
)
|
|
_require_definition_write(
|
|
principal,
|
|
definition.scope_type,
|
|
definition.scope_id,
|
|
)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="edit",
|
|
scope_type=definition.scope_type,
|
|
scope_id=definition.scope_id,
|
|
view_id=definition.id,
|
|
surface_ids=tuple(payload.visible_surface_ids),
|
|
)
|
|
revision = create_revision(
|
|
session,
|
|
definition,
|
|
visible_surface_ids=payload.visible_surface_ids,
|
|
catalogue=_catalogue(session, principal),
|
|
actor_id=_actor_id(principal),
|
|
presentation=payload.presentation,
|
|
available_product_area_ids=_product_area_ids(session, principal),
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.revision.create",
|
|
object_type="view_definition",
|
|
object_id=definition.id,
|
|
details={
|
|
"revision": revision.revision,
|
|
"content_hash": revision.content_hash,
|
|
},
|
|
)
|
|
session.commit()
|
|
return _definition_response(
|
|
session, principal, definition, readonly=False
|
|
)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.post(
|
|
"/definitions/{definition_id}/revisions/{revision_id}/publish",
|
|
response_model=ViewDefinitionResponse,
|
|
)
|
|
def api_publish_revision(
|
|
definition_id: str,
|
|
revision_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewDefinitionResponse:
|
|
try:
|
|
definition = get_definition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
definition_id=definition_id,
|
|
)
|
|
_require_definition_write(
|
|
principal,
|
|
definition.scope_type,
|
|
definition.scope_id,
|
|
)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="edit",
|
|
scope_type=definition.scope_type,
|
|
scope_id=definition.scope_id,
|
|
view_id=definition.id,
|
|
)
|
|
revision = get_revision(
|
|
session,
|
|
definition_id=definition.id,
|
|
revision_id=revision_id,
|
|
)
|
|
publish_revision(
|
|
session,
|
|
definition,
|
|
revision,
|
|
catalogue=_catalogue(session, principal),
|
|
actor_id=_actor_id(principal),
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.revision.publish",
|
|
object_type="view_definition",
|
|
object_id=definition.id,
|
|
details={"revision": revision.revision, "revision_id": revision.id},
|
|
)
|
|
session.commit()
|
|
return _definition_response(
|
|
session, principal, definition, readonly=False
|
|
)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.delete(
|
|
"/definitions/{definition_id}",
|
|
response_model=ViewDefinitionResponse,
|
|
)
|
|
def api_archive_definition(
|
|
definition_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewDefinitionResponse:
|
|
try:
|
|
definition = get_definition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
definition_id=definition_id,
|
|
)
|
|
_require_definition_write(
|
|
principal,
|
|
definition.scope_type,
|
|
definition.scope_id,
|
|
)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="edit",
|
|
scope_type=definition.scope_type,
|
|
scope_id=definition.scope_id,
|
|
view_id=definition.id,
|
|
)
|
|
archive_definition(
|
|
session,
|
|
definition,
|
|
actor_id=_actor_id(principal),
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.definition.archive",
|
|
object_type="view_definition",
|
|
object_id=definition.id,
|
|
details={},
|
|
)
|
|
session.commit()
|
|
return _definition_response(
|
|
session, principal, definition, readonly=False
|
|
)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.get(
|
|
"/assignment-targets",
|
|
response_model=ViewAssignmentTargetListResponse,
|
|
)
|
|
def api_list_assignment_targets(
|
|
scope_type: str = Query(pattern="^(group|user)$"),
|
|
query: str = Query(default="", max_length=200),
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewAssignmentTargetListResponse:
|
|
_require_assignment_read(principal, "tenant")
|
|
if principal.tenant_id is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
detail="User and group View assignments require an active tenant.",
|
|
)
|
|
directory = _optional_access_directory()
|
|
if directory is None:
|
|
return ViewAssignmentTargetListResponse(
|
|
directory_available=False,
|
|
unavailable_reason=(
|
|
"User and group selection requires an enabled Access directory."
|
|
),
|
|
)
|
|
return ViewAssignmentTargetListResponse(
|
|
directory_available=True,
|
|
targets=_search_assignment_targets(
|
|
directory,
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=scope_type,
|
|
query=query,
|
|
limit=limit,
|
|
),
|
|
)
|
|
|
|
|
|
@router.get("/assignments", response_model=ViewAssignmentListResponse)
|
|
def api_list_assignments(
|
|
scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"),
|
|
include_inherited: bool = True,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewAssignmentListResponse:
|
|
_require_assignment_read(principal, scope_type)
|
|
assignments = list(
|
|
list_assignments(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=scope_type,
|
|
include_inherited=include_inherited,
|
|
)
|
|
)
|
|
targets = _assignment_target_lookup(
|
|
_optional_access_directory(),
|
|
tenant_id=principal.tenant_id,
|
|
assignments=assignments,
|
|
)
|
|
return ViewAssignmentListResponse(
|
|
assignments=[
|
|
_assignment_response(assignment, targets=targets)
|
|
for assignment in assignments
|
|
]
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/assignments",
|
|
response_model=ViewAssignmentResponse,
|
|
status_code=status.HTTP_201_CREATED,
|
|
)
|
|
def api_create_assignment(
|
|
payload: ViewAssignmentCreateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewAssignmentResponse:
|
|
_require_assignment_write(principal, payload.scope_type)
|
|
try:
|
|
directory = _optional_access_directory()
|
|
_validate_assignment_target(
|
|
directory,
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=payload.scope_type,
|
|
scope_id=payload.scope_id,
|
|
)
|
|
definition = get_definition(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
definition_id=payload.definition_id,
|
|
)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="assign",
|
|
scope_type=payload.scope_type,
|
|
scope_id=payload.scope_id,
|
|
view_id=definition.id,
|
|
)
|
|
assignment = create_assignment(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
scope_type=payload.scope_type,
|
|
scope_id=payload.scope_id,
|
|
definition=definition,
|
|
revision_id=payload.revision_id,
|
|
mode=payload.mode,
|
|
priority=payload.priority,
|
|
is_active=payload.is_active,
|
|
metadata=payload.metadata,
|
|
catalogue=_catalogue(session, principal),
|
|
actor_id=_actor_id(principal),
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.assignment.create",
|
|
object_type="view_assignment",
|
|
object_id=assignment.id,
|
|
details={
|
|
"scope_type": assignment.scope_type,
|
|
"scope_id": assignment.scope_id,
|
|
"definition_id": assignment.definition_id,
|
|
"mode": assignment.mode,
|
|
},
|
|
)
|
|
session.commit()
|
|
targets = _assignment_target_lookup(
|
|
directory,
|
|
tenant_id=principal.tenant_id,
|
|
assignments=[assignment],
|
|
)
|
|
return _assignment_response(assignment, targets=targets)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.patch(
|
|
"/assignments/{assignment_id}",
|
|
response_model=ViewAssignmentResponse,
|
|
)
|
|
def api_update_assignment(
|
|
assignment_id: str,
|
|
payload: ViewAssignmentUpdateRequest,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> ViewAssignmentResponse:
|
|
try:
|
|
assignment = get_assignment(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
assignment_id=assignment_id,
|
|
)
|
|
_require_assignment_write(principal, assignment.scope_type)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="assign",
|
|
scope_type=assignment.scope_type,
|
|
scope_id=assignment.scope_id,
|
|
view_id=assignment.definition_id,
|
|
)
|
|
update_assignment(
|
|
session,
|
|
assignment,
|
|
updates=payload.model_dump(exclude_unset=True),
|
|
catalogue=_catalogue(session, principal),
|
|
actor_id=_actor_id(principal),
|
|
)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.assignment.update",
|
|
object_type="view_assignment",
|
|
object_id=assignment.id,
|
|
details={"fields": sorted(payload.model_fields_set)},
|
|
)
|
|
session.commit()
|
|
targets = _assignment_target_lookup(
|
|
_optional_access_directory(),
|
|
tenant_id=principal.tenant_id,
|
|
assignments=[assignment],
|
|
)
|
|
return _assignment_response(assignment, targets=targets)
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|
|
|
|
|
|
@router.delete(
|
|
"/assignments/{assignment_id}",
|
|
status_code=status.HTTP_204_NO_CONTENT,
|
|
)
|
|
def api_delete_assignment(
|
|
assignment_id: str,
|
|
session: Session = Depends(get_session),
|
|
principal: ApiPrincipal = Depends(get_api_principal),
|
|
) -> None:
|
|
try:
|
|
assignment = get_assignment(
|
|
session,
|
|
tenant_id=principal.tenant_id,
|
|
assignment_id=assignment_id,
|
|
)
|
|
_require_assignment_write(principal, assignment.scope_type)
|
|
_enforce_view_policy_action(
|
|
session,
|
|
principal,
|
|
action="assign",
|
|
scope_type=assignment.scope_type,
|
|
scope_id=assignment.scope_id,
|
|
view_id=assignment.definition_id,
|
|
)
|
|
assignment_details = {
|
|
"scope_type": assignment.scope_type,
|
|
"scope_id": assignment.scope_id,
|
|
"definition_id": assignment.definition_id,
|
|
"mode": assignment.mode,
|
|
}
|
|
delete_assignment(session, assignment)
|
|
_audit(
|
|
session,
|
|
principal,
|
|
action="views.assignment.delete",
|
|
object_type="view_assignment",
|
|
object_id=assignment_id,
|
|
details=assignment_details,
|
|
)
|
|
session.commit()
|
|
except ViewsError as exc:
|
|
session.rollback()
|
|
raise _http_error(exc) from exc
|