Files
F-r-ldraledigplannerare/backend/app/services/planned_day_exclusion_service.py
T
jacobh2 8cfb9c82c6 Let a day removed from a period stay removed
Taking one day out of a period the planner placed did nothing that
lasted: the period still asked for a full week, so the next sync put the
day straight back. The option was disabled rather than lying about it.

A removed day is now recorded as its own thing — no leave for this
parent, this child, this date — and the planner honours it on every
later sync. It is kept apart from the working-day overrides, which
answer a different question about the calendar rather than about the
plan.

The day belongs to the parent whose band was clicked, so removing one
parent's day leaves their partner's alone; where both parents were to be
off on one funding arrangement, the partner is simply funded on their
own instead. A removed day shortens what the period is asking for rather
than counting as a day the plan failed to find, so the advice about
closing a shortfall does not start arguing for a day nobody wants.

Nothing is removed without a way back: the toast undoes it, and the day
itself is marked in the calendar and offers to restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 16:05:17 +02:00

133 lines
5.8 KiB
Python

"""Business logic for planned-day exclusions.
The service owns rules; the repository only persists. Routers call services,
not repositories directly. Mirrors `DayOverrideService`'s
upsert-by-natural-key shape: a workspace has at most one exclusion per
(child, parent, date).
**Why the parent is part of the key.** A both-parents period places one
`planned_leave_day` per parent on the same date, and the calendar draws one
band per parent. "Remove this day" is always clicked on ONE band, so it
removes ONE parent's day; keying the exclusion on (workspace, child, date)
alone would make removing a day from one parent's band silently cancel the
partner's leave that day too — a change the user never asked for and, on a
shared plan, one the other parent would discover only by noticing the hole.
The UI says whose day it removed (`calendar.periodDeleteConfirmDay` /
`calendar.toastDayExcluded` name the parent). A date only leaves a window
entirely when no responsible parent may take it — see
`_Ledger._excluded_window_dates`.
**Removing the day itself.** Creating an exclusion also deletes the matching
`planned_leave_day` row, if one exists, so "remove this day" is true the
moment the call returns rather than only after the next
`POST /api/plan/sync`. The subsequent sync (which the calendar runs anyway)
is what keeps the rest of the plan consistent; the allocator will not
re-derive this date, which is the whole point of the row.
"""
from datetime import date, datetime
from uuid import UUID, uuid4
from app.entities.planned_day_exclusion import PlannedDayExclusion
from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
from app.utils.errors import NotFoundError
class PlannedDayExclusionService:
def __init__(
self,
repo: PlannedDayExclusionRepository,
planned_leave_repo: PlannedLeaveRepository | None = None,
uow: UnitOfWork | None = None,
) -> None:
self._repo = repo
self._planned_leave_repo = planned_leave_repo
self._uow = uow or NoOpUnitOfWork()
async def exclude(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
day: date,
) -> PlannedDayExclusion:
"""Create (or return) the exclusion, and drop the day it removes.
Idempotent: excluding an already-excluded day returns the existing
row rather than raising, so a double click (or a retried request)
cannot produce a UNIQUE violation.
"""
async with self._uow():
existing = await self._repo.get_by_natural_key(
workspace_id, child_id, parent_personal_data_uuid, day
)
if existing is not None:
await self._delete_planned_day(
workspace_id, child_id, parent_personal_data_uuid, day
)
return existing
entity = PlannedDayExclusion(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
parent_personal_data_uuid=parent_personal_data_uuid,
date=day,
created_at=datetime.now(),
updated_at=datetime.now(),
)
created = await self._repo.create(entity)
await self._delete_planned_day(workspace_id, child_id, parent_personal_data_uuid, day)
return created
async def _delete_planned_day(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
day: date,
) -> None:
"""Remove the `planned_leave_day` this exclusion forbids, if it exists.
A pinned row is deleted too: the user just said, explicitly and for
this exact date, that they do not want leave here — that instruction
is newer than the pin. Undo removes the exclusion and re-syncs, which
restores the day as an ordinary planner-placed (unpinned) day.
"""
if self._planned_leave_repo is None:
return
rows = await self._planned_leave_repo.list_by_workspace_child(
workspace_id, child_id, from_date=day, to_date=day
)
ids = [r.id for r in rows if r.parent_personal_data_uuid == parent_personal_data_uuid]
if ids:
await self._planned_leave_repo.delete_many(ids)
async def get(
self, exclusion_id: UUID, workspace_id: UUID | None = None
) -> PlannedDayExclusion:
entity = await self._repo.get_by_id(exclusion_id)
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
raise NotFoundError(f"PlannedDayExclusion {exclusion_id} not found")
return entity
async def list_for_workspace(self, workspace_id: UUID) -> list[PlannedDayExclusion]:
return await self._repo.list_by_workspace(workspace_id)
async def delete(self, exclusion_id: UUID, workspace_id: UUID | None = None) -> None:
"""Un-exclude: the planner may place a day here again on the next sync."""
await self.get(exclusion_id, workspace_id) # raises NotFoundError if missing/foreign
await self._repo.delete(exclusion_id)
async def get_excluded_keys(self, workspace_id: UUID) -> frozenset[tuple[UUID, UUID, date]]:
"""Return {(parent_uuid, child_id, date)} for every exclusion in a workspace.
This is the shape `AllocationInputLoader` feeds into
`AllocationInput.excluded_days` — see `_Ledger._is_excluded`, the
single place it is consulted.
"""
rows = await self._repo.list_by_workspace(workspace_id)
return frozenset((r.parent_personal_data_uuid, r.child_id, r.date) for r in rows)