possibly auth

This commit is contained in:
2026-10-03 09:44:11 +03:00
parent 86bcd5b141
commit ea21db141a
12 changed files with 1780 additions and 591 deletions
-4
View File
@@ -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
BIN
View File
Binary file not shown.
+12 -11
View File
@@ -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"}
+8 -2
View File
@@ -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"
+120 -36
View File
@@ -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.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
router = APIRouter(
prefix="/clients",
tags=["clients"],
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 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"}
+273 -67
View File
@@ -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.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
router = APIRouter(
prefix="/exercises",
tags=["exercises"],
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 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
]
+228 -43
View File
@@ -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(
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=value_data.metric_type_id,
value=value_data.value
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(
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.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()
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"}
+508 -143
View File
@@ -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.post("/", response_model=schemas.SyncResponse)
def sync_data(payload: schemas.SyncPayload, db: Session = Depends(database.get_db)):
"""
Endpoint для синхронизации офлайн-очереди с клиента.
Использует sync_id для идемпотентности — если запись с таким sync_id уже есть, пропускаем.
"""
timestamp = get_current_timestamp()
created = {
"clients": 0,
"workout_types": 0,
"exercises": 0,
"workouts": 0,
"metric_types": 0,
"body_metrics": 0
}
updated = {k: 0 for k in created}
# Синхронизация клиентов
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
router = APIRouter(
prefix="/sync",
tags=["sync"],
dependencies=[Depends(auth.get_current_user)],
)
db.add(db_client)
created["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
# Синхронизация упражнений
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
DEFAULT_CLIENT_COLOR = "#bb86fc"
# Синхронизация типов замеров
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
# Синхронизация тренировок (с упражнениями и подходами)
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,
created_at=timestamp,
updated_at=timestamp
)
db.add(db_workout)
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"
# Упражнения
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
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(db_we)
db.flush()
# Подходы
for set_data in we_data.sets or []:
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=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
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 set_data.is_dropset:
for item_data in set_data.dropset_items or []:
db_item = models.DropsetItem(
if is_dropset:
for idx, item in enumerate(data.dropset_items or []):
db.add(
models.DropsetItem(
set_id=db_set.id,
weight=item_data.weight,
reps=item_data.reps,
sort_order=item_data.sort_order
weight=float(item.weight),
reps=int(item.reps),
sort_order=item.sort_order if item.sort_order is not None else idx,
)
)
db.add(db_item)
# Кардио
for cardio_data in workout_data.cardio_sessions or []:
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=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
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,
current_user: models.User = Depends(auth.get_current_user),
db: Session = Depends(database.get_db),
):
timestamp = get_current_timestamp()
warnings = []
created = {
"clients": 0,
"exercises": 0,
"workouts": 0,
"body_metrics": 0,
}
updated = {
"clients": 0,
"exercises": 0,
"workouts": 0,
"body_metrics": 0,
}
client_map: Dict[str, int] = {}
exercise_map: Dict[str, int] = {}
# ========================================================
# 1. Clients
# ========================================================
for client_data in payload.clients or []:
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
# ========================================================
# 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
if status == "created":
created["exercises"] += 1
elif status == "updated":
updated["exercises"] += 1
# ========================================================
# 3. Workouts
# ========================================================
for workout_data in payload.workouts or []:
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 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
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.add(db_metric)
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,
)
db.add(db_we)
db.flush()
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)
for set_data in we_data.sets or []:
create_set_from_payload(db, db_we.id, set_data, timestamp, warnings)
for cardio_data in workout_data.cardio_sessions or []:
create_cardio_from_payload(db, db_workout.id, cardio_data, timestamp)
# ========================================================
# 4. Body metrics
# ========================================================
for metric_data in payload.body_metrics or []:
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,
)
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 []:
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=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,
)
+40 -14
View File
@@ -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()
+213 -71
View File
@@ -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(
@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).first()
joinedload(models.Workout.cardio_sessions),
)
.filter(
models.Workout.id == workout_id,
models.Workout.owner_id == current_user.id,
)
.first()
)
if not db_workout:
raise HTTPException(status_code=404, detail="Workout not found")
return db_workout
@router.put("/{workout_id}", response_model=schemas.Workout)
def update_workout(workout_id: int, workout: schemas.WorkoutUpdate, db: Session = Depends(database.get_db)):
db_workout = db.query(models.Workout).filter(models.Workout.id == workout_id).first()
if not db_workout:
raise HTTPException(status_code=404, detail="Workout not found")
@router.put("/{workout_id}", response_model=schemas.WorkoutOut)
def update_workout(
workout_id: int,
workout: schemas.WorkoutUpdate,
current_user: models.User = Depends(auth.get_current_user),
db: Session = Depends(database.get_db),
):
db_workout = get_owned_workout(db, workout_id, current_user)
update_data = workout.dict(exclude_unset=True)
if "workout_type_id" in update_data:
update_data["workout_type_id"] = validate_workout_type(
db,
update_data["workout_type_id"],
)
for key, value in update_data.items():
setattr(db_workout, key, value)
@@ -142,11 +281,14 @@ def update_workout(workout_id: int, workout: schemas.WorkoutUpdate, db: Session
db.refresh(db_workout)
return db_workout
@router.delete("/{workout_id}")
def delete_workout(workout_id: int, db: Session = Depends(database.get_db)):
db_workout = db.query(models.Workout).filter(models.Workout.id == workout_id).first()
if not db_workout:
raise HTTPException(status_code=404, detail="Workout not found")
def delete_workout(
workout_id: int,
current_user: models.User = Depends(auth.get_current_user),
db: Session = Depends(database.get_db),
):
db_workout = get_owned_workout(db, workout_id, current_user)
db.delete(db_workout)
db.commit()
return {"status": "deleted"}
+145 -96
View File
@@ -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)
+201 -72
View File
@@ -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 {
// Загружаем справочники с бэкенда
const { fetchWorkoutTypes, fetchMetricTypes } = await import("./api.js");
state.workoutTypes = await fetchWorkoutTypes();
state.metricTypes = await fetchMetricTypes();
state.clients = await fetchClients();
saveState();
console.log('Справочники загружены с бэкенда');
console.log("Справочники загружены с бэкенда");
} catch (e) {
console.warn('Не удалось загрузить справочники (возможно, бэк недоступен):', e);
// Не ломаем приложение, работаем офлайн с тем, что есть
console.warn("Не удалось загрузить справочники (возможно, бэк недоступен):", e);
}
}
export function getDirtyWorkouts() {
return state.workouts.filter(w => !w.synced);
return state.workouts.filter((w) => !w.synced);
}
export async function sync() {
const dirty = getDirtyWorkouts();
if (dirty.length === 0) {
alert('Нет данных для синхронизации');
return;
function ensureSyncId(obj) {
if (!obj) return null;
if (!obj.sync_id) {
obj.sync_id = generateUUID();
}
return obj.sync_id;
}
const btn = document.querySelector('.header-actions .btn-secondary');
if (btn) {
btn.disabled = true;
btn.textContent = '⏳ Отправка...';
function findWorkoutTypeByName(name) {
if (!name) return null;
return state.workoutTypes.find((t) => t.name === name) || null;
}
try {
// Маппинг данных из формата фронтенда в формат бэкенда
const payload = {
workouts: dirty.map(w => {
// Находим ID типа тренировки по его имени
const wt = state.workoutTypes.find(t => t.name === w.type);
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: w.sync_id || generateUUID(),
client_id: state.currentClientId || null,
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,
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),
};
});
}
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: sIdx,
sort_order: s.sort_order ?? setIndex,
timestamp: s.timestamp || new Date().toISOString(),
dropset_items: s.isDropset ? (s.subSets || []).map((item, idx) => ({
dropset_items: isDropset
? (s.subSets || []).map((item, itemIndex) => ({
weight: parseFloat(item.weight) || 0,
reps: parseInt(item.reps) || 0,
sort_order: idx
})) : []
reps: parseInt(item.reps, 10) || 0,
sort_order: item.sort_order ?? itemIndex,
}))
})),
cardio_sessions: (w.cardio || []).map((c, cIdx) => ({
sync_id: c.sync_id || generateUUID(),
cardio_type: c.type,
purpose: c.purpose,
: [],
};
});
}
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: cIdx
}))
sort_order: c.sort_order ?? cIndex,
};
})
};
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} тренировок`);
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 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 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);
console.error("❌ Sync error:", e);
alert("Ошибка синхронизации: " + e.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '🔄';
btn.textContent = "🔄";
}
}
}