from __future__ import annotations from datetime import UTC, datetime from dataclasses import asdict from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.institutional import ( InstitutionalContextError, InstitutionalReference, ) from govoplan_core.core.postbox import postbox_portal_projection_provider from govoplan_core.db.session import get_session from govoplan_portal.backend.schemas import ( PortalServiceLaunchRequest, PortalServiceLaunchResponse, PortalServiceListResponse, PortalPostboxListResponse, ) from govoplan_portal.backend.service_directory import ( PortalServiceDirectory, PortalServiceLaunchError, ) READ_SCOPE = "portal:service:read" router = APIRouter(prefix="/portal", tags=["portal"]) _registry: object | None = None def configure_registry(registry: object | None) -> None: global _registry _registry = registry @router.get("/services", response_model=PortalServiceListResponse) def api_list_portal_services( q: str = Query(default="", max_length=200), include_unavailable: bool = True, effective_at: datetime | None = None, limit: int = Query(default=100, ge=1, le=200), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PortalServiceListResponse: if not has_scope(principal, READ_SCOPE): raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}") observed_at = effective_at or datetime.now(tz=UTC) if observed_at.tzinfo is None: raise HTTPException( status_code=400, detail="Portal service effective_at must include a timezone.", ) try: entries = PortalServiceDirectory(_registry).list_entries( session, principal, tenant_id=principal.tenant_id, effective_at=observed_at, query=q.strip(), limit=limit, include_unavailable=include_unavailable, ) except InstitutionalContextError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return PortalServiceListResponse( services=[entry.to_dict() for entry in entries] ) @router.get("/postboxes", response_model=PortalPostboxListResponse) def api_list_portal_postboxes( limit: int = Query(default=100, ge=1, le=500), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PortalPostboxListResponse: if not has_scope(principal, READ_SCOPE): raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}") provider = postbox_portal_projection_provider(_registry) if provider is None: return PortalPostboxListResponse( provider_available=False, postboxes=[], ) entries = provider.list_portal_entries( session, principal, tenant_id=principal.tenant_id, limit=limit, ) return PortalPostboxListResponse( provider_available=True, postboxes=[asdict(entry) for entry in entries], ) @router.post( "/services/{service_id}/launch", response_model=PortalServiceLaunchResponse, ) def api_launch_portal_service( service_id: str, payload: PortalServiceLaunchRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PortalServiceLaunchResponse: if not has_scope(principal, READ_SCOPE): raise HTTPException(status_code=403, detail=f"Missing scope: {READ_SCOPE}") if payload.requested_at.tzinfo is None: raise HTTPException( status_code=400, detail="Portal service launch requested_at must include a timezone.", ) reference = InstitutionalReference( kind="service", owner_module="services", object_id=service_id, tenant_id=principal.tenant_id, version=payload.service_version, valid_at=payload.requested_at, ) try: result = PortalServiceDirectory(_registry).launch_service( session, principal, reference=reference, requested_at=payload.requested_at, idempotency_key=payload.idempotency_key, parameters=payload.parameters, ) session.commit() except LookupError as exc: session.rollback() raise HTTPException(status_code=404, detail=str(exc)) from exc except PortalServiceLaunchError as exc: session.rollback() raise HTTPException(status_code=409, detail=str(exc)) from exc except (InstitutionalContextError, ValueError) as exc: session.rollback() status_code = 409 if "conflict" in str(exc).casefold() else 400 raise HTTPException(status_code=status_code, detail=str(exc)) from exc return PortalServiceLaunchResponse(**result.to_dict()) __all__ = ["READ_SCOPE", "configure_registry", "router"]