Files
F-r-ldraledigplannerare/backend/app/routers/plan.py
T
jacobh2 6cd60887d5 Add a plan sync endpoint that recomputes, persists and reports the delta
The calendar is becoming the plan: a user edits a period and the schedule
updates immediately, with no separate propose and apply steps. POST
/api/plan/sync does both and returns a per-date diff against a snapshot taken
before any write, so the interface can say what actually moved rather than
guessing from coarse counters.

Pinned days now mean something. A day the user hand-edited keeps its type and
its parent: the loader hides unpinned wish-attributed rows from the allocator
so a re-plan is free to move them, while pinned rows stay visible and the
existing occupancy check refuses to touch them. Their cost is debited off the
pool before any window is funded, following the precedent already set for
preschool planning days.

Propose and apply are untouched. The allocator stays pure.
2026-09-17 17:58:47 +02:00

80 lines
3.4 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
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
) -> PlanProposalResponse:
"""Compute a plan proposal. Read-only — persists nothing, safe to spam."""
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_ENTITY, str(exc)) from exc
return PlanProposalResponse.from_domain(proposal)
@router.post("/apply", response_model=PlanApplyResponse)
async def apply_plan(payload: PlanApplyRequest, service: PlanningServiceDep) -> 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.
"""
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) -> 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.
"""
try:
result = await service.sync_plan(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_ENTITY, str(exc)) from exc
return PlanSyncResponse.from_domain(result)