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