4d70c8950d
`uv run pytest` reported 26 DeprecationWarnings, 20 of them raised by code we own. Two independent sources, both already renamed upstream. Starlette renamed HTTP_422_UNPROCESSABLE_ENTITY to HTTP_422_UNPROCESSABLE_CONTENT, matching the RFC 9110 name for the status. Rename all 21 call sites across the twelve routers that raise it. The numeric value is unchanged at 422, so the wire format and the frontend's error handling are untouched -- this is forward-compat debt only. The warnings attributed to fastapi/routing.py were these same handler frames raising through the dependency call, and go with them. Alembic >=1.16 supersedes `version_path_separator` with `path_separator`, which governs both `prepend_sys_path` and `version_locations`; its absence is what triggered the legacy-splitting warning on every config load. Swap the key in alembic.ini. Deliberately not fixing slowapi's `asyncio.iscoroutinefunction` warning (6 of the 26): it is upstream with no released fix, and no `filterwarnings` entry is added, so it stays visible as a reminder to drop the pin once one lands. Verified from backend/: ruff check and format clean, 893 passed with coverage at 92%, and the warnings summary is now exactly those 6 slowapi lines. `alembic upgrade head` runs 001 through 008 without the path_separator warning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
106 lines
4.7 KiB
Python
106 lines
4.7 KiB
Python
"""HTTP routes for the planning engine.
|
|
|
|
Routers stay thin: validate via Pydantic, call the service, map domain errors
|
|
to HTTP. All allocation logic lives in `app.services.allocation_service`, and
|
|
all bulk-apply logic (idempotency, collisions, `replace`, validation) lives in
|
|
`app.services.planning_service.PlanningService.apply_proposed_days`. See
|
|
PLANNING_ENGINE_SPEC.md §4.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, status
|
|
|
|
from app.dependencies import PlanningEngineServiceDep, PlanningServiceDep, WorkspaceScopeDep
|
|
from app.models.plan import (
|
|
PlanApplyRequest,
|
|
PlanApplyResponse,
|
|
PlanProposalResponse,
|
|
PlanProposeRequest,
|
|
PlanSyncRequest,
|
|
PlanSyncResponse,
|
|
)
|
|
from app.utils.errors import NotFoundError, ValidationError
|
|
|
|
router = APIRouter(prefix="/api/plan", tags=["plan"])
|
|
|
|
|
|
@router.post("/propose", response_model=PlanProposalResponse)
|
|
async def propose_plan(
|
|
payload: PlanProposeRequest, service: PlanningEngineServiceDep, scope: WorkspaceScopeDep
|
|
) -> PlanProposalResponse:
|
|
"""Compute a plan proposal. Read-only — persists nothing, safe to spam."""
|
|
scope.check_workspace(payload.workspace_id)
|
|
try:
|
|
proposal = await service.propose(payload.workspace_id)
|
|
except NotFoundError as exc:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
|
except ValidationError as exc:
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
|
|
return PlanProposalResponse.from_domain(proposal)
|
|
|
|
|
|
@router.post("/apply", response_model=PlanApplyResponse)
|
|
async def apply_plan(
|
|
payload: PlanApplyRequest, service: PlanningServiceDep, scope: WorkspaceScopeDep
|
|
) -> PlanApplyResponse:
|
|
"""Accept a (possibly user-edited) set of proposed days as real plan.
|
|
|
|
Always returns 200 with `accepted: false` and a `rejections`/`collisions`
|
|
breakdown when the batch is refused — refusal (a collision without
|
|
`replace`, an invalid day type, a balance/eligibility violation) is an
|
|
expected, structured outcome for the UI to render, not an HTTP error.
|
|
A genuinely missing workspace/child (404) is the one case still raised
|
|
as an HTTP error, since that's not something the batch report can explain.
|
|
"""
|
|
scope.check_workspace(payload.workspace_id)
|
|
# Every child/parent named in the batch must belong to the caller's own
|
|
# workspace too — otherwise a forged child_id/parent_personal_data_uuid
|
|
# could write a planned_leave_day row attributed to another tenant's
|
|
# child or parent even though workspace_id itself checks out.
|
|
for child_id in {item.child_id for item in payload.days}:
|
|
await scope.check_child(child_id)
|
|
for parent_id in {item.parent_personal_data_uuid for item in payload.days}:
|
|
await scope.check_member(parent_id)
|
|
try:
|
|
result = await service.apply_proposed_days(
|
|
workspace_id=payload.workspace_id,
|
|
days=[item.to_domain() for item in payload.days],
|
|
replace=payload.replace,
|
|
)
|
|
except NotFoundError as exc:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
|
return PlanApplyResponse.from_domain(result)
|
|
|
|
|
|
@router.post("/sync", response_model=PlanSyncResponse)
|
|
async def sync_plan(
|
|
payload: PlanSyncRequest, service: PlanningServiceDep, scope: WorkspaceScopeDep
|
|
) -> PlanSyncResponse:
|
|
"""Recompute and persist a workspace's plan in one step.
|
|
|
|
The direct-manipulation calendar UX's one call: no separate preview
|
|
step. Runs propose then apply internally (see
|
|
`PlanningService.sync_plan`), never retyping or overwriting a day the
|
|
user has pinned, and reports exactly what changed (`changes`) so the UI
|
|
can show e.g. "Sommar 2028 flyttades 2 dagar — Ångra" instead of
|
|
silently rewriting the calendar.
|
|
|
|
Doubles as its own eviction preview: when the re-derived plan would
|
|
silently delete another wish's days (a shared FP/vacation pool ran out
|
|
for that wish's parent), this call writes NOTHING and returns
|
|
`eviction_preview` describing what ran out and which days would go —
|
|
unless `eviction_choice` names one of the two caller-authorized
|
|
eviction strategies, in which case exactly that category is freed and
|
|
the edit is applied. No separate round trip for the common (fits-
|
|
within-budget) case; see `PlanningService.sync_plan`'s docstring.
|
|
"""
|
|
scope.check_workspace(payload.workspace_id)
|
|
try:
|
|
result = await service.sync_plan(
|
|
payload.workspace_id, eviction_choice=payload.eviction_choice
|
|
)
|
|
except NotFoundError as exc:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
|
except ValidationError as exc:
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, str(exc)) from exc
|
|
return PlanSyncResponse.from_domain(result)
|