6d91f153d7
Vacation rescue/wrap in allocation_service, new plan models/endpoints, VacationRescueTip, useRescueDayActions, vacationSwaps util, sv+en i18n, Dashboard/Calendar wiring. Conflicts came from main's "day assertions" restructure and the earlier merges; both intents kept: - plan.py: both import sets; main's sync-with-wish handler kept next to the branch's new /rescue-days/dismiss endpoint; HTTP_422_UNPROCESSABLE_CONTENT applied to the branch's code (and a survivor in auth.py). - Accepting a rescue day now goes through POST /api/day-assertions (an ad-hoc 'vacation' assertion, no wish) instead of /plan/apply with an `is_pinned` flag that no longer exists: on main a pin IS a day assertion (see PlanningService.add_planned_day's docstring), and an assertion is what keeps a later sync from retyping the day. useRescueDayActions, its tests, VacationRescueTip's tests and the backend integration flow follow that contract; applyPlan stays as a client for the live endpoint minus the dropped field. The hook also gets the security branch's useLatestRequest guard with an unconditionally cleared busy flag. - Calendar.tsx: `locale` threaded into main's applyWishSyncResult call of buildSyncToastMessage (the branch's new parameter); the branch's swap-visibility toast tests drive the preset path through syncWithWish, which is how main saves a wish now. - types/api.ts: ProposedDayApplyItem kept (used by applyPlan) without isPinned; the Add/UpdatePlannedDayRequest types stay removed as on main. - fixtures/plan.ts: ResourceShortage import dropped (type removed on main). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
605 lines
22 KiB
Python
605 lines
22 KiB
Python
"""Dependency-injection wiring.
|
|
|
|
Each factory picks the correct concrete repo based on `settings.database_type`.
|
|
Routers/services depend only on the abstract interfaces.
|
|
|
|
Pattern (copy for each new entity):
|
|
1. `get_<entity>_repository` — selects sqlite vs postgres impl
|
|
2. `<Entity>RepositoryDep` — Annotated type alias
|
|
3. `get_<entity>_service` — wires repo(s) into the service
|
|
4. `<Entity>ServiceDep` — Annotated type alias
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Annotated
|
|
from uuid import UUID
|
|
|
|
import aiosqlite
|
|
from fastapi import Depends, HTTPException, Request, status
|
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
|
|
from app.config import settings
|
|
from app.entities.child import Child
|
|
from app.entities.personal_data import PersonalData
|
|
from app.entities.user import User
|
|
from app.repositories.child_repository import ChildRepository
|
|
from app.repositories.day_assertion_repository import DayAssertionRepository
|
|
from app.repositories.day_override_repository import DayOverrideRepository
|
|
from app.repositories.leave_wish_repository import LeaveWishRepository
|
|
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.preschool_planning_days_repository import (
|
|
PreschoolPlanningDaysRepository,
|
|
)
|
|
from app.repositories.tip_dismissal_repository import TipDismissalRepository
|
|
from app.repositories.unit_of_work import UnitOfWork
|
|
from app.repositories.user_repository import UserRepository
|
|
from app.repositories.vacation_days_repository import VacationDaysRepository
|
|
from app.repositories.workspace_repository import (
|
|
WorkspaceInvitationRepository,
|
|
WorkspaceRepository,
|
|
)
|
|
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
|
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
|
|
from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository
|
|
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
|
|
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
|
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
|
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
|
|
from app.repositories_impl.sqlite.preschool_planning_days_repository import (
|
|
SQLitePreschoolPlanningDaysRepository,
|
|
)
|
|
from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository
|
|
from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork
|
|
from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository
|
|
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
|
|
from app.repositories_impl.sqlite.workspace_repository import (
|
|
SQLiteWorkspaceInvitationRepository,
|
|
SQLiteWorkspaceRepository,
|
|
)
|
|
from app.services.allocation_service import AllocationInputLoader, PlanningEngineService
|
|
from app.services.analytics_service import AnalyticsService
|
|
from app.services.auth_service import AuthService
|
|
from app.services.calendar_service import CalendarService
|
|
from app.services.child_service import ChildService
|
|
from app.services.day_assertion_service import DayAssertionService
|
|
from app.services.day_override_service import DayOverrideService
|
|
from app.services.leave_wish_service import LeaveWishService
|
|
from app.services.personal_data_service import PersonalDataService
|
|
from app.services.planning_service import PlanningService
|
|
from app.services.preschool_planning_days_service import PreschoolPlanningDaysService
|
|
from app.services.tips_service import TipsService
|
|
from app.services.vacation_days_service import VacationDaysService
|
|
from app.services.workspace_service import WorkspaceService
|
|
from app.utils.errors import UnauthorizedError
|
|
|
|
_bearer = HTTPBearer(auto_error=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DB connection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_connection(request: Request) -> aiosqlite.Connection:
|
|
"""Return the process-wide DB connection opened during app startup."""
|
|
return request.app.state.db_connection
|
|
|
|
|
|
ConnectionDep = Annotated[aiosqlite.Connection, Depends(get_connection)]
|
|
|
|
|
|
def get_unit_of_work(conn: ConnectionDep) -> UnitOfWork:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteUnitOfWork(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
UnitOfWorkDep = Annotated[UnitOfWork, Depends(get_unit_of_work)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Repository factories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _sqlite_or_error(label: str) -> None:
|
|
if settings.database_type not in ("sqlite", "postgres"):
|
|
raise ValueError(f"Unsupported database_type for {label}: {settings.database_type}")
|
|
|
|
|
|
def get_user_repository(conn: ConnectionDep) -> UserRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteUserRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
UserRepositoryDep = Annotated[UserRepository, Depends(get_user_repository)]
|
|
|
|
|
|
def get_personal_data_repository(conn: ConnectionDep) -> PersonalDataRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLitePersonalDataRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
PersonalDataRepositoryDep = Annotated[PersonalDataRepository, Depends(get_personal_data_repository)]
|
|
|
|
|
|
def get_workspace_repository(conn: ConnectionDep) -> WorkspaceRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteWorkspaceRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
WorkspaceRepositoryDep = Annotated[WorkspaceRepository, Depends(get_workspace_repository)]
|
|
|
|
|
|
def get_workspace_invitation_repository(conn: ConnectionDep) -> WorkspaceInvitationRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteWorkspaceInvitationRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
WorkspaceInvitationRepositoryDep = Annotated[
|
|
WorkspaceInvitationRepository, Depends(get_workspace_invitation_repository)
|
|
]
|
|
|
|
|
|
def get_child_repository(conn: ConnectionDep) -> ChildRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteChildRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
ChildRepositoryDep = Annotated[ChildRepository, Depends(get_child_repository)]
|
|
|
|
|
|
def get_parental_leave_repository(conn: ConnectionDep) -> ParentalLeaveRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteParentalLeaveRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
ParentalLeaveRepositoryDep = Annotated[
|
|
ParentalLeaveRepository, Depends(get_parental_leave_repository)
|
|
]
|
|
|
|
|
|
def get_planned_leave_repository(conn: ConnectionDep) -> PlannedLeaveRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLitePlannedLeaveRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
PlannedLeaveRepositoryDep = Annotated[PlannedLeaveRepository, Depends(get_planned_leave_repository)]
|
|
|
|
|
|
def get_vacation_days_repository(conn: ConnectionDep) -> VacationDaysRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteVacationDaysRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
VacationDaysRepositoryDep = Annotated[VacationDaysRepository, Depends(get_vacation_days_repository)]
|
|
|
|
|
|
def get_leave_wish_repository(conn: ConnectionDep) -> LeaveWishRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteLeaveWishRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
LeaveWishRepositoryDep = Annotated[LeaveWishRepository, Depends(get_leave_wish_repository)]
|
|
|
|
|
|
def get_day_assertion_repository(conn: ConnectionDep) -> DayAssertionRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteDayAssertionRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
DayAssertionRepositoryDep = Annotated[DayAssertionRepository, Depends(get_day_assertion_repository)]
|
|
|
|
|
|
def get_preschool_planning_days_repository(
|
|
conn: ConnectionDep,
|
|
) -> PreschoolPlanningDaysRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLitePreschoolPlanningDaysRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
PreschoolPlanningDaysRepositoryDep = Annotated[
|
|
PreschoolPlanningDaysRepository, Depends(get_preschool_planning_days_repository)
|
|
]
|
|
|
|
|
|
def get_day_override_repository(conn: ConnectionDep) -> DayOverrideRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteDayOverrideRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
DayOverrideRepositoryDep = Annotated[DayOverrideRepository, Depends(get_day_override_repository)]
|
|
|
|
|
|
def get_tip_dismissal_repository(conn: ConnectionDep) -> TipDismissalRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLiteTipDismissalRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
TipDismissalRepositoryDep = Annotated[TipDismissalRepository, Depends(get_tip_dismissal_repository)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Service factories
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_auth_service(
|
|
user_repo: UserRepositoryDep,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
ws_repo: WorkspaceRepositoryDep,
|
|
inv_repo: WorkspaceInvitationRepositoryDep,
|
|
uow: UnitOfWorkDep,
|
|
) -> AuthService:
|
|
return AuthService(user_repo, pd_repo, ws_repo, uow=uow, inv_repo=inv_repo)
|
|
|
|
|
|
AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)]
|
|
|
|
|
|
def get_vacation_days_service(repo: VacationDaysRepositoryDep) -> VacationDaysService:
|
|
return VacationDaysService(repo)
|
|
|
|
|
|
VacationDaysServiceDep = Annotated[VacationDaysService, Depends(get_vacation_days_service)]
|
|
|
|
|
|
def get_preschool_planning_days_service(
|
|
repo: PreschoolPlanningDaysRepositoryDep,
|
|
) -> PreschoolPlanningDaysService:
|
|
return PreschoolPlanningDaysService(repo)
|
|
|
|
|
|
PreschoolPlanningDaysServiceDep = Annotated[
|
|
PreschoolPlanningDaysService, Depends(get_preschool_planning_days_service)
|
|
]
|
|
|
|
|
|
def get_leave_wish_service(
|
|
repo: LeaveWishRepositoryDep,
|
|
planned_leave_repo: PlannedLeaveRepositoryDep,
|
|
child_repo: ChildRepositoryDep,
|
|
day_assertion_repo: DayAssertionRepositoryDep,
|
|
uow: UnitOfWorkDep,
|
|
) -> LeaveWishService:
|
|
# `child_repo` resolves "until the child turns 4" into a concrete last
|
|
# year when a repeating wish is materialised — see LeaveWishService.
|
|
# `day_assertion_repo` is what `delete()` uses to clear a pinned/promoted
|
|
# row's assertion alongside the row itself (restructure step 3b). `uow`
|
|
# makes `create`/`update`/`delete`'s multi-row writes atomic — this
|
|
# service previously had none, so a crash mid-series-delete/re-projection
|
|
# left a partially-applied series (adversarial-review-adjacent fix).
|
|
return LeaveWishService(repo, planned_leave_repo, child_repo, day_assertion_repo, uow=uow)
|
|
|
|
|
|
LeaveWishServiceDep = Annotated[LeaveWishService, Depends(get_leave_wish_service)]
|
|
|
|
|
|
def get_day_override_service(repo: DayOverrideRepositoryDep) -> DayOverrideService:
|
|
return DayOverrideService(repo)
|
|
|
|
|
|
DayOverrideServiceDep = Annotated[DayOverrideService, Depends(get_day_override_service)]
|
|
|
|
|
|
def get_calendar_service(day_override_service: DayOverrideServiceDep) -> CalendarService:
|
|
return CalendarService(day_override_service)
|
|
|
|
|
|
CalendarServiceDep = Annotated[CalendarService, Depends(get_calendar_service)]
|
|
|
|
|
|
def get_tips_service(
|
|
leave_repo: ParentalLeaveRepositoryDep,
|
|
planned_repo: PlannedLeaveRepositoryDep,
|
|
child_repo: ChildRepositoryDep,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
vacation_repo: VacationDaysRepositoryDep,
|
|
leave_wish_repo: LeaveWishRepositoryDep,
|
|
dismissal_repo: TipDismissalRepositoryDep,
|
|
) -> TipsService:
|
|
return TipsService(
|
|
leave_repo,
|
|
planned_repo,
|
|
child_repo,
|
|
pd_repo,
|
|
vacation_repo,
|
|
leave_wish_repo,
|
|
dismissal_repo,
|
|
)
|
|
|
|
|
|
TipsServiceDep = Annotated[TipsService, Depends(get_tips_service)]
|
|
|
|
|
|
def get_child_service(repo: ChildRepositoryDep) -> ChildService:
|
|
return ChildService(repo)
|
|
|
|
|
|
ChildServiceDep = Annotated[ChildService, Depends(get_child_service)]
|
|
|
|
|
|
def get_personal_data_service(repo: PersonalDataRepositoryDep) -> PersonalDataService:
|
|
return PersonalDataService(repo)
|
|
|
|
|
|
PersonalDataServiceDep = Annotated[PersonalDataService, Depends(get_personal_data_service)]
|
|
|
|
|
|
def get_workspace_service(
|
|
ws_repo: WorkspaceRepositoryDep,
|
|
inv_repo: WorkspaceInvitationRepositoryDep,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
user_repo: UserRepositoryDep,
|
|
leave_wish_repo: LeaveWishRepositoryDep,
|
|
planned_leave_repo: PlannedLeaveRepositoryDep,
|
|
parental_leave_repo: ParentalLeaveRepositoryDep,
|
|
vacation_days_repo: VacationDaysRepositoryDep,
|
|
uow: UnitOfWorkDep,
|
|
) -> WorkspaceService:
|
|
return WorkspaceService(
|
|
ws_repo,
|
|
inv_repo,
|
|
pd_repo,
|
|
user_repo,
|
|
leave_wish_repo,
|
|
planned_leave_repo,
|
|
parental_leave_repo,
|
|
vacation_days_repo,
|
|
uow=uow,
|
|
)
|
|
|
|
|
|
WorkspaceServiceDep = Annotated[WorkspaceService, Depends(get_workspace_service)]
|
|
|
|
|
|
def get_analytics_service(
|
|
leave_repo: ParentalLeaveRepositoryDep,
|
|
planned_repo: PlannedLeaveRepositoryDep,
|
|
child_repo: ChildRepositoryDep,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
workspace_repo: WorkspaceRepositoryDep,
|
|
vacation_repo: VacationDaysRepositoryDep,
|
|
) -> AnalyticsService:
|
|
return AnalyticsService(
|
|
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
|
)
|
|
|
|
|
|
AnalyticsServiceDep = Annotated[AnalyticsService, Depends(get_analytics_service)]
|
|
|
|
|
|
def get_day_assertion_service(
|
|
repo: DayAssertionRepositoryDep,
|
|
planned_leave_repo: PlannedLeaveRepositoryDep,
|
|
analytics_service: AnalyticsServiceDep,
|
|
workspace_repo: WorkspaceRepositoryDep,
|
|
day_override_repo: DayOverrideRepositoryDep,
|
|
uow: UnitOfWorkDep,
|
|
) -> DayAssertionService:
|
|
# Restructure step 3b: this is now the ONE write path behind
|
|
# `POST /api/day-assertions`, mirroring into `planned_leave_day` and
|
|
# enforcing the same guards `PlanningService.add_planned_day`/`update`
|
|
# used to — see `DayAssertionService`'s docstring. `uow` makes the
|
|
# assertion write + mirror write atomic (adversarial-review fix).
|
|
return DayAssertionService(
|
|
repo,
|
|
planned_leave_repo=planned_leave_repo,
|
|
analytics_service=analytics_service,
|
|
uow=uow,
|
|
workspace_repo=workspace_repo,
|
|
day_override_repo=day_override_repo,
|
|
)
|
|
|
|
|
|
DayAssertionServiceDep = Annotated[DayAssertionService, Depends(get_day_assertion_service)]
|
|
|
|
|
|
def get_allocation_input_loader(
|
|
workspace_repo: WorkspaceRepositoryDep,
|
|
child_repo: ChildRepositoryDep,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
parental_leave_repo: ParentalLeaveRepositoryDep,
|
|
vacation_repo: VacationDaysRepositoryDep,
|
|
planned_leave_repo: PlannedLeaveRepositoryDep,
|
|
leave_wish_repo: LeaveWishRepositoryDep,
|
|
day_override_repo: DayOverrideRepositoryDep,
|
|
preschool_planning_days_repo: PreschoolPlanningDaysRepositoryDep,
|
|
day_assertion_repo: DayAssertionRepositoryDep,
|
|
) -> AllocationInputLoader:
|
|
return AllocationInputLoader(
|
|
workspace_repo=workspace_repo,
|
|
child_repo=child_repo,
|
|
personal_data_repo=pd_repo,
|
|
parental_leave_repo=parental_leave_repo,
|
|
vacation_repo=vacation_repo,
|
|
planned_leave_repo=planned_leave_repo,
|
|
leave_wish_repo=leave_wish_repo,
|
|
day_override_repo=day_override_repo,
|
|
preschool_planning_days_repo=preschool_planning_days_repo,
|
|
day_assertion_repo=day_assertion_repo,
|
|
)
|
|
|
|
|
|
AllocationInputLoaderDep = Annotated[AllocationInputLoader, Depends(get_allocation_input_loader)]
|
|
|
|
|
|
def get_planning_engine_service(
|
|
loader: AllocationInputLoaderDep,
|
|
dismissal_repo: TipDismissalRepositoryDep,
|
|
) -> PlanningEngineService:
|
|
return PlanningEngineService(loader, dismissal_repo=dismissal_repo)
|
|
|
|
|
|
PlanningEngineServiceDep = Annotated[PlanningEngineService, Depends(get_planning_engine_service)]
|
|
|
|
|
|
def get_planning_service(
|
|
repo: PlannedLeaveRepositoryDep,
|
|
analytics_service: AnalyticsServiceDep,
|
|
parental_leave_repo: ParentalLeaveRepositoryDep,
|
|
vacation_repo: VacationDaysRepositoryDep,
|
|
planning_engine_service: PlanningEngineServiceDep,
|
|
uow: UnitOfWorkDep,
|
|
workspace_repo: WorkspaceRepositoryDep,
|
|
day_override_repo: DayOverrideRepositoryDep,
|
|
leave_wish_service: LeaveWishServiceDep,
|
|
day_assertion_repo: DayAssertionRepositoryDep,
|
|
) -> PlanningService:
|
|
return PlanningService(
|
|
repo,
|
|
analytics_service,
|
|
parental_leave_repo,
|
|
vacation_repo,
|
|
planning_engine_service,
|
|
uow=uow,
|
|
workspace_repo=workspace_repo,
|
|
day_override_repo=day_override_repo,
|
|
# Only needed by `sync_with_wish` (POST /api/plan/sync-with-wish) —
|
|
# see that method's docstring.
|
|
leave_wish_service=leave_wish_service,
|
|
# `pinned_keys` (sync_plan's own pre-write snapshot) and
|
|
# `sync_with_wish`'s 'attach'/'pin'/'unpin' day-lock actions — see
|
|
# PlanningService's docstring.
|
|
day_assertion_repo=day_assertion_repo,
|
|
)
|
|
|
|
|
|
PlanningServiceDep = Annotated[PlanningService, Depends(get_planning_service)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Current-user dependency (JWT bearer)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def get_current_user(
|
|
service: AuthServiceDep,
|
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
|
) -> User:
|
|
"""Extract and verify the JWT bearer token; return the authenticated User."""
|
|
if credentials is None:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
|
|
try:
|
|
return await service.get_user_from_token(credentials.credentials)
|
|
except UnauthorizedError as exc:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(exc)) from exc
|
|
|
|
|
|
CurrentUserDep = Annotated[User, Depends(get_current_user)]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tenant scoping
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Rule of thumb: identity-derived facts (which workspace the caller belongs
|
|
# to) live here in the DI layer; entity-ownership checks that require loading
|
|
# a row (e.g. a leave wish's own workspace_id) live in the service, mirroring
|
|
# `WorkspaceService._get_member`. Routers stay thin: they hand the resolved
|
|
# scope to the service and let a NotFoundError become a 404 — never a 403,
|
|
# so a foreign id never confirms its own existence. See the T1 authz audit,
|
|
# §3 "Scoping primitive — design for app/dependencies.py".
|
|
|
|
|
|
async def get_current_personal_data(
|
|
current_user: CurrentUserDep, pd_repo: PersonalDataRepositoryDep
|
|
) -> PersonalData:
|
|
"""The caller's own PersonalData row — the root of tenant scoping."""
|
|
pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
|
|
if pd is None or pd.deleted_at is not None:
|
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "No parent profile for this account")
|
|
return pd
|
|
|
|
|
|
CurrentPersonalDataDep = Annotated[PersonalData, Depends(get_current_personal_data)]
|
|
|
|
|
|
async def get_current_workspace_id(pd: CurrentPersonalDataDep) -> UUID:
|
|
return pd.workspace_id
|
|
|
|
|
|
CurrentWorkspaceIdDep = Annotated[UUID, Depends(get_current_workspace_id)]
|
|
|
|
|
|
def _not_found(kind: str, id_: UUID) -> HTTPException:
|
|
# 404, never 403: do not confirm that a foreign id exists.
|
|
return HTTPException(status.HTTP_404_NOT_FOUND, f"{kind} {id_} not found")
|
|
|
|
|
|
async def require_workspace_access(workspace_id: UUID, current: CurrentWorkspaceIdDep) -> UUID:
|
|
"""Path-param guard for `/{workspace_id}/...` routes.
|
|
|
|
FastAPI binds the `workspace_id` path param by name, so a router only
|
|
has to declare `workspace_id: WorkspaceIdDep` in place of `UUID` to get
|
|
this check for free.
|
|
"""
|
|
if workspace_id != current:
|
|
raise _not_found("Workspace", workspace_id)
|
|
return workspace_id
|
|
|
|
|
|
WorkspaceIdDep = Annotated[UUID, Depends(require_workspace_access)]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WorkspaceScope:
|
|
"""The caller's tenant, plus helpers for ids arriving in a body/query.
|
|
|
|
`check_workspace` is a cheap identity comparison (no DB read — the
|
|
caller's own workspace_id is already known). `check_child`/`check_member`
|
|
load the row because ownership can only be known after the load; both
|
|
404 (never 403) on a missing OR foreign-workspace id, so a foreign id can
|
|
never be distinguished from a nonexistent one.
|
|
"""
|
|
|
|
workspace_id: UUID
|
|
personal_data_uuid: UUID
|
|
user_id: UUID
|
|
_child_repo: ChildRepository
|
|
_pd_repo: PersonalDataRepository
|
|
|
|
def check_workspace(self, workspace_id: UUID) -> UUID:
|
|
if workspace_id != self.workspace_id:
|
|
raise _not_found("Workspace", workspace_id)
|
|
return workspace_id
|
|
|
|
async def check_child(self, child_id: UUID) -> Child:
|
|
child = await self._child_repo.get_by_id(child_id)
|
|
if child is None or child.workspace_id != self.workspace_id:
|
|
raise _not_found("Child", child_id)
|
|
return child
|
|
|
|
async def check_member(self, personal_data_uuid: UUID) -> PersonalData:
|
|
pd = await self._pd_repo.get_by_id(personal_data_uuid)
|
|
if pd is None or pd.deleted_at is not None or pd.workspace_id != self.workspace_id:
|
|
raise _not_found("PersonalData", personal_data_uuid)
|
|
return pd
|
|
|
|
|
|
async def get_workspace_scope(
|
|
current_user: CurrentUserDep,
|
|
pd: CurrentPersonalDataDep,
|
|
child_repo: ChildRepositoryDep,
|
|
pd_repo: PersonalDataRepositoryDep,
|
|
) -> WorkspaceScope:
|
|
return WorkspaceScope(pd.workspace_id, pd.uuid, current_user.id, child_repo, pd_repo)
|
|
|
|
|
|
WorkspaceScopeDep = Annotated[WorkspaceScope, Depends(get_workspace_scope)]
|