Add boto3 dependency and update exercise/machine assets

- Added boto3 as a dependency in pyproject.toml and uv.lock.
- Introduced multiple new exercise images in various formats (jpg, webp, avif, png).
- Added new machine images to enhance the workout assets library.
This commit is contained in:
Artem Kashaev
2026-05-29 15:50:33 +05:00
parent 7b34ce1a98
commit 800dee31b2
120 changed files with 1151 additions and 167 deletions
+278 -80
View File
@@ -1,21 +1,25 @@
import json
import re
import uuid
from collections import defaultdict
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated
import boto3
from fastapi import Body, 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.models import ActivitySource, Base, Equipment, Exercise, Workout, WorkoutItem, WorkoutSet
from app.schemas import (
ActivitySourceCreate,
ActivitySourceRead,
CaloriesRead,
EquipmentCreate,
EquipmentRead,
ExerciseCreate,
ExerciseRead,
ProgressionPoint,
ProgressionRead,
WorkoutCreate,
@@ -32,6 +36,15 @@ from app.schemas import (
app = FastAPI(title="Train Watcher Logic Service", version="0.1.0")
SEED_FILE = Path(__file__).parent / "seeds" / "activity_sources.json"
ASSET_CONTENT_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".avif": "image/avif",
}
def require_service_token(x_service_token: Annotated[str | None, Header()] = None) -> None:
if x_service_token != settings.service_token:
@@ -74,50 +87,74 @@ def health() -> dict[str, str]:
def seed_builtin_catalog(db: Session) -> None:
exists = db.scalar(
select(func.count()).select_from(Equipment).where(Equipment.is_builtin.is_(True))
)
if exists:
if not SEED_FILE.exists():
return
seed_rows = json.loads(SEED_FILE.read_text(encoding="utf-8"))
assets_dir = resolve_builtin_assets_dir()
if not assets_dir:
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,
),
]
)
for row in seed_rows:
asset_path = assets_dir / row["asset_filename"]
image = upload_builtin_asset(row["kind"], row["slug"], asset_path)
if not image:
continue
activity = db.scalar(select(ActivitySource).where(ActivitySource.slug == row["slug"]))
if activity is None:
activity = ActivitySource(slug=row["slug"], owner_user_id=None, is_builtin=True)
db.add(activity)
activity.kind = row["kind"]
activity.title = row["title"]
activity.description = row.get("description")
activity.category = row["category"]
activity.equipment = row["equipment"]
activity.measurement_type = row["measurement_type"]
activity.difficulty = row["difficulty"]
activity.default_calories_per_minute = row.get("default_calories_per_minute")
activity.image_s3_key = image["image_s3_key"]
activity.image_s3_url = image["image_s3_url"]
activity.is_builtin = True
db.commit()
def resolve_builtin_assets_dir() -> Path | None:
configured = Path(settings.builtin_assets_dir)
candidates = [configured, Path(__file__).resolve().parents[3] / "workout_assets"]
for candidate in candidates:
if candidate.exists() and candidate.is_dir():
return candidate
return None
def upload_builtin_asset(kind: str, slug: str, asset_path: Path) -> dict[str, str] | None:
if not asset_path.exists() or asset_path.suffix.lower() not in ASSET_CONTENT_TYPES:
return None
extension = asset_path.suffix.lower()
object_key = f"builtin/activity-sources/{kind}/{slug}{extension}"
try:
boto3.client(
"s3",
endpoint_url=settings.s3_endpoint_url,
aws_access_key_id=settings.s3_access_key_id,
aws_secret_access_key=settings.s3_secret_access_key,
region_name=settings.s3_region,
).put_object(
Bucket=settings.s3_bucket,
Key=object_key,
Body=asset_path.read_bytes(),
ContentType=ASSET_CONTENT_TYPES[extension],
)
except Exception as exc: # noqa: BLE001 - failed asset upload should not block service startup
print(f"Skipping builtin asset {asset_path}: {exc}", flush=True)
return None
public_base = settings.s3_public_base_url.rstrip("/")
return {
"image_s3_key": object_key,
"image_s3_url": f"{public_base}/{settings.s3_bucket}/{object_key}",
}
def accessible_equipment(db: Session, user_id: uuid.UUID):
return select(Equipment).where(
(Equipment.is_builtin.is_(True)) | (Equipment.owner_user_id == user_id)
@@ -130,11 +167,41 @@ def accessible_exercises(db: Session, user_id: uuid.UUID):
)
def accessible_activity_sources(db: Session, user_id: uuid.UUID):
return select(ActivitySource).where(
(ActivitySource.is_builtin.is_(True)) | (ActivitySource.owner_user_id == user_id)
)
def normalize_kind(kind: str | None) -> str | None:
if kind == "equipment":
return "machine"
return kind
def slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "activity"
def unique_user_slug(db: Session, title: str, user_id: uuid.UUID) -> str:
base = f"custom-{slugify(title)}-{str(user_id)[:8]}"
candidate = base
index = 2
while db.scalar(select(ActivitySource.id).where(ActivitySource.slug == candidate)):
candidate = f"{base}-{index}"
index += 1
return candidate
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))
.options(
selectinload(Workout.items).selectinload(WorkoutItem.sets),
selectinload(Workout.items).selectinload(WorkoutItem.activity_source),
)
)
if not workout:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout not found")
@@ -145,7 +212,10 @@ def get_active_workout_for_user(db: Session, user_id: uuid.UUID) -> Workout | No
return db.scalar(
select(Workout)
.where(Workout.user_id == user_id, Workout.status == "active")
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
.options(
selectinload(Workout.items).selectinload(WorkoutItem.sets),
selectinload(Workout.items).selectinload(WorkoutItem.activity_source),
)
.order_by(Workout.started_at.desc())
)
@@ -158,64 +228,151 @@ def ensure_active_workout(workout: Workout) -> None:
)
@app.get(
"/internal/catalog/activity-sources",
dependencies=[InternalAuth],
response_model=list[ActivitySourceRead],
)
def list_activity_sources(
db: Db,
user_id: CurrentUserId,
search: str | None = None,
kind: Annotated[str | None, Query(pattern="^(exercise|machine|equipment)$")] = None,
category: str | None = None,
scope: Annotated[str, Query(pattern="^(all|builtin|mine)$")] = "all",
) -> list[ActivitySource]:
statement = accessible_activity_sources(db, user_id).order_by(
ActivitySource.is_builtin.desc(), ActivitySource.title
)
normalized_kind = normalize_kind(kind)
if normalized_kind:
statement = statement.where(ActivitySource.kind == normalized_kind)
if category:
statement = statement.where(ActivitySource.category == category)
if scope == "builtin":
statement = statement.where(ActivitySource.is_builtin.is_(True))
elif scope == "mine":
statement = statement.where(ActivitySource.owner_user_id == user_id)
if search:
needle = f"%{search}%"
statement = statement.where(
ActivitySource.title.ilike(needle) | ActivitySource.description.ilike(needle)
)
return list(db.scalars(statement))
@app.post(
"/internal/catalog/activity-sources",
dependencies=[InternalAuth],
response_model=ActivitySourceRead,
status_code=status.HTTP_201_CREATED,
)
def create_activity_source(
payload: ActivitySourceCreate, db: Db, user_id: CurrentUserId
) -> ActivitySource:
slug = payload.slug or unique_user_slug(db, payload.title, user_id)
if db.scalar(select(ActivitySource.id).where(ActivitySource.slug == slug)):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Slug already exists")
data = payload.model_dump(exclude={"slug"})
activity = ActivitySource(
slug=slug,
owner_user_id=user_id,
is_builtin=False,
**data,
)
db.add(activity)
db.commit()
db.refresh(activity)
return activity
@app.get(
"/internal/catalog/equipment",
dependencies=[InternalAuth],
response_model=list[EquipmentRead],
response_model=list[ActivitySourceRead],
)
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
def list_equipment(
db: Db, user_id: CurrentUserId, search: str | None = None
) -> list[ActivitySource]:
statement = (
accessible_activity_sources(db, user_id)
.where(ActivitySource.kind == "machine")
.order_by(ActivitySource.is_builtin.desc(), ActivitySource.title)
)
if search:
statement = statement.where(Equipment.name.ilike(f"%{search}%"))
statement = statement.where(ActivitySource.title.ilike(f"%{search}%"))
return list(db.scalars(statement))
@app.post(
"/internal/catalog/equipment",
dependencies=[InternalAuth],
response_model=EquipmentRead,
response_model=ActivitySourceRead,
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)
def create_equipment(payload: EquipmentCreate, db: Db, user_id: CurrentUserId) -> ActivitySource:
activity = ActivitySource(
owner_user_id=user_id,
slug=unique_user_slug(db, payload.name, user_id),
kind="machine",
title=payload.name,
description=payload.description,
category="other",
equipment="machine",
measurement_type="weight_reps",
difficulty="intermediate",
image_s3_url=payload.image_s3_url,
image_s3_key=payload.image_s3_key,
is_builtin=False,
)
db.add(activity)
db.commit()
db.refresh(equipment)
return equipment
db.refresh(activity)
return activity
@app.get(
"/internal/catalog/exercises",
dependencies=[InternalAuth],
response_model=list[ExerciseRead],
response_model=list[ActivitySourceRead],
)
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
)
def list_exercises(
db: Db, user_id: CurrentUserId, search: str | None = None
) -> list[ActivitySource]:
statement = accessible_activity_sources(db, user_id).where(
ActivitySource.kind == "exercise"
).order_by(ActivitySource.is_builtin.desc(), ActivitySource.title)
if search:
statement = statement.where(Exercise.name.ilike(f"%{search}%"))
statement = statement.where(ActivitySource.title.ilike(f"%{search}%"))
return list(db.scalars(statement))
@app.post(
"/internal/catalog/exercises",
dependencies=[InternalAuth],
response_model=ExerciseRead,
response_model=ActivitySourceRead,
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)
def create_exercise(payload: ExerciseCreate, db: Db, user_id: CurrentUserId) -> ActivitySource:
activity = ActivitySource(
owner_user_id=user_id,
slug=unique_user_slug(db, payload.name, user_id),
kind="exercise",
title=payload.name,
description=payload.description,
category="other",
equipment="other",
measurement_type="weight_reps",
difficulty="intermediate",
image_s3_url=payload.image_s3_url,
image_s3_key=payload.image_s3_key,
default_calories_per_minute=payload.default_calories_per_minute,
is_builtin=False,
)
db.add(activity)
db.commit()
db.refresh(exercise)
return exercise
db.refresh(activity)
return activity
@app.get("/internal/workouts", dependencies=[InternalAuth], response_model=list[WorkoutRead])
@@ -224,7 +381,10 @@ def list_workouts(db: Db, user_id: CurrentUserId) -> list[Workout]:
db.scalars(
select(Workout)
.where(Workout.user_id == user_id)
.options(selectinload(Workout.items).selectinload(WorkoutItem.sets))
.options(
selectinload(Workout.items).selectinload(WorkoutItem.sets),
selectinload(Workout.items).selectinload(WorkoutItem.activity_source),
)
.order_by(Workout.started_at.desc())
)
)
@@ -341,7 +501,22 @@ def add_workout_item(
source_kind: str
title_snapshot: str
image_s3_url_snapshot: str | None
if payload.exercise_id:
measurement_type_snapshot = "weight_reps"
category_snapshot = "other"
equipment_snapshot = "other"
if payload.activity_source_id:
activity = db.get(ActivitySource, payload.activity_source_id)
if not activity or (not activity.is_builtin and activity.owner_user_id != user_id):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Activity source not found"
)
source_kind = activity.kind
title_snapshot = activity.title
image_s3_url_snapshot = activity.image_s3_url
measurement_type_snapshot = activity.measurement_type
category_snapshot = activity.category
equipment_snapshot = activity.equipment
elif 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")
@@ -352,7 +527,7 @@ def add_workout_item(
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"
source_kind = "machine"
title_snapshot = equipment.name
image_s3_url_snapshot = equipment.image_s3_url
@@ -367,6 +542,9 @@ def add_workout_item(
source_kind=source_kind,
title_snapshot=title_snapshot,
image_s3_url_snapshot=image_s3_url_snapshot,
measurement_type_snapshot=measurement_type_snapshot,
category_snapshot=category_snapshot,
equipment_snapshot=equipment_snapshot,
**payload.model_dump(exclude={"order_index"}),
order_index=next_index,
)
@@ -394,6 +572,7 @@ def add_workout_set(
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(
selectinload(WorkoutItem.sets),
selectinload(WorkoutItem.activity_source),
selectinload(WorkoutItem.exercise),
selectinload(WorkoutItem.workout),
)
@@ -419,6 +598,7 @@ def add_workout_set(
weight=payload.weight,
reps=payload.reps,
duration_seconds=payload.duration_seconds,
distance_km=payload.distance_km,
calories=calories,
completed_at=payload.completed_at or datetime.now(UTC),
)
@@ -446,7 +626,11 @@ def add_workout_sets_batch(
select(WorkoutItem)
.join(Workout)
.where(WorkoutItem.id == item_id, Workout.user_id == user_id)
.options(selectinload(WorkoutItem.exercise), selectinload(WorkoutItem.workout))
.options(
selectinload(WorkoutItem.activity_source),
selectinload(WorkoutItem.exercise),
selectinload(WorkoutItem.workout),
)
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workout item not found")
@@ -464,6 +648,7 @@ def add_workout_sets_batch(
weight=set_payload.weight,
reps=set_payload.reps,
duration_seconds=set_payload.duration_seconds,
distance_km=set_payload.distance_km,
calories=estimate_set_calories(
item,
set_payload.weight,
@@ -502,6 +687,7 @@ def update_workout_set(
.join(Workout)
.where(WorkoutSet.id == set_id, Workout.user_id == user_id)
.options(
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.activity_source),
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.exercise),
selectinload(WorkoutSet.workout_item).selectinload(WorkoutItem.workout),
)
@@ -516,6 +702,8 @@ def update_workout_set(
workout_set.reps = payload.reps
if "duration_seconds" in payload.model_fields_set:
workout_set.duration_seconds = payload.duration_seconds
if "distance_km" in payload.model_fields_set:
workout_set.distance_km = payload.distance_km
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:
@@ -612,6 +800,15 @@ def estimate_set_calories(
) -> float:
if calories is not None:
return calories
if (
item.activity_source
and item.activity_source.default_calories_per_minute
and duration_seconds
):
return round(
float(item.activity_source.default_calories_per_minute) * duration_seconds / 60,
2,
)
if item.exercise and item.exercise.default_calories_per_minute and duration_seconds:
return round(
float(item.exercise.default_calories_per_minute) * duration_seconds / 60,
@@ -649,7 +846,7 @@ def recalculate_workout_calories(db: Session, workout_id: uuid.UUID) -> None:
def get_progression(
db: Db,
user_id: CurrentUserId,
kind: Annotated[str, Query(pattern="^(exercise|equipment)$")],
kind: Annotated[str, Query(pattern="^(exercise|machine|equipment)$")],
entity_id: Annotated[uuid.UUID | None, Query()] = None,
) -> ProgressionRead:
statement = (
@@ -659,10 +856,11 @@ def get_progression(
.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":
statement = statement.where(WorkoutItem.exercise_id == entity_id)
elif entity_id and kind == "equipment":
statement = statement.where(WorkoutItem.equipment_id == entity_id)
normalized_kind = normalize_kind(kind)
if entity_id:
statement = statement.where(WorkoutItem.activity_source_id == entity_id)
if normalized_kind:
statement = statement.where(WorkoutItem.source_kind == normalized_kind)
rows = list(db.execute(statement))
grouped: dict[str, dict[str, float]] = defaultdict(lambda: {"max_weight": 0, "volume": 0})