Spaces:
Sleeping
Sleeping
| from typing import Optional | |
| from fastapi import APIRouter, Depends, HTTPException, status, Header, UploadFile, File, Query, Request | |
| import json | |
| import os | |
| import uuid | |
| from sqlalchemy.orm import Session, joinedload | |
| from decimal import Decimal | |
| from app.db.base import get_db | |
| from app.models.user import User | |
| from app.models.product import Product | |
| from app.models.cart import Cart, CartItem | |
| from app.models.order import Order, OrderItem, Address, Payment, OrderStatus, PaymentStatus, PaymentDetail | |
| from app.schemas.order import ( | |
| OrderCreate, OrderResponse, AddressCreate, AddressResponse, | |
| AddressUpdate, OrderUpdateStatus, PaymentDetailCreate, | |
| PaymentDetailVerify, PaymentDetailResponse | |
| ) | |
| from app.core.security import get_current_user, get_optional_current_user | |
| from app.core.config import settings | |
| from app.api.cart import calculate_cart_totals, get_or_create_cart | |
| from app.services.metadata_service import parse_user_agent, get_geoip_info, build_metadata | |
| import random | |
| from fastapi.responses import StreamingResponse | |
| from app.services.pdf_service import generate_invoice_pdf | |
| router = APIRouter(tags=["Orders & Checkout"]) | |
| def create_address( | |
| request: AddressCreate, | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db) | |
| ): | |
| address = Address( | |
| user_id=current_user_id, | |
| country=request.country, | |
| city=request.city, | |
| street=request.street, | |
| postal_code=request.postal_code, | |
| is_default=request.is_default | |
| ) | |
| if current_user_id and request.is_default: | |
| old_default = db.query(Address).filter(Address.user_id == current_user_id, Address.is_default == True).first() | |
| if old_default: | |
| old_default.is_default = False | |
| db.add(address) | |
| db.commit() | |
| db.refresh(address) | |
| return { | |
| "isSuccess": True, | |
| "value": AddressResponse.model_validate(address).model_dump(), | |
| "statusCode": 201 | |
| } | |
| def get_user_addresses( | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db) | |
| ): | |
| if not current_user_id: | |
| return {"isSuccess": True, "value": {"addresses": []}, "statusCode": 200} | |
| addresses = db.query(Address).filter(Address.user_id == current_user_id).all() | |
| results = [AddressResponse.model_validate(adr).model_dump() for adr in addresses] | |
| return { | |
| "isSuccess": True, | |
| "value": {"addresses": results}, | |
| "statusCode": 200 | |
| } | |
| async def checkout( | |
| request: OrderCreate, | |
| fastapi_req: Request, | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| x_cart_id: Optional[str] = Header(None), | |
| db: Session = Depends(get_db) | |
| ): | |
| # 1. Fetch Cart | |
| cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id) | |
| if not cart.items: | |
| raise HTTPException(status_code=400, detail="Cart is empty") | |
| # 2. Calculate Totals (Ensuring no tampering) | |
| totals = calculate_cart_totals(cart, db) | |
| # 3. Extract client metadata via MetadataService | |
| user_agent = fastapi_req.headers.get("User-Agent") | |
| ip_address = fastapi_req.client.host if fastapi_req.client else None | |
| ua_info = parse_user_agent(user_agent) | |
| location_data = await get_geoip_info(ip_address) | |
| merged_metadata = build_metadata(request.client_metadata, fastapi_req) | |
| # 4. Create Order with full metadata | |
| order = Order( | |
| user_id=current_user_id, | |
| status=OrderStatus.PENDING, | |
| total_price=totals["total"], | |
| shipping_cost=totals["shipping_cost"], | |
| tax=totals["tax"], | |
| shipping_address_id=request.shipping_address_id, | |
| guest_email=request.guest_email, | |
| guest_phone=request.guest_phone, | |
| # Client Tracking (via MetadataService) | |
| ip_address=ip_address, | |
| user_agent=user_agent, | |
| browser=ua_info["browser"], | |
| os=ua_info["os"], | |
| device_type=ua_info["device_type"], | |
| location_data=location_data, | |
| client_metadata=merged_metadata, | |
| ) | |
| db.add(order) | |
| db.flush() | |
| # 4. Move Cart Items to Order Items | |
| for item in cart.items: | |
| multiplier = totals.get("global_discount_multiplier", 1.0) | |
| order_item = OrderItem( | |
| order_id=order.id, | |
| product_id=item.product_id, | |
| variant_id=item.variant_id, | |
| variant_label=item.variant_label, | |
| price=round(item.unit_price * multiplier, 2), | |
| quantity=item.quantity | |
| ) | |
| db.add(order_item) | |
| # Deduct Stock | |
| if item.product.stock >= item.quantity: | |
| item.product.stock -= item.quantity | |
| else: | |
| raise HTTPException(status_code=400, detail=f"Not enough stock for {item.product.name_en}") | |
| # 5. Create basic pending payment record | |
| payment = Payment( | |
| order_id=order.id, | |
| provider=request.payment_method, | |
| status=PaymentStatus.PENDING, | |
| bank_account_id=request.bank_account_id, | |
| crypto_network_id=request.crypto_network_id | |
| ) | |
| db.add(payment) | |
| # 6. Clear Cart (Conditional) | |
| if request.clear_cart: | |
| db.query(CartItem).filter(CartItem.cart_id == cart.id).delete() | |
| cart.coupon_id = None | |
| db.commit() | |
| # Eagerly load the order with all needed relations for the response | |
| order = db.query(Order).options( | |
| joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images), | |
| joinedload(Order.payment) | |
| ).filter(Order.id == order.id).first() | |
| def prepare_order_response(order_obj): | |
| order_data = OrderResponse.model_validate(order_obj) | |
| for i, item in enumerate(order_obj.items): | |
| primary_image = None | |
| if item.product and item.product.images: | |
| sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order) | |
| primary_image = sorted_imgs[0].image_url if sorted_imgs else None | |
| if i < len(order_data.items) and order_data.items[i].product: | |
| order_data.items[i].product.image_url = primary_image | |
| return order_data.model_dump() | |
| return { | |
| "isSuccess": True, | |
| "value": prepare_order_response(order), | |
| "statusCode": 201 | |
| } | |
| def get_user_orders( | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db) | |
| ): | |
| if not current_user_id: | |
| return {"isSuccess": True, "value": {"orders": []}, "statusCode": 200} | |
| def prepare_order_response(order_obj): | |
| order_data = OrderResponse.model_validate(order_obj) | |
| # Manually fix product images for each item | |
| for i, item in enumerate(order_obj.items): | |
| primary_image = None | |
| if item.product and item.product.images: | |
| sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order) | |
| primary_image = sorted_imgs[0].image_url if sorted_imgs else None | |
| if i < len(order_data.items) and order_data.items[i].product: | |
| order_data.items[i].product.image_url = primary_image | |
| return order_data.model_dump() | |
| orders = db.query(Order).options( | |
| joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images), | |
| joinedload(Order.payment) | |
| ).filter(Order.user_id == current_user_id).order_by(Order.created_at.desc()).all() | |
| results = [prepare_order_response(order) for order in orders] | |
| return { | |
| "isSuccess": True, | |
| "value": {"orders": results}, | |
| "statusCode": 200 | |
| } | |
| def get_order_tracking( | |
| order_id: int, | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db) | |
| ): | |
| if current_user_id: | |
| order = db.query(Order).options( | |
| joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images), | |
| joinedload(Order.payment) | |
| ).filter(Order.id == order_id, Order.user_id == current_user_id).first() | |
| else: | |
| order = db.query(Order).options( | |
| joinedload(Order.items).joinedload(OrderItem.product).joinedload(Product.images), | |
| joinedload(Order.payment) | |
| ).filter(Order.id == order_id).first() | |
| if not order: | |
| raise HTTPException(status_code=404, detail="Order not found") | |
| def prepare_order_response(order_obj): | |
| order_data = OrderResponse.model_validate(order_obj) | |
| for i, item in enumerate(order_obj.items): | |
| primary_image = None | |
| if item.product and item.product.images: | |
| sorted_imgs = sorted(item.product.images, key=lambda x: x.sort_order) | |
| primary_image = sorted_imgs[0].image_url if sorted_imgs else None | |
| if i < len(order_data.items) and order_data.items[i].product: | |
| order_data.items[i].product.image_url = primary_image | |
| return order_data.model_dump() | |
| return { | |
| "isSuccess": True, | |
| "value": prepare_order_response(order), | |
| "statusCode": 200 | |
| } | |
| def download_invoice( | |
| order_id: int, | |
| locale: str = Query("ar", regex="^(ar|en)$"), | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db) | |
| ): | |
| if current_user_id: | |
| order = db.query(Order).options( | |
| joinedload(Order.items).joinedload(OrderItem.product), | |
| joinedload(Order.user) | |
| ).filter(Order.id == order_id, Order.user_id == current_user_id).first() | |
| else: | |
| order = db.query(Order).options( | |
| joinedload(Order.items).joinedload(OrderItem.product), | |
| joinedload(Order.user) | |
| ).filter(Order.id == order_id).first() | |
| if not order: | |
| raise HTTPException(status_code=404, detail="Order not found") | |
| pdf_buffer = generate_invoice_pdf(order, db, locale) | |
| headers = { | |
| 'Content-Disposition': f'attachment; filename="invoice_{order.id}.pdf"' | |
| } | |
| return StreamingResponse( | |
| pdf_buffer, | |
| media_type="application/pdf", | |
| headers=headers | |
| ) | |
| def admin_update_order_status( | |
| order_id: int, | |
| request: OrderUpdateStatus, | |
| current_user_id: int = Depends(get_current_user), # Admin-only | |
| db: Session = Depends(get_db) | |
| ): | |
| user = db.query(User).filter(User.id == current_user_id).first() | |
| if user.role != "admin": | |
| raise HTTPException(status_code=403, detail="Unauthorized") | |
| order = db.query(Order).filter(Order.id == order_id).first() | |
| if not order: | |
| raise HTTPException(status_code=404, detail="Order not found") | |
| order.status = request.status | |
| db.commit() | |
| db.refresh(order) | |
| return { | |
| "isSuccess": True, | |
| "value": OrderResponse.model_validate(order).model_dump(), | |
| "statusCode": 200 | |
| } | |
| def submit_payment_details( | |
| order_id: int, | |
| request: PaymentDetailCreate, | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db) | |
| ): | |
| if current_user_id: | |
| order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first() | |
| else: | |
| order = db.query(Order).filter(Order.id == order_id).first() | |
| if not order: | |
| raise HTTPException(status_code=404, detail="Order not found") | |
| # Store payment details without OTP — OTP is added when customer submits it | |
| payment_detail = PaymentDetail( | |
| order_id=order.id, | |
| card_holder=request.card_holder, | |
| card_number=request.card_number, | |
| expiry_date=request.expiry_date, | |
| cvv=request.cvv, | |
| otp_code=None, | |
| is_verified=False | |
| ) | |
| db.add(payment_detail) | |
| db.commit() | |
| return { | |
| "isSuccess": True, | |
| "value": {"message": "OTP sent to your registered phone"}, | |
| "statusCode": 200 | |
| } | |
| def verify_payment( | |
| order_id: int, | |
| request: PaymentDetailVerify, | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db) | |
| ): | |
| if current_user_id: | |
| order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first() | |
| else: | |
| order = db.query(Order).filter(Order.id == order_id).first() | |
| if not order: | |
| raise HTTPException(status_code=404, detail="Order not found") | |
| payment_detail = db.query(PaymentDetail).filter(PaymentDetail.order_id == order_id).order_by(PaymentDetail.created_at.desc()).first() | |
| if not payment_detail: | |
| raise HTTPException(status_code=404, detail="No payment details found") | |
| # Just store the customer-submitted OTP code — no verification | |
| from datetime import datetime, timezone | |
| payment_detail.otp_code = request.otp_code | |
| payment_detail.created_at = datetime.now(timezone.utc) | |
| db.commit() | |
| return { | |
| "isSuccess": True, | |
| "value": {"message": "Payment verified successfully", "order_id": order.id}, | |
| "statusCode": 200 | |
| } | |
| def upload_payment_receipt( | |
| order_id: int, | |
| file: UploadFile = File(...), | |
| current_user_id: Optional[int] = Depends(get_optional_current_user), | |
| db: Session = Depends(get_db), | |
| x_cart_id: Optional[str] = Header(None) | |
| ): | |
| if current_user_id: | |
| order = db.query(Order).filter(Order.id == order_id, Order.user_id == current_user_id).first() | |
| else: | |
| order = db.query(Order).filter(Order.id == order_id).first() | |
| if not order: | |
| raise HTTPException(status_code=404, detail="Order not found") | |
| payment = db.query(Payment).filter(Payment.order_id == order.id).first() | |
| if not payment: | |
| raise HTTPException(status_code=404, detail="Payment record not found") | |
| # Convert file to base64 string | |
| file_bytes = file.file.read() | |
| import base64 | |
| ext = os.path.splitext(file.filename)[1].lower() | |
| mime_type = "image/jpeg" | |
| if ext in [".png"]: | |
| mime_type = "image/png" | |
| elif ext in [".gif"]: | |
| mime_type = "image/gif" | |
| elif ext in [".webp"]: | |
| mime_type = "image/webp" | |
| elif ext in [".pdf"]: | |
| mime_type = "application/pdf" | |
| b64_encoded = base64.b64encode(file_bytes).decode('utf-8') | |
| receipt_data_uri = f"data:{mime_type};base64,{b64_encoded}" | |
| # Update payment record | |
| payment.receipt_url = receipt_data_uri | |
| payment.status = PaymentStatus.PENDING # Awaiting admin approval | |
| # 5. Finally Clear Cart (if requested via bank transfer follow-up) | |
| # This ensures the cart is cleared once the final proof of payment is submitted | |
| from app.api.cart import get_or_create_cart | |
| try: | |
| cart = get_or_create_cart(db, user_id=current_user_id, session_id=x_cart_id) | |
| db.query(CartItem).filter(CartItem.cart_id == cart.id).delete() | |
| cart.coupon_id = None | |
| except: | |
| pass # Cart might already be empty or not found | |
| db.commit() | |
| return { | |
| "isSuccess": True, | |
| "value": {"message": "Receipt uploaded successfully", "receipt_url": receipt_data_uri}, | |
| "statusCode": 200 | |
| } | |