Files
F-r-ldraledigplannerare/backend/app/models/planned_leave.py
T
jacobh2 782415122d Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

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

57 lines
1.8 KiB
Python

"""API schemas (Pydantic) for PlannedLeaveDay.
Schemas validate request bodies and shape responses. Separate from domain entities
so the wire format can evolve independently.
"""
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel
from app.entities.planned_leave import PlannedLeaveDay
class PlannedLeaveDayResponse(BaseModel):
"""Read-only wire shape. `planned_leave_day` is purely derived output
now (restructure step 3b) — there is no `PlannedLeaveDayCreate`/`Update`
model any more; every write goes through `POST /api/plan/sync(-with-
wish)` or `POST /api/day-assertions`. `is_pinned` is NOT a column on the
entity (migration 010 dropped it) — it is computed live from
`DayAssertion` and passed in explicitly by the caller (see
`PlanningService._annotate_pinned`), so a lock icon in the calendar UI
keeps working unchanged even though nothing on `planned_leave_day`
itself carries that state any more.
"""
id: UUID
workspace_id: UUID
child_id: UUID
parent_personal_data_uuid: UUID
date: date
day_type: str
is_dubbeldag: bool
wish_id: UUID | None
is_pinned: bool
created_at: datetime | None
updated_at: datetime | None
@classmethod
def from_entity(
cls, entity: PlannedLeaveDay, is_pinned: bool = False
) -> "PlannedLeaveDayResponse":
return cls(
id=entity.id,
workspace_id=entity.workspace_id,
child_id=entity.child_id,
parent_personal_data_uuid=entity.parent_personal_data_uuid,
date=entity.date,
day_type=entity.day_type,
is_dubbeldag=entity.is_dubbeldag,
wish_id=entity.wish_id,
is_pinned=is_pinned,
created_at=entity.created_at,
updated_at=entity.updated_at,
)