auth begin

This commit is contained in:
2026-10-03 07:51:44 +03:00
parent 3e966c9ff6
commit 86bcd5b141
8 changed files with 370 additions and 33 deletions
+72
View File
@@ -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
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -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"
}
+156 -31
View File
@@ -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)
+2
View File
@@ -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
+100
View File
@@ -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)
+6 -1
View File
@@ -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')
+31
View File
@@ -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
# ============================================================