From 86bcd5b1411032127900a140cd068ff4f0a23c2a Mon Sep 17 00:00:00 2001 From: "Wayne H. Shephard" Date: Sat, 3 Oct 2026 07:51:44 +0300 Subject: [PATCH] auth begin --- backend/auth.py | 72 ++++++++++++ backend/gym.db | Bin 151552 -> 172032 bytes backend/main.py | 4 +- backend/models.py | 187 ++++++++++++++++++++++++++----- backend/requirements.txt | 2 + backend/routers/auth.py | 100 +++++++++++++++++ backend/routers/workout_types.py | 7 +- backend/schemas.py | 31 +++++ 8 files changed, 370 insertions(+), 33 deletions(-) create mode 100644 backend/auth.py create mode 100644 backend/routers/auth.py 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 6fc829224221c7434aa46e8b59421fe1cf7b260f..4f0e8a60196888538df7c28d9207a5a615999c27 100644 GIT binary patch delta 933 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 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 # ============================================================