Initial commit

This commit is contained in:
2026-06-01 16:48:21 +02:00
commit 53d157535a
159 changed files with 13641 additions and 0 deletions
View File
+181
View File
@@ -0,0 +1,181 @@
"""Analytics service: days-left-at-age-4 and planned-vs-actual reconciliation.
The headline calculation (§6.5 ARCHITECTURE.md):
projected_used_by_B4 = days_used + planned_days(date < B4)
projected_left_at_B4 = total_days - projected_used_by_B4
savable_after_B4 = min(projected_left_at_B4, 96) # age-4 ceiling
expiring_at_B4 = max(projected_left_at_B4 - 96, 0) # LOST days
All rule constants come from config.LeaveRules — no magic numbers.
dateutil.relativedelta handles leap-year safe 4th-birthday arithmetic.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from uuid import UUID
from dateutil.relativedelta import relativedelta
from app.config import settings
from app.repositories.child_repository import ChildRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.utils.errors import NotFoundError
@dataclass
class Age4Projection:
"""Per-parent projection of days remaining at the child's 4th birthday."""
personal_data_uuid: UUID
child_id: UUID
birth_date: date
fourth_birthday: date
total_days: int
days_used: int # from latest FK snapshot
planned_days_before_b4: int # from calendar
projected_left_at_b4: int
savable_after_b4: int # min(projected_left, 96)
expiring_at_b4: int # max(projected_left - 96, 0) — LOST if not used
@dataclass
class WorkspaceOverview:
workspace_id: UUID
child_id: UUID
parent_projections: list[Age4Projection]
combined_days_used: int
combined_days_left: int
combined_expiring_at_b4: int
class AnalyticsService:
def __init__(
self,
parental_leave_repo: ParentalLeaveRepository,
planned_leave_repo: PlannedLeaveRepository,
child_repo: ChildRepository,
personal_data_repo: PersonalDataRepository,
) -> None:
self._leave = parental_leave_repo
self._planned = planned_leave_repo
self._children = child_repo
self._parents = personal_data_repo
async def get_days_left_at_age_4(
self, personal_data_uuid: UUID, child_id: UUID
) -> Age4Projection:
"""Compute the headline projection for a single parent+child pair."""
rules = settings.rules
child = await self._children.get_by_id(child_id)
if child is None:
raise NotFoundError(f"Child {child_id} not found")
# Compute the 4th birthday with leap-year-safe arithmetic.
b4 = child.birth_date + relativedelta(years=4)
latest = await self._leave.get_latest_by_parent(personal_data_uuid, child_id)
if latest is None:
# No FK data yet — assume full entitlement with 0 used.
days_used = 0
total_days = rules.days_per_parent
else:
days_used = latest.days_used
total_days = latest.total_days
# Count föräldrapenning planned days that fall before the 4th birthday.
# Vacation days do NOT count against the FP pool.
today = date.today()
sjukpenning_before_b4 = await self._planned.count_by_parent_type(
personal_data_uuid,
child_id,
"sjukpenning",
from_date=today,
to_date=b4,
)
lagsta_before_b4 = await self._planned.count_by_parent_type(
personal_data_uuid,
child_id,
"lagsta",
from_date=today,
to_date=b4,
)
planned_fp_before_b4 = sjukpenning_before_b4 + lagsta_before_b4
projected_used = days_used + planned_fp_before_b4
projected_left = total_days - projected_used
savable = min(projected_left, rules.max_savable_days_at_age_4)
expiring = max(projected_left - rules.max_savable_days_at_age_4, 0)
return Age4Projection(
personal_data_uuid=personal_data_uuid,
child_id=child_id,
birth_date=child.birth_date,
fourth_birthday=b4,
total_days=total_days,
days_used=days_used,
planned_days_before_b4=planned_fp_before_b4,
projected_left_at_b4=projected_left,
savable_after_b4=savable,
expiring_at_b4=expiring,
)
async def get_overview(self, workspace_id: UUID, child_id: UUID) -> WorkspaceOverview:
"""Compute projections for all parents in a workspace for one child."""
members = await self._parents.list_by_workspace(workspace_id)
if not members:
raise NotFoundError(f"No parents found in workspace {workspace_id}")
projections = []
for pd in members:
proj = await self.get_days_left_at_age_4(pd.uuid, child_id)
projections.append(proj)
combined_used = sum(p.days_used + p.planned_days_before_b4 for p in projections)
combined_left = sum(p.projected_left_at_b4 for p in projections)
combined_expiring = sum(p.expiring_at_b4 for p in projections)
return WorkspaceOverview(
workspace_id=workspace_id,
child_id=child_id,
parent_projections=projections,
combined_days_used=combined_used,
combined_days_left=combined_left,
combined_expiring_at_b4=combined_expiring,
)
async def validate_dubbeldagar(self, child_id: UUID, new_date: date) -> None:
"""Raise DubbeldagError if adding a dubbeldagar day would break rules.
Called from the planning service before persisting a new day.
"""
from app.utils.errors import DubbeldagError
child = await self._children.get_by_id(child_id)
if child is None:
raise NotFoundError(f"Child {child_id} not found")
# Window: child's birth_date ... birth_date + 15 months
window_end = child.birth_date + relativedelta(
months=settings.rules.dubbeldagar_months_limit
)
if new_date > window_end:
raise DubbeldagError(
f"Dubbeldagar only allowed within the child's first "
f"{settings.rules.dubbeldagar_months_limit} months "
f"(deadline {window_end})"
)
# Quota: count already-planned dubbeldagar from child's birth_date onward.
already_planned = await self._planned.count_dubbeldagar(
child_id, from_date=child.birth_date
)
if already_planned >= settings.rules.max_dubbeldagar:
raise DubbeldagError(
f"Dubbeldagar quota ({settings.rules.max_dubbeldagar} days) already reached"
)
+138
View File
@@ -0,0 +1,138 @@
"""Authentication service.
Handles registration, login, token generation, and current-user lookup.
Passwords hashed with argon2 (via argon2-cffi). Tokens are short-lived JWTs
(access) + httpOnly refresh tokens. Refresh tokens are NOT persisted in MVP
(stateless — revoke by letting them expire). Add a refresh_token table when
true revocation is needed.
TODO (post-MVP): password reset + email verification flows are not implemented.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.config import settings
from app.entities.personal_data import PersonalData
from app.entities.user import User
from app.entities.workspace import Workspace
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.user_repository import UserRepository
from app.repositories.workspace_repository import WorkspaceRepository
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
_ph = PasswordHasher()
class AuthService:
def __init__(
self,
user_repo: UserRepository,
personal_data_repo: PersonalDataRepository,
workspace_repo: WorkspaceRepository,
) -> None:
self._users = user_repo
self._personal_data = personal_data_repo
self._workspaces = workspace_repo
async def register(
self, email: str, password: str, display_name: str
) -> tuple[User, PersonalData]:
"""Create User + personal workspace + PersonalData. Raises ConflictError if email taken."""
existing = await self._users.get_by_email(email)
if existing is not None:
raise ConflictError(f"Email already registered: {email}")
# Create a personal workspace for the registering user.
workspace = Workspace(
id=uuid4(),
name=f"{display_name}s planering",
created_at=datetime.now(),
)
workspace = await self._workspaces.create(workspace)
pd = PersonalData(
uuid=uuid4(),
workspace_id=workspace.id,
name=display_name,
created_at=datetime.now(),
)
pd = await self._personal_data.create(pd)
user = User(
id=uuid4(),
personal_data_uuid=pd.uuid,
email=email,
hashed_password=_ph.hash(password),
created_at=datetime.now(),
)
user = await self._users.create(user)
return user, pd
async def login(self, email: str, password: str) -> User:
"""Verify credentials. Raises AuthError on wrong email or password."""
user = await self._users.get_by_email(email)
if user is None:
raise AuthError("Invalid email or password")
try:
_ph.verify(user.hashed_password, password)
except VerifyMismatchError as exc:
raise AuthError("Invalid email or password") from exc
if _ph.check_needs_rehash(user.hashed_password):
user.hashed_password = _ph.hash(password)
await self._users.update(user.id, user)
return user
def create_access_token(self, user_id: UUID) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.jwt_access_token_expire_minutes
)
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def create_refresh_token(self, user_id: UUID) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=settings.jwt_refresh_token_expire_days)
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
async def get_user_from_token(self, token: str) -> User:
"""Decode a JWT access token and return the user. Raises UnauthorizedError on failure."""
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except jwt.ExpiredSignatureError as exc:
raise UnauthorizedError("Token expired") from exc
except jwt.InvalidTokenError as exc:
raise UnauthorizedError("Invalid token") from exc
if payload.get("type") != "access":
raise UnauthorizedError("Expected access token")
user_id = UUID(payload["sub"])
user = await self._users.get_by_id(user_id)
if user is None:
raise UnauthorizedError("User not found")
return user
async def refresh_access_token(self, refresh_token: str) -> str:
"""Issue a new access token from a valid refresh token."""
try:
payload = jwt.decode(
refresh_token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
except jwt.InvalidTokenError as exc:
raise UnauthorizedError("Invalid refresh token") from exc
if payload.get("type") != "refresh":
raise UnauthorizedError("Expected refresh token")
user_id = UUID(payload["sub"])
user = await self._users.get_by_id(user_id)
if user is None:
raise UnauthorizedError("User not found")
return self.create_access_token(user.id)
@@ -0,0 +1,67 @@
"""Business logic for break periods (family home-together windows)."""
from datetime import datetime
from uuid import UUID, uuid4
from app.entities.break_period import BreakPeriod
from app.repositories.break_period_repository import BreakPeriodRepository
from app.utils.errors import NotFoundError, ValidationError
class BreakPeriodService:
def __init__(self, repo: BreakPeriodRepository) -> None:
self._repo = repo
async def create(
self,
workspace_id: UUID,
name: str,
start_date: object,
end_date: object,
target_weeks: int,
) -> BreakPeriod:
if end_date < start_date:
raise ValidationError("end_date must be on or after start_date")
now = datetime.now()
entity = BreakPeriod(
id=uuid4(),
workspace_id=workspace_id,
name=name,
start_date=start_date,
end_date=end_date,
target_weeks=target_weeks,
created_at=now,
updated_at=now,
)
return await self._repo.create(entity)
async def get(self, break_period_id: UUID) -> BreakPeriod:
entity = await self._repo.get_by_id(break_period_id)
if entity is None:
raise NotFoundError(f"BreakPeriod {break_period_id} not found")
return entity
async def list_for_workspace(self, workspace_id: UUID) -> list[BreakPeriod]:
return await self._repo.get_by_workspace(workspace_id)
async def update(
self,
break_period_id: UUID,
name: str,
start_date: object,
end_date: object,
target_weeks: int,
) -> BreakPeriod:
entity = await self.get(break_period_id)
if end_date < start_date:
raise ValidationError("end_date must be on or after start_date")
entity.name = name
entity.start_date = start_date
entity.end_date = end_date
entity.target_weeks = target_weeks
entity.updated_at = datetime.now()
return await self._repo.update(break_period_id, entity)
async def delete(self, break_period_id: UUID) -> None:
await self.get(break_period_id) # raises NotFoundError if missing
await self._repo.delete(break_period_id)
+77
View File
@@ -0,0 +1,77 @@
"""Business logic for child management."""
from datetime import datetime
from uuid import UUID, uuid4
from app.entities.child import Child
from app.repositories.child_repository import ChildRepository
from app.utils.errors import NotFoundError, ValidationError
class ChildService:
def __init__(self, repo: ChildRepository) -> None:
self._repo = repo
async def create(
self,
workspace_id: UUID,
name: str,
birth_date,
preschool_start_date=None,
) -> Child:
"""Create a new child."""
if preschool_start_date is not None and preschool_start_date <= birth_date:
raise ValidationError("preschool_start_date must be after birth_date")
now = datetime.now()
entity = Child(
id=uuid4(),
workspace_id=workspace_id,
name=name,
birth_date=birth_date,
preschool_start_date=preschool_start_date,
created_at=now,
)
return await self._repo.create(entity)
async def get(self, child_id: UUID) -> Child:
"""Get a child by ID."""
entity = await self._repo.get_by_id(child_id)
if entity is None:
raise NotFoundError(f"Child {child_id} not found")
return entity
async def list_by_workspace(self, workspace_id: UUID) -> list[Child]:
"""Get all children in a workspace."""
return await self._repo.list_by_workspace(workspace_id)
async def update(
self,
child_id: UUID,
name: str | None = None,
birth_date=None,
preschool_start_date=None,
) -> Child:
"""Update a child (partial update)."""
entity = await self.get(child_id)
if name is not None:
entity.name = name
if birth_date is not None:
entity.birth_date = birth_date
if preschool_start_date is not None:
entity.preschool_start_date = preschool_start_date
# Validate after all updates
if (
entity.preschool_start_date is not None
and entity.preschool_start_date <= entity.birth_date
):
raise ValidationError("preschool_start_date must be after birth_date")
return await self._repo.update(child_id, entity)
async def delete(self, child_id: UUID) -> None:
"""Delete a child."""
await self.get(child_id) # raises NotFoundError if missing
await self._repo.delete(child_id)
+102
View File
@@ -0,0 +1,102 @@
"""FetchDataService — stable contract for parental-leave data acquisition.
All features (analytics, tips, planning) depend on this interface, never on
FK's DOM or a specific scraper. Two concrete implementations:
- ManualFetchService — always ships, always works (manual user entry)
- FKScraperService — behind settings.scraper_enabled guard (pending legal review)
See ARCHITECTURE.md §2.3 for the legal/ToS gate before shipping scraping.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime
from uuid import UUID, uuid4
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.utils.errors import ValidationError
class FetchDataService(ABC):
@abstractmethod
async def get_latest(
self, personal_data_uuid: UUID, child_id: UUID
) -> ParentalLeaveFetch | None:
"""Return the most recent snapshot for this parent+child, or None."""
@abstractmethod
async def list_history(
self, personal_data_uuid: UUID, child_id: UUID
) -> list[ParentalLeaveFetch]:
"""Return all historical snapshots, newest first."""
class ManualFetchService(FetchDataService):
"""Persist user-entered FK data as a snapshot with source='manual'."""
def __init__(self, repo: ParentalLeaveRepository) -> None:
self._repo = repo
async def save_manual_entry(
self,
personal_data_uuid: UUID,
workspace_id: UUID,
child_id: UUID,
total_days: int,
days_used: int,
reserved_days_total: int,
reserved_days_left: int,
transferable_days_left: int,
dubbeldagar_left: int,
sjukpenning_total: int,
sjukpenning_used: int,
lagsta_niva_total: int,
lagsta_niva_used: int,
) -> ParentalLeaveFetch:
"""Validate and persist a manual data-entry snapshot."""
days_left = total_days - days_used
if days_left < 0:
raise ValidationError("days_used cannot exceed total_days")
if reserved_days_left > reserved_days_total:
raise ValidationError("reserved_days_left cannot exceed reserved_days_total")
tier_data = TierData(
sjukpenning_total=sjukpenning_total,
sjukpenning_used=sjukpenning_used,
sjukpenning_left=sjukpenning_total - sjukpenning_used,
lagsta_niva_total=lagsta_niva_total,
lagsta_niva_used=lagsta_niva_used,
lagsta_niva_left=lagsta_niva_total - lagsta_niva_used,
)
now = datetime.now()
fetch = ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
child_id=child_id,
source="manual",
fetched_at=now,
total_days=total_days,
days_used=days_used,
days_left=days_left,
reserved_days_total=reserved_days_total,
reserved_days_left=reserved_days_left,
transferable_days_left=transferable_days_left,
dubbeldagar_left=dubbeldagar_left,
tier_data=tier_data,
created_at=now,
updated_at=now,
)
return await self._repo.create(fetch)
async def get_latest(
self, personal_data_uuid: UUID, child_id: UUID
) -> ParentalLeaveFetch | None:
return await self._repo.get_latest_by_parent(personal_data_uuid, child_id)
async def list_history(
self, personal_data_uuid: UUID, child_id: UUID
) -> list[ParentalLeaveFetch]:
return await self._repo.list_history(personal_data_uuid, child_id)
+93
View File
@@ -0,0 +1,93 @@
"""Business logic for planned leave days (calendar planning)."""
from datetime import date, datetime
from uuid import UUID, uuid4
from app.entities.planned_leave import PlannedLeaveDay
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.services.analytics_service import AnalyticsService
from app.utils.errors import NotFoundError, ValidationError
class PlanningService:
def __init__(
self,
repo: PlannedLeaveRepository,
analytics_service: AnalyticsService,
) -> None:
self._repo = repo
self._analytics = analytics_service
async def add_planned_day(
self,
workspace_id: UUID,
child_id: UUID,
parent_personal_data_uuid: UUID,
date: date,
day_type: str,
is_dubbeldag: bool = False,
) -> PlannedLeaveDay:
"""Add a planned leave day.
Validates that the day is available (dubbeldagar rules, no double-booking).
"""
# Validation 1: Check dubbeldagar quota if this is a dubbeldag
if is_dubbeldag:
await self._analytics.validate_dubbeldagar(child_id, date)
# Validation 2: Check no double-booking on the same date
existing = await self._repo.list_by_workspace_child(
workspace_id=workspace_id,
child_id=child_id,
from_date=date,
to_date=date,
)
if existing:
raise ValidationError(f"A planned day already exists on {date}")
now = datetime.now()
entity = PlannedLeaveDay(
id=uuid4(),
workspace_id=workspace_id,
child_id=child_id,
parent_personal_data_uuid=parent_personal_data_uuid,
date=date,
day_type=day_type,
is_dubbeldag=is_dubbeldag,
created_at=now,
updated_at=now,
)
return await self._repo.create(entity)
async def remove_planned_day(self, planned_leave_day_id: UUID) -> None:
"""Remove a planned leave day."""
entity = await self._repo.get_by_id(planned_leave_day_id)
if entity is None:
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
await self._repo.delete(planned_leave_day_id)
async def list_by_workspace(self, workspace_id: UUID) -> list[PlannedLeaveDay]:
"""Get all planned days for a workspace."""
return await self._repo.list(workspace_id=workspace_id)
async def list_by_date_range(
self,
workspace_id: UUID,
child_id: UUID,
start_date: date,
end_date: date,
) -> list[PlannedLeaveDay]:
"""Get planned days within a date range for a child."""
return await self._repo.list_by_workspace_child(
workspace_id=workspace_id,
child_id=child_id,
from_date=start_date,
to_date=end_date,
)
async def get(self, planned_leave_day_id: UUID) -> PlannedLeaveDay:
"""Get a planned day by ID."""
entity = await self._repo.get_by_id(planned_leave_day_id)
if entity is None:
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
return entity
+268
View File
@@ -0,0 +1,268 @@
"""Tips engine.
Architecture:
- `Tip` is a plain dataclass returned to the router.
- `RULE_REGISTRY` is a list of async functions; each takes a `TipsContext`
and returns a `Tip | None`. None means the rule doesn't apply right now.
- `TipsService.get_tips()` runs all rules and returns the non-None results.
All 12 rules from ARCHITECTURE.md §6.1 are registered; the stubs return None
until implemented. This lets the junior wire rules one at a time by editing the
corresponding function without touching the plumbing.
To add a new rule:
1. Write an async function `_rule_<id>(ctx: TipsContext) -> Tip | None`.
2. Append it to RULE_REGISTRY.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Callable, Coroutine, TypeAlias
from uuid import UUID
from dateutil.relativedelta import relativedelta as _relativedelta
from app.config import settings
from app.entities.break_period import BreakPeriod
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch
from app.entities.personal_data import PersonalData
from app.entities.planned_leave import PlannedLeaveDay
from app.entities.vacation_days import VacationDays
from app.repositories.break_period_repository import BreakPeriodRepository
from app.repositories.child_repository import ChildRepository
from app.repositories.parental_leave_repository import ParentalLeaveRepository
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.planned_leave_repository import PlannedLeaveRepository
from app.repositories.vacation_days_repository import VacationDaysRepository
from app.repositories.workspace_repository import WorkspaceRepository
from app.utils.tip_predicates import (
has_vacation_near_expiry,
is_child_over_age,
is_child_under_age,
sgi_at_risk,
)
@dataclass
class Tip:
id: str
severity: str # 'info' | 'warning' | 'action'
message_key: str # i18n key for frontend
data: dict # context for frontend rendering (e.g. {days: 5, period: "Jul–Aug"})
@dataclass
class TipsContext:
workspace_id: UUID
workspace_planning_goal: str # 'max_time' | 'max_money' | 'balanced'
children: list[Child]
parents: list[PersonalData]
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] # personal_data_uuid → latest
planned_days: list[PlannedLeaveDay] # all future planned days for workspace
vacation_by_parent: dict[UUID, list[VacationDays]]
break_periods: list[BreakPeriod]
# Type alias for a rule function.
RuleFn: TypeAlias = Callable[[TipsContext], Coroutine[None, None, Tip | None]]
# ---------------------------------------------------------------------------
# Rule implementations (stub → return None until fully wired)
# ---------------------------------------------------------------------------
async def _rule_use_vacation_before_expiry(ctx: TipsContext) -> Tip | None:
for parent in ctx.parents:
for vd in ctx.vacation_by_parent.get(parent.uuid, []):
if has_vacation_near_expiry(vd.total_days, vd.saved_days_in):
return Tip(
id="use_vacation_before_expiry",
severity="warning",
message_key="tips.use_vacation_before_expiry",
data={
"parent_name": parent.name,
"saved_days": vd.saved_days_in,
"year": vd.year,
},
)
return None
async def _rule_balance_between_parents(ctx: TipsContext) -> Tip | None:
# TODO: compare days_used across parents; flag imbalance > 30 days
return None
async def _rule_preserve_for_age_4(ctx: TipsContext) -> Tip | None:
# TODO: use analytics_service.get_days_left_at_age_4; fire when expiring_at_b4 > 0
return None
async def _rule_use_dubbeldagar_strategically(ctx: TipsContext) -> Tip | None:
# TODO: detect dates where both parents plan the same day without is_dubbeldag=True
return None
async def _rule_lagsta_niva_coverage(ctx: TipsContext) -> Tip | None:
# TODO: suggest lägstanivå days when sjukpenning pool is low
return None
async def _rule_stretch_time_at_home(ctx: TipsContext) -> Tip | None:
# TODO: flag when weekly_intensity >= 7 and days_left_at_b4 is low
return None
async def _rule_skip_fp_first_12_months(ctx: TipsContext) -> Tip | None:
if ctx.workspace_planning_goal != "max_time":
return None
for child in ctx.children:
if is_child_under_age(child.birth_date, months=12):
for parent in ctx.parents:
fp_planned = [
d
for d in ctx.planned_days
if d.parent_personal_data_uuid == parent.uuid
and d.day_type in ("sjukpenning", "lagsta")
and d.date < child.birth_date + _relativedelta(months=12)
]
if fp_planned:
return Tip(
id="skip_fp_first_12_months",
severity="info",
message_key="tips.skip_fp_first_12_months",
data={"parent_name": parent.name},
)
return None
async def _rule_sgi_minimum_after_age_1(ctx: TipsContext) -> Tip | None:
today = date.today()
for child in ctx.children:
if not is_child_over_age(child.birth_date, 1):
continue
period_end = today + timedelta(weeks=4)
for parent in ctx.parents:
if sgi_at_risk(parent, ctx.planned_days, child.birth_date, today, period_end):
return Tip(
id="sgi_minimum_after_age_1",
severity="warning",
message_key="tips.sgi_minimum_after_age_1",
data={
"parent_name": parent.name,
"min_days_per_week": settings.rules.sgi_min_days_per_week_after_age_1,
},
)
return None
async def _rule_wrap_weekend_with_vacation(ctx: TipsContext) -> Tip | None:
if ctx.workspace_planning_goal != "max_money":
return None
# TODO: detect FP-spanned weekends without vacation bridging, for monthly-salary parents
return None
async def _rule_wrap_red_days_with_vacation(ctx: TipsContext) -> Tip | None:
if ctx.workspace_planning_goal != "max_money":
return None
# TODO: use red_day_clusters_in_range(); flag FP overlap with clusters
return None
async def _rule_long_break_parent_swap(ctx: TipsContext) -> Tip | None:
# TODO: match break_periods to a window where one parent could FP + other vacation
return None
async def _rule_reserve_for_preschool_planning_days(ctx: TipsContext) -> Tip | None:
# TODO: check preschool_buffer_needed(); compare against current reserved plan
return None
# ---------------------------------------------------------------------------
# Registry — order matters (earlier rules shown first)
# ---------------------------------------------------------------------------
RULE_REGISTRY: list[RuleFn] = [
_rule_use_vacation_before_expiry,
_rule_balance_between_parents,
_rule_preserve_for_age_4,
_rule_use_dubbeldagar_strategically,
_rule_lagsta_niva_coverage,
_rule_stretch_time_at_home,
_rule_skip_fp_first_12_months,
_rule_sgi_minimum_after_age_1,
_rule_wrap_weekend_with_vacation,
_rule_wrap_red_days_with_vacation,
_rule_long_break_parent_swap,
_rule_reserve_for_preschool_planning_days,
]
class TipsService:
def __init__(
self,
leave_repo: ParentalLeaveRepository,
planned_repo: PlannedLeaveRepository,
child_repo: ChildRepository,
pd_repo: PersonalDataRepository,
vacation_repo: VacationDaysRepository,
break_repo: BreakPeriodRepository,
workspace_repo: WorkspaceRepository,
) -> None:
self._leave = leave_repo
self._planned = planned_repo
self._children = child_repo
self._parents = pd_repo
self._vacation = vacation_repo
self._breaks = break_repo
self._workspace_repo = workspace_repo
async def get_tips(self, workspace_id: UUID) -> list[Tip]:
"""Load workspace context then run all rules, returning non-None tips."""
ctx = await self._build_context(workspace_id)
results = await asyncio.gather(*(rule(ctx) for rule in RULE_REGISTRY))
return [tip for tip in results if tip is not None]
async def _build_context(self, workspace_id: UUID) -> TipsContext:
parents = await self._parents.list_by_workspace(workspace_id)
children = await self._children.list_by_workspace(workspace_id)
break_periods = await self._breaks.get_by_workspace(workspace_id)
today = date.today()
all_planned: list[PlannedLeaveDay] = []
leave_by_parent: dict[UUID, ParentalLeaveFetch | None] = {}
vacation_by_parent: dict[UUID, list[VacationDays]] = {}
if children:
child = children[0] # MVP: single child
for pd in parents:
leave_by_parent[pd.uuid] = await self._leave.get_latest_by_parent(pd.uuid, child.id)
future = await self._planned.list_by_workspace_child(
workspace_id, child.id, from_date=today
)
all_planned.extend(future)
vacation_by_parent[pd.uuid] = await self._vacation.list(
personal_data_uuid=pd.uuid, workspace_id=workspace_id
)
ws = await self._workspace_repo.get_by_id(workspace_id)
planning_goal = ws.planning_goal if ws else "balanced"
return TipsContext(
workspace_id=workspace_id,
workspace_planning_goal=planning_goal,
children=children,
parents=parents,
leave_by_parent=leave_by_parent,
planned_days=all_planned,
vacation_by_parent=vacation_by_parent,
break_periods=break_periods,
)
@@ -0,0 +1,61 @@
"""Business logic for vacation days.
The service owns rules; the repository only persists. Routers call services, not
repositories directly. This is the REFERENCE service — keep services thin and
free of HTTP/DB details.
"""
from datetime import datetime
from uuid import UUID, uuid4
from app.entities.vacation_days import VacationDays
from app.repositories.vacation_days_repository import VacationDaysRepository
from app.utils.errors import NotFoundError, ValidationError
class VacationDaysService:
def __init__(self, repo: VacationDaysRepository) -> None:
self._repo = repo
async def upsert(
self,
personal_data_uuid: UUID,
workspace_id: UUID,
year: int,
total_days: int,
saved_days_in: int = 0,
) -> VacationDays:
"""Create or update the (parent, year) vacation entry.
Raises ValidationError on negative inputs (schema also guards this, but a
service should not trust its caller).
"""
if total_days < 0 or saved_days_in < 0:
raise ValidationError("Vacation day counts cannot be negative")
existing = await self._repo.get_by_parent_year(personal_data_uuid, year)
if existing is not None:
existing.total_days = total_days
existing.saved_days_in = saved_days_in
return await self._repo.update(existing.id, existing)
entity = VacationDays(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
year=year,
total_days=total_days,
saved_days_in=saved_days_in,
created_at=datetime.now(),
updated_at=datetime.now(),
)
return await self._repo.create(entity)
async def get(self, vacation_days_id: UUID) -> VacationDays:
entity = await self._repo.get_by_id(vacation_days_id)
if entity is None:
raise NotFoundError(f"VacationDays {vacation_days_id} not found")
return entity
async def list_for_workspace(self, workspace_id: UUID) -> list[VacationDays]:
return await self._repo.list(workspace_id=workspace_id)
+145
View File
@@ -0,0 +1,145 @@
"""Workspace service: creation, membership, invitation token flow.
Invitation security contract:
- The service generates a random 32-byte token.
- Only SHA-256(token) is persisted (token_hash).
- The raw token is returned to the caller exactly once and never stored.
- Redemption: hash(presented_token) must match stored token_hash.
"""
from __future__ import annotations
import hashlib
import secrets
from datetime import datetime, timedelta
from uuid import UUID, uuid4
from app.entities.personal_data import PersonalData
from app.entities.workspace import Workspace
from app.entities.workspace_invitation import WorkspaceInvitation
from app.repositories.personal_data_repository import PersonalDataRepository
from app.repositories.workspace_repository import (
WorkspaceInvitationRepository,
WorkspaceRepository,
)
from app.utils.errors import InvitationError, NotFoundError, ValidationError
_INVITE_TTL_HOURS = 72
class WorkspaceService:
def __init__(
self,
ws_repo: WorkspaceRepository,
inv_repo: WorkspaceInvitationRepository,
pd_repo: PersonalDataRepository,
) -> None:
self._ws = ws_repo
self._inv = inv_repo
self._pd = pd_repo
# ------------------------------------------------------------------
# Workspace CRUD
# ------------------------------------------------------------------
async def create_workspace(self, name: str) -> Workspace:
ws = Workspace(id=uuid4(), name=name, created_at=datetime.now())
return await self._ws.create(ws)
async def get_workspace(self, workspace_id: UUID) -> Workspace:
ws = await self._ws.get_by_id(workspace_id)
if ws is None:
raise NotFoundError(f"Workspace {workspace_id} not found")
return ws
async def update_workspace(
self, workspace_id: UUID, name: str | None = None, planning_goal: str | None = None
) -> Workspace:
ws = await self.get_workspace(workspace_id)
if name is not None:
ws.name = name
if planning_goal is not None:
valid = {"max_time", "max_money", "balanced"}
if planning_goal not in valid:
raise ValidationError(f"planning_goal must be one of {valid}")
ws.planning_goal = planning_goal
return await self._ws.update(workspace_id, ws)
async def list_members(self, workspace_id: UUID) -> list[PersonalData]:
"""Return all (non-deleted) parent profiles in the workspace."""
return await self._pd.list_by_workspace(workspace_id)
async def add_placeholder_parent(self, workspace_id: UUID, display_name: str) -> PersonalData:
"""Create a parent profile without a login (solo planning for a partner)."""
await self.get_workspace(workspace_id) # verify workspace exists
pd = PersonalData(
uuid=uuid4(),
workspace_id=workspace_id,
name=display_name,
created_at=datetime.now(),
)
return await self._pd.create(pd)
# ------------------------------------------------------------------
# Invitation flow
# ------------------------------------------------------------------
async def invite_partner(self, workspace_id: UUID, invited_by_user_id: UUID) -> str:
"""Generate a single-use invite token. Returns the RAW token (share out-of-band).
Only the SHA-256 hash is stored. The raw token is lost after this call.
"""
await self.get_workspace(workspace_id)
raw_token = secrets.token_urlsafe(32)
token_hash = _sha256(raw_token)
invitation = WorkspaceInvitation(
id=uuid4(),
workspace_id=workspace_id,
token_hash=token_hash,
invited_by_user_id=invited_by_user_id,
status="pending",
expires_at=datetime.now() + timedelta(hours=_INVITE_TTL_HOURS),
created_at=datetime.now(),
)
await self._inv.create(invitation)
return raw_token
async def redeem_invitation(
self, raw_token: str, personal_data_uuid: UUID
) -> WorkspaceInvitation:
"""Link personal_data to the workspace and mark invitation accepted."""
token_hash = _sha256(raw_token)
inv = await self._inv.get_by_token_hash(token_hash)
if inv is None:
raise InvitationError("Invitation not found")
if inv.status != "pending":
raise InvitationError(f"Invitation is {inv.status}")
if datetime.now() > inv.expires_at:
inv.status = "expired"
await self._inv.update(inv.id, inv)
raise InvitationError("Invitation has expired")
# Link the personal_data to the workspace.
pd = await self._pd.get_by_id(personal_data_uuid)
if pd is None:
raise NotFoundError(f"PersonalData {personal_data_uuid} not found")
pd.workspace_id = inv.workspace_id
await self._pd.update(pd.uuid, pd)
inv.status = "accepted"
inv.accepted_at = datetime.now()
return await self._inv.update(inv.id, inv)
async def revoke_invitation(self, invitation_id: UUID) -> None:
inv = await self._inv.get_by_id(invitation_id)
if inv is None:
raise NotFoundError(f"Invitation {invitation_id} not found")
if inv.status != "pending":
raise ValidationError(f"Cannot revoke a {inv.status} invitation")
inv.status = "revoked"
await self._inv.update(inv.id, inv)
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode()).hexdigest()