initial commit
This commit is contained in:
commit
39fdd43d8e
127 files changed
+13919
No files matched your search
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