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