Remove a period's days along with the period

Deleting a period left every day it had funded behind. The link from a day
back to its period was set to null the instant the period was deleted, so the
days became untraceable before anything could clean them up, and the next
recalculation had no way to know they no longer belonged to anything. Undo had
the same fault for the same reason: the period went, the days stayed, and a
reload showed them still there.

The days are now removed with the period, before the link can be broken. A day
pinned by hand goes too — pinning keeps the planner from quietly retyping a
day, not from being deleted when you delete the period it belongs to, which is
already how an unplanned band behaves.
This commit is contained in:
2026-09-18 00:23:34 +02:00
parent 8d2f658061
commit ac3b6a41d7
10 changed files with 337 additions and 37 deletions
+5 -2
View File
@@ -210,8 +210,11 @@ def get_vacation_days_service(repo: VacationDaysRepositoryDep) -> VacationDaysSe
VacationDaysServiceDep = Annotated[VacationDaysService, Depends(get_vacation_days_service)]
def get_leave_wish_service(repo: LeaveWishRepositoryDep) -> LeaveWishService:
return LeaveWishService(repo)
def get_leave_wish_service(
repo: LeaveWishRepositoryDep,
planned_leave_repo: PlannedLeaveRepositoryDep,
) -> LeaveWishService:
return LeaveWishService(repo, planned_leave_repo)
LeaveWishServiceDep = Annotated[LeaveWishService, Depends(get_leave_wish_service)]
@@ -33,3 +33,13 @@ class PlannedLeaveRepository(Repository[PlannedLeaveDay]):
@abstractmethod
async def count_dubbeldagar(self, child_id: UUID, from_date: date | None = None) -> int:
"""Count dubbeldagar already planned for a child (after from_date)."""
@abstractmethod
async def delete_by_wish(self, wish_id: UUID) -> int:
"""Delete every row attributed to this wish (pinned or not) and return the count deleted.
See `LeaveWishService.delete()`: called before the `leave_wish` row is
removed so the FK's `ON DELETE SET NULL` never fires — by the time
that DELETE runs, no `planned_leave_day` row still references this
wish, so there is nothing left to orphan.
"""
@@ -172,3 +172,10 @@ class SQLitePlannedLeaveRepository(PlannedLeaveRepository):
)
row = await cursor.fetchone()
return int(row[0]) if row else 0
async def delete_by_wish(self, wish_id: UUID) -> int:
cursor = await self._conn.execute(
"DELETE FROM planned_leave_day WHERE wish_id = ?", (str(wish_id),)
)
await self._conn.commit()
return cursor.rowcount
+29 -1
View File
@@ -10,6 +10,7 @@ from uuid import UUID, uuid4
from app.entities.leave_wish import LeaveWish
from app.repositories.leave_wish_repository import LeaveWishRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.utils.errors import NotFoundError, ValidationError
_VALID_RECURRENCE = {"yearly", "once"}
@@ -21,8 +22,13 @@ _LEAP_REFERENCE_YEAR = 2028
class LeaveWishService:
def __init__(self, repo: LeaveWishRepository) -> None:
def __init__(
self,
repo: LeaveWishRepository,
planned_leave_repo: PlannedLeaveRepository,
) -> None:
self._repo = repo
self._planned_leave_repo = planned_leave_repo
async def create(
self,
@@ -197,7 +203,29 @@ class LeaveWishService:
return await self._repo.update(leave_wish_id, entity)
async def delete(self, leave_wish_id: UUID) -> None:
"""Delete a wish and every `planned_leave_day` row it produced.
Under the calendar-direct-manipulation UX a period *is* its wish: the
band the user drew, the days the allocator filled it with, and any
of those days the user later hand-edited are all one thing. Deleting
the period must remove all of it — including pinned days.
`wish_id` is nullable and `ON DELETE SET NULL` at the schema level
(so a row can still exist detached/ad-hoc), but that "orphan, don't
delete" behavior is wrong for this call path: the rows are deleted
explicitly, *before* the wish row goes, so the FK's SET NULL never
gets a row left to act on. See `PlannedLeaveRepository.delete_by_wish`.
A pinned day is deleted too rather than surviving as ad-hoc. Pinning
only protects a day from being silently retyped/reassigned by a
later `sync_plan` re-plan (see that method's docstring) — it is not
a signal that the day should outlive the period a user explicitly
chose to delete. This also matches the ad-hoc-period delete path
(`Calendar.handleDeletePeriod`'s no-`wishId` branch), which already
deletes every day in the band regardless of `is_pinned` with no
special-casing.
"""
await self.get(leave_wish_id) # raises NotFoundError if missing
await self._planned_leave_repo.delete_by_wish(leave_wish_id)
await self._repo.delete(leave_wish_id)