Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""API schemas (Pydantic) for PlannedDayExclusion.
|
||||
"""API schemas (Pydantic) for DayAssertion.
|
||||
|
||||
Schemas are the API boundary — they validate request bodies and shape
|
||||
responses, kept separate from the domain entity so the wire format can
|
||||
@@ -8,35 +8,46 @@ evolve independently.
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.entities.planned_day_exclusion import PlannedDayExclusion
|
||||
from app.entities.day_assertion import DayAssertion
|
||||
|
||||
|
||||
class PlannedDayExclusionCreate(BaseModel):
|
||||
class DayAssertionCreate(BaseModel):
|
||||
workspace_id: UUID
|
||||
child_id: UUID
|
||||
parent_personal_data_uuid: UUID
|
||||
date: date
|
||||
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|none)$")
|
||||
is_dubbeldag: bool = False
|
||||
# Attachment only (band naming) — None means an ad-hoc assertion. See
|
||||
# `app.entities.day_assertion.DayAssertion`.
|
||||
wish_id: UUID | None = None
|
||||
|
||||
|
||||
class PlannedDayExclusionResponse(BaseModel):
|
||||
class DayAssertionResponse(BaseModel):
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
child_id: UUID
|
||||
parent_personal_data_uuid: UUID
|
||||
date: date
|
||||
day_type: str
|
||||
is_dubbeldag: bool
|
||||
wish_id: UUID | None
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, entity: PlannedDayExclusion) -> "PlannedDayExclusionResponse":
|
||||
def from_entity(cls, entity: DayAssertion) -> "DayAssertionResponse":
|
||||
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,
|
||||
day_type=entity.day_type,
|
||||
is_dubbeldag=entity.is_dubbeldag,
|
||||
wish_id=entity.wish_id,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
)
|
||||
+177
-101
@@ -14,28 +14,24 @@ from datetime import date
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.models.leave_wish import LeaveWishResponse, LeaveWishUpdate
|
||||
from app.models.planned_leave import PlannedLeaveDayResponse
|
||||
from app.services.allocation_service import PlanProposal
|
||||
from app.services.planning_service import (
|
||||
DayCollision,
|
||||
EvictionCandidate,
|
||||
EvictionPreview,
|
||||
DayLockAction,
|
||||
PlanApplyResult,
|
||||
PlanChange,
|
||||
PlanSyncResult,
|
||||
ProposedDayInput,
|
||||
Rejection,
|
||||
ResourceShortage,
|
||||
WishCreateInput,
|
||||
WishSyncResult,
|
||||
WishUpdateInput,
|
||||
)
|
||||
|
||||
# Wire-level mirror of `PlanningService`'s `EVICTION_CHOICE_*` constants —
|
||||
# kept as a `Literal` here (rather than importing the string constants) so
|
||||
# an invalid value 422s at the Pydantic boundary instead of silently
|
||||
# reaching the service as a no-op "do nothing".
|
||||
EvictionChoice = Literal["drop_future_pl", "drop_latest_vacation_this_year"]
|
||||
|
||||
|
||||
class PlanProposeRequest(BaseModel):
|
||||
workspace_id: UUID
|
||||
@@ -98,6 +94,11 @@ class WindowOutcomeResponse(BaseModel):
|
||||
# — spendable days the flexible placement search saved versus the naive
|
||||
# bound-start placement. 0 for a fixed window or a reused flexible one.
|
||||
shift_gain_days: int = 0
|
||||
# Mirrors `WindowOutcome.series_id` — see its docstring. Lets a caller
|
||||
# that only knows ONE occurrence's wish id (e.g. `sync_with_wish`'s
|
||||
# returned `wish.id`) find every OTHER outcome in this same batch that
|
||||
# belongs to the same repeating wish.
|
||||
series_id: UUID | None = None
|
||||
|
||||
|
||||
class PlanWarningResponse(BaseModel):
|
||||
@@ -197,6 +198,7 @@ class PlanProposalResponse(BaseModel):
|
||||
for r in o.remedies
|
||||
],
|
||||
shift_gain_days=o.shift_gain_days,
|
||||
series_id=o.series_id,
|
||||
)
|
||||
for o in proposal.outcomes
|
||||
],
|
||||
@@ -275,7 +277,6 @@ class ProposedDayApplyItem(BaseModel):
|
||||
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|free)$")
|
||||
is_dubbeldag: bool = False
|
||||
wish_id: UUID | None = None
|
||||
is_pinned: bool = False
|
||||
|
||||
def to_domain(self) -> ProposedDayInput:
|
||||
return ProposedDayInput(
|
||||
@@ -285,7 +286,6 @@ class ProposedDayApplyItem(BaseModel):
|
||||
day_type=self.day_type,
|
||||
is_dubbeldag=self.is_dubbeldag,
|
||||
wish_id=self.wish_id,
|
||||
is_pinned=self.is_pinned,
|
||||
)
|
||||
|
||||
|
||||
@@ -362,15 +362,6 @@ class PlanApplyResponse(BaseModel):
|
||||
|
||||
class PlanSyncRequest(BaseModel):
|
||||
workspace_id: UUID
|
||||
# Which of the three user-facing options resolves a would-evict save
|
||||
# (§ eviction preview). Omitted/`None` — the default — means "do
|
||||
# nothing": the save is refused and nothing is mutated, exactly as if
|
||||
# the user picked the explicit cancel option. Only meaningful when the
|
||||
# response's `eviction_preview.would_evict` was `True` on a prior call
|
||||
# with the same edit already persisted upstream (e.g. via the leave-wish
|
||||
# endpoints) — a plain sync that fits within budget ignores this field
|
||||
# entirely (never asked for a choice in the first place).
|
||||
eviction_choice: EvictionChoice | None = None
|
||||
|
||||
|
||||
class PlanChangeResponse(BaseModel):
|
||||
@@ -391,6 +382,12 @@ class PlanChangeResponse(BaseModel):
|
||||
new_is_dubbeldag: bool | None
|
||||
old_wish_id: UUID | None
|
||||
new_wish_id: UUID | None
|
||||
# Only ever `True` on a `kind == 'removed'` change — see `PlanChange.
|
||||
# evicted`'s docstring: distinguishes "this wish's own edit shed the
|
||||
# row" (evicted=False, the ordinary case) from "a different wish's edit
|
||||
# won a shared pool ahead of it" (evicted=True) so the UI can call out
|
||||
# displacement distinctly instead of folding it into a silent count.
|
||||
evicted: bool
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, change: PlanChange) -> "PlanChangeResponse":
|
||||
@@ -405,84 +402,7 @@ class PlanChangeResponse(BaseModel):
|
||||
new_is_dubbeldag=change.new_is_dubbeldag,
|
||||
old_wish_id=change.old_wish_id,
|
||||
new_wish_id=change.new_wish_id,
|
||||
)
|
||||
|
||||
|
||||
class EvictionCandidateResponse(BaseModel):
|
||||
"""One previously-persisted day a fresh sync would otherwise silently
|
||||
|
||||
delete — see `EvictionCandidate`'s docstring. `wish_name` is `None` when
|
||||
the owning wish's name couldn't be resolved (never a reason to hide the
|
||||
day itself).
|
||||
"""
|
||||
|
||||
date: date
|
||||
parent_personal_data_uuid: UUID
|
||||
child_id: UUID
|
||||
day_type: str
|
||||
is_dubbeldag: bool
|
||||
wish_id: UUID
|
||||
wish_name: str | None
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, candidate: EvictionCandidate) -> "EvictionCandidateResponse":
|
||||
return cls(
|
||||
date=candidate.date,
|
||||
parent_personal_data_uuid=candidate.parent_personal_data_uuid,
|
||||
child_id=candidate.child_id,
|
||||
day_type=candidate.day_type,
|
||||
is_dubbeldag=candidate.is_dubbeldag,
|
||||
wish_id=candidate.wish_id,
|
||||
wish_name=candidate.wish_name,
|
||||
)
|
||||
|
||||
|
||||
class ResourceShortageResponse(BaseModel):
|
||||
"""One exhausted resource behind an eviction preview — see
|
||||
|
||||
`ResourceShortage`'s docstring. `resource_kind == 'parental_leave'`
|
||||
carries `tier`/`child_id` (`year` is `None`); `resource_kind ==
|
||||
'vacation'` carries `year` instead (`tier`/`child_id` are `None`).
|
||||
Structured on purpose — the frontend composes the warning text itself,
|
||||
never a backend-rendered string.
|
||||
"""
|
||||
|
||||
resource_kind: str
|
||||
parent_personal_data_uuid: UUID
|
||||
tier: str | None
|
||||
child_id: UUID | None
|
||||
year: int | None
|
||||
days_evicted: int
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, shortage: ResourceShortage) -> "ResourceShortageResponse":
|
||||
return cls(
|
||||
resource_kind=shortage.resource_kind,
|
||||
parent_personal_data_uuid=shortage.parent_personal_data_uuid,
|
||||
tier=shortage.tier,
|
||||
child_id=shortage.child_id,
|
||||
year=shortage.year,
|
||||
days_evicted=shortage.days_evicted,
|
||||
)
|
||||
|
||||
|
||||
class EvictionPreviewResponse(BaseModel):
|
||||
"""Always present on `PlanSyncResponse`; `would_evict: false` with empty
|
||||
|
||||
lists in the common (fits-within-budget) case — see
|
||||
`EvictionPreview`'s docstring.
|
||||
"""
|
||||
|
||||
would_evict: bool
|
||||
candidates: list[EvictionCandidateResponse]
|
||||
shortages: list[ResourceShortageResponse]
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, preview: EvictionPreview) -> "EvictionPreviewResponse":
|
||||
return cls(
|
||||
would_evict=preview.would_evict,
|
||||
candidates=[EvictionCandidateResponse.from_domain(c) for c in preview.candidates],
|
||||
shortages=[ResourceShortageResponse.from_domain(s) for s in preview.shortages],
|
||||
evicted=change.evicted,
|
||||
)
|
||||
|
||||
|
||||
@@ -490,7 +410,6 @@ class PlanSyncResponse(BaseModel):
|
||||
proposal: PlanProposalResponse
|
||||
apply_result: PlanApplyResponse
|
||||
changes: list[PlanChangeResponse]
|
||||
eviction_preview: EvictionPreviewResponse
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, result: PlanSyncResult) -> "PlanSyncResponse":
|
||||
@@ -498,5 +417,162 @@ class PlanSyncResponse(BaseModel):
|
||||
proposal=PlanProposalResponse.from_domain(result.proposal),
|
||||
apply_result=PlanApplyResponse.from_domain(result.apply_result),
|
||||
changes=[PlanChangeResponse.from_domain(c) for c in result.changes],
|
||||
eviction_preview=EvictionPreviewResponse.from_domain(result.eviction_preview),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/plan/sync-with-wish — the calendar's four create/edit-then-sync
|
||||
# paths, collapsed into one atomic backend call. See
|
||||
# `PlanningService.sync_with_wish`'s docstring for the atomicity guarantee.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WishCreatePayload(BaseModel):
|
||||
"""Fields for the wish behind a drawn/promoted/preset period.
|
||||
|
||||
Field-for-field identical to `LeaveWishCreate` minus `workspace_id`
|
||||
(carried once, on `PlanSyncWithWishRequest` itself).
|
||||
"""
|
||||
|
||||
child_id: UUID | None = None
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
name_message_key: str | None = Field(default=None, max_length=255)
|
||||
|
||||
window_start_month: int = Field(ge=1, le=12)
|
||||
window_start_day: int = Field(ge=1, le=31)
|
||||
window_end_month: int = Field(ge=1, le=12)
|
||||
window_end_day: int = Field(ge=1, le=31)
|
||||
|
||||
target_weeks: int = Field(ge=1, le=52)
|
||||
target_days: int | None = Field(default=None, ge=1)
|
||||
recurrence: str = "once"
|
||||
first_year: int = Field(ge=2000, le=2200)
|
||||
last_year: int | None = Field(default=None, ge=2000, le=2200)
|
||||
|
||||
priority: int = Field(default=1, ge=1)
|
||||
parent_mode: str = "either"
|
||||
parent_personal_data_uuid: UUID | None = None
|
||||
|
||||
window_mode: str = "fixed"
|
||||
series_id: UUID | None = None
|
||||
wants_dubbeldag: bool = False
|
||||
|
||||
def to_domain(self) -> WishCreateInput:
|
||||
return WishCreateInput(
|
||||
child_id=self.child_id,
|
||||
name=self.name,
|
||||
name_message_key=self.name_message_key,
|
||||
window_start_month=self.window_start_month,
|
||||
window_start_day=self.window_start_day,
|
||||
window_end_month=self.window_end_month,
|
||||
window_end_day=self.window_end_day,
|
||||
target_weeks=self.target_weeks,
|
||||
target_days=self.target_days,
|
||||
recurrence=self.recurrence,
|
||||
first_year=self.first_year,
|
||||
last_year=self.last_year,
|
||||
priority=self.priority,
|
||||
parent_mode=self.parent_mode,
|
||||
parent_personal_data_uuid=self.parent_personal_data_uuid,
|
||||
window_mode=self.window_mode,
|
||||
series_id=self.series_id,
|
||||
wants_dubbeldag=self.wants_dubbeldag,
|
||||
)
|
||||
|
||||
|
||||
class WishUpdatePayload(BaseModel):
|
||||
"""One wish's occurrence/series edit, addressed by id — reuses
|
||||
`LeaveWishUpdate` verbatim for `patch` (it already has no `workspace_id`
|
||||
field, so it needs no stripped-down mirror the way `WishCreatePayload`
|
||||
does).
|
||||
"""
|
||||
|
||||
leave_wish_id: UUID
|
||||
scope: Literal["occurrence", "series"] = "occurrence"
|
||||
patch: LeaveWishUpdate
|
||||
|
||||
def to_domain(self) -> WishUpdateInput:
|
||||
fields_set = self.patch.model_fields_set
|
||||
p = self.patch
|
||||
return WishUpdateInput(
|
||||
leave_wish_id=self.leave_wish_id,
|
||||
scope=self.scope,
|
||||
child_id=p.child_id,
|
||||
name=p.name,
|
||||
name_message_key=p.name_message_key,
|
||||
window_start_month=p.window_start_month,
|
||||
window_start_day=p.window_start_day,
|
||||
window_end_month=p.window_end_month,
|
||||
window_end_day=p.window_end_day,
|
||||
target_weeks=p.target_weeks,
|
||||
target_days=p.target_days,
|
||||
recurrence=p.recurrence,
|
||||
first_year=p.first_year,
|
||||
last_year=p.last_year,
|
||||
priority=p.priority,
|
||||
parent_mode=p.parent_mode,
|
||||
parent_personal_data_uuid=p.parent_personal_data_uuid,
|
||||
window_mode=p.window_mode,
|
||||
wants_dubbeldag=p.wants_dubbeldag,
|
||||
clear_child_id="child_id" in fields_set and p.child_id is None,
|
||||
clear_last_year="last_year" in fields_set and p.last_year is None,
|
||||
clear_parent_personal_data_uuid=(
|
||||
"parent_personal_data_uuid" in fields_set and p.parent_personal_data_uuid is None
|
||||
),
|
||||
clear_name_message_key=(
|
||||
"name_message_key" in fields_set and p.name_message_key is None
|
||||
),
|
||||
clear_target_days="target_days" in fields_set and p.target_days is None,
|
||||
)
|
||||
|
||||
|
||||
DayLockActionKind = Literal["attach", "pin", "unpin", "detach"]
|
||||
|
||||
|
||||
class DayLockActionRequest(BaseModel):
|
||||
"""One already-persisted `planned_leave_day` row to re-point/pin/delete
|
||||
|
||||
as part of the same atomic call — see `DayLockAction`'s docstring
|
||||
(app/services/planning_service.py) for what each `action` does.
|
||||
"""
|
||||
|
||||
planned_day_id: UUID
|
||||
action: DayLockActionKind
|
||||
|
||||
def to_domain(self) -> DayLockAction:
|
||||
return DayLockAction(planned_day_id=self.planned_day_id, action=self.action)
|
||||
|
||||
|
||||
class PlanSyncWithWishRequest(BaseModel):
|
||||
workspace_id: UUID
|
||||
# Exactly one of these two — this call always touches exactly one wish.
|
||||
wish_create: WishCreatePayload | None = None
|
||||
wish_update: WishUpdatePayload | None = None
|
||||
day_actions: list[DayLockActionRequest] = Field(default_factory=list)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _exactly_one_wish_op(self) -> "PlanSyncWithWishRequest":
|
||||
if (self.wish_create is None) == (self.wish_update is None):
|
||||
raise ValueError("exactly one of wish_create/wish_update must be provided")
|
||||
return self
|
||||
|
||||
|
||||
class PlanSyncWithWishResponse(BaseModel):
|
||||
"""`wish` reflects the fields the caller asked for even when
|
||||
|
||||
`sync.apply_result.accepted` is `False` — see `WishSyncResult`'s
|
||||
docstring: in that case nothing was actually persisted (the whole point
|
||||
of the atomic call), but the frontend still needs the would-be id/fields
|
||||
to render the outcome (e.g. matching `sync.proposal.outcomes` by
|
||||
`wish_id`).
|
||||
"""
|
||||
|
||||
wish: LeaveWishResponse
|
||||
sync: PlanSyncResponse
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, result: WishSyncResult) -> "PlanSyncWithWishResponse":
|
||||
return cls(
|
||||
wish=LeaveWishResponse.from_entity(result.wish),
|
||||
sync=PlanSyncResponse.from_domain(result.sync),
|
||||
)
|
||||
|
||||
@@ -7,33 +7,24 @@ so the wire format can evolve independently.
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
|
||||
|
||||
class PlannedLeaveDayCreate(BaseModel):
|
||||
workspace_id: UUID
|
||||
child_id: UUID
|
||||
parent_personal_data_uuid: UUID
|
||||
date: date
|
||||
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation)$")
|
||||
is_dubbeldag: bool = False
|
||||
# Which `LeaveWish` produced this day; None = ad-hoc.
|
||||
wish_id: UUID | None = None
|
||||
is_pinned: bool = False
|
||||
|
||||
|
||||
class PlannedLeaveDayUpdate(BaseModel):
|
||||
"""Partial update — all fields optional."""
|
||||
|
||||
day_type: str | None = Field(None, pattern="^(sjukpenning|lagsta|vacation)$")
|
||||
is_dubbeldag: bool | None = None
|
||||
wish_id: UUID | None = None
|
||||
is_pinned: bool | None = None
|
||||
|
||||
|
||||
class PlannedLeaveDayResponse(BaseModel):
|
||||
"""Read-only wire shape. `planned_leave_day` is purely derived output
|
||||
|
||||
now (restructure step 3b) — there is no `PlannedLeaveDayCreate`/`Update`
|
||||
model any more; every write goes through `POST /api/plan/sync(-with-
|
||||
wish)` or `POST /api/day-assertions`. `is_pinned` is NOT a column on the
|
||||
entity (migration 010 dropped it) — it is computed live from
|
||||
`DayAssertion` and passed in explicitly by the caller (see
|
||||
`PlanningService._annotate_pinned`), so a lock icon in the calendar UI
|
||||
keeps working unchanged even though nothing on `planned_leave_day`
|
||||
itself carries that state any more.
|
||||
"""
|
||||
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
child_id: UUID
|
||||
@@ -47,7 +38,9 @@ class PlannedLeaveDayResponse(BaseModel):
|
||||
updated_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, entity: PlannedLeaveDay) -> "PlannedLeaveDayResponse":
|
||||
def from_entity(
|
||||
cls, entity: PlannedLeaveDay, is_pinned: bool = False
|
||||
) -> "PlannedLeaveDayResponse":
|
||||
return cls(
|
||||
id=entity.id,
|
||||
workspace_id=entity.workspace_id,
|
||||
@@ -57,7 +50,7 @@ class PlannedLeaveDayResponse(BaseModel):
|
||||
day_type=entity.day_type,
|
||||
is_dubbeldag=entity.is_dubbeldag,
|
||||
wish_id=entity.wish_id,
|
||||
is_pinned=entity.is_pinned,
|
||||
is_pinned=is_pinned,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user