61e5487078
Free days (Sat/Sun/red days, subject to DayOverride) can never be vacation, and can only be parental leave when the workspace opts in via take_pl_on_red_days. The allocator already honoured this; the hand-pin path bypassed it entirely. - PlanningService gains optional workspace_repo/day_override_repo deps and _reject_illegal_free_day, enforced in add_planned_day and update. Reuses allocation_service.is_free_day rather than re-deriving it, and no-ops when the deps are unset so existing callers are unaffected. - update_planned_day had no ValidationError handler at all, so a validation failure there 500'd instead of 422'ing. Fixed. - Calendar filters the pin write loop by the day's isFree; the modal explains free days stay off, and that pinning opts a day out of automatic re-planning. Vacation days at risk of forfeiture were computed and sent over the wire but never rendered: an at-risk column with the age-out/over-cap split, and a dashboard key figure. PlanProposal.warnings was likewise parsed but rendered nowhere, for all three kinds. Adds PlanWarningCard in a Risks section above the tips, with parent names resolved and dates formatted. POST /api/plan/sync now fires from the workspace layer on every plan-input change rather than only from Calendar, so a Settings edit or a pinned-day save re-plans day types instead of merely refetching. Overlapping bumps coalesce into one trailing run; a rejection cannot wedge the in-flight guard. eviction_preview was silently dropped by the frontend despite always being on the wire, and is now surfaced through context. Verified: backend 890 passed (91.49% coverage), frontend 1297 passed, ruff/eslint/tsc clean. Browser-checked in Swedish and English. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JVQx4NUvPbJhn6UcvRd2QY
581 lines
21 KiB
Python
581 lines
21 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_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_day_exclusion_repository import PlannedDayExclusionRepository
|
|
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_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_day_exclusion_repository import (
|
|
SQLitePlannedDayExclusionRepository,
|
|
)
|
|
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_override_service import DayOverrideService
|
|
from app.services.leave_wish_service import LeaveWishService
|
|
from app.services.personal_data_service import PersonalDataService
|
|
from app.services.planned_day_exclusion_service import PlannedDayExclusionService
|
|
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_planned_day_exclusion_repository(conn: ConnectionDep) -> PlannedDayExclusionRepository:
|
|
if settings.database_type == "sqlite":
|
|
return SQLitePlannedDayExclusionRepository(conn)
|
|
raise ValueError(f"Unsupported database_type: {settings.database_type}")
|
|
|
|
|
|
PlannedDayExclusionRepositoryDep = Annotated[
|
|
PlannedDayExclusionRepository, Depends(get_planned_day_exclusion_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,
|
|
) -> LeaveWishService:
|
|
# `child_repo` resolves "until the child turns 4" into a concrete last
|
|
# year when a repeating wish is materialised — see LeaveWishService.
|
|
return LeaveWishService(repo, planned_leave_repo, child_repo)
|
|
|
|
|
|
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_planned_day_exclusion_service(
|
|
repo: PlannedDayExclusionRepositoryDep,
|
|
planned_leave_repo: PlannedLeaveRepositoryDep,
|
|
uow: UnitOfWorkDep,
|
|
) -> PlannedDayExclusionService:
|
|
# `planned_leave_repo` is what makes "remove this day" true immediately
|
|
# rather than only after the next sync — see the service's docstring.
|
|
return PlannedDayExclusionService(repo, planned_leave_repo, uow=uow)
|
|
|
|
|
|
PlannedDayExclusionServiceDep = Annotated[
|
|
PlannedDayExclusionService, Depends(get_planned_day_exclusion_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_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,
|
|
exclusion_repo: PlannedDayExclusionRepositoryDep,
|
|
preschool_planning_days_repo: PreschoolPlanningDaysRepositoryDep,
|
|
) -> 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,
|
|
exclusion_repo=exclusion_repo,
|
|
preschool_planning_days_repo=preschool_planning_days_repo,
|
|
)
|
|
|
|
|
|
AllocationInputLoaderDep = Annotated[AllocationInputLoader, Depends(get_allocation_input_loader)]
|
|
|
|
|
|
def get_planning_engine_service(loader: AllocationInputLoaderDep) -> PlanningEngineService:
|
|
return PlanningEngineService(loader)
|
|
|
|
|
|
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,
|
|
leave_wish_repo: LeaveWishRepositoryDep,
|
|
uow: UnitOfWorkDep,
|
|
workspace_repo: WorkspaceRepositoryDep,
|
|
day_override_repo: DayOverrideRepositoryDep,
|
|
) -> PlanningService:
|
|
return PlanningService(
|
|
repo,
|
|
analytics_service,
|
|
parental_leave_repo,
|
|
vacation_repo,
|
|
planning_engine_service,
|
|
leave_wish_repo=leave_wish_repo,
|
|
uow=uow,
|
|
workspace_repo=workspace_repo,
|
|
day_override_repo=day_override_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)]
|