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:
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user