possibly auth
This commit is contained in:
@@ -1,7 +1,3 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from jwt.exceptions import PyJWTError
|
||||
|
||||
Binary file not shown.
+12
-11
@@ -1,35 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from database import engine, Base
|
||||
import routers.clients
|
||||
|
||||
import routers.auth
|
||||
import routers.clients
|
||||
import routers.workout_types
|
||||
import routers.workouts
|
||||
import routers.exercises
|
||||
import routers.metrics
|
||||
import routers.sync
|
||||
|
||||
# Создаём таблицы
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Gym Bro API",
|
||||
description="API для трекера тренировок с офлайн-синхронизацией",
|
||||
version="1.0.0"
|
||||
description="API для трекера тренировок с офлайн-синхронизацией и авторизацией",
|
||||
version="1.2.0",
|
||||
)
|
||||
|
||||
# CORS для локальной разработки
|
||||
# Для локальной разработки. В проде замени на конкретный домен фронта.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # В продакшене заменить на конкретные домены
|
||||
allow_credentials=True,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Подключаем роутеры
|
||||
app.include_router(routers.auth.router)
|
||||
app.include_router(routers.clients.router)
|
||||
app.include_router(routers.workout_types.router)
|
||||
@@ -38,14 +37,16 @@ app.include_router(routers.exercises.router)
|
||||
app.include_router(routers.metrics.router)
|
||||
app.include_router(routers.sync.router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {
|
||||
"message": "Gym Bro API",
|
||||
"docs": "/docs",
|
||||
"version": "1.0.0"
|
||||
"version": "1.2.0",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -36,10 +36,16 @@ def register(
|
||||
role = data.role or "personal"
|
||||
|
||||
if len(username) < 3:
|
||||
raise HTTPException(status_code=400, detail="Username must be at least 3 characters")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Username must be at least 3 characters",
|
||||
)
|
||||
|
||||
if len(password) < 6:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Password must be at least 6 characters",
|
||||
)
|
||||
|
||||
if role not in ("personal", "trainer"):
|
||||
role = "personal"
|
||||
|
||||
+118
-34
@@ -1,50 +1,131 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
import auth
|
||||
import database
|
||||
import models
|
||||
import schemas
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/clients", tags=["clients"])
|
||||
|
||||
def get_current_timestamp():
|
||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
||||
router = APIRouter(
|
||||
prefix="/clients",
|
||||
tags=["clients"],
|
||||
dependencies=[Depends(auth.get_current_user)],
|
||||
)
|
||||
|
||||
@router.post("/", response_model=schemas.Client)
|
||||
def create_client(client: schemas.ClientCreate, db: Session = Depends(database.get_db)):
|
||||
# Проверяем уникальность sync_id
|
||||
db_client = db.query(models.Client).filter(models.Client.sync_id == client.sync_id).first()
|
||||
if db_client:
|
||||
raise HTTPException(status_code=400, detail="Client with this sync_id already exists")
|
||||
|
||||
timestamp = get_current_timestamp()
|
||||
db_client = models.Client(
|
||||
**client.dict(),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp
|
||||
DEFAULT_CLIENT_COLOR = "#bb86fc"
|
||||
|
||||
|
||||
def get_current_timestamp() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||
|
||||
|
||||
def new_sync_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def get_owned_client(
|
||||
db: Session,
|
||||
client_id: int,
|
||||
user: models.User,
|
||||
) -> models.Client:
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.id == client_id,
|
||||
models.Client.owner_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
return client
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.ClientOut)
|
||||
def create_client(
|
||||
client: schemas.ClientCreate,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
sync_id = client.sync_id or new_sync_id()
|
||||
timestamp = get_current_timestamp()
|
||||
|
||||
existing = db.query(models.Client).filter(models.Client.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=409, detail="Client sync_id belongs to another user")
|
||||
|
||||
existing.full_name = client.full_name
|
||||
existing.birth_date = client.birth_date
|
||||
existing.goal = client.goal or ""
|
||||
existing.notes = client.notes or ""
|
||||
existing.color = client.color or existing.color or DEFAULT_CLIENT_COLOR
|
||||
existing.is_active = client.is_active if client.is_active is not None else existing.is_active
|
||||
existing.updated_at = timestamp
|
||||
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
db_client = models.Client(
|
||||
sync_id=sync_id,
|
||||
owner_id=current_user.id,
|
||||
full_name=client.full_name,
|
||||
birth_date=client.birth_date,
|
||||
goal=client.goal or "",
|
||||
notes=client.notes or "",
|
||||
color=client.color or DEFAULT_CLIENT_COLOR,
|
||||
is_active=client.is_active if client.is_active is not None else 1,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(db_client)
|
||||
db.commit()
|
||||
db.refresh(db_client)
|
||||
return db_client
|
||||
|
||||
@router.get("/", response_model=List[schemas.Client])
|
||||
def list_clients(skip: int = 0, limit: int = 100, db: Session = Depends(database.get_db)):
|
||||
return db.query(models.Client).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/{client_id}", response_model=schemas.Client)
|
||||
def get_client(client_id: int, db: Session = Depends(database.get_db)):
|
||||
db_client = db.query(models.Client).filter(models.Client.id == client_id).first()
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
return db_client
|
||||
@router.get("/", response_model=List[schemas.ClientOut])
|
||||
def list_clients(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
return (
|
||||
db.query(models.Client)
|
||||
.filter(models.Client.owner_id == current_user.id)
|
||||
.order_by(models.Client.full_name)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
@router.put("/{client_id}", response_model=schemas.Client)
|
||||
def update_client(client_id: int, client: schemas.ClientUpdate, db: Session = Depends(database.get_db)):
|
||||
db_client = db.query(models.Client).filter(models.Client.id == client_id).first()
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
|
||||
@router.get("/{client_id}", response_model=schemas.ClientOut)
|
||||
def get_client(
|
||||
client_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
return get_owned_client(db, client_id, current_user)
|
||||
|
||||
|
||||
@router.put("/{client_id}", response_model=schemas.ClientOut)
|
||||
def update_client(
|
||||
client_id: int,
|
||||
client: schemas.ClientUpdate,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
db_client = get_owned_client(db, client_id, current_user)
|
||||
|
||||
update_data = client.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
@@ -55,11 +136,14 @@ def update_client(client_id: int, client: schemas.ClientUpdate, db: Session = De
|
||||
db.refresh(db_client)
|
||||
return db_client
|
||||
|
||||
|
||||
@router.delete("/{client_id}")
|
||||
def delete_client(client_id: int, db: Session = Depends(database.get_db)):
|
||||
db_client = db.query(models.Client).filter(models.Client.id == client_id).first()
|
||||
if not db_client:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
def delete_client(
|
||||
client_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
db_client = get_owned_client(db, client_id, current_user)
|
||||
db.delete(db_client)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
+271
-65
@@ -1,52 +1,287 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
import auth
|
||||
import database
|
||||
import models
|
||||
import schemas
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/exercises", tags=["exercises"])
|
||||
|
||||
def get_current_timestamp():
|
||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
||||
router = APIRouter(
|
||||
prefix="/exercises",
|
||||
tags=["exercises"],
|
||||
dependencies=[Depends(auth.get_current_user)],
|
||||
)
|
||||
|
||||
@router.post("/", response_model=schemas.Exercise)
|
||||
def create_exercise(exercise: schemas.ExerciseCreate, db: Session = Depends(database.get_db)):
|
||||
db_exercise = db.query(models.Exercise).filter(models.Exercise.sync_id == exercise.sync_id).first()
|
||||
if db_exercise:
|
||||
raise HTTPException(status_code=400, detail="Exercise with this sync_id already exists")
|
||||
|
||||
timestamp = get_current_timestamp()
|
||||
db_exercise = models.Exercise(
|
||||
**exercise.dict(),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp
|
||||
def get_current_timestamp() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||
|
||||
|
||||
def new_sync_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
return (name or "").strip().lower()
|
||||
|
||||
|
||||
def find_exercise_by_name(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
name: str,
|
||||
) -> Optional[models.Exercise]:
|
||||
n = normalize_name(name)
|
||||
if not n:
|
||||
return None
|
||||
|
||||
owned = (
|
||||
db.query(models.Exercise)
|
||||
.filter(
|
||||
models.Exercise.owner_id == user.id,
|
||||
func.lower(models.Exercise.name) == n,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if owned:
|
||||
return owned
|
||||
|
||||
return (
|
||||
db.query(models.Exercise)
|
||||
.filter(
|
||||
models.Exercise.owner_id.is_(None),
|
||||
func.lower(models.Exercise.name) == n,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def get_owned_exercise(
|
||||
db: Session,
|
||||
exercise_id: int,
|
||||
user: models.User,
|
||||
) -> models.Exercise:
|
||||
exercise = (
|
||||
db.query(models.Exercise)
|
||||
.filter(
|
||||
models.Exercise.id == exercise_id,
|
||||
models.Exercise.owner_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not exercise:
|
||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||
return exercise
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.ExerciseOut)
|
||||
def create_exercise(
|
||||
exercise: schemas.ExerciseCreate,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
sync_id = exercise.sync_id or new_sync_id()
|
||||
timestamp = get_current_timestamp()
|
||||
|
||||
existing = db.query(models.Exercise).filter(models.Exercise.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id is not None and existing.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=409, detail="Exercise sync_id belongs to another user")
|
||||
|
||||
if existing.owner_id == current_user.id:
|
||||
existing.name = exercise.name
|
||||
existing.workout_type_id = exercise.workout_type_id
|
||||
existing.updated_at = timestamp
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
# Глобальное упражнение — не даём пользователю его менять, просто возвращаем
|
||||
return existing
|
||||
|
||||
by_name = find_exercise_by_name(db, current_user, exercise.name)
|
||||
if by_name:
|
||||
if by_name.owner_id == current_user.id:
|
||||
by_name.sync_id = by_name.sync_id or sync_id
|
||||
by_name.workout_type_id = exercise.workout_type_id
|
||||
by_name.updated_at = timestamp
|
||||
db.commit()
|
||||
db.refresh(by_name)
|
||||
return by_name
|
||||
|
||||
# Нашли глобальное с таким же именем — используем его
|
||||
return by_name
|
||||
|
||||
db_exercise = models.Exercise(
|
||||
sync_id=sync_id,
|
||||
owner_id=current_user.id,
|
||||
name=exercise.name.strip(),
|
||||
workout_type_id=exercise.workout_type_id,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(db_exercise)
|
||||
db.commit()
|
||||
db.refresh(db_exercise)
|
||||
return db_exercise
|
||||
|
||||
@router.get("/", response_model=List[schemas.Exercise])
|
||||
def list_exercises(workout_type_id: int = None, db: Session = Depends(database.get_db)):
|
||||
query = db.query(models.Exercise)
|
||||
|
||||
@router.get("/", response_model=List[schemas.ExerciseOut])
|
||||
def list_exercises(
|
||||
workout_type_id: Optional[int] = None,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
query = db.query(models.Exercise).filter(
|
||||
or_(
|
||||
models.Exercise.owner_id == current_user.id,
|
||||
models.Exercise.owner_id.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
if workout_type_id:
|
||||
query = query.filter(models.Exercise.workout_type_id == workout_type_id)
|
||||
|
||||
return query.order_by(models.Exercise.name).all()
|
||||
|
||||
@router.get("/{exercise_id}", response_model=schemas.Exercise)
|
||||
def get_exercise(exercise_id: int, db: Session = Depends(database.get_db)):
|
||||
db_exercise = db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first()
|
||||
if not db_exercise:
|
||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||
return db_exercise
|
||||
|
||||
@router.put("/{exercise_id}", response_model=schemas.Exercise)
|
||||
def update_exercise(exercise_id: int, exercise: schemas.ExerciseUpdate, db: Session = Depends(database.get_db)):
|
||||
db_exercise = db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first()
|
||||
if not db_exercise:
|
||||
@router.get("/history/{exercise_name}")
|
||||
def get_exercise_history(
|
||||
exercise_name: str,
|
||||
client_id: Optional[int] = None,
|
||||
limit: int = 10,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
"""
|
||||
Возвращает историю упражнения для текущего пользователя.
|
||||
Если указан client_id — только тренировки этого клиента.
|
||||
"""
|
||||
normalized = normalize_name(exercise_name)
|
||||
|
||||
workout_query = (
|
||||
db.query(models.Workout.id)
|
||||
.join(models.WorkoutExercise, models.WorkoutExercise.workout_id == models.Workout.id)
|
||||
.filter(
|
||||
models.Workout.owner_id == current_user.id,
|
||||
models.Workout.end_time.isnot(None),
|
||||
func.lower(models.WorkoutExercise.exercise_name) == normalized,
|
||||
)
|
||||
)
|
||||
|
||||
if client_id:
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.id == client_id,
|
||||
models.Client.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
workout_query = workout_query.filter(models.Workout.client_id == client_id)
|
||||
|
||||
workout_ids = [
|
||||
row[0]
|
||||
for row in workout_query.order_by(models.Workout.start_time.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
if not workout_ids:
|
||||
return []
|
||||
|
||||
workouts = (
|
||||
db.query(models.Workout)
|
||||
.options(
|
||||
joinedload(models.Workout.workout_exercises)
|
||||
.joinedload(models.WorkoutExercise.sets)
|
||||
.joinedload(models.Set.dropset_items)
|
||||
)
|
||||
.filter(models.Workout.id.in_(workout_ids))
|
||||
.order_by(models.Workout.start_time.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
|
||||
for workout in workouts:
|
||||
for we in workout.workout_exercises or []:
|
||||
if normalize_name(we.exercise_name) != normalized:
|
||||
continue
|
||||
|
||||
sets_payload = []
|
||||
for s in we.sets or []:
|
||||
sets_payload.append(
|
||||
{
|
||||
"is_dropset": bool(s.is_dropset),
|
||||
"weight": s.weight,
|
||||
"reps": s.reps,
|
||||
"rating": s.rating,
|
||||
"notes": s.notes or "",
|
||||
"timestamp": s.timestamp,
|
||||
"dropset_items": [
|
||||
{
|
||||
"weight": di.weight,
|
||||
"reps": di.reps,
|
||||
"sort_order": di.sort_order,
|
||||
}
|
||||
for di in (s.dropset_items or [])
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
if sets_payload:
|
||||
result.append(
|
||||
{
|
||||
"workout_sync_id": workout.sync_id,
|
||||
"date": workout.start_time,
|
||||
"client_id": workout.client_id,
|
||||
"exercise_name": we.exercise_name,
|
||||
"sets": sets_payload,
|
||||
}
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{exercise_id}", response_model=schemas.ExerciseOut)
|
||||
def get_exercise(
|
||||
exercise_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
exercise = (
|
||||
db.query(models.Exercise)
|
||||
.filter(
|
||||
or_(
|
||||
models.Exercise.id == exercise_id,
|
||||
),
|
||||
or_(
|
||||
models.Exercise.owner_id == current_user.id,
|
||||
models.Exercise.owner_id.is_(None),
|
||||
),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not exercise:
|
||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||
return exercise
|
||||
|
||||
|
||||
@router.put("/{exercise_id}", response_model=schemas.ExerciseOut)
|
||||
def update_exercise(
|
||||
exercise_id: int,
|
||||
exercise: schemas.ExerciseUpdate,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
db_exercise = get_owned_exercise(db, exercise_id, current_user)
|
||||
|
||||
update_data = exercise.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
@@ -57,43 +292,14 @@ def update_exercise(exercise_id: int, exercise: schemas.ExerciseUpdate, db: Sess
|
||||
db.refresh(db_exercise)
|
||||
return db_exercise
|
||||
|
||||
|
||||
@router.delete("/{exercise_id}")
|
||||
def delete_exercise(exercise_id: int, db: Session = Depends(database.get_db)):
|
||||
db_exercise = db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first()
|
||||
if not db_exercise:
|
||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
||||
def delete_exercise(
|
||||
exercise_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
db_exercise = get_owned_exercise(db, exercise_id, current_user)
|
||||
db.delete(db_exercise)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
@router.get("/history/{exercise_name}")
|
||||
def get_exercise_history(exercise_name: str, client_id: int = None, limit: int = 10, db: Session = Depends(database.get_db)):
|
||||
"""Получить историю упражнения для подсказок на фронте"""
|
||||
query = db.query(
|
||||
models.Workout.start_time,
|
||||
models.Set.weight,
|
||||
models.Set.reps,
|
||||
models.Set.is_dropset
|
||||
).join(
|
||||
models.WorkoutExercise, models.WorkoutExercise.workout_id == models.Workout.id
|
||||
).join(
|
||||
models.Set, models.Set.workout_exercise_id == models.WorkoutExercise.id
|
||||
).filter(
|
||||
models.WorkoutExercise.exercise_name == exercise_name,
|
||||
models.Workout.end_time.isnot(None)
|
||||
)
|
||||
|
||||
if client_id:
|
||||
query = query.filter(models.Workout.client_id == client_id)
|
||||
|
||||
results = query.order_by(models.Workout.start_time.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"date": r.start_time,
|
||||
"weight": r.weight,
|
||||
"reps": r.reps,
|
||||
"is_dropset": bool(r.is_dropset)
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
+231
-46
@@ -1,99 +1,284 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
import auth
|
||||
import database
|
||||
import models
|
||||
import schemas
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(tags=["metrics"])
|
||||
|
||||
def get_current_timestamp():
|
||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
||||
router = APIRouter(
|
||||
tags=["metrics"],
|
||||
dependencies=[Depends(auth.get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
def get_current_timestamp() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||
|
||||
|
||||
def new_sync_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def get_owned_client(
|
||||
db: Session,
|
||||
client_id: int,
|
||||
user: models.User,
|
||||
) -> models.Client:
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.id == client_id,
|
||||
models.Client.owner_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
return client
|
||||
|
||||
|
||||
def resolve_metric_type(
|
||||
db: Session,
|
||||
value: schemas.BodyMetricValueCreate,
|
||||
) -> Optional[models.MetricType]:
|
||||
if value.metric_type_id:
|
||||
return db.get(models.MetricType, value.metric_type_id)
|
||||
|
||||
if value.metric_type_sync_id:
|
||||
return (
|
||||
db.query(models.MetricType)
|
||||
.filter(models.MetricType.sync_id == value.metric_type_sync_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# METRIC TYPES
|
||||
# ============================================================
|
||||
@router.post("/metric-types/", response_model=schemas.MetricType)
|
||||
def create_metric_type(metric_type: schemas.MetricTypeCreate, db: Session = Depends(database.get_db)):
|
||||
db_mt = db.query(models.MetricType).filter(models.MetricType.sync_id == metric_type.sync_id).first()
|
||||
if db_mt:
|
||||
raise HTTPException(status_code=400, detail="MetricType with this sync_id already exists")
|
||||
@router.post("/metric-types/", response_model=schemas.MetricTypeOut)
|
||||
def create_metric_type(
|
||||
metric_type: schemas.MetricTypeCreate,
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
sync_id = metric_type.sync_id or new_sync_id()
|
||||
|
||||
existing = db.query(models.MetricType).filter(models.MetricType.sync_id == sync_id).first()
|
||||
if existing:
|
||||
existing.name = metric_type.name
|
||||
existing.unit = metric_type.unit or existing.unit
|
||||
existing.category = metric_type.category or existing.category
|
||||
existing.sort_order = metric_type.sort_order if metric_type.sort_order is not None else existing.sort_order
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
db_mt = models.MetricType(
|
||||
**metric_type.dict(),
|
||||
created_at=get_current_timestamp()
|
||||
sync_id=sync_id,
|
||||
name=metric_type.name,
|
||||
unit=metric_type.unit or "см",
|
||||
category=metric_type.category or "body",
|
||||
sort_order=metric_type.sort_order or 0,
|
||||
created_at=get_current_timestamp(),
|
||||
)
|
||||
db.add(db_mt)
|
||||
db.commit()
|
||||
db.refresh(db_mt)
|
||||
return db_mt
|
||||
|
||||
@router.get("/metric-types/", response_model=List[schemas.MetricType])
|
||||
|
||||
@router.get("/metric-types/", response_model=List[schemas.MetricTypeOut])
|
||||
def list_metric_types(db: Session = Depends(database.get_db)):
|
||||
return db.query(models.MetricType).order_by(models.MetricType.sort_order).all()
|
||||
return db.query(models.MetricType).order_by(
|
||||
models.MetricType.sort_order,
|
||||
models.MetricType.name,
|
||||
).all()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BODY METRICS
|
||||
# ============================================================
|
||||
@router.post("/body-metrics/", response_model=schemas.BodyMetric)
|
||||
def create_body_metric(metric: schemas.BodyMetricCreate, db: Session = Depends(database.get_db)):
|
||||
db_metric = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == metric.sync_id).first()
|
||||
if db_metric:
|
||||
raise HTTPException(status_code=400, detail="BodyMetric with this sync_id already exists")
|
||||
@router.post("/body-metrics/", response_model=schemas.BodyMetricOut)
|
||||
def create_body_metric(
|
||||
metric: schemas.BodyMetricCreate,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
sync_id = metric.sync_id or new_sync_id()
|
||||
timestamp = get_current_timestamp()
|
||||
|
||||
existing = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=409, detail="BodyMetric sync_id belongs to another user")
|
||||
|
||||
existing.metric_date = metric.metric_date
|
||||
existing.notes = metric.notes or ""
|
||||
existing.client_id = metric.client_id
|
||||
|
||||
db.query(models.BodyMetricValue).filter(
|
||||
models.BodyMetricValue.body_metric_id == existing.id
|
||||
).delete()
|
||||
|
||||
for value in metric.values or []:
|
||||
mt = resolve_metric_type(db, value)
|
||||
if not mt:
|
||||
raise HTTPException(status_code=422, detail="Metric type not found")
|
||||
|
||||
db.add(
|
||||
models.BodyMetricValue(
|
||||
body_metric_id=existing.id,
|
||||
metric_type_id=mt.id,
|
||||
value=value.value,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
client_id = metric.client_id
|
||||
if client_id:
|
||||
get_owned_client(db, client_id, current_user)
|
||||
|
||||
db_metric = models.BodyMetric(
|
||||
sync_id=metric.sync_id,
|
||||
client_id=metric.client_id,
|
||||
sync_id=sync_id,
|
||||
owner_id=current_user.id,
|
||||
client_id=client_id,
|
||||
metric_date=metric.metric_date,
|
||||
notes=metric.notes,
|
||||
created_at=get_current_timestamp()
|
||||
notes=metric.notes or "",
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(db_metric)
|
||||
db.flush()
|
||||
|
||||
# Добавляем значения
|
||||
for value_data in metric.values or []:
|
||||
db_value = models.BodyMetricValue(
|
||||
body_metric_id=db_metric.id,
|
||||
metric_type_id=value_data.metric_type_id,
|
||||
value=value_data.value
|
||||
for value in metric.values or []:
|
||||
mt = resolve_metric_type(db, value)
|
||||
if not mt:
|
||||
raise HTTPException(status_code=422, detail="Metric type not found")
|
||||
|
||||
db.add(
|
||||
models.BodyMetricValue(
|
||||
body_metric_id=db_metric.id,
|
||||
metric_type_id=mt.id,
|
||||
value=value.value,
|
||||
)
|
||||
)
|
||||
db.add(db_value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_metric)
|
||||
return db_metric
|
||||
|
||||
@router.get("/body-metrics/", response_model=List[schemas.BodyMetric])
|
||||
def list_body_metrics(client_id: int = None, db: Session = Depends(database.get_db)):
|
||||
query = db.query(models.BodyMetric)
|
||||
|
||||
@router.get("/body-metrics/", response_model=List[schemas.BodyMetricOut])
|
||||
def list_body_metrics(
|
||||
client_id: Optional[int] = None,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
query = db.query(models.BodyMetric).options(
|
||||
joinedload(models.BodyMetric.values)
|
||||
).filter(
|
||||
models.BodyMetric.owner_id == current_user.id
|
||||
)
|
||||
|
||||
if client_id:
|
||||
get_owned_client(db, client_id, current_user)
|
||||
query = query.filter(models.BodyMetric.client_id == client_id)
|
||||
|
||||
return query.order_by(models.BodyMetric.metric_date.desc()).all()
|
||||
|
||||
|
||||
@router.get("/body-metrics/history")
|
||||
def get_metric_history(
|
||||
client_id: int,
|
||||
metric_type_id: int,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(database.get_db)
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
"""Получить историю конкретного замера для графиков"""
|
||||
results = db.query(
|
||||
models.BodyMetric.metric_date,
|
||||
models.BodyMetricValue.value
|
||||
).join(
|
||||
models.BodyMetricValue, models.BodyMetricValue.body_metric_id == models.BodyMetric.id
|
||||
).filter(
|
||||
models.BodyMetric.client_id == client_id,
|
||||
models.BodyMetricValue.metric_type_id == metric_type_id
|
||||
).order_by(models.BodyMetric.metric_date.desc()).limit(limit).all()
|
||||
get_owned_client(db, client_id, current_user)
|
||||
|
||||
metric_type = db.get(models.MetricType, metric_type_id)
|
||||
if not metric_type:
|
||||
raise HTTPException(status_code=404, detail="Metric type not found")
|
||||
|
||||
results = (
|
||||
db.query(
|
||||
models.BodyMetric.metric_date,
|
||||
models.BodyMetricValue.value,
|
||||
)
|
||||
.join(
|
||||
models.BodyMetricValue,
|
||||
models.BodyMetricValue.body_metric_id == models.BodyMetric.id,
|
||||
)
|
||||
.filter(
|
||||
models.BodyMetric.owner_id == current_user.id,
|
||||
models.BodyMetric.client_id == client_id,
|
||||
models.BodyMetricValue.metric_type_id == metric_type_id,
|
||||
)
|
||||
.order_by(models.BodyMetric.metric_date.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"date": r.metric_date,
|
||||
"value": r.value
|
||||
"value": r.value,
|
||||
"metric_type_id": metric_type_id,
|
||||
"metric_type_name": metric_type.name,
|
||||
"unit": metric_type.unit,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
|
||||
|
||||
@router.get("/body-metrics/{metric_id}", response_model=schemas.BodyMetricOut)
|
||||
def get_body_metric(
|
||||
metric_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
metric = (
|
||||
db.query(models.BodyMetric)
|
||||
.options(joinedload(models.BodyMetric.values))
|
||||
.filter(
|
||||
models.BodyMetric.id == metric_id,
|
||||
models.BodyMetric.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not metric:
|
||||
raise HTTPException(status_code=404, detail="Body metric not found")
|
||||
return metric
|
||||
|
||||
|
||||
@router.delete("/body-metrics/{metric_id}")
|
||||
def delete_body_metric(
|
||||
metric_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
metric = (
|
||||
db.query(models.BodyMetric)
|
||||
.filter(
|
||||
models.BodyMetric.id == metric_id,
|
||||
models.BodyMetric.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not metric:
|
||||
raise HTTPException(status_code=404, detail="Body metric not found")
|
||||
|
||||
db.delete(metric)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
+509
-144
@@ -1,186 +1,551 @@
|
||||
#!/usr/bin/env python3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import auth
|
||||
import database
|
||||
import models
|
||||
import schemas
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
||||
|
||||
def get_current_timestamp():
|
||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
||||
router = APIRouter(
|
||||
prefix="/sync",
|
||||
tags=["sync"],
|
||||
dependencies=[Depends(auth.get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_CLIENT_COLOR = "#bb86fc"
|
||||
|
||||
|
||||
def get_current_timestamp() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||
|
||||
|
||||
def new_sync_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def normalize_name(name: str) -> str:
|
||||
return (name or "").strip().lower()
|
||||
|
||||
|
||||
def resolve_workout_type_id(
|
||||
db: Session,
|
||||
workout_type_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
if not workout_type_id:
|
||||
return None
|
||||
|
||||
wt = db.get(models.WorkoutType, workout_type_id)
|
||||
return wt.id if wt else None
|
||||
|
||||
|
||||
def upsert_client(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
data: schemas.ClientCreate,
|
||||
timestamp: str,
|
||||
) -> Tuple[models.Client, str]:
|
||||
sync_id = data.sync_id or new_sync_id()
|
||||
|
||||
existing = db.query(models.Client).filter(models.Client.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id != user.id:
|
||||
raise HTTPException(status_code=409, detail="Client sync_id belongs to another user")
|
||||
|
||||
existing.full_name = data.full_name
|
||||
existing.birth_date = data.birth_date
|
||||
existing.goal = data.goal or ""
|
||||
existing.notes = data.notes or ""
|
||||
existing.color = data.color or existing.color or DEFAULT_CLIENT_COLOR
|
||||
existing.is_active = data.is_active if data.is_active is not None else existing.is_active
|
||||
existing.updated_at = timestamp
|
||||
|
||||
db.flush()
|
||||
return existing, "updated"
|
||||
|
||||
obj = models.Client(
|
||||
sync_id=sync_id,
|
||||
owner_id=user.id,
|
||||
full_name=data.full_name,
|
||||
birth_date=data.birth_date,
|
||||
goal=data.goal or "",
|
||||
notes=data.notes or "",
|
||||
color=data.color or DEFAULT_CLIENT_COLOR,
|
||||
is_active=data.is_active if data.is_active is not None else 1,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj, "created"
|
||||
|
||||
|
||||
def find_exercise_by_name(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
name: str,
|
||||
) -> Optional[models.Exercise]:
|
||||
n = normalize_name(name)
|
||||
if not n:
|
||||
return None
|
||||
|
||||
owned = (
|
||||
db.query(models.Exercise)
|
||||
.filter(
|
||||
models.Exercise.owner_id == user.id,
|
||||
func.lower(models.Exercise.name) == n,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if owned:
|
||||
return owned
|
||||
|
||||
return (
|
||||
db.query(models.Exercise)
|
||||
.filter(
|
||||
models.Exercise.owner_id.is_(None),
|
||||
func.lower(models.Exercise.name) == n,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def upsert_exercise(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
data: schemas.ExerciseCreate,
|
||||
timestamp: str,
|
||||
) -> Tuple[models.Exercise, str]:
|
||||
sync_id = data.sync_id or new_sync_id()
|
||||
workout_type_id = resolve_workout_type_id(db, data.workout_type_id)
|
||||
|
||||
existing = db.query(models.Exercise).filter(models.Exercise.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id is not None and existing.owner_id != user.id:
|
||||
raise HTTPException(status_code=409, detail="Exercise sync_id belongs to another user")
|
||||
|
||||
if existing.owner_id == user.id:
|
||||
existing.name = (data.name or existing.name).strip()
|
||||
existing.workout_type_id = workout_type_id
|
||||
existing.updated_at = timestamp
|
||||
db.flush()
|
||||
return existing, "updated"
|
||||
|
||||
# Глобальное упражнение: не обновляем, просто переиспользуем
|
||||
return existing, "global"
|
||||
|
||||
by_name = find_exercise_by_name(db, user, data.name)
|
||||
if by_name:
|
||||
if by_name.owner_id == user.id:
|
||||
by_name.sync_id = by_name.sync_id or sync_id
|
||||
by_name.name = (data.name or by_name.name).strip()
|
||||
by_name.workout_type_id = workout_type_id
|
||||
by_name.updated_at = timestamp
|
||||
db.flush()
|
||||
return by_name, "updated"
|
||||
|
||||
# Нашли глобальное с тем же именем
|
||||
return by_name, "global"
|
||||
|
||||
obj = models.Exercise(
|
||||
sync_id=sync_id,
|
||||
owner_id=user.id,
|
||||
name=(data.name or "Без названия").strip(),
|
||||
workout_type_id=workout_type_id,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj, "created"
|
||||
|
||||
|
||||
def resolve_client_id_from_sync_id(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
client_sync_id: Optional[str],
|
||||
client_map: Dict[str, int],
|
||||
) -> Optional[int]:
|
||||
if not client_sync_id:
|
||||
return None
|
||||
|
||||
if client_sync_id in client_map:
|
||||
return client_map[client_sync_id]
|
||||
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.sync_id == client_sync_id,
|
||||
models.Client.owner_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return client.id if client else None
|
||||
|
||||
|
||||
def resolve_exercise_id(
|
||||
db: Session,
|
||||
user: models.User,
|
||||
exercise_sync_id: Optional[str],
|
||||
exercise_name: Optional[str],
|
||||
exercise_map: Dict[str, int],
|
||||
) -> Optional[int]:
|
||||
if exercise_sync_id:
|
||||
if exercise_sync_id in exercise_map:
|
||||
return exercise_map[exercise_sync_id]
|
||||
|
||||
ex = db.query(models.Exercise).filter(models.Exercise.sync_id == exercise_sync_id).first()
|
||||
if ex and (ex.owner_id is None or ex.owner_id == user.id):
|
||||
return ex.id
|
||||
|
||||
if exercise_name:
|
||||
ex = find_exercise_by_name(db, user, exercise_name)
|
||||
if ex:
|
||||
return ex.id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def create_set_from_payload(
|
||||
db: Session,
|
||||
workout_exercise_id: int,
|
||||
data: schemas.SetCreate,
|
||||
timestamp: str,
|
||||
warnings: list,
|
||||
) -> models.Set:
|
||||
is_dropset = 1 if data.is_dropset else 0
|
||||
|
||||
if is_dropset:
|
||||
if data.weight is not None or data.reps is not None:
|
||||
warnings.append("Dropset top-level weight/reps ignored")
|
||||
data.weight = None
|
||||
data.reps = None
|
||||
|
||||
if not data.dropset_items:
|
||||
raise HTTPException(status_code=422, detail="Dropset must have items")
|
||||
else:
|
||||
if data.weight is None or data.reps is None:
|
||||
raise HTTPException(status_code=422, detail="Regular set must have weight and reps")
|
||||
|
||||
db_set = models.Set(
|
||||
sync_id=data.sync_id or new_sync_id(),
|
||||
workout_exercise_id=workout_exercise_id,
|
||||
is_dropset=is_dropset,
|
||||
weight=None if is_dropset else float(data.weight),
|
||||
reps=None if is_dropset else int(data.reps),
|
||||
rating=data.rating or 3,
|
||||
notes=data.notes or "",
|
||||
sort_order=data.sort_order or 0,
|
||||
timestamp=data.timestamp or timestamp,
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(db_set)
|
||||
db.flush()
|
||||
|
||||
if is_dropset:
|
||||
for idx, item in enumerate(data.dropset_items or []):
|
||||
db.add(
|
||||
models.DropsetItem(
|
||||
set_id=db_set.id,
|
||||
weight=float(item.weight),
|
||||
reps=int(item.reps),
|
||||
sort_order=item.sort_order if item.sort_order is not None else idx,
|
||||
)
|
||||
)
|
||||
|
||||
return db_set
|
||||
|
||||
|
||||
def create_cardio_from_payload(
|
||||
db: Session,
|
||||
workout_id: int,
|
||||
data: schemas.CardioCreate,
|
||||
timestamp: str,
|
||||
) -> models.Cardio:
|
||||
db_cardio = models.Cardio(
|
||||
sync_id=data.sync_id or new_sync_id(),
|
||||
workout_id=workout_id,
|
||||
cardio_type=data.cardio_type,
|
||||
purpose=data.purpose,
|
||||
duration=data.duration,
|
||||
distance=data.distance,
|
||||
speed=data.speed,
|
||||
incline=data.incline,
|
||||
resistance=data.resistance,
|
||||
notes=data.notes or "",
|
||||
sort_order=data.sort_order or 0,
|
||||
created_at=timestamp,
|
||||
)
|
||||
db.add(db_cardio)
|
||||
return db_cardio
|
||||
|
||||
|
||||
def resolve_metric_type_for_sync(
|
||||
db: Session,
|
||||
value: schemas.BodyMetricValueCreate,
|
||||
) -> Optional[models.MetricType]:
|
||||
if value.metric_type_id:
|
||||
return db.get(models.MetricType, value.metric_type_id)
|
||||
|
||||
if value.metric_type_sync_id:
|
||||
return (
|
||||
db.query(models.MetricType)
|
||||
.filter(models.MetricType.sync_id == value.metric_type_sync_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.SyncResponse)
|
||||
def sync_data(payload: schemas.SyncPayload, db: Session = Depends(database.get_db)):
|
||||
"""
|
||||
Endpoint для синхронизации офлайн-очереди с клиента.
|
||||
Использует sync_id для идемпотентности — если запись с таким sync_id уже есть, пропускаем.
|
||||
"""
|
||||
def sync_data(
|
||||
payload: schemas.SyncPayload,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
timestamp = get_current_timestamp()
|
||||
warnings = []
|
||||
|
||||
created = {
|
||||
"clients": 0,
|
||||
"workout_types": 0,
|
||||
"exercises": 0,
|
||||
"workouts": 0,
|
||||
"metric_types": 0,
|
||||
"body_metrics": 0
|
||||
"body_metrics": 0,
|
||||
}
|
||||
updated = {
|
||||
"clients": 0,
|
||||
"exercises": 0,
|
||||
"workouts": 0,
|
||||
"body_metrics": 0,
|
||||
}
|
||||
updated = {k: 0 for k in created}
|
||||
|
||||
# Синхронизация клиентов
|
||||
client_map: Dict[str, int] = {}
|
||||
exercise_map: Dict[str, int] = {}
|
||||
|
||||
# ========================================================
|
||||
# 1. Clients
|
||||
# ========================================================
|
||||
for client_data in payload.clients or []:
|
||||
existing = db.query(models.Client).filter(models.Client.sync_id == client_data.sync_id).first()
|
||||
if not existing:
|
||||
db_client = models.Client(
|
||||
**client_data.dict(),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp
|
||||
)
|
||||
db.add(db_client)
|
||||
obj, status = upsert_client(db, current_user, client_data, timestamp)
|
||||
client_map[obj.sync_id] = obj.id
|
||||
|
||||
if status == "created":
|
||||
created["clients"] += 1
|
||||
elif status == "updated":
|
||||
updated["clients"] += 1
|
||||
|
||||
# Синхронизация типов тренировок
|
||||
for wt_data in payload.workout_types or []:
|
||||
existing = db.query(models.WorkoutType).filter(models.WorkoutType.sync_id == wt_data.sync_id).first()
|
||||
if not existing:
|
||||
db_wt = models.WorkoutType(
|
||||
**wt_data.dict(),
|
||||
created_at=timestamp
|
||||
)
|
||||
db.add(db_wt)
|
||||
created["workout_types"] += 1
|
||||
# ========================================================
|
||||
# 2. Exercises
|
||||
# ========================================================
|
||||
for exercise_data in payload.exercises or []:
|
||||
obj, status = upsert_exercise(db, current_user, exercise_data, timestamp)
|
||||
exercise_map[obj.sync_id] = obj.id
|
||||
|
||||
# Синхронизация упражнений
|
||||
for ex_data in payload.exercises or []:
|
||||
existing = db.query(models.Exercise).filter(models.Exercise.sync_id == ex_data.sync_id).first()
|
||||
if not existing:
|
||||
db_ex = models.Exercise(
|
||||
**ex_data.dict(),
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp
|
||||
)
|
||||
db.add(db_ex)
|
||||
if status == "created":
|
||||
created["exercises"] += 1
|
||||
elif status == "updated":
|
||||
updated["exercises"] += 1
|
||||
|
||||
# Синхронизация типов замеров
|
||||
for mt_data in payload.metric_types or []:
|
||||
existing = db.query(models.MetricType).filter(models.MetricType.sync_id == mt_data.sync_id).first()
|
||||
if not existing:
|
||||
db_mt = models.MetricType(
|
||||
**mt_data.dict(),
|
||||
created_at=timestamp
|
||||
)
|
||||
db.add(db_mt)
|
||||
created["metric_types"] += 1
|
||||
|
||||
# Синхронизация тренировок (с упражнениями и подходами)
|
||||
# ========================================================
|
||||
# 3. Workouts
|
||||
# ========================================================
|
||||
for workout_data in payload.workouts or []:
|
||||
existing = db.query(models.Workout).filter(models.Workout.sync_id == workout_data.sync_id).first()
|
||||
if not existing:
|
||||
db_workout = models.Workout(
|
||||
sync_id=workout_data.sync_id,
|
||||
client_id=workout_data.client_id,
|
||||
workout_type_id=workout_data.workout_type_id,
|
||||
start_time=workout_data.start_time,
|
||||
end_time=workout_data.end_time,
|
||||
notes=workout_data.notes,
|
||||
sync_id = workout_data.sync_id or new_sync_id()
|
||||
|
||||
existing = db.query(models.Workout).filter(models.Workout.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=409, detail="Workout sync_id belongs to another user")
|
||||
|
||||
existing.start_time = workout_data.start_time or existing.start_time
|
||||
existing.end_time = workout_data.end_time if workout_data.end_time is not None else existing.end_time
|
||||
existing.notes = workout_data.notes if workout_data.notes is not None else existing.notes
|
||||
existing.workout_type_id = resolve_workout_type_id(db, workout_data.workout_type_id)
|
||||
existing.synced = 1
|
||||
existing.updated_at = timestamp
|
||||
|
||||
updated["workouts"] += 1
|
||||
continue
|
||||
|
||||
client_id = resolve_client_id_from_sync_id(
|
||||
db,
|
||||
current_user,
|
||||
workout_data.client_sync_id,
|
||||
client_map,
|
||||
)
|
||||
|
||||
if workout_data.client_id and not client_id:
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.id == workout_data.client_id,
|
||||
models.Client.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
client_id = client.id if client else None
|
||||
|
||||
if workout_data.client_sync_id and not client_id:
|
||||
warnings.append(
|
||||
f"Client sync_id {workout_data.client_sync_id} not found for workout {sync_id}"
|
||||
)
|
||||
|
||||
db_workout = models.Workout(
|
||||
sync_id=sync_id,
|
||||
owner_id=current_user.id,
|
||||
client_id=client_id,
|
||||
workout_type_id=resolve_workout_type_id(db, workout_data.workout_type_id),
|
||||
start_time=workout_data.start_time,
|
||||
end_time=workout_data.end_time,
|
||||
notes=workout_data.notes or "",
|
||||
synced=1,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(db_workout)
|
||||
db.flush()
|
||||
created["workouts"] += 1
|
||||
|
||||
for we_data in workout_data.workout_exercises or []:
|
||||
exercise_id = resolve_exercise_id(
|
||||
db,
|
||||
current_user,
|
||||
we_data.exercise_sync_id,
|
||||
we_data.exercise_name,
|
||||
exercise_map,
|
||||
)
|
||||
|
||||
db_we = models.WorkoutExercise(
|
||||
sync_id=we_data.sync_id or new_sync_id(),
|
||||
workout_id=db_workout.id,
|
||||
exercise_id=exercise_id,
|
||||
exercise_name=we_data.exercise_name,
|
||||
sort_order=we_data.sort_order or 0,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp
|
||||
)
|
||||
db.add(db_workout)
|
||||
|
||||
db.add(db_we)
|
||||
db.flush()
|
||||
|
||||
# Упражнения
|
||||
for we_data in workout_data.workout_exercises or []:
|
||||
db_we = models.WorkoutExercise(
|
||||
sync_id=we_data.sync_id,
|
||||
workout_id=db_workout.id,
|
||||
exercise_id=we_data.exercise_id,
|
||||
exercise_name=we_data.exercise_name,
|
||||
sort_order=we_data.sort_order,
|
||||
created_at=timestamp
|
||||
)
|
||||
db.add(db_we)
|
||||
db.flush()
|
||||
for set_data in we_data.sets or []:
|
||||
create_set_from_payload(db, db_we.id, set_data, timestamp, warnings)
|
||||
|
||||
# Подходы
|
||||
for set_data in we_data.sets or []:
|
||||
db_set = models.Set(
|
||||
sync_id=set_data.sync_id,
|
||||
workout_exercise_id=db_we.id,
|
||||
is_dropset=set_data.is_dropset,
|
||||
weight=set_data.weight,
|
||||
reps=set_data.reps,
|
||||
rating=set_data.rating,
|
||||
notes=set_data.notes,
|
||||
sort_order=set_data.sort_order,
|
||||
timestamp=set_data.timestamp,
|
||||
created_at=timestamp
|
||||
)
|
||||
db.add(db_set)
|
||||
db.flush()
|
||||
for cardio_data in workout_data.cardio_sessions or []:
|
||||
create_cardio_from_payload(db, db_workout.id, cardio_data, timestamp)
|
||||
|
||||
# Дроп-сет элементы
|
||||
if set_data.is_dropset:
|
||||
for item_data in set_data.dropset_items or []:
|
||||
db_item = models.DropsetItem(
|
||||
set_id=db_set.id,
|
||||
weight=item_data.weight,
|
||||
reps=item_data.reps,
|
||||
sort_order=item_data.sort_order
|
||||
)
|
||||
db.add(db_item)
|
||||
|
||||
# Кардио
|
||||
for cardio_data in workout_data.cardio_sessions or []:
|
||||
db_cardio = models.Cardio(
|
||||
sync_id=cardio_data.sync_id,
|
||||
workout_id=db_workout.id,
|
||||
cardio_type=cardio_data.cardio_type,
|
||||
purpose=cardio_data.purpose,
|
||||
duration=cardio_data.duration,
|
||||
distance=cardio_data.distance,
|
||||
speed=cardio_data.speed,
|
||||
incline=cardio_data.incline,
|
||||
resistance=cardio_data.resistance,
|
||||
notes=cardio_data.notes,
|
||||
sort_order=cardio_data.sort_order,
|
||||
created_at=timestamp
|
||||
)
|
||||
db.add(db_cardio)
|
||||
|
||||
created["workouts"] += 1
|
||||
|
||||
# Синхронизация замеров тела
|
||||
# ========================================================
|
||||
# 4. Body metrics
|
||||
# ========================================================
|
||||
for metric_data in payload.body_metrics or []:
|
||||
existing = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == metric_data.sync_id).first()
|
||||
if not existing:
|
||||
db_metric = models.BodyMetric(
|
||||
sync_id=metric_data.sync_id,
|
||||
client_id=metric_data.client_id,
|
||||
metric_date=metric_data.metric_date,
|
||||
notes=metric_data.notes,
|
||||
created_at=timestamp
|
||||
sync_id = metric_data.sync_id or new_sync_id()
|
||||
|
||||
existing = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=409, detail="BodyMetric sync_id belongs to another user")
|
||||
|
||||
existing.metric_date = metric_data.metric_date
|
||||
existing.notes = metric_data.notes or ""
|
||||
|
||||
client_id = resolve_client_id_from_sync_id(
|
||||
db,
|
||||
current_user,
|
||||
metric_data.client_sync_id,
|
||||
client_map,
|
||||
)
|
||||
db.add(db_metric)
|
||||
db.flush()
|
||||
if metric_data.client_id and not client_id:
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.id == metric_data.client_id,
|
||||
models.Client.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
client_id = client.id if client else None
|
||||
|
||||
existing.client_id = client_id
|
||||
|
||||
db.query(models.BodyMetricValue).filter(
|
||||
models.BodyMetricValue.body_metric_id == existing.id
|
||||
).delete()
|
||||
|
||||
for value_data in metric_data.values or []:
|
||||
db_value = models.BodyMetricValue(
|
||||
body_metric_id=db_metric.id,
|
||||
metric_type_id=value_data.metric_type_id,
|
||||
value=value_data.value
|
||||
)
|
||||
db.add(db_value)
|
||||
mt = resolve_metric_type_for_sync(db, value_data)
|
||||
if not mt:
|
||||
warnings.append("Body metric value skipped: metric type not found")
|
||||
continue
|
||||
|
||||
created["body_metrics"] += 1
|
||||
db.add(
|
||||
models.BodyMetricValue(
|
||||
body_metric_id=existing.id,
|
||||
metric_type_id=mt.id,
|
||||
value=float(value_data.value),
|
||||
)
|
||||
)
|
||||
|
||||
updated["body_metrics"] += 1
|
||||
continue
|
||||
|
||||
client_id = resolve_client_id_from_sync_id(
|
||||
db,
|
||||
current_user,
|
||||
metric_data.client_sync_id,
|
||||
client_map,
|
||||
)
|
||||
if metric_data.client_id and not client_id:
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.id == metric_data.client_id,
|
||||
models.Client.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
client_id = client.id if client else None
|
||||
|
||||
db_metric = models.BodyMetric(
|
||||
sync_id=sync_id,
|
||||
owner_id=current_user.id,
|
||||
client_id=client_id,
|
||||
metric_date=metric_data.metric_date,
|
||||
notes=metric_data.notes or "",
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(db_metric)
|
||||
db.flush()
|
||||
created["body_metrics"] += 1
|
||||
|
||||
for value_data in metric_data.values or []:
|
||||
mt = resolve_metric_type_for_sync(db, value_data)
|
||||
if not mt:
|
||||
warnings.append("Body metric value skipped: metric type not found")
|
||||
continue
|
||||
|
||||
db.add(
|
||||
models.BodyMetricValue(
|
||||
body_metric_id=db_metric.id,
|
||||
metric_type_id=mt.id,
|
||||
value=float(value_data.value),
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
return schemas.SyncResponse(
|
||||
status="success",
|
||||
created=created,
|
||||
updated=updated
|
||||
updated=updated,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
import auth
|
||||
import database
|
||||
import models
|
||||
import schemas
|
||||
from datetime import datetime
|
||||
import auth
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/workout-types",
|
||||
@@ -13,24 +18,45 @@ router = APIRouter(
|
||||
dependencies=[Depends(auth.get_current_user)],
|
||||
)
|
||||
|
||||
def get_current_timestamp():
|
||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
||||
|
||||
@router.post("/", response_model=schemas.WorkoutType)
|
||||
def create_workout_type(wt: schemas.WorkoutTypeCreate, db: Session = Depends(database.get_db)):
|
||||
db_wt = db.query(models.WorkoutType).filter(models.WorkoutType.sync_id == wt.sync_id).first()
|
||||
if db_wt:
|
||||
raise HTTPException(status_code=400, detail="WorkoutType with this sync_id already exists")
|
||||
def get_current_timestamp() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||
|
||||
|
||||
def new_sync_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.WorkoutTypeOut)
|
||||
def create_workout_type(
|
||||
wt: schemas.WorkoutTypeCreate,
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
sync_id = wt.sync_id or new_sync_id()
|
||||
|
||||
existing = db.query(models.WorkoutType).filter(models.WorkoutType.sync_id == sync_id).first()
|
||||
if existing:
|
||||
existing.name = wt.name
|
||||
existing.sort_order = wt.sort_order if wt.sort_order is not None else existing.sort_order
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
db_wt = models.WorkoutType(
|
||||
**wt.dict(),
|
||||
created_at=get_current_timestamp()
|
||||
sync_id=sync_id,
|
||||
name=wt.name,
|
||||
sort_order=wt.sort_order or 0,
|
||||
created_at=get_current_timestamp(),
|
||||
)
|
||||
db.add(db_wt)
|
||||
db.commit()
|
||||
db.refresh(db_wt)
|
||||
return db_wt
|
||||
|
||||
@router.get("/", response_model=List[schemas.WorkoutType])
|
||||
|
||||
@router.get("/", response_model=List[schemas.WorkoutTypeOut])
|
||||
def list_workout_types(db: Session = Depends(database.get_db)):
|
||||
return db.query(models.WorkoutType).order_by(models.WorkoutType.sort_order).all()
|
||||
return db.query(models.WorkoutType).order_by(
|
||||
models.WorkoutType.sort_order,
|
||||
models.WorkoutType.name,
|
||||
).all()
|
||||
|
||||
+216
-74
@@ -1,82 +1,191 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from typing import List
|
||||
|
||||
import auth
|
||||
import database
|
||||
import models
|
||||
import schemas
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix="/workouts", tags=["workouts"])
|
||||
|
||||
def get_current_timestamp():
|
||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
||||
router = APIRouter(
|
||||
prefix="/workouts",
|
||||
tags=["workouts"],
|
||||
dependencies=[Depends(auth.get_current_user)],
|
||||
)
|
||||
|
||||
@router.post("/", response_model=schemas.Workout)
|
||||
def create_workout(workout: schemas.WorkoutCreate, db: Session = Depends(database.get_db)):
|
||||
# Проверяем уникальность sync_id
|
||||
db_workout = db.query(models.Workout).filter(models.Workout.sync_id == workout.sync_id).first()
|
||||
if db_workout:
|
||||
raise HTTPException(status_code=400, detail="Workout with this sync_id already exists")
|
||||
|
||||
def get_current_timestamp() -> str:
|
||||
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||
|
||||
|
||||
def new_sync_id() -> str:
|
||||
return uuid.uuid4().hex
|
||||
|
||||
|
||||
def get_owned_workout(
|
||||
db: Session,
|
||||
workout_id: int,
|
||||
user: models.User,
|
||||
) -> models.Workout:
|
||||
workout = (
|
||||
db.query(models.Workout)
|
||||
.filter(
|
||||
models.Workout.id == workout_id,
|
||||
models.Workout.owner_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
return workout
|
||||
|
||||
|
||||
def validate_client(
|
||||
db: Session,
|
||||
client_id: Optional[int],
|
||||
user: models.User,
|
||||
) -> Optional[int]:
|
||||
if not client_id:
|
||||
return None
|
||||
|
||||
client = (
|
||||
db.query(models.Client)
|
||||
.filter(
|
||||
models.Client.id == client_id,
|
||||
models.Client.owner_id == user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not client:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
return client.id
|
||||
|
||||
|
||||
def validate_workout_type(
|
||||
db: Session,
|
||||
workout_type_id: Optional[int],
|
||||
) -> Optional[int]:
|
||||
if not workout_type_id:
|
||||
return None
|
||||
|
||||
wt = db.get(models.WorkoutType, workout_type_id)
|
||||
if not wt:
|
||||
raise HTTPException(status_code=422, detail="Workout type not found")
|
||||
return wt.id
|
||||
|
||||
|
||||
def create_set_record(
|
||||
db: Session,
|
||||
workout_exercise_id: int,
|
||||
set_data: schemas.SetCreate,
|
||||
timestamp: str,
|
||||
) -> models.Set:
|
||||
is_dropset = 1 if set_data.is_dropset else 0
|
||||
|
||||
if is_dropset:
|
||||
if set_data.weight is not None or set_data.reps is not None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Dropset must not have top-level weight/reps",
|
||||
)
|
||||
if not set_data.dropset_items:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Dropset must have at least one dropset item",
|
||||
)
|
||||
else:
|
||||
if set_data.weight is None or set_data.reps is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Regular set must have weight and reps",
|
||||
)
|
||||
|
||||
db_set = models.Set(
|
||||
sync_id=set_data.sync_id or new_sync_id(),
|
||||
workout_exercise_id=workout_exercise_id,
|
||||
is_dropset=is_dropset,
|
||||
weight=None if is_dropset else float(set_data.weight),
|
||||
reps=None if is_dropset else int(set_data.reps),
|
||||
rating=set_data.rating or 3,
|
||||
notes=set_data.notes or "",
|
||||
sort_order=set_data.sort_order or 0,
|
||||
timestamp=set_data.timestamp or timestamp,
|
||||
created_at=timestamp,
|
||||
)
|
||||
|
||||
db.add(db_set)
|
||||
db.flush()
|
||||
|
||||
if is_dropset:
|
||||
for idx, item in enumerate(set_data.dropset_items or []):
|
||||
db.add(
|
||||
models.DropsetItem(
|
||||
set_id=db_set.id,
|
||||
weight=float(item.weight),
|
||||
reps=int(item.reps),
|
||||
sort_order=item.sort_order if item.sort_order is not None else idx,
|
||||
)
|
||||
)
|
||||
|
||||
return db_set
|
||||
|
||||
|
||||
@router.post("/", response_model=schemas.WorkoutOut)
|
||||
def create_workout(
|
||||
workout: schemas.WorkoutCreate,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
timestamp = get_current_timestamp()
|
||||
sync_id = workout.sync_id or new_sync_id()
|
||||
|
||||
existing = db.query(models.Workout).filter(models.Workout.sync_id == sync_id).first()
|
||||
if existing:
|
||||
if existing.owner_id != current_user.id:
|
||||
raise HTTPException(status_code=409, detail="Workout sync_id belongs to another user")
|
||||
return existing
|
||||
|
||||
client_id = validate_client(db, workout.client_id, current_user)
|
||||
workout_type_id = validate_workout_type(db, workout.workout_type_id)
|
||||
|
||||
db_workout = models.Workout(
|
||||
sync_id=workout.sync_id,
|
||||
client_id=workout.client_id,
|
||||
workout_type_id=workout.workout_type_id,
|
||||
sync_id=sync_id,
|
||||
owner_id=current_user.id,
|
||||
client_id=client_id,
|
||||
workout_type_id=workout_type_id,
|
||||
start_time=workout.start_time,
|
||||
end_time=workout.end_time,
|
||||
notes=workout.notes,
|
||||
notes=workout.notes or "",
|
||||
synced=1,
|
||||
created_at=timestamp,
|
||||
updated_at=timestamp
|
||||
updated_at=timestamp,
|
||||
)
|
||||
db.add(db_workout)
|
||||
db.flush() # Получаем ID
|
||||
|
||||
# Добавляем упражнения
|
||||
db.add(db_workout)
|
||||
db.flush()
|
||||
|
||||
for we_data in workout.workout_exercises or []:
|
||||
db_we = models.WorkoutExercise(
|
||||
sync_id=we_data.sync_id,
|
||||
sync_id=we_data.sync_id or new_sync_id(),
|
||||
workout_id=db_workout.id,
|
||||
exercise_id=we_data.exercise_id,
|
||||
exercise_name=we_data.exercise_name,
|
||||
sort_order=we_data.sort_order,
|
||||
created_at=timestamp
|
||||
sort_order=we_data.sort_order or 0,
|
||||
created_at=timestamp,
|
||||
)
|
||||
db.add(db_we)
|
||||
db.flush()
|
||||
|
||||
# Добавляем подходы
|
||||
for set_data in we_data.sets or []:
|
||||
db_set = models.Set(
|
||||
sync_id=set_data.sync_id,
|
||||
workout_exercise_id=db_we.id,
|
||||
is_dropset=set_data.is_dropset,
|
||||
weight=set_data.weight,
|
||||
reps=set_data.reps,
|
||||
rating=set_data.rating,
|
||||
notes=set_data.notes,
|
||||
sort_order=set_data.sort_order,
|
||||
timestamp=set_data.timestamp,
|
||||
created_at=timestamp
|
||||
)
|
||||
db.add(db_set)
|
||||
db.flush()
|
||||
create_set_record(db, db_we.id, set_data, timestamp)
|
||||
|
||||
# Добавляем элементы дроп-сета
|
||||
if set_data.is_dropset:
|
||||
for item_data in set_data.dropset_items or []:
|
||||
db_item = models.DropsetItem(
|
||||
set_id=db_set.id,
|
||||
weight=item_data.weight,
|
||||
reps=item_data.reps,
|
||||
sort_order=item_data.sort_order
|
||||
)
|
||||
db.add(db_item)
|
||||
|
||||
# Добавляем кардио
|
||||
for cardio_data in workout.cardio_sessions or []:
|
||||
db_cardio = models.Cardio(
|
||||
sync_id=cardio_data.sync_id,
|
||||
sync_id=cardio_data.sync_id or new_sync_id(),
|
||||
workout_id=db_workout.id,
|
||||
cardio_type=cardio_data.cardio_type,
|
||||
purpose=cardio_data.purpose,
|
||||
@@ -85,9 +194,9 @@ def create_workout(workout: schemas.WorkoutCreate, db: Session = Depends(databas
|
||||
speed=cardio_data.speed,
|
||||
incline=cardio_data.incline,
|
||||
resistance=cardio_data.resistance,
|
||||
notes=cardio_data.notes,
|
||||
sort_order=cardio_data.sort_order,
|
||||
created_at=timestamp
|
||||
notes=cardio_data.notes or "",
|
||||
sort_order=cardio_data.sort_order or 0,
|
||||
created_at=timestamp,
|
||||
)
|
||||
db.add(db_cardio)
|
||||
|
||||
@@ -95,45 +204,75 @@ def create_workout(workout: schemas.WorkoutCreate, db: Session = Depends(databas
|
||||
db.refresh(db_workout)
|
||||
return db_workout
|
||||
|
||||
@router.get("/", response_model=List[schemas.Workout])
|
||||
|
||||
@router.get("/", response_model=List[schemas.WorkoutOut])
|
||||
def list_workouts(
|
||||
client_id: int = None,
|
||||
client_id: Optional[int] = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(database.get_db)
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
query = db.query(models.Workout).options(
|
||||
joinedload(models.Workout.workout_exercises)
|
||||
.joinedload(models.WorkoutExercise.sets)
|
||||
.joinedload(models.Set.dropset_items),
|
||||
joinedload(models.Workout.cardio_sessions)
|
||||
joinedload(models.Workout.cardio_sessions),
|
||||
).filter(
|
||||
models.Workout.owner_id == current_user.id
|
||||
)
|
||||
|
||||
if client_id:
|
||||
validate_client(db, client_id, current_user)
|
||||
query = query.filter(models.Workout.client_id == client_id)
|
||||
|
||||
return query.order_by(models.Workout.start_time.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
@router.get("/{workout_id}", response_model=schemas.Workout)
|
||||
def get_workout(workout_id: int, db: Session = Depends(database.get_db)):
|
||||
db_workout = db.query(models.Workout).options(
|
||||
joinedload(models.Workout.workout_exercises)
|
||||
.joinedload(models.WorkoutExercise.sets)
|
||||
.joinedload(models.Set.dropset_items),
|
||||
joinedload(models.Workout.cardio_sessions)
|
||||
).filter(models.Workout.id == workout_id).first()
|
||||
|
||||
@router.get("/{workout_id}", response_model=schemas.WorkoutOut)
|
||||
def get_workout(
|
||||
workout_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
db_workout = (
|
||||
db.query(models.Workout)
|
||||
.options(
|
||||
joinedload(models.Workout.workout_exercises)
|
||||
.joinedload(models.WorkoutExercise.sets)
|
||||
.joinedload(models.Set.dropset_items),
|
||||
joinedload(models.Workout.cardio_sessions),
|
||||
)
|
||||
.filter(
|
||||
models.Workout.id == workout_id,
|
||||
models.Workout.owner_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not db_workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
|
||||
return db_workout
|
||||
|
||||
@router.put("/{workout_id}", response_model=schemas.Workout)
|
||||
def update_workout(workout_id: int, workout: schemas.WorkoutUpdate, db: Session = Depends(database.get_db)):
|
||||
db_workout = db.query(models.Workout).filter(models.Workout.id == workout_id).first()
|
||||
if not db_workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
|
||||
@router.put("/{workout_id}", response_model=schemas.WorkoutOut)
|
||||
def update_workout(
|
||||
workout_id: int,
|
||||
workout: schemas.WorkoutUpdate,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
db_workout = get_owned_workout(db, workout_id, current_user)
|
||||
|
||||
update_data = workout.dict(exclude_unset=True)
|
||||
|
||||
if "workout_type_id" in update_data:
|
||||
update_data["workout_type_id"] = validate_workout_type(
|
||||
db,
|
||||
update_data["workout_type_id"],
|
||||
)
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(db_workout, key, value)
|
||||
|
||||
@@ -142,11 +281,14 @@ def update_workout(workout_id: int, workout: schemas.WorkoutUpdate, db: Session
|
||||
db.refresh(db_workout)
|
||||
return db_workout
|
||||
|
||||
|
||||
@router.delete("/{workout_id}")
|
||||
def delete_workout(workout_id: int, db: Session = Depends(database.get_db)):
|
||||
db_workout = db.query(models.Workout).filter(models.Workout.id == workout_id).first()
|
||||
if not db_workout:
|
||||
raise HTTPException(status_code=404, detail="Workout not found")
|
||||
def delete_workout(
|
||||
workout_id: int,
|
||||
current_user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(database.get_db),
|
||||
):
|
||||
db_workout = get_owned_workout(db, workout_id, current_user)
|
||||
db.delete(db_workout)
|
||||
db.commit()
|
||||
return {"status": "deleted"}
|
||||
|
||||
+145
-96
@@ -1,9 +1,9 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
|
||||
# ============================================================
|
||||
# USERS
|
||||
# AUTH / USERS
|
||||
# ============================================================
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
@@ -33,34 +33,6 @@ class Token(BaseModel):
|
||||
token_type: str = "bearer"
|
||||
user: UserOut
|
||||
|
||||
# ============================================================
|
||||
# CLIENTS
|
||||
# ============================================================
|
||||
class ClientBase(BaseModel):
|
||||
full_name: str
|
||||
birth_date: Optional[str] = None
|
||||
goal: Optional[str] = ""
|
||||
notes: Optional[str] = ""
|
||||
|
||||
class ClientCreate(ClientBase):
|
||||
sync_id: str
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
birth_date: Optional[str] = None
|
||||
goal: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[int] = None
|
||||
|
||||
class Client(ClientBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
is_active: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# ============================================================
|
||||
# WORKOUT TYPES
|
||||
@@ -69,10 +41,12 @@ class WorkoutTypeBase(BaseModel):
|
||||
name: str
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
class WorkoutTypeCreate(WorkoutTypeBase):
|
||||
sync_id: str
|
||||
|
||||
class WorkoutType(WorkoutTypeBase):
|
||||
class WorkoutTypeCreate(WorkoutTypeBase):
|
||||
sync_id: Optional[str] = None
|
||||
|
||||
|
||||
class WorkoutTypeOut(WorkoutTypeBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
created_at: str
|
||||
@@ -80,6 +54,66 @@ class WorkoutType(WorkoutTypeBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# METRIC TYPES
|
||||
# ============================================================
|
||||
class MetricTypeBase(BaseModel):
|
||||
name: str
|
||||
unit: Optional[str] = "см"
|
||||
category: Optional[str] = "body"
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
|
||||
class MetricTypeCreate(MetricTypeBase):
|
||||
sync_id: Optional[str] = None
|
||||
|
||||
|
||||
class MetricTypeOut(MetricTypeBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CLIENTS
|
||||
# ============================================================
|
||||
class ClientBase(BaseModel):
|
||||
full_name: str
|
||||
birth_date: Optional[str] = None
|
||||
goal: Optional[str] = ""
|
||||
notes: Optional[str] = ""
|
||||
color: Optional[str] = None
|
||||
is_active: Optional[int] = 1
|
||||
|
||||
|
||||
class ClientCreate(ClientBase):
|
||||
sync_id: Optional[str] = None
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
full_name: Optional[str] = None
|
||||
birth_date: Optional[str] = None
|
||||
goal: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
is_active: Optional[int] = None
|
||||
|
||||
|
||||
class ClientOut(ClientBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
owner_id: Optional[int] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# EXERCISES
|
||||
# ============================================================
|
||||
@@ -87,22 +121,27 @@ class ExerciseBase(BaseModel):
|
||||
name: str
|
||||
workout_type_id: Optional[int] = None
|
||||
|
||||
|
||||
class ExerciseCreate(ExerciseBase):
|
||||
sync_id: str
|
||||
sync_id: Optional[str] = None
|
||||
|
||||
|
||||
class ExerciseUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
workout_type_id: Optional[int] = None
|
||||
|
||||
class Exercise(ExerciseBase):
|
||||
|
||||
class ExerciseOut(ExerciseBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
owner_id: Optional[int] = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DROPSET ITEMS
|
||||
# ============================================================
|
||||
@@ -111,15 +150,18 @@ class DropsetItemBase(BaseModel):
|
||||
reps: int = Field(gt=0)
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class DropsetItemCreate(DropsetItemBase):
|
||||
pass
|
||||
|
||||
class DropsetItem(DropsetItemBase):
|
||||
|
||||
class DropsetItemOut(DropsetItemBase):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SETS
|
||||
# ============================================================
|
||||
@@ -132,40 +174,47 @@ class SetBase(BaseModel):
|
||||
sort_order: int = 0
|
||||
timestamp: str
|
||||
|
||||
class SetCreate(SetBase):
|
||||
sync_id: str
|
||||
dropset_items: Optional[List[DropsetItemCreate]] = []
|
||||
|
||||
class Set(SetBase):
|
||||
class SetCreate(SetBase):
|
||||
sync_id: Optional[str] = None
|
||||
dropset_items: List[DropsetItemCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SetOut(SetBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
created_at: str
|
||||
dropset_items: List[DropsetItem] = []
|
||||
dropset_items: List[DropsetItemOut] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WORKOUT EXERCISES
|
||||
# ============================================================
|
||||
class WorkoutExerciseBase(BaseModel):
|
||||
exercise_id: Optional[int] = None
|
||||
exercise_name: str
|
||||
exercise_id: Optional[int] = None
|
||||
exercise_sync_id: Optional[str] = None
|
||||
sort_order: int = 0
|
||||
|
||||
class WorkoutExerciseCreate(WorkoutExerciseBase):
|
||||
sync_id: str
|
||||
sets: Optional[List[SetCreate]] = []
|
||||
|
||||
class WorkoutExercise(WorkoutExerciseBase):
|
||||
class WorkoutExerciseCreate(WorkoutExerciseBase):
|
||||
sync_id: Optional[str] = None
|
||||
sets: List[SetCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkoutExerciseOut(WorkoutExerciseBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
created_at: str
|
||||
sets: List[Set] = []
|
||||
sets: List[SetOut] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CARDIO
|
||||
# ============================================================
|
||||
@@ -180,10 +229,12 @@ class CardioBase(BaseModel):
|
||||
notes: Optional[str] = ""
|
||||
sort_order: int = 0
|
||||
|
||||
class CardioCreate(CardioBase):
|
||||
sync_id: str
|
||||
|
||||
class Cardio(CardioBase):
|
||||
class CardioCreate(CardioBase):
|
||||
sync_id: Optional[str] = None
|
||||
|
||||
|
||||
class CardioOut(CardioBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
created_at: str
|
||||
@@ -191,20 +242,24 @@ class Cardio(CardioBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# WORKOUTS
|
||||
# ============================================================
|
||||
class WorkoutBase(BaseModel):
|
||||
client_id: Optional[int] = None
|
||||
workout_type_id: Optional[int] = None
|
||||
start_time: str
|
||||
end_time: Optional[str] = None
|
||||
notes: Optional[str] = ""
|
||||
workout_type_id: Optional[int] = None
|
||||
|
||||
|
||||
class WorkoutCreate(WorkoutBase):
|
||||
sync_id: str
|
||||
workout_exercises: Optional[List[WorkoutExerciseCreate]] = []
|
||||
cardio_sessions: Optional[List[CardioCreate]] = []
|
||||
sync_id: Optional[str] = None
|
||||
client_id: Optional[int] = None
|
||||
client_sync_id: Optional[str] = None
|
||||
workout_exercises: List[WorkoutExerciseCreate] = Field(default_factory=list)
|
||||
cardio_sessions: List[CardioCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class WorkoutUpdate(BaseModel):
|
||||
workout_type_id: Optional[int] = None
|
||||
@@ -212,87 +267,81 @@ class WorkoutUpdate(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
synced: Optional[int] = None
|
||||
|
||||
class Workout(WorkoutBase):
|
||||
|
||||
class WorkoutOut(WorkoutBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
owner_id: Optional[int] = None
|
||||
client_id: Optional[int] = None
|
||||
synced: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
workout_exercises: List[WorkoutExercise] = []
|
||||
cardio_sessions: List[Cardio] = []
|
||||
workout_exercises: List[WorkoutExerciseOut] = Field(default_factory=list)
|
||||
cardio_sessions: List[CardioOut] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# ============================================================
|
||||
# METRIC TYPES
|
||||
# ============================================================
|
||||
class MetricTypeBase(BaseModel):
|
||||
name: str
|
||||
unit: Optional[str] = "см"
|
||||
category: Optional[str] = "body"
|
||||
sort_order: Optional[int] = 0
|
||||
|
||||
class MetricTypeCreate(MetricTypeBase):
|
||||
sync_id: str
|
||||
|
||||
class MetricType(MetricTypeBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# ============================================================
|
||||
# BODY METRIC VALUES
|
||||
# ============================================================
|
||||
class BodyMetricValueBase(BaseModel):
|
||||
metric_type_id: int
|
||||
value: float
|
||||
|
||||
class BodyMetricValueCreate(BodyMetricValueBase):
|
||||
pass
|
||||
|
||||
class BodyMetricValue(BodyMetricValueBase):
|
||||
class BodyMetricValueCreate(BodyMetricValueBase):
|
||||
metric_type_id: Optional[int] = None
|
||||
metric_type_sync_id: Optional[str] = None
|
||||
|
||||
|
||||
class BodyMetricValueOut(BodyMetricValueBase):
|
||||
id: int
|
||||
metric_type_id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# BODY METRICS
|
||||
# ============================================================
|
||||
class BodyMetricBase(BaseModel):
|
||||
client_id: Optional[int] = None
|
||||
metric_date: str
|
||||
notes: Optional[str] = ""
|
||||
|
||||
class BodyMetricCreate(BodyMetricBase):
|
||||
sync_id: str
|
||||
values: Optional[List[BodyMetricValueCreate]] = []
|
||||
|
||||
class BodyMetric(BodyMetricBase):
|
||||
class BodyMetricCreate(BodyMetricBase):
|
||||
sync_id: Optional[str] = None
|
||||
client_id: Optional[int] = None
|
||||
client_sync_id: Optional[str] = None
|
||||
values: List[BodyMetricValueCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BodyMetricOut(BodyMetricBase):
|
||||
id: int
|
||||
sync_id: str
|
||||
owner_id: Optional[int] = None
|
||||
client_id: Optional[int] = None
|
||||
created_at: str
|
||||
values: List[BodyMetricValue] = []
|
||||
values: List[BodyMetricValueOut] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SYNC
|
||||
# ============================================================
|
||||
class SyncPayload(BaseModel):
|
||||
clients: Optional[List[ClientCreate]] = []
|
||||
workout_types: Optional[List[WorkoutTypeCreate]] = []
|
||||
exercises: Optional[List[ExerciseCreate]] = []
|
||||
workouts: Optional[List[WorkoutCreate]] = []
|
||||
metric_types: Optional[List[MetricTypeCreate]] = []
|
||||
body_metrics: Optional[List[BodyMetricCreate]] = []
|
||||
clients: List[ClientCreate] = Field(default_factory=list)
|
||||
exercises: List[ExerciseCreate] = Field(default_factory=list)
|
||||
workouts: List[WorkoutCreate] = Field(default_factory=list)
|
||||
body_metrics: List[BodyMetricCreate] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
status: str
|
||||
created: dict
|
||||
updated: dict
|
||||
created: Dict[str, int]
|
||||
updated: Dict[str, int]
|
||||
warnings: List[str] = Field(default_factory=list)
|
||||
|
||||
+230
-101
@@ -1,114 +1,243 @@
|
||||
import { state, saveState, generateUUID } from './state.js';
|
||||
import { fetchWorkoutTypes, fetchMetricTypes, fetchClients, syncData } from './api.js';
|
||||
import { renderMainScreen } from './render/main-screen.js';
|
||||
import { state, saveState, generateUUID } from "./state.js";
|
||||
import { syncData } from "./api.js";
|
||||
import { renderMainScreen } from "./render/main-screen.js";
|
||||
|
||||
export async function initAppData() {
|
||||
try {
|
||||
// Загружаем справочники с бэкенда
|
||||
state.workoutTypes = await fetchWorkoutTypes();
|
||||
state.metricTypes = await fetchMetricTypes();
|
||||
state.clients = await fetchClients();
|
||||
saveState();
|
||||
console.log('Справочники загружены с бэкенда');
|
||||
} catch (e) {
|
||||
console.warn('Не удалось загрузить справочники (возможно, бэк недоступен):', e);
|
||||
// Не ломаем приложение, работаем офлайн с тем, что есть
|
||||
}
|
||||
try {
|
||||
const { fetchWorkoutTypes, fetchMetricTypes } = await import("./api.js");
|
||||
|
||||
state.workoutTypes = await fetchWorkoutTypes();
|
||||
state.metricTypes = await fetchMetricTypes();
|
||||
saveState();
|
||||
|
||||
console.log("Справочники загружены с бэкенда");
|
||||
} catch (e) {
|
||||
console.warn("Не удалось загрузить справочники (возможно, бэк недоступен):", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function getDirtyWorkouts() {
|
||||
return state.workouts.filter(w => !w.synced);
|
||||
return state.workouts.filter((w) => !w.synced);
|
||||
}
|
||||
|
||||
function ensureSyncId(obj) {
|
||||
if (!obj) return null;
|
||||
if (!obj.sync_id) {
|
||||
obj.sync_id = generateUUID();
|
||||
}
|
||||
return obj.sync_id;
|
||||
}
|
||||
|
||||
function findWorkoutTypeByName(name) {
|
||||
if (!name) return null;
|
||||
return state.workoutTypes.find((t) => t.name === name) || null;
|
||||
}
|
||||
|
||||
function findExerciseRefByName(name) {
|
||||
const normalized = String(name || "").trim().toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
return (
|
||||
state.exerciseRef.find(
|
||||
(e) => String(e.name || "").trim().toLowerCase() === normalized
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function findClientByLocalId(clientId) {
|
||||
if (clientId === undefined || clientId === null || clientId === "") return null;
|
||||
|
||||
return (
|
||||
state.clients.find((c) => String(c.id) === String(clientId)) || null
|
||||
);
|
||||
}
|
||||
|
||||
function buildClientsPayload() {
|
||||
return state.clients.map((c) => {
|
||||
ensureSyncId(c);
|
||||
|
||||
return {
|
||||
sync_id: c.sync_id,
|
||||
full_name: c.full_name || "Без имени",
|
||||
birth_date: c.birth_date || null,
|
||||
goal: c.goal || "",
|
||||
notes: c.notes || "",
|
||||
color: c.color || null,
|
||||
is_active: c.is_active === undefined ? 1 : c.is_active,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildExercisesPayload() {
|
||||
return state.exerciseRef.map((e) => {
|
||||
ensureSyncId(e);
|
||||
|
||||
const wt = findWorkoutTypeByName(e.workoutType);
|
||||
|
||||
return {
|
||||
sync_id: e.sync_id,
|
||||
name: e.name,
|
||||
workout_type_id: wt ? wt.id : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildSetsPayload(sets) {
|
||||
return (sets || []).map((s, setIndex) => {
|
||||
ensureSyncId(s);
|
||||
|
||||
const isDropset = !!s.isDropset;
|
||||
|
||||
return {
|
||||
sync_id: s.sync_id,
|
||||
is_dropset: isDropset ? 1 : 0,
|
||||
weight: isDropset ? null : (parseFloat(s.weight) || null),
|
||||
reps: isDropset ? null : (parseInt(s.reps, 10) || null),
|
||||
rating: s.rating || 3,
|
||||
notes: s.notes || "",
|
||||
sort_order: s.sort_order ?? setIndex,
|
||||
timestamp: s.timestamp || new Date().toISOString(),
|
||||
dropset_items: isDropset
|
||||
? (s.subSets || []).map((item, itemIndex) => ({
|
||||
weight: parseFloat(item.weight) || 0,
|
||||
reps: parseInt(item.reps, 10) || 0,
|
||||
sort_order: item.sort_order ?? itemIndex,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildWorkoutExercisesPayload(exercises) {
|
||||
return (exercises || []).map((ex, exIndex) => {
|
||||
ensureSyncId(ex);
|
||||
|
||||
const exerciseRef = findExerciseRefByName(ex.name);
|
||||
|
||||
return {
|
||||
sync_id: ex.sync_id,
|
||||
exercise_sync_id: exerciseRef ? exerciseRef.sync_id : null,
|
||||
exercise_id: ex.exercise_id || null,
|
||||
exercise_name: ex.name,
|
||||
sort_order: ex.sort_order ?? exIndex,
|
||||
sets: buildSetsPayload(ex.sets),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildCardioPayload(cardioList) {
|
||||
return (cardioList || []).map((c, cIndex) => {
|
||||
ensureSyncId(c);
|
||||
|
||||
return {
|
||||
sync_id: c.sync_id,
|
||||
cardio_type: c.type || "другое",
|
||||
purpose: c.purpose || "separate",
|
||||
duration: c.duration || null,
|
||||
distance: c.distance || null,
|
||||
speed: c.speed || null,
|
||||
incline: c.incline || null,
|
||||
resistance: c.resistance || null,
|
||||
notes: c.notes || "",
|
||||
sort_order: c.sort_order ?? cIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildWorkoutsPayload(dirty) {
|
||||
return dirty.map((w) => {
|
||||
ensureSyncId(w);
|
||||
|
||||
const client = findClientByLocalId(w.client_id);
|
||||
const wt = findWorkoutTypeByName(w.type);
|
||||
|
||||
return {
|
||||
sync_id: w.sync_id,
|
||||
client_id: null,
|
||||
client_sync_id: client ? client.sync_id : null,
|
||||
workout_type_id: wt ? wt.id : null,
|
||||
start_time: w.startTime,
|
||||
end_time: w.endTime || null,
|
||||
notes: w.notes || "",
|
||||
workout_exercises: buildWorkoutExercisesPayload(w.exercises),
|
||||
cardio_sessions: buildCardioPayload(w.cardio),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function sync() {
|
||||
const dirty = getDirtyWorkouts();
|
||||
if (dirty.length === 0) {
|
||||
alert('Нет данных для синхронизации');
|
||||
return;
|
||||
const dirty = getDirtyWorkouts();
|
||||
|
||||
if (dirty.length === 0) {
|
||||
alert("Нет данных для синхронизации");
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.querySelector(".header-actions .btn-secondary");
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = "⏳";
|
||||
}
|
||||
|
||||
try {
|
||||
// Генерируем sync_id локально и сохраняем, чтобы при повторе не плодить дубли
|
||||
state.clients.forEach(ensureSyncId);
|
||||
state.exerciseRef.forEach(ensureSyncId);
|
||||
|
||||
dirty.forEach((w) => {
|
||||
ensureSyncId(w);
|
||||
|
||||
(w.exercises || []).forEach((ex) => {
|
||||
ensureSyncId(ex);
|
||||
(ex.sets || []).forEach(ensureSyncId);
|
||||
});
|
||||
|
||||
(w.cardio || []).forEach(ensureSyncId);
|
||||
});
|
||||
|
||||
saveState();
|
||||
|
||||
const payload = {
|
||||
clients: buildClientsPayload(),
|
||||
exercises: buildExercisesPayload(),
|
||||
workouts: buildWorkoutsPayload(dirty),
|
||||
body_metrics: [], // динамические замеры подключим следующим этапом
|
||||
};
|
||||
|
||||
console.log("📤 Отправляем на синхронизацию:", payload);
|
||||
|
||||
const response = await syncData(payload);
|
||||
console.log("✅ Ответ синхронизации:", response);
|
||||
|
||||
dirty.forEach((w) => {
|
||||
w.synced = true;
|
||||
});
|
||||
|
||||
saveState();
|
||||
|
||||
if (typeof window.renderMainScreen === "function") {
|
||||
window.renderMainScreen();
|
||||
} else {
|
||||
renderMainScreen();
|
||||
}
|
||||
|
||||
const btn = document.querySelector('.header-actions .btn-secondary');
|
||||
const createdWorkouts = response.created?.workouts || 0;
|
||||
const updatedWorkouts = response.updated?.workouts || 0;
|
||||
const warnings = response.warnings || [];
|
||||
|
||||
let message = `Синхронизировано: создано ${createdWorkouts}, обновлено ${updatedWorkouts}`;
|
||||
if (warnings.length) {
|
||||
message += `\n\nПредупреждения:\n${warnings.join("\n")}`;
|
||||
}
|
||||
|
||||
alert(message);
|
||||
} catch (e) {
|
||||
console.error("❌ Sync error:", e);
|
||||
alert("Ошибка синхронизации: " + e.message);
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Отправка...';
|
||||
}
|
||||
|
||||
try {
|
||||
// Маппинг данных из формата фронтенда в формат бэкенда
|
||||
const payload = {
|
||||
workouts: dirty.map(w => {
|
||||
// Находим ID типа тренировки по его имени
|
||||
const wt = state.workoutTypes.find(t => t.name === w.type);
|
||||
|
||||
return {
|
||||
sync_id: w.sync_id || generateUUID(),
|
||||
client_id: state.currentClientId || null,
|
||||
workout_type_id: wt ? wt.id : null,
|
||||
start_time: w.startTime,
|
||||
end_time: w.endTime,
|
||||
notes: w.notes || "",
|
||||
workout_exercises: (w.exercises || []).map((ex, exIdx) => ({
|
||||
sync_id: ex.sync_id || generateUUID(),
|
||||
exercise_id: ex.exercise_id || null,
|
||||
exercise_name: ex.name,
|
||||
sort_order: exIdx,
|
||||
sets: (ex.sets || []).map((s, sIdx) => ({
|
||||
sync_id: s.sync_id || generateUUID(),
|
||||
is_dropset: s.isDropset ? 1 : 0,
|
||||
weight: s.isDropset ? null : (parseFloat(s.weight) || null),
|
||||
reps: s.isDropset ? null : (parseInt(s.reps) || null),
|
||||
rating: s.rating || 3,
|
||||
notes: s.notes || "",
|
||||
sort_order: sIdx,
|
||||
timestamp: s.timestamp || new Date().toISOString(),
|
||||
dropset_items: s.isDropset ? (s.subSets || []).map((item, idx) => ({
|
||||
weight: parseFloat(item.weight) || 0,
|
||||
reps: parseInt(item.reps) || 0,
|
||||
sort_order: idx
|
||||
})) : []
|
||||
}))
|
||||
})),
|
||||
cardio_sessions: (w.cardio || []).map((c, cIdx) => ({
|
||||
sync_id: c.sync_id || generateUUID(),
|
||||
cardio_type: c.type,
|
||||
purpose: c.purpose,
|
||||
duration: c.duration || null,
|
||||
distance: c.distance || null,
|
||||
speed: c.speed || null,
|
||||
incline: c.incline || null,
|
||||
resistance: c.resistance || null,
|
||||
notes: c.notes || "",
|
||||
sort_order: cIdx
|
||||
}))
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
console.log('📤 Отправляем на синхронизацию:', payload);
|
||||
const response = await syncData(payload);
|
||||
console.log('✅ Ответ синхронизации:', response);
|
||||
|
||||
// Помечаем локально как синхронизированные
|
||||
dirty.forEach(w => w.synced = true);
|
||||
saveState();
|
||||
|
||||
// Перерисовываем интерфейс (чтобы обновить бейджи и read-only режим)
|
||||
if (typeof window.renderMainScreen === 'function') {
|
||||
window.renderMainScreen();
|
||||
} else {
|
||||
// Fallback если функция не экспортирована в window
|
||||
location.reload();
|
||||
}
|
||||
|
||||
alert(`Успешно синхронизировано: ${response.created.workouts || 0} тренировок`);
|
||||
|
||||
} catch (e) {
|
||||
console.error('❌ Sync error:', e);
|
||||
alert('Ошибка синхронизации: ' + e.message);
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄';
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = "🔄";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user