Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -1,53 +0,0 @@
|
||||
"""API schemas (Pydantic) for BreakPeriod.
|
||||
|
||||
Schemas validate request bodies and shape responses. Separate from domain entities
|
||||
so the wire format can evolve independently.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.entities.break_period import BreakPeriod
|
||||
|
||||
|
||||
class BreakPeriodCreate(BaseModel):
|
||||
workspace_id: UUID
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
start_date: date
|
||||
end_date: date
|
||||
target_weeks: int = Field(ge=1, le=52)
|
||||
|
||||
|
||||
class BreakPeriodUpdate(BaseModel):
|
||||
"""Full replacement — all fields required (PUT semantics). Partial updates are not supported."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
start_date: date
|
||||
end_date: date
|
||||
target_weeks: int = Field(ge=1, le=52)
|
||||
|
||||
|
||||
class BreakPeriodResponse(BaseModel):
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
name: str
|
||||
start_date: date
|
||||
end_date: date
|
||||
target_weeks: int
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, entity: BreakPeriod) -> "BreakPeriodResponse":
|
||||
return cls(
|
||||
id=entity.id,
|
||||
workspace_id=entity.workspace_id,
|
||||
name=entity.name,
|
||||
start_date=entity.start_date,
|
||||
end_date=entity.end_date,
|
||||
target_weeks=entity.target_weeks,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
"""API schemas (Pydantic) for the Swedish calendar endpoint (röda dagar /
|
||||
klämdagar).
|
||||
|
||||
Wire format for `GET /api/calendar/days` — serves both §3.4 (shading the
|
||||
Kalender page) and §3.8 (the per-year red-days list in Settings) from one
|
||||
endpoint. Kept separate from the service's dataclasses so the wire format can
|
||||
evolve independently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.calendar_service import CalendarDay, CalendarWindow, DayType
|
||||
|
||||
|
||||
class CalendarDayResponse(BaseModel):
|
||||
date: date
|
||||
name: str | None
|
||||
day_type: DayType
|
||||
is_override: bool
|
||||
override_is_working_day: bool | None
|
||||
is_free: bool
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, day: CalendarDay) -> "CalendarDayResponse":
|
||||
return cls(
|
||||
date=day.date,
|
||||
name=day.name,
|
||||
day_type=day.day_type,
|
||||
is_override=day.is_override,
|
||||
override_is_working_day=day.override_is_working_day,
|
||||
is_free=day.is_free,
|
||||
)
|
||||
|
||||
|
||||
class CalendarDaysResponse(BaseModel):
|
||||
"""A `years`-year window of calendar days (max `MAX_WINDOW_YEARS`,
|
||||
currently 4), plus the full cached range so the UI can bound its
|
||||
pagination controls.
|
||||
"""
|
||||
|
||||
start_year: int
|
||||
end_year: int
|
||||
min_year: int | None = Field(
|
||||
default=None,
|
||||
description="First year covered by the cache, or None if the cache is missing.",
|
||||
)
|
||||
max_year: int | None = Field(
|
||||
default=None, description="Last year covered by the cache, or None if the cache is missing."
|
||||
)
|
||||
days: list[CalendarDayResponse]
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, window: CalendarWindow) -> "CalendarDaysResponse":
|
||||
return cls(
|
||||
start_year=window.start_year,
|
||||
end_year=window.end_year,
|
||||
min_year=window.min_year,
|
||||
max_year=window.max_year,
|
||||
days=[CalendarDayResponse.from_domain(d) for d in window.days],
|
||||
)
|
||||
@@ -17,6 +17,7 @@ class ChildCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
birth_date: date
|
||||
preschool_start_date: date | None = None
|
||||
preschool_planning_days_per_year: int = Field(default=6, ge=0, le=30)
|
||||
|
||||
@field_validator("preschool_start_date")
|
||||
@classmethod
|
||||
@@ -36,6 +37,7 @@ class ChildUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
birth_date: date | None = None
|
||||
preschool_start_date: date | None = None
|
||||
preschool_planning_days_per_year: int | None = Field(None, ge=0, le=30)
|
||||
|
||||
@field_validator("preschool_start_date")
|
||||
@classmethod
|
||||
@@ -55,6 +57,7 @@ class ChildResponse(BaseModel):
|
||||
name: str
|
||||
birth_date: date
|
||||
preschool_start_date: date | None
|
||||
preschool_planning_days_per_year: int
|
||||
created_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
@@ -65,5 +68,6 @@ class ChildResponse(BaseModel):
|
||||
name=entity.name,
|
||||
birth_date=entity.birth_date,
|
||||
preschool_start_date=entity.preschool_start_date,
|
||||
preschool_planning_days_per_year=entity.preschool_planning_days_per_year,
|
||||
created_at=entity.created_at,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""API schemas (Pydantic) for DayOverride.
|
||||
|
||||
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, Field
|
||||
|
||||
from app.entities.day_override import DayOverride
|
||||
|
||||
|
||||
class DayOverrideCreate(BaseModel):
|
||||
workspace_id: UUID
|
||||
date: date
|
||||
is_working_day: bool
|
||||
note: str | None = Field(default=None, max_length=280)
|
||||
|
||||
|
||||
class DayOverrideUpdate(BaseModel):
|
||||
is_working_day: bool
|
||||
note: str | None = Field(default=None, max_length=280)
|
||||
|
||||
|
||||
class DayOverrideResponse(BaseModel):
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
date: date
|
||||
is_working_day: bool
|
||||
note: str | None
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, entity: DayOverride) -> "DayOverrideResponse":
|
||||
return cls(
|
||||
id=entity.id,
|
||||
workspace_id=entity.workspace_id,
|
||||
date=entity.date,
|
||||
is_working_day=entity.is_working_day,
|
||||
note=entity.note,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""API schemas (Pydantic) for LeaveWish.
|
||||
|
||||
Schemas validate request bodies and shape responses. Separate from domain entities
|
||||
so the wire format can evolve independently. Business-rule validation (e.g. the
|
||||
parent_mode / parent_personal_data_uuid pairing) lives in the service, not here —
|
||||
these schemas only enforce shape and range.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.entities.leave_wish import LeaveWish
|
||||
|
||||
|
||||
class LeaveWishCreate(BaseModel):
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None = None
|
||||
name: str = Field(min_length=1, 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)
|
||||
recurrence: str = "once" # 'yearly' | '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" # 'either' | 'both' | 'specific'
|
||||
parent_personal_data_uuid: UUID | None = None
|
||||
|
||||
goal_override: str | None = None # 'max_time' | 'max_money' | 'balanced' | None
|
||||
|
||||
# PLANNING_ENGINE_SPEC.md §10.1 — 'fixed': window_start_*/window_end_* are the
|
||||
# exact placement (default). 'flexible': the same fields describe the
|
||||
# bounding range the allocator searches within for the best-costing window.
|
||||
window_mode: str = "fixed" # 'fixed' | 'flexible'
|
||||
|
||||
|
||||
class LeaveWishUpdate(BaseModel):
|
||||
"""Partial update — all fields optional; only provided fields are changed."""
|
||||
|
||||
child_id: UUID | None = None
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
|
||||
window_start_month: int | None = Field(None, ge=1, le=12)
|
||||
window_start_day: int | None = Field(None, ge=1, le=31)
|
||||
window_end_month: int | None = Field(None, ge=1, le=12)
|
||||
window_end_day: int | None = Field(None, ge=1, le=31)
|
||||
|
||||
target_weeks: int | None = Field(None, ge=1, le=52)
|
||||
recurrence: str | None = None
|
||||
first_year: int | None = Field(None, ge=2000, le=2200)
|
||||
last_year: int | None = Field(None, ge=2000, le=2200)
|
||||
|
||||
priority: int | None = Field(None, ge=1)
|
||||
parent_mode: str | None = None
|
||||
parent_personal_data_uuid: UUID | None = None
|
||||
|
||||
goal_override: str | None = None
|
||||
|
||||
window_mode: str | None = None # 'fixed' | 'flexible'
|
||||
|
||||
|
||||
class LeaveWishResponse(BaseModel):
|
||||
id: UUID
|
||||
workspace_id: UUID
|
||||
child_id: UUID | None
|
||||
name: str
|
||||
|
||||
window_start_month: int
|
||||
window_start_day: int
|
||||
window_end_month: int
|
||||
window_end_day: int
|
||||
|
||||
target_weeks: int
|
||||
recurrence: str
|
||||
first_year: int
|
||||
last_year: int | None
|
||||
|
||||
priority: int
|
||||
parent_mode: str
|
||||
parent_personal_data_uuid: UUID | None
|
||||
|
||||
goal_override: str | None
|
||||
|
||||
window_mode: str
|
||||
|
||||
created_at: datetime | None
|
||||
updated_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, entity: LeaveWish) -> "LeaveWishResponse":
|
||||
return cls(
|
||||
id=entity.id,
|
||||
workspace_id=entity.workspace_id,
|
||||
child_id=entity.child_id,
|
||||
name=entity.name,
|
||||
window_start_month=entity.window_start_month,
|
||||
window_start_day=entity.window_start_day,
|
||||
window_end_month=entity.window_end_month,
|
||||
window_end_day=entity.window_end_day,
|
||||
target_weeks=entity.target_weeks,
|
||||
recurrence=entity.recurrence,
|
||||
first_year=entity.first_year,
|
||||
last_year=entity.last_year,
|
||||
priority=entity.priority,
|
||||
parent_mode=entity.parent_mode,
|
||||
parent_personal_data_uuid=entity.parent_personal_data_uuid,
|
||||
goal_override=entity.goal_override,
|
||||
window_mode=entity.window_mode,
|
||||
created_at=entity.created_at,
|
||||
updated_at=entity.updated_at,
|
||||
)
|
||||
@@ -17,6 +17,10 @@ class PersonalDataUpdate(BaseModel):
|
||||
|
||||
salary_type: str | None = Field(None, pattern="^(monthly|hourly)$")
|
||||
employment_status: str | None = Field(None, pattern="^(employed|jobseeking|none)$")
|
||||
# Semesterlagen minimum (statutory 20; employers may set higher). Bounded
|
||||
# generously (0-60) at the wire level — the service layer is the source
|
||||
# of truth for the real constraint.
|
||||
min_vacation_days_per_year: int | None = Field(None, ge=0, le=60)
|
||||
|
||||
|
||||
class PersonalDataResponse(BaseModel):
|
||||
@@ -25,6 +29,7 @@ class PersonalDataResponse(BaseModel):
|
||||
name: str
|
||||
salary_type: str
|
||||
employment_status: str
|
||||
min_vacation_days_per_year: int
|
||||
created_at: datetime | None
|
||||
deleted_at: datetime | None
|
||||
|
||||
@@ -36,6 +41,7 @@ class PersonalDataResponse(BaseModel):
|
||||
name=entity.name,
|
||||
salary_type=entity.salary_type,
|
||||
employment_status=entity.employment_status,
|
||||
min_vacation_days_per_year=entity.min_vacation_days_per_year,
|
||||
created_at=entity.created_at,
|
||||
deleted_at=entity.deleted_at,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
"""API schemas (Pydantic) for the planning-engine endpoints.
|
||||
|
||||
See PLANNING_ENGINE_SPEC.md §4. `POST /api/plan/propose` is read-only and
|
||||
persists nothing — it just runs `PlanningEngineService.propose()` and shapes
|
||||
the resulting (pure) `PlanProposal` for the wire.
|
||||
|
||||
`POST /api/plan/apply` is the other half: it takes a (possibly user-edited)
|
||||
subset of a proposal's `days` and turns it into real `planned_leave_day`
|
||||
rows via `PlanningService.apply_proposed_days`. See that method's docstring
|
||||
for the idempotency/collision/`replace` semantics.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.planned_leave import PlannedLeaveDayResponse
|
||||
from app.services.allocation_service import PlanProposal
|
||||
from app.services.planning_service import (
|
||||
DayCollision,
|
||||
PlanApplyResult,
|
||||
ProposedDayInput,
|
||||
Rejection,
|
||||
)
|
||||
|
||||
|
||||
class PlanProposeRequest(BaseModel):
|
||||
workspace_id: UUID
|
||||
|
||||
|
||||
class ProposedDayResponse(BaseModel):
|
||||
date: date
|
||||
parent_personal_data_uuid: UUID
|
||||
child_id: UUID | None
|
||||
day_type: str
|
||||
is_dubbeldag: bool
|
||||
wish_id: UUID
|
||||
reason: str
|
||||
|
||||
|
||||
class ShortfallRemedyResponse(BaseModel):
|
||||
kind: str
|
||||
message_key: str
|
||||
data: dict[str, object]
|
||||
days_freed: int
|
||||
|
||||
|
||||
class SurplusAdviceResponse(BaseModel):
|
||||
"""§9.2 — the mirror of `ShortfallRemedyResponse`: a concrete suggestion
|
||||
|
||||
for a parent forecast to *forfeit* FP days at the age-4 boundary, rather
|
||||
than run short. Same (kind, message_key/data, day-count) shape as the
|
||||
remedy; `data` mirrors `PlanWarningResponse.data` for the (parent, child)
|
||||
scope.
|
||||
"""
|
||||
|
||||
kind: str
|
||||
message_key: str
|
||||
days_involved: int
|
||||
data: dict[str, object]
|
||||
|
||||
|
||||
class WindowOutcomeResponse(BaseModel):
|
||||
wish_id: UUID
|
||||
wish_name: str
|
||||
year: int
|
||||
start_date: date
|
||||
end_date: date
|
||||
weeks_wished: int
|
||||
weeks_funded: float
|
||||
shortfall_days: int
|
||||
strategy: str
|
||||
message_key: str
|
||||
data: dict[str, object]
|
||||
cost_breakdown: dict[str, int]
|
||||
remedies: list[ShortfallRemedyResponse]
|
||||
|
||||
|
||||
class PlanWarningResponse(BaseModel):
|
||||
kind: str
|
||||
message_key: str
|
||||
data: dict[str, object]
|
||||
|
||||
|
||||
class PoolBalanceResponse(BaseModel):
|
||||
parent_personal_data_uuid: UUID
|
||||
child_id: UUID
|
||||
sjukpenning_left: int
|
||||
lagsta_left: int
|
||||
dubbeldagar_left: int
|
||||
|
||||
|
||||
class VacationBalanceResponse(BaseModel):
|
||||
parent_personal_data_uuid: UUID
|
||||
year: int
|
||||
total_days: int
|
||||
saved_days_in: int
|
||||
already_used: int
|
||||
# True when no `vacation_days` row existed for this (parent, year) and the
|
||||
# loader defaulted `total_days` to the statutory entitlement rather than
|
||||
# an entered value — see `VacationBudget.is_assumed`. The UI should use
|
||||
# this to prompt the user to confirm/override the assumed year instead of
|
||||
# silently treating it as a real entry.
|
||||
is_assumed: bool
|
||||
|
||||
|
||||
class Age4ProjectionResponse(BaseModel):
|
||||
"""§3.3 part 2 — plan-aware age-4 projection: what this *proposed* plan
|
||||
|
||||
(not yet applied) implies at the child's 4th birthday, per (parent,
|
||||
child). Always present, unlike `surplus_advice` which is only emitted
|
||||
when there's a surplus to advise on.
|
||||
"""
|
||||
|
||||
parent_personal_data_uuid: UUID
|
||||
child_id: UUID
|
||||
fourth_birthday: date
|
||||
projected_left_at_b4: int
|
||||
savable_after_b4: int
|
||||
expiring_at_b4: int
|
||||
|
||||
|
||||
class PlanProposalResponse(BaseModel):
|
||||
days: list[ProposedDayResponse]
|
||||
outcomes: list[WindowOutcomeResponse]
|
||||
warnings: list[PlanWarningResponse]
|
||||
pool_balances: list[PoolBalanceResponse]
|
||||
vacation_balances: list[VacationBalanceResponse]
|
||||
surplus_advice: list[SurplusAdviceResponse]
|
||||
age4_projections: list[Age4ProjectionResponse]
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, proposal: PlanProposal) -> "PlanProposalResponse":
|
||||
return cls(
|
||||
days=[
|
||||
ProposedDayResponse(
|
||||
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,
|
||||
reason=d.reason,
|
||||
)
|
||||
for d in proposal.days
|
||||
],
|
||||
outcomes=[
|
||||
WindowOutcomeResponse(
|
||||
wish_id=o.wish_id,
|
||||
wish_name=o.wish_name,
|
||||
year=o.year,
|
||||
start_date=o.start_date,
|
||||
end_date=o.end_date,
|
||||
weeks_wished=o.weeks_wished,
|
||||
weeks_funded=o.weeks_funded,
|
||||
shortfall_days=o.shortfall_days,
|
||||
strategy=o.strategy,
|
||||
message_key=o.message_key,
|
||||
data=o.data,
|
||||
cost_breakdown=o.cost_breakdown,
|
||||
remedies=[
|
||||
ShortfallRemedyResponse(
|
||||
kind=r.kind,
|
||||
message_key=r.message_key,
|
||||
data=r.data,
|
||||
days_freed=r.days_freed,
|
||||
)
|
||||
for r in o.remedies
|
||||
],
|
||||
)
|
||||
for o in proposal.outcomes
|
||||
],
|
||||
warnings=[
|
||||
PlanWarningResponse(kind=w.kind, message_key=w.message_key, data=w.data)
|
||||
for w in proposal.warnings
|
||||
],
|
||||
pool_balances=[
|
||||
PoolBalanceResponse(
|
||||
parent_personal_data_uuid=parent_id,
|
||||
child_id=child_id,
|
||||
sjukpenning_left=pool.sjukpenning_left,
|
||||
lagsta_left=pool.lagsta_left,
|
||||
dubbeldagar_left=pool.dubbeldagar_left,
|
||||
)
|
||||
for (parent_id, child_id), pool in proposal.balances_after.pools_after.items()
|
||||
],
|
||||
vacation_balances=[
|
||||
VacationBalanceResponse(
|
||||
parent_personal_data_uuid=parent_id,
|
||||
year=year,
|
||||
total_days=budget.total_days,
|
||||
saved_days_in=budget.saved_days_in,
|
||||
already_used=budget.already_used,
|
||||
is_assumed=budget.is_assumed,
|
||||
)
|
||||
for (parent_id, year), budget in proposal.balances_after.vacation_after.items()
|
||||
],
|
||||
surplus_advice=[
|
||||
SurplusAdviceResponse(
|
||||
kind=a.kind,
|
||||
message_key=a.message_key,
|
||||
days_involved=a.days_involved,
|
||||
data=a.data,
|
||||
)
|
||||
for a in proposal.surplus_advice
|
||||
],
|
||||
age4_projections=[
|
||||
Age4ProjectionResponse(
|
||||
parent_personal_data_uuid=p.parent_personal_data_uuid,
|
||||
child_id=p.child_id,
|
||||
fourth_birthday=p.fourth_birthday,
|
||||
projected_left_at_b4=p.projected_left_at_b4,
|
||||
savable_after_b4=p.savable_after_b4,
|
||||
expiring_at_b4=p.expiring_at_b4,
|
||||
)
|
||||
for p in proposal.age4_projections
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/plan/apply
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProposedDayApplyItem(BaseModel):
|
||||
"""One day to persist. Same shape as `ProposedDayResponse` minus the
|
||||
explain-only `wish_id`/`reason` (harmless if a caller sends them anyway —
|
||||
a frontend can hand back `ProposedDayResponse` entries from `/propose`
|
||||
verbatim; extra fields are ignored).
|
||||
|
||||
`day_type` additionally accepts 'free' (an automatic weekend/red-day
|
||||
entry from a proposal) purely so a client doesn't have to filter those
|
||||
out itself — they are always skipped, never persisted, see
|
||||
`PlanApplyResponse.skipped_free_count`.
|
||||
"""
|
||||
|
||||
date: date
|
||||
parent_personal_data_uuid: UUID
|
||||
child_id: UUID
|
||||
day_type: str = Field(pattern="^(sjukpenning|lagsta|vacation|free)$")
|
||||
is_dubbeldag: bool = False
|
||||
|
||||
def to_domain(self) -> ProposedDayInput:
|
||||
return ProposedDayInput(
|
||||
date=self.date,
|
||||
parent_personal_data_uuid=self.parent_personal_data_uuid,
|
||||
child_id=self.child_id,
|
||||
day_type=self.day_type,
|
||||
is_dubbeldag=self.is_dubbeldag,
|
||||
)
|
||||
|
||||
|
||||
class PlanApplyRequest(BaseModel):
|
||||
workspace_id: UUID
|
||||
days: list[ProposedDayApplyItem]
|
||||
replace: bool = False
|
||||
|
||||
|
||||
class DayCollisionResponse(BaseModel):
|
||||
date: date
|
||||
parent_personal_data_uuid: UUID
|
||||
child_id: UUID
|
||||
existing_day_type: str
|
||||
proposed_day_type: str
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, collision: DayCollision) -> "DayCollisionResponse":
|
||||
return cls(
|
||||
date=collision.date,
|
||||
parent_personal_data_uuid=collision.parent_personal_data_uuid,
|
||||
child_id=collision.child_id,
|
||||
existing_day_type=collision.existing_day_type,
|
||||
proposed_day_type=collision.proposed_day_type,
|
||||
)
|
||||
|
||||
|
||||
class RejectionResponse(BaseModel):
|
||||
"""§3.5 — a single reason a batch (or part of it) was refused, i18n-ready:
|
||||
|
||||
`message_key` + `data` (the `tips_service.Tip` shape) instead of a
|
||||
pre-rendered string, so `PlanResults.tsx` can render it in the caller's
|
||||
active locale.
|
||||
"""
|
||||
|
||||
message_key: str
|
||||
data: dict[str, object]
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, rejection: Rejection) -> "RejectionResponse":
|
||||
return cls(message_key=rejection.message_key, data=rejection.data)
|
||||
|
||||
|
||||
class PlanApplyResponse(BaseModel):
|
||||
accepted: bool
|
||||
created_count: int
|
||||
skipped_identical_count: int
|
||||
skipped_free_count: int
|
||||
replaced_count: int
|
||||
collisions: list[DayCollisionResponse]
|
||||
rejections: list[RejectionResponse]
|
||||
created_days: list[PlannedLeaveDayResponse]
|
||||
|
||||
@classmethod
|
||||
def from_domain(cls, result: PlanApplyResult) -> "PlanApplyResponse":
|
||||
return cls(
|
||||
accepted=result.accepted,
|
||||
created_count=result.created_count,
|
||||
skipped_identical_count=result.skipped_identical_count,
|
||||
skipped_free_count=result.skipped_free_count,
|
||||
replaced_count=result.replaced_count,
|
||||
collisions=[DayCollisionResponse.from_domain(c) for c in result.collisions],
|
||||
rejections=[RejectionResponse.from_domain(r) for r in result.rejections],
|
||||
created_days=[PlannedLeaveDayResponse.from_entity(d) for d in result.created],
|
||||
)
|
||||
@@ -16,17 +16,25 @@ class WorkspaceCreate(BaseModel):
|
||||
class WorkspaceUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||
planning_goal: str | None = None
|
||||
planning_mode: str | None = None
|
||||
|
||||
|
||||
class WorkspaceResponse(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
planning_goal: str
|
||||
planning_mode: str
|
||||
created_at: datetime | None
|
||||
|
||||
@classmethod
|
||||
def from_entity(cls, ws: Workspace) -> "WorkspaceResponse":
|
||||
return cls(id=ws.id, name=ws.name, planning_goal=ws.planning_goal, created_at=ws.created_at)
|
||||
return cls(
|
||||
id=ws.id,
|
||||
name=ws.name,
|
||||
planning_goal=ws.planning_goal,
|
||||
planning_mode=ws.planning_mode,
|
||||
created_at=ws.created_at,
|
||||
)
|
||||
|
||||
|
||||
class PersonalDataResponse(BaseModel):
|
||||
|
||||
Reference in New Issue
Block a user