6d92b87bf3
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
602 lines
26 KiB
Python
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
|