"""HTTP surface of the health module — prefix /api/health (CONVENTIONS C2.1). Routers stay thin: dependencies + service/stats call + response schema. Static sub-paths are declared before their `/{id}` siblings on purpose. """ import datetime as dt from typing import Annotated from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.pagination import Page, PageParams, paginate from app.modules.auth.models import User from app.modules.health import calculations as calc from app.modules.health import foods as foods_service from app.modules.health import service, stats from app.modules.health.models import ( GoalStatus, MealType, ScheduleKind, SportType, ) from app.modules.health.schemas import ( ActiveGoalRead, AdherenceResponse, BodyMeasurementCreate, BodyMeasurementRead, BodyMeasurementUpdate, DailyActivityRead, DailyActivityUpsert, DashboardResponse, FoodEntryCreate, FoodEntryRead, FoodEntryUpdate, FoodFavoriteCreate, FoodFavoriteRead, FoodFavoriteUpdate, FoodSearchItem, FoodSearchResponse, GoalCreate, GoalRead, GoalUpdate, HealthProfileRead, HealthProfileUpdate, MergedActivityRead, NutritionDayDetail, NutritionDayRead, RecentFoodRead, ScheduleRead, ScheduleUpdate, StatsResponse, TodayResponse, WaterEntryCreate, WaterEntryRead, WeightEntryCreate, WeightEntryRead, WeightEntryUpdate, WorkoutCreate, WorkoutRead, WorkoutUpdate, ) router = APIRouter(prefix="/health", tags=["health"]) DbDep = Annotated[Session, Depends(get_db)] UserDep = Annotated[User, Depends(get_current_user)] PageDep = Annotated[PageParams, Depends()] FromQ = Annotated[dt.date | None, Query(alias="from")] ToQ = Annotated[dt.date | None, Query()] TzQ = Annotated[str | None, Query()] SortQ = Annotated[str | None, Query()] def _page(items: list, total: int, params: PageParams, schema) -> Page: return Page( items=[schema.model_validate(item) for item in items], total=total, page=params.page, page_size=params.page_size, ) # --- Profile ------------------------------------------------------------------ def _profile_read(db: Session, user_id: int) -> HealthProfileRead: profile = service.require_profile(db, user_id) read = HealthProfileRead.model_validate(profile) tz = service.profile_tz(db, user_id, None) today = service.today_local(tz) read.age = calc.age_on(today, profile.birthdate) trend = calc.weight_trend(service.daily_weights(db, user_id, tz)) weight = trend[-1][1] if trend else None read.current_weight_kg = None if weight is None else round(weight, 2) if weight is not None: read.bmr_kcal = round( calc.bmr_mifflin( weight, float(profile.height_cm), read.age, profile.sex.value ), 1, ) read.tdee_estimated_kcal = round( read.bmr_kcal * calc.ACTIVITY_FACTORS[profile.activity_level.value], 1 ) bmi = calc.bmi(weight, float(profile.height_cm)) read.bmi = None if bmi is None else round(bmi, 1) return read @router.get("/profile", response_model=HealthProfileRead) def get_profile(db: DbDep, user: UserDep) -> HealthProfileRead: return _profile_read(db, user.id) @router.put("/profile", response_model=HealthProfileRead) def put_profile( payload: HealthProfileUpdate, db: DbDep, user: UserDep ) -> HealthProfileRead: service.upsert_profile(db, user.id, payload) return _profile_read(db, user.id) # --- Weights ------------------------------------------------------------------ @router.get("/weights/stats", response_model=StatsResponse) def weights_stats( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> StatsResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone, default_days=90) return stats.weight_stats(db, user.id, start, end, zone) @router.get("/weights", response_model=Page[WeightEntryRead]) def list_weights( db: DbDep, user: UserDep, params: PageDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, sort: SortQ = None, ) -> Page[WeightEntryRead]: zone = service.profile_tz(db, user.id, tz) stmt = service.weights_query(user.id, from_, to, zone, sort) items, total = paginate(db, stmt, params) return _page(items, total, params, WeightEntryRead) @router.post("/weights", response_model=WeightEntryRead, status_code=201) def create_weight( payload: WeightEntryCreate, db: DbDep, user: UserDep ) -> WeightEntryRead: return WeightEntryRead.model_validate(service.create_weight(db, user.id, payload)) @router.patch("/weights/{entry_id}", response_model=WeightEntryRead) @router.put("/weights/{entry_id}", response_model=WeightEntryRead) def update_weight( entry_id: int, payload: WeightEntryUpdate, db: DbDep, user: UserDep ) -> WeightEntryRead: return WeightEntryRead.model_validate( service.update_weight(db, user.id, entry_id, payload) ) @router.delete("/weights/{entry_id}", status_code=204) def delete_weight(entry_id: int, db: DbDep, user: UserDep) -> None: service.delete_weight(db, user.id, entry_id) # --- Body measurements -------------------------------------------------------- @router.get("/measurements/stats", response_model=StatsResponse) def measurements_stats( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> StatsResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone, default_days=180) return stats.measurement_stats(db, user.id, start, end, zone) @router.get("/measurements", response_model=Page[BodyMeasurementRead]) def list_measurements( db: DbDep, user: UserDep, params: PageDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, sort: SortQ = None, ) -> Page[BodyMeasurementRead]: zone = service.profile_tz(db, user.id, tz) stmt = service.measurements_query(user.id, from_, to, zone, sort) items, total = paginate(db, stmt, params) return _page(items, total, params, BodyMeasurementRead) @router.post("/measurements", response_model=BodyMeasurementRead, status_code=201) def create_measurement( payload: BodyMeasurementCreate, db: DbDep, user: UserDep ) -> BodyMeasurementRead: return BodyMeasurementRead.model_validate( service.create_measurement(db, user.id, payload) ) @router.patch("/measurements/{row_id}", response_model=BodyMeasurementRead) @router.put("/measurements/{row_id}", response_model=BodyMeasurementRead) def update_measurement( row_id: int, payload: BodyMeasurementUpdate, db: DbDep, user: UserDep ) -> BodyMeasurementRead: return BodyMeasurementRead.model_validate( service.update_measurement(db, user.id, row_id, payload) ) @router.delete("/measurements/{row_id}", status_code=204) def delete_measurement(row_id: int, db: DbDep, user: UserDep) -> None: service.delete_measurement(db, user.id, row_id) # --- Daily activity ----------------------------------------------------------- @router.get("/activity/stats", response_model=StatsResponse) def activity_stats( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> StatsResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone) return stats.activity_stats(db, user.id, start, end, zone) @router.get("/activity", response_model=None) def list_activity( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, raw: Annotated[bool, Query()] = False, ) -> list[MergedActivityRead] | list[DailyActivityRead]: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone) if raw: return [ DailyActivityRead.model_validate(row) for row in service.activity_rows(db, user.id, start, end) ] merged = service.merged_activity(db, user.id, start, end) return [ MergedActivityRead( date=day, steps=value.steps, active_kcal=value.active_kcal, total_kcal=value.total_kcal, distance_m=value.distance_m, active_minutes=value.active_minutes, floors=value.floors, field_sources=value.field_sources, ) for day, value in sorted(merged.items()) ] @router.post("/activity", response_model=DailyActivityRead, status_code=201) def upsert_activity( payload: DailyActivityUpsert, db: DbDep, user: UserDep ) -> DailyActivityRead: return DailyActivityRead.model_validate( service.upsert_manual_activity(db, user.id, payload) ) @router.delete("/activity/{row_id}", status_code=204) def delete_activity(row_id: int, db: DbDep, user: UserDep) -> None: service.delete_activity(db, user.id, row_id) # --- Workouts ----------------------------------------------------------------- @router.get("/workouts/stats", response_model=StatsResponse) def workouts_stats( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> StatsResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone, default_days=90) return stats.workout_stats(db, user.id, start, end, zone) @router.get("/workouts", response_model=Page[WorkoutRead]) def list_workouts( db: DbDep, user: UserDep, params: PageDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, sport_type: Annotated[SportType | None, Query()] = None, include_hidden: Annotated[bool, Query()] = False, sort: SortQ = None, ) -> Page[WorkoutRead]: zone = service.profile_tz(db, user.id, tz) stmt = service.workouts_query( user.id, from_, to, zone, sport_type.value if sport_type else None, include_hidden, sort, ) items, total = paginate(db, stmt, params) return _page(items, total, params, WorkoutRead) @router.post("/workouts", response_model=WorkoutRead, status_code=201) def create_workout(payload: WorkoutCreate, db: DbDep, user: UserDep) -> WorkoutRead: return WorkoutRead.model_validate(service.create_workout(db, user.id, payload)) @router.patch("/workouts/{workout_id}", response_model=WorkoutRead) @router.put("/workouts/{workout_id}", response_model=WorkoutRead) def update_workout( workout_id: int, payload: WorkoutUpdate, db: DbDep, user: UserDep ) -> WorkoutRead: return WorkoutRead.model_validate( service.update_workout(db, user.id, workout_id, payload) ) @router.delete("/workouts/{workout_id}", status_code=204) def delete_workout(workout_id: int, db: DbDep, user: UserDep) -> None: service.delete_workout(db, user.id, workout_id) # --- Goals -------------------------------------------------------------------- @router.get("/goals/active", response_model=ActiveGoalRead) def get_active_goal(db: DbDep, user: UserDep, tz: TzQ = None) -> ActiveGoalRead: zone = service.profile_tz(db, user.id, tz) return stats.active_goal_view(db, user.id, zone) @router.get("/goals", response_model=Page[GoalRead]) def list_goals( db: DbDep, user: UserDep, params: PageDep, status: Annotated[GoalStatus | None, Query()] = None, sort: SortQ = None, ) -> Page[GoalRead]: stmt = service.goals_query(user.id, status.value if status else None, sort) items, total = paginate(db, stmt, params) return _page(items, total, params, GoalRead) @router.post("/goals", response_model=GoalRead, status_code=201) def create_goal( payload: GoalCreate, db: DbDep, user: UserDep, replace_active: Annotated[bool, Query()] = False, ) -> GoalRead: return GoalRead.model_validate( service.create_goal(db, user.id, payload, replace_active) ) @router.post("/goals/{goal_id}/activate", response_model=GoalRead) def activate_goal(goal_id: int, db: DbDep, user: UserDep) -> GoalRead: return GoalRead.model_validate(service.activate_goal(db, user.id, goal_id)) @router.patch("/goals/{goal_id}", response_model=GoalRead) @router.put("/goals/{goal_id}", response_model=GoalRead) def update_goal( goal_id: int, payload: GoalUpdate, db: DbDep, user: UserDep ) -> GoalRead: return GoalRead.model_validate(service.update_goal(db, user.id, goal_id, payload)) @router.delete("/goals/{goal_id}", status_code=204) def delete_goal(goal_id: int, db: DbDep, user: UserDep) -> None: service.delete_goal(db, user.id, goal_id) # --- Energy balance & dashboard ----------------------------------------------- @router.get("/energy-balance", response_model=StatsResponse) def energy_balance( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> StatsResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone) return stats.energy_balance_stats(db, user.id, start, end, zone) @router.get("/dashboard", response_model=DashboardResponse) def dashboard(db: DbDep, user: UserDep, tz: TzQ = None) -> DashboardResponse: zone = service.profile_tz(db, user.id, tz) return stats.dashboard(db, user.id, zone) # --- Nutrition: journal -------------------------------------------------------- @router.get("/nutrition/stats", response_model=StatsResponse) def nutrition_stats( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> StatsResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone) return stats.nutrition_stats(db, user.id, start, end, zone) @router.get("/nutrition/days", response_model=list[NutritionDayRead]) def nutrition_days( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> list[NutritionDayRead]: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone) return stats.nutrition_days(db, user.id, start, end, zone) @router.get("/nutrition/days/{day}", response_model=NutritionDayDetail) def nutrition_day( day: dt.date, db: DbDep, user: UserDep, tz: TzQ = None ) -> NutritionDayDetail: zone = service.profile_tz(db, user.id, tz) return stats.nutrition_day_detail(db, user.id, day, zone) @router.get("/nutrition/recent", response_model=list[RecentFoodRead]) def recent_foods( db: DbDep, user: UserDep, limit: Annotated[int, Query(ge=1, le=50)] = 20, ) -> list[RecentFoodRead]: return [ RecentFoodRead( name=entry.name, brand=entry.brand, unit=entry.unit, quantity=float(entry.quantity), kcal=float(entry.kcal), protein_g=float(entry.protein_g) if entry.protein_g is not None else None, carbs_g=float(entry.carbs_g) if entry.carbs_g is not None else None, fat_g=float(entry.fat_g) if entry.fat_g is not None else None, meal=entry.meal, last_eaten_at=entry.eaten_at, ) for entry in service.recent_foods(db, user.id, limit) ] @router.get("/nutrition/favorites", response_model=Page[FoodFavoriteRead]) def list_favorites( db: DbDep, user: UserDep, params: PageDep, sort: SortQ = None ) -> Page[FoodFavoriteRead]: stmt = service.favorites_query(user.id, sort) items, total = paginate(db, stmt, params) return _page(items, total, params, FoodFavoriteRead) @router.post("/nutrition/favorites", response_model=FoodFavoriteRead, status_code=201) def create_favorite( payload: FoodFavoriteCreate, db: DbDep, user: UserDep ) -> FoodFavoriteRead: return FoodFavoriteRead.model_validate( service.create_favorite(db, user.id, payload) ) @router.patch("/nutrition/favorites/{favorite_id}", response_model=FoodFavoriteRead) @router.put("/nutrition/favorites/{favorite_id}", response_model=FoodFavoriteRead) def update_favorite( favorite_id: int, payload: FoodFavoriteUpdate, db: DbDep, user: UserDep ) -> FoodFavoriteRead: return FoodFavoriteRead.model_validate( service.update_favorite(db, user.id, favorite_id, payload) ) @router.delete("/nutrition/favorites/{favorite_id}", status_code=204) def delete_favorite(favorite_id: int, db: DbDep, user: UserDep) -> None: service.delete_favorite(db, user.id, favorite_id) @router.get("/nutrition/water/stats", response_model=StatsResponse) def water_stats( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, ) -> StatsResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone) return stats.water_stats(db, user.id, start, end, zone) @router.get("/nutrition/water", response_model=Page[WaterEntryRead]) def list_water( db: DbDep, user: UserDep, params: PageDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, sort: SortQ = None, ) -> Page[WaterEntryRead]: zone = service.profile_tz(db, user.id, tz) stmt = service.water_query(user.id, from_, to, zone, sort) items, total = paginate(db, stmt, params) return _page(items, total, params, WaterEntryRead) @router.post("/nutrition/water", response_model=WaterEntryRead, status_code=201) def create_water(payload: WaterEntryCreate, db: DbDep, user: UserDep) -> WaterEntryRead: return WaterEntryRead.model_validate(service.create_water(db, user.id, payload)) @router.delete("/nutrition/water/{entry_id}", status_code=204) def delete_water(entry_id: int, db: DbDep, user: UserDep) -> None: service.delete_water(db, user.id, entry_id) @router.get("/nutrition/entries", response_model=Page[FoodEntryRead]) def list_food_entries( db: DbDep, user: UserDep, params: PageDep, day: Annotated[dt.date | None, Query()] = None, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, meal: Annotated[MealType | None, Query()] = None, q: Annotated[str | None, Query()] = None, sort: SortQ = None, ) -> Page[FoodEntryRead]: zone = service.profile_tz(db, user.id, tz) stmt = service.food_entries_query( user.id, zone, day, from_, to, meal.value if meal else None, q, sort ) items, total = paginate(db, stmt, params) return _page(items, total, params, FoodEntryRead) @router.post("/nutrition/entries", response_model=FoodEntryRead, status_code=201) def create_food_entry( payload: FoodEntryCreate, db: DbDep, user: UserDep ) -> FoodEntryRead: return FoodEntryRead.model_validate(service.create_food_entry(db, user.id, payload)) @router.patch("/nutrition/entries/{entry_id}", response_model=FoodEntryRead) @router.put("/nutrition/entries/{entry_id}", response_model=FoodEntryRead) def update_food_entry( entry_id: int, payload: FoodEntryUpdate, db: DbDep, user: UserDep ) -> FoodEntryRead: return FoodEntryRead.model_validate( service.update_food_entry(db, user.id, entry_id, payload) ) @router.delete("/nutrition/entries/{entry_id}", status_code=204) def delete_food_entry(entry_id: int, db: DbDep, user: UserDep) -> None: service.delete_food_entry(db, user.id, entry_id) # --- Food referential (Open Food Facts proxy + local cache) ------------------- @router.get("/foods/search", response_model=FoodSearchResponse) def search_foods( db: DbDep, user: UserDep, q: Annotated[str, Query(min_length=1, max_length=100)], limit: Annotated[int, Query(ge=1, le=50)] = 20, ) -> FoodSearchResponse: return foods_service.search_foods(db, q, limit) @router.get("/foods/barcode/{barcode}", response_model=FoodSearchItem) def food_by_barcode(barcode: str, db: DbDep, user: UserDep) -> FoodSearchItem: return foods_service.food_by_barcode(db, barcode) # --- Planning (addendum-planning.md) ------------------------------------------ @router.get("/schedules", response_model=list[ScheduleRead]) def list_schedules(db: DbDep, user: UserDep) -> list[ScheduleRead]: return [ ScheduleRead( kind=row.kind, weekdays=list(row.weekdays or []), enabled=row.enabled ) for row in service.list_schedules(db, user.id) ] @router.put("/schedules/{kind}", response_model=ScheduleRead) def put_schedule( kind: ScheduleKind, payload: ScheduleUpdate, db: DbDep, user: UserDep ) -> ScheduleRead: row = service.upsert_schedule(db, user.id, kind, payload) return ScheduleRead( kind=row.kind, weekdays=list(row.weekdays or []), enabled=row.enabled ) @router.get("/today", response_model=TodayResponse) def today(db: DbDep, user: UserDep, tz: TzQ = None) -> TodayResponse: zone = service.profile_tz(db, user.id, tz) return stats.today_view(db, user.id, zone) @router.get("/stats/adherence", response_model=AdherenceResponse) def adherence( db: DbDep, user: UserDep, from_: FromQ = None, to: ToQ = None, tz: TzQ = None, kind: Annotated[ScheduleKind | None, Query()] = None, ) -> AdherenceResponse: zone = service.profile_tz(db, user.id, tz) start, end = service.resolve_range(from_, to, zone, default_days=90) return stats.adherence_stats( db, user.id, start, end, zone, kind.value if kind else None )