diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4cf9f04f15c297c7025dd3d57e3d9b1960553768..6a635c83368978d7d8d2b227b46cf0e3061d4388 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,12 +8,19 @@ variables: PIP_CACHE_DIR: $CI_PROJECT_DIR/.pip_cache cache: + key: dependencies-cache paths: - $PIP_CACHE_DIR + policy: pull-push before_script: - python -m venv $VENV_DIR - - source $VENV_DIR/Scripts/activate + - | + if [[ "$OSTYPE" == "msys" ]]; then + source $VENV_DIR/Scripts/activate + else + source $VENV_DIR/bin/activate + fi - pip install --upgrade pip - pip install -r requirements.txt --cache-dir $PIP_CACHE_DIR @@ -21,6 +28,7 @@ install_dependencies: stage: install script: - echo "Dependencies installed successfully." + timeout: 20m run_tests: stage: test diff --git a/app/backend/dummy_data.py b/app/backend/dummy_data.py index e2550dfc5253d893607822247aa3681f1a514bf9..f7a55b4eebfe187e36075be20138792aa6826c24 100644 --- a/app/backend/dummy_data.py +++ b/app/backend/dummy_data.py @@ -34,11 +34,49 @@ def insert_dummy_data(session: Session): shops = [ Shop( owner_id=2, - name=f"Shop{i}", - description=f"Description for Shop {i}", + name="Google HQ", + description="Google Headquarters", image_url="app/static/default/default_shop.png", - ) - for i in range(1, 6) + address="1600 Amphitheatre Parkway, Mountain View, CA", + latitude=37.4220656, + longitude=-122.0840897, + ), + Shop( + owner_id=2, + name="Apple HQ", + description="Apple Headquarters", + image_url="app/static/default/default_shop.png", + address="1 Infinite Loop, Cupertino, CA", + latitude=37.33182, + longitude=-122.03118, + ), + Shop( + owner_id=2, + name="Empire State Building", + description="Famous skyscraper in New York", + image_url="app/static/default/default_shop.png", + address="350 Fifth Avenue, New York, NY", + latitude=40.748817, + longitude=-73.985428, + ), + Shop( + owner_id=2, + name="Sherlock's Home", + description="Fictional detective's residence", + image_url="app/static/default/default_shop.png", + address="221B Baker Street, London, UK", + latitude=51.523767, + longitude=-0.1585557, + ), + Shop( + owner_id=2, + name="Eiffel Tower", + description="Iconic landmark in Paris", + image_url="app/static/default/default_shop.png", + address="Eiffel Tower, Paris, France", + latitude=48.8588443, + longitude=2.2943506, + ), ] session.add_all(shops) session.commit() @@ -75,8 +113,56 @@ def insert_dummy_data(session: Session): if not session.query(Order).first(): orders = [ - Order(user_id=1, shop_id=i, total_price=100 + (i * 50), status="pending") - for i in range(1, 6) + Order( + user_id=1, + shop_id=1, + total_price=150.0, + shipping_price=10.0, + status="pending", + delivery_address="1600 Amphitheatre Parkway, Mountain View, CA", + delivery_latitude=37.4220656, + delivery_longitude=-122.0840897, + ), + Order( + user_id=1, + shop_id=2, + total_price=200.0, + shipping_price=15.0, + status="shipped", + delivery_address="1 Infinite Loop, Cupertino, CA", + delivery_latitude=37.33182, + delivery_longitude=-122.03118, + ), + Order( + user_id=1, + shop_id=3, + total_price=250.0, + shipping_price=20.0, + status="delivered", + delivery_address="350 Fifth Avenue, New York, NY", + delivery_latitude=40.748817, + delivery_longitude=-73.985428, + ), + Order( + user_id=1, + shop_id=4, + total_price=300.0, + shipping_price=25.0, + status="pending", + delivery_address="221B Baker Street, London, UK", + delivery_latitude=51.523767, + delivery_longitude=-0.1585557, + ), + Order( + user_id=1, + shop_id=5, + total_price=350.0, + shipping_price=30.0, + status="canceled", + delivery_address="Eiffel Tower, Paris, France", + delivery_latitude=48.8588443, + delivery_longitude=2.2943506, + ), ] session.add_all(orders) session.commit() diff --git a/app/backend/models/models.py b/app/backend/models/models.py index 8397f9f6a4dc9c51166c40ef9d6584c58dffaadc..77b9b590cf60683d884d81632073388953082281 100644 --- a/app/backend/models/models.py +++ b/app/backend/models/models.py @@ -21,6 +21,10 @@ class Shop(SQLModel, table=True): name: str = Field(unique=True, index=True) description: Optional[str] = None image_url: Optional[str] = None # Image URL for shop + # location coordinates + address: str + latitude: float + longitude: float created_at: datetime = Field(default_factory=datetime.utcnow) owner: User = Relationship(back_populates="shops") @@ -62,7 +66,12 @@ class Order(SQLModel, table=True): user_id: int = Field(foreign_key="user.id") shop_id: int = Field(foreign_key="shop.id") total_price: float + shipping_price: float status: str = Field(default="pending") + # delivery location coordinates + delivery_address: str + delivery_latitude: float + delivery_longitude: float created_at: datetime = Field(default_factory=datetime.utcnow) user: User = Relationship(back_populates="orders") diff --git a/app/backend/routes/order.py b/app/backend/routes/order.py index 14b55f9a926e42f4397c760aa1ab378c7900a38b..c2eefde4e63ae7fe5b6f18772dfa033a7b5ba463 100644 --- a/app/backend/routes/order.py +++ b/app/backend/routes/order.py @@ -1,9 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException from sqlmodel import Session, select, delete +from geopy.geocoders import Nominatim +from geopy.distance import geodesic from backend.database import get_session from backend.routes.auth import get_current_user -from backend.models.models import Order, OrderItem, User, Product -from backend.schemas.order import OrderCreate, OrderRead +from backend.models.models import Order, OrderItem, User, Product, Shop +from backend.schemas.order import OrderCreate, OrderRead, OrderUpdate router = APIRouter() @@ -14,7 +16,26 @@ def create_order( session: Session = Depends(get_session), current_user: User = Depends(get_current_user), ): - total_price = 0 + # Fetch the shop details + shop = session.get(Shop, order_data.shop_id) + if not shop: + raise HTTPException(status_code=404, detail="Shop not found") + + # Geocode the delivery address + geolocator = Nominatim(user_agent="order_locator") + delivery_location = geolocator.geocode(order_data.delivery_address) + if not delivery_location: + raise HTTPException(status_code=400, detail="Invalid delivery address provided") + + # Calculate the distance between the shop and the delivery location + shop_location = (shop.latitude, shop.longitude) + delivery_coordinates = (delivery_location.latitude, delivery_location.longitude) + distance_km = geodesic(shop_location, delivery_coordinates).kilometers + + # Calculate the shipping price ($1 per km) + shipping_price = distance_km * 1.0 + + total_price = shipping_price for item in order_data.items: product = session.get(Product, item.product_id) if not product or product.stock < item.quantity: @@ -23,13 +44,22 @@ def create_order( ) total_price += item.quantity * product.price + # Create the order new_order = Order( - user_id=current_user.id, shop_id=order_data.shop_id, total_price=total_price + user_id=current_user.id, + shop_id=order_data.shop_id, + total_price=total_price, + shipping_price=shipping_price, + status="pending", + delivery_address=order_data.delivery_address, + delivery_latitude=delivery_location.latitude, + delivery_longitude=delivery_location.longitude, ) session.add(new_order) session.commit() session.refresh(new_order) + # Create order items for item in order_data.items: order_item = OrderItem( order_id=new_order.id, @@ -71,19 +101,50 @@ def get_order( @router.put("/status/{order_id}", response_model=OrderRead) def update_order( order_id: int, - status: str, + order_update: OrderUpdate, session: Session = Depends(get_session), current_user: User = Depends(get_current_user), ): order = session.get(Order, order_id) if not order or order.user_id != current_user.id: raise HTTPException(status_code=404, detail="Order not found or unauthorized") - if order.status != "pending" or status != "completed": - raise HTTPException( - status_code=400, - detail="Order can only be updated from pending to completed", + + # Update the delivery address if provided + if order_update.delivery_address: + geolocator = Nominatim(user_agent="order_locator") + delivery_location = geolocator.geocode(order_update.delivery_address) + if not delivery_location: + raise HTTPException(status_code=400, detail="Invalid delivery address") + order.delivery_address = order_update.delivery_address + order.delivery_latitude = delivery_location.latitude + order.delivery_longitude = delivery_location.longitude + + # Recalculate the shipping price + shop = session.get(Shop, order.shop_id) + if not shop: + raise HTTPException(status_code=404, detail="Shop not found") + shop_location = (shop.latitude, shop.longitude) + delivery_coordinates = (delivery_location.latitude, delivery_location.longitude) + distance_km = geodesic(shop_location, delivery_coordinates).kilometers + shipping_price = distance_km * 1.0 + + # Update the total price + product_total = sum( + item.quantity * session.get(Product, item.product_id).price + for item in order.order_items ) - order.status = status + order.shipping_price = shipping_price + order.total_price = product_total + shipping_price + + # Update the order status if provided + if order_update.status: + if order.status != "pending" or order_update.status != "completed": + raise HTTPException( + status_code=400, + detail="Order can only be updated from pending to completed", + ) + order.status = order_update.status + session.commit() session.refresh(order) return order diff --git a/app/backend/routes/product.py b/app/backend/routes/product.py index cadfba24acfe07e4b53d4151e7502f0492174415..fcad951db6c8c2d2cf4b7727a2c555599cb95b6b 100644 --- a/app/backend/routes/product.py +++ b/app/backend/routes/product.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from sqlmodel import Session +from sqlmodel import Session, func from datetime import datetime -from backend.models.models import Product, ProductImage, User, Shop +from backend.models.models import Product, ProductImage, User, Shop, OrderItem from backend.schemas.product import ProductRead, ProductUpdate from backend.database import get_session from backend.routes.auth import get_current_user @@ -76,8 +76,21 @@ def create_product( @router.get("/list", response_model=list[ProductRead]) -def read_all_products(session: Session = Depends(get_session)): - products = session.query(Product).all() +def read_all_products(order: str = "desc", session: Session = Depends(get_session)): + order_by = ( + func.count(OrderItem.id).desc() + if order == "desc" + else func.count(OrderItem.id).asc() + ) + + products = ( + session.query(Product) + .outerjoin(OrderItem, Product.id == OrderItem.product_id) + .group_by(Product.id) + .order_by(order_by) + .all() + ) + return products diff --git a/app/backend/routes/shop.py b/app/backend/routes/shop.py index 643b8b2ef82f4cfa0d669dbf5ae004dcc5e125ee..a240d1be12ee0774988de102a631da60042acbc5 100644 --- a/app/backend/routes/shop.py +++ b/app/backend/routes/shop.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form -from sqlmodel import Session -from backend.models.models import Shop, User -from backend.schemas.shop import ShopCreate, ShopRead +from sqlmodel import Session, func +from geopy.geocoders import Nominatim +from backend.models.models import Shop, User, Order +from backend.schemas.shop import ShopRead from backend.database import get_session from backend.routes.auth import get_current_user from core.config import settings @@ -15,36 +16,60 @@ router = APIRouter() def create_shop( name: str = Form(...), description: str = Form(None), + address: str = Form(...), file: UploadFile = File(None), session: Session = Depends(get_session), current_user: User = Depends(get_current_user), ): - shop = ShopCreate(name=name, description=description, owner_id=current_user.id) - db_shop = Shop.from_orm(shop) - - session.add(db_shop) + # Get latitude and longitude from address + geolocator = Nominatim(user_agent="shop_locator") + location = geolocator.geocode(address) + if not location: + raise HTTPException(status_code=400, detail="Invalid address") + + shop = Shop( + name=name, + description=description, + address=address, + latitude=location.latitude, + longitude=location.longitude, + owner_id=current_user.id, + ) + session.add(shop) session.commit() - session.refresh(db_shop) + session.refresh(shop) - shop_dir = os.path.join(settings.static_dir, f"shop_{db_shop.name}") + shop_dir = os.path.join(settings.static_dir, f"shop_{shop.name}") os.makedirs(shop_dir, exist_ok=True) if file and file.filename: file_location = os.path.join(shop_dir, file.filename) with open(file_location, "wb") as buffer: shutil.copyfileobj(file.file, buffer) - db_shop.image_url = file_location + shop.image_url = file_location else: - db_shop.image_url = os.path.join( - settings.static_dir, "default/default_shop.png" - ) + shop.image_url = os.path.join(settings.static_dir, "default/default_shop.png") + + session.commit() + session.refresh(shop) - return db_shop + return shop @router.get("/list", response_model=list[ShopRead]) -def get_all_shops(session: Session = Depends(get_session)): - shops = session.query(Shop).all() +def get_all_shops(order: str = "desc", session: Session = Depends(get_session)): + order_by = ( + func.count(Order.id).desc() if order == "desc" else func.count(Order.id).asc() + ) + + shops = ( + session.query(Shop) + .outerjoin(Order, Shop.id == Order.shop_id) + .group_by(Shop.id) + .order_by(order_by) + .all() + ) + return shops @@ -61,41 +86,47 @@ def update_shop( shop_id: int, name: str = Form(None), description: str = Form(None), + address: str = Form(None), file: UploadFile = File(None), session: Session = Depends(get_session), current_user: User = Depends(get_current_user), ): - db_shop = session.get(Shop, shop_id) - if not db_shop: + shop = session.get(Shop, shop_id) + if not shop: raise HTTPException(status_code=404, detail="Shop not found") # Ensure the current user is the shop owner - if db_shop.owner_id != current_user.id: + if shop.owner_id != current_user.id: raise HTTPException(status_code=403, detail="Unauthorized to update this shop") - if name: - db_shop.name = name + shop.name = name if description: - db_shop.description = description - - shop_dir = os.path.join(settings.static_dir, f"shop_{db_shop.name}") + shop.description = description + if address: + geolocator = Nominatim(user_agent="shop_locator") + location = geolocator.geocode(address) + if not location: + raise HTTPException(status_code=400, detail="Invalid address") + shop.address = address + shop.latitude = location.latitude + shop.longitude = location.longitude + + shop_dir = os.path.join(settings.static_dir, f"shop_{shop.name}") os.makedirs(shop_dir, exist_ok=True) if file and file.filename: file_location = os.path.join(shop_dir, file.filename) with open(file_location, "wb") as buffer: shutil.copyfileobj(file.file, buffer) - db_shop.image_url = file_location + shop.image_url = file_location else: - db_shop.image_url = os.path.join( - settings.static_dir, "default/default_shop.png" - ) + shop.image_url = os.path.join(settings.static_dir, "default/default_shop.png") - session.add(db_shop) + session.add(shop) session.commit() - session.refresh(db_shop) + session.refresh(shop) - return db_shop + return shop @router.delete("/delete/{shop_id}") diff --git a/app/backend/schemas/order.py b/app/backend/schemas/order.py index 37fdda72ec31777d18347c516714ac9275a14abb..ca5e2ce1dbe2a38530794397f98fdd90cdc38e3a 100644 --- a/app/backend/schemas/order.py +++ b/app/backend/schemas/order.py @@ -11,6 +11,7 @@ class OrderItemCreate(BaseModel): class OrderCreate(BaseModel): shop_id: int items: List[OrderItemCreate] + delivery_address: str class OrderItemRead(BaseModel): @@ -28,9 +29,15 @@ class OrderRead(BaseModel): user_id: int shop_id: int total_price: float + shipping_price: float status: str created_at: datetime order_items: List[OrderItemRead] class Config: from_attributes = True + + +class OrderUpdate(BaseModel): + delivery_address: Optional[str] = None + status: Optional[str] = None diff --git a/app/backend/schemas/shop.py b/app/backend/schemas/shop.py index 71763bb8b375e6051849d32a9ad5efad889a106b..8cfb87fc29627f7ade3fd7c0b119995f728d8dec 100644 --- a/app/backend/schemas/shop.py +++ b/app/backend/schemas/shop.py @@ -14,6 +14,7 @@ class ShopCreate(ShopBase): class ShopRead(ShopBase): id: int owner_id: int + address: str image_url: Optional[str] = None class Config: diff --git a/requirements.txt b/requirements.txt index 098a8e9d014d631acb415fded564d5774243a0a0..2383f12bc661c32a3b8eac0fcf9146bd60a0f3fe 100644 Binary files a/requirements.txt and b/requirements.txt differ