From 40e151a131a80460fcf0d6337ff2bc3560a0470c Mon Sep 17 00:00:00 2001
From: duyanhehe <duyanhex@gmail.com>
Date: Sun, 23 Mar 2025 17:02:52 +0700
Subject: [PATCH] add location and calculate shipping fee

---
 app/backend/database.py      |   4 +-
 app/backend/models/models.py |   9 ++++
 app/backend/routes/order.py  |  81 ++++++++++++++++++++++++++++++-----
 app/backend/routes/shop.py   |  74 ++++++++++++++++++++------------
 app/backend/schemas/order.py |   7 +++
 app/backend/schemas/shop.py  |   1 +
 requirements.txt             | Bin 2142 -> 1045 bytes
 7 files changed, 137 insertions(+), 39 deletions(-)

diff --git a/app/backend/database.py b/app/backend/database.py
index 340a31f..51f1c51 100644
--- a/app/backend/database.py
+++ b/app/backend/database.py
@@ -7,8 +7,8 @@ engine = create_engine(settings.database_url, echo=settings.debug)
 
 def init_db():
     SQLModel.metadata.create_all(engine, checkfirst=True)
-    with Session(engine) as session:
-        insert_dummy_data(session)
+    # with Session(engine) as session:
+    #     insert_dummy_data(session)
 
 
 def get_session():
diff --git a/app/backend/models/models.py b/app/backend/models/models.py
index 8397f9f..77b9b59 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 14b55f9..c2eefde 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/shop.py b/app/backend/routes/shop.py
index 643b8b2..2ce59c7 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 geopy.geocoders import Nominatim
 from backend.models.models import Shop, User
-from backend.schemas.shop import ShopCreate, ShopRead
+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,31 +16,44 @@ 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")
 
-    return db_shop
+    session.commit()
+    session.refresh(shop)
+
+    return shop
 
 
 @router.get("/list", response_model=list[ShopRead])
@@ -61,41 +75,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 37fdda7..ca5e2ce 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 71763bb..8cfb87f 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
GIT binary patch
literal 1045
zcmYey%gZlGEJ;n#EvYO>Ew;5a&@<OF;7ZJ^%*?m7HPN%sGvG=}E~+djv9&eUGtx8S
zN=_{*$xO?%wKXy@GSxHGGdAQ(P6P1`^$gAR47rjs5{rscOLX({i*gflGOJRHKsrr8
zGC7&a*|xS8dWL!yT*>)4`9+Dji69$I^vt-xcI2lQB^G2<+S;0!=ox_QDlIO_&n?N$
z%qsyIV+wXaN@7uVN@_`JatTN;$bl(&#RZim8Tom(wnkv*r{*SR=ERpJ=47TMmgE<K
zgpKqJxY81fOA-q*LD~!rP4x^7VIsQ8Ibcx(J#((~RER4wlXEhYKspUTLIsr|Cde1*
zMX9NIIjP|AFw`^T$}ltp8)%|uz?D%_QjnZqlxk~hs0T6&#4pLu&jE+4nVtz)K?O*;
zk%gWiS7u6HA}DYS4Y)E(ic=Ev(o>7_ON&7^ft>4=nU|Gl1XiGD%9RW9c7A!DZf1!t
z$Th}#26_fuzKKQIr3Jx>X{jJ}pfJl#DJ{wYSz)MW#FblFT$rPqoS&DMnp~1!qzels
zOFfVU`9)d9pg=R$Gc?dMH03HtOwLYB&&*4=wKX!)GvX>pEG`BIf}x(do(We$W=>9i
zxvj0Cp`M|h0arm~aseo+A;D|JRZy9dm{*dS4067qfu0#$KsU9bq$m-Vz>GkaAyn%Y
zr<Rmt=B0yu4GI*PG4YUqG}1FN*E8Y@s7%jI%_{+`GPKk)<O-<t3J-x=1>*Tu1_%0p
z!W|SR5O3<H<d>x8m4O5e^bEP6g1MzRC7A_@Mc~w9pl4*j6;K)J=<8!^Yo-SZzoOK_
z($r#zO~yug##}|2$r-k`hQ@l9dM03wE+|T~Gr<NJ8tWNx6=$U8<YeZhXC&r=EHl+J
z;VRC{OiKf0AwxZ5JwvYGKp)4P<c!o@a56H`Gq&I=F3iczPe}zwm4Tj-F;{U(Vo^?N
zNl7Xw@=VP147fm9vIxX8G}SW#@iOz$<5Me2QuB&4^Ye;9`O^@TB}*aEXbK8h1Fq7d
zoSe)gV^G)_>ltvBmSraA7v+JBF$Sg4^2CzljI_)gP-X|EQWLK7)THA4<m^<CpAAj*
G47dR3XFVtY

literal 2142
zcmezWFOeaSA&()Sp@bokp@booA%#Jgp@gB5p@1Qkp_svz!Ir^*L65<lL65<JftP`c
z0i?c?A(J5=EN8-?$6x`MOJYc7C<5y(0m~XQ=rI_9F-T`JLn=cNLkUABLmERSSl)=i
zfWe5tltGWdkU@{Zn86TiLo!1eTm?wn5Ntb4E`uSFp@^ZFA(f$oK^N@uB8FUsM1~xO
zOol434InpzYzDavrVAvS%#aPX735x!xfW2{@)>d%@)?R462azxbc1{h(rpG+0rOKn
zLpoSp0Ye5uCD<$z29P?CdLyXXQifuN5{7(+T!s>cY=%sRJaDKMfmMTiiU^MsuurlX
zQow#mWk_a#gc`^+kQ*W4ox+gEP|Q%kP{~ljkin48kOwviWC|iqQo%0IWXNHNXDEY)
z1;})eJ0LcL)Ppd@%ru5XuqzW83ZO9oveS^k5E>VdP(xLv%a9BXV_5irbeMzv3<{G}
zhE#?;h8(a@Kq^4t4+>+5Nf`_vvq9n@U53yw1Eq-!h7yJnh609Ua15k^{RYwpQU@^|
zrUK*_P)s4xq#1)Ag9+FLFf|om{UE(Y;8Y2*J(B?xhKXP~h`S)6kjYTOP|N@dr#yyq
zaLUPN0L322ouIG+VTejEa6D!)Br+HwnWD#F3bq}T`idAp@tDt0&XC8T%K$Q27aV6G
zvp{x&+ypVp7aHrO3<V6q42cYB3=m(#R6){FE<*}KDMJxM4%j}BZcwPe<SH48844M4
z7(j6XN)dSskkkW8#SnXu(~2cH2g33%NKF<)F+3%K>@@`EaZv1o;-r8fkpYxf5*gCL
z{!RzG!3Z2{5Ep<{6f+cq(?b$i7UVimdW6IWC{^Wvb5%K179<bCkT3wH=41wtDUh6u
zoJL_bR6=70R9+;5O##IR#HEmY2{Hu|dk{6c45<ti3?<+kie82x^kO$(7h0l#Oai3_
zNGb#A0_8+VIzqQE9wpU+)Ea?HDu@jM43!M&47uQZ0ZM0}lmJp=$N-8Dh>0LIUJT(3
zA!z<X$oeu=G6XXOGWdX1fI<+0A@PJL6Lc9;z-2fn7nZ@*LedSU+FXWGh8%E742c6o
zxdYMxDwRQcK(32qaAfd>+78NZ5P6Vn5ko3NAwwy+mO%EOF}TDx2Iq_-hD?TJh772C
zKq(F6cSst6$$?@jp8=G!L8T?aC58+j^^mYEX2@VjWdNlpP<fip0ICCWp{9azA*>uI
zX2=8AE};4aQa^!03uF!?L?I#R$dChe6{ysNl;WUx0r||B0a7PGa#t=xKDcZ~<Ux=c
zBL;}yK&3pWCW4fCpqdjD!ypwV43Lllg%YeLg`{whtRc9Jh4>313-MDtH1&dFuozsw
zg35i6nV=fR5FDxyU8M}j`4?2DKyn2nZ$U~8P<@xgU<?i;knccY0Fy5R#|o$%fYc=*
yR~dtIH$+7_xFrCJH&8f&{0GSmps)m~g~VSuLn=cOxEukM6`<M}rV8XDh)DptEjSAR

-- 
GitLab