Initial commit
This commit is contained in:
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user