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
This commit is contained in:
2026-09-19 16:05:17 +02:00
parent 6d92b87bf3
commit 8cfb9c82c6
39 changed files with 2362 additions and 31 deletions
+19
View File
@@ -180,6 +180,23 @@ CREATE TABLE IF NOT EXISTS day_override (
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE
);
-- "The planner may not place leave for THIS parent, for THIS child, on THIS
-- date" — see app/entities/planned_day_exclusion.py for why this is its own
-- table and not another day_override.
CREATE TABLE IF NOT EXISTS planned_day_exclusion (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
child_id TEXT NOT NULL,
parent_personal_data_uuid TEXT NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(workspace_id, child_id, parent_personal_data_uuid, date),
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE,
FOREIGN KEY (parent_personal_data_uuid) REFERENCES personal_data(uuid) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tip_dismissal (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
@@ -225,6 +242,8 @@ CREATE INDEX IF NOT EXISTS idx_invitation_invited_by
ON workspace_invitation (invited_by_user_id);
CREATE INDEX IF NOT EXISTS idx_vacation_days_workspace
ON vacation_days (workspace_id);
CREATE INDEX IF NOT EXISTS idx_planned_day_exclusion_workspace
ON planned_day_exclusion (workspace_id);
"""
+33
View File
@@ -27,6 +27,7 @@ from app.repositories.day_override_repository import DayOverrideRepository
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.tip_dismissal_repository import TipDismissalRepository
from app.repositories.unit_of_work import UnitOfWork
@@ -41,6 +42,9 @@ from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverri
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
SQLitePlannedDayExclusionRepository,
)
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository
from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork
@@ -58,6 +62,7 @@ from app.services.child_service import ChildService
from app.services.day_override_service import DayOverrideService
from app.services.leave_wish_service import LeaveWishService
from app.services.personal_data_service import PersonalDataService
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
from app.services.planning_service import PlanningService
from app.services.tips_service import TipsService
from app.services.vacation_days_service import VacationDaysService
@@ -184,6 +189,17 @@ def get_leave_wish_repository(conn: ConnectionDep) -> LeaveWishRepository:
LeaveWishRepositoryDep = Annotated[LeaveWishRepository, Depends(get_leave_wish_repository)]
def get_planned_day_exclusion_repository(conn: ConnectionDep) -> PlannedDayExclusionRepository:
if settings.database_type == "sqlite":
return SQLitePlannedDayExclusionRepository(conn)
raise ValueError(f"Unsupported database_type: {settings.database_type}")
PlannedDayExclusionRepositoryDep = Annotated[
PlannedDayExclusionRepository, Depends(get_planned_day_exclusion_repository)
]
def get_day_override_repository(conn: ConnectionDep) -> DayOverrideRepository:
if settings.database_type == "sqlite":
return SQLiteDayOverrideRepository(conn)
@@ -246,6 +262,21 @@ def get_day_override_service(repo: DayOverrideRepositoryDep) -> DayOverrideServi
DayOverrideServiceDep = Annotated[DayOverrideService, Depends(get_day_override_service)]
def get_planned_day_exclusion_service(
repo: PlannedDayExclusionRepositoryDep,
planned_leave_repo: PlannedLeaveRepositoryDep,
uow: UnitOfWorkDep,
) -> PlannedDayExclusionService:
# `planned_leave_repo` is what makes "remove this day" true immediately
# rather than only after the next sync — see the service's docstring.
return PlannedDayExclusionService(repo, planned_leave_repo, uow=uow)
PlannedDayExclusionServiceDep = Annotated[
PlannedDayExclusionService, Depends(get_planned_day_exclusion_service)
]
def get_calendar_service(day_override_service: DayOverrideServiceDep) -> CalendarService:
return CalendarService(day_override_service)
@@ -339,6 +370,7 @@ def get_allocation_input_loader(
planned_leave_repo: PlannedLeaveRepositoryDep,
leave_wish_repo: LeaveWishRepositoryDep,
day_override_repo: DayOverrideRepositoryDep,
exclusion_repo: PlannedDayExclusionRepositoryDep,
) -> AllocationInputLoader:
return AllocationInputLoader(
workspace_repo=workspace_repo,
@@ -349,6 +381,7 @@ def get_allocation_input_loader(
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
day_override_repo=day_override_repo,
exclusion_repo=exclusion_repo,
)
@@ -0,0 +1,40 @@
"""Domain entity: PlannedDayExclusion.
"The planner may not place leave for THIS parent, for THIS child, on THIS
date." One row is one user instruction, recorded when the user removes a
single day out of a planner-placed period ("this day" in the calendar's
three-way remove). Without it, the next `POST /api/plan/sync` would simply
re-derive the day from the wish — the wish still says "a full week", and
nothing anywhere said that one date was off the table.
Deliberately NOT modelled as a `DayOverride`. A `DayOverride` answers a
*calendar* question ("is this date a working day for this workspace?"), is
workspace-wide, and is applied inside `is_free_day`. This answers a
*planning* question ("may a leave day be placed here?"), is per parent and
per child, and is applied where a window is funded. Folding the second into
the first would mean a removed Tuesday silently became a non-working day for
everyone and everything, analytics included.
Keyed per parent (see `PlannedDayExclusionService`): a both-parents period
places one day per parent on the same date, and removing one parent's day
must not silently remove the other's. The allocator only drops the date from
a window entirely when no responsible parent may take it — see
`_Ledger._excluded_window_dates`.
"""
from dataclasses import dataclass
from datetime import date, datetime
from uuid import UUID
@dataclass
class PlannedDayExclusion:
"""One (workspace, child, parent, date) the planner must leave empty."""
id: UUID
workspace_id: UUID
child_id: UUID
parent_personal_data_uuid: UUID
date: date
created_at: datetime | None = None
updated_at: datetime | None = None
+2
View File
@@ -28,6 +28,7 @@ from app.routers.fetch_data import router as fetch_data_router
from app.routers.fetch_data import scraper_router
from app.routers.leave_wishes import router as leave_wishes_router
from app.routers.plan import router as plan_router
from app.routers.planned_day_exclusions import router as planned_day_exclusions_router
from app.routers.planned_days import router as planned_days_router
from app.routers.settings import child_router, settings_child_router, tips_router
from app.routers.workspace import _inv_router as invitations_router
@@ -126,6 +127,7 @@ def _register_routers(app: FastAPI) -> None:
app.include_router(child_router)
app.include_router(settings_child_router)
app.include_router(planned_days_router)
app.include_router(planned_day_exclusions_router)
app.include_router(vacation_days.router)
app.include_router(day_overrides_router)
app.include_router(calendar_router)
@@ -0,0 +1,42 @@
"""API schemas (Pydantic) for PlannedDayExclusion.
Schemas are the API boundary — they validate request bodies and shape
responses, kept separate from the domain entity so the wire format can
evolve independently.
"""
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel
from app.entities.planned_day_exclusion import PlannedDayExclusion
class PlannedDayExclusionCreate(BaseModel):
workspace_id: UUID
child_id: UUID
parent_personal_data_uuid: UUID
date: date
class PlannedDayExclusionResponse(BaseModel):
id: UUID
workspace_id: UUID
child_id: UUID
parent_personal_data_uuid: UUID
date: date
created_at: datetime | None
updated_at: datetime | None
@classmethod
def from_entity(cls, entity: PlannedDayExclusion) -> "PlannedDayExclusionResponse":
return cls(
id=entity.id,
workspace_id=entity.workspace_id,
child_id=entity.child_id,
parent_personal_data_uuid=entity.parent_personal_data_uuid,
date=entity.date,
created_at=entity.created_at,
updated_at=entity.updated_at,
)
@@ -0,0 +1,28 @@
"""Abstract repository interface for PlannedDayExclusion.
Adds entity-specific queries on top of the generic CRUD contract. Concrete
implementations live in app/repositories_impl/<backend>/.
"""
from abc import abstractmethod
from datetime import date
from uuid import UUID
from app.entities.planned_day_exclusion import PlannedDayExclusion
from app.repositories.base import Repository
class PlannedDayExclusionRepository(Repository[PlannedDayExclusion]):
@abstractmethod
async def get_by_natural_key(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
day: date,
) -> PlannedDayExclusion | None:
"""Return the entry for a given (workspace, child, parent, date) (UNIQUE), or None."""
@abstractmethod
async def list_by_workspace(self, workspace_id: UUID) -> list[PlannedDayExclusion]:
"""Return all exclusions for a workspace, ordered by date."""
@@ -0,0 +1,133 @@
"""SQLite implementation of PlannedDayExclusionRepository.
Follows the SQLiteDayOverrideRepository reference shape: map rows <-> entities
explicitly in `_row_to_entity`, always parameterize queries, raise
NotFoundError from update/delete when the row is missing.
"""
from datetime import date, datetime
from uuid import UUID
import aiosqlite
from app.db.connection import commit
from app.entities.planned_day_exclusion import PlannedDayExclusion
from app.repositories.filter_validator import validate_filters
from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository
from app.utils.errors import NotFoundError
class SQLitePlannedDayExclusionRepository(PlannedDayExclusionRepository):
def __init__(self, connection: aiosqlite.Connection) -> None:
self._conn = connection
@staticmethod
def _row_to_entity(row: aiosqlite.Row) -> PlannedDayExclusion:
return PlannedDayExclusion(
id=UUID(row["id"]),
workspace_id=UUID(row["workspace_id"]),
child_id=UUID(row["child_id"]),
parent_personal_data_uuid=UUID(row["parent_personal_data_uuid"]),
date=date.fromisoformat(row["date"]),
created_at=datetime.fromisoformat(row["created_at"]),
updated_at=datetime.fromisoformat(row["updated_at"]),
)
async def create(self, item: PlannedDayExclusion) -> PlannedDayExclusion:
now = datetime.now()
item.created_at = item.created_at or now
item.updated_at = now
await self._conn.execute(
"""
INSERT INTO planned_day_exclusion
(id, workspace_id, child_id, parent_personal_data_uuid, date,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
str(item.id),
str(item.workspace_id),
str(item.child_id),
str(item.parent_personal_data_uuid),
item.date.isoformat(),
item.created_at.isoformat(),
item.updated_at.isoformat(),
),
)
await commit(self._conn)
return item
async def get_by_id(self, id: UUID) -> PlannedDayExclusion | None:
cursor = await self._conn.execute(
"SELECT * FROM planned_day_exclusion WHERE id = ?", (str(id),)
)
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def get_by_natural_key(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
day: date,
) -> PlannedDayExclusion | None:
cursor = await self._conn.execute(
"""
SELECT * FROM planned_day_exclusion
WHERE workspace_id = ? AND child_id = ?
AND parent_personal_data_uuid = ? AND date = ?
""",
(
str(workspace_id),
str(child_id),
str(parent_personal_data_uuid),
day.isoformat(),
),
)
row = await cursor.fetchone()
return self._row_to_entity(row) if row else None
async def update(self, id: UUID, item: PlannedDayExclusion) -> PlannedDayExclusion:
"""Only `date` is mutable; the rest of the row IS its identity.
Kept for the `Repository[T]` contract — the service never calls it
(an exclusion is created or deleted, never edited in place).
"""
item.updated_at = datetime.now()
cursor = await self._conn.execute(
"UPDATE planned_day_exclusion SET date = ?, updated_at = ? WHERE id = ?",
(item.date.isoformat(), item.updated_at.isoformat(), str(id)),
)
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PlannedDayExclusion {id} not found")
refreshed = await self.get_by_id(id)
assert refreshed is not None # just updated
return refreshed
async def delete(self, id: UUID) -> None:
cursor = await self._conn.execute(
"DELETE FROM planned_day_exclusion WHERE id = ?", (str(id),)
)
await commit(self._conn)
if cursor.rowcount == 0:
raise NotFoundError(f"PlannedDayExclusion {id} not found")
async def list(self, **filters: object) -> list[PlannedDayExclusion]:
validate_filters(filters, {"workspace_id", "child_id", "parent_personal_data_uuid"})
clauses: list[str] = []
params: list[object] = []
for column in ("workspace_id", "child_id", "parent_personal_data_uuid"):
if column in filters and filters[column] is not None:
value = filters[column]
clauses.append(f"{column} = ?")
params.append(str(value) if isinstance(value, UUID) else value)
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
cursor = await self._conn.execute(
f"SELECT * FROM planned_day_exclusion{where} ORDER BY date ASC", params
)
rows = await cursor.fetchall()
return [self._row_to_entity(row) for row in rows]
async def list_by_workspace(self, workspace_id: UUID) -> list[PlannedDayExclusion]:
return await self.list(workspace_id=workspace_id)
@@ -0,0 +1,64 @@
"""HTTP routes for planned-day exclusions.
Routers stay thin: validate via Pydantic, call the service, map domain
errors to HTTP.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import (
PlannedDayExclusionServiceDep,
WorkspaceIdDep,
WorkspaceScopeDep,
)
from app.models.planned_day_exclusion import (
PlannedDayExclusionCreate,
PlannedDayExclusionResponse,
)
from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(prefix="/api/planned-day-exclusions", tags=["planned-day-exclusions"])
@router.post("", response_model=PlannedDayExclusionResponse, status_code=status.HTTP_201_CREATED)
async def create_planned_day_exclusion(
payload: PlannedDayExclusionCreate,
service: PlannedDayExclusionServiceDep,
scope: WorkspaceScopeDep,
) -> PlannedDayExclusionResponse:
scope.check_workspace(payload.workspace_id)
await scope.check_child(payload.child_id)
await scope.check_member(payload.parent_personal_data_uuid)
try:
entity = await service.exclude(
workspace_id=payload.workspace_id,
child_id=payload.child_id,
parent_personal_data_uuid=payload.parent_personal_data_uuid,
day=payload.date,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return PlannedDayExclusionResponse.from_entity(entity)
@router.get("/workspace/{workspace_id}", response_model=list[PlannedDayExclusionResponse])
async def list_planned_day_exclusions(
workspace_id: WorkspaceIdDep, service: PlannedDayExclusionServiceDep
) -> list[PlannedDayExclusionResponse]:
entities = await service.list_for_workspace(workspace_id)
return [PlannedDayExclusionResponse.from_entity(e) for e in entities]
@router.delete("/{exclusion_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_planned_day_exclusion(
exclusion_id: UUID,
service: PlannedDayExclusionServiceDep,
scope: WorkspaceScopeDep,
) -> None:
"""Un-exclude the day: the planner may place leave here again."""
try:
await service.delete(exclusion_id, workspace_id=scope.workspace_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
+137 -2
View File
@@ -32,6 +32,7 @@ from app.repositories.day_override_repository import DayOverrideRepository
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_day_exclusion_repository import PlannedDayExclusionRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.vacation_days_repository import VacationDaysRepository
from app.repositories.workspace_repository import WorkspaceRepository
@@ -267,6 +268,19 @@ class AllocationInput:
# (the wish was reassigned) — matched by `wish_id`, not by parent, for
# exactly that reason. Never used for resource netting or `_existing_lookup`.
all_existing_planned: list[PlannedLeaveDay] | None = None
# §remove-this-day — every (parent_uuid, child_id, date) the user has
# explicitly removed from the plan (see `PlannedDayExclusion`). The
# allocator must never place leave on one of these, however emphatically
# the wish covering it asks: "removing a single day stays permanent, even
# on an auto-added period day". Empty by default, so every hand-built
# `AllocationInput` in the test suite keeps producing byte-identical
# output. Read only via `_Ledger._is_excluded` /
# `_Ledger._excluded_window_dates`; deliberately NOT merged into
# `day_overrides`/`is_free_day`, which answer the unrelated *calendar*
# question "is this date a working day for this workspace" (a removed
# Tuesday is still a working Tuesday, for analytics and for everyone
# else in the workspace).
excluded_days: frozenset[tuple[UUID, UUID, date]] = frozenset()
# ---------------------------------------------------------------------------
@@ -929,6 +943,78 @@ class _Ledger:
# -- resource spending ------------------------------------------------
# -- user-removed days (§remove-this-day) ------------------------------
def _is_excluded(self, parent_uuid: UUID, child_id: UUID | None, d: date) -> bool:
"""Whether the user removed THIS date from THIS parent's plan.
The single reader of `AllocationInput.excluded_days`. A wish with no
resolved child (`child_id is None`) can never match: an exclusion is
always recorded from a real `planned_leave_day`, which always has a
child.
"""
if child_id is None or not self._input.excluded_days:
return False
return (parent_uuid, child_id, d) in self._input.excluded_days
def _excluded_window_dates(
self, wish: LeaveWish, responsible: _Responsible, dates: list[date]
) -> set[date]:
"""Dates that leave a window ENTIRELY because no responsible parent may take them.
The exclusion itself is per parent (see `PlannedDayExclusionService`
for why), but what a *window* should do about it depends on how many
parents that window puts on leave per date:
- 'dubbeldag' (both parents off on the same dates): removing one
parent's day must leave the partner's standing, so a date only
drops out when BOTH parents were removed from it. The single-parent
case is handled inside `_fund_both`/`_fund_solo`, which skip just
the excluded parent.
- 'solo'/'swap' (exactly one parent on leave per date): ANY
responsible parent's exclusion empties the date. Without the `any`,
`_fund_swap`'s leftover hand-off would hand the removed date
straight to the partner — silently refilling the hole the user just
made, under a different name, which is precisely the "it came
back" failure this whole feature exists to prevent.
Dropping these dates from the list `fund_window` funds (and from the
list it measures) is also the shortfall decision: a day the user
removed reduces the window's target rather than counting as a day the
plan failed to fund. It is not a gap the planner could do anything
about, `_build_remedies` must not propose freeing resources for it,
and `sync_plan`'s eviction preview (which fires on a stale row whose
own wish still reports `shortfall_days > 0`) must not mistake a
deliberate removal for a lost priority race.
"""
if not self._input.excluded_days or not responsible.parents:
return set()
child = self._resolve_wish_child(wish)
if child is None:
return set()
quantifier = all if responsible.mode == "dubbeldag" else any
return {
d
for d in dates
if quantifier(self._is_excluded(p.uuid, child.id, d) for p in responsible.parents)
}
def _window_dates(
self, wish: LeaveWish, responsible: _Responsible, start: date, end: date
) -> list[date]:
"""`[start, end]` minus the dates the user removed from it.
Shared by `fund_window` (the real placement) and
`_candidate_shortfall` (§Decision C's budget-aware probe) so the two
keep measuring the same window — see `_shortfall_and_weeks_funded`'s
docstring for why they must never silently disagree.
"""
all_dates = _dates_in(start, end)
excluded = self._excluded_window_dates(wish, responsible, all_dates)
if not excluded:
return all_dates
return [d for d in all_dates if d not in excluded]
def _spend_vacation(self, parent_uuid: UUID, d: date) -> bool:
key = (parent_uuid, d.year)
budget = self._vacation.get(key)
@@ -1079,6 +1165,17 @@ class _Ledger:
pool_key = (parent.uuid, child_id) if child_id is not None else None
for d in dates:
if self._is_excluded(parent.uuid, child_id, d):
# §remove-this-day — the user removed this exact date for
# this exact parent. Never re-derive it, and emit nothing at
# all (not even a 'free' day): `fund_window`/
# `_candidate_shortfall` have already taken the date out of
# the window's target wherever no responsible parent may take
# it, so it is neither funded nor a gap. This is also what
# stops `_fund_swap`'s leftover hand-off from handing a
# parent a date they were removed from.
continue
key = (parent.uuid, child_id, d)
existing_type = self._existing_lookup.get(key)
if existing_type is not None:
@@ -1336,6 +1433,10 @@ class _Ledger:
else REASON_FREE_WEEKEND
)
for red_parent, red_pool in ((parent_a, pool_a), (parent_b, pool_b)):
if self._is_excluded(red_parent.uuid, child.id, d):
# Removed for this parent — the partner still gets
# their free day for this date.
continue
fp_on_red_day = (
self._spend_fp((red_parent.uuid, child.id), prefer="sjukpenning")
if self._input.take_pl_on_red_days and red_pool is not None
@@ -1363,6 +1464,19 @@ class _Ledger:
)
continue
if self._is_excluded(parent_a.uuid, child.id, d) or self._is_excluded(
parent_b.uuid, child.id, d
):
# §remove-this-day — one parent was removed from this date
# (never both: `fund_window` already dropped those dates from
# `dates` entirely). Dubbeldagar and co-leave each spend for
# BOTH parents, so neither can fund a date only one parent may
# take. Hand it to the swap path, which funds it for whichever
# parent is still allowed to have it — `_fund_solo` skips the
# excluded one — so the partner keeps their day.
leftover_for_swap.append(d)
continue
funded = False
if (
@@ -1473,7 +1587,13 @@ class _Ledger:
wish = w.wish
responsible = self._resolve_parents(wish)
all_dates = _dates_in(w.start, w.end)
# §remove-this-day — the window's dates minus the ones the user
# removed outright (see `_excluded_window_dates`, which also explains
# why an excluded date shrinks the target instead of counting as a
# shortfall). The red-day cluster is still scanned over the FULL
# range: klämdag detection is a property of the calendar, not of what
# this window happens to fund.
all_dates = self._window_dates(wish, responsible, w.start, w.end)
red_cluster = _red_day_cluster_dates(
w.start, w.end, self._input.holidays, self._input.day_overrides
)
@@ -1826,7 +1946,7 @@ class _Ledger:
candidate is funded for real afterwards through the ordinary
`fund_window()` path, exactly as before this fix.
"""
dates = _dates_in(candidate_start, candidate_end)
dates = self._window_dates(wish, responsible, candidate_start, candidate_end)
red_cluster = _red_day_cluster_dates(
candidate_start, candidate_end, self._input.holidays, self._input.day_overrides
)
@@ -2490,6 +2610,7 @@ class AllocationInputLoader:
planned_leave_repo: PlannedLeaveRepository,
leave_wish_repo: LeaveWishRepository,
day_override_repo: DayOverrideRepository | None = None,
exclusion_repo: PlannedDayExclusionRepository | None = None,
) -> None:
self._workspace_repo = workspace_repo
self._children_repo = child_repo
@@ -2499,6 +2620,7 @@ class AllocationInputLoader:
self._planned_leave_repo = planned_leave_repo
self._leave_wish_repo = leave_wish_repo
self._day_override_repo = day_override_repo
self._exclusion_repo = exclusion_repo
async def load(
self,
@@ -2686,6 +2808,18 @@ class AllocationInputLoader:
overrides = await self._day_override_repo.list_by_workspace(workspace_id)
day_overrides = {o.date: o.is_working_day for o in overrides}
# §remove-this-day — the dates the user removed by hand. Loaded
# unconditionally (never narrowed by `only_pinned_existing`): a
# removal is an instruction about the plan, not a planned row, so a
# re-plan must see every one of them or the removed day comes back on
# the very next sync — which is the bug this exists to fix.
excluded_days: frozenset[tuple[UUID, UUID, date]] = frozenset()
if self._exclusion_repo is not None:
exclusions = await self._exclusion_repo.list_by_workspace(workspace_id)
excluded_days = frozenset(
(e.parent_personal_data_uuid, e.child_id, e.date) for e in exclusions
)
return AllocationInput(
today=today,
horizon_end=horizon_end,
@@ -2700,6 +2834,7 @@ class AllocationInputLoader:
day_overrides=day_overrides,
take_pl_on_red_days=ws.take_pl_on_red_days,
all_existing_planned=all_existing_planned,
excluded_days=excluded_days,
)
@@ -0,0 +1,132 @@
"""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)
@@ -0,0 +1,66 @@
"""Add planned_day_exclusion: one date the planner may not place leave on.
Removing a single day out of a planner-placed period used to be impossible:
the wish still said "a full week", so the next `POST /api/plan/sync` simply
re-derived the day. This table is where "not this one date" is written down,
per (workspace, child, parent, date).
Deliberately a table of its own rather than a row in `day_override`: that
one answers a *calendar* question ("is this a working day for this
workspace?") and is workspace-wide, this one answers a *planning* question
("may a leave day be placed here?") and is per parent. See
`app/entities/planned_day_exclusion.py`.
A plain `CREATE TABLE` suffices — nothing existing changes, and there is
nothing to backfill: before this migration no exclusion could be expressed,
so the correct history is "none".
Must stay byte-for-byte equivalent to the `planned_day_exclusion` table in
`app/db/connection.py`'s `_FULL_SCHEMA` —
`tests/integration/test_schema_parity.py` asserts exactly that.
Revision ID: 005
Revises: 004
Create Date: 2026-09-19
"""
from __future__ import annotations
from alembic import op
revision: str = "005"
down_revision: str | None = "004"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS planned_day_exclusion (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
child_id TEXT NOT NULL,
parent_personal_data_uuid TEXT NOT NULL,
date DATE NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE(workspace_id, child_id, parent_personal_data_uuid, date),
FOREIGN KEY (workspace_id) REFERENCES workspace(id) ON DELETE CASCADE,
FOREIGN KEY (child_id) REFERENCES child(id) ON DELETE CASCADE,
FOREIGN KEY (parent_personal_data_uuid)
REFERENCES personal_data(uuid) ON DELETE CASCADE
)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_planned_day_exclusion_workspace
ON planned_day_exclusion (workspace_id)
"""
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_planned_day_exclusion_workspace")
op.execute("DROP TABLE IF EXISTS planned_day_exclusion")
+26
View File
@@ -27,6 +27,9 @@ from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverri
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
SQLitePlannedDayExclusionRepository,
)
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository
from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork
@@ -44,6 +47,7 @@ from app.services.auth_service import AuthService
from app.services.child_service import ChildService
from app.services.day_override_service import DayOverrideService
from app.services.leave_wish_service import LeaveWishService
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
from app.services.planning_service import PlanningService
from app.services.tips_service import TipsService
from app.services.vacation_days_service import VacationDaysService
@@ -299,6 +303,26 @@ async def day_override_service(
return DayOverrideService(day_override_repo)
# ---------------------------------------------------------------------------
# PlannedDayExclusion (user-removed days — see app/entities/planned_day_exclusion.py)
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def planned_day_exclusion_repo(
connection: aiosqlite.Connection,
) -> SQLitePlannedDayExclusionRepository:
return SQLitePlannedDayExclusionRepository(connection)
@pytest_asyncio.fixture
async def planned_day_exclusion_service(
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
) -> PlannedDayExclusionService:
return PlannedDayExclusionService(planned_day_exclusion_repo, planned_leave_repo)
@pytest_asyncio.fixture
async def child_service(
child_repo: SQLiteChildRepository,
@@ -329,6 +353,7 @@ async def allocation_input_loader(
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
day_override_repo: SQLiteDayOverrideRepository,
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
) -> AllocationInputLoader:
return AllocationInputLoader(
workspace_repo=workspace_repo,
@@ -339,6 +364,7 @@ async def allocation_input_loader(
planned_leave_repo=planned_leave_repo,
leave_wish_repo=leave_wish_repo,
day_override_repo=day_override_repo,
exclusion_repo=planned_day_exclusion_repo,
)
@@ -0,0 +1,367 @@
"""Tests for the PlannedDayExclusion repository, service and router.
The core promise, in the user's words: "removing a single day should stay
permanent. Even if it's an auto added period day." `TestExclusionSurvivesSync`
is that promise — a day removed from a planner-placed period must still be
gone after the next `POST /api/plan/sync`, which is precisely what re-derives
it today.
Follows the DayOverride reference pattern (arrange an entity, exercise the
repo/service, assert on the result).
"""
from datetime import date, datetime
from uuid import uuid4
import pytest
from httpx import AsyncClient
from app.entities.leave_wish import LeaveWish
from app.entities.planned_day_exclusion import PlannedDayExclusion
from app.entities.planned_leave import PlannedLeaveDay
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.planned_day_exclusion_repository import (
SQLitePlannedDayExclusionRepository,
)
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
from app.services.planning_service import PlanningService
from app.utils.errors import NotFoundError
from tests.conftest import SeededIds
_DAY = date(2027, 2, 3) # a Wednesday inside the wish window below
def _make(seeded: SeededIds, **overrides) -> PlannedDayExclusion:
base = dict(
id=uuid4(),
workspace_id=seeded.workspace_id,
child_id=seeded.child_id,
parent_personal_data_uuid=seeded.personal_data_uuid,
date=_DAY,
created_at=datetime.now(),
updated_at=datetime.now(),
)
base.update(overrides)
return PlannedDayExclusion(**base)
def _make_wish(seeded: SeededIds) -> LeaveWish:
"""A fixed, one-week February 2027 window, funded by the seeded parent."""
return LeaveWish(
id=uuid4(),
workspace_id=seeded.workspace_id,
child_id=seeded.child_id,
name="Sportlov",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=7,
target_weeks=1,
first_year=2027,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="fixed",
created_at=datetime.now(),
updated_at=datetime.now(),
)
class TestSQLitePlannedDayExclusionRepository:
async def test_create_and_get_by_id(
self,
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(seeded_ids)
await planned_day_exclusion_repo.create(entity)
fetched = await planned_day_exclusion_repo.get_by_id(entity.id)
assert fetched is not None
assert fetched.id == entity.id
assert fetched.date == _DAY
assert fetched.child_id == seeded_ids.child_id
assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid
async def test_get_by_natural_key(
self,
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(seeded_ids)
await planned_day_exclusion_repo.create(entity)
found = await planned_day_exclusion_repo.get_by_natural_key(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
assert found is not None
assert found.id == entity.id
# The parent IS part of the key — a different parent is a different row.
other_parent = await planned_day_exclusion_repo.get_by_natural_key(
seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY
)
assert other_parent is None
other_day = await planned_day_exclusion_repo.get_by_natural_key(
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
date(2027, 2, 4),
)
assert other_day is None
async def test_update_and_delete(
self,
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
seeded_ids: SeededIds,
) -> None:
entity = _make(seeded_ids)
await planned_day_exclusion_repo.create(entity)
entity.date = date(2027, 2, 5)
updated = await planned_day_exclusion_repo.update(entity.id, entity)
assert updated.date == date(2027, 2, 5)
await planned_day_exclusion_repo.delete(entity.id)
assert await planned_day_exclusion_repo.get_by_id(entity.id) is None
with pytest.raises(NotFoundError):
await planned_day_exclusion_repo.delete(entity.id)
with pytest.raises(NotFoundError):
await planned_day_exclusion_repo.update(entity.id, entity)
async def test_list_by_workspace_is_date_ordered(
self,
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
seeded_ids: SeededIds,
) -> None:
await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 5)))
await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 1)))
rows = await planned_day_exclusion_repo.list_by_workspace(seeded_ids.workspace_id)
assert [r.date for r in rows] == [date(2027, 2, 1), date(2027, 2, 5)]
assert await planned_day_exclusion_repo.list_by_workspace(uuid4()) == []
async def test_unsupported_filter_is_rejected(
self,
planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository,
) -> None:
with pytest.raises(ValueError):
await planned_day_exclusion_repo.list(nonsense=1)
class TestPlannedDayExclusionService:
async def test_exclude_is_idempotent(
self,
planned_day_exclusion_service: PlannedDayExclusionService,
seeded_ids: SeededIds,
) -> None:
first = await planned_day_exclusion_service.exclude(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
second = await planned_day_exclusion_service.exclude(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
assert second.id == first.id
rows = await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id)
assert len(rows) == 1
async def test_exclude_removes_the_planned_day_immediately(
self,
planned_day_exclusion_service: PlannedDayExclusionService,
planned_leave_repo: SQLitePlannedLeaveRepository,
seeded_ids: SeededIds,
) -> None:
await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=seeded_ids.workspace_id,
child_id=seeded_ids.child_id,
parent_personal_data_uuid=seeded_ids.personal_data_uuid,
date=_DAY,
day_type="sjukpenning",
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await planned_day_exclusion_service.exclude(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
rows = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
assert [r for r in rows if r.date == _DAY] == []
async def test_get_and_delete_are_workspace_scoped(
self,
planned_day_exclusion_service: PlannedDayExclusionService,
seeded_ids: SeededIds,
) -> None:
created = await planned_day_exclusion_service.exclude(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
with pytest.raises(NotFoundError):
await planned_day_exclusion_service.get(created.id, workspace_id=uuid4())
with pytest.raises(NotFoundError):
await planned_day_exclusion_service.delete(created.id, workspace_id=uuid4())
await planned_day_exclusion_service.delete(created.id, workspace_id=seeded_ids.workspace_id)
assert await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id) == []
async def test_excluded_keys_shape_matches_the_allocator_input(
self,
planned_day_exclusion_service: PlannedDayExclusionService,
seeded_ids: SeededIds,
) -> None:
await planned_day_exclusion_service.exclude(
seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY
)
keys = await planned_day_exclusion_service.get_excluded_keys(seeded_ids.workspace_id)
assert keys == frozenset({(seeded_ids.personal_data_uuid, seeded_ids.child_id, _DAY)})
class TestExclusionSurvivesSync:
"""The core promise: a removed day stays removed across re-syncs."""
async def test_removed_day_is_not_re_derived_by_the_next_sync(
self,
planning_service: PlanningService,
planned_day_exclusion_service: PlannedDayExclusionService,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await leave_wish_repo.create(_make_wish(seeded_ids))
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY in {r.date for r in rows}, "the planner should place the day to begin with"
await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert second.apply_result.accepted is True, second.apply_result.rejections
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY not in {r.date for r in rows}
# The rest of the week is still planned — only the one day went.
assert {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5)} <= {
r.date for r in rows
}
# ...and a third sync must not quietly bring it back either.
third = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert third.apply_result.accepted is True
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY not in {r.date for r in rows}
async def test_removal_does_not_report_a_shortfall(
self,
planning_service: PlanningService,
planned_day_exclusion_service: PlannedDayExclusionService,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""A day the user removed is not a day the plan failed to fund.
If it were counted as a gap, `sync_plan`'s eviction preview could
refuse the very sync that carries out the removal.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await leave_wish_repo.create(_make_wish(seeded_ids))
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result.apply_result.accepted is True
assert result.eviction_preview is not None
assert result.eviction_preview.would_evict is False
assert [o.shortfall_days for o in result.proposal.outcomes] == [0]
async def test_un_excluding_puts_the_day_back(
self,
planning_service: PlanningService,
planned_day_exclusion_service: PlannedDayExclusionService,
planned_leave_repo: SQLitePlannedLeaveRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""There must be a way back — this is what the undo toast calls."""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await leave_wish_repo.create(_make_wish(seeded_ids))
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
created = await planned_day_exclusion_service.exclude(ws, child, parent, _DAY)
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
await planned_day_exclusion_service.delete(created.id, workspace_id=ws)
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY in {r.date for r in rows}
class TestPlannedDayExclusionRouter:
async def test_create_list_delete_round_trip(
self, authed_client: AsyncClient, seeded_ids: SeededIds
) -> None:
payload = {
"workspace_id": str(seeded_ids.workspace_id),
"child_id": str(seeded_ids.child_id),
"parent_personal_data_uuid": str(seeded_ids.personal_data_uuid),
"date": _DAY.isoformat(),
}
created = await authed_client.post("/api/planned-day-exclusions", json=payload)
assert created.status_code == 201, created.text
body = created.json()
assert body["date"] == _DAY.isoformat()
listed = await authed_client.get(
f"/api/planned-day-exclusions/workspace/{seeded_ids.workspace_id}"
)
assert listed.status_code == 200
assert [r["id"] for r in listed.json()] == [body["id"]]
deleted = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}")
assert deleted.status_code == 204
missing = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}")
assert missing.status_code == 404
async def test_a_foreign_workspace_is_rejected(
self, authed_client: AsyncClient, seeded_ids: SeededIds
) -> None:
response = await authed_client.post(
"/api/planned-day-exclusions",
json={
"workspace_id": str(uuid4()),
"child_id": str(seeded_ids.child_id),
"parent_personal_data_uuid": str(seeded_ids.personal_data_uuid),
"date": _DAY.isoformat(),
},
)
assert response.status_code == 404
async def test_a_foreign_list_is_rejected(self, authed_client: AsyncClient) -> None:
response = await authed_client.get(f"/api/planned-day-exclusions/workspace/{uuid4()}")
assert response.status_code == 404
@@ -0,0 +1,270 @@
"""The allocator must never re-place a day the user removed by hand.
User's requirement, verbatim: "removing a single day should stay permanent.
Even if it's an auto added period day." The wish still says "a full week", so
without `AllocationInput.excluded_days` the next `POST /api/plan/sync` simply
re-derives the removed date — which is exactly what these tests pin down.
Two decisions are asserted here, both explained at length on
`_Ledger._excluded_window_dates`:
1. **The parent is part of the key.** A both-parents window places one day
per parent on the same date; removing one parent's day leaves the
partner's standing. A date only leaves the window entirely when no
responsible parent may take it.
2. **An excluded date shrinks the window's target; it is not a shortfall.**
A day the user removed is not a day the plan failed to fund, so
`shortfall_days` must not grow (which would make the new shortfall
remedies advise freeing resources for a day nobody wants, and would make
`sync_plan`'s eviction preview treat a deliberate removal as a lost
priority race).
"""
from datetime import date
from app.services.allocation_service import Allocator, DayPool, VacationBudget
from tests.unit.test_services.test_allocation_service import _build_input, _child, _parent, _wish
# Feb 2027 is the suite's clean "no red-day interference" month; Feb 1-7 2027
# is Mon-Sun, so 5 spendable days + Sat 6 + Sun 7.
_WINDOW_START = (2, 1)
_WINDOW_END = (2, 7)
_REMOVED = date(2027, 2, 3) # a Wednesday in the middle of the window
def _solo_scenario(*, excluded: frozenset | None = None, reuse=None):
"""A one-parent, one-week fixed window.
`reuse` takes a `(child, parent)` pair from an earlier call so a test can
build two inputs that are identical down to the uuids — needed both for a
before/after comparison and for the determinism check, since `_Ledger`
mutates its copies of the pool/vacation dicts in place and every
`propose()` run therefore needs its own fresh input.
"""
if reuse is not None:
child, parent = reuse
else:
child, parent = _child(birth_date=date(2025, 6, 1)), _parent(name="Solo")
wish = _wish(
child_id=child.id,
name="Sportlov",
start=_WINDOW_START,
end=_WINDOW_END,
weeks=1,
first_year=2027,
parent_mode="specific",
parent_uuid=parent.uuid,
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent],
pools={(parent.uuid, child.id): DayPool(200, 90, 60)},
vacation={(parent.uuid, 2027): VacationBudget(25, 0, 0)},
wishes=[wish],
excluded_days=excluded,
)
return alloc_input, parent, child
def _both_scenario(*, excluded: frozenset | None = None, reuse=None):
"""A both-parents ("co-leave") one-week fixed window; `reuse` as above."""
if reuse is not None:
child, parent_a, parent_b = reuse
else:
child = _child(birth_date=date(2025, 6, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
name="Tillsammans",
start=_WINDOW_START,
end=_WINDOW_END,
weeks=1,
first_year=2027,
parent_mode="both",
)
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(200, 90, 60),
(parent_b.uuid, child.id): DayPool(200, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[wish],
excluded_days=excluded,
)
return alloc_input, parent_a, parent_b, child
class TestExcludedDayIsNeverPlaced:
def test_solo_window_skips_the_removed_date(self) -> None:
baseline_input, parent, child = _solo_scenario()
baseline = Allocator().propose(baseline_input)
assert _REMOVED in {d.date for d in baseline.days if d.day_type != "free"}
alloc_input, parent, child = _solo_scenario(
excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent)
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED not in {d.date for d in proposal.days}
# The rest of the window is untouched.
assert {d.date for d in proposal.days if d.day_type != "free"} == {
date(2027, 2, 1),
date(2027, 2, 2),
date(2027, 2, 4),
date(2027, 2, 5),
}
def test_removed_date_is_not_reported_as_a_shortfall(self) -> None:
"""Decision: an excluded date reduces the window's target.
The user removed it; the plan did not fail to fund it. If this
counted as a gap, `_build_remedies` would start proposing ways to
free up resources for a day nobody wants, and `sync_plan`'s eviction
preview would block the sync that carries out the removal.
"""
_seed, parent, child = _solo_scenario()
alloc_input, _p, _c = _solo_scenario(
excluded=frozenset({(parent.uuid, child.id, _REMOVED)}), reuse=(child, parent)
)
proposal = Allocator().propose(alloc_input)
outcome = proposal.outcomes[0]
assert outcome.shortfall_days == 0
assert outcome.remedies == []
# `weeks_funded` still tells the truth about what was actually
# placed: 6 of the window's 7 days (4 funded + Sat + Sun).
assert outcome.weeks_funded == round(6 / 7, 2)
def test_an_unrelated_parents_exclusion_does_not_touch_this_window(self) -> None:
_seed, parent, child = _solo_scenario()
alloc_input, _p, _c = _solo_scenario(
excluded=frozenset({(_parent(name="Stranger").uuid, child.id, _REMOVED)}),
reuse=(child, parent),
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED in {d.date for d in proposal.days if d.day_type != "free"}
assert proposal.outcomes[0].shortfall_days == 0
class TestExclusionIsKeyedPerParent:
def test_both_parents_window_keeps_the_partners_day(self) -> None:
"""The parent-keying decision, stated as a test.
Removing "this day" from A's band must not silently cancel B's leave
on the same date — B never asked for that, and on a shared plan they
would only discover it by noticing the hole.
"""
alloc_input, parent_a, parent_b, child = _both_scenario()
baseline = Allocator().propose(alloc_input)
baseline_on_date = [d for d in baseline.days if d.date == _REMOVED]
assert {d.parent_personal_data_uuid for d in baseline_on_date} == {
parent_a.uuid,
parent_b.uuid,
}
alloc_input, parent_a, parent_b, child = _both_scenario(
excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
reuse=(child, parent_a, parent_b),
)
proposal = Allocator().propose(alloc_input)
on_date = [d for d in proposal.days if d.date == _REMOVED]
assert [d.parent_personal_data_uuid for d in on_date] == [parent_b.uuid]
assert on_date[0].day_type != "free"
def test_the_date_leaves_the_window_only_when_both_parents_are_removed(self) -> None:
_seed, parent_a, parent_b, child = _both_scenario()
alloc_input, _a, _b, _c = _both_scenario(
excluded=frozenset(
{
(parent_a.uuid, child.id, _REMOVED),
(parent_b.uuid, child.id, _REMOVED),
}
),
reuse=(child, parent_a, parent_b),
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED not in {d.date for d in proposal.days}
assert proposal.outcomes[0].shortfall_days == 0
def test_one_parents_exclusion_is_not_a_shortfall_for_the_window(self) -> None:
_seed, parent_a, parent_b, child = _both_scenario()
alloc_input, _a, _b, _c = _both_scenario(
excluded=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
reuse=(child, parent_a, parent_b),
)
proposal = Allocator().propose(alloc_input)
assert proposal.outcomes[0].shortfall_days == 0
class TestSwapWindowDoesNotRefillTheHole:
def test_the_removed_date_is_not_handed_to_the_other_parent(self) -> None:
"""`_fund_swap`'s leftover hand-off must not undo the removal.
In a swap window exactly one parent is on leave per date, so a date
one parent is removed from would otherwise fall through as "unfunded
leftover" and be offered to the partner — the day would visibly come
back, just in the other parent's colour.
"""
child = _child(birth_date=date(2025, 6, 1))
parent_a = _parent(name="A")
parent_b = _parent(name="B")
wish = _wish(
child_id=child.id,
name="Sommar",
start=_WINDOW_START,
end=_WINDOW_END,
weeks=1,
first_year=2027,
parent_mode="either",
)
# `parent_mode='either'` with two parents resolves to a swap window.
alloc_input = _build_input(
today=date(2027, 1, 1),
horizon_end=date(2027, 12, 31),
children=[child],
parents=[parent_a, parent_b],
pools={
(parent_a.uuid, child.id): DayPool(200, 90, 60),
(parent_b.uuid, child.id): DayPool(200, 90, 60),
},
vacation={
(parent_a.uuid, 2027): VacationBudget(25, 0, 0),
(parent_b.uuid, 2027): VacationBudget(25, 0, 0),
},
wishes=[wish],
excluded_days=frozenset({(parent_a.uuid, child.id, _REMOVED)}),
)
proposal = Allocator().propose(alloc_input)
assert _REMOVED not in {d.date for d in proposal.days}
assert proposal.outcomes[0].shortfall_days == 0
class TestDeterminismIsPreserved:
def test_same_input_same_output_with_exclusions(self) -> None:
"""§5 test #11 — the purity guarantee still holds with exclusions."""
_seed_input, parent, child = _solo_scenario()
excluded = frozenset({(parent.uuid, child.id, _REMOVED)})
first_input, _p, _c = _solo_scenario(excluded=excluded, reuse=(child, parent))
second_input, _p2, _c2 = _solo_scenario(excluded=excluded, reuse=(child, parent))
first = Allocator().propose(first_input)
second = Allocator().propose(second_input)
assert [(d.date, d.day_type, d.reason) for d in first.days] == [
(d.date, d.day_type, d.reason) for d in second.days
]
@@ -181,6 +181,7 @@ def _build_input(
day_overrides: dict[date, bool] | None = None,
age4_savable_cap: int | None = None,
take_pl_on_red_days: bool = False,
excluded_days: frozenset[tuple[UUID, UUID, date]] | None = None,
) -> AllocationInput:
years = {today.year, horizon_end.year, horizon_end.year + 1, *extra_holiday_years}
if age4_savable_cap is None:
@@ -204,6 +205,7 @@ def _build_input(
day_overrides=day_overrides or {},
take_pl_on_red_days=take_pl_on_red_days,
all_existing_planned=all_existing_planned,
excluded_days=excluded_days or frozenset(),
)