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:
+12
-11
@@ -271,17 +271,6 @@ def get_analytics_service(
|
||||
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
|
||||
|
||||
|
||||
def get_planning_service(
|
||||
repo: PlannedLeaveRepositoryDep,
|
||||
analytics_service: AnalyticsServiceDep,
|
||||
parental_leave_repo: ParentalLeaveRepositoryDep,
|
||||
) -> PlanningService:
|
||||
return PlanningService(repo, analytics_service, parental_leave_repo)
|
||||
|
||||
|
||||
PlanningServiceDep = Annotated[PlanningService, Depends(get_planning_service)]
|
||||
|
||||
|
||||
def get_allocation_input_loader(
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
@@ -314,6 +303,18 @@ def get_planning_engine_service(loader: AllocationInputLoaderDep) -> PlanningEng
|
||||
PlanningEngineServiceDep = Annotated[PlanningEngineService, Depends(get_planning_engine_service)]
|
||||
|
||||
|
||||
def get_planning_service(
|
||||
repo: PlannedLeaveRepositoryDep,
|
||||
analytics_service: AnalyticsServiceDep,
|
||||
parental_leave_repo: ParentalLeaveRepositoryDep,
|
||||
planning_engine_service: PlanningEngineServiceDep,
|
||||
) -> PlanningService:
|
||||
return PlanningService(repo, analytics_service, parental_leave_repo, planning_engine_service)
|
||||
|
||||
|
||||
PlanningServiceDep = Annotated[PlanningService, Depends(get_planning_service)]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Current-user dependency (JWT bearer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -20,6 +20,8 @@ from app.services.allocation_service import PlanProposal
|
||||
from app.services.planning_service import (
|
||||
DayCollision,
|
||||
PlanApplyResult,
|
||||
PlanChange,
|
||||
PlanSyncResult,
|
||||
ProposedDayInput,
|
||||
Rejection,
|
||||
)
|
||||
@@ -328,3 +330,61 @@ class PlanApplyResponse(BaseModel):
|
||||
rejections=[RejectionResponse.from_domain(r) for r in result.rejections],
|
||||
created_days=[PlannedLeaveDayResponse.from_entity(d) for d in result.created],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/plan/sync
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PlanSyncRequest(BaseModel):
|
||||
workspace_id: UUID
|
||||
|
||||
|
||||
class PlanChangeResponse(BaseModel):
|
||||
"""One (child, parent, date) slot's delta from a `sync` call — see
|
||||
|
||||
`PlanChange`'s docstring for the `kind` vocabulary. Structured data only;
|
||||
the frontend composes the toast copy ("Sommar 2028 flyttades 2 dagar —
|
||||
Ångra") itself.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, change: PlanChange) -> "PlanChangeResponse":
|
||||
return cls(
|
||||
kind=change.kind,
|
||||
date=change.date,
|
||||
parent_personal_data_uuid=change.parent_personal_data_uuid,
|
||||
child_id=change.child_id,
|
||||
old_day_type=change.old_day_type,
|
||||
new_day_type=change.new_day_type,
|
||||
old_is_dubbeldag=change.old_is_dubbeldag,
|
||||
new_is_dubbeldag=change.new_is_dubbeldag,
|
||||
old_wish_id=change.old_wish_id,
|
||||
new_wish_id=change.new_wish_id,
|
||||
)
|
||||
|
||||
|
||||
class PlanSyncResponse(BaseModel):
|
||||
proposal: PlanProposalResponse
|
||||
apply_result: PlanApplyResponse
|
||||
changes: list[PlanChangeResponse]
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, result: PlanSyncResult) -> "PlanSyncResponse":
|
||||
return cls(
|
||||
proposal=PlanProposalResponse.from_domain(result.proposal),
|
||||
apply_result=PlanApplyResponse.from_domain(result.apply_result),
|
||||
changes=[PlanChangeResponse.from_domain(c) for c in result.changes],
|
||||
)
|
||||
|
||||
@@ -15,6 +15,8 @@ from app.models.plan import (
|
||||
PlanApplyResponse,
|
||||
PlanProposalResponse,
|
||||
PlanProposeRequest,
|
||||
PlanSyncRequest,
|
||||
PlanSyncResponse,
|
||||
)
|
||||
from app.utils.errors import NotFoundError, ValidationError
|
||||
|
||||
@@ -55,3 +57,23 @@ async def apply_plan(payload: PlanApplyRequest, service: PlanningServiceDep) ->
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
return PlanApplyResponse.from_domain(result)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=PlanSyncResponse)
|
||||
async def sync_plan(payload: PlanSyncRequest, service: PlanningServiceDep) -> PlanSyncResponse:
|
||||
"""Recompute and persist a workspace's plan in one step.
|
||||
|
||||
The direct-manipulation calendar UX's one call: no separate preview
|
||||
step. Runs propose then apply internally (see
|
||||
`PlanningService.sync_plan`), never retyping or overwriting a day the
|
||||
user has pinned, and reports exactly what changed (`changes`) so the UI
|
||||
can show e.g. "Sommar 2028 flyttades 2 dagar — Ångra" instead of
|
||||
silently rewriting the calendar.
|
||||
"""
|
||||
try:
|
||||
result = await service.sync_plan(payload.workspace_id)
|
||||
except NotFoundError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
|
||||
return PlanSyncResponse.from_domain(result)
|
||||
|
||||
@@ -624,10 +624,38 @@ class _Ledger:
|
||||
self._existing_lookup
|
||||
)
|
||||
|
||||
self._reserve_pinned_days()
|
||||
self._reserve_preschool_days()
|
||||
|
||||
# -- setup ----------------------------------------------------------
|
||||
|
||||
def _reserve_pinned_days(self) -> None:
|
||||
"""§Task 4 — debit a pinned day's cost off the top, before any
|
||||
|
||||
window is funded, exactly like `_reserve_preschool_days` below
|
||||
reserves preschool closure days. A pinned `planned_leave_day` is a
|
||||
hard, already-spent fact regardless of which mode loaded this input
|
||||
(see `AllocationInputLoader.load`'s `only_pinned_existing`, which
|
||||
never nets a pinned row out of the pools/vacation budgets itself —
|
||||
this is the one place that happens). `_existing_lookup`/
|
||||
`_persisted_lookup` (built above from the same `existing_planned`)
|
||||
separately keep the Allocator from ever reassigning a pinned day's
|
||||
type or parent — this method only handles the resource debit.
|
||||
"""
|
||||
for p in self._input.existing_planned:
|
||||
if not p.is_pinned:
|
||||
continue
|
||||
if p.day_type == "vacation":
|
||||
self._spend_vacation(p.parent_personal_data_uuid, p.date)
|
||||
continue
|
||||
pool = self._pools.get((p.parent_personal_data_uuid, p.child_id))
|
||||
if pool is None:
|
||||
continue
|
||||
if p.day_type == "sjukpenning" and pool.sjukpenning_left > 0:
|
||||
pool.sjukpenning_left -= 1
|
||||
elif p.day_type == "lagsta" and pool.lagsta_left > 0:
|
||||
pool.lagsta_left -= 1
|
||||
|
||||
def _reserve_preschool_days(self) -> None:
|
||||
for child in self._input.children:
|
||||
reserve = _preschool_reserve_days(child, self._input.today, self._input.horizon_end)
|
||||
@@ -1854,7 +1882,27 @@ class AllocationInputLoader:
|
||||
self._leave_wish_repo = leave_wish_repo
|
||||
self._day_override_repo = day_override_repo
|
||||
|
||||
async def load(self, workspace_id: UUID, today: date | None = None) -> AllocationInput:
|
||||
async def load(
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
today: date | None = None,
|
||||
*,
|
||||
only_pinned_existing: bool = False,
|
||||
) -> AllocationInput:
|
||||
"""Assemble the pure `AllocationInput`.
|
||||
|
||||
`only_pinned_existing` (§Task 4, see `PlanningEngineService.propose`)
|
||||
restricts `existing_planned` — and everything derived from it here
|
||||
(pool/vacation netting) — to rows that must stay immovable: pinned
|
||||
rows (`is_pinned`) and ad-hoc rows with no `wish_id`. Every other
|
||||
(unpinned, wish-attributed) row is left out entirely, so the
|
||||
allocator treats that date as open and re-derives it fresh. Pinned
|
||||
rows are *never* netted out of the pools/vacation budgets here even
|
||||
when `only_pinned_existing` is False — the pure `_Ledger` does that
|
||||
itself (`_reserve_pinned_days`), matching how it reserves preschool
|
||||
planning days off the top, so the debit happens in one place
|
||||
regardless of which mode loaded the input.
|
||||
"""
|
||||
today = today or date.today()
|
||||
|
||||
ws = await self._workspace_repo.get_by_id(workspace_id)
|
||||
@@ -1885,7 +1933,16 @@ class AllocationInputLoader:
|
||||
planned_for_child = await self._planned_leave_repo.list_by_workspace_child(
|
||||
workspace_id, child.id
|
||||
)
|
||||
existing_planned.extend(planned_for_child)
|
||||
if only_pinned_existing:
|
||||
# Hide unpinned, wish-attributed rows entirely -- see the
|
||||
# docstring above. Pinned rows and ad-hoc (no wish_id) rows
|
||||
# stay visible/immovable either way.
|
||||
visible_for_child = [
|
||||
d for d in planned_for_child if d.is_pinned or d.wish_id is None
|
||||
]
|
||||
else:
|
||||
visible_for_child = planned_for_child
|
||||
existing_planned.extend(visible_for_child)
|
||||
|
||||
for parent in parent_entities:
|
||||
latest = await self._parental_leave_repo.get_latest_by_parent(parent.uuid, child.id)
|
||||
@@ -1910,11 +1967,16 @@ class AllocationInputLoader:
|
||||
# Net out days already committed locally (planned_leave_day)
|
||||
# that an FK snapshot (if any) doesn't yet reflect, so the pure
|
||||
# allocator never double-books or over-spends a resource.
|
||||
# Pinned days are excluded here — the pure `_Ledger` debits
|
||||
# those itself (`_reserve_pinned_days`), so this loader-side
|
||||
# netting only ever covers non-pinned commitments (whichever
|
||||
# of those `visible_for_child` still includes).
|
||||
committed = [
|
||||
d
|
||||
for d in planned_for_child
|
||||
for d in visible_for_child
|
||||
if d.parent_personal_data_uuid == parent.uuid
|
||||
and d.day_type in ("sjukpenning", "lagsta")
|
||||
and not d.is_pinned
|
||||
]
|
||||
sjuk_committed = sum(1 for d in committed if d.day_type == "sjukpenning")
|
||||
lagsta_committed = sum(1 for d in committed if d.day_type == "lagsta")
|
||||
@@ -1928,12 +1990,16 @@ class AllocationInputLoader:
|
||||
horizon_end = _compute_horizon_end(today, children, wishes)
|
||||
|
||||
def _already_used_vacation(parent_uuid: UUID, year: int) -> int:
|
||||
# Pinned vacation days are excluded here for the same reason as
|
||||
# the FP pools above: `_Ledger._reserve_pinned_days` debits them
|
||||
# itself.
|
||||
return sum(
|
||||
1
|
||||
for d in existing_planned
|
||||
if d.parent_personal_data_uuid == parent_uuid
|
||||
and d.day_type == "vacation"
|
||||
and d.date.year == year
|
||||
and not d.is_pinned
|
||||
)
|
||||
|
||||
vacation: dict[tuple[UUID, int], VacationBudget] = {}
|
||||
@@ -2027,6 +2093,27 @@ class PlanningEngineService:
|
||||
self._loader = loader
|
||||
self._allocator = allocator or Allocator()
|
||||
|
||||
async def propose(self, workspace_id: UUID, today: date | None = None) -> PlanProposal:
|
||||
alloc_input = await self._loader.load(workspace_id, today=today)
|
||||
async def propose(
|
||||
self,
|
||||
workspace_id: UUID,
|
||||
today: date | None = None,
|
||||
*,
|
||||
only_pinned_existing: bool = False,
|
||||
) -> PlanProposal:
|
||||
"""Load fresh input and run the pure allocator over it.
|
||||
|
||||
`only_pinned_existing` (used by `PlanningService.sync_plan`, §Task 4)
|
||||
makes the loader hide every *unpinned*, wish-attributed
|
||||
`planned_leave_day` row from `AllocationInput.existing_planned` (and
|
||||
stop netting it out of the pools/vacation budgets) so the allocator
|
||||
re-derives that portion of the plan from scratch instead of treating
|
||||
it as an immovable fact — this is what lets a re-plan legitimately
|
||||
move/retype previously-auto-placed days. Pinned days and ad-hoc
|
||||
(`wish_id is None`) days are unaffected either way: always visible,
|
||||
always immovable. `POST /api/plan/propose` never sets this — it
|
||||
keeps its historical, unconditional behaviour.
|
||||
"""
|
||||
alloc_input = await self._loader.load(
|
||||
workspace_id, today=today, only_pinned_existing=only_pinned_existing
|
||||
)
|
||||
return self._allocator.propose(alloc_input)
|
||||
|
||||
@@ -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