From c43b1da1341cc31b300fba2a3bae9c54bff44ca1 Mon Sep 17 00:00:00 2001 From: Nikitha Raj Date: Tue, 28 Apr 2026 23:51:39 +0530 Subject: [PATCH 1/2] Get products and orders endpoints --- app/core/security.py | 9 +++++++ app/deps.py | 54 ++++++++++++++++++++++++++++++++++++++++++ app/main.py | 4 ++++ app/routes/orders.py | 22 +++++++++++++++++ app/routes/products.py | 17 +++++++++++++ app/schemas/order.py | 18 ++++++++++++++ app/schemas/product.py | 18 ++++++++++++++ 7 files changed, 142 insertions(+) create mode 100644 app/routes/orders.py create mode 100644 app/routes/products.py create mode 100644 app/schemas/order.py create mode 100644 app/schemas/product.py diff --git a/app/core/security.py b/app/core/security.py index e923950..a64b875 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta, timezone +from typing import Any from uuid import UUID import bcrypt @@ -38,3 +39,11 @@ def create_access_token( settings.SECRET_KEY, algorithm=settings.ALGORITHM, ) + + +def decode_access_token(token: str) -> dict[str, Any]: + return jwt.decode( + token, + settings.SECRET_KEY, + algorithms=[settings.ALGORITHM], + ) diff --git a/app/deps.py b/app/deps.py index 09109e9..7654e33 100644 --- a/app/deps.py +++ b/app/deps.py @@ -1,8 +1,18 @@ from collections.abc import Generator +from typing import Annotated +from uuid import UUID +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy import select from sqlalchemy.orm import Session from app.core.database import SessionLocal +from app.core.security import decode_access_token +from app.models.user import User + +http_bearer = HTTPBearer(auto_error=False) def get_db() -> Generator[Session, None, None]: @@ -11,3 +21,47 @@ def get_db() -> Generator[Session, None, None]: yield db finally: db.close() + + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(http_bearer)], + db: Session = Depends(get_db), +) -> User: + if credentials is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing or invalid bearer token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = decode_access_token(credentials.credentials) + sub = payload.get("sub") + if sub is None: + raise jwt.InvalidTokenError() + user_id = UUID(sub) + except jwt.PyJWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) from None + except ValueError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token subject", + headers={"WWW-Authenticate": "Bearer"}, + ) from None + + user = db.scalars(select(User).where(User.id == user_id)).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found or token revoked", + headers={"WWW-Authenticate": "Bearer"}, + ) + if not user.is_active: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user") + return user + + +CurrentUserDep = Annotated[User, Depends(get_current_user)] diff --git a/app/main.py b/app/main.py index dde12c5..4387cde 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,8 @@ from app.core.config import settings from app.routes.auth import router as auth_router from app.routes.health import router as health_router +from app.routes.orders import router as orders_router +from app.routes.products import router as products_router app = FastAPI( title=settings.APP_NAME, @@ -10,6 +12,8 @@ ) app.include_router(auth_router, prefix="/auth", tags=["auth"]) +app.include_router(products_router, prefix="/products", tags=["products"]) +app.include_router(orders_router, prefix="/orders", tags=["orders"]) app.include_router(health_router, prefix="/api", tags=["health"]) diff --git a/app/routes/orders.py b/app/routes/orders.py new file mode 100644 index 0000000..f5e900a --- /dev/null +++ b/app/routes/orders.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.deps import CurrentUserDep, get_db +from app.models.order import Order +from app.schemas.order import OrderOut + +router = APIRouter() + + +@router.get("", response_model=list[OrderOut]) +def list_my_orders( + current_user: CurrentUserDep, + db: Session = Depends(get_db), +) -> list[Order]: + stmt = ( + select(Order) + .where(Order.user_id == current_user.id) + .order_by(Order.created_at.desc()) + ) + return list(db.scalars(stmt)) diff --git a/app/routes/products.py b/app/routes/products.py new file mode 100644 index 0000000..1189a36 --- /dev/null +++ b/app/routes/products.py @@ -0,0 +1,17 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.deps import get_db +from app.models.product import Product +from app.schemas.product import ProductOut + +router = APIRouter() + + +@router.get("", response_model=list[ProductOut]) +def list_products(db: Session = Depends(get_db)) -> list[Product]: + stmt = ( + select(Product).where(Product.is_active.is_(True)).order_by(Product.name) + ) + return list(db.scalars(stmt)) diff --git a/app/schemas/order.py b/app/schemas/order.py new file mode 100644 index 0000000..84efdf2 --- /dev/null +++ b/app/schemas/order.py @@ -0,0 +1,18 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + +from app.models.order import OrderStatus + + +class OrderOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + user_id: uuid.UUID + status: OrderStatus + total_amount: Decimal + created_at: datetime + updated_at: datetime diff --git a/app/schemas/product.py b/app/schemas/product.py new file mode 100644 index 0000000..be6cc69 --- /dev/null +++ b/app/schemas/product.py @@ -0,0 +1,18 @@ +import uuid +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + + +class ProductOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + name: str + description: str | None + price: Decimal + stock_quantity: int + is_active: bool + created_at: datetime + updated_at: datetime From a5d64fcc1cf4196231ec152d7b114ec74daefd4b Mon Sep 17 00:00:00 2001 From: Nikitha Raj Date: Wed, 29 Apr 2026 00:02:42 +0530 Subject: [PATCH 2/2] CRUD for orders and 3 layer architecture --- app/controllers/__init__.py | 1 - app/controllers/auth.py | 35 ++++++ app/controllers/orders.py | 60 ++++++++++ app/controllers/products.py | 8 ++ app/exceptions.py | 41 +++++++ app/routes/auth.py | 52 ++------- app/routes/orders.py | 55 +++++++-- app/routes/products.py | 10 +- app/schemas/order.py | 40 ++++++- app/services/__init__.py | 1 - app/services/auth_service.py | 41 +++++++ app/services/order_service.py | 190 ++++++++++++++++++++++++++++++++ app/services/product_service.py | 11 ++ 13 files changed, 479 insertions(+), 66 deletions(-) create mode 100644 app/controllers/auth.py create mode 100644 app/controllers/orders.py create mode 100644 app/controllers/products.py create mode 100644 app/exceptions.py create mode 100644 app/services/auth_service.py create mode 100644 app/services/order_service.py create mode 100644 app/services/product_service.py diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py index 8b13789..e69de29 100644 --- a/app/controllers/__init__.py +++ b/app/controllers/__init__.py @@ -1 +0,0 @@ - diff --git a/app/controllers/auth.py b/app/controllers/auth.py new file mode 100644 index 0000000..1ee9c3c --- /dev/null +++ b/app/controllers/auth.py @@ -0,0 +1,35 @@ +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.exceptions import AuthenticationError, ConflictError, ForbiddenError +from app.models.user import User +from app.schemas.auth import LoginRequest, TokenResponse, UserRegisterRequest +from app.services import auth_service + + +def register(payload: UserRegisterRequest, db: Session) -> User: + try: + return auth_service.register_user( + db, email=str(payload.email), password=payload.password, + ) + except ConflictError as exc: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, detail=exc.detail, + ) from exc + + +def login(payload: LoginRequest, db: Session) -> TokenResponse: + try: + return auth_service.authenticate_user( + db, email=str(payload.email), password=payload.password, + ) + except AuthenticationError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=exc.detail, + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + except ForbiddenError as exc: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail=exc.detail, + ) from exc diff --git a/app/controllers/orders.py b/app/controllers/orders.py new file mode 100644 index 0000000..322d3a1 --- /dev/null +++ b/app/controllers/orders.py @@ -0,0 +1,60 @@ +import uuid + +from fastapi import HTTPException, status +from sqlalchemy.orm import Session + +from app.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError +from app.models.order import Order +from app.models.user import User +from app.schemas.order import OrderCreate, OrderUpdate +from app.services import order_service + + +def _map_service_exceptions(exc: Exception) -> HTTPException: + if isinstance(exc, NotFoundError): + return HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=exc.detail) + if isinstance(exc, ForbiddenError): + return HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=exc.detail) + if isinstance(exc, ConflictError): + return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=exc.detail) + if isinstance(exc, BadRequestError): + return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=exc.detail) + return HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Unexpected error") + + +def create_order(payload: OrderCreate, current_user: User, db: Session) -> Order: + items = [(item.product_id, item.quantity) for item in payload.items] + try: + return order_service.create_order(db, user_id=current_user.id, items=items) + except (NotFoundError, BadRequestError, ConflictError) as exc: + raise _map_service_exceptions(exc) from exc + + +def list_my_orders(current_user: User, db: Session) -> list[Order]: + return order_service.list_user_orders(db, user_id=current_user.id) + + +def get_order(order_id: uuid.UUID, current_user: User, db: Session) -> Order: + try: + return order_service.get_user_order(db, order_id=order_id, user_id=current_user.id) + except (NotFoundError, ForbiddenError) as exc: + raise _map_service_exceptions(exc) from exc + + +def update_order( + order_id: uuid.UUID, payload: OrderUpdate, current_user: User, db: Session, +) -> Order: + items = [(item.product_id, item.quantity) for item in payload.items] + try: + return order_service.update_order( + db, order_id=order_id, user_id=current_user.id, items=items, + ) + except (NotFoundError, ForbiddenError, ConflictError, BadRequestError) as exc: + raise _map_service_exceptions(exc) from exc + + +def cancel_order(order_id: uuid.UUID, current_user: User, db: Session) -> Order: + try: + return order_service.cancel_order(db, order_id=order_id, user_id=current_user.id) + except (NotFoundError, ForbiddenError, ConflictError) as exc: + raise _map_service_exceptions(exc) from exc diff --git a/app/controllers/products.py b/app/controllers/products.py new file mode 100644 index 0000000..f7b41d6 --- /dev/null +++ b/app/controllers/products.py @@ -0,0 +1,8 @@ +from sqlalchemy.orm import Session + +from app.models.product import Product +from app.services import product_service + + +def list_products(db: Session) -> list[Product]: + return product_service.list_active_products(db) diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..b504405 --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,41 @@ +class AppException(Exception): + """Base domain exception. All service-layer errors inherit from this.""" + + def __init__(self, detail: str = "An application error occurred") -> None: + self.detail = detail + super().__init__(detail) + + +class NotFoundError(AppException): + """Raised when a requested resource does not exist.""" + + def __init__(self, detail: str = "Resource not found") -> None: + super().__init__(detail) + + +class ConflictError(AppException): + """Raised on duplicate resources, insufficient stock, or invalid state transitions.""" + + def __init__(self, detail: str = "Conflict") -> None: + super().__init__(detail) + + +class BadRequestError(AppException): + """Raised when the request is semantically invalid (e.g. duplicate product IDs).""" + + def __init__(self, detail: str = "Bad request") -> None: + super().__init__(detail) + + +class ForbiddenError(AppException): + """Raised when the user lacks permission (e.g. accessing another user's order).""" + + def __init__(self, detail: str = "Forbidden") -> None: + super().__init__(detail) + + +class AuthenticationError(AppException): + """Raised when credentials are invalid.""" + + def __init__(self, detail: str = "Incorrect email or password") -> None: + super().__init__(detail) diff --git a/app/routes/auth.py b/app/routes/auth.py index 1011706..e821c03 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,56 +1,18 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import select +from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session -from app.core.security import create_access_token, hash_password, verify_password +from app.controllers import auth as ctrl from app.deps import get_db -from app.models.user import User from app.schemas.auth import LoginRequest, TokenResponse, UserPublic, UserRegisterRequest router = APIRouter() -def _normalize_email(email: str) -> str: - return email.strip().lower() - - -@router.post( - "/register", - response_model=UserPublic, - status_code=status.HTTP_201_CREATED, -) -def register(payload: UserRegisterRequest, db: Session = Depends(get_db)) -> User: - normalized = _normalize_email(str(payload.email)) - exists = db.scalars(select(User).where(User.email == normalized)).first() - if exists: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail="A user with this email already exists", - ) - user = User( - email=normalized, - password_hash=hash_password(payload.password), - ) - db.add(user) - db.commit() - db.refresh(user) - return user +@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED) +def register(payload: UserRegisterRequest, db: Session = Depends(get_db)): + return ctrl.register(payload, db) @router.post("/login", response_model=TokenResponse) -def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse: - normalized = _normalize_email(str(payload.email)) - user = db.scalars(select(User).where(User.email == normalized)).first() - if not user or not verify_password(payload.password, user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Incorrect email or password", - headers={"WWW-Authenticate": "Bearer"}, - ) - if not user.is_active: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Inactive user", - ) - token = create_access_token(subject=user.id, email=user.email) - return TokenResponse(access_token=token, token_type="bearer") +def login(payload: LoginRequest, db: Session = Depends(get_db)): + return ctrl.login(payload, db) diff --git a/app/routes/orders.py b/app/routes/orders.py index f5e900a..d4071a7 100644 --- a/app/routes/orders.py +++ b/app/routes/orders.py @@ -1,22 +1,55 @@ -from fastapi import APIRouter, Depends -from sqlalchemy import select +import uuid + +from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session +from app.controllers import orders as ctrl from app.deps import CurrentUserDep, get_db -from app.models.order import Order -from app.schemas.order import OrderOut +from app.schemas.order import OrderCreate, OrderDetailOut, OrderOut, OrderUpdate router = APIRouter() +@router.post("", response_model=OrderDetailOut, status_code=status.HTTP_201_CREATED) +def create_order( + payload: OrderCreate, + current_user: CurrentUserDep, + db: Session = Depends(get_db), +): + return ctrl.create_order(payload, current_user, db) + + @router.get("", response_model=list[OrderOut]) def list_my_orders( current_user: CurrentUserDep, db: Session = Depends(get_db), -) -> list[Order]: - stmt = ( - select(Order) - .where(Order.user_id == current_user.id) - .order_by(Order.created_at.desc()) - ) - return list(db.scalars(stmt)) +): + return ctrl.list_my_orders(current_user, db) + + +@router.get("/{order_id}", response_model=OrderDetailOut) +def get_order( + order_id: uuid.UUID, + current_user: CurrentUserDep, + db: Session = Depends(get_db), +): + return ctrl.get_order(order_id, current_user, db) + + +@router.patch("/{order_id}", response_model=OrderDetailOut) +def update_order( + order_id: uuid.UUID, + payload: OrderUpdate, + current_user: CurrentUserDep, + db: Session = Depends(get_db), +): + return ctrl.update_order(order_id, payload, current_user, db) + + +@router.post("/{order_id}/cancel", response_model=OrderDetailOut) +def cancel_order( + order_id: uuid.UUID, + current_user: CurrentUserDep, + db: Session = Depends(get_db), +): + return ctrl.cancel_order(order_id, current_user, db) diff --git a/app/routes/products.py b/app/routes/products.py index 1189a36..a26f948 100644 --- a/app/routes/products.py +++ b/app/routes/products.py @@ -1,17 +1,13 @@ from fastapi import APIRouter, Depends -from sqlalchemy import select from sqlalchemy.orm import Session +from app.controllers import products as ctrl from app.deps import get_db -from app.models.product import Product from app.schemas.product import ProductOut router = APIRouter() @router.get("", response_model=list[ProductOut]) -def list_products(db: Session = Depends(get_db)) -> list[Product]: - stmt = ( - select(Product).where(Product.is_active.is_(True)).order_by(Product.name) - ) - return list(db.scalars(stmt)) +def list_products(db: Session = Depends(get_db)): + return ctrl.list_products(db) diff --git a/app/schemas/order.py b/app/schemas/order.py index 84efdf2..3c841ce 100644 --- a/app/schemas/order.py +++ b/app/schemas/order.py @@ -2,11 +2,45 @@ from datetime import datetime from decimal import Decimal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from app.models.order import OrderStatus +# ── Create ─────────────────────────────────────────────────────────────── + +class OrderItemCreate(BaseModel): + product_id: uuid.UUID + quantity: int = Field(gt=0, description="Must be at least 1") + + +class OrderCreate(BaseModel): + items: list[OrderItemCreate] = Field(min_length=1, description="At least one item required") + + +# ── Update (only pending orders) ───────────────────────────────────────── + +class OrderItemUpdate(BaseModel): + product_id: uuid.UUID + quantity: int = Field(gt=0) + + +class OrderUpdate(BaseModel): + items: list[OrderItemUpdate] = Field(min_length=1, description="Full replacement of order items") + + +# ── Response ───────────────────────────────────────────────────────────── + +class OrderItemOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + product_id: uuid.UUID + quantity: int + unit_price: Decimal + total_price: Decimal + + class OrderOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -16,3 +50,7 @@ class OrderOut(BaseModel): total_amount: Decimal created_at: datetime updated_at: datetime + + +class OrderDetailOut(OrderOut): + items: list[OrderItemOut] = [] diff --git a/app/services/__init__.py b/app/services/__init__.py index 8b13789..e69de29 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1 +0,0 @@ - diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..6d193e7 --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,41 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.security import create_access_token, hash_password, verify_password +from app.exceptions import AuthenticationError, ConflictError, ForbiddenError +from app.models.user import User +from app.schemas.auth import TokenResponse + + +def _normalize_email(email: str) -> str: + return email.strip().lower() + + +def register_user(db: Session, *, email: str, password: str) -> User: + normalized = _normalize_email(email) + exists = db.scalars(select(User).where(User.email == normalized)).first() + if exists: + raise ConflictError("A user with this email already exists") + + user = User( + email=normalized, + password_hash=hash_password(password), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def authenticate_user(db: Session, *, email: str, password: str) -> TokenResponse: + normalized = _normalize_email(email) + user = db.scalars(select(User).where(User.email == normalized)).first() + + if not user or not verify_password(password, user.password_hash): + raise AuthenticationError("Incorrect email or password") + + if not user.is_active: + raise ForbiddenError("Inactive user") + + token = create_access_token(subject=user.id, email=user.email) + return TokenResponse(access_token=token, token_type="bearer") diff --git a/app/services/order_service.py b/app/services/order_service.py new file mode 100644 index 0000000..e5d106e --- /dev/null +++ b/app/services/order_service.py @@ -0,0 +1,190 @@ +import uuid +from decimal import Decimal + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from app.exceptions import BadRequestError, ConflictError, ForbiddenError, NotFoundError +from app.models.order import Order, OrderStatus +from app.models.order_item import OrderItem +from app.models.product import Product + + +# ── internal helpers ───────────────────────────────────────────────────── + +def _enforce_ownership(order: Order, user_id: uuid.UUID) -> None: + if order.user_id != user_id: + raise ForbiddenError("Not your order") + + +def _enforce_pending(order: Order) -> None: + if order.status != OrderStatus.pending: + raise ConflictError(f"Cannot modify order with status '{order.status.value}'") + + +def _validate_and_fetch_products( + product_ids: list[uuid.UUID], + db: Session, +) -> dict[uuid.UUID, Product]: + if len(product_ids) != len(set(product_ids)): + raise BadRequestError("Duplicate product IDs in items") + + products = list( + db.scalars(select(Product).where(Product.id.in_(product_ids))) + ) + product_map = {p.id: p for p in products} + + missing = [str(pid) for pid in product_ids if pid not in product_map] + if missing: + raise NotFoundError(f"Products not found: {', '.join(missing)}") + + inactive = [str(pid) for pid in product_ids if not product_map[pid].is_active] + if inactive: + raise BadRequestError(f"Inactive products: {', '.join(inactive)}") + + return product_map + + +def _check_stock( + product_map: dict[uuid.UUID, Product], + items: list[tuple[uuid.UUID, int]], +) -> None: + insufficient: list[str] = [] + for pid, qty in items: + p = product_map[pid] + if p.stock_quantity < qty: + insufficient.append( + f"{p.name} (requested {qty}, available {p.stock_quantity})" + ) + if insufficient: + raise ConflictError(f"Insufficient stock: {'; '.join(insufficient)}") + + +def _get_order_with_items(db: Session, order_id: uuid.UUID) -> Order: + order = db.scalars( + select(Order) + .options(selectinload(Order.items)) + .where(Order.id == order_id) + ).first() + if order is None: + raise NotFoundError("Order not found") + return order + + +def _build_items( + order: Order, + pid_qty: list[tuple[uuid.UUID, int]], + product_map: dict[uuid.UUID, Product], +) -> Decimal: + """Append OrderItems to *order*, deduct stock, return total amount.""" + total = Decimal("0.00") + for pid, qty in pid_qty: + product = product_map[pid] + unit_price = product.price + line_total = unit_price * qty + total += line_total + + order.items.append( + OrderItem( + product_id=pid, + quantity=qty, + unit_price=unit_price, + total_price=line_total, + ) + ) + product.stock_quantity -= qty + return total + + +def _restore_stock(db: Session, items: list[OrderItem]) -> None: + for oi in items: + product = db.get(Product, oi.product_id) + if product is not None: + product.stock_quantity += oi.quantity + + +# ── public service API ─────────────────────────────────────────────────── + +def create_order( + db: Session, + *, + user_id: uuid.UUID, + items: list[tuple[uuid.UUID, int]], +) -> Order: + product_ids = [pid for pid, _ in items] + product_map = _validate_and_fetch_products(product_ids, db) + _check_stock(product_map, items) + + order = Order(user_id=user_id, status=OrderStatus.pending) + order.total_amount = _build_items(order, items, product_map) + + db.add(order) + db.commit() + db.refresh(order) + return order + + +def list_user_orders(db: Session, *, user_id: uuid.UUID) -> list[Order]: + stmt = ( + select(Order) + .where(Order.user_id == user_id) + .order_by(Order.created_at.desc()) + ) + return list(db.scalars(stmt)) + + +def get_user_order( + db: Session, + *, + order_id: uuid.UUID, + user_id: uuid.UUID, +) -> Order: + order = _get_order_with_items(db, order_id) + _enforce_ownership(order, user_id) + return order + + +def update_order( + db: Session, + *, + order_id: uuid.UUID, + user_id: uuid.UUID, + items: list[tuple[uuid.UUID, int]], +) -> Order: + order = _get_order_with_items(db, order_id) + _enforce_ownership(order, user_id) + _enforce_pending(order) + + product_ids = [pid for pid, _ in items] + product_map = _validate_and_fetch_products(product_ids, db) + + _restore_stock(db, order.items) + _check_stock(product_map, items) + + for oi in list(order.items): + db.delete(oi) + order.items.clear() + + order.total_amount = _build_items(order, items, product_map) + + db.commit() + db.refresh(order) + return order + + +def cancel_order( + db: Session, + *, + order_id: uuid.UUID, + user_id: uuid.UUID, +) -> Order: + order = _get_order_with_items(db, order_id) + _enforce_ownership(order, user_id) + _enforce_pending(order) + + _restore_stock(db, order.items) + order.status = OrderStatus.cancelled + + db.commit() + db.refresh(order) + return order diff --git a/app/services/product_service.py b/app/services/product_service.py new file mode 100644 index 0000000..16ec278 --- /dev/null +++ b/app/services/product_service.py @@ -0,0 +1,11 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.product import Product + + +def list_active_products(db: Session) -> list[Product]: + stmt = ( + select(Product).where(Product.is_active.is_(True)).order_by(Product.name) + ) + return list(db.scalars(stmt))