from typing import Annotated, Any import httpx from fastapi import Body, Depends, FastAPI, File, HTTPException, Query, UploadFile, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.core import settings from app.db import create_schema, get_db from app.models import Base, User from app.s3 import upload_catalog_image from app.schemas import MediaUploadRead, TokenRead, UserCreate, UserLogin, UserRead from app.security import create_access_token, get_current_user, hash_password, verify_password app = FastAPI(title="Train Watcher BFF", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) Db = Annotated[Session, Depends(get_db)] CurrentUser = Annotated[User, Depends(get_current_user)] @app.on_event("startup") def on_startup() -> None: create_schema(Base.metadata) @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @app.post("/auth/register", response_model=TokenRead, status_code=status.HTTP_201_CREATED) def register(payload: UserCreate, db: Db) -> TokenRead: user = User( email=payload.email.lower(), password_hash=hash_password(payload.password), display_name=payload.display_name, ) db.add(user) try: db.commit() except IntegrityError as exc: db.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Email already registered", ) from exc db.refresh(user) return TokenRead(access_token=create_access_token(user), user=UserRead.model_validate(user)) @app.post("/auth/login", response_model=TokenRead) def login(payload: UserLogin, db: Db) -> TokenRead: user = db.scalar(select(User).where(User.email == payload.email.lower())) if not user or not verify_password(payload.password, user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password", ) return TokenRead(access_token=create_access_token(user), user=UserRead.model_validate(user)) @app.post("/auth/logout") def logout() -> dict[str, str]: return {"status": "ok"} @app.get("/me", response_model=UserRead) def me(user: CurrentUser) -> User: return user def logic_headers(user: User) -> dict[str, str]: return {"X-Service-Token": settings.service_token, "X-User-Id": str(user.id)} async def logic_request( method: str, path: str, user: User, *, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None, ) -> Any: async with httpx.AsyncClient(base_url=settings.logic_base_url, timeout=10) as client: response = await client.request( method, path, headers=logic_headers(user), json=json, params=params, ) if response.status_code >= 400: try: detail = response.json().get("detail", response.text) except ValueError: detail = response.text raise HTTPException(status_code=response.status_code, detail=detail) if not response.content: return None return response.json() @app.post("/media/images", response_model=MediaUploadRead, status_code=status.HTTP_201_CREATED) async def upload_image( user: CurrentUser, entity_type: Annotated[str, Query(pattern="^(equipment|exercise|machine)$")], file: Annotated[UploadFile, File()], ) -> dict[str, str]: if entity_type == "equipment": entity_type = "machine" return await upload_catalog_image(file, user.id, entity_type) @app.get("/catalog/activity-sources") async def list_activity_sources( user: CurrentUser, search: str | None = None, kind: Annotated[str | None, Query(pattern="^(exercise|machine|equipment)$")] = None, category: str | None = None, scope: Annotated[str, Query(pattern="^(all|builtin|mine)$")] = "all", ) -> Any: params = { key: value for key, value in { "search": search, "kind": kind, "category": category, "scope": scope, }.items() if value not in (None, "") } return await logic_request( "GET", "/internal/catalog/activity-sources", user, params=params, ) @app.post("/catalog/activity-sources", status_code=status.HTTP_201_CREATED) async def create_activity_source(payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request("POST", "/internal/catalog/activity-sources", user, json=payload) @app.get("/catalog/equipment") async def list_equipment(user: CurrentUser, search: str | None = None) -> Any: return await logic_request( "GET", "/internal/catalog/equipment", user, params={"search": search} ) @app.post("/catalog/equipment", status_code=status.HTTP_201_CREATED) async def create_equipment(payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request("POST", "/internal/catalog/equipment", user, json=payload) @app.get("/catalog/exercises") async def list_exercises(user: CurrentUser, search: str | None = None) -> Any: return await logic_request( "GET", "/internal/catalog/exercises", user, params={"search": search} ) @app.post("/catalog/exercises", status_code=status.HTTP_201_CREATED) async def create_exercise(payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request("POST", "/internal/catalog/exercises", user, json=payload) @app.get("/workouts") async def list_workouts(user: CurrentUser) -> Any: return await logic_request("GET", "/internal/workouts", user) @app.post("/workouts", status_code=status.HTTP_201_CREATED) async def create_workout(payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request("POST", "/internal/workouts", user, json=payload) @app.get("/workouts/active") async def get_active_workout(user: CurrentUser) -> Any: return await logic_request("GET", "/internal/workouts/active", user) @app.get("/workouts/{workout_id}") async def get_workout(workout_id: str, user: CurrentUser) -> Any: return await logic_request("GET", f"/internal/workouts/{workout_id}", user) @app.patch("/workouts/{workout_id}") async def update_workout(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request("PATCH", f"/internal/workouts/{workout_id}", user, json=payload) @app.post("/workouts/{workout_id}/finish") async def finish_workout( workout_id: str, user: CurrentUser, payload: Annotated[dict[str, Any] | None, Body()] = None, ) -> Any: return await logic_request( "POST", f"/internal/workouts/{workout_id}/finish", user, json=payload ) @app.post("/workouts/{workout_id}/discard") async def discard_workout(workout_id: str, user: CurrentUser) -> Any: return await logic_request("POST", f"/internal/workouts/{workout_id}/discard", user) @app.post("/workouts/{workout_id}/items", status_code=status.HTTP_201_CREATED) async def add_workout_item(workout_id: str, payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request("POST", f"/internal/workouts/{workout_id}/items", user, json=payload) @app.post("/workout-items/{item_id}/sets", status_code=status.HTTP_201_CREATED) async def add_workout_set(item_id: str, payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request( "POST", f"/internal/workout-items/{item_id}/sets", user, json=payload ) @app.post("/workout-items/{item_id}/sets/batch", status_code=status.HTTP_201_CREATED) async def add_workout_sets_batch(item_id: str, payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request( "POST", f"/internal/workout-items/{item_id}/sets/batch", user, json=payload ) @app.patch("/workout-sets/{set_id}") async def update_workout_set(set_id: str, payload: dict[str, Any], user: CurrentUser) -> Any: return await logic_request("PATCH", f"/internal/workout-sets/{set_id}", user, json=payload) @app.delete("/workout-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_workout_item(item_id: str, user: CurrentUser) -> None: await logic_request("DELETE", f"/internal/workout-items/{item_id}", user) @app.delete("/workout-items/{item_id}/sets/{set_id}", status_code=status.HTTP_204_NO_CONTENT) async def remove_workout_set(item_id: str, set_id: str, user: CurrentUser) -> None: await logic_request("DELETE", f"/internal/workout-items/{item_id}/sets/{set_id}", user) @app.get("/analytics/progression") async def progression( user: CurrentUser, kind: Annotated[str, Query(pattern="^(exercise|machine|equipment)$")] = "exercise", entity_id: str | None = None, ) -> Any: p: dict[str, Any] = {"kind": kind} if entity_id is not None: p["entity_id"] = entity_id return await logic_request( "GET", "/internal/analytics/progression", user, params=p, ) @app.get("/analytics/calories") async def calories(user: CurrentUser) -> Any: return await logic_request("GET", "/internal/analytics/calories", user)