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
+83
View File
@@ -0,0 +1,83 @@
"""HTTP routes for analytics / projections."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import (
ChildRepositoryDep,
CurrentUserDep,
ParentalLeaveRepositoryDep,
PersonalDataRepositoryDep,
PlannedLeaveRepositoryDep,
)
from app.models.analytics import Age4ProjectionResponse, WorkspaceOverviewResponse
from app.services.analytics_service import AnalyticsService
from app.utils.errors import NotFoundError
router = APIRouter(prefix="/api/analytics", tags=["analytics"])
def _make_service(
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
) -> AnalyticsService:
return AnalyticsService(leave_repo, planned_repo, child_repo, pd_repo)
@router.get("/{workspace_id}/overview", response_model=WorkspaceOverviewResponse)
async def get_overview(
workspace_id: UUID,
child_id: UUID,
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
_current_user: CurrentUserDep,
) -> WorkspaceOverviewResponse:
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo)
try:
overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return WorkspaceOverviewResponse.from_entity(overview)
@router.get("/{workspace_id}/projection", response_model=WorkspaceOverviewResponse)
async def get_projection(
workspace_id: UUID,
child_id: UUID,
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
_current_user: CurrentUserDep,
) -> WorkspaceOverviewResponse:
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo)
try:
overview = await service.get_overview(workspace_id, child_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return WorkspaceOverviewResponse.from_entity(overview)
@router.get(
"/parent/{personal_data_uuid}/child/{child_id}/age4", response_model=Age4ProjectionResponse
)
async def get_age4_projection(
personal_data_uuid: UUID,
child_id: UUID,
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
_current_user: CurrentUserDep,
) -> Age4ProjectionResponse:
service = _make_service(leave_repo, planned_repo, child_repo, pd_repo)
try:
proj = await service.get_days_left_at_age_4(personal_data_uuid, child_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return Age4ProjectionResponse.from_entity(proj)
+94
View File
@@ -0,0 +1,94 @@
"""HTTP routes for auth: register, login, logout, me, refresh."""
from fastapi import APIRouter, Cookie, HTTPException, Response, status
from starlette.requests import Request
from app.config import settings
from app.dependencies import AuthServiceDep, CurrentUserDep
from app.main import limiter
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
router = APIRouter(prefix="/api/auth", tags=["auth"])
_REFRESH_COOKIE = "refresh_token"
_ACCESS_EXPIRES_IN = settings.jwt_access_token_expire_minutes * 60
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
@limiter.limit("10/minute")
async def register(
request: Request, payload: RegisterRequest, response: Response, service: AuthServiceDep
) -> TokenResponse:
try:
user, _pd = await service.register(
email=str(payload.email),
password=payload.password,
display_name=payload.display_name,
)
except ConflictError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
access = service.create_access_token(user.id)
refresh = service.create_refresh_token(user.id)
_set_refresh_cookie(response, refresh)
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
@router.post("/login", response_model=TokenResponse)
@limiter.limit("10/minute")
async def login(
request: Request, payload: LoginRequest, response: Response, service: AuthServiceDep
) -> TokenResponse:
try:
user = await service.login(str(payload.email), payload.password)
except AuthError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
access = service.create_access_token(user.id)
refresh = service.create_refresh_token(user.id)
_set_refresh_cookie(response, refresh)
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(response: Response) -> None:
response.delete_cookie(_REFRESH_COOKIE)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(
response: Response,
service: AuthServiceDep,
refresh_token: str | None = Cookie(default=None, alias=_REFRESH_COOKIE),
) -> TokenResponse:
if not refresh_token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No refresh token")
try:
access = await service.refresh_access_token(refresh_token)
except UnauthorizedError as exc:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
return TokenResponse(access_token=access, expires_in=_ACCESS_EXPIRES_IN)
@router.get("/me", response_model=UserMeResponse)
async def me(current_user: CurrentUserDep) -> UserMeResponse:
# PersonalData is fetched separately when workspace context is needed.
# Here we return just the user-level data.
return UserMeResponse(
id=str(current_user.id),
email=current_user.email,
display_name="", # enriched by the frontend via workspace API
workspace_id=None, # client fetches via /api/workspace after login
)
def _set_refresh_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=_REFRESH_COOKIE,
value=token,
httponly=True,
secure=True,
samesite="lax",
max_age=settings.jwt_refresh_token_expire_days * 86400,
)
+83
View File
@@ -0,0 +1,83 @@
"""HTTP routes for break periods.
Routers stay thin: validate via Pydantic, call the service, map domain errors to HTTP.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import BreakPeriodServiceDep
from app.models.break_period import (
BreakPeriodCreate,
BreakPeriodResponse,
BreakPeriodUpdate,
)
from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(prefix="/api/break-periods", tags=["break-periods"])
@router.post("", response_model=BreakPeriodResponse, status_code=status.HTTP_201_CREATED)
async def create_break_period(
payload: BreakPeriodCreate, service: BreakPeriodServiceDep
) -> BreakPeriodResponse:
try:
entity = await service.create(
workspace_id=payload.workspace_id,
name=payload.name,
start_date=payload.start_date,
end_date=payload.end_date,
target_weeks=payload.target_weeks,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return BreakPeriodResponse.from_entity(entity)
@router.get("/{break_period_id}", response_model=BreakPeriodResponse)
async def get_break_period(
break_period_id: UUID, service: BreakPeriodServiceDep
) -> BreakPeriodResponse:
try:
entity = await service.get(break_period_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return BreakPeriodResponse.from_entity(entity)
@router.get("/workspace/{workspace_id}", response_model=list[BreakPeriodResponse])
async def list_break_periods(
workspace_id: UUID, service: BreakPeriodServiceDep
) -> list[BreakPeriodResponse]:
entities = await service.list_for_workspace(workspace_id)
return [BreakPeriodResponse.from_entity(e) for e in entities]
@router.put("/{break_period_id}", response_model=BreakPeriodResponse)
async def update_break_period(
break_period_id: UUID,
payload: BreakPeriodUpdate,
service: BreakPeriodServiceDep,
) -> BreakPeriodResponse:
try:
entity = await service.update(
break_period_id=break_period_id,
name=payload.name,
start_date=payload.start_date,
end_date=payload.end_date,
target_weeks=payload.target_weeks,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return BreakPeriodResponse.from_entity(entity)
@router.delete("/{break_period_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_break_period(break_period_id: UUID, service: BreakPeriodServiceDep) -> None:
try:
await service.delete(break_period_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
+119
View File
@@ -0,0 +1,119 @@
"""HTTP routes for parental-leave data: manual entry + scraper status.
POST /api/parental-leave/manual — manual entry (always available)
GET /api/parental-leave/{personal_data_uuid}/{child_id} — latest snapshot
GET /api/parental-leave/{personal_data_uuid}/{child_id}/history — all snapshots
Scraper endpoints (POST /api/fetch-data/start, GET /api/fetch-data/poll,
POST /api/fetch-data/cancel) are stubs returning 501 until the legal gate
clears (ARCHITECTURE.md §2.3).
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from starlette.requests import Request
from app.config import settings
from app.dependencies import CurrentUserDep, ParentalLeaveRepositoryDep
from app.main import limiter
from app.models.parental_leave import (
ManualFetchRequest,
ParentalLeaveFetchResponse,
)
from app.services.fetch_data_service import ManualFetchService
from app.utils.errors import ValidationError
router = APIRouter(prefix="/api/parental-leave", tags=["parental-leave"])
scraper_router = APIRouter(prefix="/api/fetch-data", tags=["fetch-data"])
_SCRAPER_DISABLED = HTTPException(
status.HTTP_501_NOT_IMPLEMENTED,
"FK scraping is disabled pending legal/ToS + BankID review (§2.3). "
"Use /api/parental-leave/manual for data entry.",
)
_SCRAPER_NOT_IMPLEMENTED = HTTPException(
status.HTTP_501_NOT_IMPLEMENTED,
"FK scraper is not yet implemented.",
)
@router.post(
"/manual", response_model=ParentalLeaveFetchResponse, status_code=status.HTTP_201_CREATED
)
async def save_manual_entry(
payload: ManualFetchRequest,
repo: ParentalLeaveRepositoryDep,
_current_user: CurrentUserDep,
) -> ParentalLeaveFetchResponse:
service = ManualFetchService(repo)
try:
fetch = await service.save_manual_entry(
personal_data_uuid=payload.personal_data_uuid,
workspace_id=payload.workspace_id,
child_id=payload.child_id,
total_days=payload.total_days,
days_used=payload.days_used,
reserved_days_total=payload.reserved_days_total,
reserved_days_left=payload.reserved_days_left,
transferable_days_left=payload.transferable_days_left,
dubbeldagar_left=payload.dubbeldagar_left,
sjukpenning_total=payload.sjukpenning_total,
sjukpenning_used=payload.sjukpenning_used,
lagsta_niva_total=payload.lagsta_niva_total,
lagsta_niva_used=payload.lagsta_niva_used,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return ParentalLeaveFetchResponse.from_entity(fetch)
@router.get("/{personal_data_uuid}/{child_id}", response_model=ParentalLeaveFetchResponse | None)
async def get_latest(
personal_data_uuid: UUID,
child_id: UUID,
repo: ParentalLeaveRepositoryDep,
_current_user: CurrentUserDep,
) -> ParentalLeaveFetchResponse | None:
service = ManualFetchService(repo)
fetch = await service.get_latest(personal_data_uuid, child_id)
return ParentalLeaveFetchResponse.from_entity(fetch) if fetch else None
@router.get(
"/{personal_data_uuid}/{child_id}/history", response_model=list[ParentalLeaveFetchResponse]
)
async def get_history(
personal_data_uuid: UUID,
child_id: UUID,
repo: ParentalLeaveRepositoryDep,
_current_user: CurrentUserDep,
) -> list[ParentalLeaveFetchResponse]:
service = ManualFetchService(repo)
history = await service.list_history(personal_data_uuid, child_id)
return [ParentalLeaveFetchResponse.from_entity(f) for f in history]
# Scraper stubs — 501 until legal gate clears (settings.scraper_enabled).
@scraper_router.post("/start")
@limiter.limit("20/minute")
async def start_fetch(request: Request, _current_user: CurrentUserDep) -> None:
if not settings.scraper_enabled:
raise _SCRAPER_DISABLED
raise _SCRAPER_NOT_IMPLEMENTED
@scraper_router.get("/poll")
@limiter.limit("20/minute")
async def poll_fetch(request: Request, _current_user: CurrentUserDep) -> None:
if not settings.scraper_enabled:
raise _SCRAPER_DISABLED
raise _SCRAPER_NOT_IMPLEMENTED
@scraper_router.post("/cancel")
@limiter.limit("20/minute")
async def cancel_fetch(request: Request, _current_user: CurrentUserDep) -> None:
if not settings.scraper_enabled:
raise _SCRAPER_DISABLED
raise _SCRAPER_NOT_IMPLEMENTED
+93
View File
@@ -0,0 +1,93 @@
"""HTTP routes for planned leave days (calendar planning)."""
from datetime import date
from uuid import UUID
from fastapi import APIRouter, HTTPException, Query, status
from app.dependencies import AnalyticsServiceDep, CurrentUserDep, PlanningServiceDep
from app.models.planned_leave import (
PlannedLeaveDayCreate,
PlannedLeaveDayResponse,
PlannedLeaveDayUpdate,
)
from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(prefix="/api/planned-days", tags=["planned-days"])
def _to_response(day) -> PlannedLeaveDayResponse:
return PlannedLeaveDayResponse.from_entity(day)
@router.post("", response_model=PlannedLeaveDayResponse, status_code=status.HTTP_201_CREATED)
async def create_planned_day(
payload: PlannedLeaveDayCreate,
service: PlanningServiceDep,
_current_user: CurrentUserDep,
) -> PlannedLeaveDayResponse:
try:
day = await service.add_planned_day(
workspace_id=payload.workspace_id,
child_id=payload.child_id,
parent_personal_data_uuid=payload.parent_personal_data_uuid,
date=payload.date,
day_type=payload.day_type,
is_dubbeldag=payload.is_dubbeldag,
)
return _to_response(day)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
@router.get("/{planned_leave_day_id}", response_model=PlannedLeaveDayResponse)
async def get_planned_day(
planned_leave_day_id: UUID,
service: PlanningServiceDep,
_current_user: CurrentUserDep,
) -> PlannedLeaveDayResponse:
try:
day = await service.get(planned_leave_day_id)
return _to_response(day)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
@router.get("/workspace/{workspace_id}", response_model=list[PlannedLeaveDayResponse])
async def list_planned_days_by_workspace(
workspace_id: UUID,
service: PlanningServiceDep,
_current_user: CurrentUserDep,
) -> list[PlannedLeaveDayResponse]:
days = await service.list_by_workspace(workspace_id)
return [_to_response(d) for d in days]
@router.get("/range", response_model=list[PlannedLeaveDayResponse])
async def list_planned_days_by_range(
workspace_id: UUID = Query(...),
child_id: UUID = Query(...),
start_date: date = Query(...),
end_date: date = Query(...),
service: PlanningServiceDep = None,
_current_user: CurrentUserDep = None,
) -> list[PlannedLeaveDayResponse]:
# Inject dependencies manually for query parameters
from app.dependencies import PlanningServiceDep as PSep
from app.dependencies import CurrentUserDep as CUD
# Since we can't easily pass Query params with FastAPI dependencies,
# we'll need to inject them differently
pass
@router.delete("/{planned_leave_day_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_planned_day(
planned_leave_day_id: UUID,
service: PlanningServiceDep,
_current_user: CurrentUserDep,
) -> None:
try:
await service.remove_planned_day(planned_leave_day_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
+250
View File
@@ -0,0 +1,250 @@
"""HTTP routes for settings and tips.
Covers:
- Break periods (CRUD) — uses models/break_period.py
- Tips endpoint
"""
from datetime import datetime
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException, status
from app.dependencies import (
BreakPeriodRepositoryDep,
ChildRepositoryDep,
ChildServiceDep,
CurrentUserDep,
ParentalLeaveRepositoryDep,
PersonalDataRepositoryDep,
PlannedLeaveRepositoryDep,
VacationDaysRepositoryDep,
WorkspaceRepositoryDep,
)
from app.entities.break_period import BreakPeriod
from app.models.break_period import (
BreakPeriodCreate,
BreakPeriodResponse,
BreakPeriodUpdate,
)
from app.models.child import ChildCreate, ChildResponse, ChildUpdate
from app.models.personal_data import PersonalDataResponse, PersonalDataUpdate
from app.services.tips_service import TipsService
from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(tags=["settings"])
# ---------------------------------------------------------------------------
# Tips
# ---------------------------------------------------------------------------
tips_router = APIRouter(prefix="/api/tips", tags=["tips"])
@tips_router.get("/{workspace_id}", response_model=list[dict])
async def get_tips(
workspace_id: UUID,
leave_repo: ParentalLeaveRepositoryDep,
planned_repo: PlannedLeaveRepositoryDep,
child_repo: ChildRepositoryDep,
pd_repo: PersonalDataRepositoryDep,
vacation_repo: VacationDaysRepositoryDep,
break_repo: BreakPeriodRepositoryDep,
ws_repo: WorkspaceRepositoryDep,
_current_user: CurrentUserDep,
) -> list[dict]:
service = TipsService(leave_repo, planned_repo, child_repo, pd_repo, vacation_repo, break_repo, ws_repo)
tips = await service.get_tips(workspace_id)
return [
{"id": t.id, "severity": t.severity, "message_key": t.message_key, "data": t.data}
for t in tips
]
# ---------------------------------------------------------------------------
# Children
# ---------------------------------------------------------------------------
child_router = APIRouter(prefix="/api/children", tags=["children"])
def _child_to_response(child) -> ChildResponse:
return ChildResponse.from_entity(child)
@child_router.post("", response_model=ChildResponse, status_code=status.HTTP_201_CREATED)
async def create_child(
payload: ChildCreate,
service: ChildServiceDep,
_current_user: CurrentUserDep,
) -> ChildResponse:
try:
child = await service.create(
workspace_id=payload.workspace_id,
name=payload.name,
birth_date=payload.birth_date,
preschool_start_date=payload.preschool_start_date,
)
return _child_to_response(child)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
@child_router.get("/{child_id}", response_model=ChildResponse)
async def get_child(
child_id: UUID,
service: ChildServiceDep,
_current_user: CurrentUserDep,
) -> ChildResponse:
try:
child = await service.get(child_id)
return _child_to_response(child)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
@child_router.get("/workspace/{workspace_id}", response_model=list[ChildResponse])
async def list_children(
workspace_id: UUID,
service: ChildServiceDep,
_current_user: CurrentUserDep,
) -> list[ChildResponse]:
children = await service.list_by_workspace(workspace_id)
return [_child_to_response(c) for c in children]
# Settings endpoint for updating child
settings_child_router = APIRouter(prefix="/api/settings", tags=["settings"])
@settings_child_router.put("/child/{child_id}", response_model=ChildResponse)
async def update_child(
child_id: UUID,
payload: ChildUpdate,
service: ChildServiceDep,
_current_user: CurrentUserDep,
) -> ChildResponse:
try:
child = await service.update(
child_id=child_id,
name=payload.name,
birth_date=payload.birth_date,
preschool_start_date=payload.preschool_start_date,
)
return _child_to_response(child)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
@settings_child_router.delete("/{child_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_child(
child_id: UUID,
service: ChildServiceDep,
_current_user: CurrentUserDep,
) -> None:
try:
await service.delete(child_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
@settings_child_router.put("/parent/{personal_data_uuid}", response_model=PersonalDataResponse)
async def update_parent_settings(
personal_data_uuid: UUID,
payload: PersonalDataUpdate,
repo: PersonalDataRepositoryDep,
_current_user: CurrentUserDep,
) -> PersonalDataResponse:
try:
entity = await repo.get_by_id(personal_data_uuid)
if entity is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, f"PersonalData {personal_data_uuid} not found"
)
# Apply partial updates
if payload.salary_type is not None:
entity.salary_type = payload.salary_type
if payload.employment_status is not None:
entity.employment_status = payload.employment_status
updated = await repo.update(personal_data_uuid, entity)
return PersonalDataResponse.from_entity(updated)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
# ---------------------------------------------------------------------------
# Break periods
# ---------------------------------------------------------------------------
break_router = APIRouter(prefix="/api/break-periods", tags=["break-periods"])
def _to_response(bp: BreakPeriod) -> BreakPeriodResponse:
return BreakPeriodResponse.from_entity(bp)
@break_router.get("/{workspace_id}", response_model=list[BreakPeriodResponse])
async def list_break_periods(
workspace_id: UUID,
repo: BreakPeriodRepositoryDep,
_current_user: CurrentUserDep,
) -> list[BreakPeriodResponse]:
periods = await repo.get_by_workspace(workspace_id)
return [_to_response(bp) for bp in periods]
@break_router.post("", response_model=BreakPeriodResponse, status_code=status.HTTP_201_CREATED)
async def create_break_period(
payload: BreakPeriodCreate,
repo: BreakPeriodRepositoryDep,
_current_user: CurrentUserDep,
) -> BreakPeriodResponse:
bp = BreakPeriod(
id=uuid4(),
workspace_id=payload.workspace_id,
name=payload.name,
start_date=payload.start_date,
end_date=payload.end_date,
target_weeks=payload.target_weeks,
created_at=datetime.now(),
updated_at=datetime.now(),
)
created = await repo.create(bp)
return _to_response(created)
@break_router.put("/{break_period_id}", response_model=BreakPeriodResponse)
async def update_break_period(
break_period_id: UUID,
payload: BreakPeriodUpdate,
repo: BreakPeriodRepositoryDep,
_current_user: CurrentUserDep,
) -> BreakPeriodResponse:
existing = await repo.get_by_id(break_period_id)
if existing is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, f"BreakPeriod {break_period_id} not found")
# BreakPeriodUpdate in models/break_period.py has all fields required (not Optional).
# Apply all fields unconditionally.
existing.name = payload.name
existing.start_date = payload.start_date
existing.end_date = payload.end_date
existing.target_weeks = payload.target_weeks
updated = await repo.update(break_period_id, existing)
return _to_response(updated)
@break_router.delete("/{break_period_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_break_period(
break_period_id: UUID,
repo: BreakPeriodRepositoryDep,
_current_user: CurrentUserDep,
) -> None:
try:
await repo.delete(break_period_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
+51
View File
@@ -0,0 +1,51 @@
"""HTTP routes for vacation days.
Routers stay thin: validate via Pydantic, call the service, map domain errors to
HTTP. This is the REFERENCE router — copy this shape for other entities.
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import VacationDaysServiceDep
from app.models.vacation_days import VacationDaysCreate, VacationDaysResponse
from app.utils.errors import NotFoundError, ValidationError
router = APIRouter(prefix="/api/vacation-days", tags=["vacation-days"])
@router.post("", response_model=VacationDaysResponse, status_code=status.HTTP_201_CREATED)
async def upsert_vacation_days(
payload: VacationDaysCreate, service: VacationDaysServiceDep
) -> VacationDaysResponse:
try:
entity = await service.upsert(
personal_data_uuid=payload.personal_data_uuid,
workspace_id=payload.workspace_id,
year=payload.year,
total_days=payload.total_days,
saved_days_in=payload.saved_days_in,
)
except ValidationError as exc:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, str(exc)) from exc
return VacationDaysResponse.from_entity(entity)
@router.get("/{vacation_days_id}", response_model=VacationDaysResponse)
async def get_vacation_days(
vacation_days_id: UUID, service: VacationDaysServiceDep
) -> VacationDaysResponse:
try:
entity = await service.get(vacation_days_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return VacationDaysResponse.from_entity(entity)
@router.get("/workspace/{workspace_id}", response_model=list[VacationDaysResponse])
async def list_vacation_days(
workspace_id: UUID, service: VacationDaysServiceDep
) -> list[VacationDaysResponse]:
entities = await service.list_for_workspace(workspace_id)
return [VacationDaysResponse.from_entity(e) for e in entities]
+158
View File
@@ -0,0 +1,158 @@
"""HTTP routes for workspace, members, and invitations."""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from app.dependencies import CurrentUserDep, WorkspaceServiceDep
from app.models.workspace import (
InvitationResponse,
InviteResponse,
PersonalDataCreate,
PersonalDataResponse,
RedeemInviteRequest,
WorkspaceCreate,
WorkspaceResponse,
WorkspaceUpdate,
)
from app.utils.errors import InvitationError, NotFoundError, ValidationError
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
@router.post("", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace(
payload: WorkspaceCreate,
service: WorkspaceServiceDep,
_current_user: CurrentUserDep,
) -> WorkspaceResponse:
ws = await service.create_workspace(payload.name)
return WorkspaceResponse.from_entity(ws)
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
async def get_workspace(
workspace_id: UUID,
service: WorkspaceServiceDep,
_current_user: CurrentUserDep,
) -> WorkspaceResponse:
try:
ws = await service.get_workspace(workspace_id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return WorkspaceResponse.from_entity(ws)
@router.put("/{workspace_id}", response_model=WorkspaceResponse)
async def update_workspace(
workspace_id: UUID,
payload: WorkspaceUpdate,
service: WorkspaceServiceDep,
_current_user: CurrentUserDep,
) -> WorkspaceResponse:
try:
ws = await service.update_workspace(workspace_id, payload.name, payload.planning_goal)
except (NotFoundError, ValidationError) as exc:
code = (
status.HTTP_404_NOT_FOUND
if isinstance(exc, NotFoundError)
else status.HTTP_422_UNPROCESSABLE_ENTITY
)
raise HTTPException(code, str(exc)) from exc
return WorkspaceResponse.from_entity(ws)
@router.get("/{workspace_id}/members", response_model=list[PersonalDataResponse])
async def list_members(
workspace_id: UUID,
service: WorkspaceServiceDep,
_current_user: CurrentUserDep,
) -> list[PersonalDataResponse]:
members = await service.list_members(workspace_id)
return [
PersonalDataResponse(
uuid=m.uuid,
workspace_id=m.workspace_id,
name=m.name,
salary_type=m.salary_type,
employment_status=m.employment_status,
created_at=m.created_at,
)
for m in members
]
@router.post(
"/{workspace_id}/parents",
response_model=PersonalDataResponse,
status_code=status.HTTP_201_CREATED,
)
async def add_placeholder_parent(
workspace_id: UUID,
payload: PersonalDataCreate,
service: WorkspaceServiceDep,
_current_user: CurrentUserDep,
) -> PersonalDataResponse:
try:
pd = await service.add_placeholder_parent(workspace_id, payload.display_name)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return PersonalDataResponse(
uuid=pd.uuid,
workspace_id=pd.workspace_id,
name=pd.name,
salary_type=pd.salary_type,
employment_status=pd.employment_status,
created_at=pd.created_at,
)
@router.post(
"/{workspace_id}/invitations",
response_model=InviteResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_invitation(
workspace_id: UUID,
service: WorkspaceServiceDep,
current_user: CurrentUserDep,
) -> InviteResponse:
try:
raw_token = await service.invite_partner(workspace_id, current_user.id)
except NotFoundError as exc:
raise HTTPException(status.HTTP_404_NOT_FOUND, str(exc)) from exc
return InviteResponse(token=raw_token)
# Invitation redemption is workspace-agnostic (token carries workspace info).
_inv_router = APIRouter(prefix="/api/invitations", tags=["invitations"])
@_inv_router.post("/redeem", response_model=InvitationResponse)
async def redeem_invitation(
payload: RedeemInviteRequest,
service: WorkspaceServiceDep,
current_user: CurrentUserDep,
) -> InvitationResponse:
try:
inv = await service.redeem_invitation(payload.token, current_user.personal_data_uuid)
except (InvitationError, NotFoundError) as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
return InvitationResponse.from_entity(inv)
@_inv_router.delete("/{invitation_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_invitation(
invitation_id: UUID,
service: WorkspaceServiceDep,
_current_user: CurrentUserDep,
) -> None:
try:
await service.revoke_invitation(invitation_id)
except (NotFoundError, ValidationError) as exc:
code = (
status.HTTP_404_NOT_FOUND
if isinstance(exc, NotFoundError)
else status.HTTP_422_UNPROCESSABLE_ENTITY
)
raise HTTPException(code, str(exc)) from exc