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.views import VIEW_SURFACE_CONTRACT_VERSION, ViewSurface from govoplan_core.db.session import get_session from govoplan_views.backend.manifest import ( ASSIGNMENT_READ_SCOPE, ASSIGNMENT_WRITE_SCOPE, DEFINITION_READ_SCOPE, 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, ViewAssignmentUpdateRequest, ViewDefinitionCreateRequest, ViewDefinitionListResponse, ViewDefinitionResponse, ViewDefinitionUpdateRequest, ViewProvenanceResponse, ViewRevisionCreateRequest, ViewRevisionResponse, ViewSelectionRequest, ViewSurfaceCatalogueResponse, ViewSurfaceResponse, ) 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() -> tuple[ViewSurface, ...]: return get_registry().view_surfaces() 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 _require_definition_read(principal: ApiPrincipal, scope_type: str) -> None: if scope_type == "system": _require_any_scope( principal, SYSTEM_DEFINITION_READ_SCOPE, SYSTEM_DEFINITION_WRITE_SCOPE, ) return _require_any_scope( principal, DEFINITION_READ_SCOPE, DEFINITION_WRITE_SCOPE, ) def _require_definition_write(principal: ApiPrincipal, scope_type: str) -> None: _require_any_scope( principal, SYSTEM_DEFINITION_WRITE_SCOPE if scope_type == "system" else DEFINITION_WRITE_SCOPE, ) 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), 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, definition, *, readonly: bool, ) -> ViewDefinitionResponse: return ViewDefinitionResponse.model_validate( definition_payload( session, definition, readonly=readonly, catalogue=_catalogue(), ) ) @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(), ) ) @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(), ) _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( principal: ApiPrincipal = Depends(get_api_principal), ) -> ViewSurfaceCatalogueResponse: _require_any_scope( principal, DEFINITION_READ_SCOPE, 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() ], ) @router.get("/definitions", response_model=ViewDefinitionListResponse) def api_list_definitions( scope_type: str = Query(default="tenant", pattern="^(system|tenant)$"), include_inherited: bool = True, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> ViewDefinitionListResponse: _require_definition_read(principal, scope_type) definitions = list_definitions( session, tenant_id=principal.tenant_id, scope_type=scope_type, include_inherited=include_inherited, ) return ViewDefinitionListResponse( definitions=[ _definition_response( session, definition, readonly=(scope_type == "tenant" and definition.scope_type == "system"), ) 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: _require_definition_write(principal, payload.scope_type) try: definition = create_definition( session, tenant_id=principal.tenant_id, scope_type=payload.scope_type, definition_key=payload.definition_key, name=payload.name, description=payload.description, visible_surface_ids=payload.visible_surface_ids, catalogue=_catalogue(), actor_id=_actor_id(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, 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) 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, 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) 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) revision = create_revision( session, definition, visible_surface_ids=payload.visible_surface_ids, catalogue=_catalogue(), actor_id=_actor_id(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, 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) revision = get_revision( session, definition_id=definition.id, revision_id=revision_id, ) publish_revision( session, definition, revision, catalogue=_catalogue(), 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, 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) 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, definition, readonly=False) except ViewsError as exc: session.rollback() raise _http_error(exc) from exc @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_assignments( session, tenant_id=principal.tenant_id, scope_type=scope_type, include_inherited=include_inherited, ) return ViewAssignmentListResponse( assignments=[ ViewAssignmentResponse.model_validate(assignment_payload(assignment)) 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: definition = get_definition( session, tenant_id=principal.tenant_id, definition_id=payload.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(), 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() return ViewAssignmentResponse.model_validate(assignment_payload(assignment)) 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) update_assignment( session, assignment, updates=payload.model_dump(exclude_unset=True), catalogue=_catalogue(), 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() return ViewAssignmentResponse.model_validate(assignment_payload(assignment)) 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) 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