"""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.day_assertion import DAY_TYPE_NONE from app.entities.leave_wish import LeaveWish, project_window from app.repositories.child_repository import ChildRepository from app.repositories.day_assertion_repository import DayAssertionRepository from app.repositories.leave_wish_repository import LeaveWishRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork 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, day_assertion_repo: DayAssertionRepository | None = None, uow: UnitOfWork | 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 # Optional, like `child_repo` above — `None` means existing # direct-construction call sites (older tests) keep today's # behaviour: `delete()` still removes every `planned_leave_day` row, # it just doesn't also clear the `DayAssertion` at each row's key. # See `delete()`'s docstring for why that cleanup matters now. self._day_assertion_repo = day_assertion_repo # `delete()`/`_materialise_series()` are each a multi-statement # sequence over possibly several rows (a series delete/re-projection # touches every occurrence's `planned_leave_day` rows, `DayAssertion` # rows, and the `LeaveWish` rows themselves) with no atomicity of # their own — this service has never had a `uow`, so a crash midway # left a partially-deleted/partially-reprojected series. Defaults to # a no-op so existing direct-construction call sites (tests, # `PlanningService.sync_with_wish`'s own nested call — nesting a # `unit_of_work()` inside one already open is a safe passthrough, # see `app.db.connection.unit_of_work`) keep working unchanged; # `app.dependencies.get_leave_wish_service` wires a real # `SQLiteUnitOfWork` for the standalone `/api/leave-wishes` router, # which previously had none at all. self._uow = uow or NoOpUnitOfWork() 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, ) async with self._uow(): 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, ) async with self._uow(): 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), every `planned_leave_day` row it produced, and any `DayAssertion` locking one of those rows in place. 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 pin state with no special-casing. **Restructure step 3b — the `DayAssertion` cleanup.** A pinned or promoted-and-locked row's assertion (`day_type` concrete — the 'attach'/'pin' day-lock actions, see `PlanningService._assert_pin`) is cleared at the same key, for every row this call deletes. Deliberately unconditional: nothing in the stored data distinguishes an assertion the user made independently and *later* attached to this wish ('attach') from one made *because* the day was already under this wish ('pin') — both look identical (concrete `day_type`, `wish_id` = this wish). Left alone, a surviving assertion would become invisible to the frontend the moment its mirrored `planned_leave_day` row is deleted above (nothing in the UI lists an assertion with no row behind it) while still silently pinning whatever new row a later sync/hand-pin happens to place at that same (workspace, child, parent, date) — a slot the user cannot see, let alone unlock, through the UI. Deleting it is the safe default: the user can trivially re-pin a day they still want locked, which is a far smaller cost than a permanently and invisibly stuck slot. A `day_type='none'` assertion (an exclusion) is the one exception — left untouched. An exclusion is never actually produced with this wish's `wish_id` by any current write path (a `planned_leave_day` row's `day_type` CHECK constraint doesn't allow `'none'`, so 'attach'/ 'pin' can never assert one), but even if one existed, "the planner may not place leave here" is a standing instruction about the DATE, not about this particular period — see the restructure notes' already-accepted "exclusions outlive the wish they were made against" behaviour (keyed without `wish_id` on purpose). `scope='series'` removes every year of the repeating wish this occurrence belongs to (and every occurrence's own rows/assertions, not just the clicked year's); `'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}") async with self._uow(): 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._clear_assertions_for(row.id) await self._planned_leave_repo.delete_by_wish(row.id) await self._repo.delete(row.id) async def _clear_assertions_for(self, wish_id: UUID) -> None: """Clear every `DayAssertion` attached to this wish — except a `'none'` (exclusion) assertion, which outlives the wish on purpose. See `delete()`'s docstring for the full reasoning. Adversarial-review fix: queries `DayAssertionRepository.list_by_wish` directly by `wish_id` rather than walking the wish's surviving `planned_leave_day` rows and clearing at each row's natural key. The row-walk was blind to a DANGLING assertion — one whose mirrored `planned_leave_day` row had already gone missing (e.g. a crash between `DayAssertionService`'s own assertion write and its mirror write) — which then outlived the wish as a `wish_id=NULL` ad-hoc assertion (FK `ON DELETE SET NULL`) that silently, permanently locked its date with no UI path to find it. Querying by `wish_id` catches that assertion regardless of whether a mirror row survives. """ if self._day_assertion_repo is None: return for assertion in await self._day_assertion_repo.list_by_wish(wish_id): if assertion.day_type != DAY_TYPE_NONE: await self._day_assertion_repo.delete_by_natural_key( assertion.workspace_id, assertion.child_id, assertion.parent_personal_data_uuid, assertion.date, ) 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