Files
F-r-ldraledigplannerare/backend/app/routers/plan.py
T
jacobh2 4d70c8950d Stop emitting deprecation warnings from our own backend code
`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>
2026-09-20 18:47:29 +02:00

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)