"""Business logic for leave wishes (family recurring home-together intent). See PLANNING_ENGINE_SPEC.md §2.1. This service owns the validation the allocator (Phase B) depends on: valid recurrence/parent_mode, sane month/day ranges, and the parent_mode <-> parent_personal_data_uuid pairing rule. """ from datetime import date, datetime from uuid import UUID, uuid4 from app.entities.leave_wish import LeaveWish from app.repositories.leave_wish_repository import LeaveWishRepository from app.repositories.planned_leave_repository import PlannedLeaveRepository from app.utils.errors import NotFoundError, ValidationError _VALID_RECURRENCE = {"yearly", "once"} _VALID_PARENT_MODE = {"either", "both", "specific"} _VALID_WINDOW_MODE = {"fixed", "flexible"} # A leap year, so day=29 for month=2 (Feb 29) is accepted when validating ranges. _LEAP_REFERENCE_YEAR = 2028 class LeaveWishService: def __init__( self, repo: LeaveWishRepository, planned_leave_repo: PlannedLeaveRepository, ) -> None: self._repo = repo self._planned_leave_repo = planned_leave_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", wants_dubbeldag: bool = False, ) -> LeaveWish: _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, ) now = datetime.now() entity = 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, recurrence=recurrence, first_year=first_year, last_year=last_year, priority=priority, parent_mode=parent_mode, parent_personal_data_uuid=parent_personal_data_uuid, window_mode=window_mode, wants_dubbeldag=wants_dubbeldag, created_at=now, updated_at=now, ) return await self._repo.create(entity) 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, _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. `_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. """ entity = await self.get(leave_wish_id, workspace_id) original_name = entity.name 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 recurrence is not None: entity.recurrence = recurrence if first_year is not None: entity.first_year = first_year if _clear_last_year: entity.last_year = None elif last_year is not None: entity.last_year = last_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 _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=entity.recurrence, 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, ) return await self._repo.update(leave_wish_id, entity) async def delete(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> None: """Delete a wish 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. """ await self.get(leave_wish_id, workspace_id) # raises NotFoundError if missing/foreign await self._planned_leave_repo.delete_by_wish(leave_wish_id) await self._repo.delete(leave_wish_id) 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") 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()`/`window_for_year()` 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