Files
F-r-ldraledigplannerare/backend/app/services/leave_wish_service.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

276 lines
11 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/goal_override,
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.utils.errors import NotFoundError, ValidationError
_VALID_RECURRENCE = {"yearly", "once"}
_VALID_PARENT_MODE = {"either", "both", "specific"}
_VALID_GOAL_OVERRIDE = {"max_time", "max_money", "balanced"}
_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) -> None:
self._repo = 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,
recurrence: str = "once",
last_year: int | None = None,
priority: int = 1,
parent_mode: str = "either",
parent_personal_data_uuid: UUID | None = None,
goal_override: str | 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,
recurrence=recurrence,
first_year=first_year,
last_year=last_year,
parent_mode=parent_mode,
parent_personal_data_uuid=parent_personal_data_uuid,
goal_override=goal_override,
window_mode=window_mode,
)
now = datetime.now()
entity = LeaveWish(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
name=name,
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,
recurrence=recurrence,
first_year=first_year,
last_year=last_year,
priority=priority,
parent_mode=parent_mode,
parent_personal_data_uuid=parent_personal_data_uuid,
goal_override=goal_override,
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,
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,
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,
goal_override: str | 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_goal_override: 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.
"""
entity = await self.get(leave_wish_id)
if _clear_child_id:
entity.child_id = None
elif child_id is not None:
entity.child_id = child_id
if name is not None:
entity.name = name
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 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 _clear_goal_override:
entity.goal_override = None
elif goal_override is not None:
entity.goal_override = goal_override
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,
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,
goal_override=entity.goal_override,
window_mode=entity.window_mode,
)
return await self._repo.update(leave_wish_id, entity)
async def delete(self, leave_wish_id: UUID) -> None:
await self.get(leave_wish_id) # raises NotFoundError if missing
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,
recurrence: str,
first_year: int,
last_year: int | None,
parent_mode: str,
parent_personal_data_uuid: UUID | None,
goal_override: str | 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 goal_override is not None and goal_override not in _VALID_GOAL_OVERRIDE:
raise ValidationError(f"goal_override must be one of {_VALID_GOAL_OVERRIDE} or None")
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 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 target_weeks;
# "find a 6-week window inside this 2-week range" is a user-input
# error, not a legitimate future shortfall.
bound_days = _bound_length_days(
window_start_month, window_start_day, window_end_month, window_end_day, first_year
)
needed_days = target_weeks * 7
if bound_days < needed_days:
raise ValidationError(
"flexible wish bounding range "
f"({bound_days} days) is shorter than target_weeks "
f"({target_weeks} weeks = {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