246 lines
8.1 KiB
Python
246 lines
8.1 KiB
Python
"""Board CRUD and snapshot endpoints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from sqlalchemy import func
|
|
from sqlmodel import col, select
|
|
|
|
from app.api.deps import (
|
|
get_board_for_actor_read,
|
|
get_board_for_user_read,
|
|
get_board_for_user_write,
|
|
require_org_admin,
|
|
require_org_member,
|
|
)
|
|
from app.core.time import utcnow
|
|
from app.db import crud
|
|
from app.db.pagination import paginate
|
|
from app.db.session import get_session
|
|
from app.models.board_groups import BoardGroup
|
|
from app.models.boards import Board
|
|
from app.models.gateways import Gateway
|
|
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
|
from app.schemas.common import OkResponse
|
|
from app.schemas.pagination import DefaultLimitOffsetPage
|
|
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
|
|
from app.services.board_group_snapshot import build_board_group_snapshot
|
|
from app.services.board_lifecycle import delete_board as delete_board_service
|
|
from app.services.board_snapshot import build_board_snapshot
|
|
from app.services.organizations import OrganizationContext, board_access_filter
|
|
|
|
if TYPE_CHECKING:
|
|
from fastapi_pagination.limit_offset import LimitOffsetPage
|
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
|
|
|
router = APIRouter(prefix="/boards", tags=["boards"])
|
|
|
|
SESSION_DEP = Depends(get_session)
|
|
ORG_ADMIN_DEP = Depends(require_org_admin)
|
|
ORG_MEMBER_DEP = Depends(require_org_member)
|
|
BOARD_USER_READ_DEP = Depends(get_board_for_user_read)
|
|
BOARD_USER_WRITE_DEP = Depends(get_board_for_user_write)
|
|
BOARD_ACTOR_READ_DEP = Depends(get_board_for_actor_read)
|
|
GATEWAY_ID_QUERY = Query(default=None)
|
|
BOARD_GROUP_ID_QUERY = Query(default=None)
|
|
INCLUDE_SELF_QUERY = Query(default=False)
|
|
INCLUDE_DONE_QUERY = Query(default=False)
|
|
PER_BOARD_TASK_LIMIT_QUERY = Query(default=5, ge=0, le=100)
|
|
|
|
|
|
async def _require_gateway(
|
|
session: AsyncSession,
|
|
gateway_id: object,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
) -> Gateway:
|
|
gateway = await crud.get_by_id(session, Gateway, gateway_id)
|
|
if gateway is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="gateway_id is invalid",
|
|
)
|
|
if organization_id is not None and gateway.organization_id != organization_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="gateway_id is invalid",
|
|
)
|
|
return gateway
|
|
|
|
|
|
async def _require_gateway_for_create(
|
|
payload: BoardCreate,
|
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> Gateway:
|
|
return await _require_gateway(
|
|
session,
|
|
payload.gateway_id,
|
|
organization_id=ctx.organization.id,
|
|
)
|
|
|
|
|
|
async def _require_board_group(
|
|
session: AsyncSession,
|
|
board_group_id: object,
|
|
*,
|
|
organization_id: UUID | None = None,
|
|
) -> BoardGroup:
|
|
group = await crud.get_by_id(session, BoardGroup, board_group_id)
|
|
if group is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="board_group_id is invalid",
|
|
)
|
|
if organization_id is not None and group.organization_id != organization_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="board_group_id is invalid",
|
|
)
|
|
return group
|
|
|
|
|
|
async def _require_board_group_for_create(
|
|
payload: BoardCreate,
|
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> BoardGroup | None:
|
|
if payload.board_group_id is None:
|
|
return None
|
|
return await _require_board_group(
|
|
session,
|
|
payload.board_group_id,
|
|
organization_id=ctx.organization.id,
|
|
)
|
|
|
|
|
|
GATEWAY_CREATE_DEP = Depends(_require_gateway_for_create)
|
|
BOARD_GROUP_CREATE_DEP = Depends(_require_board_group_for_create)
|
|
|
|
|
|
async def _apply_board_update(
|
|
*,
|
|
payload: BoardUpdate,
|
|
session: AsyncSession,
|
|
board: Board,
|
|
) -> Board:
|
|
updates = payload.model_dump(exclude_unset=True)
|
|
if "gateway_id" in updates:
|
|
await _require_gateway(
|
|
session,
|
|
updates["gateway_id"],
|
|
organization_id=board.organization_id,
|
|
)
|
|
if "board_group_id" in updates and updates["board_group_id"] is not None:
|
|
await _require_board_group(
|
|
session,
|
|
updates["board_group_id"],
|
|
organization_id=board.organization_id,
|
|
)
|
|
crud.apply_updates(board, updates)
|
|
if updates.get("board_type") == "goal" and (not board.objective or not board.success_metrics):
|
|
# Validate only when explicitly switching to goal boards.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="Goal boards require objective and success_metrics",
|
|
)
|
|
if not board.gateway_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="gateway_id is required",
|
|
)
|
|
board.updated_at = utcnow()
|
|
return await crud.save(session, board)
|
|
|
|
|
|
@router.get("", response_model=DefaultLimitOffsetPage[BoardRead])
|
|
async def list_boards(
|
|
gateway_id: UUID | None = GATEWAY_ID_QUERY,
|
|
board_group_id: UUID | None = BOARD_GROUP_ID_QUERY,
|
|
session: AsyncSession = SESSION_DEP,
|
|
ctx: OrganizationContext = ORG_MEMBER_DEP,
|
|
) -> LimitOffsetPage[BoardRead]:
|
|
"""List boards visible to the current organization member."""
|
|
statement = select(Board).where(board_access_filter(ctx.member, write=False))
|
|
if gateway_id is not None:
|
|
statement = statement.where(col(Board.gateway_id) == gateway_id)
|
|
if board_group_id is not None:
|
|
statement = statement.where(col(Board.board_group_id) == board_group_id)
|
|
statement = statement.order_by(
|
|
func.lower(col(Board.name)).asc(),
|
|
col(Board.created_at).desc(),
|
|
)
|
|
return await paginate(session, statement)
|
|
|
|
|
|
@router.post("", response_model=BoardRead)
|
|
async def create_board(
|
|
payload: BoardCreate,
|
|
_gateway: Gateway = GATEWAY_CREATE_DEP,
|
|
_board_group: BoardGroup | None = BOARD_GROUP_CREATE_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
ctx: OrganizationContext = ORG_ADMIN_DEP,
|
|
) -> Board:
|
|
"""Create a board in the active organization."""
|
|
data = payload.model_dump()
|
|
data["organization_id"] = ctx.organization.id
|
|
return await crud.create(session, Board, **data)
|
|
|
|
|
|
@router.get("/{board_id}", response_model=BoardRead)
|
|
def get_board(
|
|
board: Board = BOARD_USER_READ_DEP,
|
|
) -> Board:
|
|
"""Get a board by id."""
|
|
return board
|
|
|
|
|
|
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
|
|
async def get_board_snapshot(
|
|
board: Board = BOARD_ACTOR_READ_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> BoardSnapshot:
|
|
"""Get a board snapshot view model."""
|
|
return await build_board_snapshot(session, board)
|
|
|
|
|
|
@router.get("/{board_id}/group-snapshot", response_model=BoardGroupSnapshot)
|
|
async def get_board_group_snapshot(
|
|
*,
|
|
include_self: bool = INCLUDE_SELF_QUERY,
|
|
include_done: bool = INCLUDE_DONE_QUERY,
|
|
per_board_task_limit: int = PER_BOARD_TASK_LIMIT_QUERY,
|
|
board: Board = BOARD_ACTOR_READ_DEP,
|
|
session: AsyncSession = SESSION_DEP,
|
|
) -> BoardGroupSnapshot:
|
|
"""Get a grouped snapshot across related boards."""
|
|
return await build_board_group_snapshot(
|
|
session,
|
|
board=board,
|
|
include_self=include_self,
|
|
include_done=include_done,
|
|
per_board_task_limit=per_board_task_limit,
|
|
)
|
|
|
|
|
|
@router.patch("/{board_id}", response_model=BoardRead)
|
|
async def update_board(
|
|
payload: BoardUpdate,
|
|
session: AsyncSession = SESSION_DEP,
|
|
board: Board = BOARD_USER_WRITE_DEP,
|
|
) -> Board:
|
|
"""Update mutable board properties."""
|
|
return await _apply_board_update(payload=payload, session=session, board=board)
|
|
|
|
|
|
@router.delete("/{board_id}", response_model=OkResponse)
|
|
async def delete_board(
|
|
session: AsyncSession = SESSION_DEP,
|
|
board: Board = BOARD_USER_WRITE_DEP,
|
|
) -> OkResponse:
|
|
"""Delete a board and all dependent records."""
|
|
return await delete_board_service(session, board=board)
|