Files
gym-bro/backend/routers/sync.py
T
2026-10-03 09:44:11 +03:00

552 lines
16 KiB
Python

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
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,
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 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,
)
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 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,
warnings=warnings,
)