From 0c3db8b695074c5eb805c7bf1971d90cac00daab Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 16:52:14 +0500 Subject: [PATCH 01/11] Create User implemented, db connection pending --- app/db/session.py | 17 +++++++++++++++++ app/services/users.py | 24 ++++++++++++++++++++++++ app/utils/security.py | 6 ++++++ app/validators/users.py | 19 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 app/db/session.py create mode 100644 app/services/users.py create mode 100644 app/utils/security.py create mode 100644 app/validators/users.py diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..b0d3cac --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,17 @@ +# app/database/session.py + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator # ✅ required for generator type annotation + +DATABASE_URL = "postgresql://postgres:password@localhost/db_name" + +engine = create_engine(DATABASE_URL, echo=True) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +def get_db() -> Generator[Session, None, None]: # ✅ fixed type hint + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/services/users.py b/app/services/users.py new file mode 100644 index 0000000..cac4aa4 --- /dev/null +++ b/app/services/users.py @@ -0,0 +1,24 @@ +from sqlalchemy.orm import Session +from schemas.user import UserCreate +from crud.user import get_user_by_email, get_user_by_username, create_user as db_create_user +from utils.security import hash_password # make sure this exists + +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 + ) diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000..ab5bf33 --- /dev/null +++ b/app/utils/security.py @@ -0,0 +1,6 @@ +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/validators/users.py b/app/validators/users.py new file mode 100644 index 0000000..0534eae --- /dev/null +++ b/app/validators/users.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, EmailStr, constr +from typing import Annotated + +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 UserOut(BaseModel): + id: int + username: str + email: EmailStr + role: str + created_at: str + updated_at: str + + class Config: + orm_mode = True From 27795eb808fa3307e39ac21f6f2caebbeed5f68c Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 16:52:36 +0500 Subject: [PATCH 02/11] Create User implemented, db connection pending --- app/db/models/users.py | 26 ++++++++++++++++++++------ app/routers/users.py | 21 +++++++++++++++------ requirements.txt | 14 ++++++++++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app/db/models/users.py b/app/db/models/users.py index 58b243e..18dfaeb 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -11,20 +11,34 @@ 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") # --- DB interaction functions --- +from sqlalchemy.orm import Session +from models.users import User + def get_user_by_id(db: Session, user_id: int): 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 diff --git a/app/routers/users.py b/app/routers/users.py index 66cce2e..51c95a0 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,14 +1,23 @@ +# --- User Endpoints --- + from fastapi import APIRouter, Depends, HTTPException, status -from typing import List, Optional +from sqlalchemy.orm import Session +from db.models import get_db +from validators.users import UserCreate, UserOut +from services.users import create_user_service 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)): + 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: + 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(): diff --git a/requirements.txt b/requirements.txt index e69de29..d787039 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,14 @@ +annotated-types==0.7.0 +anyio==4.9.0 +exceptiongroup==1.3.0 +fastapi==0.115.12 +greenlet==3.2.2 +idna==3.10 +psycopg2-binary==2.9.10 +pydantic==2.11.5 +pydantic_core==2.33.2 +sniffio==1.3.1 +SQLAlchemy==2.0.41 +starlette==0.46.2 +typing-inspection==0.4.1 +typing_extensions==4.13.2 From cd153cd8d54a99a6ea0d6ce46aa05a18300adc84 Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 16:54:52 +0500 Subject: [PATCH 03/11] Create User implemented, db connection pending --- app/db/session.py | 4 ++-- app/services/users.py | 6 +++--- app/validators/users.py | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/db/session.py b/app/db/session.py index b0d3cac..febf66b 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -2,14 +2,14 @@ from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, Session -from typing import Generator # ✅ required for generator type annotation +from typing import Generator DATABASE_URL = "postgresql://postgres:password@localhost/db_name" engine = create_engine(DATABASE_URL, echo=True) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -def get_db() -> Generator[Session, None, None]: # ✅ fixed type hint +def get_db() -> Generator[Session, None, None]: db = SessionLocal() try: yield db diff --git a/app/services/users.py b/app/services/users.py index cac4aa4..aaa2802 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session -from schemas.user import UserCreate -from crud.user import get_user_by_email, get_user_by_username, create_user as db_create_user -from utils.security import hash_password # make sure this exists +from validators.users import UserCreate +from db.models.users import get_user_by_email, get_user_by_username, create_user as db_create_user +from utils.security import hash_password def create_user_service(db: Session, user_in: UserCreate): # Check if user exists by email or username diff --git a/app/validators/users.py b/app/validators/users.py index 0534eae..56f2cbb 100644 --- a/app/validators/users.py +++ b/app/validators/users.py @@ -7,6 +7,7 @@ class UserCreate(BaseModel): password: Annotated[str, constr(min_length=6, max_length=255)] role: str = "customer" + class UserOut(BaseModel): id: int username: str From 882c936ca492e49f4391bbda7ed376558b2adf14 Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 17:15:33 +0500 Subject: [PATCH 04/11] db connection established --- app/db/models/users.py | 1 - app/routers/users.py | 4 ++-- main.py | 14 ++------------ requirements.txt | 5 +++++ 4 files changed, 9 insertions(+), 15 deletions(-) diff --git a/app/db/models/users.py b/app/db/models/users.py index 18dfaeb..f4671c5 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -20,7 +20,6 @@ class User(Base): # --- DB interaction functions --- from sqlalchemy.orm import Session -from models.users import User def get_user_by_id(db: Session, user_id: int): return db.query(User).filter(User.id == user_id).first() diff --git a/app/routers/users.py b/app/routers/users.py index 51c95a0..0911fda 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -2,14 +2,14 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from db.models import get_db +from db import session from validators.users import UserCreate, UserOut from services.users import create_user_service router = APIRouter() @router.post("/", summary="Create a new user (Admin only)", response_model=UserOut) -def create_user(user_in: UserCreate, db: Session = Depends(get_db)): +def create_user(user_in: UserCreate, db: Session = Depends(session.get_db)): try: user = create_user_service(db, user_in) return user diff --git a/main.py b/main.py index dfb1efe..b1312bc 100644 --- a/main.py +++ b/main.py @@ -1,27 +1,17 @@ -# 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 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"]) -# Startup Event @app.lifespan("startup") async def on_startup(): - #init_db() print("✅ App started and DB initialized.") -# Shutdown Event @app.lifespan("shutdown") async def on_shutdown(): print("🛑 App shutting down.") - diff --git a/requirements.txt b/requirements.txt index d787039..0eaada2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,13 @@ annotated-types==0.7.0 anyio==4.9.0 +bcrypt==4.3.0 +click==8.2.1 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 @@ -12,3 +16,4 @@ SQLAlchemy==2.0.41 starlette==0.46.2 typing-inspection==0.4.1 typing_extensions==4.13.2 +uvicorn==0.34.2 From 201269f9237438e528f29bbdb5b75e1b893261bd Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 17:30:10 +0500 Subject: [PATCH 05/11] some refactor and cleanup pending at this point, but db connection established, creation to be tested --- app/db/models/users.py | 13 ++++++++----- app/db/session.py | 13 ++++++++++++- app/routers/users.py | 8 ++++---- app/services/users.py | 6 +++--- main.py | 12 +++++++----- 5 files changed, 34 insertions(+), 18 deletions(-) diff --git a/app/db/models/users.py b/app/db/models/users.py index f4671c5..20603cb 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -1,7 +1,7 @@ from sqlalchemy import Column, Integer, String, DateTime, func -from sqlalchemy.orm import relationship, Session -from sqlalchemy.orm import declarative_base - +from sqlalchemy.orm import relationship, declarative_base +from sqlalchemy.orm import Session +from app.db.session import engine Base = declarative_base() class User(Base): @@ -17,9 +17,12 @@ class User(Base): orders = relationship("Order", back_populates="user", cascade="all, delete-orphan") -# --- DB interaction functions --- -from sqlalchemy.orm import Session +def create_tables(): + Base.metadata.create_all(bind=engine) + + +# --- DB interaction functions --- def get_user_by_id(db: Session, user_id: int): return db.query(User).filter(User.id == user_id).first() diff --git a/app/db/session.py b/app/db/session.py index febf66b..474a2ee 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -1,10 +1,21 @@ # 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 -DATABASE_URL = "postgresql://postgres:password@localhost/db_name" +# 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) diff --git a/app/routers/users.py b/app/routers/users.py index 0911fda..1744308 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -2,14 +2,14 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from db import session -from validators.users import UserCreate, UserOut -from services.users import create_user_service +from app.db.session import get_db +from app.validators.users import UserCreate, UserOut +from app.services.users import create_user_service router = APIRouter() @router.post("/", summary="Create a new user (Admin only)", response_model=UserOut) -def create_user(user_in: UserCreate, db: Session = Depends(session.get_db)): +def create_user(user_in: UserCreate, db: Session = Depends(get_db)): try: user = create_user_service(db, user_in) return user diff --git a/app/services/users.py b/app/services/users.py index aaa2802..20ef16b 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session -from validators.users import UserCreate -from db.models.users import get_user_by_email, get_user_by_username, create_user as db_create_user -from utils.security import hash_password +from app.validators.users import UserCreate +from app.db.models.users import get_user_by_email, get_user_by_username, create_user as db_create_user +from app.utils.security import hash_password def create_user_service(db: Session, user_in: UserCreate): # Check if user exists by email or username diff --git a/main.py b/main.py index b1312bc..df8412d 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ from fastapi import FastAPI from app.routers import users +from app.db.models.users import create_tables # import the table creation func app = FastAPI( title="User Order Management API", @@ -8,10 +9,11 @@ app.include_router(users.router, prefix="/users", tags=["Users"]) -@app.lifespan("startup") -async def on_startup(): - 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.") -@app.lifespan("shutdown") -async def on_shutdown(): +@app.on_event("shutdown") +def on_shutdown(): print("🛑 App shutting down.") From a97091ce5ce6321091c17f44263386430b77e3fc Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 17:44:34 +0500 Subject: [PATCH 06/11] create user working, tested and bugs fixed incrementally --- app/db/base.py | 3 +++ app/db/models/orders.py | 3 +-- app/db/models/users.py | 5 +++-- app/routers/users.py | 3 ++- app/validators/users.py | 6 ++++-- 5 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 app/db/base.py 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 20603cb..f6ac7bb 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -2,7 +2,8 @@ from sqlalchemy.orm import relationship, declarative_base from sqlalchemy.orm import Session from app.db.session import engine -Base = declarative_base() +from app.db.models.orders import Order +from app.db.base import Base class User(Base): __tablename__ = "users" @@ -15,7 +16,7 @@ class User(Base): 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") + orders = relationship(Order, back_populates="user", cascade="all, delete-orphan") def create_tables(): diff --git a/app/routers/users.py b/app/routers/users.py index 1744308..e404ca5 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -15,7 +15,8 @@ def create_user(user_in: UserCreate, db: Session = Depends(get_db)): return user except ValueError as ve: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ve)) - except Exception: + except Exception as e: + print(f"Unexpected error: {e}") # Log error here raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unexpected error occurred.") diff --git a/app/validators/users.py b/app/validators/users.py index 56f2cbb..43db508 100644 --- a/app/validators/users.py +++ b/app/validators/users.py @@ -7,14 +7,16 @@ class UserCreate(BaseModel): password: Annotated[str, constr(min_length=6, max_length=255)] role: str = "customer" +from pydantic import BaseModel, EmailStr +from datetime import datetime class UserOut(BaseModel): id: int username: str email: EmailStr role: str - created_at: str - updated_at: str + created_at: datetime + updated_at: datetime class Config: orm_mode = True From 30ce0c7839c6f500a7f5e640473ff089f86c66a4 Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 18:18:40 +0500 Subject: [PATCH 07/11] get all users as admin implemented, to be added = check if user admin, will be added with JWT auth --- app/db/models/users.py | 3 +++ app/routers/users.py | 15 +++++++++++---- app/services/users.py | 4 ++++ app/validators/users.py | 3 +-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/db/models/users.py b/app/db/models/users.py index f6ac7bb..e1448db 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -45,3 +45,6 @@ def create_user(db: Session, username: str, email: str, hashed_password: str, ro db.commit() db.refresh(user) return user + +def get_all_users(db: Session): + return db.query(User).all() diff --git a/app/routers/users.py b/app/routers/users.py index e404ca5..e2e8f86 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -5,6 +5,9 @@ from app.db.session import get_db from app.validators.users import UserCreate, UserOut from app.services.users import create_user_service +from app.services.users import list_users_service +from typing import List + router = APIRouter() @@ -19,11 +22,15 @@ def create_user(user_in: UserCreate, db: Session = Depends(get_db)): print(f"Unexpected error: {e}") # Log error here raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unexpected error occurred.") +@router.get("/", summary="List all users (Admin only)", response_model=List[UserOut]) +def list_users(db: Session = Depends(get_db)): + 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("/", summary="List all users (Admin only)") -def list_users(): - # Only Admins allowed - pass @router.get("/me", summary="Get current user's profile") def get_me(): diff --git a/app/services/users.py b/app/services/users.py index 20ef16b..b0e1497 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,6 +1,7 @@ from sqlalchemy.orm import Session from app.validators.users import UserCreate from app.db.models.users import get_user_by_email, get_user_by_username, create_user as db_create_user +from app.db.models.users import get_all_users from app.utils.security import hash_password def create_user_service(db: Session, user_in: UserCreate): @@ -22,3 +23,6 @@ def create_user_service(db: Session, user_in: UserCreate): hashed_password=hashed_pw, role=user_in.role ) + +def list_users_service(db: Session): + return get_all_users(db) diff --git a/app/validators/users.py b/app/validators/users.py index 43db508..00dff1c 100644 --- a/app/validators/users.py +++ b/app/validators/users.py @@ -1,5 +1,6 @@ from pydantic import BaseModel, EmailStr, constr from typing import Annotated +from datetime import datetime class UserCreate(BaseModel): username: Annotated[str, constr(min_length=3, max_length=255)] @@ -7,8 +8,6 @@ class UserCreate(BaseModel): password: Annotated[str, constr(min_length=6, max_length=255)] role: str = "customer" -from pydantic import BaseModel, EmailStr -from datetime import datetime class UserOut(BaseModel): id: int From ae51cc08d0c482d5fec0c67386588719bc4cfbf6 Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 18:36:18 +0500 Subject: [PATCH 08/11] Get specific user implemented, for now the server does not distinguish between admin and regular user, JWT auth pending --- app/db/models/users.py | 2 +- app/routers/users.py | 18 +++++++++++------- app/services/users.py | 11 +++++++++-- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/app/db/models/users.py b/app/db/models/users.py index e1448db..8375040 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -25,7 +25,7 @@ def create_tables(): # --- 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 get_user_by_email(db: Session, email: str): diff --git a/app/routers/users.py b/app/routers/users.py index e2e8f86..1729e73 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -4,11 +4,9 @@ from sqlalchemy.orm import Session from app.db.session import get_db from app.validators.users import UserCreate, UserOut -from app.services.users import create_user_service -from app.services.users import list_users_service +from app.services.users import create_user_service, list_users_service, get_user_service from typing import List - router = APIRouter() @router.post("/", summary="Create a new user (Admin only)", response_model=UserOut) @@ -42,10 +40,16 @@ def update_me(user_update: dict): # Customer only pass -@router.get("/{user_id}", summary="Get user by ID") -def get_user(user_id: int): - # Admin or Customer (own) - pass +@router.get("/{user_id}", summary="Get user by ID", response_model=UserOut) +def get_user(user_id: int, db: Session = Depends(get_db)): + 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") def update_user(user_id: int, user_update: dict): diff --git a/app/services/users.py b/app/services/users.py index b0e1497..03ab40e 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,7 +1,7 @@ from sqlalchemy.orm import Session from app.validators.users import UserCreate -from app.db.models.users import get_user_by_email, get_user_by_username, create_user as db_create_user -from app.db.models.users import get_all_users +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 +from fastapi import HTTPException, status from app.utils.security import hash_password def create_user_service(db: Session, user_in: UserCreate): @@ -26,3 +26,10 @@ def create_user_service(db: Session, user_in: UserCreate): 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 \ No newline at end of file From 54c08af32af3cf4941380b0ebda000e55d51aeca Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 18:48:53 +0500 Subject: [PATCH 09/11] delete user by id working fine, both user exists vs not tested --- app/db/models/users.py | 6 ++++++ app/routers/users.py | 24 +++++++++++++++++------- app/services/users.py | 11 ++++++++--- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/app/db/models/users.py b/app/db/models/users.py index 8375040..0b5b41c 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -48,3 +48,9 @@ def create_user(db: Session, username: str, email: str, hashed_password: str, ro 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() diff --git a/app/routers/users.py b/app/routers/users.py index 1729e73..6964207 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,10 +1,11 @@ # --- User Endpoints --- from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from app.db.session import get_db from app.validators.users import UserCreate, UserOut -from app.services.users import create_user_service, list_users_service, get_user_service +from app.services.users import create_user_service, list_users_service, get_user_service, delete_user_service from typing import List router = APIRouter() @@ -17,7 +18,7 @@ def create_user(user_in: UserCreate, db: Session = Depends(get_db)): 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}") # Log error here + print(f"Unexpected error: {e}") raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unexpected error occurred.") @router.get("/", summary="List all users (Admin only)", response_model=List[UserOut]) @@ -29,7 +30,6 @@ def list_users(db: Session = Depends(get_db)): 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 @@ -56,10 +56,20 @@ def update_user(user_id: int, user_update: dict): # Admin or Customer (own) pass -@router.delete("/{user_id}", summary="Delete user by ID") -def delete_user(user_id: int): - # Admin only - pass + +@router.delete("/{user_id}", summary="Delete user by ID", status_code=200) +def delete_user(user_id: int, db: Session = Depends(get_db)): + 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): diff --git a/app/services/users.py b/app/services/users.py index 03ab40e..bc25d15 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session from app.validators.users import UserCreate -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 +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 from fastapi import HTTPException, status from app.utils.security import hash_password @@ -27,9 +27,14 @@ def create_user_service(db: Session, user_in: UserCreate): 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 \ No newline at end of file + 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) From d3d89314d3e0c75f08de4952cbb5ec1c2a3b46d1 Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 19:04:19 +0500 Subject: [PATCH 10/11] update user by id working fine, both user exists vs not tested --- app/db/models/users.py | 24 ++++++++++++++++++++++-- app/routers/users.py | 22 +++++++++++++++------- app/services/users.py | 8 ++++++-- app/validators/users.py | 9 +++++++-- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/app/db/models/users.py b/app/db/models/users.py index 0b5b41c..e268d80 100644 --- a/app/db/models/users.py +++ b/app/db/models/users.py @@ -1,9 +1,10 @@ from sqlalchemy import Column, Integer, String, DateTime, func -from sqlalchemy.orm import relationship, declarative_base -from sqlalchemy.orm import Session +from sqlalchemy.orm import relationship, Session 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" @@ -54,3 +55,22 @@ def delete_user_by_id(db: Session, user_id: int): 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/routers/users.py b/app/routers/users.py index 6964207..e0f1ac1 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -4,8 +4,8 @@ from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from app.db.session import get_db -from app.validators.users import UserCreate, UserOut -from app.services.users import create_user_service, list_users_service, get_user_service, delete_user_service +from app.validators.users import UserCreate, UserUpdate, UserOut +from app.services.users import create_user_service, list_users_service, get_user_service, delete_user_service, update_user_service from typing import List router = APIRouter() @@ -51,11 +51,19 @@ def get_user(user_id: int, db: Session = Depends(get_db)): 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") -def update_user(user_id: int, user_update: dict): - # Admin or Customer (own) - pass - +@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)): + 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)): diff --git a/app/services/users.py b/app/services/users.py index bc25d15..27bdf38 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session -from app.validators.users import UserCreate -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 +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.security import hash_password @@ -38,3 +38,7 @@ def delete_user_service(db: Session, user_id: int): 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/validators/users.py b/app/validators/users.py index 00dff1c..8e7e109 100644 --- a/app/validators/users.py +++ b/app/validators/users.py @@ -1,5 +1,5 @@ -from pydantic import BaseModel, EmailStr, constr -from typing import Annotated +from pydantic import BaseModel, EmailStr, constr, Field +from typing import Annotated, Optional from datetime import datetime class UserCreate(BaseModel): @@ -9,6 +9,11 @@ class UserCreate(BaseModel): 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 From 45cdc1fac36b595a8995176c403c90decefc7752 Mon Sep 17 00:00:00 2001 From: Muhammad Raza Bhatti Date: Mon, 26 May 2025 20:28:26 +0500 Subject: [PATCH 11/11] JWT auth implemented, requirements updated, tests pending for all routes but list all users works with permissions as expected via curl, swagger pending --- app/routers/auth.py | 22 +++++ app/routers/users.py | 131 +++++++++++++++++++++----- app/services/auth.py | 26 +++++ app/services/users.py | 2 +- app/utils/{security.py => hashing.py} | 1 + app/utils/jwt.py | 47 +++++++++ app/validators/users.py | 5 + main.py | 3 +- requirements.txt | 4 + 9 files changed, 213 insertions(+), 28 deletions(-) create mode 100644 app/routers/auth.py create mode 100644 app/services/auth.py rename app/utils/{security.py => hashing.py} (99%) create mode 100644 app/utils/jwt.py 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 e0f1ac1..c6cc938 100644 --- a/app/routers/users.py +++ b/app/routers/users.py @@ -1,17 +1,26 @@ -# --- User Endpoints --- - from fastapi import APIRouter, Depends, HTTPException, status 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 -from app.services.users import create_user_service, list_users_service, get_user_service, delete_user_service, update_user_service +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() @router.post("/", summary="Create a new user (Admin only)", response_model=UserOut) -def create_user(user_in: UserCreate, db: Session = Depends(get_db)): +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 @@ -19,29 +28,75 @@ def create_user(user_in: UserCreate, db: Session = Depends(get_db)): 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.") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Unexpected error occurred.", + ) + @router.get("/", summary="List all users (Admin only)", response_model=List[UserOut]) -def list_users(db: Session = Depends(get_db)): +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.") + 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.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.get("/{user_id}", summary="Get user by ID", response_model=UserOut) -def get_user(user_id: int, db: Session = Depends(get_db)): +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 @@ -49,10 +104,25 @@ def get_user(user_id: int, db: Session = Depends(get_db)): 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.") + 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)): +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 @@ -62,24 +132,33 @@ def update_user(user_id: int, user_update: UserUpdate, db: Session = Depends(get print(f"Unexpected error: {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Unexpected error occurred." + 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)): +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."}) + 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." + 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 index 27bdf38..c140e3a 100644 --- a/app/services/users.py +++ b/app/services/users.py @@ -2,7 +2,7 @@ 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.security import hash_password +from app.utils.hashing import hash_password def create_user_service(db: Session, user_in: UserCreate): # Check if user exists by email or username diff --git a/app/utils/security.py b/app/utils/hashing.py similarity index 99% rename from app/utils/security.py rename to app/utils/hashing.py index ab5bf33..26d57cf 100644 --- a/app/utils/security.py +++ b/app/utils/hashing.py @@ -4,3 +4,4 @@ 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 index 8e7e109..d061201 100644 --- a/app/validators/users.py +++ b/app/validators/users.py @@ -24,3 +24,8 @@ class UserOut(BaseModel): 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 df8412d..b376029 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ from fastapi import FastAPI -from app.routers import users +from app.routers import users, auth from app.db.models.users import create_tables # import the table creation func app = FastAPI( @@ -8,6 +8,7 @@ ) app.include_router(users.router, prefix="/users", tags=["Users"]) +app.include_router(auth.router, prefix="/auth", tags=["Auth"]) @app.on_event("startup") def on_startup(): diff --git a/requirements.txt b/requirements.txt index 0eaada2..ad680d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,8 @@ 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 @@ -11,6 +13,8 @@ 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