Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,11 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-dev
|
||||
COPY app ./app
|
||||
COPY alembic.ini ./alembic.ini
|
||||
COPY alembic ./alembic
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["/app/.venv/bin/uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,37 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,47 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
from app.core import settings
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=settings.database_url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,117 @@
|
||||
"""initial logic schema
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-05-28 10:00:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"logic_equipment",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_url", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_key", sa.Text(), nullable=True),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_logic_equipment_is_builtin", "logic_equipment", ["is_builtin"])
|
||||
op.create_index("ix_logic_equipment_name", "logic_equipment", ["name"])
|
||||
op.create_index("ix_logic_equipment_owner_user_id", "logic_equipment", ["owner_user_id"])
|
||||
|
||||
op.create_table(
|
||||
"logic_exercises",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("owner_user_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("equipment_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("name", sa.String(length=160), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_url", sa.Text(), nullable=True),
|
||||
sa.Column("image_s3_key", sa.Text(), nullable=True),
|
||||
sa.Column("is_builtin", sa.Boolean(), nullable=False),
|
||||
sa.Column("default_calories_per_minute", sa.Numeric(8, 2), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(["equipment_id"], ["logic_equipment.id"]),
|
||||
)
|
||||
op.create_index("ix_logic_exercises_is_builtin", "logic_exercises", ["is_builtin"])
|
||||
op.create_index("ix_logic_exercises_name", "logic_exercises", ["name"])
|
||||
op.create_index("ix_logic_exercises_owner_user_id", "logic_exercises", ["owner_user_id"])
|
||||
|
||||
op.create_table(
|
||||
"logic_workouts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("estimated_calories", sa.Numeric(10, 2), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_logic_workouts_user_id", "logic_workouts", ["user_id"])
|
||||
|
||||
op.create_table(
|
||||
"logic_workout_items",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("workout_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("exercise_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("equipment_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("order_index", sa.Integer(), nullable=False),
|
||||
sa.Column("planned_working_weight", sa.Numeric(8, 2), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint(
|
||||
"(exercise_id IS NOT NULL AND equipment_id IS NULL) OR "
|
||||
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
|
||||
name="ck_workout_item_exactly_one_entity",
|
||||
),
|
||||
sa.ForeignKeyConstraint(["equipment_id"], ["logic_equipment.id"]),
|
||||
sa.ForeignKeyConstraint(["exercise_id"], ["logic_exercises.id"]),
|
||||
sa.ForeignKeyConstraint(["workout_id"], ["logic_workouts.id"], ondelete="CASCADE"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"logic_workout_sets",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("workout_item_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("set_index", sa.Integer(), nullable=False),
|
||||
sa.Column("weight", sa.Numeric(8, 2), nullable=False),
|
||||
sa.Column("reps", sa.Integer(), nullable=False),
|
||||
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
||||
sa.Column("calories", sa.Numeric(8, 2), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["workout_item_id"], ["logic_workout_items.id"], ondelete="CASCADE"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("logic_workout_sets")
|
||||
op.drop_table("logic_workout_items")
|
||||
op.drop_index("ix_logic_workouts_user_id", table_name="logic_workouts")
|
||||
op.drop_table("logic_workouts")
|
||||
op.drop_index("ix_logic_exercises_owner_user_id", table_name="logic_exercises")
|
||||
op.drop_index("ix_logic_exercises_name", table_name="logic_exercises")
|
||||
op.drop_index("ix_logic_exercises_is_builtin", table_name="logic_exercises")
|
||||
op.drop_table("logic_exercises")
|
||||
op.drop_index("ix_logic_equipment_owner_user_id", table_name="logic_equipment")
|
||||
op.drop_index("ix_logic_equipment_name", table_name="logic_equipment")
|
||||
op.drop_index("ix_logic_equipment_is_builtin", table_name="logic_equipment")
|
||||
op.drop_table("logic_equipment")
|
||||
@@ -0,0 +1,18 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
database_url: str = "postgresql+psycopg://train_watcher:train_watcher@localhost:5432/train_watcher"
|
||||
service_token: str = "dev-service-token-change-me"
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,34 @@
|
||||
from collections.abc import Generator
|
||||
from time import sleep
|
||||
|
||||
from sqlalchemy import MetaData, create_engine, text
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def create_schema(metadata: MetaData, attempts: int = 30, delay_seconds: int = 2) -> None:
|
||||
last_error: OperationalError | None = None
|
||||
for _ in range(attempts):
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("SELECT 1"))
|
||||
metadata.create_all(bind=connection)
|
||||
return
|
||||
except OperationalError as exc:
|
||||
last_error = exc
|
||||
sleep(delay_seconds)
|
||||
if last_error:
|
||||
raise last_error
|
||||
|
||||
|
||||
def get_db() -> Generator[Session]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,412 @@
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.core import settings
|
||||
from app.db import SessionLocal, create_schema, get_db
|
||||
from app.models import Base, Equipment, Exercise, Workout, WorkoutItem, WorkoutSet
|
||||
from app.schemas import (
|
||||
CaloriesRead,
|
||||
EquipmentCreate,
|
||||
EquipmentRead,
|
||||
ExerciseCreate,
|
||||
ExerciseRead,
|
||||
ProgressionPoint,
|
||||
ProgressionRead,
|
||||
WorkoutCreate,
|
||||
WorkoutItemCreate,
|
||||
WorkoutItemRead,
|
||||
WorkoutRead,
|
||||
WorkoutSetCreate,
|
||||
WorkoutSetRead,
|
||||
WorkoutUpdate,
|
||||
)
|
||||
|
||||
app = FastAPI(title="Train Watcher Logic Service", version="0.1.0")
|
||||
|
||||
|
||||
def require_service_token(x_service_token: Annotated[str | None, Header()] = None) -> None:
|
||||
if x_service_token != settings.service_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid service token",
|
||||
)
|
||||
|
||||
|
||||
def get_user_id(x_user_id: Annotated[str | None, Header()] = None) -> uuid.UUID:
|
||||
if not x_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing X-User-Id header",
|
||||
)
|
||||
try:
|
||||
return uuid.UUID(x_user_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid X-User-Id",
|
||||
) from exc
|
||||
|
||||
|
||||
InternalAuth = Depends(require_service_token)
|
||||
Db = Annotated[Session, Depends(get_db)]
|
||||
CurrentUserId = Annotated[uuid.UUID, Depends(get_user_id)]
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
create_schema(Base.metadata)
|
||||
with SessionLocal() as db:
|
||||
seed_builtin_catalog(db)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
def seed_builtin_catalog(db: Session) -> None:
|
||||
exists = db.scalar(
|
||||
select(func.count()).select_from(Equipment).where(Equipment.is_builtin.is_(True))
|
||||
)
|
||||
if exists:
|
||||
return
|
||||
|
||||
treadmill = Equipment(
|
||||
name="Беговая дорожка",
|
||||
description="Кардио-тренажер для ходьбы и бега.",
|
||||
is_builtin=True,
|
||||
)
|
||||
smith = Equipment(
|
||||
name="Машина Смита",
|
||||
description="Силовая рама с фиксированной траекторией грифа.",
|
||||
is_builtin=True,
|
||||
)
|
||||
db.add_all([treadmill, smith])
|
||||
db.flush()
|
||||
db.add_all(
|
||||
[
|
||||
Exercise(
|
||||
name="Жим лежа",
|
||||
description="Базовое упражнение для груди, трицепса и передней дельты.",
|
||||
is_builtin=True,
|
||||
default_calories_per_minute=6,
|
||||
),
|
||||
Exercise(
|
||||
name="Приседания",
|
||||
description="Базовое упражнение для ног и корпуса.",
|
||||
is_builtin=True,
|
||||
default_calories_per_minute=8,
|
||||
),
|
||||
Exercise(
|
||||
name="Бег",
|
||||
description="Кардио-нагрузка на беговой дорожке.",
|
||||
equipment_id=treadmill.id,
|
||||
is_builtin=True,
|
||||
default_calories_per_minute=10,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def accessible_equipment(db: Session, user_id: uuid.UUID):
|
||||
return select(Equipment).where(
|
||||
(Equipment.is_builtin.is_(True)) | (Equipment.owner_user_id == user_id)
|
||||
)
|
||||
|
||||
|
||||
def accessible_exercises(db: Session, user_id: uuid.UUID):
|
||||
return select(Exercise).where(
|
||||
(Exercise.is_builtin.is_(True)) | (Exercise.owner_user_id == user_id)
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/catalog/equipment",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=list[EquipmentRead],
|
||||
)
|
||||
def list_equipment(db: Db, user_id: CurrentUserId, search: str | None = None) -> list[Equipment]:
|
||||
statement = accessible_equipment(db, user_id).order_by(
|
||||
Equipment.is_builtin.desc(), Equipment.name
|
||||
)
|
||||
if search:
|
||||
statement = statement.where(Equipment.name.ilike(f"%{search}%"))
|
||||
return list(db.scalars(statement))
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/catalog/equipment",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=EquipmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_equipment(payload: EquipmentCreate, db: Db, user_id: CurrentUserId) -> Equipment:
|
||||
equipment = Equipment(owner_user_id=user_id, is_builtin=False, **payload.model_dump())
|
||||
db.add(equipment)
|
||||
db.commit()
|
||||
db.refresh(equipment)
|
||||
return equipment
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/catalog/exercises",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=list[ExerciseRead],
|
||||
)
|
||||
def list_exercises(db: Db, user_id: CurrentUserId, search: str | None = None) -> list[Exercise]:
|
||||
statement = accessible_exercises(db, user_id).order_by(
|
||||
Exercise.is_builtin.desc(), Exercise.name
|
||||
)
|
||||
if search:
|
||||
statement = statement.where(Exercise.name.ilike(f"%{search}%"))
|
||||
return list(db.scalars(statement))
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/catalog/exercises",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=ExerciseRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_exercise(payload: ExerciseCreate, db: Db, user_id: CurrentUserId) -> Exercise:
|
||||
if 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")
|
||||
exercise = Exercise(owner_user_id=user_id, is_builtin=False, **payload.model_dump())
|
||||
db.add(exercise)
|
||||
db.commit()
|
||||
db.refresh(exercise)
|
||||
return exercise
|
||||
|
||||
|
||||
@app.get("/internal/workouts", dependencies=[InternalAuth], response_model=list[WorkoutRead])
|
||||
def list_workouts(db: Db, user_id: CurrentUserId) -> list[Workout]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Workout)
|
||||
.where(Workout.user_id == user_id)
|
||||
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
|
||||
.order_by(Workout.started_at.desc())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/workouts",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_workout(payload: WorkoutCreate, db: Db, user_id: CurrentUserId) -> Workout:
|
||||
workout = Workout(
|
||||
user_id=user_id,
|
||||
started_at=payload.started_at or datetime.now(UTC),
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(workout)
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@app.patch(
|
||||
"/internal/workouts/{workout_id}",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutRead,
|
||||
)
|
||||
def update_workout(
|
||||
workout_id: uuid.UUID, payload: WorkoutUpdate, db: Db, user_id: CurrentUserId
|
||||
) -> Workout:
|
||||
workout = get_workout(workout_id, db, user_id)
|
||||
if payload.finished_at is not None:
|
||||
workout.finished_at = payload.finished_at
|
||||
if payload.notes is not None:
|
||||
workout.notes = payload.notes
|
||||
recalculate_workout_calories(db, workout.id)
|
||||
db.commit()
|
||||
db.refresh(workout)
|
||||
return workout
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/workouts/{workout_id}/items",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutItemRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_workout_item(
|
||||
workout_id: uuid.UUID, payload: WorkoutItemCreate, db: Db, user_id: CurrentUserId
|
||||
) -> WorkoutItem:
|
||||
workout = get_workout(workout_id, db, user_id)
|
||||
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:
|
||||
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")
|
||||
|
||||
next_index = payload.order_index
|
||||
if next_index is None:
|
||||
next_index = len(workout.items)
|
||||
item = WorkoutItem(
|
||||
workout_id=workout.id,
|
||||
**payload.model_dump(exclude={"order_index"}),
|
||||
order_index=next_index,
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@app.post(
|
||||
"/internal/workout-items/{item_id}/sets",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=WorkoutSetRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_workout_set(
|
||||
item_id: uuid.UUID,
|
||||
payload: WorkoutSetCreate,
|
||||
db: Db,
|
||||
user_id: CurrentUserId,
|
||||
) -> WorkoutSet:
|
||||
item = db.scalar(
|
||||
select(WorkoutItem)
|
||||
.join(Workout)
|
||||
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
|
||||
.options(selectinload(WorkoutItem.sets), selectinload(WorkoutItem.exercise))
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
|
||||
|
||||
calories = payload.calories
|
||||
if calories is None:
|
||||
calories = estimate_set_calories(item, payload)
|
||||
|
||||
workout_set = WorkoutSet(
|
||||
workout_item_id=item.id,
|
||||
set_index=len(item.sets) + 1,
|
||||
weight=payload.weight,
|
||||
reps=payload.reps,
|
||||
duration_seconds=payload.duration_seconds,
|
||||
calories=calories,
|
||||
completed_at=payload.completed_at or datetime.now(UTC),
|
||||
)
|
||||
db.add(workout_set)
|
||||
db.flush()
|
||||
recalculate_workout_calories(db, item.workout_id)
|
||||
db.commit()
|
||||
db.refresh(workout_set)
|
||||
return workout_set
|
||||
|
||||
|
||||
def estimate_set_calories(item: WorkoutItem, payload: WorkoutSetCreate) -> float:
|
||||
if item.exercise and item.exercise.default_calories_per_minute and payload.duration_seconds:
|
||||
return round(
|
||||
float(item.exercise.default_calories_per_minute) * payload.duration_seconds / 60,
|
||||
2,
|
||||
)
|
||||
return round((payload.weight * max(payload.reps, 1)) / 120, 2)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/internal/analytics/progression",
|
||||
dependencies=[InternalAuth],
|
||||
response_model=ProgressionRead,
|
||||
)
|
||||
def get_progression(
|
||||
db: Db,
|
||||
user_id: CurrentUserId,
|
||||
kind: str = Query(pattern="^(exercise|equipment)$"),
|
||||
entity_id: uuid.UUID | None = None,
|
||||
) -> ProgressionRead:
|
||||
statement = (
|
||||
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)
|
||||
.order_by(Workout.started_at.asc(), WorkoutSet.completed_at.asc())
|
||||
)
|
||||
if entity_id and kind == "exercise":
|
||||
statement = statement.where(WorkoutItem.exercise_id == entity_id)
|
||||
elif entity_id and kind == "equipment":
|
||||
statement = statement.where(WorkoutItem.equipment_id == entity_id)
|
||||
|
||||
rows = list(db.execute(statement))
|
||||
grouped: dict[str, dict[str, float]] = defaultdict(lambda: {"max_weight": 0, "volume": 0})
|
||||
weights: list[float] = []
|
||||
for started_at, weight, reps in rows:
|
||||
date_key = started_at.date().isoformat()
|
||||
numeric_weight = float(weight or 0)
|
||||
grouped[date_key]["max_weight"] = max(grouped[date_key]["max_weight"], numeric_weight)
|
||||
grouped[date_key]["volume"] += numeric_weight * int(reps or 0)
|
||||
weights.append(numeric_weight)
|
||||
|
||||
points = [
|
||||
ProgressionPoint(date=date, max_weight=values["max_weight"], volume=values["volume"])
|
||||
for date, values in sorted(grouped.items())
|
||||
]
|
||||
previous_delta = None
|
||||
if len(weights) >= 2:
|
||||
previous_delta = round(weights[-1] - weights[-2], 2)
|
||||
return ProgressionRead(
|
||||
last_weight=weights[-1] if weights else None,
|
||||
max_weight=max(weights) if weights else None,
|
||||
previous_delta=previous_delta,
|
||||
points=points,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/internal/analytics/calories", dependencies=[InternalAuth], response_model=CaloriesRead)
|
||||
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())
|
||||
)
|
||||
)
|
||||
total = sum(float(workout.estimated_calories or 0) for workout in workouts)
|
||||
return CaloriesRead(
|
||||
total_calories=round(total, 2),
|
||||
workouts=[
|
||||
{
|
||||
"id": str(workout.id),
|
||||
"date": workout.started_at.date().isoformat(),
|
||||
"calories": float(workout.estimated_calories or 0),
|
||||
}
|
||||
for workout in workouts
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def uuid_pk() -> Mapped[uuid.UUID]:
|
||||
return mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
def now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now)
|
||||
|
||||
|
||||
class Equipment(Base, TimestampMixin):
|
||||
__tablename__ = "logic_equipment"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
|
||||
name: Mapped[str] = mapped_column(String(160), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_url: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_key: Mapped[str | None] = mapped_column(Text)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
|
||||
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="equipment")
|
||||
|
||||
|
||||
class Exercise(Base, TimestampMixin):
|
||||
__tablename__ = "logic_exercises"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
owner_user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), index=True)
|
||||
equipment_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("logic_equipment.id"))
|
||||
name: Mapped[str] = mapped_column(String(160), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_url: Mapped[str | None] = mapped_column(Text)
|
||||
image_s3_key: Mapped[str | None] = mapped_column(Text)
|
||||
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||
default_calories_per_minute: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||
|
||||
equipment: Mapped[Equipment | None] = relationship()
|
||||
workout_items: Mapped[list[WorkoutItem]] = relationship(back_populates="exercise")
|
||||
|
||||
|
||||
class Workout(Base, TimestampMixin):
|
||||
__tablename__ = "logic_workouts"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), 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)
|
||||
estimated_calories: Mapped[float] = mapped_column(Numeric(10, 2), default=0)
|
||||
|
||||
items: Mapped[list[WorkoutItem]] = relationship(
|
||||
back_populates="workout", cascade="all, delete-orphan", order_by="WorkoutItem.order_index"
|
||||
)
|
||||
|
||||
|
||||
class WorkoutItem(Base):
|
||||
__tablename__ = "logic_workout_items"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"(exercise_id IS NOT NULL AND equipment_id IS NULL) OR "
|
||||
"(exercise_id IS NULL AND equipment_id IS NOT NULL)",
|
||||
name="ck_workout_item_exactly_one_entity",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
workout_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("logic_workouts.id", ondelete="CASCADE")
|
||||
)
|
||||
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)
|
||||
planned_working_weight: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
workout: Mapped[Workout] = relationship(back_populates="items")
|
||||
exercise: Mapped[Exercise | None] = relationship(back_populates="workout_items")
|
||||
equipment: Mapped[Equipment | None] = relationship(back_populates="workout_items")
|
||||
sets: Mapped[list[WorkoutSet]] = relationship(
|
||||
back_populates="workout_item", cascade="all, delete-orphan", order_by="WorkoutSet.set_index"
|
||||
)
|
||||
|
||||
|
||||
class WorkoutSet(Base):
|
||||
__tablename__ = "logic_workout_sets"
|
||||
|
||||
id: Mapped[uuid.UUID] = uuid_pk()
|
||||
workout_item_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("logic_workout_items.id", ondelete="CASCADE")
|
||||
)
|
||||
set_index: Mapped[int] = mapped_column(Integer)
|
||||
weight: Mapped[float] = mapped_column(Numeric(8, 2), default=0)
|
||||
reps: Mapped[int] = mapped_column(Integer, default=0)
|
||||
duration_seconds: Mapped[int | None] = mapped_column(Integer)
|
||||
calories: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||
|
||||
workout_item: Mapped[WorkoutItem] = relationship(back_populates="sets")
|
||||
@@ -0,0 +1,122 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class EquipmentCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
image_s3_url: str | None = None
|
||||
image_s3_key: str | None = None
|
||||
|
||||
|
||||
class EquipmentRead(EquipmentCreate):
|
||||
id: uuid.UUID
|
||||
owner_user_id: uuid.UUID | None
|
||||
is_builtin: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ExerciseCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
description: str | None = None
|
||||
equipment_id: uuid.UUID | None = None
|
||||
image_s3_url: str | None = None
|
||||
image_s3_key: str | None = None
|
||||
default_calories_per_minute: float | None = None
|
||||
|
||||
|
||||
class ExerciseRead(ExerciseCreate):
|
||||
id: uuid.UUID
|
||||
owner_user_id: uuid.UUID | None
|
||||
is_builtin: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkoutCreate(BaseModel):
|
||||
started_at: datetime | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WorkoutUpdate(BaseModel):
|
||||
finished_at: datetime | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class WorkoutSetCreate(BaseModel):
|
||||
weight: float = 0
|
||||
reps: int = 0
|
||||
duration_seconds: int | None = None
|
||||
calories: float | None = None
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class WorkoutSetRead(WorkoutSetCreate):
|
||||
id: uuid.UUID
|
||||
workout_item_id: uuid.UUID
|
||||
set_index: int
|
||||
completed_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkoutItemCreate(BaseModel):
|
||||
exercise_id: uuid.UUID | None = None
|
||||
equipment_id: uuid.UUID | None = None
|
||||
order_index: int | None = None
|
||||
planned_working_weight: float | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def exactly_one_entity(self) -> WorkoutItemCreate:
|
||||
if bool(self.exercise_id) == bool(self.equipment_id):
|
||||
raise ValueError("Provide exactly one of exercise_id or equipment_id")
|
||||
return self
|
||||
|
||||
|
||||
class WorkoutItemRead(WorkoutItemCreate):
|
||||
id: uuid.UUID
|
||||
workout_id: uuid.UUID
|
||||
order_index: int
|
||||
created_at: datetime
|
||||
sets: list[WorkoutSetRead] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkoutRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
started_at: datetime
|
||||
finished_at: datetime | None
|
||||
notes: str | None
|
||||
estimated_calories: float
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items: list[WorkoutItemRead] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProgressionPoint(BaseModel):
|
||||
date: str
|
||||
max_weight: float
|
||||
volume: float
|
||||
|
||||
|
||||
class ProgressionRead(BaseModel):
|
||||
last_weight: float | None
|
||||
max_weight: float | None
|
||||
previous_delta: float | None
|
||||
points: list[ProgressionPoint]
|
||||
|
||||
|
||||
class CaloriesRead(BaseModel):
|
||||
total_calories: float
|
||||
workouts: list[dict[str, str | float]]
|
||||
@@ -0,0 +1,25 @@
|
||||
[project]
|
||||
name = "train-watcher-logic"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"alembic>=1.16.0",
|
||||
"fastapi[standard]>=0.115.12",
|
||||
"psycopg[binary]>=3.2.9",
|
||||
"pydantic-settings>=2.9.1",
|
||||
"sqlalchemy>=2.0.41",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3.5",
|
||||
"ruff>=0.11.11",
|
||||
"ty>=0.0.1a6",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py314"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
Generated
+1001
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user