"""Fitness Exercises REST API — FastAPI + SQLite""" from __future__ import annotations import hashlib import json import os import sqlite3 import threading import time from contextlib import asynccontextmanager from typing import Annotated, Optional from dotenv import load_dotenv from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, RedirectResponse from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.staticfiles import StaticFiles from jose import JWTError, jwt import bcrypt as _bcrypt from pydantic import BaseModel load_dotenv() # ── Config ──────────────────────────────────────────────────────────────────── DB_PATH = os.getenv("DB_PATH", "data/exercises.db") JWT_SECRET = os.getenv("JWT_SECRET", "change-this-secret-in-production") JWT_ALGORITHM = "HS256" JWT_EXPIRES = 7 # days ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "*") FOCUS_BODY_PARTS: dict[str, list[str]] = { "upper": ["chest", "back", "shoulders", "upper arms", "lower arms"], "lower": ["upper legs", "lower legs"], "core": ["waist"], "cardio": ["cardio"], "push": ["chest", "shoulders"], "pull": ["back", "upper arms"], "legs": ["upper legs", "lower legs"], "full": [], "custom": [], } VALID_FOCUS = set(FOCUS_BODY_PARTS) DAYS = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] # ── Argos Translate ─────────────────────────────────────────────────────────── _argos_ready = False _argos_error: str | None = None _preload_progress: dict = {"total": 0, "done": 0, "running": False} def _install_argos() -> None: global _argos_ready, _argos_error try: from argostranslate import package as _pkg, translate as _tr # noqa: F401 _pkg.update_package_index() installed = {(p.from_code, p.to_code) for p in _pkg.get_installed_packages()} needed = [("en", "pt")] for from_c, to_c in needed: if (from_c, to_c) not in installed: available = _pkg.get_available_packages() pkg = next((p for p in available if p.from_code == from_c and p.to_code == to_c), None) if pkg: print(f"[argos] baixando pacote {from_c}→{to_c}…") _pkg.install_from_path(pkg.download()) print(f"[argos] pacote {from_c}→{to_c} instalado") _argos_ready = True print("[argos] pronto") _preload_translations() except Exception as exc: _argos_error = str(exc) print(f"[argos] erro na inicialização: {exc}") _PRELOAD_SENTINEL = "__preload_done__" def _preload_translations() -> None: global _preload_progress try: conn = open_db() total = conn.execute( "SELECT COUNT(*) FROM exercises " "WHERE instructions_en IS NOT NULL AND instructions_en != ''" ).fetchone()[0] # Skip entirely if a previous run already completed already_done = conn.execute( "SELECT 1 FROM translations " "WHERE source_lang='__meta__' AND source_hash=?", (_PRELOAD_SENTINEL,), ).fetchone() if already_done: _preload_progress = {"total": total, "done": total, "running": False} print(f"[argos] pré-tradução já concluída ({total} em cache), pulando") conn.close() return rows = conn.execute( "SELECT instructions_en FROM exercises " "WHERE instructions_en IS NOT NULL AND instructions_en != ''" ).fetchall() _preload_progress = {"total": total, "done": 0, "running": True} print(f"[argos] pré-tradução iniciada: {total} exercícios") for row in rows: text = row["instructions_en"] h = _text_hash(f"en:pt:{text}") exists = conn.execute( "SELECT 1 FROM translations " "WHERE source_lang='en' AND target_lang='pt' AND source_hash=?", (h,), ).fetchone() if not exists: translated = _do_translate(text, "en", "pt") conn.execute( "INSERT OR IGNORE INTO translations" "(source_lang, target_lang, source_hash, translated_text) VALUES(?,?,?,?)", ("en", "pt", h, translated), ) conn.commit() _preload_progress["done"] += 1 if _preload_progress["done"] % 100 == 0: pct = _preload_progress["done"] * 100 // total print(f"[argos] {_preload_progress['done']}/{total} ({pct}%) traduzidos") # Mark as done so subsequent startups skip this loop conn.execute( "INSERT OR IGNORE INTO translations" "(source_lang, target_lang, source_hash, translated_text) VALUES(?,?,?,?)", ("__meta__", "__meta__", _PRELOAD_SENTINEL, "1"), ) conn.commit() _preload_progress["running"] = False print(f"[argos] pré-tradução concluída: {total} exercícios em cache") conn.close() except Exception as exc: _preload_progress["running"] = False print(f"[argos] erro na pré-tradução: {exc}") def _do_translate(text: str, source: str, target: str) -> str: from argostranslate import translate as _tr return _tr.translate(text, source, target) def _text_hash(text: str) -> str: return hashlib.sha256(text.encode()).hexdigest() def _translate_cached(text: str, source: str, target: str, db: sqlite3.Connection) -> str: """Translate with cache. Returns original text if Argos not ready or text is empty.""" if not text or not text.strip(): return text h = _text_hash(f"{source}:{target}:{text}") row = db.execute( "SELECT translated_text FROM translations WHERE source_lang=? AND target_lang=? AND source_hash=?", (source, target, h), ).fetchone() if row: return row["translated_text"] if not _argos_ready: return text translated = _do_translate(text, source, target) db.execute( "INSERT OR IGNORE INTO translations(source_lang, target_lang, source_hash, translated_text) VALUES(?,?,?,?)", (source, target, h, translated), ) db.commit() return translated # ── Password & JWT ──────────────────────────────────────────────────────────── def hash_password(plain: str) -> str: return _bcrypt.hashpw(plain.encode(), _bcrypt.gensalt(12)).decode() def verify_password(plain: str, hashed: str) -> bool: return _bcrypt.checkpw(plain.encode(), hashed.encode()) def create_token(payload: dict) -> str: from datetime import datetime, timedelta, timezone data = {**payload, "exp": datetime.now(timezone.utc) + timedelta(days=JWT_EXPIRES)} return jwt.encode(data, JWT_SECRET, algorithm=JWT_ALGORITHM) # ── Database ────────────────────────────────────────────────────────────────── def open_db() -> sqlite3.Connection: conn = sqlite3.connect(DB_PATH, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA foreign_keys=ON") return conn def get_db(): conn = open_db() try: yield conn finally: conn.close() DB = Annotated[sqlite3.Connection, Depends(get_db)] def init_db() -> None: conn = open_db() conn.executescript(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, weight_kg REAL, height_cm REAL, age INTEGER, sex TEXT, created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS exercises ( id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT, body_part TEXT, equipment TEXT, instructions_en TEXT, instructions_es TEXT, instructions_it TEXT, instructions_tr TEXT, muscle_group TEXT, secondary_muscles TEXT, target TEXT, image TEXT, gif_url TEXT, created_at TEXT ); CREATE TABLE IF NOT EXISTS workout_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, name TEXT NOT NULL, focus TEXT NOT NULL DEFAULT 'custom', created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS schedule_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, day_of_week INTEGER NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), exercise_id TEXT NOT NULL, sets INTEGER NOT NULL DEFAULT 3, reps INTEGER NOT NULL DEFAULT 10, rest_seconds INTEGER NOT NULL DEFAULT 60, position INTEGER NOT NULL DEFAULT 0, FOREIGN KEY (plan_id) REFERENCES workout_plans(id) ON DELETE CASCADE, FOREIGN KEY (exercise_id) REFERENCES exercises(id) ); CREATE TABLE IF NOT EXISTS translations ( source_lang TEXT NOT NULL, target_lang TEXT NOT NULL, source_hash TEXT NOT NULL, translated_text TEXT NOT NULL, PRIMARY KEY (source_lang, target_lang, source_hash) ); CREATE TABLE IF NOT EXISTS exercise_insights ( exercise_id TEXT PRIMARY KEY, difficulty TEXT, effort_type TEXT, calories_per_min_met REAL, common_mistakes TEXT, benefits TEXT, injury_risk TEXT, injury_risk_area TEXT, easier_variation TEXT, harder_variation TEXT, no_equipment_alt TEXT, generated_at TEXT, FOREIGN KEY (exercise_id) REFERENCES exercises(id) ); """) conn.commit() # Migrate existing databases: add physical profile columns if missing for col, typedef in [("weight_kg","REAL"),("height_cm","REAL"),("age","INTEGER"),("sex","TEXT")]: try: conn.execute(f"ALTER TABLE users ADD COLUMN {col} {typedef}") except Exception: pass conn.commit() conn.close() def seed_if_empty() -> None: """Import exercises.json into SQLite if the exercises table is empty.""" json_path = os.getenv("EXERCISES_JSON_PATH", "data/exercises.json") if not os.path.exists(json_path): print(f"[seed] exercises.json not found at {json_path} — skipping") return conn = open_db() count = conn.execute("SELECT COUNT(*) FROM exercises").fetchone()[0] if count > 0: conn.close() return print(f"[seed] exercises table empty — importing from {json_path} …") exercises: list[dict] = json.loads(open(json_path, encoding="utf-8").read()) rows = [] for ex in exercises: instr = ex.get("instructions") or {} rows.append(( ex["id"], ex["name"], ex.get("category") or ex.get("body_part"), ex.get("body_part"), ex.get("equipment"), instr.get("en"), instr.get("es"), instr.get("it"), instr.get("tr"), ex.get("muscle_group"), json.dumps(ex.get("secondary_muscles") or []), ex.get("target"), ex.get("image"), ex.get("gif_url"), ex.get("created_at"), )) conn.executemany(""" INSERT OR REPLACE INTO exercises (id, name, category, body_part, equipment, instructions_en, instructions_es, instructions_it, instructions_tr, muscle_group, secondary_muscles, target, image, gif_url, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) """, rows) conn.commit() conn.close() print(f"[seed] {len(rows)} exercises imported") # ── Helpers ─────────────────────────────────────────────────────────────────── def row_to_dict(row: sqlite3.Row) -> dict: d = dict(row) raw = d.get("secondary_muscles") if isinstance(raw, str): try: d["secondary_muscles"] = json.loads(raw) except Exception: d["secondary_muscles"] = [] return d # ── Auth dependency ─────────────────────────────────────────────────────────── _bearer = HTTPBearer() _bearer_optional = HTTPBearer(auto_error=False) def get_current_user( creds: Annotated[HTTPAuthorizationCredentials, Depends(_bearer)], db: Annotated[sqlite3.Connection, Depends(get_db)], ) -> dict: try: payload = jwt.decode(creds.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM]) except JWTError: raise HTTPException(status_code=401, detail="Invalid or expired token") row = db.execute( "SELECT id, name, email, weight_kg, height_cm, age, sex FROM users WHERE id = ?", (int(payload["id"]),), ).fetchone() if not row: raise HTTPException(status_code=401, detail="Session expired — please log in again") return dict(row) CurrentUser = Annotated[dict, Depends(get_current_user)] # ── Pydantic schemas ────────────────────────────────────────────────────────── class RegisterIn(BaseModel): name: str email: str password: str weight_kg: Optional[float] = None height_cm: Optional[float] = None age: Optional[int] = None sex: Optional[str] = None class LoginIn(BaseModel): email: str password: str class PlanIn(BaseModel): name: str focus: str = "custom" class PlanUpdate(BaseModel): name: Optional[str] = None focus: Optional[str] = None class ScheduleIn(BaseModel): day_of_week: int exercise_id: str sets: int = 3 reps: int = 10 rest_seconds: int = 60 class ScheduleUpdate(BaseModel): sets: Optional[int] = None reps: Optional[int] = None rest_seconds: Optional[int] = None position: Optional[int] = None class TranslateIn(BaseModel): text: str source: str = "en" target: str = "pt" class ProfileUpdate(BaseModel): name: Optional[str] = None current_password: Optional[str] = None new_password: Optional[str] = None weight_kg: Optional[float] = None height_cm: Optional[float] = None age: Optional[int] = None sex: Optional[str] = None # ── App ─────────────────────────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): init_db() seed_if_empty() threading.Thread(target=_install_argos, daemon=True).start() yield app = FastAPI(title="Exercises API", version="1.0.0", lifespan=lifespan) origins = ["*"] if ALLOWED_ORIGINS.strip() == "*" else [o.strip() for o in ALLOWED_ORIGINS.split(",")] app.add_middleware( CORSMiddleware, allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.middleware("http") async def log_requests(request: Request, call_next): start = time.perf_counter() response = await call_next(request) ms = (time.perf_counter() - start) * 1000 print(f"{request.method} {request.url.path} {response.status_code} {ms:.1f}ms") return response # ═════════════════════════════════════════════════════════════════════════════ # ROOT # ═════════════════════════════════════════════════════════════════════════════ @app.get("/", include_in_schema=False) def root(): return RedirectResponse(url="/app") @app.get("/app", include_in_schema=False) def frontend(): return FileResponse("frontend/app.html") @app.get("/logo-volt.png", include_in_schema=False) def logo(): return FileResponse("frontend/logo-volt.png", media_type="image/png") # ═════════════════════════════════════════════════════════════════════════════ # AUTH # ═════════════════════════════════════════════════════════════════════════════ @app.post("/auth/register", status_code=201) def register(body: RegisterIn, db: DB): if not body.name.strip(): raise HTTPException(400, "name is required") if len(body.password) < 8: raise HTTPException(400, "password must be at least 8 characters") email = body.email.lower().strip() if db.execute("SELECT id FROM users WHERE email = ?", (email,)).fetchone(): raise HTTPException(409, "Email already registered") cur = db.execute( "INSERT INTO users (name, email, password_hash, weight_kg, height_cm, age, sex) VALUES (?,?,?,?,?,?,?)", (body.name.strip(), email, hash_password(body.password), body.weight_kg, body.height_cm, body.age, body.sex), ) db.commit() user = { "id": cur.lastrowid, "name": body.name.strip(), "email": email, "weight_kg": body.weight_kg, "height_cm": body.height_cm, "age": body.age, "sex": body.sex, } return {"token": create_token(user), "user": user} @app.post("/auth/login") def login(body: LoginIn, db: DB): row = db.execute( "SELECT id, name, email, password_hash, weight_kg, height_cm, age, sex FROM users WHERE email = ?", (body.email.lower().strip(),), ).fetchone() if not row or not verify_password(body.password, row["password_hash"]): raise HTTPException(401, "Email ou senha incorretos") user = { "id": row["id"], "name": row["name"], "email": row["email"], "weight_kg": row["weight_kg"], "height_cm": row["height_cm"], "age": row["age"], "sex": row["sex"], } return {"token": create_token(user), "user": user} # ═════════════════════════════════════════════════════════════════════════════ # USER PROFILE # ═════════════════════════════════════════════════════════════════════════════ @app.get("/me") def get_me(user: CurrentUser, db: DB): row = db.execute( "SELECT id, name, email, weight_kg, height_cm, age, sex FROM users WHERE id=?", (user["id"],), ).fetchone() if not row: raise HTTPException(404, "Usuário não encontrado") return dict(row) @app.put("/me") def update_me(body: ProfileUpdate, user: CurrentUser, db: DB): row = db.execute("SELECT * FROM users WHERE id=?", (user["id"],)).fetchone() if not row: raise HTTPException(404, "Usuário não encontrado") updates: dict = {} if body.name and body.name.strip(): updates["name"] = body.name.strip() if body.weight_kg is not None: updates["weight_kg"] = body.weight_kg if body.height_cm is not None: updates["height_cm"] = body.height_cm if body.age is not None: updates["age"] = body.age if body.sex is not None: updates["sex"] = body.sex if body.new_password: if not body.current_password: raise HTTPException(400, "Senha atual obrigatória para trocar a senha") pw_bytes = body.current_password.encode() hash_bytes = row["password_hash"].encode() if isinstance(row["password_hash"], str) else row["password_hash"] if not _bcrypt.checkpw(pw_bytes, hash_bytes): raise HTTPException(400, "Senha atual incorreta") if len(body.new_password) < 8: raise HTTPException(400, "Nova senha deve ter pelo menos 8 caracteres") updates["password_hash"] = _bcrypt.hashpw(body.new_password.encode(), _bcrypt.gensalt()).decode() if updates: set_clause = ", ".join(f"{k}=?" for k in updates) db.execute(f"UPDATE users SET {set_clause} WHERE id=?", (*updates.values(), user["id"])) db.commit() updated = db.execute( "SELECT id, name, email, weight_kg, height_cm, age, sex FROM users WHERE id=?", (user["id"],), ).fetchone() return dict(updated) # ═════════════════════════════════════════════════════════════════════════════ # EXERCISES (static routes before parameterised /{id}) # ═════════════════════════════════════════════════════════════════════════════ @app.get("/exercises/random") def random_exercise(db: DB): row = db.execute("SELECT * FROM exercises ORDER BY RANDOM() LIMIT 1").fetchone() if not row: raise HTTPException(404, "No exercises found") return row_to_dict(row) @app.get("/exercises/suggestions") def suggest_exercises( focus: str, db: DB, limit: int = Query(default=20, ge=1, le=100), ): if focus not in FOCUS_BODY_PARTS: raise HTTPException(400, f"focus must be one of: {', '.join(sorted(VALID_FOCUS))}") parts = FOCUS_BODY_PARTS[focus] if not parts: rows = db.execute( "SELECT * FROM exercises ORDER BY RANDOM() LIMIT ?", (limit,) ).fetchall() else: placeholders = ",".join("?" * len(parts)) rows = db.execute( f"SELECT * FROM exercises WHERE body_part IN ({placeholders}) ORDER BY RANDOM() LIMIT ?", (*parts, limit), ).fetchall() return [row_to_dict(r) for r in rows] @app.get("/exercises") def list_exercises( db: DB, page: int = Query(default=1, ge=1), limit: int = Query(default=20, ge=1, le=100), name: Optional[str] = None, category: Optional[str] = None, body_part: Optional[str] = None, equipment: Optional[str] = None, muscle_group: Optional[str] = None, target: Optional[str] = None, ): conditions, params = [], [] for field, value in [ ("name", name), ("category", category), ("body_part", body_part), ("equipment", equipment), ("muscle_group", muscle_group), ("target", target), ]: if value: conditions.append(f"{field} LIKE ? COLLATE NOCASE") params.append(f"%{value}%") where = f"WHERE {' AND '.join(conditions)}" if conditions else "" offset = (page - 1) * limit total = db.execute(f"SELECT COUNT(*) FROM exercises {where}", params).fetchone()[0] rows = db.execute( f"SELECT * FROM exercises {where} ORDER BY id LIMIT ? OFFSET ?", params + [limit, offset], ).fetchall() return { "data": [row_to_dict(r) for r in rows], "total": total, "page": page, "limit": limit, "totalPages": -(-total // limit), } @app.get("/exercises/{exercise_id}/insights") def get_exercise_insights(exercise_id: str, db: DB, lang: Optional[str] = Query(None)): row = db.execute( "SELECT * FROM exercise_insights WHERE exercise_id = ?", (exercise_id,) ).fetchone() if not row: raise HTTPException(404, "Insights não gerados para este exercício") d = dict(row) for field in ("common_mistakes", "benefits"): if isinstance(d.get(field), str): try: d[field] = json.loads(d[field]) except Exception: d[field] = [] if lang == "pt": for field in ("injury_risk_area", "easier_variation", "harder_variation", "no_equipment_alt"): if d.get(field): d[field] = _translate_cached(d[field], "en", "pt", db) for arr_field in ("common_mistakes", "benefits"): if isinstance(d.get(arr_field), list): d[arr_field] = [_translate_cached(item, "en", "pt", db) for item in d[arr_field]] d["_translated"] = _argos_ready return d @app.get("/exercises/{exercise_id}/calories") def get_exercise_calories( exercise_id: str, db: DB, weight: Optional[float] = Query(None), credentials: Annotated[ Optional[HTTPAuthorizationCredentials], Depends(_bearer_optional) ] = None, ): insight = db.execute( "SELECT calories_per_min_met FROM exercise_insights WHERE exercise_id = ?", (exercise_id,), ).fetchone() if not insight: raise HTTPException(404, "Insights não gerados para este exercício") exercise = db.execute("SELECT name FROM exercises WHERE id = ?", (exercise_id,)).fetchone() if not exercise: raise HTTPException(404, "Exercise not found") met = insight["calories_per_min_met"] weight_kg = weight if weight_kg is None and credentials: try: payload = jwt.decode(credentials.credentials, JWT_SECRET, algorithms=[JWT_ALGORITHM]) user = db.execute( "SELECT weight_kg FROM users WHERE id = ?", (int(payload["id"]),) ).fetchone() if user and user["weight_kg"]: weight_kg = user["weight_kg"] except Exception: pass if weight_kg is None: weight_kg = 70.0 calories_per_min = met * weight_kg * 3.5 / 200 return { "exercise_id": exercise_id, "exercise_name": exercise["name"], "weight_kg": weight_kg, "met_value": met, "calories_per_minute": round(calories_per_min, 2), "calories_30min": round(calories_per_min * 30, 1), "note": "Estimativa baseada em MET. Varia com intensidade e condicionamento físico.", } @app.get("/exercises/{exercise_id}/alternatives") def get_exercise_alternatives( exercise_id: str, db: DB, equipment: Optional[str] = Query(None), limit: int = Query(8, ge=1, le=20), ): ex = db.execute( "SELECT target, body_part, equipment FROM exercises WHERE id = ?", (exercise_id,) ).fetchone() if not ex: raise HTTPException(404, "Exercise not found") equip_clause = "" extra_params: list = [] if equipment: equip_clause = "AND LOWER(e.equipment) = LOWER(?)" extra_params = [equipment] rows = db.execute( f"""SELECT e.id, e.name, e.body_part, e.equipment, e.target, e.muscle_group, e.image, e.gif_url, ei.difficulty, ei.effort_type, ei.calories_per_min_met, CASE WHEN e.target = ? THEN 0 ELSE 1 END AS relevance FROM exercises e LEFT JOIN exercise_insights ei ON e.id = ei.exercise_id WHERE e.id != ? AND (e.target = ? OR e.body_part = ?) {equip_clause} ORDER BY relevance, ei.difficulty NULLS LAST, e.name LIMIT ?""", [ex["target"], exercise_id, ex["target"], ex["body_part"]] + extra_params + [limit], ).fetchall() result = [] for r in rows: d = dict(r) d.pop("relevance", None) result.append(d) return result @app.get("/exercises/{exercise_id}") def get_exercise(exercise_id: str, db: DB): row = db.execute("SELECT * FROM exercises WHERE id = ?", (exercise_id,)).fetchone() if not row: raise HTTPException(404, "Exercise not found") return row_to_dict(row) @app.get("/categories") def list_categories(db: DB): rows = db.execute( "SELECT DISTINCT category FROM exercises WHERE category IS NOT NULL ORDER BY category" ).fetchall() return [r[0] for r in rows] @app.get("/body-parts") def list_body_parts(db: DB): rows = db.execute( "SELECT DISTINCT body_part FROM exercises WHERE body_part IS NOT NULL ORDER BY body_part" ).fetchall() return [r[0] for r in rows] @app.get("/equipment") def list_equipment(db: DB): rows = db.execute( "SELECT DISTINCT equipment FROM exercises WHERE equipment IS NOT NULL ORDER BY equipment" ).fetchall() return [r[0] for r in rows] # ═════════════════════════════════════════════════════════════════════════════ # TRANSLATE # ═════════════════════════════════════════════════════════════════════════════ @app.post("/translate") def translate_text(body: TranslateIn, db: DB): if not body.text.strip(): return {"translated": ""} h = _text_hash(f"{body.source}:{body.target}:{body.text}") row = db.execute( "SELECT translated_text FROM translations WHERE source_lang=? AND target_lang=? AND source_hash=?", (body.source, body.target, h), ).fetchone() if row: return {"translated": row["translated_text"], "cached": True} if not _argos_ready: msg = _argos_error or "serviço de tradução ainda inicializando, aguarde alguns segundos" raise HTTPException(status_code=503, detail=msg) translated = _do_translate(body.text, body.source, body.target) db.execute( "INSERT OR IGNORE INTO translations(source_lang, target_lang, source_hash, translated_text) VALUES(?,?,?,?)", (body.source, body.target, h, translated), ) db.commit() return {"translated": translated, "cached": False} @app.get("/translate/status") def translate_status(): return { "ready": _argos_ready, "error": _argos_error, "preload": _preload_progress, } # ═════════════════════════════════════════════════════════════════════════════ # PLANS # ═════════════════════════════════════════════════════════════════════════════ @app.get("/plans") def list_plans(user: CurrentUser, db: DB): rows = db.execute( "SELECT * FROM workout_plans WHERE user_id = ? ORDER BY created_at DESC", (user["id"],), ).fetchall() return [dict(r) for r in rows] @app.post("/plans", status_code=201) def create_plan(body: PlanIn, user: CurrentUser, db: DB): if not body.name.strip(): raise HTTPException(400, "name is required") if body.focus not in VALID_FOCUS: raise HTTPException(400, f"focus must be one of: {', '.join(sorted(VALID_FOCUS))}") cur = db.execute( "INSERT INTO workout_plans (user_id, name, focus) VALUES (?, ?, ?)", (user["id"], body.name.strip(), body.focus), ) db.commit() return dict(db.execute("SELECT * FROM workout_plans WHERE id = ?", (cur.lastrowid,)).fetchone()) @app.get("/plans/{plan_id}/progression") def get_plan_progression(plan_id: int, user: CurrentUser, db: DB): plan = db.execute( "SELECT * FROM workout_plans WHERE id = ? AND user_id = ?", (plan_id, user["id"]), ).fetchone() if not plan: raise HTTPException(404, "Plan not found") entries = db.execute( """SELECT se.day_of_week, se.sets, se.reps, se.position, e.id AS exercise_id, e.name, ei.difficulty, ei.harder_variation, ei.effort_type FROM schedule_entries se JOIN exercises e ON se.exercise_id = e.id LEFT JOIN exercise_insights ei ON e.id = ei.exercise_id WHERE se.plan_id = ? ORDER BY se.day_of_week, se.position""", (plan["id"],), ).fetchall() days: dict = {} for e in entries: dow = e["day_of_week"] if dow not in days: days[dow] = [] sets, reps = e["sets"], e["reps"] harder = e["harder_variation"] if reps < 12: suggestion = {"type": "reps", "sets": sets, "reps": reps + 2, "note": f"Aumente para {reps + 2} repetições por série"} elif sets < 5: suggestion = {"type": "sets", "sets": sets + 1, "reps": 8, "note": f"Adicione uma série ({sets + 1}×8) e construa de volta"} elif harder: suggestion = {"type": "variation", "sets": sets, "reps": reps, "note": f"Avance para: {harder}"} else: suggestion = {"type": "weight", "sets": sets, "reps": reps, "note": "Aumente a carga em 2,5–5 kg"} days[dow].append({ "exercise_id": e["exercise_id"], "exercise_name": e["name"], "current": {"sets": sets, "reps": reps}, "suggestion": suggestion, "harder_variation": harder, "difficulty": e["difficulty"], "effort_type": e["effort_type"], }) return { "plan_id": plan_id, "plan_name": dict(plan)["name"], "progression": [ {"day_of_week": dow, "day": DAYS[dow], "exercises": exs} for dow, exs in sorted(days.items()) ], } @app.get("/plans/{plan_id}") def get_plan(plan_id: int, user: CurrentUser, db: DB): plan = db.execute( "SELECT * FROM workout_plans WHERE id = ? AND user_id = ?", (plan_id, user["id"]), ).fetchone() if not plan: raise HTTPException(404, "Plan not found") entries = db.execute( """SELECT se.id AS entry_id, se.day_of_week, se.sets, se.reps, se.rest_seconds, se.position, e.id AS exercise_id, e.name, e.category, e.body_part, e.equipment, e.target, e.muscle_group, e.secondary_muscles, e.image, e.gif_url FROM schedule_entries se JOIN exercises e ON se.exercise_id = e.id WHERE se.plan_id = ? ORDER BY se.day_of_week, se.position""", (plan["id"],), ).fetchall() schedule = [{"day_of_week": i, "day": DAYS[i], "exercises": []} for i in range(7)] for e in entries: try: secondary = json.loads(e["secondary_muscles"]) if e["secondary_muscles"] else [] except Exception: secondary = [] schedule[e["day_of_week"]]["exercises"].append({ "entry_id": e["entry_id"], "position": e["position"], "sets": e["sets"], "reps": e["reps"], "rest_seconds": e["rest_seconds"], "exercise": { "id": e["exercise_id"], "name": e["name"], "category": e["category"], "body_part": e["body_part"], "equipment": e["equipment"], "target": e["target"], "muscle_group": e["muscle_group"], "secondary_muscles": secondary, "image": e["image"], "gif_url": e["gif_url"], }, }) return {**dict(plan), "schedule": schedule} @app.put("/plans/{plan_id}") def update_plan(plan_id: int, body: PlanUpdate, user: CurrentUser, db: DB): if body.focus is not None and body.focus not in VALID_FOCUS: raise HTTPException(400, f"focus must be one of: {', '.join(sorted(VALID_FOCUS))}") plan = db.execute( "SELECT * FROM workout_plans WHERE id = ? AND user_id = ?", (plan_id, user["id"]), ).fetchone() if not plan: raise HTTPException(404, "Plan not found") new_name = body.name.strip() if body.name else plan["name"] new_focus = body.focus or plan["focus"] db.execute( "UPDATE workout_plans SET name = ?, focus = ? WHERE id = ?", (new_name, new_focus, plan["id"]), ) db.commit() return {**dict(plan), "name": new_name, "focus": new_focus} @app.delete("/plans/{plan_id}", status_code=204) def delete_plan(plan_id: int, user: CurrentUser, db: DB): plan = db.execute( "SELECT id FROM workout_plans WHERE id = ? AND user_id = ?", (plan_id, user["id"]), ).fetchone() if not plan: raise HTTPException(404, "Plan not found") db.execute("DELETE FROM workout_plans WHERE id = ?", (plan["id"],)) db.commit() @app.post("/plans/{plan_id}/schedule", status_code=201) def add_to_schedule(plan_id: int, body: ScheduleIn, user: CurrentUser, db: DB): if not (0 <= body.day_of_week <= 6): raise HTTPException(400, "day_of_week must be 0 (Sun) – 6 (Sat)") if body.sets < 1: raise HTTPException(400, "sets must be >= 1") if body.reps < 1: raise HTTPException(400, "reps must be >= 1") if body.rest_seconds < 0: raise HTTPException(400, "rest_seconds must be >= 0") plan = db.execute( "SELECT id FROM workout_plans WHERE id = ? AND user_id = ?", (plan_id, user["id"]), ).fetchone() if not plan: raise HTTPException(404, "Plan not found") if not db.execute("SELECT id FROM exercises WHERE id = ?", (body.exercise_id,)).fetchone(): raise HTTPException(404, "Exercise not found") max_pos = db.execute( "SELECT COALESCE(MAX(position), -1) FROM schedule_entries WHERE plan_id = ? AND day_of_week = ?", (plan["id"], body.day_of_week), ).fetchone()[0] cur = db.execute( "INSERT INTO schedule_entries (plan_id, day_of_week, exercise_id, sets, reps, rest_seconds, position) VALUES (?, ?, ?, ?, ?, ?, ?)", (plan["id"], body.day_of_week, body.exercise_id, body.sets, body.reps, body.rest_seconds, max_pos + 1), ) db.commit() return { "id": cur.lastrowid, "plan_id": plan["id"], "day_of_week": body.day_of_week, "day": DAYS[body.day_of_week], "exercise_id": body.exercise_id, "sets": body.sets, "reps": body.reps, "rest_seconds": body.rest_seconds, "position": max_pos + 1, } @app.put("/plans/{plan_id}/schedule/{entry_id}") def update_schedule_entry( plan_id: int, entry_id: int, body: ScheduleUpdate, user: CurrentUser, db: DB ): plan = db.execute( "SELECT id FROM workout_plans WHERE id = ? AND user_id = ?", (plan_id, user["id"]), ).fetchone() if not plan: raise HTTPException(404, "Plan not found") entry = db.execute( "SELECT * FROM schedule_entries WHERE id = ? AND plan_id = ?", (entry_id, plan["id"]), ).fetchone() if not entry: raise HTTPException(404, "Schedule entry not found") new_sets = body.sets if body.sets is not None else entry["sets"] new_reps = body.reps if body.reps is not None else entry["reps"] new_rest = body.rest_seconds if body.rest_seconds is not None else entry["rest_seconds"] new_pos = body.position if body.position is not None else entry["position"] db.execute( "UPDATE schedule_entries SET sets=?, reps=?, rest_seconds=?, position=? WHERE id=?", (new_sets, new_reps, new_rest, new_pos, entry["id"]), ) db.commit() return {**dict(entry), "sets": new_sets, "reps": new_reps, "rest_seconds": new_rest, "position": new_pos} @app.delete("/plans/{plan_id}/schedule/{entry_id}", status_code=204) def delete_schedule_entry(plan_id: int, entry_id: int, user: CurrentUser, db: DB): plan = db.execute( "SELECT id FROM workout_plans WHERE id = ? AND user_id = ?", (plan_id, user["id"]), ).fetchone() if not plan: raise HTTPException(404, "Plan not found") entry = db.execute( "SELECT id FROM schedule_entries WHERE id = ? AND plan_id = ?", (entry_id, plan["id"]), ).fetchone() if not entry: raise HTTPException(404, "Schedule entry not found") db.execute("DELETE FROM schedule_entries WHERE id = ?", (entry["id"],)) db.commit() # ── Static assets (images & GIFs) — must come after all API routes ──────────── import os as _os if _os.path.isdir("public/images"): app.mount("/images", StaticFiles(directory="public/images"), name="images") if _os.path.isdir("public/videos"): app.mount("/videos", StaticFiles(directory="public/videos"), name="videos")