"""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_ENTITY, 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_ENTITY, str(exc)) from exc return PlanSyncResponse.from_domain(result)