Add a plan sync endpoint that recomputes, persists and reports the delta
The calendar is becoming the plan: a user edits a period and the schedule updates immediately, with no separate propose and apply steps. POST /api/plan/sync does both and returns a per-date diff against a snapshot taken before any write, so the interface can say what actually moved rather than guessing from coarse counters. Pinned days now mean something. A day the user hand-edited keeps its type and its parent: the loader hides unpinned wish-attributed rows from the allocator so a re-plan is free to move them, while pinned rows stay visible and the existing occupancy check refuses to touch them. Their cost is debited off the pool before any window is funded, following the precedent already set for preschool planning days. Propose and apply are untouched. The allocator stays pure.
This commit is contained in:
@@ -10,6 +10,7 @@ from app.config import settings
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
||||
from app.services.allocation_service import PlanningEngineService, PlanProposal
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
from app.utils.errors import DubbeldagError, NotFoundError, ValidationError
|
||||
|
||||
@@ -87,16 +88,63 @@ class PlanApplyResult:
|
||||
rejections: list[Rejection] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/plan/sync — propose + apply in one step, with a structured diff.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanChange:
|
||||
"""One (child, parent, date) slot's delta from `sync_plan`'s pre-write
|
||||
|
||||
snapshot to what's actually persisted after the call. Structured data
|
||||
only — house style is `message_key` + `data`, never backend-rendered
|
||||
prose, so the frontend composes the toast copy ("Sommar 2028 flyttades 2
|
||||
dagar") itself. `kind`:
|
||||
|
||||
- 'added' — no row existed for (child, parent, date); one does now.
|
||||
- 'removed' — a row existed and no longer does (old_* populated, new_*
|
||||
all `None`).
|
||||
- 'retyped' — the row survived but `day_type`/`is_dubbeldag` changed.
|
||||
- 'reattributed' — same day and type, but `wish_id` changed (the day now
|
||||
belongs to a different period).
|
||||
"""
|
||||
|
||||
kind: str
|
||||
date: date
|
||||
parent_personal_data_uuid: UUID
|
||||
child_id: UUID
|
||||
old_day_type: str | None
|
||||
new_day_type: str | None
|
||||
old_is_dubbeldag: bool | None
|
||||
new_is_dubbeldag: bool | None
|
||||
old_wish_id: UUID | None
|
||||
new_wish_id: UUID | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanSyncResult:
|
||||
proposal: PlanProposal
|
||||
apply_result: PlanApplyResult
|
||||
changes: list[PlanChange] = field(default_factory=list)
|
||||
|
||||
|
||||
class PlanningService:
|
||||
def __init__(
|
||||
self,
|
||||
repo: PlannedLeaveRepository,
|
||||
analytics_service: AnalyticsService,
|
||||
parental_leave_repo: ParentalLeaveRepository,
|
||||
planning_engine_service: PlanningEngineService | None = None,
|
||||
) -> None:
|
||||
self._repo = repo
|
||||
self._analytics = analytics_service
|
||||
self._parental_leave_repo = parental_leave_repo
|
||||
# Only needed by `sync_plan` (POST /api/plan/sync) — optional so
|
||||
# existing callers (scripts/seed_demo.py, older fixtures) that only
|
||||
# ever use the per-day CRUD/`apply_proposed_days` paths don't need to
|
||||
# wire up a `PlanningEngineService` they'll never use.
|
||||
self._engine = planning_engine_service
|
||||
|
||||
async def add_planned_day(
|
||||
self,
|
||||
@@ -413,6 +461,164 @@ class PlanningService:
|
||||
replaced_count=len(to_replace),
|
||||
)
|
||||
|
||||
# -- sync (POST /api/plan/sync) ---------------------------------------
|
||||
#
|
||||
# Recomputes and persists a workspace's plan in one step, then reports
|
||||
# what changed. Runs `PlanningEngineService.propose(..., only_pinned_
|
||||
# existing=True)`, which hides every *unpinned* wish-attributed
|
||||
# `planned_leave_day` row from the allocator (see that flag's docstring
|
||||
# in `allocation_service.py`) so the auto-planned portion of the plan is
|
||||
# re-derived from scratch each call — this is what lets a re-plan
|
||||
# legitimately move or retype previously-auto-placed days, while a
|
||||
# pinned day (fed to the allocator unchanged, and debited off the top by
|
||||
# `_Ledger._reserve_pinned_days`) can never be reassigned or overwritten.
|
||||
#
|
||||
# Pinned days therefore never appear in `proposal.days` in the first
|
||||
# place (the allocator never emits anything for an already-occupied
|
||||
# key) -- "no collision ever arises" is a property of the allocator, not
|
||||
# something this method has to special-case in the write path. The one
|
||||
# extra step this method DOES own is deleting rows that fall out of the
|
||||
# freshly-computed plan entirely (a wish's window moved away from a date
|
||||
# it used to occupy, or the wish was deleted) -- `apply_proposed_days`
|
||||
# only ever creates/replaces/updates keys present in its own batch, so
|
||||
# nothing else would ever clean those up.
|
||||
|
||||
async def sync_plan(self, workspace_id: UUID, today: date | None = None) -> PlanSyncResult:
|
||||
"""Propose, persist, and diff — the single call the direct-
|
||||
|
||||
manipulation calendar UX needs (no more separate propose/apply
|
||||
steps). The diff (`changes`) is computed from a pre-write snapshot
|
||||
read here, before any write, compared against what's actually
|
||||
persisted afterwards — never reconstructed from the client side or
|
||||
guessed from `apply_proposed_days`'s own (coarser) counters.
|
||||
"""
|
||||
if self._engine is None:
|
||||
raise ValidationError("PlanningService.sync_plan requires a PlanningEngineService")
|
||||
|
||||
before = await self._repo.list(workspace_id=workspace_id)
|
||||
before_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
||||
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in before
|
||||
}
|
||||
pinned_keys = {key for key, d in before_by_key.items() if d.is_pinned}
|
||||
|
||||
proposal = await self._engine.propose(workspace_id, today=today, only_pinned_existing=True)
|
||||
|
||||
batch = [
|
||||
ProposedDayInput(
|
||||
date=d.date,
|
||||
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
||||
child_id=d.child_id,
|
||||
day_type=d.day_type,
|
||||
is_dubbeldag=d.is_dubbeldag,
|
||||
wish_id=d.wish_id,
|
||||
is_pinned=False,
|
||||
)
|
||||
for d in proposal.days
|
||||
if d.day_type != "free"
|
||||
and d.child_id is not None
|
||||
# Defense-in-depth only: the allocator never proposes anything
|
||||
# for an already-occupied key (pinned rows stay in
|
||||
# `existing_planned` — see `only_pinned_existing` above), so this
|
||||
# should never actually filter anything out.
|
||||
and (d.child_id, d.parent_personal_data_uuid, d.date) not in pinned_keys
|
||||
]
|
||||
|
||||
apply_result = await self.apply_proposed_days(
|
||||
workspace_id=workspace_id, days=batch, replace=True
|
||||
)
|
||||
|
||||
changes: list[PlanChange] = []
|
||||
if apply_result.accepted:
|
||||
batch_keys = {(d.child_id, d.parent_personal_data_uuid, d.date) for d in batch}
|
||||
# Rows the freshly-computed plan no longer accounts for at all:
|
||||
# unpinned, wish-attributed, and absent from this run's batch —
|
||||
# e.g. the wish was deleted, or its window moved elsewhere.
|
||||
# `apply_proposed_days` never deletes a key it wasn't handed, so
|
||||
# this is the one bit of write logic sync owns beyond it.
|
||||
stale = [
|
||||
row
|
||||
for key, row in before_by_key.items()
|
||||
if not row.is_pinned and row.wish_id is not None and key not in batch_keys
|
||||
]
|
||||
for row in stale:
|
||||
await self._repo.delete(row.id)
|
||||
|
||||
after = await self._repo.list(workspace_id=workspace_id)
|
||||
after_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
||||
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in after
|
||||
}
|
||||
|
||||
for key in sorted(
|
||||
set(before_by_key) | set(after_by_key),
|
||||
key=lambda k: (k[2], str(k[0]), str(k[1])),
|
||||
):
|
||||
child_id, parent_uuid, d = key
|
||||
old = before_by_key.get(key)
|
||||
new = after_by_key.get(key)
|
||||
if old is None and new is not None:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="added",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=None,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=None,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=None,
|
||||
new_wish_id=new.wish_id,
|
||||
)
|
||||
)
|
||||
elif old is not None and new is None:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="removed",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=None,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=None,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=None,
|
||||
)
|
||||
)
|
||||
elif old is not None and new is not None:
|
||||
if old.day_type != new.day_type or old.is_dubbeldag != new.is_dubbeldag:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="retyped",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=new.wish_id,
|
||||
)
|
||||
)
|
||||
elif old.wish_id != new.wish_id:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="reattributed",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=new.wish_id,
|
||||
)
|
||||
)
|
||||
|
||||
return PlanSyncResult(proposal=proposal, apply_result=apply_result, changes=changes)
|
||||
|
||||
async def _validate_dubbeldagar_batch(
|
||||
self,
|
||||
pending: list[ProposedDayInput],
|
||||
|
||||
Reference in New Issue
Block a user