"""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)