Files
govoplan-dashboard/src/govoplan_dashboard/backend/router.py
T
zemion ec240ed219
Module Package Release / publish-packages (push) Successful in 11s
feat: require explicit dashboard read permission
2026-08-24 01:15:42 +02:00

136 lines
4.2 KiB
Python

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"]