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