Files
F-r-ldraledigplannerare/backend/app/services/leave_wish_service.py
T
jacobh2 6d92b87bf3 Give every year of a repeating period its own row
A repeating period was a single row that the planner expanded into one
window per year at read time, so no year had anywhere to differ from the
others. Editing one year rewrote them all, and removing a year in the
middle had to split the wish in two. Neither survives contact with the
thing this app is for: Easter moves, and the cheapest week is not the
same week every year.

Each year is now its own row, linked by a series id. Editing one year
touches one row; editing the series re-projects the rest from it.
Removing a year is one delete. The weekday snap is worked out once, when
the rows are made, and stored, so a row now says what it means rather
than being re-derived on every read. Recurrence stops being a stored
field and becomes what it always was in practice — an instruction at
create and update time; whether a period repeats is whether it belongs
to a series.

Migration 004 rebuilds the table and expands the existing rows, keeping
each original row as its first year so the days already planned against
it still point somewhere. A Feb 29 anchor recurring into a common year
used to raise out of the allocator; it clamps now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 15:53:25 +02:00

602 lines
26 KiB
Python

"""Business logic for leave wishes (family home-together intent).
See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the
allocator depends on (valid parent_mode, sane month/day ranges, the
parent_mode <-> parent_personal_data_uuid pairing rule) **and** the
materialisation of a repeating wish into one row per year.
Materialisation happens HERE, on create/update — not lazily at plan time.
That is the whole point of the `series_id` shape: the rows have to be real
before the user can click one year's band and edit or remove just that year.
A lazy expansion would leave nothing for the calendar to list, nothing for
`PUT /api/leave-wishes/{id}` to address, and nowhere to store the per-year
offset the planner's "shift this window onto the red days" advice produces
(Easter moves, so that advice is genuinely per year).
`recurrence` is therefore no longer a stored field. It survives only as an
*instruction* on the create/update payloads: 'yearly' means "materialise a
series from this occurrence", 'once' means "this stands alone". What comes
back is always a concrete occurrence; `series_id` (non-NULL) is what tells a
caller the occurrence belongs to a repeating series.
"""
from datetime import date, datetime
from uuid import UUID, uuid4
from app.entities.leave_wish import LeaveWish, project_window
from app.repositories.child_repository import ChildRepository
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.utils.errors import NotFoundError, ValidationError
from app.utils.holidays import cached_year_bounds
_VALID_RECURRENCE = {"yearly", "once"}
_VALID_PARENT_MODE = {"either", "both", "specific"}
_VALID_WINDOW_MODE = {"fixed", "flexible"}
# An edit/delete either touches the clicked year only, or every year of the
# series it belongs to. On a standalone wish the two coincide.
SCOPE_OCCURRENCE = "occurrence"
SCOPE_SERIES = "series"
_VALID_SCOPE = {SCOPE_OCCURRENCE, SCOPE_SERIES}
# A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges.
_LEAP_REFERENCE_YEAR = 2028
# How far ahead a series is materialised when nothing else bounds it.
#
# The natural bound is the child's 4th birthday year — the app's whole premise
# (see `AnalyticsService.get_days_left_at_age_4`), and exactly what the
# allocator's own year expansion used before this change. A wish not tied to a
# child has no birthday to lean on, so it falls back to the allocator's
# childless horizon rule (`_compute_horizon_end`: four years out), counted from
# `first_year` rather than from the clock so the stored rows are deterministic.
#
# Everything is then clamped to `cached_year_bounds()` — planning past
# `swedish_calendar.json` raises `UncachedYearError`, so materialising rows the
# planner could never cost would only produce 422s. `_MAX_SERIES_LENGTH_YEARS`
# is a belt-and-braces row-count bound for the (impossible today) case of a
# missing calendar cache.
_CHILDLESS_YEARS_AHEAD = 4
_MAX_SERIES_LENGTH_YEARS = 30
class LeaveWishService:
def __init__(
self,
repo: LeaveWishRepository,
planned_leave_repo: PlannedLeaveRepository,
child_repo: ChildRepository | None = None,
) -> None:
self._repo = repo
self._planned_leave_repo = planned_leave_repo
# Optional so unit tests that only exercise single-occurrence paths can
# keep constructing the service with two repos. Without it, an
# open-ended ("until the child turns 4") series falls back to the
# childless horizon instead of the child's actual 4th birthday.
self._child_repo = child_repo
async def create(
self,
workspace_id: UUID,
name: str,
window_start_month: int,
window_start_day: int,
window_end_month: int,
window_end_day: int,
target_weeks: int,
first_year: int,
child_id: UUID | None = None,
name_message_key: str | None = None,
target_days: int | None = None,
recurrence: str = "once",
last_year: int | None = None,
priority: int = 1,
parent_mode: str = "either",
parent_personal_data_uuid: UUID | None = None,
window_mode: str = "fixed",
series_id: UUID | None = None,
wants_dubbeldag: bool = False,
) -> LeaveWish:
"""Create one occurrence, or a whole materialised series.
`recurrence='yearly'` mints a `series_id` and writes one row per year
from `first_year` through the resolved series end (see
`_resolve_series_end_year`). The returned entity is always the
`first_year` occurrence — the one whose window is exactly what the
caller asked for, and the one a caller watching "the wish it just
created" means.
`series_id` (only with `recurrence='once'`) re-attaches a single
occurrence to an EXISTING series in the same workspace — the undo of
a "remove just this year", which must put the year back as part of
the repeating wish rather than as a detached one-off. The series'
`last_year` intent is copied from its surviving rows so the mirror
stays consistent.
"""
_validate(
window_start_month=window_start_month,
window_start_day=window_start_day,
window_end_month=window_end_month,
window_end_day=window_end_day,
target_weeks=target_weeks,
target_days=target_days,
recurrence=recurrence,
first_year=first_year,
last_year=last_year,
parent_mode=parent_mode,
parent_personal_data_uuid=parent_personal_data_uuid,
window_mode=window_mode,
wants_dubbeldag=wants_dubbeldag,
)
repeats = recurrence == "yearly"
if series_id is not None and repeats:
raise ValidationError(
"series_id may not be combined with recurrence='yearly' "
"(a repeating wish mints its own series)"
)
if series_id is not None:
existing = await self._repo.get_by_series(series_id)
if not existing or existing[0].workspace_id != workspace_id:
raise ValidationError(f"series_id {series_id} is not a series in this workspace")
last_year = existing[0].last_year
repeats = True # it IS part of a series; it just doesn't create one
now = datetime.now()
anchor = LeaveWish(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
name=name,
name_message_key=name_message_key,
window_start_month=window_start_month,
window_start_day=window_start_day,
window_end_month=window_end_month,
window_end_day=window_end_day,
target_weeks=target_weeks,
target_days=target_days,
first_year=first_year,
last_year=last_year if repeats else None,
priority=priority,
parent_mode=parent_mode,
parent_personal_data_uuid=parent_personal_data_uuid,
window_mode=window_mode,
series_id=series_id if series_id is not None else (uuid4() if repeats else None),
wants_dubbeldag=wants_dubbeldag,
created_at=now,
updated_at=now,
)
created = await self._repo.create(anchor)
if repeats and series_id is None:
end_year = await self._resolve_series_end_year(child_id, first_year, last_year)
for year in range(first_year + 1, end_year + 1):
await self._repo.create(_project_occurrence(created, year))
return created
async def get(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> LeaveWish:
"""`workspace_id`, when given, must match the entity's own — callers
that already know the caller's tenant (routers) pass it so a foreign
workspace's wish 404s exactly like a nonexistent one; internal
callers that don't need that check (e.g. `update`'s own re-reads
with no tenant context) leave it `None`.
"""
entity = await self._repo.get_by_id(leave_wish_id)
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
raise NotFoundError(f"LeaveWish {leave_wish_id} not found")
return entity
async def list_for_workspace(self, workspace_id: UUID) -> list[LeaveWish]:
return await self._repo.get_by_workspace(workspace_id)
async def update(
self,
leave_wish_id: UUID,
workspace_id: UUID | None = None,
child_id: UUID | None = None,
name: str | None = None,
name_message_key: str | None = None,
window_start_month: int | None = None,
window_start_day: int | None = None,
window_end_month: int | None = None,
window_end_day: int | None = None,
target_weeks: int | None = None,
target_days: int | None = None,
recurrence: str | None = None,
first_year: int | None = None,
last_year: int | None = None,
priority: int | None = None,
parent_mode: str | None = None,
parent_personal_data_uuid: UUID | None = None,
window_mode: str | None = None,
wants_dubbeldag: bool | None = None,
scope: str = SCOPE_OCCURRENCE,
_clear_child_id: bool = False,
_clear_last_year: bool = False,
_clear_parent_personal_data_uuid: bool = False,
_clear_name_message_key: bool = False,
_clear_target_days: bool = False,
) -> LeaveWish:
"""Partial update — only non-None fields are applied.
`scope` decides how far the edit reaches:
* `'occurrence'` (default) — this year's row only. Siblings are not
touched, which is the point of materialising in the first place.
* `'series'` — every row sharing this row's `series_id`. The edited
row becomes the series' new anchor and every other year is
re-projected from it (`project_window`), so a fixed wish keeps its
weekday alignment across the series exactly as before.
`recurrence` is an instruction, not a field: `'yearly'` on a
standalone wish materialises a series around it; `'once'` on a series
occurrence keeps this row and deletes its siblings.
`_clear_*` flags exist because a nullable field's "no change" and
"set to None" both look like `None` on the wire; the router passes
them based on whether the field was present in the request payload.
`name_message_key` is additionally enforced here rather than left to
callers: whenever `name` actually changes, the key is force-cleared
regardless of what was passed for it, since a caller (frontend bug,
future API client, ...) forgetting to clear it would silently make a
user's edited name revert to a stale preset label.
`_clear_target_days` resets a day-granular wish back to being sized
by `target_weeks` alone.
"""
if scope not in _VALID_SCOPE:
raise ValidationError(f"scope must be one of {_VALID_SCOPE}")
if recurrence is not None and recurrence not in _VALID_RECURRENCE:
raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}")
entity = await self.get(leave_wish_id, workspace_id)
was_series = entity.series_id is not None
original_name = entity.name
# What the caller asked the row's repeat-ness to become. Absent
# `recurrence` means "leave it as it is".
will_repeat = was_series if recurrence is None else recurrence == "yearly"
touches_last_year = _clear_last_year or last_year is not None
if touches_last_year and will_repeat and scope != SCOPE_SERIES and was_series:
raise ValidationError(
"last_year is a property of the whole series; pass scope='series' to change it"
)
if _clear_child_id:
entity.child_id = None
elif child_id is not None:
entity.child_id = child_id
if _clear_name_message_key:
entity.name_message_key = None
elif name_message_key is not None:
entity.name_message_key = name_message_key
if name is not None:
entity.name = name
if name != original_name:
entity.name_message_key = None
if window_start_month is not None:
entity.window_start_month = window_start_month
if window_start_day is not None:
entity.window_start_day = window_start_day
if window_end_month is not None:
entity.window_end_month = window_end_month
if window_end_day is not None:
entity.window_end_day = window_end_day
if target_weeks is not None:
entity.target_weeks = target_weeks
if _clear_target_days:
entity.target_days = None
elif target_days is not None:
entity.target_days = target_days
if first_year is not None:
entity.first_year = first_year
if priority is not None:
entity.priority = priority
if parent_mode is not None:
entity.parent_mode = parent_mode
if _clear_parent_personal_data_uuid:
entity.parent_personal_data_uuid = None
elif parent_personal_data_uuid is not None:
entity.parent_personal_data_uuid = parent_personal_data_uuid
if window_mode is not None:
entity.window_mode = window_mode
if wants_dubbeldag is not None:
entity.wants_dubbeldag = wants_dubbeldag
# `last_year` is the series' intent, mirrored on every row. A
# standalone occurrence never carries one.
if will_repeat:
if _clear_last_year:
entity.last_year = None
elif last_year is not None:
entity.last_year = last_year
else:
entity.last_year = None
_validate(
window_start_month=entity.window_start_month,
window_start_day=entity.window_start_day,
window_end_month=entity.window_end_month,
window_end_day=entity.window_end_day,
target_weeks=entity.target_weeks,
target_days=entity.target_days,
recurrence="yearly" if will_repeat else "once",
first_year=entity.first_year,
last_year=entity.last_year,
parent_mode=entity.parent_mode,
parent_personal_data_uuid=entity.parent_personal_data_uuid,
window_mode=entity.window_mode,
wants_dubbeldag=entity.wants_dubbeldag,
)
siblings = (
[s for s in await self._repo.get_by_series(entity.series_id) if s.id != entity.id]
if entity.series_id is not None
else []
)
if was_series and not will_repeat:
# "Stop repeating": keep the clicked occurrence, drop the rest.
entity.series_id = None
await self._delete_rows(siblings)
return await self._repo.update(entity.id, entity)
if not will_repeat:
return await self._repo.update(entity.id, entity)
if not was_series:
# "Make this repeat": this occurrence becomes the series anchor.
entity.series_id = uuid4()
anchor = await self._repo.update(entity.id, entity)
if was_series and scope != SCOPE_SERIES:
# A single year of an existing series changed; siblings keep
# whatever the user (or the planner) had already given them.
return anchor
await self._materialise_series(anchor, siblings)
return anchor
async def delete(
self,
leave_wish_id: UUID,
workspace_id: UUID | None = None,
scope: str = SCOPE_OCCURRENCE,
) -> None:
"""Delete a wish occurrence (or a whole series) and every
`planned_leave_day` row it produced.
Under the calendar-direct-manipulation UX a period *is* its wish: the
band the user drew, the days the allocator filled it with, and any
of those days the user later hand-edited are all one thing. Deleting
the period must remove all of it — including pinned days.
`wish_id` is nullable and `ON DELETE SET NULL` at the schema level
(so a row can still exist detached/ad-hoc), but that "orphan, don't
delete" behavior is wrong for this call path: the rows are deleted
explicitly, *before* the wish row goes, so the FK's SET NULL never
gets a row left to act on. See `PlannedLeaveRepository.delete_by_wish`.
A pinned day is deleted too rather than surviving as ad-hoc. Pinning
only protects a day from being silently retyped/reassigned by a
later `sync_plan` re-plan (see that method's docstring) — it is not
a signal that the day should outlive the period a user explicitly
chose to delete. This also matches the ad-hoc-period delete path
(`Calendar.handleDeletePeriod`'s no-`wishId` branch), which already
deletes every day in the band regardless of `is_pinned` with no
special-casing.
`scope='series'` removes every year of the repeating wish this
occurrence belongs to; `'occurrence'` removes just this year's row,
which is now a plain delete rather than the range-narrowing (and
split-the-wish-in-two) contortion the single-row shape forced on the
frontend.
"""
if scope not in _VALID_SCOPE:
raise ValidationError(f"scope must be one of {_VALID_SCOPE}")
entity = await self.get(leave_wish_id, workspace_id)
targets = [entity]
if scope == SCOPE_SERIES and entity.series_id is not None:
targets = await self._repo.get_by_series(entity.series_id)
await self._delete_rows(targets)
# -- internals ---------------------------------------------------------
async def _delete_rows(self, rows: list[LeaveWish]) -> None:
for row in rows:
await self._planned_leave_repo.delete_by_wish(row.id)
await self._repo.delete(row.id)
async def _materialise_series(self, anchor: LeaveWish, siblings: list[LeaveWish]) -> None:
"""Make the rows of `anchor`'s series agree with `anchor`.
Every year in the series is re-projected from `anchor` — that is what
"apply this to every year" means, and it is the same projection the
old single-row shape ran at read time, just written down. Years past
the (possibly newly shortened) end are deleted; years newly inside it
are created. The anchor row itself is never touched here: it already
holds exactly the dates the user typed.
"""
assert anchor.series_id is not None # only called on a series row
end_year = await self._resolve_series_end_year(
anchor.child_id, anchor.first_year, anchor.last_year
)
start_year = min([anchor.first_year, *(s.first_year for s in siblings)])
wanted = set(range(start_year, end_year + 1))
by_year: dict[int, LeaveWish] = {}
stale: list[LeaveWish] = []
for sibling in siblings:
if sibling.first_year in wanted and sibling.first_year not in by_year:
by_year[sibling.first_year] = sibling
else:
# Out of range, or a duplicate year (only reachable if a
# `first_year` edit collided with an existing sibling).
stale.append(sibling)
await self._delete_rows(stale)
for year in sorted(wanted - {anchor.first_year}):
projected = _project_occurrence(anchor, year)
existing = by_year.get(year)
if existing is None:
await self._repo.create(projected)
continue
projected.id = existing.id
projected.created_at = existing.created_at
await self._repo.update(existing.id, projected)
async def _resolve_series_end_year(
self, child_id: UUID | None, first_year: int, last_year: int | None
) -> int:
"""Last anchor year a series is materialised for. See the module's
`_CHILDLESS_YEARS_AHEAD` comment for why these bounds and not others.
"""
if last_year is not None:
end_year = last_year
else:
end_year = first_year + _CHILDLESS_YEARS_AHEAD
if child_id is not None and self._child_repo is not None:
child = await self._child_repo.get_by_id(child_id)
if child is not None:
end_year = child.birth_date.year + 4
bounds = cached_year_bounds()
if bounds is not None:
end_year = min(end_year, bounds[1])
end_year = min(end_year, first_year + _MAX_SERIES_LENGTH_YEARS)
return max(end_year, first_year)
def _project_occurrence(anchor: LeaveWish, year: int) -> LeaveWish:
"""A brand-new row for `year`, carrying `anchor`'s projected window."""
start, end = project_window(
start_month=anchor.window_start_month,
start_day=anchor.window_start_day,
end_month=anchor.window_end_month,
end_day=anchor.window_end_day,
anchor_year=anchor.first_year,
year=year,
window_mode=anchor.window_mode,
)
now = datetime.now()
return LeaveWish(
id=uuid4(),
workspace_id=anchor.workspace_id,
child_id=anchor.child_id,
name=anchor.name,
name_message_key=anchor.name_message_key,
window_start_month=start.month,
window_start_day=start.day,
window_end_month=end.month,
window_end_day=end.day,
target_weeks=anchor.target_weeks,
target_days=anchor.target_days,
first_year=year,
last_year=anchor.last_year,
priority=anchor.priority,
parent_mode=anchor.parent_mode,
parent_personal_data_uuid=anchor.parent_personal_data_uuid,
window_mode=anchor.window_mode,
series_id=anchor.series_id,
wants_dubbeldag=anchor.wants_dubbeldag,
created_at=now,
updated_at=now,
)
def _validate(
*,
window_start_month: int,
window_start_day: int,
window_end_month: int,
window_end_day: int,
target_weeks: int,
target_days: int | None,
recurrence: str,
first_year: int,
last_year: int | None,
parent_mode: str,
parent_personal_data_uuid: UUID | None,
window_mode: str,
wants_dubbeldag: bool = False,
) -> None:
if recurrence not in _VALID_RECURRENCE:
raise ValidationError(f"recurrence must be one of {_VALID_RECURRENCE}")
if parent_mode not in _VALID_PARENT_MODE:
raise ValidationError(f"parent_mode must be one of {_VALID_PARENT_MODE}")
if window_mode not in _VALID_WINDOW_MODE:
raise ValidationError(f"window_mode must be one of {_VALID_WINDOW_MODE}")
if wants_dubbeldag and parent_mode != "both":
raise ValidationError("wants_dubbeldag may only be set when parent_mode is 'both'")
_validate_month_day(window_start_month, window_start_day, label="window_start")
_validate_month_day(window_end_month, window_end_day, label="window_end")
if target_weeks <= 0:
raise ValidationError("target_weeks must be > 0")
if target_days is not None and target_days < 1:
raise ValidationError("target_days must be >= 1")
# `last_year` is the series' end-year intent and is meaningless on a
# standalone occurrence; `create`/`update` normalise it to None there
# rather than rejecting it, so an older client that still sends one on a
# 'once' wish keeps working.
if last_year is not None and last_year < first_year:
raise ValidationError("last_year must be >= first_year")
if parent_mode == "specific" and parent_personal_data_uuid is None:
raise ValidationError(
"parent_personal_data_uuid is required when parent_mode is 'specific'"
)
if parent_mode != "specific" and parent_personal_data_uuid is not None:
raise ValidationError(
"parent_personal_data_uuid must not be set unless parent_mode is 'specific'"
)
if window_mode == "flexible":
# PLANNING_ENGINE_SPEC.md §10.1 — the bounding range's raw length
# (before any horizon clipping) must be able to fit the wanted
# duration; "find a 6-week window inside this 2-week range" is a
# user-input error, not a legitimate future shortfall. Mirrors
# `LeaveWish.target_span_days()`: `target_days` wins when set,
# otherwise `target_weeks * 7` (no entity exists yet in `create()`).
bound_days = _bound_length_days(
window_start_month, window_start_day, window_end_month, window_end_day, first_year
)
needed_days = target_days if target_days is not None else target_weeks * 7
if bound_days < needed_days:
raise ValidationError(
"flexible wish bounding range "
f"({bound_days} days) is shorter than the wanted duration "
f"({needed_days} days)"
)
def _validate_month_day(month: int, day: int, *, label: str) -> None:
if not (1 <= month <= 12):
raise ValidationError(f"{label}_month must be between 1 and 12")
if not (1 <= day <= 31):
raise ValidationError(f"{label}_day must be between 1 and 31")
try:
# Use a leap year as the reference so Feb 29 validates; this only
# checks the (month, day) combination is a real calendar date, not
# that it is valid in every year it might recur in.
date(_LEAP_REFERENCE_YEAR, month, day)
except ValueError as exc:
raise ValidationError(f"{label} is not a valid calendar date: {month}-{day}") from exc
def _bound_length_days(
start_month: int, start_day: int, end_month: int, end_day: int, anchor_year: int
) -> int:
"""Span (inclusive, in days) of a window/bound anchored at `anchor_year`.
Mirrors `LeaveWish.window_wraps()`/`LeaveWish.window()` without requiring
a full entity — used at validation time (§10.1), before an entity exists
in `create()`. `anchor_year` should be a real calendar year (not the leap
reference year `_validate_month_day` uses) so Feb 29 in a non-leap
`first_year` naturally raises via `date()`, consistent with
`_validate_month_day`'s own calendar-date check.
"""
wraps = (end_month, end_day) < (start_month, start_day)
start = date(anchor_year, start_month, start_day)
end_year = anchor_year + 1 if wraps else anchor_year
end = date(end_year, end_month, end_day)
return (end - start).days + 1