diff --git a/src/database/__init__.py b/src/database/__init__.py index 791639b..0823c61 100644 --- a/src/database/__init__.py +++ b/src/database/__init__.py @@ -20,7 +20,7 @@ ) from database.models.orders import Order, OrderItem from database.models.payments import Payment, PaymentItem, PaymentStatusEnum -from database.models.shoping_cart import Cart, CartItem +from database.models.shopping_cart import Cart, CartItem from database.session_postgresql import get_postgresql_db as get_db from database.session_postgresql import ( get_postgresql_db_contextmanager as get_db_contextmanager, diff --git a/src/database/crud/shopping_cart.py b/src/database/crud/shopping_cart.py new file mode 100644 index 0000000..11b3cbc --- /dev/null +++ b/src/database/crud/shopping_cart.py @@ -0,0 +1,162 @@ +from typing import List, Optional, cast + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from database import ( + Cart, + CartItem, + Movie, + Order, + OrderItem, + Payment, + PaymentItem, + PaymentStatusEnum, + User, +) +from schemas.shopping_cart import CartItemDetail + + +def get_user_cart(user: User, db: Session) -> Cart | None: + return db.query(Cart).filter(Cart.user_id == user.id).first() + + +def get_movie_by_id(movie_id: int, db: Session) -> Movie | None: + return db.query(Movie).filter(Movie.id == movie_id).first() + + +def get_cart_item(cart: Cart, movie_id: int, db: Session) -> CartItem | None: + return db.query(CartItem).filter( + CartItem.cart_id == cart.id, CartItem.movie_id == movie_id + ).first() + + +def create_cart(user: User, db: Session) -> Cart: + cart = Cart(user_id=user.id) + db.add(cart) + db.commit() + db.refresh(cart) + return cart + + +def add_cart_item(cart: Cart, movie: Movie, db: Session) -> CartItem: + existing_item = get_cart_item(cart, movie.id, db) + if existing_item: + raise HTTPException(status_code=400, detail="Movie is already in the cart") + + cart_item = CartItem(cart_id=cart.id, movie_id=movie.id) + db.add(cart_item) + db.commit() + db.refresh(cart_item) + return cart_item + + +def delete_cart_item(cart_item: CartItem, db: Session) -> None: + db.delete(cart_item) + db.commit() + + +def delete_cart_item_by_cart(db: Session, cart_id: int) -> None: + db.query(CartItem).filter(CartItem.cart_id == cart_id).delete() + db.commit() + + +def create_order(db: Session, order: Order) -> None: + db.add(order) + db.commit() + db.refresh(order) + + +def create_order_items(db: Session, order: Order, cart: Cart) -> List[OrderItem]: + order_items: List[OrderItem] = [] + for item in cart.items: + order_item = OrderItem( + order_id=order.id, + movie_id=item.movie.id, + price_at_order=item.movie.price + ) + db.add(order_item) + order_items.append(order_item) + + db.commit() + return order_items + + +def create_payment(db: Session, user: User, order: Order) -> Payment: + payment = Payment( + user_id=user.id, + order_id=order.id, + status=PaymentStatusEnum.PENDING, + amount=order.total_amount, + external_payment_id=None + ) + db.add(payment) + db.commit() + db.refresh(payment) + return payment + + +def create_payment_items( + db: Session, + payment: Payment, + order_items: List[OrderItem] +) -> None: + for order_item in order_items: + payment_item = PaymentItem( + payment_id=payment.id, + order_item_id=order_item.id, + price_at_payment=order_item.price_at_order + ) + db.add(payment_item) + + db.commit() + + +def process_order_payment_and_clear_cart( + db: Session, + user: User, + order: Order, + cart: Cart +) -> Payment: + order_items = create_order_items(db, order, cart) + payment = create_payment(db, user, order) + create_payment_items(db, payment, order_items) + + delete_cart_item_by_cart(db, cart.id) + + return payment + + +def is_movie_in_any_cart(db: Session, movie_id: int) -> bool: + return bool(db.query(CartItem).filter(CartItem.movie_id == movie_id).count()) + + +def delete_movie(db: Session, movie: Movie) -> None: + db.delete(movie) + db.commit() + + +def get_purchased_movies_from_db(user: User, db: Session) -> list[Movie]: + result = ( + db.query(Movie) + .join(CartItem) + .join(Cart) + .join(Order, Order.user_id == Cart.user_id) + .filter(Order.user_id == user.id) + .distinct() + .all() + ) + return cast(List[Movie], result) + + +def get_cart_items_details(cart: Optional[Cart]) -> List[CartItemDetail]: + return [ + CartItemDetail( + movie_id=item.movie.id, + title=item.movie.name, + price=item.movie.price, + genre=item.movie.genres[0].name if item.movie.genres else "Unknown", + release_year=item.movie.year + ) + for item in cart.items + ] if cart else [] diff --git a/src/database/models/shoping_cart.py b/src/database/models/shopping_cart.py similarity index 100% rename from src/database/models/shoping_cart.py rename to src/database/models/shopping_cart.py diff --git a/src/main.py b/src/main.py index aa68c98..45809c8 100644 --- a/src/main.py +++ b/src/main.py @@ -2,6 +2,7 @@ from fastapi_pagination import add_pagination from routes import accounts_router, payments_router, profiles_router +from routes.shopping_cart import router as shopping_carts_router app = FastAPI( title="Movies Cinema", @@ -20,3 +21,6 @@ ) add_pagination(app) +app.include_router( + shopping_carts_router, prefix=f"{api_version_prefix}/carts", tags=["carts"] +) diff --git a/src/routes/shopping_cart.py b/src/routes/shopping_cart.py new file mode 100644 index 0000000..b8d39ae --- /dev/null +++ b/src/routes/shopping_cart.py @@ -0,0 +1,318 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from config import get_jwt_auth_manager +from database import ( + Order, + User, + UserGroupEnum, +) +from database.crud.accounts import get_user_by_id +from database.crud.shopping_cart import ( + add_cart_item, + create_cart, + create_order, + delete_cart_item, + delete_cart_item_by_cart, + delete_movie, + get_cart_item, + get_cart_items_details, + get_movie_by_id, + get_purchased_movies_from_db, + get_user_cart, + is_movie_in_any_cart, + process_order_payment_and_clear_cart, +) +from database.session_postgresql import get_postgresql_db +from schemas.accounts import MessageResponseSchema +from schemas.shopping_cart import ( + CartCreate, + CartItemResponse, + CartResponse, + PurchasedMoviesResponse, +) +from security.http import get_token +from security.token_manager import JWTAuthManager +from validation.shopping_cart import ( + validate_movie_availability, + validate_not_in_cart, + validate_not_purchased, +) + +router = APIRouter() + + +@router.get("/", response_model=CartResponse) +def get_cart( + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> CartResponse: + try: + payload = jwt_manager.decode_access_token(token) + user_id = payload.get("user_id") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + cart = get_user_cart(user, db) + if not cart: + return CartResponse(user_id=user.id, movies=[]) + return CartResponse(user_id=user.id, movies=get_cart_items_details(cart)) + + +@router.post("/add", response_model=CartResponse) +def add_to_cart( + cart_data: CartCreate, + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> CartResponse: + try: + payload = jwt_manager.decode_access_token(token) + user_id = payload.get("user_id") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + movie = get_movie_by_id(cart_data.movie_id, db) + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + + validate_movie_availability(movie) + validate_not_purchased(user, movie, db) + validate_not_in_cart(user, movie, db) + + cart = get_user_cart(user, db) or create_cart(user, db) + add_cart_item(cart, movie, db) + db.refresh(cart) + + return CartResponse(user_id=user.id, movies=get_cart_items_details(cart)) + + +@router.delete("/remove/{movie_id}", response_model=CartItemResponse) +def remove_from_cart( + movie_id: int, + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> CartItemResponse: + try: + payload = jwt_manager.decode_access_token(token) + user_id = payload.get("user_id") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + cart = get_user_cart(user, db) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + cart_item = get_cart_item(cart, movie_id, db) + if not cart_item: + raise HTTPException(status_code=404, detail="Movie not in cart") + + delete_cart_item(cart_item, db) + return CartItemResponse(message="Movie removed from cart") + + +@router.delete("/clear", response_model=CartItemResponse) +def clear_cart( + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> CartItemResponse: + try: + payload = jwt_manager.decode_access_token(token) + user_id = payload.get("user_id") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + cart = get_user_cart(user, db) + if not cart or not cart.items: + raise HTTPException(status_code=404, detail="Cart is already empty") + + delete_cart_item_by_cart(db, cart.id) + + return CartItemResponse(message="Cart cleared successfully") + + +@router.post("/checkout", response_model=MessageResponseSchema) +def checkout( + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> MessageResponseSchema: + try: + payload = jwt_manager.decode_access_token(token) + user_id = payload.get("user_id") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + if not user.is_active: + raise HTTPException( + status_code=403, + detail="Please activate your account before making a purchase." + ) + + cart = get_user_cart(user, db) + if not cart or not cart.items: + raise HTTPException(status_code=400, detail="Your cart is empty") + + order = Order( + user_id=user.id, + total_amount=sum(item.movie.price for item in cart.items) + ) + create_order(db, order) + + process_order_payment_and_clear_cart(db, user, order, cart) + + return MessageResponseSchema(message="Payment successful") + + +@router.get("/purchased", response_model=PurchasedMoviesResponse) +def get_purchased_movies( + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> PurchasedMoviesResponse: + try: + payload = jwt_manager.decode_access_token(token) + user_id = payload.get("user_id") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + + purchased_movies = get_purchased_movies_from_db(user, db) + return PurchasedMoviesResponse( + purchased_movies=[movie.name for movie in purchased_movies] + ) + + +@router.get("/admin/{user_id}", response_model=CartResponse) +def get_user_cart_admin( + user_id: int, + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> CartResponse: + try: + payload = jwt_manager.decode_access_token(token) + admin_id = payload.get("user_id") + admin = db.query(User).filter(User.id == admin_id).first() + + if not admin or not admin.has_group(UserGroupEnum.ADMIN): + raise HTTPException(status_code=403, detail="Access denied") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + user = get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + cart = get_user_cart(user, db) + if not cart: + return CartResponse(user_id=user.id, movies=[]) + + return CartResponse(user_id=user.id, movies=get_cart_items_details(cart)) + + +@router.delete( + "/admin/movies/{movie_id}", + response_model=MessageResponseSchema +) +def delete_movie_route( + movie_id: int, + db: Annotated[Session, Depends(get_postgresql_db)], + token: Annotated[str, Depends(get_token)], + jwt_manager: Annotated[JWTAuthManager, Depends(get_jwt_auth_manager)] +) -> MessageResponseSchema: + try: + payload = jwt_manager.decode_access_token(token) + user_id = payload.get("user_id") + user = db.query(User).filter(User.id == user_id).first() + + if not user or not ( + user.has_group(UserGroupEnum.ADMIN) + or user.has_group(UserGroupEnum.MODERATOR) + ): + raise HTTPException(status_code=403, detail="Access denied") + except Exception as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + + movie = get_movie_by_id(movie_id, db) + if not movie: + raise HTTPException(status_code=404, detail="Movie not found") + + if is_movie_in_any_cart(db, movie_id): + raise HTTPException( + status_code=400, + detail="Movie cannot be deleted because it exists in user carts" + ) + + delete_movie(db, movie) + return MessageResponseSchema(message="Movie deleted successfully") diff --git a/src/schemas/shopping_cart.py b/src/schemas/shopping_cart.py new file mode 100644 index 0000000..ed565d0 --- /dev/null +++ b/src/schemas/shopping_cart.py @@ -0,0 +1,28 @@ +from typing import List, Optional + +from pydantic import BaseModel + + +class CartCreate(BaseModel): + movie_id: int + + +class CartItemResponse(BaseModel): + message: str + + +class CartItemDetail(BaseModel): + movie_id: int + title: str + price: float + genre: Optional[str] + release_year: int + + +class CartResponse(BaseModel): + user_id: int + movies: List[CartItemDetail] + + +class PurchasedMoviesResponse(BaseModel): + purchased_movies: List[str] diff --git a/src/validation/shopping_cart.py b/src/validation/shopping_cart.py new file mode 100644 index 0000000..98fa319 --- /dev/null +++ b/src/validation/shopping_cart.py @@ -0,0 +1,38 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from database import Cart, CartItem, Movie, User + + +def validate_movie_availability(movie: Movie | None) -> None: + if not movie: + raise HTTPException( + status_code=400, + detail="Movie is not available for purchase." + ) + + +def validate_not_purchased(user: User, movie: Movie, db: Session) -> None: + purchased_movies = db.query(CartItem).join(Cart).filter( + Cart.user_id == user.id, + CartItem.movie_id == movie.id + ).first() + if purchased_movies: + raise HTTPException( + status_code=400, + detail="You have already purchased this movie." + ) + + +def validate_not_in_cart(user: User, movie: Movie, db: Session) -> None: + cart = db.query(Cart).filter(Cart.user_id == user.id).first() + if cart: + existing_item = db.query(CartItem).filter( + CartItem.cart_id == cart.id, + CartItem.movie_id == movie.id + ).first() + if existing_item: + raise HTTPException( + status_code=400, + detail="Movie already in cart." + )