diff --git a/backend/auth.py b/backend/auth.py new file mode 100644 index 0000000..94e4e54 --- /dev/null +++ b/backend/auth.py @@ -0,0 +1,72 @@ +import os +from datetime import datetime, timedelta, timezone +from typing import Optional + +import bcrypt +import jwt +from jwt.exceptions import PyJWTError + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session + +import database +import models + + +SECRET = os.getenv("GYM_JWT_SECRET", "dev-secret-change-me") +ALGORITHM = "HS256" +EXPIRE_DAYS = int(os.getenv("GYM_JWT_EXPIRE_DAYS", "30")) + +bearer_scheme = HTTPBearer(auto_error=False) + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def verify_password(password: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8")) + except ValueError: + return False + + +def create_access_token(user: models.User) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(user.id), + "username": user.username, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(days=EXPIRE_DAYS)).timestamp()), + } + return jwt.encode(payload, SECRET, algorithm=ALGORITHM) + + +def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme), + db: Session = Depends(database.get_db), +) -> models.User: + if credentials is None or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + + try: + payload = jwt.decode(credentials.credentials, SECRET, algorithms=[ALGORITHM]) + user_id = int(payload["sub"]) + except (PyJWTError, KeyError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) + + user = db.query(models.User).filter(models.User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + return user diff --git a/backend/gym.db b/backend/gym.db index 6fc8292..4f0e8a6 100644 Binary files a/backend/gym.db and b/backend/gym.db differ diff --git a/backend/main.py b/backend/main.py index 8f02231..9270c35 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from database import engine, Base import routers.clients +import routers.auth import routers.workout_types import routers.workouts import routers.exercises @@ -29,6 +30,7 @@ app.add_middleware( ) # Подключаем роутеры +app.include_router(routers.auth.router) app.include_router(routers.clients.router) app.include_router(routers.workout_types.router) app.include_router(routers.workouts.router) @@ -39,7 +41,7 @@ app.include_router(routers.sync.router) @app.get("/") def root(): return { - "message": "Gym Tracker API", + "message": "Gym Bro API", "docs": "/docs", "version": "1.0.0" } diff --git a/backend/models.py b/backend/models.py index bd32062..bedf989 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,22 +1,27 @@ -from sqlalchemy import Column, Integer, String, Float, ForeignKey, CheckConstraint, Index +from sqlalchemy import ( + Column, + Integer, + String, + Float, + ForeignKey, + CheckConstraint, + UniqueConstraint, +) from sqlalchemy.orm import relationship + from database import Base -class Client(Base): - __tablename__ = "clients" + +class User(Base): + __tablename__ = "users" 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) + username = Column(String, unique=True, nullable=False, index=True) + password_hash = Column(String, nullable=False) + display_name = Column(String, default="") + role = Column(String, default="personal") # personal | trainer 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" @@ -30,12 +35,22 @@ class WorkoutType(Base): 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")) + + # NULL = глобальный справочник, иначе — личное упражнение пользователя + owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True) + + workout_type_id = Column( + Integer, + ForeignKey("workout_types.id", ondelete="SET NULL"), + index=True, + ) + name = Column(String, nullable=False, index=True) created_at = Column(String) updated_at = Column(String) @@ -43,46 +58,99 @@ class Exercise(Base): workout_type = relationship("WorkoutType", back_populates="exercises") workout_exercises = relationship("WorkoutExercise", back_populates="exercise") + +class Client(Base): + __tablename__ = "clients" + + id = Column(Integer, primary_key=True, index=True) + sync_id = Column(String, unique=True, nullable=False, index=True) + owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True) + + full_name = Column(String, nullable=False) + birth_date = Column(String) + goal = Column(String, default="") + notes = Column(String, default="") + color = Column(String) + 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 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")) + + owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True) + client_id = Column(Integer, ForeignKey("clients.id", ondelete="SET NULL"), index=True) + workout_type_id = Column( + Integer, + ForeignKey("workout_types.id", ondelete="SET NULL"), + index=True, + ) + 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") + + 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) + + workout_id = Column(Integer, ForeignKey("workouts.id", ondelete="CASCADE"), index=True) + exercise_id = Column(Integer, ForeignKey("exercises.id", ondelete="SET NULL"), index=True) + + exercise_name = Column(String, nullable=False, index=True) 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") + 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")) + + workout_exercise_id = Column( + Integer, + ForeignKey("workout_exercises.id", ondelete="CASCADE"), + index=True, + ) + is_dropset = Column(Integer, default=0) weight = Column(Float) reps = Column(Integer) @@ -93,51 +161,73 @@ class Set(Base): created_at = Column(String) workout_exercise = relationship("WorkoutExercise", back_populates="sets") - dropset_items = relationship("DropsetItem", back_populates="set", cascade="all, delete-orphan") + 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" + name="ck_sets_dropset_shape", + ), + CheckConstraint( + "rating IS NULL OR (rating >= 1 AND rating <= 5)", + name="ck_sets_rating", ), ) + class DropsetItem(Base): __tablename__ = "dropset_items" id = Column(Integer, primary_key=True, index=True) - set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE")) + set_id = Column(Integer, ForeignKey("sets.id", ondelete="CASCADE"), index=True) + weight = Column(Float, nullable=False) reps = Column(Integer, nullable=False) sort_order = Column(Integer, default=0) set = relationship("Set", back_populates="dropset_items") + __table_args__ = ( + CheckConstraint("weight > 0", name="ck_dropset_weight_positive"), + CheckConstraint("reps > 0", name="ck_dropset_reps_positive"), + ) + + 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")) + + workout_id = Column(Integer, ForeignKey("workouts.id", ondelete="CASCADE"), index=True) + cardio_type = Column(String, nullable=False) - purpose = Column(String, nullable=False) + purpose = Column(String, nullable=False) # warmup | cooldown | separate | другое + 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") @@ -146,26 +236,61 @@ class MetricType(Base): 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")) + + owner_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True) + client_id = Column(Integer, ForeignKey("clients.id", ondelete="SET NULL"), index=True) + 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") + 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")) + + body_metric_id = Column( + Integer, + ForeignKey("body_metrics.id", ondelete="CASCADE"), + index=True, + ) + metric_type_id = Column( + Integer, + ForeignKey("metric_types.id", ondelete="CASCADE"), + index=True, + ) + value = Column(Float, nullable=False) body_metric = relationship("BodyMetric", back_populates="values") metric_type = relationship("MetricType", back_populates="metric_values") + + __table_args__ = ( + UniqueConstraint( + "body_metric_id", + "metric_type_id", + name="uq_body_metric_value_type", + ), + ) + + +class SyncLog(Base): + __tablename__ = "sync_log" + + device_id = Column(String, primary_key=True) + last_sync = Column(String, nullable=False) + updated_at = Column(String) diff --git a/backend/requirements.txt b/backend/requirements.txt index 4ab590c..754b52e 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,5 @@ uvicorn[standard]==0.24.0 sqlalchemy==2.0.23 pydantic==2.5.0 python-multipart==0.0.6 +PyJWT==2.8.0 +bcrypt==4.1.2 diff --git a/backend/routers/auth.py b/backend/routers/auth.py new file mode 100644 index 0000000..87ee0f9 --- /dev/null +++ b/backend/routers/auth.py @@ -0,0 +1,100 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +import auth +import database +import models +import schemas + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +def get_current_timestamp() -> str: + return datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%fZ") + + +def user_to_out(user: models.User) -> schemas.UserOut: + return schemas.UserOut( + id=user.id, + username=user.username, + display_name=user.display_name or user.username, + role=user.role or "personal", + created_at=user.created_at or get_current_timestamp(), + ) + + +@router.post("/register", response_model=schemas.Token) +def register( + data: schemas.UserCreate, + db: Session = Depends(database.get_db), +): + username = (data.username or "").strip().lower() + password = data.password or "" + role = data.role or "personal" + + if len(username) < 3: + raise HTTPException(status_code=400, detail="Username must be at least 3 characters") + + if len(password) < 6: + raise HTTPException(status_code=400, detail="Password must be at least 6 characters") + + if role not in ("personal", "trainer"): + role = "personal" + + exists = db.query(models.User).filter(models.User.username == username).first() + if exists: + raise HTTPException(status_code=400, detail="Username already exists") + + user = models.User( + username=username, + password_hash=auth.hash_password(password), + display_name=(data.display_name or username).strip(), + role=role, + created_at=get_current_timestamp(), + ) + + db.add(user) + db.commit() + db.refresh(user) + + token = auth.create_access_token(user) + + return schemas.Token( + access_token=token, + token_type="bearer", + user=user_to_out(user), + ) + + +@router.post("/login", response_model=schemas.Token) +def login( + data: schemas.UserLogin, + db: Session = Depends(database.get_db), +): + username = (data.username or "").strip().lower() + password = data.password or "" + + user = db.query(models.User).filter(models.User.username == username).first() + if not user: + raise HTTPException(status_code=401, detail="Invalid username or password") + + if not auth.verify_password(password, user.password_hash): + raise HTTPException(status_code=401, detail="Invalid username or password") + + token = auth.create_access_token(user) + + return schemas.Token( + access_token=token, + token_type="bearer", + user=user_to_out(user), + ) + + +@router.get("/me", response_model=schemas.UserOut) +def me( + current_user: models.User = Depends(auth.get_current_user), +): + return user_to_out(current_user) diff --git a/backend/routers/workout_types.py b/backend/routers/workout_types.py index 693c25f..e3efbe7 100644 --- a/backend/routers/workout_types.py +++ b/backend/routers/workout_types.py @@ -5,8 +5,13 @@ import database import models import schemas from datetime import datetime +import auth -router = APIRouter(prefix="/workout-types", tags=["workout-types"]) +router = APIRouter( + prefix="/workout-types", + tags=["workout-types"], + dependencies=[Depends(auth.get_current_user)], +) def get_current_timestamp(): return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%fZ') diff --git a/backend/schemas.py b/backend/schemas.py index c7141d3..8af7031 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -2,6 +2,37 @@ from pydantic import BaseModel, Field from typing import Optional, List from datetime import datetime +# ============================================================ +# USERS +# ============================================================ +class UserCreate(BaseModel): + username: str + password: str + display_name: Optional[str] = "" + role: Optional[str] = "personal" + + +class UserLogin(BaseModel): + username: str + password: str + + +class UserOut(BaseModel): + id: int + username: str + display_name: str + role: str + created_at: str + + class Config: + from_attributes = True + + +class Token(BaseModel): + access_token: str + token_type: str = "bearer" + user: UserOut + # ============================================================ # CLIENTS # ============================================================