from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.db.session import get_session from govoplan_dashboard.backend.db.models import DashboardLayout from govoplan_dashboard.backend.permissions import READ_SCOPE from govoplan_dashboard.backend.schemas import ( DashboardLayoutResponse, DashboardLayoutUpdateRequest, ) from govoplan_dashboard.backend.service import ( DashboardLayoutConflict, DashboardLayoutLimitExceeded, delete_dashboard_layout, get_dashboard_layout, normalize_view_id, save_dashboard_layout, ) router = APIRouter(prefix="/dashboard", tags=["dashboard"]) def _require_read(principal: ApiPrincipal) -> None: if not has_scope(principal, READ_SCOPE): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing required scope: {READ_SCOPE}", ) def _response( layout: DashboardLayout | None, *, view_id: str | None, ) -> DashboardLayoutResponse: if layout is None: return DashboardLayoutResponse( exists=False, view_id=normalize_view_id(view_id), ) return DashboardLayoutResponse( exists=True, view_id=layout.view_id, layout_version=layout.layout_version, revision=layout.revision, placements=layout.placements, known_widget_ids=layout.known_widget_ids, updated_at=layout.updated_at, ) @router.get("/layout", response_model=DashboardLayoutResponse) def api_get_dashboard_layout( view_id: str | None = Query(default=None, max_length=255), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> DashboardLayoutResponse: _require_read(principal) return _response( get_dashboard_layout( session, tenant_id=principal.tenant_id, account_id=principal.account_id, view_id=view_id, ), view_id=view_id, ) @router.put("/layout", response_model=DashboardLayoutResponse) def api_save_dashboard_layout( payload: DashboardLayoutUpdateRequest, view_id: str | None = Query(default=None, max_length=255), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> DashboardLayoutResponse: _require_read(principal) try: layout = save_dashboard_layout( session, tenant_id=principal.tenant_id, account_id=principal.account_id, view_id=view_id, expected_revision=payload.expected_revision, layout_version=payload.layout_version, placements=payload.placements, known_widget_ids=payload.known_widget_ids, ) session.commit() except DashboardLayoutConflict as exc: session.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc except DashboardLayoutLimitExceeded as exc: session.rollback() raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc), ) from exc except IntegrityError as exc: session.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( "The dashboard layout changed in another session. " "Reload it before saving." ), ) from exc return _response(layout, view_id=view_id) @router.delete("/layout", status_code=status.HTTP_204_NO_CONTENT) def api_delete_dashboard_layout( view_id: str | None = Query(default=None, max_length=255), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> None: _require_read(principal) delete_dashboard_layout( session, tenant_id=principal.tenant_id, account_id=principal.account_id, view_id=view_id, ) session.commit() __all__ = ["router"]