ac3b6a41d7
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.
324 lines
13 KiB
Python
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
|