diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..59be703 --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +Base = declarative_base() diff --git a/app/db/models/orders.py b/app/db/models/orders.py index 54eabb4..ddd59ee 100644 --- a/app/db/models/orders.py +++ b/app/db/models/orders.py @@ -1,8 +1,7 @@ from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Numeric, func from sqlalchemy.orm import relationship, Session from sqlalchemy.orm import declarative_base - -Base = declarative_base() +from app.db.base import Base class Order(Base): __tablename__ = "orders" diff --git a/app/db/models/users.py b/app/db/models/users.py index 58b243e..e268d80 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -1,8 +1,10 @@ from sqlalchemy import Column, Integer, String, DateTime, func from sqlalchemy.orm import relationship, Session -from sqlalchemy.orm import declarative_base - -Base = declarative_base() +from app.db.session import engine +from app.db.models.orders import Order +from app.db.base import Base +from fastapi import HTTPException, status +from app.validators.users import UserUpdate class User(Base): __tablename__ = "users" @@ -11,20 +13,64 @@ class User(Base): username = Column(String(255), unique=True, nullable=False) email = Column(String(255), unique=True, nullable=False) hashed_password = Column(String(255), nullable=False) - role = Column(String(50), default="customer") - created_at = Column(DateTime, server_default=func.now()) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + role = Column(String(50), nullable=False, default="customer") + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + orders = relationship(Order, back_populates="user", cascade="all, delete-orphan") + + +def create_tables(): + Base.metadata.create_all(bind=engine) - orders = relationship("Order", back_populates="user", cascade="all, delete-orphan") # --- DB interaction functions --- -def get_user_by_id(db: Session, user_id: int): +def get_user_by_id(db: Session, user_id: int) -> User | None: return db.query(User).filter(User.id == user_id).first() -def create_user(db: Session, username: str, email: str, hashed_password: str, role: str = "customer"): - user = User(username=username, email=email, hashed_password=hashed_password, role=role) +def get_user_by_email(db: Session, email: str): + return db.query(User).filter(User.email == email).first() + +def get_user_by_username(db: Session, username: str): + return db.query(User).filter(User.username == username).first() + +def create_user(db: Session, username: str, email: str, hashed_password: str, role: str = "customer") -> User: + user = User( + username=username, + email=email, + hashed_password=hashed_password, + role=role + ) db.add(user) db.commit() db.refresh(user) - return user \ No newline at end of file + return user + +def get_all_users(db: Session): + return db.query(User).all() + +def delete_user_by_id(db: Session, user_id: int): + user = db.query(User).filter(User.id == user_id).first() + if user: + db.delete(user) + db.commit() + +def update_user_db(db: Session, user_id: int, user_update: UserUpdate): + user = db.query(User).filter(User.id == user_id).first() + + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"User with ID {user_id} not found" + ) + + if user_update.username is not None: + user.username = user_update.username + if user_update.email is not None: + user.email = user_update.email + + db.commit() + db.refresh(user) + return user + diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..474a2ee --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,28 @@ +# app/database/session.py + +import os +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator + +# Load environment variables from .env file +load_dotenv() + +DB_HOST = os.getenv("DB_HOST", "localhost") +DB_PORT = os.getenv("DB_PORT", "5432") +DB_USER = os.getenv("POSTGRES_USER", "postgres") +DB_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password") +DB_NAME = os.getenv("POSTGRES_DB", "db_name") + +DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + +engine = create_engine(DATABASE_URL, echo=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..5b83fb7 --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.services.auth import authenticate_user, create_token_for_user + +router = APIRouter() + +@router.post("/token") +def login_for_access_token( + db: Session = Depends(get_db), + form_data: OAuth2PasswordRequestForm = Depends() +): + user = authenticate_user(db, form_data.username, form_data.password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + headers={"WWW-Authenticate": "Bearer"}, + ) + token = create_token_for_user(user) + return {"access_token": token, "token_type": "bearer"} diff --git a/app/routers/users.py b/app/routers/users.py index 66cce2e..c6cc938 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,46 +1,164 @@ from fastapi import APIRouter, Depends, HTTPException, status -from typing import List, Optional +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.validators.users import UserCreate, UserUpdate, UserOut, TokenData +from app.services.users import ( + create_user_service, + list_users_service, + get_user_service, + delete_user_service, + update_user_service, +) +from typing import List +from app.utils.jwt import get_current_user, admin_required router = APIRouter() -# --- User Endpoints --- +@router.post("/", summary="Create a new user (Admin only)", response_model=UserOut) +def create_user( + user_in: UserCreate, + db: Session = Depends(get_db), + current_user: TokenData = Depends(admin_required), +): + try: + user = create_user_service(db, user_in) + return user + except ValueError as ve: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve)) + except Exception as e: + print(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) -@router.post("/", summary="Create a new user (Admin only)") -def create_user(user_in: dict): - # Only Admins allowed - pass -@router.get("/", summary="List all users (Admin only)") -def list_users(): - # Only Admins allowed - pass +@router.get("/", summary="List all users (Admin only)", response_model=List[UserOut]) +def list_users( + db: Session = Depends(get_db), current_user: TokenData = Depends(admin_required) +): + try: + users = list_users_service(db) + return users + except Exception as e: + print(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) -@router.get("/me", summary="Get current user's profile") -def get_me(): - # Customer only - pass -@router.put("/me", summary="Update current user's profile") -def update_me(user_update: dict): - # Customer only - pass +@router.get("/me", summary="Get current user's profile", response_model=UserOut) +def get_me( + db: Session = Depends(get_db), current_user: TokenData = Depends(get_current_user) +): + try: + user = get_user_service(db, current_user.user_id) + return user + except HTTPException as e: + raise e + except Exception as e: + print(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) -@router.get("/{user_id}", summary="Get user by ID") -def get_user(user_id: int): - # Admin or Customer (own) - pass -@router.put("/{user_id}", summary="Update user by ID") -def update_user(user_id: int, user_update: dict): - # Admin or Customer (own) - pass +@router.put("/me", summary="Update current user's profile", response_model=UserOut) +def update_me( + user_update: UserUpdate, + db: Session = Depends(get_db), + current_user: TokenData = Depends(get_current_user), +): + try: + user = update_user_service(db, current_user.user_id, user_update) + return user + except HTTPException as e: + raise e + except Exception as e: + print(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) -@router.delete("/{user_id}", summary="Delete user by ID") -def delete_user(user_id: int): - # Admin only - pass + +@router.get("/{user_id}", summary="Get user by ID", response_model=UserOut) +def get_user( + user_id: int, + db: Session = Depends(get_db), + current_user: TokenData = Depends(get_current_user), +): + # Admin or user themself allowed + if current_user.role != "admin" and current_user.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied.", + ) + try: + user = get_user_service(db, user_id) + return user + except HTTPException as e: + raise e + except Exception as e: + print(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) + + +@router.put("/{user_id}", summary="Update user by ID", response_model=UserOut) +def update_user( + user_id: int, + user_update: UserUpdate, + db: Session = Depends(get_db), + current_user: TokenData = Depends(get_current_user), +): + # Admin or user themself allowed + if current_user.role != "admin" and current_user.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied.", + ) + try: + user = update_user_service(db, user_id, user_update) + return user + except HTTPException as e: + raise e + except Exception as e: + print(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) + + +@router.delete("/{user_id}", summary="Delete user by ID", status_code=200) +def delete_user( + user_id: int, + db: Session = Depends(get_db), + current_user: TokenData = Depends(admin_required), +): + try: + delete_user_service(db, user_id) + return JSONResponse( + content={"message": f"User with ID {user_id} deleted successfully."} + ) + except HTTPException as e: + raise e + except Exception as e: + print(f"Unexpected error: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) @router.get("/{user_id}/orders", summary="List orders by user (Admin only)") -def list_user_orders(user_id: int): - # Admin only - pass \ No newline at end of file +def list_user_orders( + user_id: int, current_user: TokenData = Depends(admin_required) +): + # Implement orders fetching logic here, admin only + pass diff --git a/app/services/auth.py b/app/services/auth.py new file mode 100644 index 0000000..6de5b8c --- /dev/null +++ b/app/services/auth.py @@ -0,0 +1,26 @@ +from passlib.context import CryptContext +from datetime import timedelta +from app.utils.jwt import create_access_token +from app.services.users import get_user_by_username + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return pwd_context.verify(plain_password, hashed_password) + +def authenticate_user(db, username: str, password: str): + user = get_user_by_username(db, username) + if not user: + return False + if not verify_password(password, user.hashed_password): + return False + return user + +def create_token_for_user(user): + token_data = {"user_id": user.id, "role": user.role} + access_token_expires = timedelta(minutes=60) + token = create_access_token(token_data, expires_delta=access_token_expires) + return token diff --git a/app/services/users.py b/app/services/users.py new file mode 100644 index 0000000..c140e3a --- /dev/null +++ b/app/services/users.py @@ -0,0 +1,44 @@ +from sqlalchemy.orm import Session +from app.validators.users import UserCreate, UserUpdate +from app.db.models.users import get_user_by_email, get_user_by_username, create_user as db_create_user, get_all_users, get_user_by_id, delete_user_by_id, update_user_db +from fastapi import HTTPException, status +from app.utils.hashing import hash_password + +def create_user_service(db: Session, user_in: UserCreate): + # Check if user exists by email or username + if get_user_by_email(db, user_in.email): + raise ValueError("Email is already in use.") + + if get_user_by_username(db, user_in.username): + raise ValueError("Username is already taken.") + + # Hash the password + hashed_pw = hash_password(user_in.password) + + # Create user in DB + return db_create_user( + db=db, + username=user_in.username, + email=user_in.email, + hashed_password=hashed_pw, + role=user_in.role + ) + +def list_users_service(db: Session): + return get_all_users(db) + +def get_user_service(db: Session, user_id: int): + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + return user + +def delete_user_service(db: Session, user_id: int): + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found") + delete_user_by_id(db, user_id) + +def update_user_service(db: Session, user_id: int, user_update: UserUpdate): + return update_user_db(db, user_id, user_update) + diff --git a/app/utils/hashing.py b/app/utils/hashing.py new file mode 100644 index 0000000..26d57cf --- /dev/null +++ b/app/utils/hashing.py @@ -0,0 +1,7 @@ +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def hash_password(password: str) -> str: + return pwd_context.hash(password) + diff --git a/app/utils/jwt.py b/app/utils/jwt.py new file mode 100644 index 0000000..336f30e --- /dev/null +++ b/app/utils/jwt.py @@ -0,0 +1,47 @@ +import os +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +import jwt +from jwt import PyJWTError +from app.validators.users import TokenData + +JWT_SECRET = os.getenv("JWT_SECRET", "your-secret") # Use env var in prod +JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # URL for token endpoint + +def create_access_token(data: dict, expires_delta=None): + import datetime + to_encode = data.copy() + if expires_delta: + expire = datetime.datetime.utcnow() + expires_delta + else: + expire = datetime.datetime.utcnow() + datetime.timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) + return encoded_jwt + +def get_current_user(token: str = Depends(oauth2_scheme)) -> TokenData: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + user_id: int = payload.get("user_id") + role: str = payload.get("role") + if user_id is None or role is None: + raise credentials_exception + token_data = TokenData(user_id=user_id, role=role) + except PyJWTError: + raise credentials_exception + return token_data + +def admin_required(current_user: TokenData = Depends(get_current_user)): + if current_user.role != "admin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required.", + ) + return current_user diff --git a/app/validators/users.py b/app/validators/users.py new file mode 100644 index 0000000..d061201 --- /dev/null +++ b/app/validators/users.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel, EmailStr, constr, Field +from typing import Annotated, Optional +from datetime import datetime + +class UserCreate(BaseModel): + username: Annotated[str, constr(min_length=3, max_length=255)] + email: EmailStr + password: Annotated[str, constr(min_length=6, max_length=255)] + role: str = "customer" + + +class UserUpdate(BaseModel): + username: Optional[str] = Field(None, min_length=3, max_length=255) + email: Optional[EmailStr] = None + + +class UserOut(BaseModel): + id: int + username: str + email: EmailStr + role: str + created_at: datetime + updated_at: datetime + + class Config: + orm_mode = True + + +class TokenData(BaseModel): + user_id: int + role: str \ No newline at end of file diff --git a/main.py b/main.py index dfb1efe..b376029 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,20 @@ -# main.py - from fastapi import FastAPI -#from app.config.settings import settings # assuming you have a settings.py -#from app.db.database import init_db # DB session maker or init logic -from app.routers import user # adjust as per your actual router files +from app.routers import users, auth +from app.db.models.users import create_tables # import the table creation func app = FastAPI( title="User Order Management API", version="1.0.0", - #description="FastAPI with SQLAlchemy, Alembic, and Pydantic." ) -# Routers -app.include_router(user.router, prefix="/users", tags=["Users"]) +app.include_router(users.router, prefix="/users", tags=["Users"]) +app.include_router(auth.router, prefix="/auth", tags=["Auth"]) -# Startup Event -@app.lifespan("startup") -async def on_startup(): - #init_db() - print("✅ App started and DB initialized.") +@app.on_event("startup") +def on_startup(): + create_tables() # create tables if not exist + print("✅ Database tables are ready.") -# Shutdown Event -@app.lifespan("shutdown") -async def on_shutdown(): +@app.on_event("shutdown") +def on_shutdown(): print("🛑 App shutting down.") - diff --git a/requirements.txt b/requirements.txt index e69de29..ad680d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,23 @@ +annotated-types==0.7.0 +anyio==4.9.0 +bcrypt==4.3.0 +click==8.2.1 +dnspython==2.7.0 +email_validator==2.2.0 +exceptiongroup==1.3.0 +fastapi==0.115.12 +greenlet==3.2.2 +h11==0.16.0 +idna==3.10 +passlib==1.7.4 +psycopg2-binary==2.9.10 +pydantic==2.11.5 +pydantic_core==2.33.2 +PyJWT==2.10.1 +python-dotenv==1.1.0 +sniffio==1.3.1 +SQLAlchemy==2.0.41 +starlette==0.46.2 +typing-inspection==0.4.1 +typing_extensions==4.13.2 +uvicorn==0.34.2