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 bcrypt
|
||||||
import jwt
|
import jwt
|
||||||
from jwt.exceptions import PyJWTError
|
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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from database import engine, Base
|
from database import engine, Base
|
||||||
import routers.clients
|
|
||||||
import routers.auth
|
import routers.auth
|
||||||
|
import routers.clients
|
||||||
import routers.workout_types
|
import routers.workout_types
|
||||||
import routers.workouts
|
import routers.workouts
|
||||||
import routers.exercises
|
import routers.exercises
|
||||||
import routers.metrics
|
import routers.metrics
|
||||||
import routers.sync
|
import routers.sync
|
||||||
|
|
||||||
# Создаём таблицы
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Gym Bro API",
|
title="Gym Bro API",
|
||||||
description="API для трекера тренировок с офлайн-синхронизацией",
|
description="API для трекера тренировок с офлайн-синхронизацией и авторизацией",
|
||||||
version="1.0.0"
|
version="1.2.0",
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS для локальной разработки
|
# Для локальной разработки. В проде замени на конкретный домен фронта.
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"], # В продакшене заменить на конкретные домены
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=False,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Подключаем роутеры
|
|
||||||
app.include_router(routers.auth.router)
|
app.include_router(routers.auth.router)
|
||||||
app.include_router(routers.clients.router)
|
app.include_router(routers.clients.router)
|
||||||
app.include_router(routers.workout_types.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.metrics.router)
|
||||||
app.include_router(routers.sync.router)
|
app.include_router(routers.sync.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def root():
|
def root():
|
||||||
return {
|
return {
|
||||||
"message": "Gym Bro API",
|
"message": "Gym Bro API",
|
||||||
"docs": "/docs",
|
"docs": "/docs",
|
||||||
"version": "1.0.0"
|
"version": "1.2.0",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health_check():
|
def health_check():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -36,10 +36,16 @@ def register(
|
|||||||
role = data.role or "personal"
|
role = data.role or "personal"
|
||||||
|
|
||||||
if len(username) < 3:
|
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:
|
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"):
|
if role not in ("personal", "trainer"):
|
||||||
role = "personal"
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
|
||||||
|
import auth
|
||||||
import database
|
import database
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/clients", tags=["clients"])
|
|
||||||
|
|
||||||
def get_current_timestamp():
|
router = APIRouter(
|
||||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
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()
|
DEFAULT_CLIENT_COLOR = "#bb86fc"
|
||||||
db_client = models.Client(
|
|
||||||
**client.dict(),
|
|
||||||
created_at=timestamp,
|
def get_current_timestamp() -> str:
|
||||||
updated_at=timestamp
|
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.add(db_client)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_client)
|
db.refresh(db_client)
|
||||||
return 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)
|
@router.get("/", response_model=List[schemas.ClientOut])
|
||||||
def get_client(client_id: int, db: Session = Depends(database.get_db)):
|
def list_clients(
|
||||||
db_client = db.query(models.Client).filter(models.Client.id == client_id).first()
|
skip: int = 0,
|
||||||
if not db_client:
|
limit: int = 100,
|
||||||
raise HTTPException(status_code=404, detail="Client not found")
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
return db_client
|
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)):
|
@router.get("/{client_id}", response_model=schemas.ClientOut)
|
||||||
db_client = db.query(models.Client).filter(models.Client.id == client_id).first()
|
def get_client(
|
||||||
if not db_client:
|
client_id: int,
|
||||||
raise HTTPException(status_code=404, detail="Client not found")
|
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)
|
update_data = client.dict(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
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)
|
db.refresh(db_client)
|
||||||
return db_client
|
return db_client
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{client_id}")
|
@router.delete("/{client_id}")
|
||||||
def delete_client(client_id: int, db: Session = Depends(database.get_db)):
|
def delete_client(
|
||||||
db_client = db.query(models.Client).filter(models.Client.id == client_id).first()
|
client_id: int,
|
||||||
if not db_client:
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
raise HTTPException(status_code=404, detail="Client not found")
|
db: Session = Depends(database.get_db),
|
||||||
|
):
|
||||||
|
db_client = get_owned_client(db, client_id, current_user)
|
||||||
db.delete(db_client)
|
db.delete(db_client)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "deleted"}
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy import func, or_
|
||||||
from typing import List
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
import auth
|
||||||
import database
|
import database
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/exercises", tags=["exercises"])
|
|
||||||
|
|
||||||
def get_current_timestamp():
|
router = APIRouter(
|
||||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
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()
|
def get_current_timestamp() -> str:
|
||||||
db_exercise = models.Exercise(
|
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||||
**exercise.dict(),
|
|
||||||
created_at=timestamp,
|
|
||||||
updated_at=timestamp
|
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.add(db_exercise)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_exercise)
|
db.refresh(db_exercise)
|
||||||
return 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)):
|
@router.get("/", response_model=List[schemas.ExerciseOut])
|
||||||
query = db.query(models.Exercise)
|
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:
|
if workout_type_id:
|
||||||
query = query.filter(models.Exercise.workout_type_id == workout_type_id)
|
query = query.filter(models.Exercise.workout_type_id == workout_type_id)
|
||||||
|
|
||||||
return query.order_by(models.Exercise.name).all()
|
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)
|
@router.get("/history/{exercise_name}")
|
||||||
def update_exercise(exercise_id: int, exercise: schemas.ExerciseUpdate, db: Session = Depends(database.get_db)):
|
def get_exercise_history(
|
||||||
db_exercise = db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first()
|
exercise_name: str,
|
||||||
if not db_exercise:
|
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")
|
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)
|
update_data = exercise.dict(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
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)
|
db.refresh(db_exercise)
|
||||||
return db_exercise
|
return db_exercise
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{exercise_id}")
|
@router.delete("/{exercise_id}")
|
||||||
def delete_exercise(exercise_id: int, db: Session = Depends(database.get_db)):
|
def delete_exercise(
|
||||||
db_exercise = db.query(models.Exercise).filter(models.Exercise.id == exercise_id).first()
|
exercise_id: int,
|
||||||
if not db_exercise:
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
raise HTTPException(status_code=404, detail="Exercise not found")
|
db: Session = Depends(database.get_db),
|
||||||
|
):
|
||||||
|
db_exercise = get_owned_exercise(db, exercise_id, current_user)
|
||||||
db.delete(db_exercise)
|
db.delete(db_exercise)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "deleted"}
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from typing import List
|
|
||||||
|
import auth
|
||||||
import database
|
import database
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
router = APIRouter(tags=["metrics"])
|
|
||||||
|
|
||||||
def get_current_timestamp():
|
router = APIRouter(
|
||||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
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
|
# METRIC TYPES
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@router.post("/metric-types/", response_model=schemas.MetricType)
|
@router.post("/metric-types/", response_model=schemas.MetricTypeOut)
|
||||||
def create_metric_type(metric_type: schemas.MetricTypeCreate, db: Session = Depends(database.get_db)):
|
def create_metric_type(
|
||||||
db_mt = db.query(models.MetricType).filter(models.MetricType.sync_id == metric_type.sync_id).first()
|
metric_type: schemas.MetricTypeCreate,
|
||||||
if db_mt:
|
db: Session = Depends(database.get_db),
|
||||||
raise HTTPException(status_code=400, detail="MetricType with this sync_id already exists")
|
):
|
||||||
|
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(
|
db_mt = models.MetricType(
|
||||||
**metric_type.dict(),
|
sync_id=sync_id,
|
||||||
created_at=get_current_timestamp()
|
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.add(db_mt)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_mt)
|
db.refresh(db_mt)
|
||||||
return 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)):
|
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
|
# BODY METRICS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@router.post("/body-metrics/", response_model=schemas.BodyMetric)
|
@router.post("/body-metrics/", response_model=schemas.BodyMetricOut)
|
||||||
def create_body_metric(metric: schemas.BodyMetricCreate, db: Session = Depends(database.get_db)):
|
def create_body_metric(
|
||||||
db_metric = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == metric.sync_id).first()
|
metric: schemas.BodyMetricCreate,
|
||||||
if db_metric:
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
raise HTTPException(status_code=400, detail="BodyMetric with this sync_id already exists")
|
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(
|
db_metric = models.BodyMetric(
|
||||||
sync_id=metric.sync_id,
|
sync_id=sync_id,
|
||||||
client_id=metric.client_id,
|
owner_id=current_user.id,
|
||||||
|
client_id=client_id,
|
||||||
metric_date=metric.metric_date,
|
metric_date=metric.metric_date,
|
||||||
notes=metric.notes,
|
notes=metric.notes or "",
|
||||||
created_at=get_current_timestamp()
|
created_at=timestamp,
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(db_metric)
|
db.add(db_metric)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Добавляем значения
|
for value in metric.values or []:
|
||||||
for value_data in metric.values or []:
|
mt = resolve_metric_type(db, value)
|
||||||
db_value = models.BodyMetricValue(
|
if not mt:
|
||||||
body_metric_id=db_metric.id,
|
raise HTTPException(status_code=422, detail="Metric type not found")
|
||||||
metric_type_id=value_data.metric_type_id,
|
|
||||||
value=value_data.value
|
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.commit()
|
||||||
db.refresh(db_metric)
|
db.refresh(db_metric)
|
||||||
return 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)):
|
@router.get("/body-metrics/", response_model=List[schemas.BodyMetricOut])
|
||||||
query = db.query(models.BodyMetric)
|
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:
|
if client_id:
|
||||||
|
get_owned_client(db, client_id, current_user)
|
||||||
query = query.filter(models.BodyMetric.client_id == client_id)
|
query = query.filter(models.BodyMetric.client_id == client_id)
|
||||||
|
|
||||||
return query.order_by(models.BodyMetric.metric_date.desc()).all()
|
return query.order_by(models.BodyMetric.metric_date.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/body-metrics/history")
|
@router.get("/body-metrics/history")
|
||||||
def get_metric_history(
|
def get_metric_history(
|
||||||
client_id: int,
|
client_id: int,
|
||||||
metric_type_id: int,
|
metric_type_id: int,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
db: Session = Depends(database.get_db)
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
|
db: Session = Depends(database.get_db),
|
||||||
):
|
):
|
||||||
"""Получить историю конкретного замера для графиков"""
|
get_owned_client(db, client_id, current_user)
|
||||||
results = db.query(
|
|
||||||
models.BodyMetric.metric_date,
|
metric_type = db.get(models.MetricType, metric_type_id)
|
||||||
models.BodyMetricValue.value
|
if not metric_type:
|
||||||
).join(
|
raise HTTPException(status_code=404, detail="Metric type not found")
|
||||||
models.BodyMetricValue, models.BodyMetricValue.body_metric_id == models.BodyMetric.id
|
|
||||||
).filter(
|
results = (
|
||||||
models.BodyMetric.client_id == client_id,
|
db.query(
|
||||||
models.BodyMetricValue.metric_type_id == metric_type_id
|
models.BodyMetric.metric_date,
|
||||||
).order_by(models.BodyMetric.metric_date.desc()).limit(limit).all()
|
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 [
|
return [
|
||||||
{
|
{
|
||||||
"date": r.metric_date,
|
"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
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
import auth
|
||||||
import database
|
import database
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/sync", tags=["sync"])
|
|
||||||
|
|
||||||
def get_current_timestamp():
|
router = APIRouter(
|
||||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
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)
|
@router.post("/", response_model=schemas.SyncResponse)
|
||||||
def sync_data(payload: schemas.SyncPayload, db: Session = Depends(database.get_db)):
|
def sync_data(
|
||||||
"""
|
payload: schemas.SyncPayload,
|
||||||
Endpoint для синхронизации офлайн-очереди с клиента.
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
Использует sync_id для идемпотентности — если запись с таким sync_id уже есть, пропускаем.
|
db: Session = Depends(database.get_db),
|
||||||
"""
|
):
|
||||||
timestamp = get_current_timestamp()
|
timestamp = get_current_timestamp()
|
||||||
|
warnings = []
|
||||||
|
|
||||||
created = {
|
created = {
|
||||||
"clients": 0,
|
"clients": 0,
|
||||||
"workout_types": 0,
|
|
||||||
"exercises": 0,
|
"exercises": 0,
|
||||||
"workouts": 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 []:
|
for client_data in payload.clients or []:
|
||||||
existing = db.query(models.Client).filter(models.Client.sync_id == client_data.sync_id).first()
|
obj, status = upsert_client(db, current_user, client_data, timestamp)
|
||||||
if not existing:
|
client_map[obj.sync_id] = obj.id
|
||||||
db_client = models.Client(
|
|
||||||
**client_data.dict(),
|
if status == "created":
|
||||||
created_at=timestamp,
|
|
||||||
updated_at=timestamp
|
|
||||||
)
|
|
||||||
db.add(db_client)
|
|
||||||
created["clients"] += 1
|
created["clients"] += 1
|
||||||
|
elif status == "updated":
|
||||||
|
updated["clients"] += 1
|
||||||
|
|
||||||
# Синхронизация типов тренировок
|
# ========================================================
|
||||||
for wt_data in payload.workout_types or []:
|
# 2. Exercises
|
||||||
existing = db.query(models.WorkoutType).filter(models.WorkoutType.sync_id == wt_data.sync_id).first()
|
# ========================================================
|
||||||
if not existing:
|
for exercise_data in payload.exercises or []:
|
||||||
db_wt = models.WorkoutType(
|
obj, status = upsert_exercise(db, current_user, exercise_data, timestamp)
|
||||||
**wt_data.dict(),
|
exercise_map[obj.sync_id] = obj.id
|
||||||
created_at=timestamp
|
|
||||||
)
|
|
||||||
db.add(db_wt)
|
|
||||||
created["workout_types"] += 1
|
|
||||||
|
|
||||||
# Синхронизация упражнений
|
if status == "created":
|
||||||
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)
|
|
||||||
created["exercises"] += 1
|
created["exercises"] += 1
|
||||||
|
elif status == "updated":
|
||||||
|
updated["exercises"] += 1
|
||||||
|
|
||||||
# Синхронизация типов замеров
|
# ========================================================
|
||||||
for mt_data in payload.metric_types or []:
|
# 3. Workouts
|
||||||
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
|
|
||||||
|
|
||||||
# Синхронизация тренировок (с упражнениями и подходами)
|
|
||||||
for workout_data in payload.workouts or []:
|
for workout_data in payload.workouts or []:
|
||||||
existing = db.query(models.Workout).filter(models.Workout.sync_id == workout_data.sync_id).first()
|
sync_id = workout_data.sync_id or new_sync_id()
|
||||||
if not existing:
|
|
||||||
db_workout = models.Workout(
|
existing = db.query(models.Workout).filter(models.Workout.sync_id == sync_id).first()
|
||||||
sync_id=workout_data.sync_id,
|
if existing:
|
||||||
client_id=workout_data.client_id,
|
if existing.owner_id != current_user.id:
|
||||||
workout_type_id=workout_data.workout_type_id,
|
raise HTTPException(status_code=409, detail="Workout sync_id belongs to another user")
|
||||||
start_time=workout_data.start_time,
|
|
||||||
end_time=workout_data.end_time,
|
existing.start_time = workout_data.start_time or existing.start_time
|
||||||
notes=workout_data.notes,
|
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,
|
created_at=timestamp,
|
||||||
updated_at=timestamp
|
|
||||||
)
|
)
|
||||||
db.add(db_workout)
|
|
||||||
|
db.add(db_we)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Упражнения
|
for set_data in we_data.sets or []:
|
||||||
for we_data in workout_data.workout_exercises or []:
|
create_set_from_payload(db, db_we.id, set_data, timestamp, warnings)
|
||||||
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 cardio_data in workout_data.cardio_sessions or []:
|
||||||
for set_data in we_data.sets or []:
|
create_cardio_from_payload(db, db_workout.id, cardio_data, timestamp)
|
||||||
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()
|
|
||||||
|
|
||||||
# Дроп-сет элементы
|
# ========================================================
|
||||||
if set_data.is_dropset:
|
# 4. Body metrics
|
||||||
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
|
|
||||||
|
|
||||||
# Синхронизация замеров тела
|
|
||||||
for metric_data in payload.body_metrics or []:
|
for metric_data in payload.body_metrics or []:
|
||||||
existing = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == metric_data.sync_id).first()
|
sync_id = metric_data.sync_id or new_sync_id()
|
||||||
if not existing:
|
|
||||||
db_metric = models.BodyMetric(
|
existing = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == sync_id).first()
|
||||||
sync_id=metric_data.sync_id,
|
if existing:
|
||||||
client_id=metric_data.client_id,
|
if existing.owner_id != current_user.id:
|
||||||
metric_date=metric_data.metric_date,
|
raise HTTPException(status_code=409, detail="BodyMetric sync_id belongs to another user")
|
||||||
notes=metric_data.notes,
|
|
||||||
created_at=timestamp
|
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)
|
if metric_data.client_id and not client_id:
|
||||||
db.flush()
|
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 []:
|
for value_data in metric_data.values or []:
|
||||||
db_value = models.BodyMetricValue(
|
mt = resolve_metric_type_for_sync(db, value_data)
|
||||||
body_metric_id=db_metric.id,
|
if not mt:
|
||||||
metric_type_id=value_data.metric_type_id,
|
warnings.append("Body metric value skipped: metric type not found")
|
||||||
value=value_data.value
|
continue
|
||||||
)
|
|
||||||
db.add(db_value)
|
|
||||||
|
|
||||||
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()
|
db.commit()
|
||||||
|
|
||||||
return schemas.SyncResponse(
|
return schemas.SyncResponse(
|
||||||
status="success",
|
status="success",
|
||||||
created=created,
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List
|
|
||||||
|
import auth
|
||||||
import database
|
import database
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
from datetime import datetime
|
|
||||||
import auth
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/workout-types",
|
prefix="/workout-types",
|
||||||
@@ -13,24 +18,45 @@ router = APIRouter(
|
|||||||
dependencies=[Depends(auth.get_current_user)],
|
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 get_current_timestamp() -> str:
|
||||||
def create_workout_type(wt: schemas.WorkoutTypeCreate, db: Session = Depends(database.get_db)):
|
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
|
||||||
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 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(
|
db_wt = models.WorkoutType(
|
||||||
**wt.dict(),
|
sync_id=sync_id,
|
||||||
created_at=get_current_timestamp()
|
name=wt.name,
|
||||||
|
sort_order=wt.sort_order or 0,
|
||||||
|
created_at=get_current_timestamp(),
|
||||||
)
|
)
|
||||||
db.add(db_wt)
|
db.add(db_wt)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_wt)
|
db.refresh(db_wt)
|
||||||
return 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)):
|
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 fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from typing import List
|
|
||||||
|
import auth
|
||||||
import database
|
import database
|
||||||
import models
|
import models
|
||||||
import schemas
|
import schemas
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/workouts", tags=["workouts"])
|
|
||||||
|
|
||||||
def get_current_timestamp():
|
router = APIRouter(
|
||||||
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ')
|
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()
|
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(
|
db_workout = models.Workout(
|
||||||
sync_id=workout.sync_id,
|
sync_id=sync_id,
|
||||||
client_id=workout.client_id,
|
owner_id=current_user.id,
|
||||||
workout_type_id=workout.workout_type_id,
|
client_id=client_id,
|
||||||
|
workout_type_id=workout_type_id,
|
||||||
start_time=workout.start_time,
|
start_time=workout.start_time,
|
||||||
end_time=workout.end_time,
|
end_time=workout.end_time,
|
||||||
notes=workout.notes,
|
notes=workout.notes or "",
|
||||||
|
synced=1,
|
||||||
created_at=timestamp,
|
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 []:
|
for we_data in workout.workout_exercises or []:
|
||||||
db_we = models.WorkoutExercise(
|
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,
|
workout_id=db_workout.id,
|
||||||
exercise_id=we_data.exercise_id,
|
exercise_id=we_data.exercise_id,
|
||||||
exercise_name=we_data.exercise_name,
|
exercise_name=we_data.exercise_name,
|
||||||
sort_order=we_data.sort_order,
|
sort_order=we_data.sort_order or 0,
|
||||||
created_at=timestamp
|
created_at=timestamp,
|
||||||
)
|
)
|
||||||
db.add(db_we)
|
db.add(db_we)
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# Добавляем подходы
|
|
||||||
for set_data in we_data.sets or []:
|
for set_data in we_data.sets or []:
|
||||||
db_set = models.Set(
|
create_set_record(db, db_we.id, set_data, timestamp)
|
||||||
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()
|
|
||||||
|
|
||||||
# Добавляем элементы дроп-сета
|
|
||||||
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 []:
|
for cardio_data in workout.cardio_sessions or []:
|
||||||
db_cardio = models.Cardio(
|
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,
|
workout_id=db_workout.id,
|
||||||
cardio_type=cardio_data.cardio_type,
|
cardio_type=cardio_data.cardio_type,
|
||||||
purpose=cardio_data.purpose,
|
purpose=cardio_data.purpose,
|
||||||
@@ -85,9 +194,9 @@ def create_workout(workout: schemas.WorkoutCreate, db: Session = Depends(databas
|
|||||||
speed=cardio_data.speed,
|
speed=cardio_data.speed,
|
||||||
incline=cardio_data.incline,
|
incline=cardio_data.incline,
|
||||||
resistance=cardio_data.resistance,
|
resistance=cardio_data.resistance,
|
||||||
notes=cardio_data.notes,
|
notes=cardio_data.notes or "",
|
||||||
sort_order=cardio_data.sort_order,
|
sort_order=cardio_data.sort_order or 0,
|
||||||
created_at=timestamp
|
created_at=timestamp,
|
||||||
)
|
)
|
||||||
db.add(db_cardio)
|
db.add(db_cardio)
|
||||||
|
|
||||||
@@ -95,45 +204,75 @@ def create_workout(workout: schemas.WorkoutCreate, db: Session = Depends(databas
|
|||||||
db.refresh(db_workout)
|
db.refresh(db_workout)
|
||||||
return db_workout
|
return db_workout
|
||||||
|
|
||||||
@router.get("/", response_model=List[schemas.Workout])
|
|
||||||
|
@router.get("/", response_model=List[schemas.WorkoutOut])
|
||||||
def list_workouts(
|
def list_workouts(
|
||||||
client_id: int = None,
|
client_id: Optional[int] = None,
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
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(
|
query = db.query(models.Workout).options(
|
||||||
joinedload(models.Workout.workout_exercises)
|
joinedload(models.Workout.workout_exercises)
|
||||||
.joinedload(models.WorkoutExercise.sets)
|
.joinedload(models.WorkoutExercise.sets)
|
||||||
.joinedload(models.Set.dropset_items),
|
.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:
|
if client_id:
|
||||||
|
validate_client(db, client_id, current_user)
|
||||||
query = query.filter(models.Workout.client_id == client_id)
|
query = query.filter(models.Workout.client_id == client_id)
|
||||||
|
|
||||||
return query.order_by(models.Workout.start_time.desc()).offset(skip).limit(limit).all()
|
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)):
|
@router.get("/{workout_id}", response_model=schemas.WorkoutOut)
|
||||||
db_workout = db.query(models.Workout).options(
|
def get_workout(
|
||||||
joinedload(models.Workout.workout_exercises)
|
workout_id: int,
|
||||||
.joinedload(models.WorkoutExercise.sets)
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
.joinedload(models.Set.dropset_items),
|
db: Session = Depends(database.get_db),
|
||||||
joinedload(models.Workout.cardio_sessions)
|
):
|
||||||
).filter(models.Workout.id == workout_id).first()
|
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:
|
if not db_workout:
|
||||||
raise HTTPException(status_code=404, detail="Workout not found")
|
raise HTTPException(status_code=404, detail="Workout not found")
|
||||||
|
|
||||||
return db_workout
|
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)):
|
@router.put("/{workout_id}", response_model=schemas.WorkoutOut)
|
||||||
db_workout = db.query(models.Workout).filter(models.Workout.id == workout_id).first()
|
def update_workout(
|
||||||
if not db_workout:
|
workout_id: int,
|
||||||
raise HTTPException(status_code=404, detail="Workout not found")
|
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)
|
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():
|
for key, value in update_data.items():
|
||||||
setattr(db_workout, key, value)
|
setattr(db_workout, key, value)
|
||||||
|
|
||||||
@@ -142,11 +281,14 @@ def update_workout(workout_id: int, workout: schemas.WorkoutUpdate, db: Session
|
|||||||
db.refresh(db_workout)
|
db.refresh(db_workout)
|
||||||
return db_workout
|
return db_workout
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{workout_id}")
|
@router.delete("/{workout_id}")
|
||||||
def delete_workout(workout_id: int, db: Session = Depends(database.get_db)):
|
def delete_workout(
|
||||||
db_workout = db.query(models.Workout).filter(models.Workout.id == workout_id).first()
|
workout_id: int,
|
||||||
if not db_workout:
|
current_user: models.User = Depends(auth.get_current_user),
|
||||||
raise HTTPException(status_code=404, detail="Workout not found")
|
db: Session = Depends(database.get_db),
|
||||||
|
):
|
||||||
|
db_workout = get_owned_workout(db, workout_id, current_user)
|
||||||
db.delete(db_workout)
|
db.delete(db_workout)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|||||||
+145
-96
@@ -1,9 +1,9 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, List
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# USERS
|
# AUTH / USERS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
@@ -33,34 +33,6 @@ class Token(BaseModel):
|
|||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
user: UserOut
|
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
|
# WORKOUT TYPES
|
||||||
@@ -69,10 +41,12 @@ class WorkoutTypeBase(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
sort_order: Optional[int] = 0
|
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
|
id: int
|
||||||
sync_id: str
|
sync_id: str
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -80,6 +54,66 @@ class WorkoutType(WorkoutTypeBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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
|
# EXERCISES
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -87,22 +121,27 @@ class ExerciseBase(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
workout_type_id: Optional[int] = None
|
workout_type_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class ExerciseCreate(ExerciseBase):
|
class ExerciseCreate(ExerciseBase):
|
||||||
sync_id: str
|
sync_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ExerciseUpdate(BaseModel):
|
class ExerciseUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
workout_type_id: Optional[int] = None
|
workout_type_id: Optional[int] = None
|
||||||
|
|
||||||
class Exercise(ExerciseBase):
|
|
||||||
|
class ExerciseOut(ExerciseBase):
|
||||||
id: int
|
id: int
|
||||||
sync_id: str
|
sync_id: str
|
||||||
|
owner_id: Optional[int] = None
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# DROPSET ITEMS
|
# DROPSET ITEMS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -111,15 +150,18 @@ class DropsetItemBase(BaseModel):
|
|||||||
reps: int = Field(gt=0)
|
reps: int = Field(gt=0)
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
class DropsetItemCreate(DropsetItemBase):
|
class DropsetItemCreate(DropsetItemBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class DropsetItem(DropsetItemBase):
|
|
||||||
|
class DropsetItemOut(DropsetItemBase):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# SETS
|
# SETS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -132,40 +174,47 @@ class SetBase(BaseModel):
|
|||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
timestamp: str
|
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
|
id: int
|
||||||
sync_id: str
|
sync_id: str
|
||||||
created_at: str
|
created_at: str
|
||||||
dropset_items: List[DropsetItem] = []
|
dropset_items: List[DropsetItemOut] = Field(default_factory=list)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# WORKOUT EXERCISES
|
# WORKOUT EXERCISES
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class WorkoutExerciseBase(BaseModel):
|
class WorkoutExerciseBase(BaseModel):
|
||||||
exercise_id: Optional[int] = None
|
|
||||||
exercise_name: str
|
exercise_name: str
|
||||||
|
exercise_id: Optional[int] = None
|
||||||
|
exercise_sync_id: Optional[str] = None
|
||||||
sort_order: int = 0
|
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
|
id: int
|
||||||
sync_id: str
|
sync_id: str
|
||||||
created_at: str
|
created_at: str
|
||||||
sets: List[Set] = []
|
sets: List[SetOut] = Field(default_factory=list)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# CARDIO
|
# CARDIO
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -180,10 +229,12 @@ class CardioBase(BaseModel):
|
|||||||
notes: Optional[str] = ""
|
notes: Optional[str] = ""
|
||||||
sort_order: int = 0
|
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
|
id: int
|
||||||
sync_id: str
|
sync_id: str
|
||||||
created_at: str
|
created_at: str
|
||||||
@@ -191,20 +242,24 @@ class Cardio(CardioBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# WORKOUTS
|
# WORKOUTS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class WorkoutBase(BaseModel):
|
class WorkoutBase(BaseModel):
|
||||||
client_id: Optional[int] = None
|
|
||||||
workout_type_id: Optional[int] = None
|
|
||||||
start_time: str
|
start_time: str
|
||||||
end_time: Optional[str] = None
|
end_time: Optional[str] = None
|
||||||
notes: Optional[str] = ""
|
notes: Optional[str] = ""
|
||||||
|
workout_type_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class WorkoutCreate(WorkoutBase):
|
class WorkoutCreate(WorkoutBase):
|
||||||
sync_id: str
|
sync_id: Optional[str] = None
|
||||||
workout_exercises: Optional[List[WorkoutExerciseCreate]] = []
|
client_id: Optional[int] = None
|
||||||
cardio_sessions: Optional[List[CardioCreate]] = []
|
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):
|
class WorkoutUpdate(BaseModel):
|
||||||
workout_type_id: Optional[int] = None
|
workout_type_id: Optional[int] = None
|
||||||
@@ -212,87 +267,81 @@ class WorkoutUpdate(BaseModel):
|
|||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
synced: Optional[int] = None
|
synced: Optional[int] = None
|
||||||
|
|
||||||
class Workout(WorkoutBase):
|
|
||||||
|
class WorkoutOut(WorkoutBase):
|
||||||
id: int
|
id: int
|
||||||
sync_id: str
|
sync_id: str
|
||||||
|
owner_id: Optional[int] = None
|
||||||
|
client_id: Optional[int] = None
|
||||||
synced: int
|
synced: int
|
||||||
created_at: str
|
created_at: str
|
||||||
updated_at: str
|
updated_at: str
|
||||||
workout_exercises: List[WorkoutExercise] = []
|
workout_exercises: List[WorkoutExerciseOut] = Field(default_factory=list)
|
||||||
cardio_sessions: List[Cardio] = []
|
cardio_sessions: List[CardioOut] = Field(default_factory=list)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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
|
# BODY METRIC VALUES
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class BodyMetricValueBase(BaseModel):
|
class BodyMetricValueBase(BaseModel):
|
||||||
metric_type_id: int
|
|
||||||
value: float
|
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
|
id: int
|
||||||
|
metric_type_id: int
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# BODY METRICS
|
# BODY METRICS
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class BodyMetricBase(BaseModel):
|
class BodyMetricBase(BaseModel):
|
||||||
client_id: Optional[int] = None
|
|
||||||
metric_date: str
|
metric_date: str
|
||||||
notes: Optional[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
|
id: int
|
||||||
sync_id: str
|
sync_id: str
|
||||||
|
owner_id: Optional[int] = None
|
||||||
|
client_id: Optional[int] = None
|
||||||
created_at: str
|
created_at: str
|
||||||
values: List[BodyMetricValue] = []
|
values: List[BodyMetricValueOut] = Field(default_factory=list)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# SYNC
|
# SYNC
|
||||||
# ============================================================
|
# ============================================================
|
||||||
class SyncPayload(BaseModel):
|
class SyncPayload(BaseModel):
|
||||||
clients: Optional[List[ClientCreate]] = []
|
clients: List[ClientCreate] = Field(default_factory=list)
|
||||||
workout_types: Optional[List[WorkoutTypeCreate]] = []
|
exercises: List[ExerciseCreate] = Field(default_factory=list)
|
||||||
exercises: Optional[List[ExerciseCreate]] = []
|
workouts: List[WorkoutCreate] = Field(default_factory=list)
|
||||||
workouts: Optional[List[WorkoutCreate]] = []
|
body_metrics: List[BodyMetricCreate] = Field(default_factory=list)
|
||||||
metric_types: Optional[List[MetricTypeCreate]] = []
|
|
||||||
body_metrics: Optional[List[BodyMetricCreate]] = []
|
|
||||||
|
|
||||||
class SyncResponse(BaseModel):
|
class SyncResponse(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
created: dict
|
created: Dict[str, int]
|
||||||
updated: dict
|
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 { state, saveState, generateUUID } from "./state.js";
|
||||||
import { fetchWorkoutTypes, fetchMetricTypes, fetchClients, syncData } from './api.js';
|
import { syncData } from "./api.js";
|
||||||
import { renderMainScreen } from './render/main-screen.js';
|
import { renderMainScreen } from "./render/main-screen.js";
|
||||||
|
|
||||||
export async function initAppData() {
|
export async function initAppData() {
|
||||||
try {
|
try {
|
||||||
// Загружаем справочники с бэкенда
|
const { fetchWorkoutTypes, fetchMetricTypes } = await import("./api.js");
|
||||||
state.workoutTypes = await fetchWorkoutTypes();
|
|
||||||
state.metricTypes = await fetchMetricTypes();
|
state.workoutTypes = await fetchWorkoutTypes();
|
||||||
state.clients = await fetchClients();
|
state.metricTypes = await fetchMetricTypes();
|
||||||
saveState();
|
saveState();
|
||||||
console.log('Справочники загружены с бэкенда');
|
|
||||||
} catch (e) {
|
console.log("Справочники загружены с бэкенда");
|
||||||
console.warn('Не удалось загрузить справочники (возможно, бэк недоступен):', e);
|
} catch (e) {
|
||||||
// Не ломаем приложение, работаем офлайн с тем, что есть
|
console.warn("Не удалось загрузить справочники (возможно, бэк недоступен):", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDirtyWorkouts() {
|
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() {
|
export async function sync() {
|
||||||
const dirty = getDirtyWorkouts();
|
const dirty = getDirtyWorkouts();
|
||||||
if (dirty.length === 0) {
|
|
||||||
alert('Нет данных для синхронизации');
|
if (dirty.length === 0) {
|
||||||
return;
|
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) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = false;
|
||||||
btn.textContent = '⏳ Отправка...';
|
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 = '🔄';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user