initial commit
This commit is contained in:
commit
39fdd43d8e
127 files changed
+13919
No files matched your search
Whitespace-only changes.
@@ -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
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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="Прогресс удалён. Пройдите онбординг заново.")
|
||||
@@ -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)
|
||||
Whitespace-only changes.
@@ -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()
|
||||
@@ -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} не найдена")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in new issue
Block a user