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
+509 -144
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 = APIRouter(
prefix="/sync",
tags=["sync"],
dependencies=[Depends(auth.get_current_user)],
)
DEFAULT_CLIENT_COLOR = "#bb86fc"
def get_current_timestamp() -> str:
return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ")
def new_sync_id() -> str:
return uuid.uuid4().hex
def normalize_name(name: str) -> str:
return (name or "").strip().lower()
def resolve_workout_type_id(
db: Session,
workout_type_id: Optional[int],
) -> Optional[int]:
if not workout_type_id:
return None
wt = db.get(models.WorkoutType, workout_type_id)
return wt.id if wt else None
def upsert_client(
db: Session,
user: models.User,
data: schemas.ClientCreate,
timestamp: str,
) -> Tuple[models.Client, str]:
sync_id = data.sync_id or new_sync_id()
existing = db.query(models.Client).filter(models.Client.sync_id == sync_id).first()
if existing:
if existing.owner_id != user.id:
raise HTTPException(status_code=409, detail="Client sync_id belongs to another user")
existing.full_name = data.full_name
existing.birth_date = data.birth_date
existing.goal = data.goal or ""
existing.notes = data.notes or ""
existing.color = data.color or existing.color or DEFAULT_CLIENT_COLOR
existing.is_active = data.is_active if data.is_active is not None else existing.is_active
existing.updated_at = timestamp
db.flush()
return existing, "updated"
obj = models.Client(
sync_id=sync_id,
owner_id=user.id,
full_name=data.full_name,
birth_date=data.birth_date,
goal=data.goal or "",
notes=data.notes or "",
color=data.color or DEFAULT_CLIENT_COLOR,
is_active=data.is_active if data.is_active is not None else 1,
created_at=timestamp,
updated_at=timestamp,
)
db.add(obj)
db.flush()
return obj, "created"
def find_exercise_by_name(
db: Session,
user: models.User,
name: str,
) -> Optional[models.Exercise]:
n = normalize_name(name)
if not n:
return None
owned = (
db.query(models.Exercise)
.filter(
models.Exercise.owner_id == user.id,
func.lower(models.Exercise.name) == n,
)
.first()
)
if owned:
return owned
return (
db.query(models.Exercise)
.filter(
models.Exercise.owner_id.is_(None),
func.lower(models.Exercise.name) == n,
)
.first()
)
def upsert_exercise(
db: Session,
user: models.User,
data: schemas.ExerciseCreate,
timestamp: str,
) -> Tuple[models.Exercise, str]:
sync_id = data.sync_id or new_sync_id()
workout_type_id = resolve_workout_type_id(db, data.workout_type_id)
existing = db.query(models.Exercise).filter(models.Exercise.sync_id == sync_id).first()
if existing:
if existing.owner_id is not None and existing.owner_id != user.id:
raise HTTPException(status_code=409, detail="Exercise sync_id belongs to another user")
if existing.owner_id == user.id:
existing.name = (data.name or existing.name).strip()
existing.workout_type_id = workout_type_id
existing.updated_at = timestamp
db.flush()
return existing, "updated"
# Глобальное упражнение: не обновляем, просто переиспользуем
return existing, "global"
by_name = find_exercise_by_name(db, user, data.name)
if by_name:
if by_name.owner_id == user.id:
by_name.sync_id = by_name.sync_id or sync_id
by_name.name = (data.name or by_name.name).strip()
by_name.workout_type_id = workout_type_id
by_name.updated_at = timestamp
db.flush()
return by_name, "updated"
# Нашли глобальное с тем же именем
return by_name, "global"
obj = models.Exercise(
sync_id=sync_id,
owner_id=user.id,
name=(data.name or "Без названия").strip(),
workout_type_id=workout_type_id,
created_at=timestamp,
updated_at=timestamp,
)
db.add(obj)
db.flush()
return obj, "created"
def resolve_client_id_from_sync_id(
db: Session,
user: models.User,
client_sync_id: Optional[str],
client_map: Dict[str, int],
) -> Optional[int]:
if not client_sync_id:
return None
if client_sync_id in client_map:
return client_map[client_sync_id]
client = (
db.query(models.Client)
.filter(
models.Client.sync_id == client_sync_id,
models.Client.owner_id == user.id,
)
.first()
)
return client.id if client else None
def resolve_exercise_id(
db: Session,
user: models.User,
exercise_sync_id: Optional[str],
exercise_name: Optional[str],
exercise_map: Dict[str, int],
) -> Optional[int]:
if exercise_sync_id:
if exercise_sync_id in exercise_map:
return exercise_map[exercise_sync_id]
ex = db.query(models.Exercise).filter(models.Exercise.sync_id == exercise_sync_id).first()
if ex and (ex.owner_id is None or ex.owner_id == user.id):
return ex.id
if exercise_name:
ex = find_exercise_by_name(db, user, exercise_name)
if ex:
return ex.id
return None
def create_set_from_payload(
db: Session,
workout_exercise_id: int,
data: schemas.SetCreate,
timestamp: str,
warnings: list,
) -> models.Set:
is_dropset = 1 if data.is_dropset else 0
if is_dropset:
if data.weight is not None or data.reps is not None:
warnings.append("Dropset top-level weight/reps ignored")
data.weight = None
data.reps = None
if not data.dropset_items:
raise HTTPException(status_code=422, detail="Dropset must have items")
else:
if data.weight is None or data.reps is None:
raise HTTPException(status_code=422, detail="Regular set must have weight and reps")
db_set = models.Set(
sync_id=data.sync_id or new_sync_id(),
workout_exercise_id=workout_exercise_id,
is_dropset=is_dropset,
weight=None if is_dropset else float(data.weight),
reps=None if is_dropset else int(data.reps),
rating=data.rating or 3,
notes=data.notes or "",
sort_order=data.sort_order or 0,
timestamp=data.timestamp or timestamp,
created_at=timestamp,
)
db.add(db_set)
db.flush()
if is_dropset:
for idx, item in enumerate(data.dropset_items or []):
db.add(
models.DropsetItem(
set_id=db_set.id,
weight=float(item.weight),
reps=int(item.reps),
sort_order=item.sort_order if item.sort_order is not None else idx,
)
)
return db_set
def create_cardio_from_payload(
db: Session,
workout_id: int,
data: schemas.CardioCreate,
timestamp: str,
) -> models.Cardio:
db_cardio = models.Cardio(
sync_id=data.sync_id or new_sync_id(),
workout_id=workout_id,
cardio_type=data.cardio_type,
purpose=data.purpose,
duration=data.duration,
distance=data.distance,
speed=data.speed,
incline=data.incline,
resistance=data.resistance,
notes=data.notes or "",
sort_order=data.sort_order or 0,
created_at=timestamp,
)
db.add(db_cardio)
return db_cardio
def resolve_metric_type_for_sync(
db: Session,
value: schemas.BodyMetricValueCreate,
) -> Optional[models.MetricType]:
if value.metric_type_id:
return db.get(models.MetricType, value.metric_type_id)
if value.metric_type_sync_id:
return (
db.query(models.MetricType)
.filter(models.MetricType.sync_id == value.metric_type_sync_id)
.first()
)
return None
@router.post("/", response_model=schemas.SyncResponse)
def sync_data(payload: schemas.SyncPayload, db: Session = Depends(database.get_db)):
"""
Endpoint для синхронизации офлайн-очереди с клиента.
Использует sync_id для идемпотентности — если запись с таким sync_id уже есть, пропускаем.
"""
def sync_data(
payload: schemas.SyncPayload,
current_user: models.User = Depends(auth.get_current_user),
db: Session = Depends(database.get_db),
):
timestamp = get_current_timestamp()
warnings = []
created = {
"clients": 0,
"workout_types": 0,
"exercises": 0,
"workouts": 0,
"metric_types": 0,
"body_metrics": 0
"body_metrics": 0,
}
updated = {
"clients": 0,
"exercises": 0,
"workouts": 0,
"body_metrics": 0,
}
updated = {k: 0 for k in created}
# Синхронизация клиентов
client_map: Dict[str, int] = {}
exercise_map: Dict[str, int] = {}
# ========================================================
# 1. Clients
# ========================================================
for client_data in payload.clients or []:
existing = db.query(models.Client).filter(models.Client.sync_id == client_data.sync_id).first()
if not existing:
db_client = models.Client(
**client_data.dict(),
created_at=timestamp,
updated_at=timestamp
)
db.add(db_client)
obj, status = upsert_client(db, current_user, client_data, timestamp)
client_map[obj.sync_id] = obj.id
if status == "created":
created["clients"] += 1
elif status == "updated":
updated["clients"] += 1
# Синхронизация типов тренировок
for wt_data in payload.workout_types or []:
existing = db.query(models.WorkoutType).filter(models.WorkoutType.sync_id == wt_data.sync_id).first()
if not existing:
db_wt = models.WorkoutType(
**wt_data.dict(),
created_at=timestamp
)
db.add(db_wt)
created["workout_types"] += 1
# ========================================================
# 2. Exercises
# ========================================================
for exercise_data in payload.exercises or []:
obj, status = upsert_exercise(db, current_user, exercise_data, timestamp)
exercise_map[obj.sync_id] = obj.id
# Синхронизация упражнений
for ex_data in payload.exercises or []:
existing = db.query(models.Exercise).filter(models.Exercise.sync_id == ex_data.sync_id).first()
if not existing:
db_ex = models.Exercise(
**ex_data.dict(),
created_at=timestamp,
updated_at=timestamp
)
db.add(db_ex)
if status == "created":
created["exercises"] += 1
elif status == "updated":
updated["exercises"] += 1
# Синхронизация типов замеров
for mt_data in payload.metric_types or []:
existing = db.query(models.MetricType).filter(models.MetricType.sync_id == mt_data.sync_id).first()
if not existing:
db_mt = models.MetricType(
**mt_data.dict(),
created_at=timestamp
)
db.add(db_mt)
created["metric_types"] += 1
# Синхронизация тренировок (с упражнениями и подходами)
# ========================================================
# 3. Workouts
# ========================================================
for workout_data in payload.workouts or []:
existing = db.query(models.Workout).filter(models.Workout.sync_id == workout_data.sync_id).first()
if not existing:
db_workout = models.Workout(
sync_id=workout_data.sync_id,
client_id=workout_data.client_id,
workout_type_id=workout_data.workout_type_id,
start_time=workout_data.start_time,
end_time=workout_data.end_time,
notes=workout_data.notes,
sync_id = workout_data.sync_id or new_sync_id()
existing = db.query(models.Workout).filter(models.Workout.sync_id == sync_id).first()
if existing:
if existing.owner_id != current_user.id:
raise HTTPException(status_code=409, detail="Workout sync_id belongs to another user")
existing.start_time = workout_data.start_time or existing.start_time
existing.end_time = workout_data.end_time if workout_data.end_time is not None else existing.end_time
existing.notes = workout_data.notes if workout_data.notes is not None else existing.notes
existing.workout_type_id = resolve_workout_type_id(db, workout_data.workout_type_id)
existing.synced = 1
existing.updated_at = timestamp
updated["workouts"] += 1
continue
client_id = resolve_client_id_from_sync_id(
db,
current_user,
workout_data.client_sync_id,
client_map,
)
if workout_data.client_id and not client_id:
client = (
db.query(models.Client)
.filter(
models.Client.id == workout_data.client_id,
models.Client.owner_id == current_user.id,
)
.first()
)
client_id = client.id if client else None
if workout_data.client_sync_id and not client_id:
warnings.append(
f"Client sync_id {workout_data.client_sync_id} not found for workout {sync_id}"
)
db_workout = models.Workout(
sync_id=sync_id,
owner_id=current_user.id,
client_id=client_id,
workout_type_id=resolve_workout_type_id(db, workout_data.workout_type_id),
start_time=workout_data.start_time,
end_time=workout_data.end_time,
notes=workout_data.notes or "",
synced=1,
created_at=timestamp,
updated_at=timestamp,
)
db.add(db_workout)
db.flush()
created["workouts"] += 1
for we_data in workout_data.workout_exercises or []:
exercise_id = resolve_exercise_id(
db,
current_user,
we_data.exercise_sync_id,
we_data.exercise_name,
exercise_map,
)
db_we = models.WorkoutExercise(
sync_id=we_data.sync_id or new_sync_id(),
workout_id=db_workout.id,
exercise_id=exercise_id,
exercise_name=we_data.exercise_name,
sort_order=we_data.sort_order or 0,
created_at=timestamp,
updated_at=timestamp
)
db.add(db_workout)
db.add(db_we)
db.flush()
# Упражнения
for we_data in workout_data.workout_exercises or []:
db_we = models.WorkoutExercise(
sync_id=we_data.sync_id,
workout_id=db_workout.id,
exercise_id=we_data.exercise_id,
exercise_name=we_data.exercise_name,
sort_order=we_data.sort_order,
created_at=timestamp
)
db.add(db_we)
db.flush()
for set_data in we_data.sets or []:
create_set_from_payload(db, db_we.id, set_data, timestamp, warnings)
# Подходы
for set_data in we_data.sets or []:
db_set = models.Set(
sync_id=set_data.sync_id,
workout_exercise_id=db_we.id,
is_dropset=set_data.is_dropset,
weight=set_data.weight,
reps=set_data.reps,
rating=set_data.rating,
notes=set_data.notes,
sort_order=set_data.sort_order,
timestamp=set_data.timestamp,
created_at=timestamp
)
db.add(db_set)
db.flush()
for cardio_data in workout_data.cardio_sessions or []:
create_cardio_from_payload(db, db_workout.id, cardio_data, timestamp)
# Дроп-сет элементы
if set_data.is_dropset:
for item_data in set_data.dropset_items or []:
db_item = models.DropsetItem(
set_id=db_set.id,
weight=item_data.weight,
reps=item_data.reps,
sort_order=item_data.sort_order
)
db.add(db_item)
# Кардио
for cardio_data in workout_data.cardio_sessions or []:
db_cardio = models.Cardio(
sync_id=cardio_data.sync_id,
workout_id=db_workout.id,
cardio_type=cardio_data.cardio_type,
purpose=cardio_data.purpose,
duration=cardio_data.duration,
distance=cardio_data.distance,
speed=cardio_data.speed,
incline=cardio_data.incline,
resistance=cardio_data.resistance,
notes=cardio_data.notes,
sort_order=cardio_data.sort_order,
created_at=timestamp
)
db.add(db_cardio)
created["workouts"] += 1
# Синхронизация замеров тела
# ========================================================
# 4. Body metrics
# ========================================================
for metric_data in payload.body_metrics or []:
existing = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == metric_data.sync_id).first()
if not existing:
db_metric = models.BodyMetric(
sync_id=metric_data.sync_id,
client_id=metric_data.client_id,
metric_date=metric_data.metric_date,
notes=metric_data.notes,
created_at=timestamp
sync_id = metric_data.sync_id or new_sync_id()
existing = db.query(models.BodyMetric).filter(models.BodyMetric.sync_id == sync_id).first()
if existing:
if existing.owner_id != current_user.id:
raise HTTPException(status_code=409, detail="BodyMetric sync_id belongs to another user")
existing.metric_date = metric_data.metric_date
existing.notes = metric_data.notes or ""
client_id = resolve_client_id_from_sync_id(
db,
current_user,
metric_data.client_sync_id,
client_map,
)
db.add(db_metric)
db.flush()
if metric_data.client_id and not client_id:
client = (
db.query(models.Client)
.filter(
models.Client.id == metric_data.client_id,
models.Client.owner_id == current_user.id,
)
.first()
)
client_id = client.id if client else None
existing.client_id = client_id
db.query(models.BodyMetricValue).filter(
models.BodyMetricValue.body_metric_id == existing.id
).delete()
for value_data in metric_data.values or []:
db_value = models.BodyMetricValue(
body_metric_id=db_metric.id,
metric_type_id=value_data.metric_type_id,
value=value_data.value
)
db.add(db_value)
mt = resolve_metric_type_for_sync(db, value_data)
if not mt:
warnings.append("Body metric value skipped: metric type not found")
continue
created["body_metrics"] += 1
db.add(
models.BodyMetricValue(
body_metric_id=existing.id,
metric_type_id=mt.id,
value=float(value_data.value),
)
)
updated["body_metrics"] += 1
continue
client_id = resolve_client_id_from_sync_id(
db,
current_user,
metric_data.client_sync_id,
client_map,
)
if metric_data.client_id and not client_id:
client = (
db.query(models.Client)
.filter(
models.Client.id == metric_data.client_id,
models.Client.owner_id == current_user.id,
)
.first()
)
client_id = client.id if client else None
db_metric = models.BodyMetric(
sync_id=sync_id,
owner_id=current_user.id,
client_id=client_id,
metric_date=metric_data.metric_date,
notes=metric_data.notes or "",
created_at=timestamp,
)
db.add(db_metric)
db.flush()
created["body_metrics"] += 1
for value_data in metric_data.values or []:
mt = resolve_metric_type_for_sync(db, value_data)
if not mt:
warnings.append("Body metric value skipped: metric type not found")
continue
db.add(
models.BodyMetricValue(
body_metric_id=db_metric.id,
metric_type_id=mt.id,
value=float(value_data.value),
)
)
db.commit()
return schemas.SyncResponse(
status="success",
created=created,
updated=updated
updated=updated,
warnings=warnings,
)