Files
F-r-ldraledigplannerare/backend/app/routers/plan.py
T
jacobh2 014503e89b Spend vacation on the days worth having, and name the ones about to expire
The planner knew exactly how many semesterdagar a parent was about to
forfeit and never placed a single day to prevent it. It also had no
weekend-wrap logic at all: REASON_VACATION_WRAP_WEEKEND was stamped on
every tier-3 interior vacation day, including a lone Tuesday in February.

Two changes.

Spend better. A workday adjacent to a free stretch, or in the middle of a
gap of three or fewer fenced by free days, now takes vacation --
_vacation_bridge_reason, consulted for every parent type. This departs
from spec 6.1's "wrapping suppressed for hourly": the FP-preservation
argument is salary-type independent, only the pay argument was hourly-moot.
In a both-parents window, both now take vacation on such a date rather
than one paying with FP. After funding, reallocate_vacation_by_value swaps
vacation onto higher-ranked dates 1-for-1 with FP days, so which dates hold
vacation improves while how many never drops -- greedy first, re-timed
after, never withheld. An invariant test asserts the count can only rise.

Name the losses. A new pass proposes concrete dates for vacation that would
otherwise expire, current year only, ranked red-day bridge then weekend
bridge then interior, later dates first. They ride on
PlanProposal.rescue_days, kept off `days` so nothing downstream commits
them, and surface on the Dashboard with accept-one, accept-all (one
batched /api/plan/apply, not N) and dismiss. Dismissal reuses the
tip_dismissal table, keyed on (parent, year) identity rather than the
group's dates -- those are recomputed from the whole plan's residual on
every propose, so date-hashing made dismissal self-defeating. Dismiss does
not bump the plan version; it changes no data and must not trigger a write.

Measured on the seeded demo: 12 forfeiting days for one parent now yield 12
proposed dates, all Fridays or Mondays. Total vacation placed is unchanged
at 203 days, confirming the reallocation only re-times.

Three defects here were found by driving a browser, while 2273 unit tests
passed -- all three lived in the seam between layers that unit tests mock
away. Adds API-level tests over the real request sequence. Rate limiting is
now off in tests (one file consumed the whole registration budget) with the
suite's first real 429 assertion.

PROPOSE_VS_SYNC_BUG.md documents a pre-existing, unfixed issue found along
the way: Dashboard figures swing on the first sync with no user action.

Backend 946 tests, frontend 1341, coverage above all gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZaX6xbuMEMXuyKA1fq2Mm
2026-09-21 07:00:18 +02:00

131 lines
5.8 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,
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_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)
@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