Logging sessions
What we’re building
Section titled “What we’re building”POST /workouts in app/routers/workouts.py — the endpoint at the heart of FitTrack. A workout is a session: a timestamp, optional notes, and a list of sets, where each set is an exercise plus reps plus weight. The client sends the whole thing in one request body, and WorkoutRepo.create_with_sets writes the Workout row and every WorkoutSet row in a single transaction — so a session is either fully logged or not logged at all, never a workout with half its sets missing.
This uses the Workout / WorkoutSet models and their relationship from The Domain Model →, and the WorkoutCreate schema (with its nested SetInput) from schemas & repositories →. Reading history → then reads these sessions back out.
A workout and its sets are one fact — “I trained today, and here is exactly what I did.” They should be created together or not at all. If the API created the workout first and then took sets one request at a time, a dropped connection halfway through leaves a workout row with three of its five sets, and now every progress calculation over that session is silently wrong. Bundling the session into one request and writing it in one transaction makes that impossible: SQLAlchemy flushes the parent and all children together, and a single commit() makes them all durable at once. Any error before the commit rolls the whole thing back, and the database is never left in a half-logged state.
The relationship cascade is what makes this ergonomic. Because Workout.sets is configured with cascade="all, delete-orphan", you build the object graph in Python — a Workout with WorkoutSet children appended to its .sets — add() the parent, and SQLAlchemy figures out the insert order (workout first, so its generated id can fill each set’s workout_id) and does it all in the one unit of work. You never write the child inserts by hand.
Two smaller decisions matter. First, the server owns set_index: the order of sets is their position in the request list (enumerate), not a number the client sends, so the stored order is always contiguous and gapless regardless of what the client does. Second, performed_at defaults to now but the client may override it, because people log a session after they finish it — the timestamp is when they trained, not when the request arrived. Finally, to return the created workout with its sets attached, the read has to eager-load the sets relationship (selectinload), because under async SQLAlchemy a lazy load outside a session context raises rather than quietly issuing a query.
Pros & cons
Section titled “Pros & cons”One request + one transaction (workout and its sets together) vs. create the workout, then POST sets one by one
- Pros: the session is atomic — it commits whole or rolls back whole, so a partial workout can never exist; it’s one network round-trip instead of N+1; and the API models the real-world unit (“a logged session”) directly instead of exposing the client to the intermediate, invalid states.
- Cons: the request body is larger and nested, so the client must assemble the full session before sending (no incremental “add a set as I go” against the server — that’s local UI state until save); and a validation error rejects the entire submission rather than just one bad set. For a workout logger that’s the right shape — a set has no meaning without its workout.
Server-assigned set_index from list position vs. trusting a client-supplied index
- Pros: ordering is always contiguous and starts at zero because it’s derived from the array the client already ordered; the server can’t be handed duplicate or gappy indices, and the stored order is exactly the submitted order.
- Cons: the client can’t express an intentional non-sequential numbering (rarely wanted here), and reordering after the fact means resubmitting. For sets-in-a-session, “their order in the list” is precisely the semantics you want, so deriving it server-side removes a whole class of bad input.
Set it up
Section titled “Set it up”1. app/schemas/workout.py
Section titled “1. app/schemas/workout.py”The request nests SetInput inside WorkoutCreate; the response nests SetRead inside WorkoutRead. Field constraints keep reps and weight sane, and a workout must have at least one set.
# app/schemas/workout.py — Pydantic v2 request/response shapes.import uuidfrom datetime import datetimefrom decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class SetInput(BaseModel): """One set as the client sends it — no set_index; the server assigns that."""
exercise_id: uuid.UUID reps: int = Field(gt=0, le=1000) weight_kg: Decimal = Field(ge=0, max_digits=6, decimal_places=2)
class WorkoutCreate(BaseModel): performed_at: datetime | None = None # defaults to now() server-side notes: str | None = Field(default=None, max_length=2000) sets: list[SetInput] = Field(min_length=1) # a session has at least one set
class SetRead(BaseModel): model_config = ConfigDict(from_attributes=True)
id: uuid.UUID exercise_id: uuid.UUID set_index: int reps: int weight_kg: float
class WorkoutRead(BaseModel): model_config = ConfigDict(from_attributes=True)
id: uuid.UUID user_id: uuid.UUID performed_at: datetime notes: str | None created_at: datetime sets: list[SetRead]2. app/repositories/workout.py
Section titled “2. app/repositories/workout.py”create_with_sets builds the whole object graph and commits it once. The set_index comes from enumerate, and refresh(..., ["sets"]) reloads the children so the response includes them.
# app/repositories/workout.py — data access for workouts and their sets.import uuidfrom datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workout import Workoutfrom app.models.workout_set import WorkoutSetfrom app.schemas.workout import WorkoutCreate
class WorkoutRepo: def __init__(self, session: AsyncSession) -> None: self.session = session
async def create_with_sets( self, user_id: uuid.UUID, data: WorkoutCreate ) -> Workout: """Persist a workout and all of its sets in one transaction. The relationship cascade inserts the children with the parent; a single commit makes the whole session durable, or the rollback undoes it all.""" workout = Workout( user_id=user_id, performed_at=data.performed_at or datetime.now(timezone.utc), notes=data.notes, ) for index, item in enumerate(data.sets): workout.sets.append( WorkoutSet( exercise_id=item.exercise_id, set_index=index, # server owns ordering: position in the list reps=item.reps, weight_kg=item.weight_kg, ) )
self.session.add(workout) # cascade queues the sets too await self.session.commit() # one transaction: parent + all children await self.session.refresh(workout, attribute_names=["sets"]) return workout3. app/routers/workouts.py
Section titled “3. app/routers/workouts.py”The handler is thin — validate the body into WorkoutCreate, hand it and the caller’s id to the repository, return the created session as WorkoutRead.
# app/routers/workouts.py — HTTP for workout sessions.import uuid
from fastapi import APIRouter, Depends, statusfrom sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_userfrom app.db import get_sessionfrom app.models.workout import Workoutfrom app.repositories.workout import WorkoutRepofrom app.schemas.workout import WorkoutCreate, WorkoutRead
router = APIRouter(prefix="/workouts", tags=["workouts"])
@router.post("", response_model=WorkoutRead, status_code=status.HTTP_201_CREATED)async def log_workout( data: WorkoutCreate, user_id: uuid.UUID = Depends(get_current_user), session: AsyncSession = Depends(get_session),) -> Workout: """Log a whole session — the workout and its sets — in one transaction.""" return await WorkoutRepo(session).create_with_sets(user_id, data)Then include it in app/main.py alongside the exercises router:
from app.routers import exercises, workouts
app.include_router(exercises.router)app.include_router(workouts.router)Verify
Section titled “Verify”Get a token (as in the exercises verify) and make sure you have an exercise id to reference — reuse one from GET /exercises:
TOKEN=$(curl -s "http://127.0.0.1:54321/auth/v1/token?grant_type=password" \ -H "apikey: $SUPABASE_ANON_KEY" -H "Content-Type: application/json" \ -d '{"email":"you@example.com","password":"password123"}' | jq -r .access_token)
EX=$(curl -s localhost:8000/exercises -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')Log a session with two sets in one request:
curl -s -X POST localhost:8000/workouts \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{ \"notes\": \"Leg day\", \"sets\": [ {\"exercise_id\": \"$EX\", \"reps\": 5, \"weight_kg\": 100.0}, {\"exercise_id\": \"$EX\", \"reps\": 5, \"weight_kg\": 102.5} ] }" | jq{ "id": "3d9a...workout-id...", "user_id": "b1e7...your-user-id...", "performed_at": "2026-07-14T09:30:00Z", "notes": "Leg day", "created_at": "2026-07-14T09:30:00Z", "sets": [ { "id": "…", "exercise_id": "…", "set_index": 0, "reps": 5, "weight_kg": 100.0 }, { "id": "…", "exercise_id": "…", "set_index": 1, "reps": 5, "weight_kg": 102.5 } ]}Note the set_index values 0 and 1 — the server assigned them from list order; you never sent them. Now confirm the validation and atomicity guarantees. A session with no sets violates min_length=1 and is rejected with 422:
curl -s -o /dev/null -w "empty sets: %{http_code}\n" -X POST localhost:8000/workouts \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"notes":"nothing","sets":[]}'empty sets: 422And a set with a bad weight (reps: 0) is caught before anything is written — the whole session is refused, so no orphan workout is left behind:
curl -s -X POST localhost:8000/workouts \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d "{\"sets\":[{\"exercise_id\":\"$EX\",\"reps\":0,\"weight_kg\":50}]}" \ | jq '.detail[0] | {loc, msg}'{ "loc": ["body", "sets", 0, "reps"], "msg": "Input should be greater than 0"}Check your understanding:
- What could go wrong if the API created the workout row first and then accepted its sets in separate follow-up requests? How does one transaction rule that out?
set_indexisn’t inSetInput. Where does it come from, and why is deriving it server-side better than trusting the client?- Why does reading the created workout back with its sets require
selectinload(or therefresh(..., ["sets"])here) under async SQLAlchemy? performed_atdefaults tonow()but is overridable. Why should a workout logger let the client set the timestamp?
POST /workouts logs an entire session in one shot: the request body carries the workout plus a non-empty list of sets (WorkoutCreate nesting SetInput), and WorkoutRepo.create_with_sets builds the Workout with its WorkoutSet children and persists them in a single transaction through the relationship cascade — one commit, all-or-nothing, so a half-logged session can’t exist. The server assigns set_index from list position, performed_at defaults to now but is overridable, and the response returns the full nested session via eager-loaded sets. Pydantic v2 constraints reject empty or malformed submissions with a 422 before any row is written. You verified a two-set log, the server-assigned indices, and the validation gate with curl. Next, Reading history → reads these sessions back — a user’s history newest-first, a single workout, and delete — enforcing ownership through the query itself.