initial commit

This commit is contained in:
parovin committed 2026-10-11 14:12:10 +03:00
commit 39fdd43d8e
127 files changed
+13919

No files matched your search

View File
Whitespace-only changes.
+24
View File
@@ -0,0 +1,24 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: str = "postgresql+psycopg://benchpress:benchpress@localhost:5432/benchpress"
secret_key: str = "dev-secret-key-change-me"
access_token_expire_minutes: int = 60 * 24 * 30
cors_origins: str = (
"http://localhost:5173,http://localhost:3000,http://localhost,"
"https://localhost,capacitor://localhost,http://10.0.2.2:8000"
)
plate_step_kg: float = 1.25
pause_limit_minutes: int = 15
rest_min_days: int = 3
rest_max_days: int = 5
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
settings = Settings()
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
from collections.abc import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.database import Base, engine
from app.routers import actions, auth, workouts
app = FastAPI(title="Benchpress Secret", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
app.include_router(workouts.router)
app.include_router(actions.router)
@app.on_event("startup")
def on_startup() -> None:
Base.metadata.create_all(bind=engine)
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Optional
from sqlalchemy import Boolean, Date, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class Profile(Base):
__tablename__ = "profiles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
one_rm_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
training_weekdays: Mapped[Optional[list[Any]]] = mapped_column(JSONB, nullable=True)
start_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
onboarded: Mapped[bool] = mapped_column(Boolean, default=False)
phase: Mapped[str] = mapped_column(String(32), default="active")
# active | rest | probe | completed_cycle
rest_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
probe_available_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
cycle_number: Mapped[int] = mapped_column(Integer, default=1)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Workout(Base):
__tablename__ = "workouts"
__table_args__ = (UniqueConstraint("scheduled_date", "cycle_number", name="uq_workout_date_cycle"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
cycle_number: Mapped[int] = mapped_column(Integer, default=1)
week_number: Mapped[int] = mapped_column(Integer, nullable=False)
day_index: Mapped[int] = mapped_column(Integer, nullable=False) # 0..2 within week
scheduled_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
status: Mapped[str] = mapped_column(String(32), default="planned")
# planned | in_progress | paused | completed | abandoned
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
current_exercise_index: Mapped[int] = mapped_column(Integer, default=0)
current_set_index: Mapped[int] = mapped_column(Integer, default=0)
rest_started_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
exercises: Mapped[list[WorkoutExercise]] = relationship(
back_populates="workout", cascade="all, delete-orphan", order_by="WorkoutExercise.order_index"
)
class WorkoutExercise(Base):
__tablename__ = "workout_exercises"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
workout_id: Mapped[int] = mapped_column(ForeignKey("workouts.id", ondelete="CASCADE"), index=True)
order_index: Mapped[int] = mapped_column(Integer, nullable=False)
name: Mapped[str] = mapped_column(String(128), nullable=False)
kind: Mapped[str] = mapped_column(String(32), nullable=False) # bench | accessory | failure
prescribed_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
weight_locked: Mapped[bool] = mapped_column(Boolean, default=False)
needs_weight_input: Mapped[bool] = mapped_column(Boolean, default=False)
pending_prompt: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
# None | choose_weight_missed | choose_weight_easy | confirm_easy
workout: Mapped[Workout] = relationship(back_populates="exercises")
sets: Mapped[list[WorkoutSet]] = relationship(
back_populates="exercise", cascade="all, delete-orphan", order_by="WorkoutSet.set_index"
)
class WorkoutSet(Base):
__tablename__ = "workout_sets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
exercise_id: Mapped[int] = mapped_column(ForeignKey("workout_exercises.id", ondelete="CASCADE"), index=True)
set_index: Mapped[int] = mapped_column(Integer, nullable=False)
target_percent: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
target_reps: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
target_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
is_failure: Mapped[bool] = mapped_column(Boolean, default=False)
completed: Mapped[bool] = mapped_column(Boolean, default=False)
actual_reps: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
actual_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
exercise: Mapped[WorkoutExercise] = relationship(back_populates="sets")
class AccessoryState(Base):
"""Tracks working weight and lock status for accessory exercises within a cycle."""
__tablename__ = "accessory_states"
__table_args__ = (UniqueConstraint("cycle_number", "exercise_name", name="uq_accessory_cycle_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
cycle_number: Mapped[int] = mapped_column(Integer, default=1)
exercise_name: Mapped[str] = mapped_column(String(128), nullable=False)
weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
locked: Mapped[bool] = mapped_column(Boolean, default=False)
pending_weight_kg: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class AppMeta(Base):
__tablename__ = "app_meta"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
key: Mapped[str] = mapped_column(String(64), unique=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
View File
Whitespace-only changes.
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, Workout
from app.routers.auth import require_auth
from app.schemas import (
AdjustSetRequest,
CompleteSetRequest,
MoveWorkoutRequest,
PromptChoiceRequest,
SetWeightRequest,
WorkoutDetail,
)
from app.services.auth import get_or_create_profile
from app.services.reschedule import plan_move
from app.services.workout import (
adjust_completed_set,
complete_set,
load_workout,
pause_workout,
resolve_prompt,
resume_workout,
set_exercise_weight,
start_workout,
workout_detail,
)
router = APIRouter(prefix="/api/workouts", tags=["workout-actions"])
@router.post("/{workout_id}/start", response_model=WorkoutDetail)
def api_start(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = start_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/pause", response_model=WorkoutDetail)
def api_pause(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = pause_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/resume", response_model=WorkoutDetail)
def api_resume(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = resume_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/complete-set", response_model=WorkoutDetail)
def api_complete_set(
workout_id: int,
body: CompleteSetRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
w = complete_set(
db,
load_workout(db, workout_id),
actual_reps=body.actual_reps,
actual_weight_kg=body.actual_weight_kg,
)
return workout_detail(db, w)
@router.post("/{workout_id}/exercises/{exercise_id}/weight", response_model=WorkoutDetail)
def api_set_weight(
workout_id: int,
exercise_id: int,
body: SetWeightRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
w = set_exercise_weight(db, workout, exercise, body.weight_kg)
return workout_detail(db, w)
@router.post("/{workout_id}/exercises/{exercise_id}/sets/{set_id}", response_model=WorkoutDetail)
def api_adjust_set(
workout_id: int,
exercise_id: int,
set_id: int,
body: AdjustSetRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
workout_set = next((s for s in exercise.sets if s.id == set_id), None)
if not workout_set:
raise HTTPException(404, "Подход не найден")
w = adjust_completed_set(
db,
workout,
exercise,
workout_set,
actual_weight_kg=body.actual_weight_kg,
actual_reps=body.actual_reps,
)
return workout_detail(db, w)
@router.post("/{workout_id}/move", response_model=WorkoutDetail)
def api_move(
workout_id: int,
body: MoveWorkoutRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
profile = get_or_create_profile(db)
weekdays = profile.training_weekdays or []
if len(weekdays) != 3:
raise HTTPException(400, "Не заданы дни тренировок")
rows = db.query(Workout).filter(Workout.cycle_number == workout.cycle_number).all()
assigned = plan_move(rows, workout_id, body.scheduled_date, list(weekdays))
for row in rows:
if row.id in assigned:
row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300)
db.flush()
by_id = {row.id: row for row in rows}
for workout_row_id, scheduled in assigned.items():
by_id[workout_row_id].scheduled_date = scheduled
db.commit()
return workout_detail(db, load_workout(db, workout_id))
@router.post("/{workout_id}/exercises/{exercise_id}/prompt", response_model=WorkoutDetail)
def api_prompt(
workout_id: int,
exercise_id: int,
body: PromptChoiceRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
w = resolve_prompt(
db,
workout,
exercise,
choice=body.choice,
weight_kg=body.weight_kg,
increase_kg=body.increase_kg,
decrease_kg=body.decrease_kg,
)
return workout_detail(db, w)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.schemas import LoginRequest, SetupPasswordRequest, StatusResponse, TokenResponse
from app.services.auth import (
create_access_token,
decode_token,
get_or_create_profile,
get_user,
has_password,
hash_password,
verify_password,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
security = HTTPBearer(auto_error=False)
def require_auth(
creds: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(get_db),
) -> User:
if not creds:
raise HTTPException(401, "Требуется авторизация")
sub = decode_token(creds.credentials)
if not sub:
raise HTTPException(401, "Недействительный токен")
user = get_user(db)
if not user:
raise HTTPException(401, "Пользователь не найден")
return user
@router.get("/status", response_model=StatusResponse)
def auth_status(db: Session = Depends(get_db)) -> StatusResponse:
profile = get_or_create_profile(db)
return StatusResponse(
has_password=has_password(db),
onboarded=profile.onboarded,
phase=profile.phase,
)
@router.post("/setup", response_model=TokenResponse)
def setup_password(body: SetupPasswordRequest, db: Session = Depends(get_db)) -> TokenResponse:
if has_password(db):
raise HTTPException(400, "Пароль уже задан")
user = User(password_hash=hash_password(body.password), created_at=datetime.utcnow())
db.add(user)
get_or_create_profile(db)
db.commit()
profile = get_or_create_profile(db)
return TokenResponse(
access_token=create_access_token(),
needs_setup=False,
onboarded=profile.onboarded,
)
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = get_user(db)
if not user:
raise HTTPException(400, "Сначала задайте пароль")
if not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Неверный пароль")
profile = get_or_create_profile(db)
return TokenResponse(
access_token=create_access_token(),
onboarded=profile.onboarded,
)
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import AccessoryState, Profile, User, Workout, WorkoutExercise
from app.routers.auth import require_auth
from app.schemas import (
CalendarResponse,
ExportResponse,
ImportRequest,
MessageResponse,
OnboardingRequest,
ProbeRequest,
ProfileResponse,
StartRestRequest,
TrainingDaysRequest,
WorkoutDetail,
WorkoutSummary,
)
from app.services.reschedule import plan_weekday_change
from app.services.auth import get_or_create_profile
from app.services.schedule import clear_cycle_workouts, generate_schedule
from app.services.workout import (
load_workout,
profile_response,
start_rest_phase,
submit_probe,
workout_detail,
workout_summary,
)
from app.services.weights import round_to_plate
router = APIRouter(prefix="/api", tags=["app"])
@router.get("/profile", response_model=ProfileResponse)
def get_profile(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ProfileResponse:
return profile_response(get_or_create_profile(db))
@router.post("/onboarding", response_model=ProfileResponse)
def onboarding(
body: OnboardingRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
if profile.onboarded:
raise HTTPException(400, "Онбординг уже пройден. Используйте сброс в настройках.")
one_rm = round_to_plate(body.one_rm_kg)
profile.one_rm_kg = one_rm
profile.training_weekdays = body.training_weekdays
profile.start_date = body.start_date
profile.onboarded = True
profile.phase = "active"
profile.cycle_number = 1
clear_cycle_workouts(db, 1)
generate_schedule(
db,
one_rm_kg=one_rm,
training_weekdays=body.training_weekdays,
start_date=body.start_date,
cycle_number=1,
)
db.commit()
db.refresh(profile)
return profile_response(profile)
@router.get("/calendar", response_model=CalendarResponse)
def calendar(
year: int,
month: int,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> CalendarResponse:
if month < 1 or month > 12:
raise HTTPException(400, "Неверный месяц")
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises))
.filter(
Workout.cycle_number == profile.cycle_number,
)
.order_by(Workout.scheduled_date)
.all()
)
# filter by month for display but also include all cycle for week strip — return month filtered
month_workouts = [w for w in workouts if w.scheduled_date.year == year and w.scheduled_date.month == month]
return CalendarResponse(
year=year,
month=month,
workouts=[workout_summary(w) for w in month_workouts],
profile=profile_response(profile),
)
@router.post("/training-days", response_model=ProfileResponse)
def update_training_days(
body: TrainingDaysRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
if not profile.onboarded:
raise HTTPException(400, "Сначала создайте программу")
rows = db.query(Workout).filter(Workout.cycle_number == profile.cycle_number).all()
done = [w.scheduled_date for w in rows if w.status == "completed"]
start = date.today()
if done:
start = max(start, max(done) + timedelta(days=1))
assigned = plan_weekday_change(rows, list(body.training_weekdays), start)
for row in rows:
if row.id in assigned:
row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300)
db.flush()
by_id = {row.id: row for row in rows}
for workout_row_id, scheduled in assigned.items():
by_id[workout_row_id].scheduled_date = scheduled
profile.training_weekdays = list(body.training_weekdays)
db.commit()
db.refresh(profile)
return profile_response(profile)
@router.get("/workouts/upcoming", response_model=list[WorkoutSummary])
def upcoming(
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> list[WorkoutSummary]:
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises))
.filter(Workout.cycle_number == profile.cycle_number)
.order_by(Workout.scheduled_date)
.all()
)
return [workout_summary(w) for w in workouts]
@router.get("/workouts/{workout_id}", response_model=WorkoutDetail)
def get_workout(
workout_id: int,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
return workout_detail(db, load_workout(db, workout_id))
@router.post("/rest/start", response_model=ProfileResponse)
def rest_start(
body: StartRestRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
return profile_response(start_rest_phase(db, profile, body.rest_days))
@router.post("/probe", response_model=ProfileResponse)
def probe(
body: ProbeRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
return profile_response(
submit_probe(
db,
profile,
one_rm_kg=body.one_rm_kg,
start_date=body.start_date,
training_weekdays=body.training_weekdays,
)
)
@router.get("/export", response_model=ExportResponse)
def export_data(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ExportResponse:
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises).joinedload(WorkoutExercise.sets))
.order_by(Workout.scheduled_date)
.all()
)
accessories = db.query(AccessoryState).all()
def dump_workout(w: Workout) -> dict:
return {
"cycle_number": w.cycle_number,
"week_number": w.week_number,
"day_index": w.day_index,
"scheduled_date": w.scheduled_date.isoformat(),
"status": w.status,
"started_at": w.started_at.isoformat() if w.started_at else None,
"completed_at": w.completed_at.isoformat() if w.completed_at else None,
"current_exercise_index": w.current_exercise_index,
"current_set_index": w.current_set_index,
"exercises": [
{
"order_index": e.order_index,
"name": e.name,
"kind": e.kind,
"prescribed_weight_kg": e.prescribed_weight_kg,
"weight_locked": e.weight_locked,
"needs_weight_input": e.needs_weight_input,
"pending_prompt": e.pending_prompt,
"sets": [
{
"set_index": s.set_index,
"target_percent": s.target_percent,
"target_reps": s.target_reps,
"target_weight_kg": s.target_weight_kg,
"is_failure": s.is_failure,
"completed": s.completed,
"actual_reps": s.actual_reps,
"actual_weight_kg": s.actual_weight_kg,
"completed_at": s.completed_at.isoformat() if s.completed_at else None,
}
for s in sorted(e.sets, key=lambda x: x.set_index)
],
}
for e in sorted(w.exercises, key=lambda x: x.order_index)
],
}
return ExportResponse(
exported_at=datetime.utcnow(),
data={
"profile": {
"one_rm_kg": profile.one_rm_kg,
"training_weekdays": profile.training_weekdays,
"start_date": profile.start_date.isoformat() if profile.start_date else None,
"onboarded": profile.onboarded,
"phase": profile.phase,
"rest_until": profile.rest_until.isoformat() if profile.rest_until else None,
"probe_available_at": profile.probe_available_at.isoformat()
if profile.probe_available_at
else None,
"cycle_number": profile.cycle_number,
},
"workouts": [dump_workout(w) for w in workouts],
"accessories": [
{
"cycle_number": a.cycle_number,
"exercise_name": a.exercise_name,
"weight_kg": a.weight_kg,
"locked": a.locked,
"pending_weight_kg": a.pending_weight_kg,
}
for a in accessories
],
},
)
@router.post("/import", response_model=MessageResponse)
def import_data(
body: ImportRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> MessageResponse:
if body.version != 1:
raise HTTPException(400, "Неподдерживаемая версия экспорта")
data = body.data
# full replace of progress
db.query(Workout).delete()
db.query(AccessoryState).delete()
profile = get_or_create_profile(db)
p = data.get("profile", {})
profile.one_rm_kg = p.get("one_rm_kg")
profile.training_weekdays = p.get("training_weekdays")
profile.start_date = datetime.fromisoformat(p["start_date"]).date() if p.get("start_date") else None
profile.onboarded = bool(p.get("onboarded"))
profile.phase = p.get("phase") or "active"
profile.rest_until = datetime.fromisoformat(p["rest_until"]) if p.get("rest_until") else None
profile.probe_available_at = (
datetime.fromisoformat(p["probe_available_at"]) if p.get("probe_available_at") else None
)
profile.cycle_number = int(p.get("cycle_number") or 1)
from app.models import WorkoutSet
for a in data.get("accessories", []):
db.add(
AccessoryState(
cycle_number=a["cycle_number"],
exercise_name=a["exercise_name"],
weight_kg=a.get("weight_kg"),
locked=bool(a.get("locked")),
pending_weight_kg=a.get("pending_weight_kg"),
)
)
for w in data.get("workouts", []):
workout = Workout(
cycle_number=w["cycle_number"],
week_number=w["week_number"],
day_index=w["day_index"],
scheduled_date=datetime.fromisoformat(w["scheduled_date"]).date(),
status=w.get("status") or "planned",
started_at=datetime.fromisoformat(w["started_at"]) if w.get("started_at") else None,
completed_at=datetime.fromisoformat(w["completed_at"]) if w.get("completed_at") else None,
current_exercise_index=w.get("current_exercise_index") or 0,
current_set_index=w.get("current_set_index") or 0,
)
db.add(workout)
db.flush()
for e in w.get("exercises", []):
ex = WorkoutExercise(
workout_id=workout.id,
order_index=e["order_index"],
name=e["name"],
kind=e["kind"],
prescribed_weight_kg=e.get("prescribed_weight_kg"),
weight_locked=bool(e.get("weight_locked")),
needs_weight_input=bool(e.get("needs_weight_input")),
pending_prompt=e.get("pending_prompt"),
)
db.add(ex)
db.flush()
for s in e.get("sets", []):
db.add(
WorkoutSet(
exercise_id=ex.id,
set_index=s["set_index"],
target_percent=s.get("target_percent"),
target_reps=s.get("target_reps"),
target_weight_kg=s.get("target_weight_kg"),
is_failure=bool(s.get("is_failure")),
completed=bool(s.get("completed")),
actual_reps=s.get("actual_reps"),
actual_weight_kg=s.get("actual_weight_kg"),
completed_at=datetime.fromisoformat(s["completed_at"])
if s.get("completed_at")
else None,
)
)
db.commit()
return MessageResponse(message="Данные импортированы")
@router.post("/reset", response_model=MessageResponse)
def reset_all(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> MessageResponse:
db.query(Workout).delete()
db.query(AccessoryState).delete()
profile = get_or_create_profile(db)
profile.one_rm_kg = None
profile.training_weekdays = None
profile.start_date = None
profile.onboarded = False
profile.phase = "active"
profile.rest_until = None
profile.probe_available_at = None
profile.cycle_number = 1
db.commit()
return MessageResponse(message="Прогресс удалён. Пройдите онбординг заново.")
+193
View File
@@ -0,0 +1,193 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, field_validator
Weekday = Literal[0, 1, 2, 3, 4, 5, 6] # Mon=0 .. Sun=6
class SetupPasswordRequest(BaseModel):
password: str = Field(min_length=4, max_length=128)
class LoginRequest(BaseModel):
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
needs_setup: bool = False
onboarded: bool = False
class StatusResponse(BaseModel):
has_password: bool
onboarded: bool
phase: str = "active"
class OnboardingRequest(BaseModel):
one_rm_kg: float = Field(gt=0)
training_weekdays: list[int] = Field(min_length=3, max_length=3)
start_date: date
@field_validator("training_weekdays")
@classmethod
def validate_days(cls, v: list[int]) -> list[int]:
if len(set(v)) != 3:
raise ValueError("Нужно выбрать 3 разных дня")
for d in v:
if d < 0 or d > 6:
raise ValueError("День недели должен быть 0–6")
sorted_days = sorted(v)
# check circular gap of at least 1 day between consecutive selected days
for i in range(3):
a = sorted_days[i]
b = sorted_days[(i + 1) % 3]
if i < 2:
gap = b - a - 1
else:
gap = (a + 7) - b - 1
if gap < 1:
raise ValueError("Между выбранными днями нужен минимум 1 день перерыва")
return sorted(v)
class ProfileResponse(BaseModel):
one_rm_kg: Optional[float]
training_weekdays: Optional[list[int]]
start_date: Optional[date]
onboarded: bool
phase: str
rest_until: Optional[datetime]
probe_available_at: Optional[datetime]
cycle_number: int
rest_countdown_seconds: Optional[int] = None
can_probe: bool = False
class SetResponse(BaseModel):
id: int
set_index: int
target_percent: Optional[float]
target_reps: Optional[int]
target_weight_kg: Optional[float]
is_failure: bool
completed: bool
actual_reps: Optional[int]
actual_weight_kg: Optional[float]
class ExerciseResponse(BaseModel):
id: int
order_index: int
name: str
kind: str
prescribed_weight_kg: Optional[float]
weight_locked: bool
needs_weight_input: bool
pending_prompt: Optional[str]
weight_step_kg: float = 1.25
sets: list[SetResponse]
class WorkoutSummary(BaseModel):
id: int
cycle_number: int
week_number: int
day_index: int
scheduled_date: date
status: str
exercise_count: int = 0
class WorkoutDetail(BaseModel):
id: int
cycle_number: int
week_number: int
day_index: int
scheduled_date: date
status: str
started_at: Optional[datetime]
paused_at: Optional[datetime]
completed_at: Optional[datetime]
current_exercise_index: int
current_set_index: int
rest_started_at: Optional[datetime]
rest_elapsed_seconds: int = 0
can_start: bool = False
can_resume: bool = False
pause_expires_at: Optional[datetime] = None
exercises: list[ExerciseResponse]
class CalendarResponse(BaseModel):
year: int
month: int
workouts: list[WorkoutSummary]
profile: ProfileResponse
class CompleteSetRequest(BaseModel):
actual_reps: Optional[int] = None
actual_weight_kg: Optional[float] = None
class SetWeightRequest(BaseModel):
weight_kg: float = Field(gt=0)
class AdjustSetRequest(BaseModel):
actual_weight_kg: float = Field(gt=0)
actual_reps: int = Field(ge=0)
class PromptChoiceRequest(BaseModel):
choice: Literal["easy_yes", "easy_no"]
weight_kg: Optional[float] = None
increase_kg: Optional[float] = None
decrease_kg: Optional[float] = None
class StartRestRequest(BaseModel):
rest_days: int = Field(ge=3, le=5)
class ProbeRequest(BaseModel):
one_rm_kg: float = Field(gt=0)
start_date: date
training_weekdays: Optional[list[int]] = None
class ExportResponse(BaseModel):
version: int = 1
exported_at: datetime
data: dict[str, Any]
class ImportRequest(BaseModel):
version: int
data: dict[str, Any]
class MessageResponse(BaseModel):
message: str
workout: Optional[WorkoutDetail] = None
profile: Optional[ProfileResponse] = None
class MoveWorkoutRequest(BaseModel):
scheduled_date: date
class TrainingDaysRequest(BaseModel):
training_weekdays: list[int] = Field(min_length=3, max_length=3)
@field_validator("training_weekdays")
@classmethod
def validate_days(cls, v: list[int]) -> list[int]:
return OnboardingRequest.validate_days(v)
View File
Whitespace-only changes.
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.config import settings
from app.models import Profile, User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def create_access_token(subject: str = "user") -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
return jwt.encode(
{"sub": subject, "exp": expire},
settings.secret_key,
algorithm="HS256",
)
def decode_token(token: str) -> Optional[str]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
return payload.get("sub")
except JWTError:
return None
def get_or_create_profile(db: Session) -> Profile:
profile = db.query(Profile).first()
if not profile:
profile = Profile()
db.add(profile)
db.commit()
db.refresh(profile)
return profile
def has_password(db: Session) -> bool:
return db.query(User).first() is not None
def get_user(db: Session) -> Optional[User]:
return db.query(User).first()
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
from typing import Any
PROGRAM_PATH = Path(__file__).resolve().parent.parent / "data" / "program.json"
@lru_cache(maxsize=1)
def load_program() -> dict[str, Any]:
with PROGRAM_PATH.open(encoding="utf-8") as f:
return json.load(f)
def get_week(week_number: int) -> dict[str, Any]:
program = load_program()
for week in program["weeks"]:
if week["week"] == week_number:
return week
raise ValueError(f"Неделя {week_number} не найдена")
+114
View File
@@ -0,0 +1,114 @@
from __future__ import annotations
from datetime import date, timedelta
from fastapi import HTTPException
def _add(d: date, days: int) -> date:
return d + timedelta(days=days)
def _step(d: date, weekdays: list[int], direction: int) -> date:
selected = set(weekdays)
cursor = _add(d, direction)
for _ in range(28):
if cursor.weekday() in selected:
return cursor
cursor = _add(cursor, direction)
raise HTTPException(400, "Не удалось найти тренировочный день")
def _on_or_after(d: date, weekdays: list[int]) -> date:
if d.weekday() in set(weekdays):
return d
return _step(_add(d, -1), weekdays, 1)
def _order(week: int, day_index: int) -> int:
return week * 10 + day_index
def _next_training(after: date, weekdays: list[int], taken: set[date]) -> date:
value = _step(after, weekdays, 1)
while value in taken:
value = _step(value, weekdays, 1)
return value
def _place(desired: date, previous: date | None, weekdays: list[int], taken: set[date]) -> date:
value = desired
if previous is not None and value <= previous:
value = _next_training(previous, weekdays, taken)
while value in taken:
value = _next_training(value, weekdays, taken)
return value
def plan_move(workouts: list, moved_id: int, new_date: date, weekdays: list[int]) -> dict[int, date]:
moved = next((w for w in workouts if w.id == moved_id), None)
if moved is None:
raise HTTPException(404, "Тренировка не найдена")
if moved.status == "completed":
raise HTTPException(400, "Выполненную тренировку переносить нельзя")
if new_date < date.today():
raise HTTPException(400, "В закрытый период переносить нельзя")
sorted_items = sorted(workouts, key=lambda w: (_order(w.week_number, w.day_index), w.id))
completed = [w for w in sorted_items if w.status == "completed"]
if any(w.scheduled_date == new_date for w in completed):
raise HTTPException(400, "На эту дату уже есть выполненная тренировка")
moved_order = _order(moved.week_number, moved.day_index)
earlier = [w for w in completed if _order(w.week_number, w.day_index) < moved_order]
later = [w for w in completed if _order(w.week_number, w.day_index) > moved_order]
if earlier and new_date <= earlier[-1].scheduled_date:
raise HTTPException(400, "Дата должна быть позже уже выполненной тренировки")
if later and new_date >= later[0].scheduled_date:
raise HTTPException(400, "Дата должна быть раньше уже выполненной следующей тренировки")
taken = {w.scheduled_date for w in completed}
assigned: dict[int, date] = {}
index = 0
while index < len(sorted_items):
if sorted_items[index].status == "completed":
index += 1
continue
start = index
while index < len(sorted_items) and sorted_items[index].status != "completed":
index += 1
segment = sorted_items[start:index]
prev_anchor = sorted_items[start - 1] if start > 0 else None
next_anchor = sorted_items[index] if index < len(sorted_items) else None
proposed = sorted(new_date if item.id == moved_id else item.scheduled_date for item in segment)
dates: list[date] = []
for raw in proposed:
value = _place(raw, dates[-1] if dates else None, weekdays, taken)
if prev_anchor is not None and value <= prev_anchor.scheduled_date:
raise HTTPException(400, "Дата должна быть позже уже выполненной тренировки")
if next_anchor is not None and value >= next_anchor.scheduled_date:
raise HTTPException(400, "Дата должна быть раньше уже выполненной следующей тренировки")
dates.append(value)
taken.add(value)
for item, value in zip(segment, dates):
assigned[item.id] = value
return assigned
def plan_weekday_change(workouts: list, weekdays: list[int], start_from: date) -> dict[int, date]:
open_items = sorted(
(w for w in workouts if w.status != "completed"),
key=lambda w: _order(w.week_number, w.day_index),
)
taken = {w.scheduled_date for w in workouts if w.status == "completed"}
assigned: dict[int, date] = {}
cursor = start_from
for item in open_items:
date_value = _on_or_after(cursor, weekdays)
while date_value in taken:
date_value = _step(date_value, weekdays, 1)
assigned[item.id] = date_value
taken.add(date_value)
cursor = _add(date_value, 1)
return assigned
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
from datetime import date, timedelta
from sqlalchemy.orm import Session
from app.models import AccessoryState, Workout, WorkoutExercise, WorkoutSet
from app.services.program import load_program
from app.services.weights import bench_weight
WEEKDAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
def next_dates_for_weekdays(start: date, weekdays: list[int], count: int) -> list[date]:
"""Return `count` dates on selected weekdays starting from start (inclusive if matches)."""
selected = set(weekdays)
dates: list[date] = []
cursor = start
# safety limit
for _ in range(400):
if cursor.weekday() in selected:
dates.append(cursor)
if len(dates) >= count:
break
cursor += timedelta(days=1)
if len(dates) < count:
raise ValueError("Не удалось построить расписание")
return dates
def clear_cycle_workouts(db: Session, cycle_number: int) -> None:
workouts = db.query(Workout).filter(Workout.cycle_number == cycle_number).all()
for w in workouts:
db.delete(w)
db.query(AccessoryState).filter(AccessoryState.cycle_number == cycle_number).delete()
db.flush()
def generate_schedule(
db: Session,
*,
one_rm_kg: float,
training_weekdays: list[int],
start_date: date,
cycle_number: int,
) -> list[Workout]:
program = load_program()
total_days = sum(len(w["days"]) for w in program["weeks"])
dates = next_dates_for_weekdays(start_date, training_weekdays, total_days)
# Map chronological day_index within week order from template (day 0,1,2 of each week)
date_idx = 0
created: list[Workout] = []
for week in program["weeks"]:
for day_index, day in enumerate(week["days"]):
scheduled = dates[date_idx]
date_idx += 1
workout = Workout(
cycle_number=cycle_number,
week_number=week["week"],
day_index=day_index,
scheduled_date=scheduled,
status="planned",
)
db.add(workout)
db.flush()
for order_index, ex in enumerate(day["exercises"]):
kind = ex["kind"]
accessory_state = None
needs_weight = False
prescribed = None
locked = False
if kind == "accessory":
accessory_state = (
db.query(AccessoryState)
.filter(
AccessoryState.cycle_number == cycle_number,
AccessoryState.exercise_name == ex["name"],
)
.first()
)
if accessory_state and accessory_state.locked and accessory_state.weight_kg is not None:
prescribed = accessory_state.weight_kg
locked = True
elif accessory_state and accessory_state.pending_weight_kg is not None:
prescribed = accessory_state.pending_weight_kg
needs_weight = False
elif accessory_state and accessory_state.weight_kg is not None and not accessory_state.locked:
prescribed = accessory_state.weight_kg
needs_weight = False
else:
needs_weight = True
we = WorkoutExercise(
workout_id=workout.id,
order_index=order_index,
name=ex["name"],
kind=kind,
prescribed_weight_kg=prescribed,
weight_locked=locked,
needs_weight_input=needs_weight,
)
db.add(we)
db.flush()
for set_index, s in enumerate(ex["sets"]):
if kind == "bench":
tw = bench_weight(one_rm_kg, s["percent"])
ws = WorkoutSet(
exercise_id=we.id,
set_index=set_index,
target_percent=s["percent"],
target_reps=s["reps"],
target_weight_kg=tw,
is_failure=False,
)
elif kind == "failure":
ws = WorkoutSet(
exercise_id=we.id,
set_index=set_index,
target_reps=None,
target_weight_kg=None,
is_failure=True,
)
else:
reps = s.get("reps") if isinstance(s, dict) else None
ws = WorkoutSet(
exercise_id=we.id,
set_index=set_index,
target_reps=reps,
target_weight_kg=prescribed,
is_failure=False,
)
db.add(ws)
created.append(workout)
db.flush()
return created
def apply_accessory_weight_to_future(
db: Session,
*,
cycle_number: int,
exercise_name: str,
weight_kg: float,
from_date: date,
lock: bool,
) -> None:
state = (
db.query(AccessoryState)
.filter(
AccessoryState.cycle_number == cycle_number,
AccessoryState.exercise_name == exercise_name,
)
.first()
)
if not state:
state = AccessoryState(cycle_number=cycle_number, exercise_name=exercise_name)
db.add(state)
if lock:
state.weight_kg = weight_kg
state.locked = True
state.pending_weight_kg = None
else:
state.pending_weight_kg = weight_kg
state.weight_kg = weight_kg
state.locked = False
# Update future workouts' exercises of same name
future = (
db.query(WorkoutExercise)
.join(Workout)
.filter(
Workout.cycle_number == cycle_number,
Workout.scheduled_date >= from_date,
Workout.status != "completed",
WorkoutExercise.name == exercise_name,
WorkoutExercise.kind == "accessory",
)
.all()
)
for we in future:
if we.sets and all(s.completed for s in we.sets):
continue
we.prescribed_weight_kg = weight_kg
we.weight_locked = lock
we.needs_weight_input = False
for s in we.sets:
if not s.completed:
s.target_weight_kg = weight_kg
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
from app.config import settings
# Шаг корректировки/ввода веса вспомогательных (прибавка и снижение).
ACCESSORY_STEP_KG = 0.5
def weight_step_for(exercise_name: str | None = None, *, kind: str | None = None) -> float:
"""Жим — блины (config). Вспомогательные — 0.5 кг."""
if kind == "bench" or (exercise_name == "Жим"):
return settings.plate_step_kg
if kind in ("accessory", "failure"):
return ACCESSORY_STEP_KG
if exercise_name:
return ACCESSORY_STEP_KG
return settings.plate_step_kg
def round_to_plate(weight_kg: float, step: float | None = None) -> float:
step = step if step is not None else settings.plate_step_kg
if weight_kg <= 0:
return 0.0
return round(round(weight_kg / step) * step, 2)
def round_exercise_weight(weight_kg: float, exercise_name: str, *, kind: str | None = None) -> float:
return round_to_plate(weight_kg, weight_step_for(exercise_name, kind=kind))
def bench_weight(one_rm_kg: float, percent: float) -> float:
"""Жим: округление как раньше — по plate_step_kg (1.25)."""
return round_to_plate(one_rm_kg * (percent / 100.0), settings.plate_step_kg)
+589
View File
@@ -0,0 +1,589 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from typing import Optional
from fastapi import HTTPException
from sqlalchemy.orm import Session, joinedload
from app.config import settings
from app.models import AccessoryState, Profile, Workout, WorkoutExercise, WorkoutSet
from app.schemas import (
ExerciseResponse,
ProfileResponse,
SetResponse,
WorkoutDetail,
WorkoutSummary,
)
from app.services.schedule import apply_accessory_weight_to_future, generate_schedule
from app.services.weights import round_exercise_weight, round_to_plate, weight_step_for
def profile_response(profile: Profile, now: Optional[datetime] = None) -> ProfileResponse:
now = now or datetime.utcnow()
rest_countdown = None
can_probe = False
if profile.phase == "rest" and profile.probe_available_at:
delta = (profile.probe_available_at - now).total_seconds()
if delta > 0:
rest_countdown = int(delta)
else:
can_probe = True
elif profile.phase == "probe":
can_probe = True
return ProfileResponse(
one_rm_kg=profile.one_rm_kg,
training_weekdays=profile.training_weekdays,
start_date=profile.start_date,
onboarded=profile.onboarded,
phase=profile.phase if not (profile.phase == "rest" and can_probe) else "probe",
rest_until=profile.rest_until,
probe_available_at=profile.probe_available_at,
cycle_number=profile.cycle_number,
rest_countdown_seconds=rest_countdown,
can_probe=can_probe,
)
def set_response(s: WorkoutSet) -> SetResponse:
return SetResponse(
id=s.id,
set_index=s.set_index,
target_percent=s.target_percent,
target_reps=s.target_reps,
target_weight_kg=s.target_weight_kg,
is_failure=s.is_failure,
completed=s.completed,
actual_reps=s.actual_reps,
actual_weight_kg=s.actual_weight_kg,
)
def exercise_response(ex: WorkoutExercise) -> ExerciseResponse:
return ExerciseResponse(
id=ex.id,
order_index=ex.order_index,
name=ex.name,
kind=ex.kind,
prescribed_weight_kg=ex.prescribed_weight_kg,
weight_locked=ex.weight_locked,
needs_weight_input=ex.needs_weight_input,
pending_prompt=ex.pending_prompt,
weight_step_kg=weight_step_for(ex.name, kind=ex.kind),
sets=[set_response(s) for s in sorted(ex.sets, key=lambda x: x.set_index)],
)
def workout_summary(w: Workout) -> WorkoutSummary:
return WorkoutSummary(
id=w.id,
cycle_number=w.cycle_number,
week_number=w.week_number,
day_index=w.day_index,
scheduled_date=w.scheduled_date,
status=w.status,
exercise_count=len(w.exercises) if w.exercises is not None else 0,
)
def maybe_expire_pause(db: Session, workout: Workout, now: Optional[datetime] = None) -> Workout:
now = now or datetime.utcnow()
if workout.status == "paused" and workout.paused_at:
limit = workout.paused_at + timedelta(minutes=settings.pause_limit_minutes)
if now > limit:
workout.status = "abandoned"
workout.paused_at = None
workout.rest_started_at = None
db.commit()
db.refresh(workout)
return workout
def workout_detail(db: Session, workout: Workout, today: Optional[date] = None) -> WorkoutDetail:
today = today or date.today()
now = datetime.utcnow()
workout = maybe_expire_pause(db, workout, now)
active = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.id != workout.id,
Workout.status.in_(["in_progress", "paused"]),
)
.first()
)
can_start = workout.status == "planned" and workout.scheduled_date == today and active is None
can_resume = False
pause_expires_at = None
if workout.status == "paused" and workout.paused_at:
pause_expires_at = workout.paused_at + timedelta(minutes=settings.pause_limit_minutes)
can_resume = now <= pause_expires_at and workout.scheduled_date == today
rest_elapsed = 0
if workout.status == "in_progress" and workout.rest_started_at:
rest_elapsed = max(0, int((now - workout.rest_started_at).total_seconds()))
return WorkoutDetail(
id=workout.id,
cycle_number=workout.cycle_number,
week_number=workout.week_number,
day_index=workout.day_index,
scheduled_date=workout.scheduled_date,
status=workout.status,
started_at=workout.started_at,
paused_at=workout.paused_at,
completed_at=workout.completed_at,
current_exercise_index=workout.current_exercise_index,
current_set_index=workout.current_set_index,
rest_started_at=workout.rest_started_at,
rest_elapsed_seconds=rest_elapsed,
can_start=can_start,
can_resume=can_resume,
pause_expires_at=pause_expires_at,
exercises=[exercise_response(e) for e in sorted(workout.exercises, key=lambda x: x.order_index)],
)
def load_workout(db: Session, workout_id: int) -> Workout:
def _fetch() -> Workout | None:
return (
db.query(Workout)
.options(joinedload(Workout.exercises).joinedload(WorkoutExercise.sets))
.filter(Workout.id == workout_id)
.first()
)
workout = _fetch()
if not workout:
raise HTTPException(404, "Тренировка не найдена")
if sync_accessory_weights(db, workout):
db.commit()
workout = _fetch()
assert workout is not None
return workout
def sync_accessory_weights(db: Session, workout: Workout) -> bool:
"""Подтянуть сохранённый вес каждого вспомогательного из AccessoryState."""
changed = False
for ex in workout.exercises:
if ex.kind != "accessory":
continue
if any(s.completed for s in ex.sets):
continue
state = (
db.query(AccessoryState)
.filter(
AccessoryState.cycle_number == workout.cycle_number,
AccessoryState.exercise_name == ex.name,
)
.first()
)
if not state:
continue
weight = state.pending_weight_kg if state.pending_weight_kg is not None else state.weight_kg
if weight is None:
continue
if (
ex.prescribed_weight_kg != weight
or ex.needs_weight_input
or ex.weight_locked != bool(state.locked)
):
ex.prescribed_weight_kg = weight
ex.needs_weight_input = False
ex.weight_locked = bool(state.locked)
for s in ex.sets:
if not s.completed:
s.target_weight_kg = weight
changed = True
return changed
def start_workout(db: Session, workout: Workout, today: Optional[date] = None) -> Workout:
today = today or date.today()
if workout.scheduled_date != today:
raise HTTPException(400, "Начать можно только сегодняшнюю тренировку")
if workout.status not in ("planned",):
raise HTTPException(400, "Тренировку нельзя начать")
active = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.status.in_(["in_progress", "paused"]),
)
.first()
)
if active:
raise HTTPException(400, "Уже есть активная тренировка")
workout.status = "in_progress"
workout.started_at = datetime.utcnow()
workout.current_exercise_index = 0
workout.current_set_index = 0
db.commit()
db.refresh(workout)
return load_workout(db, workout.id)
def pause_workout(db: Session, workout: Workout) -> Workout:
if workout.status != "in_progress":
raise HTTPException(400, "Пауза доступна только во время тренировки")
workout.status = "paused"
workout.paused_at = datetime.utcnow()
workout.rest_started_at = None
db.commit()
return load_workout(db, workout.id)
def resume_workout(db: Session, workout: Workout, today: Optional[date] = None) -> Workout:
today = today or date.today()
workout = maybe_expire_pause(db, workout)
if workout.status == "abandoned":
raise HTTPException(400, "Тренировка остановлена: пауза дольше 15 минут")
if workout.status != "paused":
raise HTTPException(400, "Тренировка не на паузе")
if workout.scheduled_date != today:
raise HTTPException(400, "Продолжить можно только в тот же день")
assert workout.paused_at
if datetime.utcnow() > workout.paused_at + timedelta(minutes=settings.pause_limit_minutes):
workout.status = "abandoned"
db.commit()
raise HTTPException(400, "Тренировка остановлена: пауза дольше 15 минут")
workout.status = "in_progress"
workout.paused_at = None
db.commit()
return load_workout(db, workout.id)
def set_exercise_weight(db: Session, workout: Workout, exercise: WorkoutExercise, weight_kg: float) -> Workout:
if exercise.kind != "accessory":
raise HTTPException(400, "Вес задаётся только для вспомогательных")
if workout.status == "completed":
raise HTTPException(400, "Выполненную тренировку менять нельзя")
if exercise.sets and all(s.completed for s in exercise.sets):
raise HTTPException(400, "Это упражнение уже выполнено")
weight = round_exercise_weight(weight_kg, exercise.name, kind=exercise.kind)
exercise.prescribed_weight_kg = weight
exercise.needs_weight_input = False
for s in exercise.sets:
if not s.completed:
s.target_weight_kg = weight
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=weight,
from_date=workout.scheduled_date,
lock=False,
)
db.commit()
return load_workout(db, workout.id)
def adjust_completed_set(
db: Session,
workout: Workout,
exercise: WorkoutExercise,
workout_set: WorkoutSet,
*,
actual_weight_kg: float,
actual_reps: int,
) -> Workout:
if exercise.kind != "accessory":
raise HTTPException(400, "Править можно только вспомогательные")
if not workout_set.completed:
raise HTTPException(400, "Можно править только выполненный подход")
workout_set.actual_weight_kg = round_exercise_weight(actual_weight_kg, exercise.name, kind=exercise.kind)
workout_set.actual_reps = actual_reps
db.commit()
return load_workout(db, workout.id)
def _advance_pointer(workout: Workout, exercises: list[WorkoutExercise]) -> bool:
"""Advance to next incomplete set. Returns True if workout completed."""
for ei, ex in enumerate(exercises):
for s in sorted(ex.sets, key=lambda x: x.set_index):
if not s.completed:
workout.current_exercise_index = ei
workout.current_set_index = s.set_index
return False
return True
def _analyze_accessory(db: Session, workout: Workout, exercise: WorkoutExercise) -> None:
if exercise.kind != "accessory":
return
if exercise.weight_locked:
return
if any(not s.completed for s in exercise.sets):
return
all_hit = all(
(s.actual_reps or 0) >= (s.target_reps or 0) for s in exercise.sets if s.target_reps is not None
)
if not all_hit:
exercise.pending_prompt = "choose_weight_missed"
else:
exercise.pending_prompt = "confirm_easy"
def complete_set(
db: Session,
workout: Workout,
*,
actual_reps: Optional[int] = None,
actual_weight_kg: Optional[float] = None,
) -> Workout:
if workout.status != "in_progress":
raise HTTPException(400, "Тренировка не активна")
exercises = sorted(workout.exercises, key=lambda x: x.order_index)
if workout.current_exercise_index >= len(exercises):
raise HTTPException(400, "Нет текущего упражнения")
exercise = exercises[workout.current_exercise_index]
if exercise.pending_prompt:
raise HTTPException(400, "Сначала ответьте на вопрос по упражнению")
if exercise.kind == "accessory" and exercise.needs_weight_input:
raise HTTPException(400, "Сначала введите рабочий вес")
sets = sorted(exercise.sets, key=lambda x: x.set_index)
current = next((s for s in sets if s.set_index == workout.current_set_index), None)
if not current or current.completed:
# find first incomplete
current = next((s for s in sets if not s.completed), None)
if not current:
raise HTTPException(400, "Нет активного подхода")
if exercise.kind == "bench" or exercise.kind == "failure":
current.completed = True
current.completed_at = datetime.utcnow()
current.actual_weight_kg = current.target_weight_kg
current.actual_reps = current.target_reps
else:
if actual_reps is None:
raise HTTPException(400, "Укажите выполненные повторения")
weight = actual_weight_kg if actual_weight_kg is not None else exercise.prescribed_weight_kg
if weight is None:
raise HTTPException(400, "Не задан вес")
current.completed = True
current.completed_at = datetime.utcnow()
current.actual_reps = actual_reps
current.actual_weight_kg = round_exercise_weight(weight, exercise.name, kind=exercise.kind)
# Check if exercise finished → maybe prompt
if all(s.completed for s in sets):
_analyze_accessory(db, workout, exercise)
if exercise.pending_prompt:
workout.rest_started_at = None
db.commit()
return load_workout(db, workout.id)
done = _advance_pointer(workout, exercises)
if done:
workout.status = "completed"
workout.completed_at = datetime.utcnow()
workout.rest_started_at = None
_maybe_finish_cycle(db, workout)
else:
workout.rest_started_at = datetime.utcnow()
db.commit()
return load_workout(db, workout.id)
def resolve_prompt(
db: Session,
workout: Workout,
exercise: WorkoutExercise,
*,
choice: str,
weight_kg: Optional[float] = None,
increase_kg: Optional[float] = None,
decrease_kg: Optional[float] = None,
) -> Workout:
if workout.status != "in_progress":
raise HTTPException(400, "Тренировка не активна")
if not exercise.pending_prompt:
raise HTTPException(400, "Нет активного вопроса")
prompt = exercise.pending_prompt
today = workout.scheduled_date
current_weight = exercise.prescribed_weight_kg
step = weight_step_for(exercise.name, kind=exercise.kind)
if prompt == "confirm_easy":
if choice == "easy_no":
if current_weight is None:
raise HTTPException(400, "Вес не задан")
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=current_weight,
from_date=today,
lock=True,
)
exercise.weight_locked = True
exercise.pending_prompt = None
elif choice == "easy_yes":
exercise.pending_prompt = "choose_weight_easy"
db.commit()
return load_workout(db, workout.id)
else:
raise HTTPException(400, "Неверный выбор")
elif prompt == "choose_weight_easy":
if current_weight is None:
raise HTTPException(400, "Текущий вес не задан")
if increase_kg is None or increase_kg <= 0:
raise HTTPException(400, "Укажите, на сколько поднять вес")
bump = round_to_plate(increase_kg, step)
if bump < step:
bump = step
weight = round_to_plate(current_weight + bump, step)
if weight <= current_weight:
weight = round_to_plate(current_weight + step, step)
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=weight,
from_date=today + timedelta(days=1),
lock=False,
)
exercise.pending_prompt = None
elif prompt == "choose_weight_missed":
if current_weight is None:
raise HTTPException(400, "Текущий вес не задан")
if decrease_kg is None or decrease_kg <= 0:
raise HTTPException(400, "Укажите, на сколько снизить вес")
drop = round_to_plate(decrease_kg, step)
if drop < step:
drop = step
weight = round_to_plate(current_weight - drop, step)
if weight >= current_weight:
weight = round_to_plate(current_weight - step, step)
if weight < step:
weight = step
apply_accessory_weight_to_future(
db,
cycle_number=workout.cycle_number,
exercise_name=exercise.name,
weight_kg=weight,
from_date=today + timedelta(days=1),
lock=False,
)
exercise.pending_prompt = None
else:
raise HTTPException(400, "Неизвестный вопрос")
exercises = sorted(workout.exercises, key=lambda x: x.order_index)
done = _advance_pointer(workout, exercises)
if done:
workout.status = "completed"
workout.completed_at = datetime.utcnow()
workout.rest_started_at = None
_maybe_finish_cycle(db, workout)
else:
workout.rest_started_at = datetime.utcnow()
db.commit()
return load_workout(db, workout.id)
def _maybe_finish_cycle(db: Session, workout: Workout) -> None:
remaining = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.status.in_(["planned", "in_progress", "paused"]),
Workout.id != workout.id,
)
.count()
)
# also count if this one not yet marked - it's being completed
unfinished_others = (
db.query(Workout)
.filter(
Workout.cycle_number == workout.cycle_number,
Workout.id != workout.id,
Workout.status.notin_(["completed", "abandoned"]),
)
.count()
)
if unfinished_others == 0:
profile = db.query(Profile).first()
if profile:
profile.phase = "rest"
# wait for user to choose rest days via API; temporarily set probe far
profile.probe_available_at = None
profile.rest_until = None
def start_rest_phase(db: Session, profile: Profile, rest_days: int) -> Profile:
if profile.phase not in ("rest", "probe"):
# allow if cycle just finished
unfinished = (
db.query(Workout)
.filter(
Workout.cycle_number == profile.cycle_number,
Workout.status.notin_(["completed", "abandoned"]),
)
.count()
)
if unfinished > 0 and profile.phase == "active":
raise HTTPException(400, "Цикл ещё не завершён")
if rest_days < settings.rest_min_days or rest_days > settings.rest_max_days:
raise HTTPException(400, f"Отдых от {settings.rest_min_days} до {settings.rest_max_days} дней")
now = datetime.utcnow()
profile.phase = "rest"
profile.probe_available_at = now + timedelta(days=rest_days)
profile.rest_until = profile.probe_available_at
db.commit()
db.refresh(profile)
return profile
def submit_probe(
db: Session,
profile: Profile,
*,
one_rm_kg: float,
start_date: date,
training_weekdays: Optional[list[int]] = None,
) -> Profile:
now = datetime.utcnow()
if profile.phase == "rest" and profile.probe_available_at and now < profile.probe_available_at:
raise HTTPException(400, "Ещё рано для проходки")
profile.phase = "probe" if profile.phase == "rest" else profile.phase
weekdays = training_weekdays or profile.training_weekdays
if not weekdays:
raise HTTPException(400, "Не заданы дни тренировок")
one_rm = round_to_plate(one_rm_kg)
new_cycle = profile.cycle_number + 1
profile.one_rm_kg = one_rm
profile.training_weekdays = weekdays
profile.start_date = start_date
profile.cycle_number = new_cycle
profile.phase = "active"
profile.rest_until = None
profile.probe_available_at = None
profile.onboarded = True
generate_schedule(
db,
one_rm_kg=one_rm,
training_weekdays=weekdays,
start_date=start_date,
cycle_number=new_cycle,
)
db.commit()
db.refresh(profile)
return profile