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.
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
from datetime import date, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, Workout
from app.routers.auth import require_auth
from app.schemas import (
AdjustSetRequest,
CompleteSetRequest,
MoveWorkoutRequest,
PromptChoiceRequest,
SetWeightRequest,
WorkoutDetail,
)
from app.services.auth import get_or_create_profile
from app.services.reschedule import plan_move
from app.services.workout import (
adjust_completed_set,
complete_set,
load_workout,
pause_workout,
resolve_prompt,
resume_workout,
set_exercise_weight,
start_workout,
workout_detail,
)
router = APIRouter(prefix="/api/workouts", tags=["workout-actions"])
@router.post("/{workout_id}/start", response_model=WorkoutDetail)
def api_start(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = start_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/pause", response_model=WorkoutDetail)
def api_pause(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = pause_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/resume", response_model=WorkoutDetail)
def api_resume(workout_id: int, _: User = Depends(require_auth), db: Session = Depends(get_db)) -> WorkoutDetail:
w = resume_workout(db, load_workout(db, workout_id))
return workout_detail(db, w)
@router.post("/{workout_id}/complete-set", response_model=WorkoutDetail)
def api_complete_set(
workout_id: int,
body: CompleteSetRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
w = complete_set(
db,
load_workout(db, workout_id),
actual_reps=body.actual_reps,
actual_weight_kg=body.actual_weight_kg,
)
return workout_detail(db, w)
@router.post("/{workout_id}/exercises/{exercise_id}/weight", response_model=WorkoutDetail)
def api_set_weight(
workout_id: int,
exercise_id: int,
body: SetWeightRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
w = set_exercise_weight(db, workout, exercise, body.weight_kg)
return workout_detail(db, w)
@router.post("/{workout_id}/exercises/{exercise_id}/sets/{set_id}", response_model=WorkoutDetail)
def api_adjust_set(
workout_id: int,
exercise_id: int,
set_id: int,
body: AdjustSetRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
workout_set = next((s for s in exercise.sets if s.id == set_id), None)
if not workout_set:
raise HTTPException(404, "Подход не найден")
w = adjust_completed_set(
db,
workout,
exercise,
workout_set,
actual_weight_kg=body.actual_weight_kg,
actual_reps=body.actual_reps,
)
return workout_detail(db, w)
@router.post("/{workout_id}/move", response_model=WorkoutDetail)
def api_move(
workout_id: int,
body: MoveWorkoutRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
profile = get_or_create_profile(db)
weekdays = profile.training_weekdays or []
if len(weekdays) != 3:
raise HTTPException(400, "Не заданы дни тренировок")
rows = db.query(Workout).filter(Workout.cycle_number == workout.cycle_number).all()
assigned = plan_move(rows, workout_id, body.scheduled_date, list(weekdays))
for row in rows:
if row.id in assigned:
row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300)
db.flush()
by_id = {row.id: row for row in rows}
for workout_row_id, scheduled in assigned.items():
by_id[workout_row_id].scheduled_date = scheduled
db.commit()
return workout_detail(db, load_workout(db, workout_id))
@router.post("/{workout_id}/exercises/{exercise_id}/prompt", response_model=WorkoutDetail)
def api_prompt(
workout_id: int,
exercise_id: int,
body: PromptChoiceRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
workout = load_workout(db, workout_id)
exercise = next((e for e in workout.exercises if e.id == exercise_id), None)
if not exercise:
raise HTTPException(404, "Упражнение не найдено")
w = resolve_prompt(
db,
workout,
exercise,
choice=body.choice,
weight_kg=body.weight_kg,
increase_kg=body.increase_kg,
decrease_kg=body.decrease_kg,
)
return workout_detail(db, w)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.schemas import LoginRequest, SetupPasswordRequest, StatusResponse, TokenResponse
from app.services.auth import (
create_access_token,
decode_token,
get_or_create_profile,
get_user,
has_password,
hash_password,
verify_password,
)
router = APIRouter(prefix="/api/auth", tags=["auth"])
security = HTTPBearer(auto_error=False)
def require_auth(
creds: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(get_db),
) -> User:
if not creds:
raise HTTPException(401, "Требуется авторизация")
sub = decode_token(creds.credentials)
if not sub:
raise HTTPException(401, "Недействительный токен")
user = get_user(db)
if not user:
raise HTTPException(401, "Пользователь не найден")
return user
@router.get("/status", response_model=StatusResponse)
def auth_status(db: Session = Depends(get_db)) -> StatusResponse:
profile = get_or_create_profile(db)
return StatusResponse(
has_password=has_password(db),
onboarded=profile.onboarded,
phase=profile.phase,
)
@router.post("/setup", response_model=TokenResponse)
def setup_password(body: SetupPasswordRequest, db: Session = Depends(get_db)) -> TokenResponse:
if has_password(db):
raise HTTPException(400, "Пароль уже задан")
user = User(password_hash=hash_password(body.password), created_at=datetime.utcnow())
db.add(user)
get_or_create_profile(db)
db.commit()
profile = get_or_create_profile(db)
return TokenResponse(
access_token=create_access_token(),
needs_setup=False,
onboarded=profile.onboarded,
)
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
user = get_user(db)
if not user:
raise HTTPException(400, "Сначала задайте пароль")
if not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Неверный пароль")
profile = get_or_create_profile(db)
return TokenResponse(
access_token=create_access_token(),
onboarded=profile.onboarded,
)
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.models import AccessoryState, Profile, User, Workout, WorkoutExercise
from app.routers.auth import require_auth
from app.schemas import (
CalendarResponse,
ExportResponse,
ImportRequest,
MessageResponse,
OnboardingRequest,
ProbeRequest,
ProfileResponse,
StartRestRequest,
TrainingDaysRequest,
WorkoutDetail,
WorkoutSummary,
)
from app.services.reschedule import plan_weekday_change
from app.services.auth import get_or_create_profile
from app.services.schedule import clear_cycle_workouts, generate_schedule
from app.services.workout import (
load_workout,
profile_response,
start_rest_phase,
submit_probe,
workout_detail,
workout_summary,
)
from app.services.weights import round_to_plate
router = APIRouter(prefix="/api", tags=["app"])
@router.get("/profile", response_model=ProfileResponse)
def get_profile(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ProfileResponse:
return profile_response(get_or_create_profile(db))
@router.post("/onboarding", response_model=ProfileResponse)
def onboarding(
body: OnboardingRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
if profile.onboarded:
raise HTTPException(400, "Онбординг уже пройден. Используйте сброс в настройках.")
one_rm = round_to_plate(body.one_rm_kg)
profile.one_rm_kg = one_rm
profile.training_weekdays = body.training_weekdays
profile.start_date = body.start_date
profile.onboarded = True
profile.phase = "active"
profile.cycle_number = 1
clear_cycle_workouts(db, 1)
generate_schedule(
db,
one_rm_kg=one_rm,
training_weekdays=body.training_weekdays,
start_date=body.start_date,
cycle_number=1,
)
db.commit()
db.refresh(profile)
return profile_response(profile)
@router.get("/calendar", response_model=CalendarResponse)
def calendar(
year: int,
month: int,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> CalendarResponse:
if month < 1 or month > 12:
raise HTTPException(400, "Неверный месяц")
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises))
.filter(
Workout.cycle_number == profile.cycle_number,
)
.order_by(Workout.scheduled_date)
.all()
)
# filter by month for display but also include all cycle for week strip — return month filtered
month_workouts = [w for w in workouts if w.scheduled_date.year == year and w.scheduled_date.month == month]
return CalendarResponse(
year=year,
month=month,
workouts=[workout_summary(w) for w in month_workouts],
profile=profile_response(profile),
)
@router.post("/training-days", response_model=ProfileResponse)
def update_training_days(
body: TrainingDaysRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
if not profile.onboarded:
raise HTTPException(400, "Сначала создайте программу")
rows = db.query(Workout).filter(Workout.cycle_number == profile.cycle_number).all()
done = [w.scheduled_date for w in rows if w.status == "completed"]
start = date.today()
if done:
start = max(start, max(done) + timedelta(days=1))
assigned = plan_weekday_change(rows, list(body.training_weekdays), start)
for row in rows:
if row.id in assigned:
row.scheduled_date = date(2099, 1, 1) + timedelta(days=row.id % 300)
db.flush()
by_id = {row.id: row for row in rows}
for workout_row_id, scheduled in assigned.items():
by_id[workout_row_id].scheduled_date = scheduled
profile.training_weekdays = list(body.training_weekdays)
db.commit()
db.refresh(profile)
return profile_response(profile)
@router.get("/workouts/upcoming", response_model=list[WorkoutSummary])
def upcoming(
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> list[WorkoutSummary]:
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises))
.filter(Workout.cycle_number == profile.cycle_number)
.order_by(Workout.scheduled_date)
.all()
)
return [workout_summary(w) for w in workouts]
@router.get("/workouts/{workout_id}", response_model=WorkoutDetail)
def get_workout(
workout_id: int,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> WorkoutDetail:
return workout_detail(db, load_workout(db, workout_id))
@router.post("/rest/start", response_model=ProfileResponse)
def rest_start(
body: StartRestRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
return profile_response(start_rest_phase(db, profile, body.rest_days))
@router.post("/probe", response_model=ProfileResponse)
def probe(
body: ProbeRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> ProfileResponse:
profile = get_or_create_profile(db)
return profile_response(
submit_probe(
db,
profile,
one_rm_kg=body.one_rm_kg,
start_date=body.start_date,
training_weekdays=body.training_weekdays,
)
)
@router.get("/export", response_model=ExportResponse)
def export_data(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> ExportResponse:
profile = get_or_create_profile(db)
workouts = (
db.query(Workout)
.options(joinedload(Workout.exercises).joinedload(WorkoutExercise.sets))
.order_by(Workout.scheduled_date)
.all()
)
accessories = db.query(AccessoryState).all()
def dump_workout(w: Workout) -> dict:
return {
"cycle_number": w.cycle_number,
"week_number": w.week_number,
"day_index": w.day_index,
"scheduled_date": w.scheduled_date.isoformat(),
"status": w.status,
"started_at": w.started_at.isoformat() if w.started_at else None,
"completed_at": w.completed_at.isoformat() if w.completed_at else None,
"current_exercise_index": w.current_exercise_index,
"current_set_index": w.current_set_index,
"exercises": [
{
"order_index": e.order_index,
"name": e.name,
"kind": e.kind,
"prescribed_weight_kg": e.prescribed_weight_kg,
"weight_locked": e.weight_locked,
"needs_weight_input": e.needs_weight_input,
"pending_prompt": e.pending_prompt,
"sets": [
{
"set_index": s.set_index,
"target_percent": s.target_percent,
"target_reps": s.target_reps,
"target_weight_kg": s.target_weight_kg,
"is_failure": s.is_failure,
"completed": s.completed,
"actual_reps": s.actual_reps,
"actual_weight_kg": s.actual_weight_kg,
"completed_at": s.completed_at.isoformat() if s.completed_at else None,
}
for s in sorted(e.sets, key=lambda x: x.set_index)
],
}
for e in sorted(w.exercises, key=lambda x: x.order_index)
],
}
return ExportResponse(
exported_at=datetime.utcnow(),
data={
"profile": {
"one_rm_kg": profile.one_rm_kg,
"training_weekdays": profile.training_weekdays,
"start_date": profile.start_date.isoformat() if profile.start_date else None,
"onboarded": profile.onboarded,
"phase": profile.phase,
"rest_until": profile.rest_until.isoformat() if profile.rest_until else None,
"probe_available_at": profile.probe_available_at.isoformat()
if profile.probe_available_at
else None,
"cycle_number": profile.cycle_number,
},
"workouts": [dump_workout(w) for w in workouts],
"accessories": [
{
"cycle_number": a.cycle_number,
"exercise_name": a.exercise_name,
"weight_kg": a.weight_kg,
"locked": a.locked,
"pending_weight_kg": a.pending_weight_kg,
}
for a in accessories
],
},
)
@router.post("/import", response_model=MessageResponse)
def import_data(
body: ImportRequest,
_: User = Depends(require_auth),
db: Session = Depends(get_db),
) -> MessageResponse:
if body.version != 1:
raise HTTPException(400, "Неподдерживаемая версия экспорта")
data = body.data
# full replace of progress
db.query(Workout).delete()
db.query(AccessoryState).delete()
profile = get_or_create_profile(db)
p = data.get("profile", {})
profile.one_rm_kg = p.get("one_rm_kg")
profile.training_weekdays = p.get("training_weekdays")
profile.start_date = datetime.fromisoformat(p["start_date"]).date() if p.get("start_date") else None
profile.onboarded = bool(p.get("onboarded"))
profile.phase = p.get("phase") or "active"
profile.rest_until = datetime.fromisoformat(p["rest_until"]) if p.get("rest_until") else None
profile.probe_available_at = (
datetime.fromisoformat(p["probe_available_at"]) if p.get("probe_available_at") else None
)
profile.cycle_number = int(p.get("cycle_number") or 1)
from app.models import WorkoutSet
for a in data.get("accessories", []):
db.add(
AccessoryState(
cycle_number=a["cycle_number"],
exercise_name=a["exercise_name"],
weight_kg=a.get("weight_kg"),
locked=bool(a.get("locked")),
pending_weight_kg=a.get("pending_weight_kg"),
)
)
for w in data.get("workouts", []):
workout = Workout(
cycle_number=w["cycle_number"],
week_number=w["week_number"],
day_index=w["day_index"],
scheduled_date=datetime.fromisoformat(w["scheduled_date"]).date(),
status=w.get("status") or "planned",
started_at=datetime.fromisoformat(w["started_at"]) if w.get("started_at") else None,
completed_at=datetime.fromisoformat(w["completed_at"]) if w.get("completed_at") else None,
current_exercise_index=w.get("current_exercise_index") or 0,
current_set_index=w.get("current_set_index") or 0,
)
db.add(workout)
db.flush()
for e in w.get("exercises", []):
ex = WorkoutExercise(
workout_id=workout.id,
order_index=e["order_index"],
name=e["name"],
kind=e["kind"],
prescribed_weight_kg=e.get("prescribed_weight_kg"),
weight_locked=bool(e.get("weight_locked")),
needs_weight_input=bool(e.get("needs_weight_input")),
pending_prompt=e.get("pending_prompt"),
)
db.add(ex)
db.flush()
for s in e.get("sets", []):
db.add(
WorkoutSet(
exercise_id=ex.id,
set_index=s["set_index"],
target_percent=s.get("target_percent"),
target_reps=s.get("target_reps"),
target_weight_kg=s.get("target_weight_kg"),
is_failure=bool(s.get("is_failure")),
completed=bool(s.get("completed")),
actual_reps=s.get("actual_reps"),
actual_weight_kg=s.get("actual_weight_kg"),
completed_at=datetime.fromisoformat(s["completed_at"])
if s.get("completed_at")
else None,
)
)
db.commit()
return MessageResponse(message="Данные импортированы")
@router.post("/reset", response_model=MessageResponse)
def reset_all(_: User = Depends(require_auth), db: Session = Depends(get_db)) -> MessageResponse:
db.query(Workout).delete()
db.query(AccessoryState).delete()
profile = get_or_create_profile(db)
profile.one_rm_kg = None
profile.training_weekdays = None
profile.start_date = None
profile.onboarded = False
profile.phase = "active"
profile.rest_until = None
profile.probe_available_at = None
profile.cycle_number = 1
db.commit()
return MessageResponse(message="Прогресс удалён. Пройдите онбординг заново.")