552 lines
16 KiB
Python
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,
|
|
)
|