147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
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"]
|