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.
+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