diff --git a/.#readme.md b/.#readme.md deleted file mode 120000 index 492fb61..0000000 --- a/.#readme.md +++ /dev/null @@ -1 +0,0 @@ -ivan-igorevich@MacBook-Pro-16.local.32588 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..5ba59b9 --- /dev/null +++ b/backend/database.py @@ -0,0 +1,21 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +SQLALCHEMY_DATABASE_URL = "sqlite:///./gym.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False} # только для SQLite +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +# Dependency +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/gym.db b/backend/gym.db new file mode 100644 index 0000000..2b62ce5 Binary files /dev/null and b/backend/gym.db differ diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..612f3d8 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from database import engine, Base +import routers.clients +import routers.workouts +import routers.exercises +import routers.metrics +import routers.sync + +# Создаём таблицы +Base.metadata.create_all(bind=engine) + +app = FastAPI( + title="Gym Tracker API", + description="API для трекера тренировок с офлайн-синхронизацией", + version="1.0.0" +) + +# CORS для локальной разработки +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # В продакшене заменить на конкретные домены + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Подключаем роутеры +app.include_router(routers.clients.router) +app.include_router(routers.workouts.router) +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 Tracker API", + "docs": "/docs", + "version": "1.0.0" + } + +@app.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..bd32062 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,171 @@ +from sqlalchemy import Column, Integer, String, Float, ForeignKey, CheckConstraint, Index +from sqlalchemy.orm import relationship +from database import Base + +class Client(Base): + __tablename__ = "clients" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + full_name = Column(String, nullable=False) + birth_date = Column(String) + goal = Column(String, default="") + notes = Column(String, default="") + is_active = Column(Integer, default=1) + created_at = Column(String) + updated_at = Column(String) + + workouts = relationship("Workout", back_populates="client") + body_metrics = relationship("BodyMetric", back_populates="client") + +class WorkoutType(Base): + __tablename__ = "workout_types" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + name = Column(String, unique=True, nullable=False) + sort_order = Column(Integer, default=0) + created_at = Column(String) + + exercises = relationship("Exercise", back_populates="workout_type") + workouts = relationship("Workout", back_populates="workout_type") + +class Exercise(Base): + __tablename__ = "exercises" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + workout_type_id = Column(Integer, ForeignKey("workout_types.id", ondelete="SET NULL")) + name = Column(String, nullable=False, index=True) + created_at = Column(String) + updated_at = Column(String) + + workout_type = relationship("WorkoutType", back_populates="exercises") + workout_exercises = relationship("WorkoutExercise", back_populates="exercise") + +class Workout(Base): + __tablename__ = "workouts" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + client_id = Column(Integer, ForeignKey("clients.id", ondelete="SET NULL")) + workout_type_id = Column(Integer, ForeignKey("workout_types.id", ondelete="SET NULL")) + start_time = Column(String, nullable=False) + end_time = Column(String) + notes = Column(String, default="") + synced = Column(Integer, default=0) + created_at = Column(String) + updated_at = Column(String) + + client = relationship("Client", back_populates="workouts") + workout_type = relationship("WorkoutType", back_populates="workouts") + workout_exercises = relationship("WorkoutExercise", back_populates="workout", cascade="all, delete-orphan") + cardio_sessions = relationship("Cardio", back_populates="workout", cascade="all, delete-orphan") + +class WorkoutExercise(Base): + __tablename__ = "workout_exercises" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + workout_id = Column(Integer, ForeignKey("workouts.id", ondelete="CASCADE")) + exercise_id = Column(Integer, ForeignKey("exercises.id", ondelete="SET NULL")) + exercise_name = Column(String, nullable=False) + sort_order = Column(Integer, default=0) + created_at = Column(String) + + workout = relationship("Workout", back_populates="workout_exercises") + exercise = relationship("Exercise", back_populates="workout_exercises") + sets = relationship("Set", back_populates="workout_exercise", cascade="all, delete-orphan") + +class Set(Base): + __tablename__ = "sets" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + workout_exercise_id = Column(Integer, ForeignKey("workout_exercises.id", ondelete="CASCADE")) + is_dropset = Column(Integer, default=0) + weight = Column(Float) + reps = Column(Integer) + rating = Column(Integer) + notes = Column(String, default="") + sort_order = Column(Integer, default=0) + timestamp = Column(String, nullable=False) + created_at = Column(String) + + workout_exercise = relationship("WorkoutExercise", back_populates="sets") + dropset_items = relationship("DropsetItem", back_populates="set", cascade="all, delete-orphan") + + __table_args__ = ( + CheckConstraint( + "(is_dropset = 0 AND weight IS NOT NULL AND reps IS NOT NULL) OR " + "(is_dropset = 1 AND weight IS NULL AND reps IS NULL)", + name="check_set_data" + ), + ) + +class DropsetItem(Base): + __tablename__ = "dropset_items" + + id = Column(Integer, primary_key=True, index=True) + set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE")) + weight = Column(Float, nullable=False) + reps = Column(Integer, nullable=False) + sort_order = Column(Integer, default=0) + + set = relationship("Set", back_populates="dropset_items") + +class Cardio(Base): + __tablename__ = "cardio" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + workout_id = Column(Integer, ForeignKey("workouts.id", ondelete="CASCADE")) + cardio_type = Column(String, nullable=False) + purpose = Column(String, nullable=False) + duration = Column(Integer) + distance = Column(Float) + speed = Column(Float) + incline = Column(Float) + resistance = Column(Integer) + notes = Column(String, default="") + sort_order = Column(Integer, default=0) + created_at = Column(String) + + workout = relationship("Workout", back_populates="cardio_sessions") + +class MetricType(Base): + __tablename__ = "metric_types" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + name = Column(String, unique=True, nullable=False) + unit = Column(String, default="см") + category = Column(String, default="body") + sort_order = Column(Integer, default=0) + created_at = Column(String) + + metric_values = relationship("BodyMetricValue", back_populates="metric_type") + +class BodyMetric(Base): + __tablename__ = "body_metrics" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + client_id = Column(Integer, ForeignKey("clients.id", ondelete="SET NULL")) + metric_date = Column(String, nullable=False) + notes = Column(String, default="") + created_at = Column(String) + + client = relationship("Client", back_populates="body_metrics") + values = relationship("BodyMetricValue", back_populates="body_metric", cascade="all, delete-orphan") + +class BodyMetricValue(Base): + __tablename__ = "body_metric_values" + + id = Column(Integer, primary_key=True, index=True) + body_metric_id = Column(Integer, ForeignKey("body_metrics.id", ondelete="CASCADE")) + metric_type_id = Column(Integer, ForeignKey("metric_types.id", ondelete="CASCADE")) + value = Column(Float, nullable=False) + + body_metric = relationship("BodyMetric", back_populates="values") + metric_type = relationship("MetricType", back_populates="metric_values") diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..4ab590c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +pydantic==2.5.0 +python-multipart==0.0.6 diff --git a/backend/routers/clients.py b/backend/routers/clients.py new file mode 100644 index 0000000..f5115c4 --- /dev/null +++ b/backend/routers/clients.py @@ -0,0 +1,65 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +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 + ) + 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.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") + + update_data = client.dict(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_client, key, value) + + db_client.updated_at = get_current_timestamp() + db.commit() + 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") + db.delete(db_client) + db.commit() + return {"status": "deleted"} diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py new file mode 100644 index 0000000..f5b0703 --- /dev/null +++ b/backend/routers/exercises.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +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 + ) + 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) + 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: + raise HTTPException(status_code=404, detail="Exercise not found") + + update_data = exercise.dict(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_exercise, key, value) + + db_exercise.updated_at = get_current_timestamp() + db.commit() + 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") + 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 + ] diff --git a/backend/routers/metrics.py b/backend/routers/metrics.py new file mode 100644 index 0000000..d2fe7b8 --- /dev/null +++ b/backend/routers/metrics.py @@ -0,0 +1,99 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +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') + +# ============================================================ +# 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") + + db_mt = models.MetricType( + **metric_type.dict(), + 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]) +def list_metric_types(db: Session = Depends(database.get_db)): + return db.query(models.MetricType).order_by(models.MetricType.sort_order).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") + + db_metric = models.BodyMetric( + sync_id=metric.sync_id, + client_id=metric.client_id, + metric_date=metric.metric_date, + notes=metric.notes, + created_at=get_current_timestamp() + ) + db.add(db_metric) + db.flush() + + # Добавляем значения + for value_data in metric.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) + + 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) + if client_id: + 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) +): + """Получить историю конкретного замера для графиков""" + results = db.query( + models.BodyMetric.metric_date, + models.BodyMetricValue.value + ).join( + models.BodyMetricValue, models.BodyMetricValue.body_metric_id == models.BodyMetric.id + ).filter( + models.BodyMetric.client_id == client_id, + 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 + } + for r in results + ] diff --git a/backend/routers/sync.py b/backend/routers/sync.py new file mode 100644 index 0000000..9676979 --- /dev/null +++ b/backend/routers/sync.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +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 + ) + 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 + + # Синхронизация типов замеров + 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) + 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 []: + 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() + + # Дроп-сет элементы + 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 + + # Синхронизация замеров тела + 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 + ) + db.add(db_metric) + 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) + + created["body_metrics"] += 1 + + db.commit() + + return schemas.SyncResponse( + status="success", + created=created, + updated=updated + ) diff --git a/backend/routers/workouts.py b/backend/routers/workouts.py new file mode 100644 index 0000000..7435fea --- /dev/null +++ b/backend/routers/workouts.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session, joinedload +from typing import List +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.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") + + timestamp = get_current_timestamp() + db_workout = models.Workout( + sync_id=workout.sync_id, + client_id=workout.client_id, + workout_type_id=workout.workout_type_id, + start_time=workout.start_time, + end_time=workout.end_time, + notes=workout.notes, + created_at=timestamp, + updated_at=timestamp + ) + db.add(db_workout) + db.flush() # Получаем ID + + # Добавляем упражнения + for we_data in workout.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 []: + 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() + + # Добавляем элементы дроп-сета + 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, + 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) + + db.commit() + db.refresh(db_workout) + return db_workout + +@router.get("/", response_model=List[schemas.Workout]) +def list_workouts( + client_id: int = None, + skip: int = 0, + limit: int = 100, + 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) + ) + + if client_id: + 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( + 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() + + 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") + + update_data = workout.dict(exclude_unset=True) + for key, value in update_data.items(): + setattr(db_workout, key, value) + + db_workout.updated_at = get_current_timestamp() + db.commit() + 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") + db.delete(db_workout) + db.commit() + return {"status": "deleted"} diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..c7141d3 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,267 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +# ============================================================ +# 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 +# ============================================================ +class WorkoutTypeBase(BaseModel): + name: str + sort_order: Optional[int] = 0 + +class WorkoutTypeCreate(WorkoutTypeBase): + sync_id: str + +class WorkoutType(WorkoutTypeBase): + id: int + sync_id: str + created_at: str + + class Config: + from_attributes = True + +# ============================================================ +# EXERCISES +# ============================================================ +class ExerciseBase(BaseModel): + name: str + workout_type_id: Optional[int] = None + +class ExerciseCreate(ExerciseBase): + sync_id: str + +class ExerciseUpdate(BaseModel): + name: Optional[str] = None + workout_type_id: Optional[int] = None + +class Exercise(ExerciseBase): + id: int + sync_id: str + created_at: str + updated_at: str + + class Config: + from_attributes = True + +# ============================================================ +# DROPSET ITEMS +# ============================================================ +class DropsetItemBase(BaseModel): + weight: float = Field(gt=0) + reps: int = Field(gt=0) + sort_order: int = 0 + +class DropsetItemCreate(DropsetItemBase): + pass + +class DropsetItem(DropsetItemBase): + id: int + + class Config: + from_attributes = True + +# ============================================================ +# SETS +# ============================================================ +class SetBase(BaseModel): + is_dropset: int = 0 + weight: Optional[float] = None + reps: Optional[int] = None + rating: Optional[int] = Field(None, ge=1, le=5) + notes: Optional[str] = "" + sort_order: int = 0 + timestamp: str + +class SetCreate(SetBase): + sync_id: str + dropset_items: Optional[List[DropsetItemCreate]] = [] + +class Set(SetBase): + id: int + sync_id: str + created_at: str + dropset_items: List[DropsetItem] = [] + + class Config: + from_attributes = True + +# ============================================================ +# WORKOUT EXERCISES +# ============================================================ +class WorkoutExerciseBase(BaseModel): + exercise_id: Optional[int] = None + exercise_name: str + sort_order: int = 0 + +class WorkoutExerciseCreate(WorkoutExerciseBase): + sync_id: str + sets: Optional[List[SetCreate]] = [] + +class WorkoutExercise(WorkoutExerciseBase): + id: int + sync_id: str + created_at: str + sets: List[Set] = [] + + class Config: + from_attributes = True + +# ============================================================ +# CARDIO +# ============================================================ +class CardioBase(BaseModel): + cardio_type: str + purpose: str + duration: Optional[int] = None + distance: Optional[float] = None + speed: Optional[float] = None + incline: Optional[float] = None + resistance: Optional[int] = None + notes: Optional[str] = "" + sort_order: int = 0 + +class CardioCreate(CardioBase): + sync_id: str + +class Cardio(CardioBase): + id: int + sync_id: str + created_at: str + + 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] = "" + +class WorkoutCreate(WorkoutBase): + sync_id: str + workout_exercises: Optional[List[WorkoutExerciseCreate]] = [] + cardio_sessions: Optional[List[CardioCreate]] = [] + +class WorkoutUpdate(BaseModel): + workout_type_id: Optional[int] = None + end_time: Optional[str] = None + notes: Optional[str] = None + synced: Optional[int] = None + +class Workout(WorkoutBase): + id: int + sync_id: str + synced: int + created_at: str + updated_at: str + workout_exercises: List[WorkoutExercise] = [] + cardio_sessions: List[Cardio] = [] + + 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): + 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): + id: int + sync_id: str + created_at: str + values: List[BodyMetricValue] = [] + + 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]] = [] + +class SyncResponse(BaseModel): + status: str + created: dict + updated: dict diff --git a/#readme.md# b/readme.md similarity index 99% rename from #readme.md# rename to readme.md index 3a96e26..5a66555 100644 --- a/#readme.md# +++ b/readme.md @@ -19,3 +19,4 @@ clients (ФИО, ДР, цель) │ 1:N └──► body_metric_values ──► metric_types (справочник) ``` +