782415122d
The calendar mixed two kinds of truth in one table. `planned_leave_day` was simultaneously the allocator's output cache, the user's override store (`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that analytics and the age-4 maths read. The planner therefore had to ask "am I allowed to touch this row?" on every sync, and got it wrong at the edges. Every user assertion is now one `day_assertion` row — a concrete `day_type` replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and `day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes purely derived with a single writer, and `PlanningService.pinned_keys()` is the single answer to "is this day pinned", used by the allocator's loader, by `sync_plan` and by the read router alike. On top of that, every calendar band is now a real event: an ad-hoc band opens the same full editor as a wish-backed one and saving it promotes it to a `LeaveWish`, so it can be named, repeated yearly, assigned to either or both parents, and handed to the planner. "Planering" is a `moveable` toggle plus a from/to month bound plus a separate lock/unlock action on the days themselves, replacing a three-rung ladder whose "planner decides" rung set a search window equal to the drawn dates and so could never move anything. Bugs found and fixed along the way: - The plan erased its own history. Fixed windows clip to today, so an elapsed auto-placed day fell into `stale_keys` and was deleted on the next sync — and a background sync runs on every workspace load. Past rows are now frozen and counted as spent. - Migration 010's downgrade silently destroyed every pinned day and exclusion (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty, exit 0, no warning). It now reconstructs both from `day_assertion`, and 009 itemises what it cannot recover before dropping the table. - Deleting a period left its assertions behind, locking those dates forever with no UI path to clear them. Wish deletion now clears them by `wish_id`. - `DayAssertionService` and `LeaveWishService` committed multi-statement writes without a transaction; a crash mid-write diverged the tables or half-deleted a series. Both now run in a unit of work. - A sibling year's shortfall was never surfaced: the toast only inspected the edited occurrence's outcome, so a fully unfunded later year rendered as an empty band with no warning. - A flexible window whose bound had entirely elapsed was dropped from the proposal with no outcome at all. It now reports as `unplaced`. - Unlocking a period deleted its days instead of returning them to the planner. - The e2e global setup posted to a route deleted in this change, which would have failed the whole suite before its first test. Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 | 97.4. Migrations 009 and 010 audited against a hand-built realistic database: the upgrade reconciles exactly and a constraint violation fails safe, leaving the schema at 008. Not complete. Step 4's toggle, month picker and lock/unlock action are verified by browser walk and unit tests, but the partial-lock readout was not exercised. `ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner detects expiring days and then does nothing about them) — not implemented here. Remaining smaller items are listed in the session's restructure notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
602 lines
22 KiB
Python
602 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) -> 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,
|
|
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)]
|