feat: Implement active workout flow with status management

- Added `status`, `total_sets`, and `total_volume` fields to the Workout model.
- Introduced `source_kind`, `title_snapshot`, and `image_s3_url_snapshot` fields to the WorkoutItem model.
- Created endpoints for managing active workouts, including finishing and discarding workouts.
- Updated workout creation to ensure only one active workout exists per user.
- Implemented batch addition of workout sets and updates to workout set details.
- Enhanced database schema with Alembic migrations to support new fields and constraints.
- Added validation to ensure at least one field is provided for workout set updates.
- Updated calorie estimation logic to reflect new workout set structure.
This commit is contained in:
Artem Kashaev
2026-05-29 10:09:56 +05:00
parent d7b0c7754f
commit 7b34ce1a98
30 changed files with 2081 additions and 846 deletions
+34 -1
View File
@@ -1,7 +1,7 @@
from typing import Annotated, Any
import httpx
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile, status
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
@@ -152,6 +152,11 @@ 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)
@@ -162,6 +167,22 @@ async def update_workout(workout_id: str, payload: dict[str, Any], user: Current
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)
@@ -174,6 +195,18 @@ async def add_workout_set(item_id: str, payload: dict[str, Any], user: CurrentUs
)
@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)
@@ -0,0 +1,116 @@
"""active workout flow
Revision ID: 0002_active_workout_flow
Revises: 0001_initial
Create Date: 2026-05-29 08:40:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0002_active_workout_flow"
down_revision: str | None = "0001_initial"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"logic_workouts",
sa.Column("status", sa.String(length=20), nullable=False, server_default="active"),
)
op.add_column(
"logic_workouts",
sa.Column("total_sets", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"logic_workouts",
sa.Column("total_volume", sa.Numeric(12, 2), nullable=False, server_default="0"),
)
op.create_index("ix_logic_workouts_status", "logic_workouts", ["status"])
op.create_check_constraint(
"ck_workout_status",
"logic_workouts",
"status IN ('active', 'finished', 'discarded')",
)
op.execute(
"UPDATE logic_workouts SET status = 'finished' WHERE finished_at IS NOT NULL"
)
op.add_column(
"logic_workout_items",
sa.Column("source_kind", sa.String(length=20), nullable=True),
)
op.add_column(
"logic_workout_items",
sa.Column("title_snapshot", sa.String(length=160), nullable=True),
)
op.add_column(
"logic_workout_items",
sa.Column("image_s3_url_snapshot", sa.Text(), nullable=True),
)
op.execute(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'exercise',
title_snapshot = exercise.name,
image_s3_url_snapshot = exercise.image_s3_url
FROM logic_exercises AS exercise
WHERE item.exercise_id = exercise.id
"""
)
op.execute(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'equipment',
title_snapshot = equipment.name,
image_s3_url_snapshot = equipment.image_s3_url
FROM logic_equipment AS equipment
WHERE item.equipment_id = equipment.id
"""
)
op.alter_column("logic_workout_items", "source_kind", nullable=False)
op.alter_column("logic_workout_items", "title_snapshot", nullable=False)
op.create_check_constraint(
"ck_workout_item_source_kind",
"logic_workout_items",
"source_kind IN ('exercise', 'equipment')",
)
op.execute(
"""
UPDATE logic_workouts AS workout
SET
total_sets = totals.total_sets,
total_volume = totals.total_volume,
estimated_calories = totals.estimated_calories
FROM (
SELECT
item.workout_id,
COUNT(set_row.id) AS total_sets,
COALESCE(SUM(set_row.weight * set_row.reps), 0) AS total_volume,
COALESCE(SUM(set_row.calories), 0) AS estimated_calories
FROM logic_workout_items AS item
LEFT JOIN logic_workout_sets AS set_row ON set_row.workout_item_id = item.id
GROUP BY item.workout_id
) AS totals
WHERE workout.id = totals.workout_id
"""
)
def downgrade() -> None:
op.drop_constraint("ck_workout_item_source_kind", "logic_workout_items", type_="check")
op.drop_column("logic_workout_items", "image_s3_url_snapshot")
op.drop_column("logic_workout_items", "title_snapshot")
op.drop_column("logic_workout_items", "source_kind")
op.drop_constraint("ck_workout_status", "logic_workouts", type_="check")
op.drop_index("ix_logic_workouts_status", table_name="logic_workouts")
op.drop_column("logic_workouts", "total_volume")
op.drop_column("logic_workouts", "total_sets")
op.drop_column("logic_workouts", "status")
+163 -2
View File
@@ -1,7 +1,8 @@
from collections.abc import Generator
from time import sleep
from typing import Any
from sqlalchemy import MetaData, create_engine, text
from sqlalchemy import Connection, MetaData, create_engine, text
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session, sessionmaker
@@ -23,6 +24,7 @@ def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2
with engine.begin() as connection:
connection.execute(text("SELECT 1"))
metadata.create_all(bind=connection)
upgrade_existing_schema(connection)
print("Database schema is ready", flush=True)
return
except OperationalError as exc:
@@ -33,8 +35,167 @@ def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2
raise last_error
def upgrade_existing_schema(connection: Connection) -> None:
"""Apply safe additive upgrades for DBs created by pre-Alembic create_all.
Local Compose databases may already have the initial tables without the
tidy-wolf columns. SQLAlchemy create_all intentionally does not ALTER
existing tables, so keep this idempotent compatibility upgrade until
migrations are wired into service startup.
"""
connection.execute(
text("ALTER TABLE logic_workouts ADD COLUMN IF NOT EXISTS status VARCHAR(20)")
)
connection.execute(
text("ALTER TABLE logic_workouts ADD COLUMN IF NOT EXISTS total_sets INTEGER")
)
connection.execute(
text("ALTER TABLE logic_workouts ADD COLUMN IF NOT EXISTS total_volume NUMERIC(12, 2)")
)
connection.execute(
text(
"""
UPDATE logic_workouts
SET status = CASE WHEN finished_at IS NULL THEN 'active' ELSE 'finished' END
WHERE status IS NULL
"""
)
)
connection.execute(text("UPDATE logic_workouts SET total_sets = 0 WHERE total_sets IS NULL"))
connection.execute(
text("UPDATE logic_workouts SET total_volume = 0 WHERE total_volume IS NULL")
)
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN status SET DEFAULT 'active'"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN status SET NOT NULL"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_sets SET DEFAULT 0"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_sets SET NOT NULL"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_volume SET DEFAULT 0"))
connection.execute(text("ALTER TABLE logic_workouts ALTER COLUMN total_volume SET NOT NULL"))
connection.execute(
text("CREATE INDEX IF NOT EXISTS ix_logic_workouts_status ON logic_workouts (status)")
)
add_check_constraint_if_missing(
connection,
constraint_name="ck_workout_status",
table_name="logic_workouts",
check_sql="status IN ('active', 'finished', 'discarded')",
)
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS source_kind VARCHAR(20)")
)
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS title_snapshot VARCHAR(160)")
)
connection.execute(
text("ALTER TABLE logic_workout_items ADD COLUMN IF NOT EXISTS image_s3_url_snapshot TEXT")
)
connection.execute(
text(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'exercise',
title_snapshot = exercise.name,
image_s3_url_snapshot = exercise.image_s3_url
FROM logic_exercises AS exercise
WHERE item.exercise_id = exercise.id
AND (item.source_kind IS NULL OR item.title_snapshot IS NULL)
"""
)
)
connection.execute(
text(
"""
UPDATE logic_workout_items AS item
SET
source_kind = 'equipment',
title_snapshot = equipment.name,
image_s3_url_snapshot = equipment.image_s3_url
FROM logic_equipment AS equipment
WHERE item.equipment_id = equipment.id
AND (item.source_kind IS NULL OR item.title_snapshot IS NULL)
"""
)
)
connection.execute(
text(
"""
UPDATE logic_workout_items
SET source_kind = CASE WHEN exercise_id IS NOT NULL THEN 'exercise' ELSE 'equipment' END
WHERE source_kind IS NULL
"""
)
)
connection.execute(
text(
"UPDATE logic_workout_items "
"SET title_snapshot = 'Без названия' "
"WHERE title_snapshot IS NULL"
)
)
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN source_kind SET NOT NULL")
)
connection.execute(
text("ALTER TABLE logic_workout_items ALTER COLUMN title_snapshot SET NOT NULL")
)
add_check_constraint_if_missing(
connection,
constraint_name="ck_workout_item_source_kind",
table_name="logic_workout_items",
check_sql="source_kind IN ('exercise', 'equipment')",
)
connection.execute(
text(
"""
UPDATE logic_workouts AS workout
SET
total_sets = totals.total_sets,
total_volume = totals.total_volume,
estimated_calories = totals.estimated_calories
FROM (
SELECT
workout_source.id AS workout_id,
COUNT(set_row.id) AS total_sets,
COALESCE(SUM(set_row.weight * set_row.reps), 0) AS total_volume,
COALESCE(SUM(set_row.calories), 0) AS estimated_calories
FROM logic_workouts AS workout_source
LEFT JOIN logic_workout_items AS item ON item.workout_id = workout_source.id
LEFT JOIN logic_workout_sets AS set_row ON set_row.workout_item_id = item.id
GROUP BY workout_source.id
) AS totals
WHERE workout.id = totals.workout_id
"""
)
)
def add_check_constraint_if_missing(
connection: Connection,
*,
constraint_name: str,
table_name: str,
check_sql: str,
) -> None:
exists = connection.execute(
text("SELECT 1 FROM pg_constraint WHERE conname = :constraint_name"),
{"constraint_name": constraint_name},
).scalar()
if exists:
return
connection.execute(
text(
f"ALTER TABLE {table_name} "
f"ADD CONSTRAINT {constraint_name} "
f"CHECK ({check_sql})"
)
)
def get_db() -> Generator[Session]:
db = SessionLocal()
db: Any = SessionLocal()
try:
yield db
finally:
+285 -40
View File
@@ -3,7 +3,7 @@ from collections import defaultdict
from datetime import UTC, datetime
from typing import Annotated
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
@@ -19,11 +19,14 @@ from app.schemas import (
ProgressionPoint,
ProgressionRead,
WorkoutCreate,
WorkoutFinishRequest,
WorkoutItemCreate,
WorkoutItemRead,
WorkoutRead,
WorkoutSetBatchCreate,
WorkoutSetCreate,
WorkoutSetRead,
WorkoutSetUpdate,
WorkoutUpdate,
)
@@ -127,6 +130,34 @@ def accessible_exercises(db: Session, user_id: uuid.UUID):
)
def load_workout(db: Session, workout_id: uuid.UUID, user_id: uuid.UUID) -> Workout:
workout = db.scalar(
select(Workout)
.where(Workout.id == workout_id, Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
)
if not workout:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
return workout
def get_active_workout_for_user(db: Session, user_id: uuid.UUID) -> Workout | None:
return db.scalar(
select(Workout)
.where(Workout.user_id == user_id, Workout.status == "active")
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
.order_by(Workout.started_at.desc())
)
def ensure_active_workout(workout: Workout) -> None:
if workout.status != "active":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Workout is not active",
)
@app.get(
"/internal/catalog/equipment",
dependencies=[InternalAuth],
@@ -206,8 +237,14 @@ def list_workouts(db: Db, user_id: CurrentUserId) -> list[Workout]:
status_code=status.HTTP_201_CREATED,
)
def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Workout:
if get_active_workout_for_user(db, user_id):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Active workout already exists",
)
workout = Workout(
user_id=user_id,
status="active",
started_at=payload.started_at or datetime.now(UTC),
notes=payload.notes,
)
@@ -217,16 +254,18 @@ def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Wo
return workout
@app.get(
"/internal/workouts/active",
dependencies=[InternalAuth],
response_model=WorkoutRead | None,
)
def get_active_workout(db: Db, user_id: CurrentUserId) -> Workout | None:
return get_active_workout_for_user(db, user_id)
@app.get("/internal/workouts/{workout_id}", dependencies=[InternalAuth], response_model=WorkoutRead)
def get_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workout:
workout = db.scalar(
select(Workout)
.where(Workout.id == workout_id, Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
)
if not workout:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
return workout
return load_workout(db, workout_id, user_id)
@app.patch(
@@ -237,12 +276,52 @@ def get_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workou
def update_workout(
workout_id: uuid.UUID, payload: WorkoutUpdate, db: Db, user_id: CurrentUserId
) -> Workout:
workout = get_workout(workout_id, db, user_id)
workout = load_workout(db, workout_id, user_id)
if payload.finished_at is not None:
workout.finished_at = payload.finished_at
workout.status = "finished"
if payload.notes is not None:
workout.notes = payload.notes
recalculate_workout_calories(db, workout.id)
recalculate_workout_totals(db, workout.id)
db.commit()
db.refresh(workout)
return workout
@app.post(
"/internal/workouts/{workout_id}/finish",
dependencies=[InternalAuth],
response_model=WorkoutRead,
)
def finish_workout(
workout_id: uuid.UUID,
db: Db,
user_id: CurrentUserId,
payload: Annotated[WorkoutFinishRequest | None, Body()] = None,
) -> Workout:
workout = load_workout(db, workout_id, user_id)
ensure_active_workout(workout)
if payload and payload.notes is not None:
workout.notes = payload.notes
recalculate_workout_totals(db, workout.id)
workout.finished_at = datetime.now(UTC)
workout.status = "finished"
db.commit()
db.refresh(workout)
return workout
@app.post(
"/internal/workouts/{workout_id}/discard",
dependencies=[InternalAuth],
response_model=WorkoutRead,
)
def discard_workout(workout_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> Workout:
workout = load_workout(db, workout_id, user_id)
ensure_active_workout(workout)
recalculate_workout_totals(db, workout.id)
workout.finished_at = datetime.now(UTC)
workout.status = "discarded"
db.commit()
db.refresh(workout)
return workout
@@ -257,21 +336,37 @@ def update_workout(
def add_workout_item(
workout_id: uuid.UUID, payload: WorkoutItemCreate, db: Db, user_id: CurrentUserId
) -> WorkoutItem:
workout = get_workout(workout_id, db, user_id)
workout = load_workout(db, workout_id, user_id)
ensure_active_workout(workout)
source_kind: str
title_snapshot: str
image_s3_url_snapshot: str | None
if payload.exercise_id:
exercise = db.get(Exercise, payload.exercise_id)
if not exercise or (not exercise.is_builtin and exercise.owner_user_id != user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Exercise not found")
if payload.equipment_id:
source_kind = "exercise"
title_snapshot = exercise.name
image_s3_url_snapshot = exercise.image_s3_url
elif payload.equipment_id:
equipment = db.get(Equipment, payload.equipment_id)
if not equipment or (not equipment.is_builtin and equipment.owner_user_id != user_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Equipment not found")
source_kind = "equipment"
title_snapshot = equipment.name
image_s3_url_snapshot = equipment.image_s3_url
next_index = payload.order_index
if next_index is None:
next_index = len(workout.items)
max_index = db.scalar(
select(func.max(WorkoutItem.order_index)).where(WorkoutItem.workout_id == workout.id)
)
next_index = int(max_index or 0) + 1 if max_index is not None else 0
item = WorkoutItem(
workout_id=workout.id,
source_kind=source_kind,
title_snapshot=title_snapshot,
image_s3_url_snapshot=image_s3_url_snapshot,
**payload.model_dump(exclude={"order_index"}),
order_index=next_index,
)
@@ -297,18 +392,30 @@ def add_workout_set(
select(WorkoutItem)
.join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(selectinload(WorkoutItem.sets), selectinload(WorkoutItem.exercise))
.options(
selectinload(WorkoutItem.sets),
selectinload(WorkoutItem.exercise),
selectinload(WorkoutItem.workout),
)
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
ensure_active_workout(item.workout)
calories = payload.calories
if calories is None:
calories = estimate_set_calories(item, payload)
calories = estimate_set_calories(
item,
payload.weight,
payload.reps,
payload.duration_seconds,
payload.calories,
)
max_index = db.scalar(
select(func.max(WorkoutSet.set_index)).where(WorkoutSet.workout_item_id == item.id)
)
workout_set = WorkoutSet(
workout_item_id=item.id,
set_index=len(item.sets) + 1,
set_index=int(max_index or 0) + 1,
weight=payload.weight,
reps=payload.reps,
duration_seconds=payload.duration_seconds,
@@ -317,7 +424,112 @@ def add_workout_set(
)
db.add(workout_set)
db.flush()
recalculate_workout_calories(db, item.workout_id)
recalculate_workout_totals(db, item.workout_id)
db.commit()
db.refresh(workout_set)
return workout_set
@app.post(
"/internal/workout-items/{item_id}/sets/batch",
dependencies=[InternalAuth],
response_model=list[WorkoutSetRead],
status_code=status.HTTP_201_CREATED,
)
def add_workout_sets_batch(
item_id: uuid.UUID,
payload: WorkoutSetBatchCreate,
db: Db,
user_id: CurrentUserId,
) -> list[WorkoutSet]:
item = db.scalar(
select(WorkoutItem)
.join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(selectinload(WorkoutItem.exercise), selectinload(WorkoutItem.workout))
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
ensure_active_workout(item.workout)
max_index = db.scalar(
select(func.max(WorkoutSet.set_index)).where(WorkoutSet.workout_item_id == item.id)
)
next_index = int(max_index or 0) + 1
workout_sets: list[WorkoutSet] = []
for set_payload in payload.sets:
workout_set = WorkoutSet(
workout_item_id=item.id,
set_index=next_index,
weight=set_payload.weight,
reps=set_payload.reps,
duration_seconds=set_payload.duration_seconds,
calories=estimate_set_calories(
item,
set_payload.weight,
set_payload.reps,
set_payload.duration_seconds,
set_payload.calories,
),
completed_at=set_payload.completed_at or datetime.now(UTC),
)
db.add(workout_set)
workout_sets.append(workout_set)
next_index += 1
db.flush()
recalculate_workout_totals(db, item.workout_id)
db.commit()
for workout_set in workout_sets:
db.refresh(workout_set)
return workout_sets
@app.patch(
"/internal/workout-sets/{set_id}",
dependencies=[InternalAuth],
response_model=WorkoutSetRead,
)
def update_workout_set(
set_id: uuid.UUID,
payload: WorkoutSetUpdate,
db: Db,
user_id: CurrentUserId,
) -> WorkoutSet:
workout_set = db.scalar(
select(WorkoutSet)
.join(WorkoutItem)
.join(Workout)
.where(WorkoutSet.id == set_id, Workout.user_id == user_id)
.options(
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.exercise),
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.workout),
)
)
if not workout_set:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Set not found")
ensure_active_workout(workout_set.workout_item.workout)
if payload.weight is not None:
workout_set.weight = payload.weight
if payload.reps is not None:
workout_set.reps = payload.reps
if "duration_seconds" in payload.model_fields_set:
workout_set.duration_seconds = payload.duration_seconds
if "completed_at" in payload.model_fields_set and payload.completed_at is not None:
workout_set.completed_at = payload.completed_at
if "calories" in payload.model_fields_set:
workout_set.calories = payload.calories
else:
workout_set.calories = estimate_set_calories(
workout_set.workout_item,
float(workout_set.weight or 0),
int(workout_set.reps or 0),
workout_set.duration_seconds,
None,
)
recalculate_workout_totals(db, workout_set.workout_item.workout_id)
db.commit()
db.refresh(workout_set)
return workout_set
@@ -337,12 +549,12 @@ def delete_workout_item(item_id: uuid.UUID, db: Db, user_id: CurrentUserId) -> N
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
workout = db.get(Workout, item.workout_id)
if workout and workout.finished_at:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished")
if workout:
ensure_active_workout(workout)
workout_id = item.workout_id
db.delete(item)
db.flush()
recalculate_workout_calories(db, workout_id)
recalculate_workout_totals(db, workout_id)
db.commit()
@@ -369,33 +581,64 @@ def delete_workout_set(
workout = db.scalar(
select(Workout).join(WorkoutItem).where(WorkoutItem.id == item_id)
)
if workout and workout.finished_at:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Workout already finished")
if workout:
ensure_active_workout(workout)
db.delete(ws)
db.flush()
reindex_workout_item_sets(db, item_id)
if workout:
recalculate_workout_calories(db, workout.id)
recalculate_workout_totals(db, workout.id)
db.commit()
def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float:
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds:
def reindex_workout_item_sets(db: Session, item_id: uuid.UUID) -> None:
remaining_sets = list(
db.scalars(
select(WorkoutSet)
.where(WorkoutSet.workout_item_id == item_id)
.order_by(WorkoutSet.set_index.asc(), WorkoutSet.completed_at.asc())
)
)
for index, workout_set in enumerate(remaining_sets, start=1):
workout_set.set_index = index
def estimate_set_calories(
item: WorkoutItem,
weight: float,
reps: int,
duration_seconds: int | None,
calories: float | None,
) -> float:
if calories is not None:
return calories
if item.exercise and item.exercise.default_calories_per_minute and duration_seconds:
return round(
float(item.exercise.default_calories_per_minute) * payload.duration_seconds / 60,
float(item.exercise.default_calories_per_minute) * duration_seconds / 60,
2,
)
return round((payload.weight * max(payload.reps, 1)) / 120, 2)
return round((weight * max(reps, 1)) / 120, 2)
def recalculate_workout_totals(db: Session, workout_id: uuid.UUID) -> None:
total_sets, total_volume, estimated_calories = db.execute(
select(
func.count(WorkoutSet.id),
func.coalesce(func.sum(WorkoutSet.weight * WorkoutSet.reps), 0),
func.coalesce(func.sum(WorkoutSet.calories), 0),
)
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.where(WorkoutItem.workout_id == workout_id)
).one()
workout = db.get(Workout, workout_id)
if workout:
workout.total_sets = int(total_sets or 0)
workout.total_volume = float(total_volume or 0)
workout.estimated_calories = float(estimated_calories or 0)
def recalculate_workout_calories(db: Session, workout_id: uuid.UUID) -> None:
total = db.scalar(
select(func.coalesce(func.sum(WorkoutSet.calories), 0))
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.where(WorkoutItem.workout_id == workout_id)
)
workout = db.get(Workout, workout_id)
if workout:
workout.estimated_calories = float(total or 0)
recalculate_workout_totals(db, workout_id)
@app.get(
@@ -413,7 +656,7 @@ def get_progression(
select(Workout.started_at, WorkoutSet.weight, WorkoutSet.reps)
.join(WorkoutItem, WorkoutSet.workout_item_id == WorkoutItem.id)
.join(Workout, WorkoutItem.workout_id == Workout.id)
.where(Workout.user_id == user_id)
.where(Workout.user_id == user_id, Workout.status != "discarded")
.order_by(Workout.started_at.asc(), WorkoutSet.completed_at.asc())
)
if entity_id and kind == "exercise":
@@ -450,7 +693,9 @@ def get_progression(
def get_calories(db: Db, user_id: CurrentUserId) -> CaloriesRead:
workouts = list(
db.scalars(
select(Workout).where(Workout.user_id == user_id).order_by(Workout.started_at.desc())
select(Workout)
.where(Workout.user_id == user_id, Workout.status != "discarded")
.order_by(Workout.started_at.desc())
)
)
total = sum(float(workout.estimated_calories or 0) for workout in workouts)
+16
View File
@@ -67,12 +67,21 @@ class Exercise(Base, TimestampMixin):
class Workout(Base, TimestampMixin):
__tablename__ = "logic_workouts"
__table_args__ = (
CheckConstraint(
"status IN ('active', 'finished', 'discarded')",
name="ck_workout_status",
),
)
id: Mapped[uuid.UUID] = uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
status: Mapped[str] = mapped_column(String(20), default="active", index=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
notes: Mapped[str | None] = mapped_column(Text)
total_sets: Mapped[int] = mapped_column(Integer, default=0)
total_volume: Mapped[float] = mapped_column(Numeric(12, 2), default=0)
estimated_calories: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
items: Mapped[list[WorkoutItem]] = relationship(
@@ -88,12 +97,19 @@ class WorkoutItem(Base):
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
name="ck_workout_item_exactly_one_entity",
),
CheckConstraint(
"source_kind IN ('exercise', 'equipment')",
name="ck_workout_item_source_kind",
),
)
id: Mapped[uuid.UUID] = uuid_pk()
workout_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("logic_workouts.id", ondelete="CASCADE")
)
source_kind: Mapped[str] = mapped_column(String(20))
title_snapshot: Mapped[str] = mapped_column(String(160))
image_s3_url_snapshot: Mapped[str | None] = mapped_column(Text)
exercise_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_exercises.id"))
equipment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_equipment.id"))
order_index: Mapped[int] = mapped_column(Integer, default=0)
+33 -2
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -50,14 +53,36 @@ class WorkoutUpdate(BaseModel):
notes: str | None = None
class WorkoutFinishRequest(BaseModel):
notes: str | None = None
class WorkoutSetCreate(BaseModel):
weight: float = 0
reps: int = 0
weight: float = Field(default=0, ge=0)
reps: int = Field(default=0, ge=0)
duration_seconds: int | None = None
calories: float | None = None
completed_at: datetime | None = None
class WorkoutSetBatchCreate(BaseModel):
sets: list[WorkoutSetCreate] = Field(min_length=1)
class WorkoutSetUpdate(BaseModel):
weight: float | None = Field(default=None, ge=0)
reps: int | None = Field(default=None, ge=0)
duration_seconds: int | None = None
calories: float | None = None
completed_at: datetime | None = None
@model_validator(mode="after")
def at_least_one_field(self) -> WorkoutSetUpdate:
if not self.model_fields_set:
raise ValueError("Provide at least one field to update")
return self
class WorkoutSetRead(WorkoutSetCreate):
id: uuid.UUID
workout_item_id: uuid.UUID
@@ -83,6 +108,9 @@ class WorkoutItemCreate(BaseModel):
class WorkoutItemRead(WorkoutItemCreate):
id: uuid.UUID
workout_id: uuid.UUID
source_kind: Literal["exercise", "equipment"]
title_snapshot: str
image_s3_url_snapshot: str | None
order_index: int
created_at: datetime
sets: list[WorkoutSetRead] = []
@@ -93,9 +121,12 @@ class WorkoutItemRead(WorkoutItemCreate):
class WorkoutRead(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
status: Literal["active", "finished", "discarded"]
started_at: datetime
finished_at: datetime | None
notes: str | None
total_sets: int
total_volume: float
estimated_calories: float
created_at: datetime
updated_at: datetime