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:
@@ -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);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user