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~hs0T6pLu&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