0e9b20ec37
Three strands of work that grew together across the same settings screen and
API client, so they land as one change.
Per-year preschool planning days ("studiedagar"): new table, repository
(SQLite + Postgres), service, router and migration 007, plus the hooks and
Settings UI to plan and record them per child and year. Allocation now
reserves the recorded figure for a year where one exists and only falls back
to the child-level estimate otherwise, marking that fallback as assumed.
Partner invitations: workspaces issue and list invitations, registration
accepts an invite token to join the inviter's workspace instead of creating a
personal one, and a /join/:token page plus the settings invite panel drive it.
Vacation forfeiture: tips and the dashboard now distinguish days a parent
genuinely loses from days that merely age, so the warning stops firing on
balances that were never at risk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94 lines
3.7 KiB
Python
94 lines
3.7 KiB
Python
"""Business logic for per-year preschool planning ("studiedagar") days.
|
|
|
|
The service owns the "no row yet" fallback: `child.preschool_planning_days_per_year`
|
|
seeds `planned_days` the first time a year is touched (either a planned-days
|
|
override or a taken-days record), so a caller never has to know whether a row
|
|
already exists.
|
|
|
|
NOTE for the allocation engine (`app/services/allocation_service.py`,
|
|
`_preschool_reserve_days`): this service/table is not wired into it yet.
|
|
That currently still multiplies `child.preschool_planning_days_per_year` by
|
|
the number of years in the horizon. A follow-up should make it consume
|
|
`PreschoolPlanningDaysRepository.list(child_id=...)` per year instead,
|
|
falling back to the child default for years with no row (see
|
|
`PreschoolPlanningDaysResponse.default_for`).
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from uuid import UUID, uuid4
|
|
|
|
from app.entities.child import Child
|
|
from app.entities.preschool_planning_days import PreschoolPlanningDays
|
|
from app.repositories.preschool_planning_days_repository import (
|
|
PreschoolPlanningDaysRepository,
|
|
)
|
|
from app.utils.errors import NotFoundError, ValidationError
|
|
|
|
|
|
class PreschoolPlanningDaysService:
|
|
def __init__(self, repo: PreschoolPlanningDaysRepository) -> None:
|
|
self._repo = repo
|
|
|
|
async def upsert_planned(
|
|
self, child: Child, year: int, planned_days: int
|
|
) -> PreschoolPlanningDays:
|
|
"""Create or update the planned-days figure for (child, year).
|
|
|
|
Preserves any already-recorded `taken_days`.
|
|
"""
|
|
if planned_days < 0:
|
|
raise ValidationError("Planned days cannot be negative")
|
|
|
|
existing = await self._repo.get_by_child_year(child.id, year)
|
|
if existing is not None:
|
|
existing.planned_days = planned_days
|
|
return await self._repo.update(existing.id, existing)
|
|
|
|
entity = PreschoolPlanningDays(
|
|
id=uuid4(),
|
|
child_id=child.id,
|
|
year=year,
|
|
planned_days=planned_days,
|
|
taken_days=None,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
return await self._repo.create(entity)
|
|
|
|
async def record_taken(self, child: Child, year: int, taken_days: int) -> PreschoolPlanningDays:
|
|
"""Record how many planning days were actually taken for (child, year).
|
|
|
|
Seeds `planned_days` from `child.preschool_planning_days_per_year` if
|
|
no row exists yet.
|
|
"""
|
|
if taken_days < 0:
|
|
raise ValidationError("Taken days cannot be negative")
|
|
|
|
existing = await self._repo.get_by_child_year(child.id, year)
|
|
if existing is not None:
|
|
existing.taken_days = taken_days
|
|
return await self._repo.update(existing.id, existing)
|
|
|
|
entity = PreschoolPlanningDays(
|
|
id=uuid4(),
|
|
child_id=child.id,
|
|
year=year,
|
|
planned_days=child.preschool_planning_days_per_year,
|
|
taken_days=taken_days,
|
|
created_at=datetime.now(),
|
|
updated_at=datetime.now(),
|
|
)
|
|
return await self._repo.create(entity)
|
|
|
|
async def get_by_child_year(self, child_id: UUID, year: int) -> PreschoolPlanningDays | None:
|
|
return await self._repo.get_by_child_year(child_id, year)
|
|
|
|
async def get(self, entry_id: UUID, child_id: UUID | None = None) -> PreschoolPlanningDays:
|
|
entity = await self._repo.get_by_id(entry_id)
|
|
if entity is None or (child_id is not None and entity.child_id != child_id):
|
|
raise NotFoundError(f"PreschoolPlanningDays {entry_id} not found")
|
|
return entity
|
|
|
|
async def list_for_child(self, child_id: UUID) -> list[PreschoolPlanningDays]:
|
|
return await self._repo.list(child_id=child_id)
|