Files
F-r-ldraledigplannerare/backend/app/services/leave_wish_service.py
T
jacobh2 ac3b6a41d7 Remove a period's days along with the period
Deleting a period left every day it had funded behind. The link from a day
back to its period was set to null the instant the period was deleted, so the
days became untraceable before anything could clean them up, and the next
recalculation had no way to know they no longer belonged to anything. Undo had
the same fault for the same reason: the period went, the days stayed, and a
reload showed them still there.

The days are now removed with the period, before the link can be broken. A day
pinned by hand goes too — pinning keeps the planner from quietly retyping a
day, not from being deleted when you delete the period it belongs to, which is
already how an unplanned band behaves.
2026-09-18 00:23:34 +02:00

324 lines
13 KiB
Python

"""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",
) -> 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,
)
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,
created_at=now,
updated_at=now,
)
return await self._repo.create(entity)
async def get(self, leave_wish_id: UUID) -> LeaveWish:
entity = await self._repo.get_by_id(leave_wish_id)
if entity is None:
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,
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,
_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)
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
_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,
)
return await self._repo.update(leave_wish_id, entity)
async def delete(self, leave_wish_id: UUID) -> 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) # raises NotFoundError if missing
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,
) -> 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}")
_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