ข้ามไปยังเนื้อหา

Logging sessions

POST /workouts ใน app/routers/workouts.py — endpoint หัวใจของ FitTrack workout คือ session: timestamp, notes ที่ใส่หรือไม่ก็ได้ และ list ของ sets ที่แต่ละ set คือ exercise บวก reps บวก weight client ส่งทั้งก้อนมาใน request body เดียว และ WorkoutRepo.create_with_sets เขียนแถว Workout และ แถว WorkoutSet ทุกตัวใน transaction เดียว — ดังนั้น session จะถูก log ครบทั้งหมด หรือไม่ถูก log เลย ไม่มีทางเป็น workout ที่ set หายไปครึ่งหนึ่ง

สิ่งนี้ใช้ model Workout / WorkoutSet และ relationship จาก The Domain Model → และ schema WorkoutCreate (พร้อม SetInput ที่ซ้อนอยู่) จาก schemas & repositories → จากนั้น Reading history → อ่าน session พวกนี้กลับออกมา

workout กับ set ทั้งชุดคือ หนึ่งข้อเท็จจริง — “วันนี้ฉันเทรน และนี่คือสิ่งที่ฉันทำเป๊ะ ๆ” จึงควรสร้างพร้อมกันหรือไม่สร้างเลย ถ้า API สร้าง workout ก่อนแล้วค่อยรับ set ทีละ request การหลุดของ connection กลางคันทิ้ง workout ที่มี set สามในห้าตัวไว้ และตอนนี้ทุกการคำนวณ progress เหนือ session นั้นผิดเงียบ ๆ การรวม session ไว้ใน request เดียว แล้วเขียนใน transaction เดียว ทำให้เรื่องนั้นเป็นไปไม่ได้: SQLAlchemy flush parent กับ children ทุกตัวพร้อมกัน และ commit() เดียวทำให้ทั้งหมด durable ในคราวเดียว error ใด ๆ ก่อน commit roll back ทั้งก้อน และ database ไม่เหลือสถานะที่ log ครึ่ง ๆ ไว้เลย

relationship cascade คือสิ่งที่ทำให้แบบนี้ใช้งานสะดวก เพราะ Workout.sets ถูกตั้งค่าด้วย cascade="all, delete-orphan" คุณสร้าง object graph ใน Python — Workout ที่มี WorkoutSet children ต่อเข้าที่ .setsadd() parent แล้ว SQLAlchemy คิดลำดับ insert ให้เอง (workout ก่อน เพื่อให้ id ที่ generate มาเติมลง workout_id ของแต่ละ set ได้) แล้วทำทั้งหมดใน unit of work เดียว คุณไม่เคยเขียน child insert ด้วยมือ

การตัดสินใจเล็ก ๆ สองอย่างสำคัญ อย่างแรก server เป็นเจ้าของ set_index: ลำดับของ set คือตำแหน่งใน request list (enumerate) ไม่ใช่เลขที่ client ส่งมา ดังนั้นลำดับที่เก็บไว้ต่อเนื่องและไม่มีช่องว่างเสมอ ไม่ว่า client จะทำอะไร อย่างที่สอง performed_at default เป็นตอนนี้ แต่ client override ได้ เพราะคนมัก log session หลังจากทำเสร็จ — timestamp คือตอนที่พวกเขา เทรน ไม่ใช่ตอนที่ request มาถึง สุดท้าย เพื่อคืน workout ที่สร้างพร้อม set ที่ต่ออยู่ การอ่านต้อง eager-load relationship sets (selectinload) เพราะภายใต้ async SQLAlchemy การ lazy load นอก session context จะ raise แทนที่จะยิง query เงียบ ๆ

One request + one transaction (workout and its sets together) vs. create the workout, then POST sets one by one

  • Pros: session เป็น atomic — commit ทั้งก้อนหรือ roll back ทั้งก้อน ดังนั้น workout ที่ไม่สมบูรณ์ไม่มีทางมีอยู่ ใช้ network round-trip เดียวแทนที่จะเป็น N+1 และ API model หน่วยในโลกจริง (“session ที่ถูก log”) ตรง ๆ แทนที่จะเปิด client ให้เจอสถานะกลางที่ไม่ถูกต้อง
  • Cons: request body ใหญ่กว่าและซ้อนกัน ดังนั้น client ต้องประกอบ session ทั้งอันก่อนส่ง (ไม่มีการ “เพิ่ม set ระหว่างทาง” แบบ incremental ยิงไปที่ server — นั่นเป็น local UI state จนกว่าจะ save) และ validation error ปฏิเสธการส่งทั้งชุดแทนที่จะเป็นแค่ set เดียวที่แย่ สำหรับ workout logger นั่นคือรูปที่ถูก — set ไม่มีความหมายถ้าไม่มี workout

Server-assigned set_index from list position vs. trusting a client-supplied index

  • Pros: ลำดับต่อเนื่องเสมอและเริ่มที่ศูนย์ เพราะ derive มาจาก array ที่ client เรียงมาแล้ว server จึงไม่มีทางได้รับ index ที่ซ้ำหรือมีช่องว่าง และลำดับที่เก็บไว้คือลำดับที่ส่งมาเป๊ะ ๆ
  • Cons: client แสดง numbering ที่ตั้งใจให้ไม่เรียงลำดับไม่ได้ (แทบไม่ต้องการในนี้) และการจัดลำดับใหม่ภายหลังแปลว่าต้องส่งใหม่ สำหรับ set-ใน-session “ลำดับใน list” คือ semantics ที่คุณต้องการเป๊ะ ๆ การ derive ฝั่ง server จึงตัด input แย่ ๆ ทั้งกลุ่มออกไป

request ซ้อน SetInput ไว้ใน WorkoutCreate ส่วน response ซ้อน SetRead ไว้ใน WorkoutRead field constraint คุมให้ reps กับ weight สมเหตุสมผล และ workout ต้องมีอย่างน้อยหนึ่ง set

# app/schemas/workout.py — Pydantic v2 request/response shapes.
import uuid
from datetime import datetime
from 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]

create_with_sets สร้าง object graph ทั้งก้อนแล้ว commit ครั้งเดียว set_index มาจาก enumerate และ refresh(..., ["sets"]) โหลด children กลับมาเพื่อให้ response มี set ครบ

# app/repositories/workout.py — data access for workouts and their sets.
import uuid
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workout import Workout
from app.models.workout_set import WorkoutSet
from 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 workout

handler บาง — validate body เป็น WorkoutCreate, ส่งพร้อม id ของ caller ให้ repository, คืน session ที่สร้างเป็น WorkoutRead

# app/routers/workouts.py — HTTP for workout sessions.
import uuid
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import get_current_user
from app.db import get_session
from app.models.workout import Workout
from app.repositories.workout import WorkoutRepo
from 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)

จากนั้น include router ใน app/main.py ควบคู่กับ exercises router:

app/main.py
from app.routers import exercises, workouts
app.include_router(exercises.router)
app.include_router(workouts.router)

ขอ token (เหมือนใน exercises verify) แล้วให้แน่ใจว่าคุณมี exercise id ให้อ้างถึง — reuse ตัวหนึ่งจาก GET /exercises:

Terminal window
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 session ที่มีสอง set ใน request เดียว:

Terminal window
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 }
]
}

สังเกตค่า set_index 0 กับ 1 — server กำหนดให้จากลำดับใน list คุณไม่ได้ส่งมาเอง ตอนนี้ยืนยันการรับประกัน validation และ atomicity session ที่ ไม่มี set ละเมิด min_length=1 และโดนปฏิเสธด้วย 422:

Terminal window
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: 422

และ set ที่ weight แย่ (reps: 0) ถูกจับได้ก่อนจะเขียนอะไรลง database — session ทั้งอันโดนปฏิเสธ ดังนั้นไม่มี orphan workout ทิ้งไว้ข้างหลัง:

Terminal window
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"
}

ตรวจสอบความเข้าใจ:

  • อะไรอาจผิดพลาดถ้า API สร้างแถว workout ก่อนแล้วค่อยรับ set ใน request ตามมาแยกกัน? transaction เดียวตัดเรื่องนั้นออกอย่างไร?
  • set_index ไม่อยู่ใน SetInput ค่านี้มาจากไหน และทำไมการ derive ฝั่ง server ถึงดีกว่าเชื่อ client?
  • ทำไมการอ่าน workout ที่สร้างกลับพร้อม set ถึงต้องใช้ selectinload (หรือ refresh(..., ["sets"]) ที่นี่) ภายใต้ async SQLAlchemy?
  • performed_at default เป็น now() แต่ override ได้ ทำไม workout logger ควรให้ client เซ็ต timestamp?

POST /workouts log session ทั้งอันในทีเดียว: request body พก workout บวก list ของ set ที่ไม่ว่าง (WorkoutCreate ซ้อน SetInput) และ WorkoutRepo.create_with_sets สร้าง Workout พร้อม WorkoutSet children แล้วเขียนใน transaction เดียว ผ่าน relationship cascade — commit เดียว, all-or-nothing, ดังนั้น session ที่ log ครึ่ง ๆ มีอยู่ไม่ได้ server กำหนด set_index จากตำแหน่งใน list, performed_at default เป็นตอนนี้แต่ override ได้ และ response คืน session ที่ซ้อนกันครบผ่าน sets ที่ eager-load Pydantic v2 constraint ปฏิเสธการส่งที่ว่างหรือผิดรูปด้วย 422 ก่อนจะเขียนแถวไหนลง database คุณตรวจสอบการ log สอง set, index ที่ server กำหนด และ validation gate ด้วย curl ต่อไป Reading history → อ่าน session พวกนี้กลับ — history ของ user เรียงใหม่สุดก่อน, workout ตัวเดียว และ delete — บังคับ ownership ผ่านตัว query เอง