"""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, PlanSyncWithWishRequest, PlanSyncWithWishResponse, RescueDayDismissRequest, ) 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. When the re-derived plan would displace another wish's already-placed days (a shared FP/vacation pool ran out for that wish's parent), this call still writes — displacement is a normal, informational outcome, not a refusal. The displaced rows come back as 'removed' entries in `changes` with `evicted: true` (see `PlanChange`/`PlanChangeResponse`), so the UI can call them out distinctly instead of silently rewriting the calendar. See `PlanningService.sync_plan`'s docstring. """ scope.check_workspace(payload.workspace_id) 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_CONTENT, str(exc)) from exc return PlanSyncResponse.from_domain(result) @router.post("/sync-with-wish", response_model=PlanSyncWithWishResponse) async def sync_plan_with_wish( payload: PlanSyncWithWishRequest, service: PlanningServiceDep, scope: WorkspaceScopeDep ) -> PlanSyncWithWishResponse: """One atomic call: create-or-update the `LeaveWish` behind a calendar period, apply the day-lock/detach list, and sync — all in one unit of work (`PlanningService.sync_with_wish`). Replaces the client-orchestrated "create/patch the wish, then re-point or delete the affected planned days, then sync" 3-phase sequence (and the compensating rollback it needed) that used to back the calendar's promote/draw/edit/preset paths: a rejected sync now rolls back the wish write and every day-lock action with it, server-side, so the frontend never needs to undo a half-applied edit by hand. Same "always 200, `accepted: false` is a structured outcome" contract as `POST /api/plan/sync` — see that endpoint's docstring. The one case still raised as an HTTP error is a genuinely missing/foreign workspace, child, parent, wish, or `planned_day_id` (404), or invalid wish fields (422). """ scope.check_workspace(payload.workspace_id) if payload.wish_create is not None: if payload.wish_create.child_id is not None: await scope.check_child(payload.wish_create.child_id) if payload.wish_create.parent_personal_data_uuid is not None: await scope.check_member(payload.wish_create.parent_personal_data_uuid) if payload.wish_update is not None: patch = payload.wish_update.patch if patch.child_id is not None: await scope.check_child(patch.child_id) if patch.parent_personal_data_uuid is not None: await scope.check_member(patch.parent_personal_data_uuid) try: result = await service.sync_with_wish( workspace_id=payload.workspace_id, wish_create=payload.wish_create.to_domain() if payload.wish_create else None, wish_update=payload.wish_update.to_domain() if payload.wish_update else None, day_actions=[a.to_domain() for a in payload.day_actions], ) 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 PlanSyncWithWishResponse.from_domain(result) @router.post("/rescue-days/dismiss", status_code=status.HTTP_204_NO_CONTENT) async def dismiss_rescue_days( payload: RescueDayDismissRequest, service: PlanningEngineServiceDep, scope: WorkspaceScopeDep, ) -> None: """Dismiss a suggested vacation-rescue group (§vacation-rescue) so it stops appearing in `/propose`/`/sync` — until the underlying dates change, at which point it resurfaces (see `PlanningEngineService.dismiss_rescue_days`). """ scope.check_workspace(payload.workspace_id) # The parent must belong to the caller's own workspace too — otherwise a # foreign parent_personal_data_uuid could dismiss (or probe for) another # tenant's rescue-day suggestion even though workspace_id itself checks out. await scope.check_member(payload.parent_personal_data_uuid) try: await service.dismiss_rescue_days( payload.workspace_id, payload.parent_personal_data_uuid, payload.year ) except NotFoundError as exc: raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc