commit b2fe1b8502af8bd176dc2186a7ecc2776357d602 Author: mealno Date: Mon May 25 14:07:00 2026 +0000 Upload files to "/" diff --git a/See b/See new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/See differ diff --git a/[34 b/[34 new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/[34 differ diff --git a/[41 b/[41 new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/[41 differ diff --git a/[43 b/[43 new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/[43 differ diff --git a/[44 b/[44 new file mode 100644 index 0000000..06d7405 Binary files /dev/null and b/[44 differ diff --git a/cloudinary_config.py b/cloudinary_config.py new file mode 100644 index 0000000..4cb4c97 --- /dev/null +++ b/cloudinary_config.py @@ -0,0 +1,11 @@ +import cloudinary +import os +from dotenv import load_dotenv + +load_dotenv() + +cloudinary.config( + cloud_name=os.getenv("CLOUDINARY_CLOUD_NAME"), + api_key=os.getenv("CLOUDINARY_API_KEY"), + api_secret=os.getenv("CLOUDINARY_API_SECRET") +) \ No newline at end of file diff --git a/crud.py b/crud.py new file mode 100644 index 0000000..21b29fe --- /dev/null +++ b/crud.py @@ -0,0 +1,1127 @@ +from sqlalchemy.orm import Session +from models import VendorMenu, MenuItem +import random +from models import Vendor,Order, OrderItem, MenuItem,Delivery, Order,User +from utils import verify_password, create_token,haversine,hash_password +from models import DeliveryBoy, Batch,SupportTicket +from notifications.sendnotification import ( + send_vendor_new_order_notification, + send_vendor_delivered_notification, + send_vendor_cancelled_notification +) + +def find_nearest_vendor(db, lat, lng): + vendors = db.query(Vendor).filter( + Vendor.is_active == True, + Vendor.is_open == True # โœ… ADD THIS + ).all() + + available = [] + + for v in vendors: + if v.lat is None or v.lng is None: + continue + + distance = haversine(lat, lng, v.lat, v.lng) + + if distance <= v.radius: + available.append((v, distance)) + + if not available: + return None + + available.sort(key=lambda x: x[1]) + return available[0] # (vendor, distance) + + +def get_vendor_menu(db: Session, vendor_id: int): + results = ( + db.query(VendorMenu, MenuItem) + .join(MenuItem, VendorMenu.menu_item_id == MenuItem.id) + .filter( + VendorMenu.vendor_id == vendor_id, + VendorMenu.is_available == True + ) + .all() + ) + + menu = [] + + for vm, item in results: + menu.append({ + "id": item.id, + "name": item.name, + "category": item.category, + "description": item.description, + "price": item.selling_price, + "image_url": item.image_url # ๐Ÿ”ฅ ADD THIS + + }) + + return menu + + + + + +def create_order(db: Session, order_data, backend_ip): + + total = 0 + order_items_data = [] + + # ========================= + # ๐Ÿ”น PROCESS ITEMS + # ========================= + for item in order_data.items: + menu_item = db.query(MenuItem).filter( + MenuItem.id == item.menu_item_id + ).first() + + if not menu_item: + raise Exception(f"Item {item.menu_item_id} not found") + + # ๐Ÿ”ฅ CHECK ITEM AVAILABLE FOR THIS VENDOR + vm = db.query(VendorMenu).filter( + VendorMenu.menu_item_id == menu_item.id, + VendorMenu.vendor_id == item.vendor_id, + VendorMenu.is_available == True + ).first() + + if not vm: + raise Exception(f"Item {menu_item.name} not available") + + vendor_id = vm.vendor_id + + item_total = menu_item.selling_price * item.quantity + total += item_total + + order_items_data.append({ + "menu_item_id": menu_item.id, + "vendor_id": vendor_id, + "quantity": item.quantity, + "name": menu_item.name, + "vendor_price": menu_item.vendor_price, + "selling_price": menu_item.selling_price + }) + + # ========================= + # โŒ SAFETY CHECK + # ========================= + if not order_items_data: + raise Exception("No valid items in order") + + # ========================= + # ๐Ÿ”ฅ ENSURE SAME ZONAL ADMIN + # ========================= + admin_ids = set() + + for item in order_items_data: + vendor = db.query(Vendor).filter( + Vendor.id == item["vendor_id"] + ).first() + + if vendor: + admin_ids.add(vendor.zonal_admin_id) + + if len(admin_ids) != 1: + raise Exception("All items must belong to same zonal admin") + + zonal_admin_id = list(admin_ids)[0] + + + # ========================= + # ๐Ÿ“ GET USER ADDRESS + # ========================= + + from models import UserAddress + + address = db.query(UserAddress).filter( + UserAddress.user_id == order_data.user_id, + UserAddress.is_default == True + ).first() + + # ========================= + # ๐Ÿ’ฐ CALCULATIONS + # ========================= + gst = round(total * 0.05, 2) + final_amount = round(total + gst, 2) + + # ========================= + # ๐Ÿงพ CREATE ORDER + # ========================= + + # โœ… Prevent duplicate orders from rapid double click + recent_order = ( + db.query(Order) + .filter(Order.user_id == order_data.user_id) + .order_by(Order.created_at.desc()) + .first() + ) + + if recent_order: + + time_diff = datetime.utcnow() - recent_order.created_at + + # block duplicate within 5 seconds + if time_diff.total_seconds() < 5: + raise Exception("Duplicate order detected") + + order = Order( + user_id=order_data.user_id, + frontend_ip=order_data.frontend_ip, + backend_ip=backend_ip, + + user_lat=order_data.lat, + user_lng=order_data.lng, + + # ๐Ÿ”ฅ SAVE ADDRESS SNAPSHOT + address_flat=address.flat if address else "", + address_building=address.building if address else "", + address_landmark=address.landmark if address else "", + address_type=address.address_type if address else "", + + address_lat=address.lat if address else None, + address_lng=address.lng if address else None, + + total_amount=total, + gst=gst, + final_amount=final_amount, + + status="PLACED", + payment_status="PENDING", + + zonal_admin_id=zonal_admin_id + ) + + db.add(order) + db.flush() # get order.id before commit + + # ========================= + # ๐Ÿ“ฆ INSERT ORDER ITEMS + # ========================= + for item_data in order_items_data: + db_item = OrderItem(order_id=order.id, **item_data) + db.add(db_item) + + db.commit() + db.refresh(order) + + # ========================= + # ๐Ÿ”” SEND VENDOR PUSH + # ========================= + + vendor_ids = set() + + for item in order_items_data: + vendor_ids.add(item["vendor_id"]) + + for vendor_id in vendor_ids: + + send_vendor_new_order_notification( + vendor_id, + order.id + ) + # ========================= + # โœ… RESPONSE + # ========================= + return { + "order_id": order.id, + "final_amount": final_amount + } + + + + +# def update_delivery_status(db, data, user): + +# if user.get("role") != "DELIVERY_BOY": +# return {"error": "Unauthorized"} + +# boy = db.query(DeliveryBoy).filter( +# DeliveryBoy.id == user["delivery_boy_id"] +# ).first() + +# new_status = data.get("status") + +# allowed = [ +# "AT_VENDOR", +# "PICKED_UP", +# "OUT_FOR_DELIVERY", +# "DELIVERED" +# ] + +# if new_status not in allowed: +# return {"error": "Invalid status"} + +# boy.status = new_status + +# # ========================= +# # โœ… SINGLE ORDER FLOW +# # ========================= +# if boy.current_order_id: +# order = db.query(Order).filter( +# Order.id == boy.current_order_id +# ).first() + +# if new_status != "AT_VENDOR": +# order.status = new_status + +# if new_status == "DELIVERED": +# order.status = "DELIVERED" + +# boy.status = "IDLE" +# boy.current_order_id = None + +# # ========================= +# # โœ… BATCH FLOW +# # ========================= +# elif boy.current_batch_id: +# batch = db.query(Batch).filter( +# Batch.id == boy.current_batch_id +# ).first() + +# if new_status == "PICKED_UP": +# batch.status = "PICKED_UP" +# for o in batch.orders: +# o.status = "PICKED_UP" + +# elif new_status == "OUT_FOR_DELIVERY": +# batch.status = "OUT_FOR_DELIVERY" + +# for o in batch.orders: +# o.status = "OUT_FOR_DELIVERY" + +# elif new_status == "DELIVERED": +# # ๐Ÿ”ฅ Deliver ONE ORDER at a time (frontend controls which) +# order_id = data.get("order_id") + +# order = db.query(Order).filter(Order.id == order_id).first() +# order.status = "DELIVERED" + +# # ๐Ÿ”ฅ FINAL RULE (YOUR CORE LOGIC) +# if all(o.status == "DELIVERED" for o in batch.orders): +# batch.status = "DELIVERED" + +# boy.status = "IDLE" +# boy.current_batch_id = None + +# db.commit() + +# return {"message": f"Updated to {new_status}"} + + +def add_payment(db, data, user): + + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + delivery = db.query(Delivery).filter( + Delivery.order_id == data.order_id + ).first() + + if not delivery: + return {"error": "Delivery not found"} + + order = db.query(Order).filter(Order.id == data.order_id).first() + + if not order: + return {"error": "Order not found"} + + # โœ… VALIDATION + if data.payment_method not in ["COD", "UPI"]: + return {"error": "Invalid payment method"} + + # โœ… UPI proof required + if data.payment_method == "UPI": + + if not data.payment_proof: + return {"error": "Payment screenshot required"} + + # โœ… basic cloudinary validation + if "cloudinary.com" not in data.payment_proof: + return {"error": "Invalid payment proof"} + + # โœ… STORE PAYMENT + delivery.payment_method = data.payment_method + delivery.payment_proof = data.payment_proof + delivery.payment_amount = data.amount + delivery.status = "COMPLETED" + + # โœ… UPDATE ORDER + order.payment_status = "PAID" + order.status = "DELIVERED" + + # โœ… RESET DELIVERY BOY + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + if boy.current_order_id == order.id: + boy.current_order_id = None + boy.status = "IDLE" + + db.commit() + + return {"message": "Payment recorded & order completed"} + +# User Login + + + +# def create_user(db, data): +# existing = db.query(User).filter( +# (User.phone == data.phone) | (User.email == data.email) +# ).first() + +# if existing: +# return {"error": "User already exists"} + +# user = User( +# name=data.name, +# phone=data.phone, +# email=data.email, +# password=hash_password(data.password), +# address=data.address +# ) + +# db.add(user) +# db.commit() +# db.refresh(user) + +# # ๐Ÿ”ฅ ADD TOKEN +# token = create_token({"user_id": user.id}) + +# return { +# "token": token, +# "user": { +# "id": user.id, +# "name": user.name +# } +# } + + +def login_user(db, data): + user = db.query(User).filter( + (User.phone == data.phone) | (User.email == data.email) + ).first() + + if not user: + return {"error": "User not found"} + + if not verify_password(data.password, user.password): + return {"error": "Wrong password"} + + token = create_token({"user_id": user.id}) + + return { + "token": token, + "user": { + "id": user.id, + "name": user.name + } + } + +# twilo + +from dotenv import load_dotenv +import os +from twilio.rest import Client + +load_dotenv() + +# โœ… helper +def get_twilio_client(): + return Client( + os.getenv("TWILIO_ACCOUNT_SID"), + os.getenv("TWILIO_AUTH_TOKEN") + ) + +def normalize_phone(phone: str): + return phone.replace("+91", "").strip() + + +# ================= OTP ================= + +def send_otp(data): + try: + if not data.phone: + return {"success": False, "error": "Phone required"} + + clean_phone = normalize_phone(data.phone) + phone = "+91" + clean_phone + + client = get_twilio_client() + + client.verify.v2.services( + os.getenv("TWILIO_VERIFY_SERVICE_SID") + ).verifications.create( + to=phone, + channel='sms' + ) + + return {"success": True} + + except Exception as e: + print("TWILIO ERROR:", e) + return {"success": False, "error": str(e)} + + +def verify_otp(db, data, backend_ip): + try: + + clean_phone = normalize_phone(data.phone) + phone = "+91" + clean_phone + + client = get_twilio_client() + + verification_check = client.verify.v2.services( + os.getenv("TWILIO_VERIFY_SERVICE_SID") + ).verification_checks.create( + to=phone, + code=data.otp + ) + + if verification_check.status == "approved": + + # โœ… FIND EXISTING USER + user = db.query(User).filter( + User.phone == clean_phone + ).first() + + # ===================================== + # โœ… NEW USER REGISTRATION + # ===================================== + if not user: + + print("\n==============================") + print("๐Ÿ”ฅ NEW USER REGISTERED") + print("๐Ÿ“ฑ Frontend IP:", data.frontend_ip) + print("๐Ÿ–ฅ๏ธ Backend IP :", backend_ip) + print("==============================\n") + + user = User( + phone=clean_phone, + name=data.name or "User", + email=data.email, + + # โœ… SAVE IPS + frontend_ip=data.frontend_ip, + backend_ip=backend_ip + ) + + db.add(user) + db.commit() + db.refresh(user) + + # ===================================== + # โœ… EXISTING USER LOGIN + # ===================================== + else: + + # โœ… UPDATE LATEST IP + user.frontend_ip = data.frontend_ip + user.backend_ip = backend_ip + + db.commit() + + # โœ… TOKEN + token = create_token({ + "user_id": user.id, + "role": "USER" + }) + + return { + "success": True, + "token": token, + "user": { + "id": user.id, + "phone": user.phone, + "name": user.name, + "email": user.email, + "dob": user.dob, + "gender": user.gender, + + # โœ… OPTIONAL RETURN + "frontend_ip": user.frontend_ip, + "backend_ip": user.backend_ip + } + } + + return { + "success": False, + "message": "Invalid OTP" + } + + except Exception as e: + print("VERIFY ERROR:", e) + + return { + "success": False, + "error": str(e) + } +# import random +# import requests +# import os + +# def send_otp(data): +# phone = data.phone +# AUTH_KEY = os.getenv("MSG91_AUTH_KEY") + +# url = f"https://control.msg91.com/api/v5/otp?mobile=91{phone}" + +# headers = { +# "authkey": AUTH_KEY +# } + +# response = requests.get(url, headers=headers) + +# print("MSG91 RESPONSE:", response.text) + +# return {"message": "OTP sent"} + +# def verify_otp(db, data): +# AUTH_KEY = os.getenv("MSG91_AUTH_KEY") + +# url = "https://control.msg91.com/api/v5/otp/verify" + +# payload = { +# "mobile": f"91{data.phone}", +# "otp": data.otp +# } + +# headers = { +# "authkey": AUTH_KEY, +# "Content-Type": "application/json" +# } + +# response = requests.post(url, json=payload, headers=headers) +# result = response.json() + +# print("VERIFY RESPONSE:", result) + +# if result.get("type") != "success": +# return {"error": "Invalid OTP"} + +# # existing logic (KEEP SAME) +# user = db.query(User).filter(User.phone == data.phone).first() + +# if not user: +# user = User( +# phone=data.phone, +# name=getattr(data, "name", "User"), +# email=getattr(data, "email", None) +# ) +# db.add(user) +# db.commit() +# db.refresh(user) + +# token = create_token({"user_id": user.id}) + +# return { +# "token": token, +# "user": { +# "id": user.id, +# "phone": user.phone, +# "name": user.name +# } +# } + +## Admin + +from models import Admin + +def login_admin(db, data): + admin = db.query(Admin).filter(Admin.email == data.email).first() + + if not admin: + return {"error": "Admin not found"} + + if not verify_password(data.password, admin.password): + return {"error": "Wrong password"} + + token = create_token({ + "admin_id": admin.id, + "role": admin.role, + "city": admin.city, + "zone": admin.zone + }) + + return { + "token": token, + "admin": { + "id": admin.id, + "name": admin.name, + "email": admin.email, + "role": admin.role, + "city": admin.city, + "zone": admin.zone + } + } + + +def create_zonal_admin(db, data, current_admin): + + if current_admin["role"] != "CITY_ADMIN": + return {"error": "Not allowed"} + + existing = db.query(Admin).filter(Admin.email == data.email).first() + if existing: + return {"error": "Admin already exists"} + + admin = Admin( + name=data.name, + email=data.email, + password=hash_password(data.password), + role="ZONAL_ADMIN", + city=current_admin["city"], + zone=data.zone + ) + + db.add(admin) + db.commit() + + return {"message": "Zonal admin created"} + +def get_vendors_by_admin(db, admin): + vendors = [] + + if admin["role"] == "CITY_ADMIN": + data = db.query(Vendor).filter( + Vendor.city == admin["city"] + ).all() + + elif admin["role"] == "ZONAL_ADMIN": + data = db.query(Vendor).filter( + Vendor.city == admin["city"], + Vendor.zone == admin["zone"] + ).all() + else: + return [] + + for v in data: + vendors.append({ + "id": v.id, + "name": v.name, + "location": v.location, + "email": v.email, + "lat": v.lat, + "lng": v.lng, + "city": v.city, + "zone": v.zone, + "is_active": v.is_active, + "is_open": v.is_open, + "zonal_admin_id": v.zonal_admin_id + }) + + return vendors + +def create_vendor(db, data, admin): + + if admin["role"] != "ZONAL_ADMIN": + return {"error": "Only zonal admin can create vendor"} + + if data.lat is None or data.lng is None: + return {"error": "Latitude & Longitude required"} + + vendor = Vendor( + name=data.name, + location=data.location, + flat=data.flat, + building=data.building, + landmark=data.landmark, + lat=data.lat, + lng=data.lng, + email=data.email, + password=hash_password(data.password), + + city=admin["city"], + zone=admin["zone"], + zonal_admin_id=admin["admin_id"], + + is_active=True, # โœ… vendor is approved by default + is_open=False # โœ… shop closed initially + ) + + db.add(vendor) + db.commit() + db.refresh(vendor) + + return { + "message": "Vendor created", + "vendor_id": vendor.id + } + + +def get_zonal_admins(db, admin): + if admin["role"] != "CITY_ADMIN": + return {"error": "Not allowed"} + + admins = db.query(Admin).filter( + Admin.role == "ZONAL_ADMIN", + Admin.city == admin["city"] + ).all() + + result = [] + + for a in admins: + result.append({ + "id": a.id, + "name": a.name, + "email": a.email, + "zone": a.zone + }) + + return result + + + +# user address + +from models import UserAddress + +def ensure_default_address(db, user_id): + default = db.query(UserAddress).filter( + UserAddress.user_id == user_id, + UserAddress.is_default == True + ).first() + + if not default: + first = db.query(UserAddress).filter( + UserAddress.user_id == user_id + ).first() + + if first: + first.is_default = True + db.commit() + +def add_address(db, data, backend_ip): + + address = UserAddress( + user_id=data.user_id, + flat=data.flat, + building=data.building, + landmark=data.landmark, + lat=data.lat, + lng=data.lng, + address_type=data.address_type, + is_default=False, + frontend_ip=data.frontend_ip, + backend_ip=backend_ip, + ) + + db.add(address) + db.commit() + db.refresh(address) + + # โœ… auto default logic + first = db.query(UserAddress).filter( + UserAddress.user_id == data.user_id + ).count() + + if first == 1: + address.is_default = True + db.commit() + + # ๐Ÿ”ฅ RETURN JSON (FIX) + return { + "id": address.id, + "flat": address.flat, + "building": address.building, + "landmark": address.landmark, + "lat": address.lat, + "lng": address.lng, + "address_type": address.address_type, + "is_default": address.is_default + } + + +def get_user_addresses(db, user_id): + return db.query(UserAddress).filter( + UserAddress.user_id == user_id + ).all() + + +def get_default_address(db, user_id): + return db.query(UserAddress).filter( + UserAddress.user_id == user_id, + UserAddress.is_default == True + ).first() + + +from models import DeliveryBoy + +def create_delivery_boy(db, data, admin): + + if admin["role"] != "ZONAL_ADMIN": + return {"error": "Only zonal admin can add delivery boy"} + + existing = db.query(DeliveryBoy).filter( + DeliveryBoy.email == data.email + ).first() + + if existing: + return {"error": "Delivery boy already exists"} + + boy = DeliveryBoy( + name=data.name, + email=data.email, + phone=data.phone, + password=hash_password(data.password), + city=admin["city"], + zone=admin["zone"], + zonal_admin_id=admin["admin_id"] + ) + + db.add(boy) + db.commit() + + return {"message": "Delivery boy created"} + +def login_delivery_boy( + db, + data, + backend_ip +): + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.email == data.email + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + if not verify_password( + data.password, + boy.password + ): + return {"error": "Wrong password"} + # โœ… SAVE IPS + boy.frontend_ip = data.frontend_ip + boy.backend_ip = backend_ip + db.commit() + + token = create_token({ + "delivery_boy_id": boy.id, + "role": "DELIVERY_BOY", + "city": boy.city, + "zone": boy.zone + }) + + return { + "token": token, + "delivery_boy": { + "id": boy.id, + "name": boy.name, + "email": boy.email, + "phone": boy.phone, + "status": boy.status, + "city": boy.city, + "zone": boy.zone, + + # โœ… OPTIONAL RETURN + "frontend_ip": boy.frontend_ip, + "backend_ip": boy.backend_ip + } + } + +from models import DeliveryBoy + +# โœ… GET ALL DELIVERY BOYS +def get_delivery_boys(db, admin): + if admin["role"] != "ZONAL_ADMIN": + return [] + + boys = db.query(DeliveryBoy).filter( + DeliveryBoy.zonal_admin_id == admin["admin_id"] + ).all() + + return [ + { + "id": b.id, + "name": b.name, + "email": b.email, + "phone": b.phone, + "status": b.status, + "active": b.status != "OFFLINE", + "zone": b.zone, + "city": b.city, + "current_order_id": b.current_order_id, + "current_batch_id": b.current_batch_id + } + for b in boys + ] + + +# โœ… UPDATE DELIVERY BOY +def update_delivery_boy(db, boy_id, data, admin): + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == boy_id, + DeliveryBoy.zonal_admin_id == admin["admin_id"] + ).first() + + if not boy: + return {"error": "Not found"} + + if admin["role"] != "ZONAL_ADMIN": + return {"error": "Not allowed"} + + boy.name = data.get("name", boy.name) + boy.email = data.get("email", boy.email) + boy.phone = data.get("phone", boy.phone) + + if data.get("password"): + boy.password = hash_password(data["password"]) + + db.commit() + + return {"message": "Updated"} + + +# โœ… DELETE DELIVERY BOY +def delete_delivery_boy(db, boy_id, admin): + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == boy_id, + DeliveryBoy.zonal_admin_id == admin["admin_id"] + ).first() + + if not boy: + return {"error": "Not found"} + + if admin["role"] != "ZONAL_ADMIN": + return {"error": "Not allowed"} + + db.delete(boy) + db.commit() + + return {"message": "Deleted"} + +#################################################### Batching Function #################################################### + +from datetime import datetime, timedelta +from models import Order, Batch +from utils import haversine + + +def auto_create_batches(db): + print("๐Ÿš€ Running auto batching...") + + time_threshold = datetime.utcnow() - timedelta(minutes=3) + + orders = db.query(Order).filter( + Order.batch_id == None, + Order.status.in_(["PLACED", "ACCEPTED", "READY"]), + Order.created_at <= time_threshold, + ~Order.id.in_(db.query(Delivery.order_id)) + ).all() + + # โœ… GROUP BY ZONAL ADMIN + orders_by_admin = {} + + for o in orders: + if o.zonal_admin_id not in orders_by_admin: + orders_by_admin[o.zonal_admin_id] = [] + + orders_by_admin[o.zonal_admin_id].append(o) + + # โœ… PROCESS EACH ADMIN SEPARATELY + for admin_id, admin_orders in orders_by_admin.items(): + + admin_orders = sorted(admin_orders, key=lambda x: x.created_at) + used = set() + + for i in range(len(admin_orders)): + if admin_orders[i].id in used: + continue + + base = admin_orders[i] + group = [base] + used.add(base.id) + + for j in range(i + 1, len(admin_orders)): + if admin_orders[j].id in used: + continue + + dist = haversine( + base.user_lat, base.user_lng, + admin_orders[j].user_lat, admin_orders[j].user_lng + ) + + if dist <= 0.7: + group.append(admin_orders[j]) + used.add(admin_orders[j].id) + + if len(group) >= 4: + break + + if len(group) < 2: + continue + + # โœ… CREATE BATCH WITH ADMIN ID + batch = Batch( + status="CREATED", + zonal_admin_id=admin_id # ๐Ÿ”ฅ CRITICAL + ) + + db.add(batch) + db.commit() + db.refresh(batch) + + for o in group: + o.batch_id = batch.id + + db.commit() + + print(f"โœ… Batch {batch.id} created for admin {admin_id}") + +# ========================= +# CREATE SUPPORT TICKET +# ========================= + +def create_support_ticket(db, data, backend_ip): + + user = db.query(User).filter( + User.id == data.user_id + ).first() + + if not user: + return {"error": "User not found"} + + ticket = SupportTicket( + user_id=user.id, + user_name=user.name, + mobile_number=user.phone, + message=data.message, + + frontend_ip=data.frontend_ip, + backend_ip=backend_ip + ) + + db.add(ticket) + db.commit() + db.refresh(ticket) + + return { + "message": "Support ticket submitted", + "ticket_id": ticket.id + } + +def get_support_tickets(db): + + tickets = db.query(SupportTicket).order_by( + SupportTicket.created_at.desc() + ).all() + + result = [] + + for t in tickets: + result.append({ + "id": t.id, + "user_id": t.user_id, + "user_name": t.user_name, + "mobile_number": t.mobile_number, + "message": t.message, + "status": t.status, + "created_at": t.created_at + }) + + return result \ No newline at end of file diff --git a/database.py b/database.py new file mode 100644 index 0000000..f016ffa --- /dev/null +++ b/database.py @@ -0,0 +1,82 @@ +#datatbase.py + +import os +import psycopg2 +from psycopg2 import OperationalError +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from sqlalchemy.engine.url import URL +from dotenv import load_dotenv + +# Load env variables +load_dotenv() + +# Neon DB credentials +DB_NAME = os.getenv("DB_NAME") +DB_USER = os.getenv("DB_USER") +DB_PASSWORD = os.getenv("DB_PASSWORD") +DB_HOST = os.getenv("DB_HOST") +DB_PORT = os.getenv("DB_PORT", "5432") + +# Create DATABASE URL +DATABASE_URL = URL.create( + drivername="postgresql", + username=DB_USER, + password=DB_PASSWORD, + host=DB_HOST, + port=DB_PORT, + database=DB_NAME, + query={ + "sslmode": "require" # ๐Ÿ”ฅ MUST for Neon + } +) + +# Create engine +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, + pool_recycle=300, + pool_size=5, + max_overflow=10, + echo=False +) + +# Session +SessionLocal = sessionmaker( + autoflush=False, + autocommit=False, + bind=engine +) + +# Base model +Base = declarative_base() + +# Dependency for FastAPI +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# Test connection +def test_connection(): + try: + connection = psycopg2.connect( + host=DB_HOST, + database=DB_NAME, + user=DB_USER, + password=DB_PASSWORD, + port=DB_PORT, + sslmode="require" + ) + print("โœ… Neon PostgreSQL connection successful!") + connection.close() + + except OperationalError as e: + print(f"โŒ Error: {e}") + print("Failed to connect to Neon database.") + + +if __name__ == "__main__": + test_connection() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..aa5a8dd --- /dev/null +++ b/main.py @@ -0,0 +1,2846 @@ +from fastapi import FastAPI,Depends,Form, File, UploadFile,Request +from database import engine, Base,get_db +import crud +from pydantic import BaseModel +from utils import haversine, calculate_eta, get_current_admin,verify_password,create_token,hash_password +from sqlalchemy.orm import Session +from schemas import OrderCreate,AddressCreate,AssignDelivery, UpdateDeliveryStatus, DeliveryPayment, UserCreate,SendOTP,VerifyOTP,AdminLogin, CreateZonalAdmin,VendorCreate, DeliveryBoyCreate, DeliveryBoyLogin, ReviewCreate ,SupportTicketCreate +from fastapi.middleware.cors import CORSMiddleware +from models import OrderItem, Order, Delivery, VendorMenu, MenuItem, Vendor, User,Admin,UserAddress +import cloudinary_config +from datetime import datetime, timedelta + +import notifications.firebase_config +from notifications.notification_service import send_push_notification + +from notifications.sendnotification import ( + router as notification_router, + + send_vendor_delivered_notification, + send_vendor_cancelled_notification, + + send_deliveryboy_order_notification, + send_deliveryboy_batch_notification, + + send_user_out_for_delivery_notification, + send_user_delivered_notification, + send_user_cancelled_notification, +) +from notifications.sendnotification import router as notification_router + + +# โœ… FIRST create app +app = FastAPI() + +app.include_router(notification_router) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # ๐Ÿ”ฅ for development + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# โœ… THEN use app +@app.on_event("startup") +def create_tables(): + Base.metadata.create_all(bind=engine) + +# @app.on_event("startup") +# def reset_database(): +# # ๐Ÿ”ฅ DROP ALL TABLES +# Base.metadata.drop_all(bind=engine) + +# # ๐Ÿ”ฅ CREATE ALL TABLES AGAIN +# Base.metadata.create_all(bind=engine) + +# Test route +@app.get("/") +def read_root(): + return {"message": "Backend is running ๐Ÿš€"} + + +# ===== INPUT SCHEMA ===== +class LocationRequest(BaseModel): + lat: float + lng: float + +# ===== API ===== +@app.post("/check-location") +def check_location(data: LocationRequest, db: Session = Depends(get_db)): + + # ๐Ÿ”ฅ ONLY ACTIVE + OPEN KITCHENS + vendors = db.query(Vendor).filter( + Vendor.is_active == True, + Vendor.is_open == True # โœ… IMPORTANT + ).all() + + available = [] + + for v in vendors: + if v.lat is None or v.lng is None: + continue + + distance = haversine(data.lat, data.lng, v.lat, v.lng) + + if distance <= v.radius: + available.append({ + "vendor_id": v.id, + "distance": distance + }) + + if not available: + return { + "serviceable": False, + "message": "No kitchens are open in your area" + } + + available.sort(key=lambda x: x["distance"]) + nearest = available[0] + + return { + "serviceable": True, + "vendor_id": nearest["vendor_id"], + "distance": round(nearest["distance"], 2), + "eta": calculate_eta(nearest["distance"]) + } +# # test route +# # menu + +# @app.get("/menu/{vendor_id}") +# def get_menu(vendor_id: int, db: Session = Depends(get_db)): +# menu = crud.get_vendor_menu(db, vendor_id) + +# return { +# "vendor_id": vendor_id, +# "menu": menu +# } + +# ## testing st +# from database import SessionLocal +# from models import Vendor, MenuItem, VendorMenu + + +# @app.get("/seed") +# def seed_data(): +# db = SessionLocal() + +# # Create vendor +# vendor = Vendor(name="Test Kitchen", location="Newtown") +# db.add(vendor) +# db.commit() +# db.refresh(vendor) + +# # Create menu items +# rice = MenuItem(name="Rice", category="Rice", vendor_price=10, selling_price=20) +# chicken = MenuItem(name="Chicken", category="Non-Veg", vendor_price=50, selling_price=70) + +# db.add_all([rice, chicken]) +# db.commit() + +# # Link vendor menu +# vm1 = VendorMenu(vendor_id=vendor.id, menu_item_id=rice.id, is_available=True) +# vm2 = VendorMenu(vendor_id=vendor.id, menu_item_id=chicken.id, is_available=True) + +# db.add_all([vm1, vm2]) +# db.commit() + +# return {"message": "Test data inserted"} +# ## testing ed + + +@app.post("/order") +def create_order_api( + order: OrderCreate, + request: Request, + db: Session = Depends(get_db) +): + backend_ip = request.client.host + result = crud.create_order( + db, + order, + backend_ip + ) + + return result + + +# @app.post("/assign-delivery") +# def assign_delivery_api(data: AssignDelivery, db: Session = Depends(get_db)): +# return crud.assign_delivery(db, data) + + +# @app.put("/delivery/status") +# def update_status_api(data: UpdateDeliveryStatus, db: Session = Depends(get_db)): +# return crud.update_delivery_status(db, data) + +@app.post("/delivery/complete-order") +def complete_order( + data: DeliveryPayment, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + return crud.add_payment(db, data, user) + + +@app.post("/menu-by-location") +def menu_by_location(data: LocationRequest, db: Session = Depends(get_db)): + + vendors = db.query(Vendor).filter( + Vendor.is_active == True, + Vendor.is_open == True + ).all() + + available_vendors = [] + + # โœ… STEP 1: FILTER VENDORS IN RADIUS + for v in vendors: + if v.lat is None or v.lng is None: + continue + + distance = haversine(data.lat, data.lng, v.lat, v.lng) + + if distance <= v.radius: + available_vendors.append(v) + + if not available_vendors: + return {"error": "No kitchens available right now"} + + # โœ… STEP 2: GET ALL ITEMS FROM ALL VENDORS + all_items = [] + + for v in available_vendors: + results = ( + db.query(VendorMenu, MenuItem) + .join(MenuItem, VendorMenu.menu_item_id == MenuItem.id) + .filter( + VendorMenu.vendor_id == v.id, + VendorMenu.is_available == True, + MenuItem.is_deleted == False + ) + .all() + ) + + for vm, item in results: + all_items.append({ + "id": item.id, + "name": item.name, + "category": item.category, + "description": item.description, + "price": item.selling_price, + "vendor_id": v.id, + "vendor_name": v.name, # ๐Ÿ”ฅ IMPORTANT FOR UI + "image": item.image_url # โœ… THIS LINE IS MISSING + + }) + + return { + "menu": all_items + } + + +# User login + +@app.post("/check-user") +def check_user(data: SendOTP, db: Session = Depends(get_db)): + user = db.query(User).filter(User.phone == data.phone).first() + + return { + "exists": True if user else False + } + +@app.post("/user/update-profile") +def update_profile(data: dict, db: Session = Depends(get_db)): + user = db.query(User).filter(User.id == data["user_id"]).first() + + if not user: + return {"error": "User not found"} + + user.name = data.get("name", user.name) + user.email = data.get("email", user.email) + user.dob = data.get("dob", user.dob) # โœ… ADD THIS + user.gender = data.get("gender", user.gender) # โœ… ADD THIS + + db.commit() + + return {"message": "Profile updated"} + +@app.post("/send-otp") +def send_otp_api(data: SendOTP): + return crud.send_otp(data) + + +@app.post("/verify-otp") +def verify_otp_api( + data: VerifyOTP, + request: Request, + db: Session = Depends(get_db) +): + + backend_ip = request.client.host + + frontend_ip = data.frontend_ip + + result = crud.verify_otp( + db, + data, + backend_ip + ) + + return result + +## Admin + +@app.post("/admin/login") +def admin_login(data: AdminLogin, db: Session = Depends(get_db)): + return crud.login_admin(db, data) + +@app.post("/admin/create-zonal-admin") +def create_zonal_admin_api( + data: CreateZonalAdmin, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) # โœ… THIS LINE IS KEY +): + return crud.create_zonal_admin(db, data, admin) + +@app.get("/admin/vendors") +def get_vendors( + db: Session = Depends(get_db), + admin = Depends(get_current_admin) +): + return crud.get_vendors_by_admin(db, admin) + +## test st +@app.get("/create-admin") +def create_admin(db: Session = Depends(get_db)): + + + admin = Admin( + name="City Admin", + email="admin@test.com", + password=hash_password("1234"), + role="CITY_ADMIN", + city="Kolkata" + ) + + db.add(admin) + db.commit() + + return {"message": "Admin created"} + +## test ed + + +@app.post("/vendors") +def create_vendor_api( + data: VendorCreate, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) +): + return crud.create_vendor(db, data, admin) + + +@app.get("/admin/zonal-admins") +def get_zonal_admins_api( + db: Session = Depends(get_db), + admin = Depends(get_current_admin) +): + return crud.get_zonal_admins(db, admin) + + +@app.get("/vendor/{vendor_id}/financial-summary") +def vendor_financial_summary( + vendor_id: int, + filter: str = "today", + start_date: str = None, + end_date: str = None, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + + # โœ… SECURITY + if user.get("role") != "VENDOR": + return {"error": "Unauthorized"} + + if user.get("vendor_id") != vendor_id: + return {"error": "Access denied"} + + # ========================= + # DATE FILTERS + # ========================= + today = datetime.utcnow() + + date_from = None + date_to = today + + if filter == "today": + + date_from = today.replace( + hour=0, + minute=0, + second=0, + microsecond=0 + ) + + elif filter == "week": + + date_from = today - timedelta(days=today.weekday()) + + date_from = date_from.replace( + hour=0, + minute=0, + second=0, + microsecond=0 + ) + + elif filter == "month": + + date_from = today.replace( + day=1, + hour=0, + minute=0, + second=0, + microsecond=0 + ) + + elif filter == "year": + + date_from = today.replace( + month=1, + day=1, + hour=0, + minute=0, + second=0, + microsecond=0 + ) + + elif filter == "custom": + + if not start_date or not end_date: + return { + "error": "start_date and end_date required" + } + + date_from = datetime.strptime( + start_date, + "%Y-%m-%d" + ) + + date_to = datetime.strptime( + end_date, + "%Y-%m-%d" + ) + + date_to = date_to.replace( + hour=23, + minute=59, + second=59 + ) + + # ========================= + # QUERY + # ========================= + results = ( + db.query(OrderItem, Order) + .join(Order, OrderItem.order_id == Order.id) + .filter( + + # โœ… THIS VENDOR ONLY + OrderItem.vendor_id == vendor_id, + + # โœ… ONLY SUCCESSFUL ORDERS + Order.status == "DELIVERED", + Order.payment_status == "PAID", + + # โŒ EXCLUDE CANCELLED + Order.cancelled_at == None, + + # โœ… DATE FILTER + Order.created_at >= date_from, + Order.created_at <= date_to + ) + .all() + ) + + # ========================= + # CALCULATIONS + # ========================= + total_amount = 0 + total_orders = set() + items_sold = 0 + + orders_data = {} + + for item, order in results: + + # โœ… VENDOR TOTAL ONLY + vendor_total = ( + item.vendor_price * item.quantity + ) + + total_amount += vendor_total + + items_sold += item.quantity + + total_orders.add(order.id) + + # ========================= + # ORDER GROUPING + # ========================= + if order.id not in orders_data: + + orders_data[order.id] = { + "order_id": order.id, + "created_at": order.created_at, + "amount": 0, + "items": [] + } + + orders_data[order.id]["amount"] += vendor_total + + orders_data[order.id]["items"].append({ + "name": item.name, + "qty": item.quantity, + "vendor_price": item.vendor_price + }) + + # ========================= + # RESPONSE + # ========================= + return { + "total_amount": round(total_amount, 2), + + "total_orders": len(total_orders), + + "items_sold": items_sold, + + "orders": list(orders_data.values()) + } + + +@app.put("/vendor/menu/{menu_id}") +def toggle_menu(menu_id: int, db: Session = Depends(get_db)): + vm = db.query(VendorMenu).filter(VendorMenu.id == menu_id).first() + + vm.is_available = not vm.is_available + + db.commit() + + return {"message": "Updated"} + +@app.get("/vendor/{vendor_id}/menu") +def get_vendor_menu_admin(vendor_id: int, db: Session = Depends(get_db)): + + results = ( + db.query(VendorMenu, MenuItem) + .join(MenuItem, VendorMenu.menu_item_id == MenuItem.id) + .filter( + VendorMenu.vendor_id == vendor_id, + MenuItem.is_deleted == False + ) + .all() + ) + + data = [] + + for vm, item in results: + data.append({ + "id": item.id, + "name": item.name, + "category": item.category, + "description": item.description or "", + "vendor_price": item.vendor_price, + "selling_price": item.selling_price, + "is_available": vm.is_available, + "image_url": item.image_url + + }) + + return data + + +@app.put("/vendor/menu-item/{item_id}") +async def update_menu_item_vendor( + item_id: int, + name: str = Form(...), + category: str = Form(...), + description: str = Form(None), + image: UploadFile = File(None), + db: Session = Depends(get_db) +): + item = db.query(MenuItem).filter(MenuItem.id == item_id).first() + + if not item: + return {"error": "Item not found"} + + # ๐Ÿ”’ ONLY THESE FIELDS ALLOWED + item.name = name + item.category = category + item.description = description + + if image: + try: + upload = cloudinary.uploader.upload(image.file) + item.image_url = upload["secure_url"] + except Exception: + return {"error": "Image upload failed"} + + db.commit() + + return {"message": "Updated"} + + +@app.get("/vendor/{vendor_id}") +def get_vendor(vendor_id: int, db: Session = Depends(get_db)): + v = db.query(Vendor).filter(Vendor.id == vendor_id).first() + + if not v: + return {"error": "Vendor not found"} + + return { + "id": v.id, + "name": v.name, + "location": v.location, + "email": v.email, + "lat": v.lat, + "lng": v.lng, + "city": v.city, + "zone": v.zone, + "is_active": v.is_active, + "is_open": v.is_open + } +# vendor menu set + + +# @app.post("/menu-item") +# def create_menu_item(data: dict, db: Session = Depends(get_db)): +# item = MenuItem( +# name=data["name"], +# category=data["category"], +# description=data.get("description"), # โœ… NEW +# vendor_price=data["vendor_price"], +# selling_price=data["selling_price"] +# ) + +# db.add(item) +# db.commit() +# db.refresh(item) + +# return {"id": item.id} + + +# @app.put("/menu-item/{item_id}") +# def update_menu_item(item_id: int, data: dict, db: Session = Depends(get_db)): +# item = db.query(MenuItem).filter(MenuItem.id == item_id).first() + +# if not item: +# return {"error": "Item not found"} + +# item.name = data["name"] +# item.category = data["category"] +# item.description = data.get("description") # โœ… NEW +# item.vendor_price = data["vendor_price"] +# item.selling_price = data["selling_price"] + +# db.commit() + +# return {"message": "Updated"} +# iamge upload test + +from fastapi import UploadFile, File, Form +import cloudinary.uploader + +@app.post("/menu-item") +async def create_menu_item( + name: str = Form(...), + category: str = Form(...), + description: str = Form(None), + vendor_price: float = Form(...), + selling_price: float = Form(...), + image: UploadFile = File(None), + db: Session = Depends(get_db) +): + image_url = None + + if image: + upload = cloudinary.uploader.upload(image.file) + image_url = upload["secure_url"] + + item = MenuItem( + name=name, + category=category, + description=description, + vendor_price=vendor_price, + selling_price=selling_price, + image_url=image_url + ) + + db.add(item) + db.commit() + db.refresh(item) + + return {"id": item.id, "image_url": image_url} + + +@app.put("/menu-item/{item_id}") +async def update_menu_item( + item_id: int, + name: str = Form(...), + category: str = Form(...), + description: str = Form(None), + vendor_price: float = Form(...), + selling_price: float = Form(...), + image: UploadFile = File(None), + db: Session = Depends(get_db) +): + item = db.query(MenuItem).filter(MenuItem.id == item_id).first() + + if not item: + return {"error": "Item not found"} + + if image: + upload = cloudinary.uploader.upload(image.file) + item.image_url = upload["secure_url"] + + item.name = name + item.category = category + item.description = description + item.vendor_price = vendor_price + item.selling_price = selling_price + + db.commit() + + return {"message": "Updated"} + + +@app.delete("/menu-item/{item_id}") +def delete_menu_item(item_id: int, db: Session = Depends(get_db)): + + item = db.query(MenuItem).filter(MenuItem.id == item_id).first() + + if not item: + return {"error": "Item not found"} + + # ๐Ÿ”ฅ SOFT DELETE + item.is_deleted = True + + db.commit() + + return {"message": "Item hidden successfully"} + + +# VENDOR lOgin + +@app.post("/vendor/login") +def vendor_login( + data: dict, + request: Request, + db: Session = Depends(get_db) +): + + vendor = db.query(Vendor).filter( + Vendor.email == data["email"] + ).first() + + if not vendor: + return {"error": "Vendor not found"} + + if not verify_password( + data["password"], + vendor.password + ): + return {"error": "Wrong password"} + + # โœ… SAVE IPS + frontend_ip = data.get("frontend_ip") + + backend_ip = request.client.host + + vendor.frontend_ip = frontend_ip + vendor.backend_ip = backend_ip + + db.commit() + + token = create_token({ + "vendor_id": vendor.id, + "role": "VENDOR", + "city": vendor.city, + "zone": vendor.zone + }) + + return { + "token": token, + "vendor": { + "id": vendor.id, + "name": vendor.name, + "email": vendor.email, + "city": vendor.city, + "zone": vendor.zone + } + } + + + +@app.get("/vendor/{vendor_id}/menu/vendor-view") +def get_vendor_menu_view(vendor_id: int, db: Session = Depends(get_db)): + + results = ( + db.query(VendorMenu, MenuItem) + .join(MenuItem, VendorMenu.menu_item_id == MenuItem.id) + .filter( + VendorMenu.vendor_id == vendor_id, + MenuItem.is_deleted == False # โœ… ADD + ) + .all() + ) + + data = [] + + for vm, item in results: + data.append({ + "menu_id": vm.id, + "item_id": item.id, # ๐Ÿ”ฅ ADD THIS (VERY IMPORTANT) + "name": item.name, + "category": item.category, + "description": item.description, + "vendor_price": item.vendor_price, + "is_available": vm.is_available, + "image_url": item.image_url # ๐Ÿ”ฅ ADD THIS + }) + + return data + + +@app.post("/vendor/{vendor_id}/menu") +def add_item_to_vendor(vendor_id: int, data: dict, db: Session = Depends(get_db)): + + # ๐Ÿ”ฅ prevent duplicate + existing = db.query(VendorMenu).filter( + VendorMenu.vendor_id == vendor_id, + VendorMenu.menu_item_id == data["menu_item_id"] + ).first() + + if existing: + return {"error": "Item already added"} + + vm = VendorMenu( + vendor_id=vendor_id, + menu_item_id=data["menu_item_id"], + is_available=True + ) + + db.add(vm) + db.commit() + + return {"message": "Item added"} + +@app.put("/vendor/{vendor_id}/toggle-open") +def toggle_vendor_open(vendor_id: int, db: Session = Depends(get_db)): + + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + + if not vendor: + return {"error": "Vendor not found"} + + vendor.is_open = not vendor.is_open + db.commit() + + return { + "message": "Updated", + "is_open": vendor.is_open + } + +@app.put("/vendor/{vendor_id}/toggle-status") +def toggle_vendor_status(vendor_id: int, db: Session = Depends(get_db)): + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + + if not vendor: + return {"error": "Vendor not found"} + + vendor.is_open = not vendor.is_open + db.commit() + db.refresh(vendor) # โœ… ADD THIS + + return { + "message": "Updated", + "is_open": vendor.is_open + } + + +@app.post("/user/address") +def add_user_address( + data: AddressCreate, + request: Request, + db: Session = Depends(get_db) +): + + backend_ip = request.client.host + return crud.add_address( + db, + data, + backend_ip + ) + +@app.put("/user/address/{address_id}/set-default") +def set_default_address( + address_id: int, + request: Request, + db: Session = Depends(get_db) +): + + address = db.query(UserAddress).filter(UserAddress.id == address_id).first() + + if not address: + return {"error": "Address not found"} + + # โ— remove old default + db.query(UserAddress).filter( + UserAddress.user_id == address.user_id, + UserAddress.is_default == True + ).update({"is_default": False}) + + # โœ… set new default + address.is_default = True + + db.commit() + + return {"message": "Default address updated"} + + +@app.get("/user/{user_id}/addresses") +def get_addresses(user_id: int, db: Session = Depends(get_db)): + + addresses = crud.get_user_addresses(db, user_id) + + return [ + { + "id": a.id, + "flat": a.flat, + "building": a.building, + "landmark": a.landmark, + "address_type": a.address_type, + "is_default": a.is_default, + "lat": a.lat, # โœ… ADD THIS + "lng": a.lng + } + for a in addresses + ] + + +@app.get("/user/{user_id}/default-address") +def default_address(user_id: int, db: Session = Depends(get_db)): + + crud.ensure_default_address(db, user_id) + + addr = crud.get_default_address(db, user_id) + + if not addr: + return {"error": "No address found"} + + return { + "id": addr.id, + "flat": addr.flat, + "building": addr.building, + "landmark": addr.landmark, + "address_type": addr.address_type, + "lat": addr.lat, + "lng": addr.lng + } + +@app.put("/user/address/{address_id}") +def update_address( + address_id: int, + data: dict, + request: Request, + db: Session = Depends(get_db) +): + + address = db.query(UserAddress).filter(UserAddress.id == address_id).first() + + if not address: + return {"error": "Address not found"} + + address.flat = data.get("flat", address.flat) + address.building = data.get("building", address.building) + address.landmark = data.get("landmark", address.landmark) + address.address_type = data.get("address_type", address.address_type) + + db.commit() + + return {"message": "Address updated"} + +@app.delete("/user/address/{address_id}") +def delete_address( + address_id: int, + request: Request, + db: Session = Depends(get_db) +): + + address = db.query(UserAddress).filter(UserAddress.id == address_id).first() + + if not address: + return {"error": "Address not found"} + + user_id = address.user_id + was_default = address.is_default + + db.delete(address) + db.commit() + + # โœ… auto assign new default + if was_default: + other = db.query(UserAddress).filter( + UserAddress.user_id == user_id + ).first() + + if other: + other.is_default = True + db.commit() + + return {"message": "Deleted"} + +@app.put("/vendors/{vendor_id}") +def update_vendor( + vendor_id: int, + data: VendorCreate, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) +): + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + + if not vendor: + return {"error": "Vendor not found"} + + vendor.name = data.name + vendor.location = data.location + vendor.flat = data.flat + vendor.building = data.building + vendor.landmark = data.landmark + vendor.lat = data.lat + vendor.lng = data.lng + vendor.email = data.email + + if data.password: + vendor.password = hash_password(data.password) + + db.commit() + + return {"message": "Vendor updated"} + +@app.delete("/vendors/{vendor_id}") +def delete_vendor( + vendor_id: int, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) +): + vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first() + + if not vendor: + return {"error": "Vendor not found"} + + db.delete(vendor) + db.commit() + + return {"message": "Vendor deleted"} + +@app.get("/user/{user_id}/orders") +def get_user_orders(user_id: int, db: Session = Depends(get_db)): + # 1. Fetch orders + orders = db.query(Order).filter(Order.user_id == user_id).all() + + + + result = [] + for o in orders: + item_list = [] + for i in o.items: + # Look up the actual menu item to get its category + menu_item = db.query(MenuItem).filter(MenuItem.id == i.menu_item_id).first() + + item_list.append({ + "name": i.name, + "quantity": i.quantity, + "price": i.selling_price, + "category": menu_item.category if menu_item else "Veg", + "menu_item_id": i.menu_item_id, # โœ… ADD THIS LINE + "vendor_id": i.vendor_id, # โœ… ADD THIS LINE + "image": menu_item.image_url if menu_item else None # ๐Ÿ‘ˆ ADD THIS LINE + }) + + # 3. Create a safe address dictionary + # If address_ob is None, we provide fallback strings + addr_data = { + "flat": o.address_flat, + "building": o.address_building, + "landmark": o.address_landmark + } + + # ๐Ÿ‘‡ ADD THIS LINE to check if a review exists + has_review = db.query(Review).filter(Review.order_id == o.id).first() is not None + + result.append({ + "order_id": o.id, + "status": o.status, + "final": o.final_amount, + "items": item_list, + "payment_method": ( + o.delivery.payment_method + if o.delivery and o.delivery.payment_method + else "UPI" + ), + + "payment_status": o.payment_status, + "created_at": o.created_at, + "address": addr_data, + "is_reviewed": has_review + }) + + return result + + +@app.get("/order/live/{order_id}") +def get_live_order(order_id: int, db: Session = Depends(get_db)): + + # ========================= + # GET ORDER + # ========================= + order = db.query(Order).filter( + Order.id == order_id + ).first() + + if not order: + return {"error": "Order not found"} + + # ========================= + # ORDER ITEMS + # ========================= + items_data = [] + + for item in order.items: + items_data.append({ + "name": item.name, + "quantity": item.quantity, + "price": item.selling_price, + "status": item.status + }) + + # ========================= + # DELIVERY DETAILS + # ========================= + delivery = db.query(Delivery).filter( + Delivery.order_id == order.id + ).first() + + delivery_boy = None + + if delivery and delivery.delivery_boy_id: + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == delivery.delivery_boy_id + ).first() + + if boy: + delivery_boy = { + "id": boy.id, + "name": boy.name, + "phone": boy.phone, + "status": boy.status, + "lat": boy.lat, + "lng": boy.lng + } + + # ========================= + # RESPONSE + # ========================= + return { + "order_id": order.id, + + "status": order.status, + + "payment_status": order.payment_status, + + "total_amount": order.total_amount, + "gst": order.gst, + "final_amount": order.final_amount, + + "created_at": order.created_at, + + "items": items_data, + + "delivery_boy": delivery_boy, + + "address": { + "flat": order.address_flat, + "building": order.address_building, + "landmark": order.address_landmark + } + } + + +@app.put("/vendor/order/accept/{order_id}") +def accept_order(order_id: int, db: Session = Depends(get_db)): + + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + return {"error": "Order not found"} + + order.status = "ACCEPTED" + db.commit() + + return {"message": "Order accepted"} + + +@app.put("/vendor/order/reject/{order_id}") +def reject_order(order_id: int, data: dict, db: Session = Depends(get_db)): + + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + return {"error": "Order not found"} + + order.status = "REJECTED" + order.reject_reason = data.get("reason", "Not available") + + db.commit() + + return {"message": "Order rejected"} + + + +@app.get("/vendor/{vendor_id}/orders") +def get_vendor_orders( + vendor_id: int, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + + if user.get("role") != "VENDOR" or user.get("vendor_id") != vendor_id: + return {"error": "Unauthorized"} + + orders_map = {} + + results = ( + db.query(OrderItem, Order) + .join(Order, OrderItem.order_id == Order.id) + .filter(OrderItem.vendor_id == vendor_id) + .all() + ) + + orders_map = {} + + for i, order in results: + + if order.id not in orders_map: + orders_map[order.id] = { + "order_id": order.id, + "user_id": order.user_id, + "status": order.status, + "items": [], + "total_vendor_price": 0, + "total_selling_price": 0, + "gst": order.gst, + "final_amount": order.final_amount + } + + vendor_total = i.vendor_price * i.quantity + selling_total = i.selling_price * i.quantity + + orders_map[order.id]["items"].append({ + "name": i.name, + "quantity": i.quantity, + "vendor_price": i.vendor_price, + "selling_price": i.selling_price, + "status": i.status + }) + + orders_map[order.id]["total_vendor_price"] += vendor_total + orders_map[order.id]["total_selling_price"] += selling_total + + return list(orders_map.values()) + + + +@app.get("/admin/order/{order_id}") +def get_order_details( + order_id: int, + db: Session = Depends(get_db), + admin=Depends(get_current_admin) +): + + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + return {"error": "Order not found"} + + if admin["role"] == "ZONAL_ADMIN" and order.zonal_admin_id != admin["admin_id"]: + return {"error": "Not allowed"} + + user = db.query(User).filter(User.id == order.user_id).first() + + if not user: + return {"error": "User not found"} + + # ๐Ÿ”ฅ ADDRESS + full_address = f"{order.address_flat}, {order.address_building}, {order.address_landmark}" + + items = db.query(OrderItem).filter(OrderItem.order_id == order_id).all() + + # ๐Ÿ”ฅ DELIVERY BOY DETAILS + delivery = db.query(Delivery).filter( + Delivery.order_id == order_id + ).first() + + delivery_boy = None + + if delivery and delivery.delivery_boy_id: + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == delivery.delivery_boy_id + ).first() + + if boy: + delivery_boy = { + "id": boy.id, + "name": boy.name, + "phone": boy.phone + } + + item_list = [] + total_vendor = 0 + total_selling = 0 + + for i in items: + item_list.append({ + "name": i.name, + "quantity": i.quantity, + "vendor_price": i.vendor_price, + "selling_price": i.selling_price + }) + + total_vendor += i.vendor_price * i.quantity + total_selling += i.selling_price * i.quantity + + return { + "order_id": order.id, + "delivery_boy": delivery_boy, + "status": order.status, + "user_id": user.id, + "user_name": user.name, + "user_address": full_address, + "items": item_list, + "total_vendor": total_vendor, + "total_selling": total_selling, + "gst": order.gst, + "final_amount": order.final_amount + } + +@app.post("/admin/create-delivery-boy") +def create_delivery_boy_api( + data: DeliveryBoyCreate, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) +): + return crud.create_delivery_boy(db, data, admin) + +@app.post("/delivery/login") +def delivery_login( + data: DeliveryBoyLogin, + request: Request, + db: Session = Depends(get_db) +): + + backend_ip = request.client.host + + return crud.login_delivery_boy( + db, + data, + backend_ip + ) + +@app.get("/delivery/me") +def get_delivery_profile( + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + return { + "id": boy.id, + "name": boy.name, + "email": boy.email, + "phone": boy.phone, + "status": boy.status, + "city": boy.city, + "zone": boy.zone + } + + +# GET ALL +@app.get("/admin/delivery-boys") +def get_delivery_boys_api( + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) +): + return crud.get_delivery_boys(db, admin) + + +# UPDATE +@app.put("/admin/delivery-boy/{boy_id}") +def update_delivery_boy_api( + boy_id: int, + data: dict, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) +): + return crud.update_delivery_boy(db, boy_id, data, admin) + + +# DELETE +@app.delete("/admin/delivery-boy/{boy_id}") +def delete_delivery_boy_api( + boy_id: int, + db: Session = Depends(get_db), + admin: dict = Depends(get_current_admin) +): + return crud.delete_delivery_boy(db, boy_id, admin) + +############################################## Batching functions ############################################## + +import asyncio +from database import SessionLocal +import crud +from models import Batch + +async def batch_worker(): + while True: + try: + db = SessionLocal() + try: + crud.auto_create_batches(db) + finally: + db.close() + except Exception as e: + print("Batch Error:", e) + + await asyncio.sleep(120) # every 2 minutes + +@app.on_event("startup") +async def start_batching(): + asyncio.create_task(batch_worker()) + + +from models import Batch, DeliveryBoy + +@app.get("/admin/batches") +def get_batches( + db: Session = Depends(get_db), + admin=Depends(get_current_admin) +): + if not admin: + return {"error": "Unauthorized"} + + if admin["role"] == "ZONAL_ADMIN": + batches = db.query(Batch).filter( + Batch.zonal_admin_id == admin["admin_id"] + ).order_by(Batch.created_at.desc()).all() + else: + batches = db.query(Batch).order_by(Batch.created_at.desc()).all() + + result = [] + for b in batches: + batch_ready = True + + for o in b.orders: + for i in o.items: + if i.status != "READY": + batch_ready = False + break + if not batch_ready: + break + + # ๐Ÿ”ฅ GET DELIVERY BOY + delivery_boy = None + + if b.delivery_boy_id: + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == b.delivery_boy_id + ).first() + + if boy: + delivery_boy = { + "id": boy.id, + "name": boy.name + } + + result.append({ + "batch_id": b.id, + "status": b.status, + "total_orders": len(b.orders), + "created_at": b.created_at, + "delivery_boy": delivery_boy, + "ready_status": "FULL_READY" if batch_ready else "PARTIAL_READY" + }) + + return result + +@app.get("/admin/batch/{batch_id}") +def get_batch_details(batch_id: int, db: Session = Depends(get_db)): + batch = db.query(Batch).filter(Batch.id == batch_id).first() + + if not batch: + return {"error": "Batch not found"} + + orders_data = [] + batch_ready = True # ๐Ÿ”ฅ track full batch + + for o in batch.orders: + items = o.items + + item_list = [] + vendor_map = {} + order_ready = True # ๐Ÿ”ฅ track per order + + for i in items: + item_list.append({ + "name": i.name, + "quantity": i.quantity, + "vendor_id": i.vendor_id, + "status": i.status # โœ… ADD THIS + }) + + # group by vendor + if i.vendor_id not in vendor_map: + vendor_map[i.vendor_id] = [] + + vendor_map[i.vendor_id].append({ + "name": i.name, + "qty": i.quantity, + "status": i.status # โœ… ADD THIS + }) + + # โŒ if any item not ready + if i.status != "READY": + order_ready = False + batch_ready = False + + orders_data.append({ + "order_id": o.id, + "user_id": o.user_id, + "status": "READY" if order_ready else o.status, + "ready_status": "READY" if order_ready else "PARTIAL_READY", + "items": item_list, + "vendors": vendor_map + }) + + return { + "batch_id": batch.id, + "status": batch.status, + "ready_status": "FULL_READY" if batch_ready else "PARTIAL_READY", + "orders": orders_data + } + + +@app.get("/admin/single-orders") +def get_single_orders( + db: Session = Depends(get_db), + admin = Depends(get_current_admin) +): + if not admin: + return {"error": "Unauthorized"} + + query = db.query(Order).filter( + Order.batch_id == None, + Order.status.notin_(["REJECTED"]) + ) + + if admin["role"] == "ZONAL_ADMIN": + query = query.filter( + Order.zonal_admin_id == admin["admin_id"] + ) + + orders = query.order_by( + Order.created_at.desc() + ).all() + + result = [] + + for o in orders: + + items = db.query(OrderItem).filter( + OrderItem.order_id == o.id + ).all() + + # ๐Ÿ”ฅ READY CHECK + is_ready = all(i.status == "READY" for i in items) + + # ๐Ÿ”ฅ DELIVERY CHECK + delivery = db.query(Delivery).filter( + Delivery.order_id == o.id + ).first() + + delivery_boy = None + + if delivery: + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == delivery.delivery_boy_id + ).first() + + if boy: + delivery_boy = { + "id": boy.id, + "name": boy.name + } + + result.append({ + "order_id": o.id, + "user_id": o.user_id, + "status": o.status, + "created_at": o.created_at, + "lat": o.user_lat, + "lng": o.user_lng, + + # โœ… ASSIGNMENT INFO + "assigned": True if delivery else False, + "delivery_boy": delivery_boy, + + "ready_status": "READY" if is_ready else "NOT_READY" + }) + + return result + + +@app.post("/admin/order/assign") +def assign_single_order( + data: dict, + db: Session = Depends(get_db), + admin = Depends(get_current_admin) +): + if not admin: + return {"error": "Unauthorized"} + + # โœ… SAFE CHECK (ADD THIS) + if "order_id" not in data or "delivery_boy_id" not in data: + return {"error": "order_id and delivery_boy_id required"} + + order = db.query(Order).filter(Order.id == data["order_id"]).first() + + if admin["role"] == "ZONAL_ADMIN" and order.zonal_admin_id != admin["admin_id"]: + return {"error": "Not your order"} + + if not order: + return {"error": "Order not found"} + + if order.batch_id: + return {"error": "This order is part of a batch"} + + # โŒ prevent duplicate assignment + existing = db.query(Delivery).filter( + Delivery.order_id == order.id + ).first() + if existing: + return {"error": "Already assigned"} + + # โœ… GET DELIVERY BOY + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == data["delivery_boy_id"] + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + if boy.zonal_admin_id != admin["admin_id"]: + return {"error": "Not your delivery boy"} + + # ๐Ÿ”ฅ IMPORTANT CHECKS + if boy.status != "IDLE": + return {"error": "Delivery boy is not available"} + + if boy.current_batch_id is not None or boy.current_order_id is not None: + return {"error": "Delivery boy already assigned"} + + # โœ… CREATE DELIVERY + delivery = Delivery( + order_id=order.id, + delivery_boy_id=boy.id, + status="ASSIGNED" + ) + + + db.add(delivery) + + order.status = "ASSIGNED" + boy.status = "BUSY" + boy.current_order_id = order.id + boy.current_batch_id = None + + db.commit() + + # โœ… PUSH DELIVERY BOY + send_deliveryboy_order_notification( + boy.id, + order.id + ) + + return { + "message": "Order assigned successfully", + "delivery_boy": { + "id": boy.id, + "name": boy.name + } + } + + +@app.post("/admin/batch/assign") +def assign_batch( + data: dict, + db: Session = Depends(get_db), + admin=Depends(get_current_admin) +): + if not admin or admin.get("role") not in ["CITY_ADMIN", "ZONAL_ADMIN"]: + return {"error": "Unauthorized"} + + batch = db.query(Batch).filter( + Batch.id == data.get("batch_id") + ).first() + + if not batch: + return {"error": "Batch not found"} + + if batch.zonal_admin_id != admin["admin_id"]: + return {"error": "Not your batch"} + + if batch.delivery_boy_id: + return {"error": "Batch already assigned"} + + # โœ… READY CHECK (FULL BATCH) + for o in batch.orders: + for i in o.items: + if i.status != "READY": + return {"error": "Batch not fully ready"} + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == data.get("delivery_boy_id") + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + if boy.zonal_admin_id != admin["admin_id"]: + return {"error": "Not your delivery boy"} + + if boy.status != "IDLE": + return {"error": "Delivery boy not available"} + + if boy.current_batch_id is not None or boy.current_order_id is not None: + return {"error": "Delivery boy already assigned"} + try: + # โœ… ASSIGN BATCH + batch.delivery_boy_id = boy.id + batch.status = "ASSIGNED" + + # โœ… UPDATE DELIVERY BOY + boy.status = "BUSY" + boy.current_batch_id = batch.id + boy.current_order_id = None + + # โœ… CREATE DELIVERY ENTRY FOR EACH ORDER + for o in batch.orders: + o.status = "ASSIGNED" + + delivery = Delivery( + order_id=o.id, + delivery_boy_id=boy.id, + status="ASSIGNED" + ) + db.add(delivery) + + db.commit() + + + # โœ… PUSH DELIVERY BOY + send_deliveryboy_batch_notification( + boy.id, + batch.id, + len(batch.orders) + ) + + except: + db.rollback() + return {"error": "Assignment failed"} + + return {"message": "Batch assigned successfully"} + + + +@app.get("/delivery/batches") +def get_my_batches( + db: Session = Depends(get_db), + user=Depends(get_current_admin) # using same token system +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + batches = db.query(Batch).filter( + Batch.delivery_boy_id == user["delivery_boy_id"] + ).order_by(Batch.created_at.desc()).all() + + result = [] + + for b in batches: + result.append({ + "batch_id": b.id, + "status": b.status, + "total_orders": len(b.orders), + "created_at": b.created_at + }) + + return result + +from models import User, UserAddress + +@app.get("/delivery/batch/{batch_id}") +def get_batch_for_delivery( + batch_id: int, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + batch = db.query(Batch).filter(Batch.id == batch_id).first() + + if not batch: + return {"error": "Batch not found"} + + if batch.delivery_boy_id != user["delivery_boy_id"]: + return {"error": "Not your batch"} + + orders_data = [] + + for o in batch.orders: + + user_data = db.query(User).filter(User.id == o.user_id).first() + + + + orders_data.append({ + "order_id": o.id, + "lat": o.user_lat, + "lng": o.user_lng, + "status": o.status, + "final_amount": o.final_amount, + # โœ… USER + "user": { + "name": user_data.name if user_data else "", + "phone": user_data.phone if user_data else "" + }, + + # โœ… ADDRESS + "address": { + "flat": o.address_flat, + "building": o.address_building, + "landmark": o.address_landmark + }, + + # โœ… ITEMS + "items": [ + { + "name": i.name, + "qty": i.quantity + } + for i in o.items + ] + }) + + return { + "batch_id": batch.id, + "status": batch.status, + "orders": orders_data + } + +@app.put("/delivery/batch/status") +def update_batch_status( + data: dict, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + batch = db.query(Batch).filter( + Batch.id == data.get("batch_id") + ).first() + + if not batch: + return {"error": "Batch not found"} + + if batch.delivery_boy_id != user.get("delivery_boy_id"): + return {"error": "Not your batch"} + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + # โŒ prevent conflict with single order + if boy.current_order_id: + return {"error": "You have active single order"} + + new_status = data.get("status") + + allowed = ["AT_VENDOR", "PICKED_UP", "OUT_FOR_DELIVERY", "DELIVERED"] + + if new_status not in allowed: + return {"error": "Invalid status"} + + # ========================= + # ๐Ÿš€ STATUS FLOW + # ========================= + + if new_status == "AT_VENDOR": + boy.status = "BUSY" + batch.status = "AT_VENDOR" + + elif new_status == "PICKED_UP": + batch.status = "PICKED_UP" + boy.status = "ON_DELIVERY" + + for o in batch.orders: + o.status = "PICKED_UP" + + delivery = db.query(Delivery).filter( + Delivery.order_id == o.id + ).first() + + if delivery: + delivery.status = "PICKED_UP" + else: + print(f"โš ๏ธ Missing delivery entry for order {o.id}") + + elif new_status == "OUT_FOR_DELIVERY": + batch.status = "OUT_FOR_DELIVERY" + boy.status = "ON_DELIVERY" + + for o in batch.orders: + o.status = "OUT_FOR_DELIVERY" + + delivery = db.query(Delivery).filter( + Delivery.order_id == o.id + ).first() + + if delivery: + delivery.status = "OUT_FOR_DELIVERY" + + send_user_out_for_delivery_notification( + o.user_id, + o.id + ) + + elif new_status == "DELIVERED": + + order_id = data.get("order_id") + + if not order_id: + return {"error": "order_id required"} + + order = db.query(Order).filter( + Order.id == order_id + ).first() + + if not order: + return {"error": "Order not found"} + + # โœ… UPDATE ORDER + order.status = "DELIVERED" + + delivery = db.query(Delivery).filter( + Delivery.order_id == order.id + ).first() + + if delivery: + delivery.status = "DELIVERED" + + # โœ… SAVE CURRENT ORDER FIRST + db.commit() + + # โœ… RELOAD BATCH + db.refresh(batch) + + # โœ… check full batch + all_done = all( + o.status == "DELIVERED" + for o in batch.orders + ) + + if all_done: + + batch.status = "DELIVERED" + + boy.status = "IDLE" + + boy.current_batch_id = None + + db.commit() + + # โœ… SEND PUSH AFTER SAVE + send_user_delivered_notification( + order.user_id, + order.id + ) + + # โœ… VENDOR PUSH + + vendor_ids = set() + + for item in order.items: + vendor_ids.add(item.vendor_id) + + for vendor_id in vendor_ids: + + send_vendor_delivered_notification( + vendor_id, + order.id, + boy.name + ) + + print("โœ… BATCH DELIVERED PUSH SENT") + + # โœ… SAVE NORMAL STATUS + if new_status != "DELIVERED": + db.commit() + + return {"message": f"Updated to {new_status}"} + + +@app.put("/vendor/order/ready") +def mark_vendor_ready( + data: dict, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + if user.get("role") != "VENDOR": + return {"error": "Unauthorized"} + + if user.get("vendor_id") != data.get("vendor_id"): + return {"error": "Not your vendor"} + + items = db.query(OrderItem).filter( + OrderItem.order_id == data["order_id"], + OrderItem.vendor_id == data["vendor_id"] + ).all() + + if not items: + return {"error": "No items found"} + + # โœ… mark vendor items ready + for i in items: + i.status = "READY" + + db.commit() + + # ๐Ÿ”ฅ CHECK FULL ORDER READY + all_items = db.query(OrderItem).filter( + OrderItem.order_id == data["order_id"] + ).all() + + all_ready = all(i.status == "READY" for i in all_items) + + if all_ready: + order = db.query(Order).filter( + Order.id == data["order_id"] + ).first() + + if order: + order.status = "READY" + db.commit() + + return {"message": "Marked READY"} + + +@app.get("/admin/batch/{batch_id}/ready") +def is_batch_ready(batch_id: int, db: Session = Depends(get_db)): + batch = db.query(Batch).filter(Batch.id == batch_id).first() + + if not batch: + return {"error": "Batch not found"} + + for o in batch.orders: + items = o.items + + for i in items: + if i.status != "READY": + return {"ready": False} + + return {"ready": True} + + +@app.put("/delivery/order/status") +def update_single_order_status( + data: dict, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + order_id = data.get("order_id") + + # โœ… USE ORDER ID DIRECTLY + if order_id: + + order = db.query(Order).filter( + Order.id == order_id + ).first() + + else: + + if not boy or not boy.current_order_id: + return {"error": "No active order"} + + order = db.query(Order).filter( + Order.id == boy.current_order_id + ).first() + + if not order: + return {"error": "Order not found"} + + delivery = db.query(Delivery).filter( + Delivery.order_id == order.id + ).first() + + new_status = data.get("status") + + allowed = ["AT_VENDOR", "PICKED_UP", "OUT_FOR_DELIVERY", "DELIVERED"] + + if new_status not in allowed: + return {"error": "Invalid status"} + + # ๐Ÿš€ FLOW CONTROL + if new_status == "AT_VENDOR": + order.status = "AT_VENDOR" + boy.status = "BUSY" + + elif new_status == "PICKED_UP": + order.status = "PICKED_UP" + boy.status = "ON_DELIVERY" + + elif new_status == "OUT_FOR_DELIVERY": + + order.status = "OUT_FOR_DELIVERY" + + boy.status = "ON_DELIVERY" + + # ๐Ÿ”ฅ PUSH NOTIFICATION + try: + send_user_out_for_delivery_notification( + order.user_id, + order.id + ) + + print("โœ… OUT_FOR_DELIVERY PUSH SENT") + + except Exception as e: + print("โŒ OUT_FOR_DELIVERY PUSH ERROR:", e) + + + elif new_status == "DELIVERED": + + # โœ… UPDATE STATUS + order.status = "DELIVERED" + + if delivery: + delivery.status = "DELIVERED" + + # โœ… RESET DELIVERY BOY + boy.status = "IDLE" + boy.current_order_id = None + boy.current_batch_id = None + + # โœ… SAVE FIRST + db.commit() + + # โœ… SEND PUSH AFTER DB UPDATE + send_user_delivered_notification( + order.user_id, + order.id + ) + # โœ… VENDOR PUSH + + vendor_ids = set() + + for item in order.items: + vendor_ids.add(item.vendor_id) + + for vendor_id in vendor_ids: + + send_vendor_delivered_notification( + vendor_id, + order.id, + boy.name + ) + + print("โœ… DELIVERED PUSH SENT") + + return {"message": "Updated to DELIVERED"} + + + # โœ… skip second commit for delivered + if new_status != "DELIVERED": + + if delivery: + delivery.status = new_status + + db.commit() + + return {"message": f"Updated to {new_status}"} + + +@app.put("/delivery/order/cancel") +def cancel_single_order( + data: dict, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + + # ========================= + # AUTH CHECK + # ========================= + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + # ========================= + # DELIVERY BOY + # ========================= + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + # ========================= + # ORDER + # ========================= + order = db.query(Order).filter( + Order.id == data.get("order_id") + ).first() + + if not order: + return {"error": "Order not found"} + + # ========================= + # REASON REQUIRED + # ========================= + reason = data.get("reason") + + if not reason or reason.strip() == "": + return {"error": "Cancel reason required"} + + # ========================= + # PREVENT INVALID CANCEL + # ========================= + if order.status == "DELIVERED": + return {"error": "Delivered order cannot be cancelled"} + + if order.status == "CANCELLED": + return {"error": "Order already cancelled"} + + # ========================= + # UPDATE ORDER + # ========================= + order.status = "CANCELLED" + + order.cancel_reason = reason + + order.cancelled_by = boy.name + + order.cancelled_at = datetime.utcnow() + + # ========================= + # UPDATE DELIVERY + # ========================= + delivery = db.query(Delivery).filter( + Delivery.order_id == order.id + ).first() + + if delivery: + delivery.status = "CANCELLED" + + # ========================= + # RESET DELIVERY BOY + # ========================= + if boy.current_order_id == order.id: + boy.current_order_id = None + boy.status = "IDLE" + + # ========================= + # HANDLE BATCH + # ========================= + if order.batch_id: + + batch = db.query(Batch).filter( + Batch.id == order.batch_id + ).first() + + if batch: + + remaining = db.query(Order).filter( + Order.batch_id == batch.id, + Order.status.notin_(["DELIVERED", "CANCELLED"]) + ).count() + + if remaining == 0: + batch.status = "COMPLETED" + + boy.current_batch_id = None + boy.status = "IDLE" + + + # ========================= + # โœ… VENDOR PUSH + # ========================= + + vendor_ids = set() + + for item in order.items: + vendor_ids.add(item.vendor_id) + + for vendor_id in vendor_ids: + + send_vendor_cancelled_notification( + vendor_id, + order.id, + reason + ) + + # โœ… USER PUSH + + send_user_cancelled_notification( + order.user_id, + order.id, + reason + ) + + db.commit() + + return { + "success": True, + "message": "Order cancelled successfully" + } + +@app.post("/delivery/update-location") +def update_location( + data: dict, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + boy.lat = data.get("lat") + boy.lng = data.get("lng") + + db.commit() + + return {"message": "Location updated"} + + +@app.get("/admin/delivery-boy/{boy_id}/location") +def get_delivery_boy_location( + boy_id: int, + db: Session = Depends(get_db), + admin=Depends(get_current_admin) +): + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == boy_id + ).first() + + if not boy: + return {"error": "Not found"} + + return { + "lat": boy.lat, + "lng": boy.lng, + "status": boy.status + } + +@app.get("/delivery/my-work") +def get_my_work( + db: Session = Depends(get_db), + user: dict = Depends(get_current_admin) + +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + # ๐Ÿ”ฅ SAFE QUERY + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user.get("delivery_boy_id") + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + # ========================= + # โœ… BATCH WORK + # ========================= + if boy.current_batch_id: + + batch = db.query(Batch).filter( + Batch.id == boy.current_batch_id + ).first() + + # โŒ stale batch cleanup + if not batch: + boy.current_batch_id = None + boy.status = "IDLE" + db.commit() + + return { + "type": "NONE", + "delivery_status": boy.status, + "delivery_boy_name": boy.name + } + + # โœ… check remaining active orders + remaining_orders = db.query(Order).filter( + Order.batch_id == batch.id, + Order.status.notin_(["DELIVERED", "CANCELLED"]) + ).count() + + # โœ… all done + if remaining_orders == 0: + + batch.status = "COMPLETED" + + boy.current_batch_id = None + boy.status = "IDLE" + + db.commit() + + return { + "type": "NONE", + "delivery_status": boy.status, + "delivery_boy_name": boy.name + } + + return { + "type": "BATCH", + "batch_id": batch.id, + "status": batch.status, + "delivery_status": boy.status, + "delivery_boy_name": boy.name + } + # ========================= + # โœ… SINGLE ORDER WORK + # ========================= + if boy.current_order_id: + + order = db.query(Order).filter( + Order.id == boy.current_order_id + ).first() + + # ๐Ÿ”ฅ FIX STALE ORDER + if not order: + return {"error": "Order not found"} + + user_data = db.query(User).filter( + User.id == order.user_id + ).first() + + + return { + "type": "ORDER", + "delivery_boy_name": boy.name, + "delivery_status": boy.status, + "order_id": order.id, + "status": order.status, + "lat": order.user_lat, + "lng": order.user_lng, + "user": { + "name": user_data.name if user_data else "", + "phone": user_data.phone if user_data else "" + }, + "address": { + "flat": order.address_flat, + "building": order.address_building, + "landmark": order.address_landmark + } + } + + # ========================= + # โœ… NO ACTIVE WORK + # ========================= + return { + "type": "NONE", + "delivery_status": boy.status, + "delivery_boy_name": boy.name + + } + + +@app.put("/delivery/toggle-status") +def toggle_delivery_status( + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + if not boy: + return {"error": "Delivery boy not found"} + + # โŒ cannot go offline during work + if boy.current_batch_id or boy.current_order_id: + return {"error": "Complete current delivery first"} + + # ๐Ÿ”ฅ TOGGLE + if boy.status == "OFFLINE": + boy.status = "IDLE" + is_active = True + else: + boy.status = "OFFLINE" + is_active = False + + db.commit() + + return { + "success": True, + "active": is_active, + "status": boy.status + } + + +@app.get("/delivery/order/{order_id}") +def get_order_for_delivery( + order_id: int, + db: Session = Depends(get_db), + user=Depends(get_current_admin) +): + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized", "items": []} # โœ… ALWAYS include items + + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + return {"error": "Order not found", "items": []} + + boy = db.query(DeliveryBoy).filter( + DeliveryBoy.id == user["delivery_boy_id"] + ).first() + + if not boy or boy.current_order_id != order.id: + return {"error": "Not your order", "items": []} + + user_data = db.query(User).filter(User.id == order.user_id).first() + + + + # โœ… SAFELY BUILD ITEMS + items_list = [] + if order.items: + for i in order.items: + items_list.append({ + "name": i.name, + "qty": i.quantity + }) + + return { + "order_id": order.id, + "status": order.status, + "lat": order.user_lat, + "lng": order.user_lng, + + # ๐Ÿ’ฐ PAYMENT INFO (๐Ÿ”ฅ ADD THIS) + "final_amount": order.final_amount, + "payment_status": order.payment_status, + + + # ๐Ÿ‘ค USER + "user": { + "name": user_data.name if user_data else "", + "phone": user_data.phone if user_data else "" + }, + + # ๐Ÿ“ ADDRESS + "address": { + "flat": order.address_flat, + "building": order.address_building, + "landmark": order.address_landmark + }, + # ๐Ÿ“ฆ ITEMS (ALWAYS SAFE) + "items": items_list + } + +from helpers.payment import generate_upi_link + +@app.get("/delivery/order/{order_id}/upi") +def get_upi_payment(order_id: int, db: Session = Depends(get_db)): + + order = db.query(Order).filter(Order.id == order_id).first() + + if not order: + return {"error": "Order not found"} + + upi_url = generate_upi_link(order.final_amount, order.id) + + return { + "upi_url": upi_url, + "amount": order.final_amount + } + +@app.post("/delivery/upload-payment-proof") +async def upload_payment_proof( + file: UploadFile = File(...), + user=Depends(get_current_admin) +): + + # โœ… only delivery boys + if user.get("role") != "DELIVERY_BOY": + return {"error": "Unauthorized"} + + # โœ… validate type + allowed = [ + "image/jpeg", + "image/png", + "image/webp" + ] + + if file.content_type not in allowed: + return {"error": "Invalid image type"} + + try: + upload = cloudinary.uploader.upload( + file.file, + folder="payment_proofs" + ) + + return { + "success": True, + "url": upload["secure_url"] + } + + except Exception as e: + print("UPLOAD ERROR:", e) + + return { + "error": "Upload failed" + } + + + +@app.get("/invoice/{order_id}") +def get_invoice_data( + order_id: int, + db: Session = Depends(get_db) +): + + # ========================= + # GET ORDER + # ========================= + order = db.query(Order).filter( + Order.id == order_id + ).first() + + if not order: + return {"error": "Order not found"} + + # ========================= + # OPTIONAL SECURITY + # ========================= + # Only paid orders should have invoice + + if order.payment_status != "PAID": + return {"error": "Invoice unavailable until payment completed"} + + # ========================= + # GET USER + # ========================= + user = db.query(User).filter( + User.id == order.user_id + ).first() + + # ========================= + # GET ITEMS + # ========================= + items = db.query(OrderItem).filter( + OrderItem.order_id == order.id + ).all() + + item_data = [] + + for item in items: + + taxable = item.selling_price * item.quantity + gst_amount = round(taxable * 0.05, 2) + + item_data.append({ + "name": item.name, + "quantity": item.quantity, + "price": item.selling_price, + "hsn": "2106", + "gst_rate": "5%", + "taxable_value": taxable, + "total": taxable + gst_amount + }) + + # ========================= + # RESPONSE + # ========================= + return { + + # COMPANY + "company": { + "name": "MEALNO (Partnership Firm)", + "email": "support@mealno.com", + "gstin": "19ABCDE1234F1Z5", + "pan": "ABCDE1234F", + "fssai": "1234567890", + "address": "Kolkata, West Bengal" + }, + + # INVOICE DETAILS + "invoice": { + "invoice_no": f"INV-{order.id}", + "order_id": order.id, + "invoice_date": order.created_at.strftime("%d-%m-%Y"), + "place_of_supply": "West Bengal (19)" + }, + + # CUSTOMER + "customer": { + "name": user.name, + "phone": user.phone, + "address": { + "flat": order.address_flat, + "building": order.address_building, + "landmark": order.address_landmark + } + }, + + # ITEMS + "items": item_data, + + # SUMMARY + "summary": { + "taxable_value": order.total_amount, + "cgst": round(order.gst / 2, 2), + "sgst": round(order.gst / 2, 2), + "gst": order.gst, + "grand_total": order.final_amount + }, + + # PAYMENT + "payment_status": order.payment_status, + + # ORDER STATUS + "order_status": order.status + } + +# Updated get_user_orders function for main.py +from models import Review # Ensure this is at the top of the file + +@app.post("/order/review") +def submit_review(data: ReviewCreate, db: Session = Depends(get_db)): + print("DEBUG: Review endpoint was hit!") + + # 1. Check if review already exists + existing = db.query(Review).filter(Review.order_id == data.order_id).first() + if existing: + return {"error": "Order already reviewed"} + + # 2. Get the order to extract the zonal_admin_id automatically + order = db.query(Order).filter(Order.id == data.order_id).first() + if not order: + return {"error": "Order not found"} + + # 3. Save the review + review = Review( + user_id=data.user_id, + order_id=data.order_id, + zonal_admin_id=order.zonal_admin_id, + rating=data.rating, + comment=data.comment + ) + + db.add(review) + db.commit() + + return {"success": True, "message": "Review submitted successfully!"} + +@app.get("/user/{user_id}/reviews") +def get_user_reviews(user_id: int, db: Session = Depends(get_db)): + # Fetch reviews joined with order data + reviews = db.query(Review).filter(Review.user_id == user_id).order_by(Review.created_at.desc()).all() + + result = [] + for r in reviews: + order = db.query(Order).filter(Order.id == r.order_id).first() + + # Get item names for context (e.g., "Chicken Biryani, Roti...") + item_names = [] + if order: + items = db.query(OrderItem).filter(OrderItem.order_id == order.id).all() + item_names = [i.name for i in items] + + result.append({ + "review_id": r.id, + "order_id": r.order_id, + "rating": r.rating, + "comment": r.comment, + "created_at": r.created_at, + "items_summary": ", ".join(item_names) if item_names else "Order Items" + }) + + return result + +# ========================= +# SUPPORT TICKET +# ========================= + +@app.post("/support-ticket") +def create_support_ticket_api( + data: SupportTicketCreate, + request: Request, + db: Session = Depends(get_db) +): + + backend_ip = request.client.host + + return crud.create_support_ticket( + db, + data, + backend_ip + ) + +@app.get("/admin/support-tickets") +def admin_support_tickets( + db: Session = Depends(get_db), + admin=Depends(get_current_admin) +): + + return crud.get_support_tickets(db) \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..8515034 --- /dev/null +++ b/models.py @@ -0,0 +1,405 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text +from sqlalchemy.orm import relationship +from datetime import datetime +from sqlalchemy import Index +from database import Base + +# ========================= +# USER +# ========================= +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=True) + phone = Column(String, unique=True, nullable=False) + email = Column(String, unique=True) + # ๐Ÿ”ฅ ADD THESE TWO NEW COLUMNS + dob = Column(String, nullable=True) + gender = Column(String, nullable=True) + + + created_at = Column(DateTime, default=datetime.utcnow) + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + + orders = relationship("Order", back_populates="user") + + # ๐Ÿ”ฅ NEW RELATION + addresses = relationship("UserAddress", back_populates="user") + +class UserAddress(Base): + __tablename__ = "user_addresses" + + id = Column(Integer, primary_key=True, index=True) + + user_id = Column(Integer, ForeignKey("users.id"), index=True) + flat = Column(String) + building = Column(String) + landmark = Column(String) + + lat = Column(Float) + lng = Column(Float) + + address_type = Column(String) # HOME / WORK / OTHER + + is_default = Column(Boolean, default=False) + + created_at = Column(DateTime, default=datetime.utcnow) + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + user = relationship("User", back_populates="addresses") + +# ========================= +# VENDOR (Kitchen) +# ========================= +class Vendor(Base): + __tablename__ = "vendors" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + + # ๐Ÿ”ฅ FULL ADDRESS (structured) + flat = Column(String) + building = Column(String) + landmark = Column(String) + + location = Column(String) # full readable address + + lat = Column(Float, nullable=False) + lng = Column(Float, nullable=False) + + radius = Column(Float, default=2) + + email = Column(String, unique=True) + password = Column(String) + + is_active = Column(Boolean, default=True) + is_open = Column(Boolean, default=False) + + created_at = Column(DateTime, default=datetime.utcnow) + + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + city = Column(String) + zone = Column(String) + zonal_admin_id = Column(Integer, ForeignKey("admins.id")) + + + menus = relationship("VendorMenu", back_populates="vendor") + + +# ========================= +# MENU ITEMS (Admin Controlled) +# ========================= +class MenuItem(Base): + __tablename__ = "menu_items" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + category = Column(String) + + description = Column(Text) # ๐Ÿ”ฅ NEW + + vendor_price = Column(Float, nullable=False) + selling_price = Column(Float, nullable=False) + image_url = Column(String, nullable=True) + is_deleted = Column(Boolean, default=False) + + created_at = Column(DateTime, default=datetime.utcnow) + + vendors = relationship("VendorMenu", back_populates="menu_item") + + +# ========================= +# VENDOR MENU (Availability) +# ========================= +class VendorMenu(Base): + __tablename__ = "vendor_menu" + + id = Column(Integer, primary_key=True, index=True) + + vendor_id = Column(Integer, ForeignKey("vendors.id"), index=True) + menu_item_id = Column(Integer, ForeignKey("menu_items.id"), index=True) + is_available = Column(Boolean, default=True) + + vendor = relationship("Vendor", back_populates="menus") + menu_item = relationship("MenuItem", back_populates="vendors") + + +# ========================= +# ORDER +# ========================= +class Order(Base): + __tablename__ = "orders" + + id = Column(Integer, primary_key=True, index=True) + batch_id = Column(Integer, ForeignKey("batches.id"), nullable=True, index=True) + zonal_admin_id = Column(Integer, ForeignKey("admins.id"), index=True) # โœ… ADD + admin = relationship("Admin") + user_id = Column(Integer, ForeignKey("users.id"), index=True) + user_lat = Column(Float) + user_lng = Column(Float) + + # ๐Ÿ”ฅ ORDER ADDRESS SNAPSHOT + address_flat = Column(String, nullable=True) + address_building = Column(String, nullable=True) + address_landmark = Column(String, nullable=True) + address_type = Column(String, nullable=True) + + address_lat = Column(Float, nullable=True) + address_lng = Column(Float, nullable=True) + + total_amount = Column(Float, nullable=False) + gst = Column(Float, nullable=False) + final_amount = Column(Float, nullable=False) + + status = Column(String, default="PLACED", index=True) + reject_reason = Column(String, nullable=True) # ๐Ÿ”ฅ ADD THIS + payment_status = Column(String, default="PENDING") + delivery = relationship("Delivery", back_populates="order", uselist=False) + created_at = Column(DateTime, default=datetime.utcnow) + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + + + user = relationship("User", back_populates="orders") + items = relationship("OrderItem", back_populates="order") + + batch = relationship("Batch", back_populates="orders") + + cancel_reason = Column(Text, nullable=True) + cancelled_by = Column(String, nullable=True) + cancelled_at = Column(DateTime, nullable=True) + +# ========================= +# ORDER ITEMS (Snapshot) +# ========================= +class OrderItem(Base): + __tablename__ = "order_items" + + id = Column(Integer, primary_key=True, index=True) + order_id = Column(Integer, ForeignKey("orders.id"), index=True) + menu_item_id = Column(Integer, ForeignKey("menu_items.id"), index=True) + + vendor_id = Column(Integer, index=True) + + quantity = Column(Integer, nullable=False) + + name = Column(String) + vendor_price = Column(Float, nullable=False) + selling_price = Column(Float, nullable=False) + status = Column(String, default="PREPARING") + + order = relationship("Order", back_populates="items") + + # โœ… MUST BE INSIDE CLASS + __table_args__ = ( + Index("idx_order_vendor", "order_id", "vendor_id"), + ) + +# ========================= +# DELIVERY +# ========================= +class Delivery(Base): + __tablename__ = "deliveries" + + id = Column(Integer, primary_key=True, index=True) + + order_id = Column(Integer, ForeignKey("orders.id"), index=True) + delivery_boy_id = Column(Integer, ForeignKey("delivery_boys.id"), index=True) + + status = Column(String, default="ASSIGNED") + + # Payment handling + payment_method = Column(String) # COD / QR + payment_amount = Column(Float) # โœ… store actual paid amount + payment_proof = Column(Text) # image URL / text note + + created_at = Column(DateTime, default=datetime.utcnow) + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + + delivery_boy = relationship("DeliveryBoy") + order = relationship("Order", back_populates="delivery") + + +class Batch(Base): + __tablename__ = "batches" + + id = Column(Integer, primary_key=True, index=True) + zonal_admin_id = Column(Integer, ForeignKey("admins.id"), index=True) # โœ… ADD + + status = Column(String, default="CREATED", index=True) + # CREATED / ASSIGNED / PICKED_UP / OUT_FOR_DELIVERY / DELIVERED # CREATED / ASSIGNED / PICKED / COMPLETED + + created_at = Column(DateTime, default=datetime.utcnow) + + delivery_boy_id = Column(Integer, ForeignKey("delivery_boys.id"), nullable=True) + orders = relationship("Order", back_populates="batch") + # โœ… FIX HERE + delivery_boy = relationship( + "DeliveryBoy", + back_populates="batches", + foreign_keys=[delivery_boy_id] + ) + +# ========================= +# ADMIN (City + Zonal) +# ========================= +class Admin(Base): + __tablename__ = "admins" + + id = Column(Integer, primary_key=True, index=True) + + name = Column(String, nullable=False) + email = Column(String, unique=True, nullable=False) + password = Column(String, nullable=False) + + role = Column(String, nullable=False) + # CITY_ADMIN / ZONAL_ADMIN + + city = Column(String, nullable=True) + zone = Column(String, nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow) + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + + +class DeliveryBoy(Base): + __tablename__ = "delivery_boys" + + id = Column(Integer, primary_key=True, index=True) + + name = Column(String, nullable=False) + email = Column(String, unique=True, nullable=False) + phone = Column(String, nullable=False) + password = Column(String, nullable=False) + + city = Column(String) + zone = Column(String) + + zonal_admin_id = Column(Integer, ForeignKey("admins.id")) + + is_active = Column(Boolean, default=True) + + # ๐Ÿ”ฅ NEW: STATUS SYSTEM + status = Column(String, default="OFFLINE", index=True) + # OFFLINE / IDLE / ASSIGNED / PICKED_UP / OUT_FOR_DELIVERY + + # ๐Ÿ”ฅ NEW: ASSIGNMENT TRACKING (VERY IMPORTANT) + current_batch_id = Column(Integer, nullable=True) + current_order_id = Column(Integer, nullable=True) + + # ๐Ÿ”ฅ NEW: LIVE LOCATION (for admin tracking later) + lat = Column(Float, nullable=True) + lng = Column(Float, nullable=True) + + created_at = Column(DateTime, default=datetime.utcnow) + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + + # โœ… ADD THIS (CRITICAL FIX) + batches = relationship( + "Batch", + back_populates="delivery_boy", + foreign_keys="Batch.delivery_boy_id" + ) + +from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean +from datetime import datetime + + +class NotificationToken(Base): + + __tablename__ = "notification_tokens" + + id = Column( + Integer, + primary_key=True, + index=True + ) + + user_id = Column( + Integer, + nullable=False, + index=True + ) + + role = Column( + String, + nullable=False + ) + + token = Column( + Text, + unique=True, + nullable=False + ) + + # โœ… DEVICE INFO + device_name = Column(String) + + platform = Column(String) + + browser = Column(String) + + # โœ… ACTIVE / LOGOUT SUPPORT + is_active = Column( + Boolean, + default=True + ) + + created_at = Column( + DateTime, + default=datetime.utcnow + ) + + updated_at = Column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow + ) + + # ========================= +# REVIEWS +# ========================= +class Review(Base): + __tablename__ = "reviews" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), index=True) + order_id = Column(Integer, ForeignKey("orders.id"), unique=True, index=True) # One review per order + zonal_admin_id = Column(Integer, ForeignKey("admins.id"), index=True) + + rating = Column(Integer, nullable=False) # 1 to 5 stars + comment = Column(Text, nullable=True) # Optional written review + + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + user = relationship("User") + order = relationship("Order") + zonal_admin = relationship("Admin") + + +# ========================= +# SUPPORT TICKETS +# ========================= + +class SupportTicket(Base): + __tablename__ = "support_tickets" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id")) + user_name = Column(String, nullable=False) + mobile_number = Column(String, nullable=False) + message = Column(Text, nullable=False) + status = Column(String, default="OPEN") + # OPEN / CLOSED / IN_PROGRESS + created_at = Column(DateTime, default=datetime.utcnow) + frontend_ip = Column(String, nullable=True) + backend_ip = Column(String, nullable=True) + user = relationship("User") + \ No newline at end of file diff --git a/newrequirements.txt b/newrequirements.txt new file mode 100644 index 0000000..115e4e8 --- /dev/null +++ b/newrequirements.txt @@ -0,0 +1,48 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.13.1 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.10.0 +attrs==25.4.0 +bcrypt==3.2.2 +certifi==2025.8.3 +cffi==2.0.0 +charset-normalizer==3.4.3 +click==8.2.1 +colorama==0.4.6 +cryptography==45.0.7 +ecdsa==0.19.1 +fastapi==0.116.1 +frozenlist==1.8.0 +greenlet==3.2.4 +h11==0.16.0 +http_ece==1.2.1 +httptools==0.6.4 +idna==3.10 +multidict==6.7.0 +passlib==1.7.4 +propcache==0.4.1 +psycopg2-binary==2.9.10 +py-vapid==1.9.2 +pyasn1==0.6.1 +pycparser==2.23 +pydantic==2.11.7 +pydantic_core==2.33.2 +python-dotenv==1.1.1 +python-jose==3.5.0 +python-multipart==0.0.20 +pywebpush==2.1.1 +PyYAML==6.0.2 +requests==2.32.5 +rsa==4.9.1 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.43 +starlette==0.47.3 +typing-inspection==0.4.1 +typing_extensions==4.15.0 +urllib3==2.5.0 +uvicorn==0.35.0 +watchfiles==1.1.0 +websockets==15.0.1 +yarl==1.22.0 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..56970ac --- /dev/null +++ b/package-lock.json @@ -0,0 +1,648 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@mui/x-date-pickers": "^9.1.0", + "dayjs": "^1.11.20", + "qrcode.react": "^4.2.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT", + "peer": true + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT", + "peer": true + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-9.0.1.tgz", + "integrity": "sha512-GzamIIhZ1bH77dq7eKaeyRgJdkypsxin4jBFq2EMs4lBWRR0LFO1CSVMsoebn/VvjcNrnrOrjy48MkrkQUK2iw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/material": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-9.0.1.tgz", + "integrity": "sha512-voyCpeUxcSWLN7KPZuq0pGCIt726T9K6kiVM3XUcywZDAlZSarLHaUxJVQpospbjjOzN53hwyjo8s6KoWl6utw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/core-downloads-tracker": "^9.0.1", + "@mui/system": "^9.0.1", + "@mui/types": "^9.0.0", + "@mui/utils": "^9.0.1", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1", + "react-is": "^19.2.4", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^9.0.1", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-9.0.1.tgz", + "integrity": "sha512-pSIGq4Yw749KHEwlkYZWVERgHgwJELP6ODtBNUfV8V4oIb5H+h7IQDFXuk/b2oQccODK1enJAtiEzlgLZmq+8g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "^9.0.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-9.0.0.tgz", + "integrity": "sha512-9RLGdX4Jg0aQPRuvqh/OLzYSPlgd5zyEw5/1HIRfdavSiOd03WtUaGZH9/w1RoTYuRKwpgy0hpIFaMHIqPVIWg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-9.0.1.tgz", + "integrity": "sha512-WvlioaLxk6ewUIOfh0StxUvOPDS1mCfzaulcudsL1brZNXuh0N9FMk7RpH7ImJKjEz412SEy/V/yvqmtxbqxCQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/private-theming": "^9.0.1", + "@mui/styled-engine": "^9.0.0", + "@mui/types": "^9.0.0", + "@mui/utils": "^9.0.1", + "clsx": "^2.1.1", + "csstype": "^3.2.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-9.0.0.tgz", + "integrity": "sha512-i1cuFCAWN44b3AJWO7mh7tuh1sqbQSeVr/94oG0TX5uXivac8XalgE4/6fQZcmGZigzbQ35IXxj/4jLpRIBYZg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.1.tgz", + "integrity": "sha512-f3UO3jNN1pYg5zxqXC81Bvv8hx5ACcYc0387382ZI7M5ono1heIwHYLrKsz85myguWdeVKPRZGmDdynWUBjK2g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.0.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-9.1.0.tgz", + "integrity": "sha512-vE2oXP8bAlwppFckOc4HEwbhj5Mz7ZUqKU8kNyDa6v19cYsX3ais+fcuCGMh1xZiO1Q+H97s9xgN3WzzgcfmPw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "9.0.0", + "@mui/x-internals": "^9.1.0", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^7.3.0 || ^9.0.0", + "@mui/system": "^7.3.0 || ^9.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0 || ^4.0.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2 || ^3.0.0", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers/node_modules/@mui/utils": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", + "integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.0.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-9.1.0.tgz", + "integrity": "sha512-fVezTa1lU+Hb3y9UMI8D/iWXADhs0I8PaZqoh2LOUXjGEUJmKqwsRD19ZXInZsH2yu+YS0dqYMPDvzjYTTyo+Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/utils": "9.0.0", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-internals/node_modules/@mui/utils": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-9.0.0.tgz", + "integrity": "sha512-bQcqyg/gjULUqTuyUjSAFr6LQGLvtkNtDbJerAtoUn9kGZ0hg5QJiN1PLHMLbeFpe3te1831uq7GFl2ITokGdg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "@mui/types": "^9.0.0", + "@types/prop-types": "^15.7.15", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.2.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-is": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", + "license": "MIT" + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT", + "peer": true + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e0aa8c1 --- /dev/null +++ b/package.json @@ -0,0 +1,7 @@ +{ + "dependencies": { + "@mui/x-date-pickers": "^9.1.0", + "dayjs": "^1.11.20", + "qrcode.react": "^4.2.0" + } +} diff --git a/requirements.docx b/requirements.docx new file mode 100644 index 0000000..25df032 Binary files /dev/null and b/requirements.docx differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..115e4e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,48 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.13.1 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.10.0 +attrs==25.4.0 +bcrypt==3.2.2 +certifi==2025.8.3 +cffi==2.0.0 +charset-normalizer==3.4.3 +click==8.2.1 +colorama==0.4.6 +cryptography==45.0.7 +ecdsa==0.19.1 +fastapi==0.116.1 +frozenlist==1.8.0 +greenlet==3.2.4 +h11==0.16.0 +http_ece==1.2.1 +httptools==0.6.4 +idna==3.10 +multidict==6.7.0 +passlib==1.7.4 +propcache==0.4.1 +psycopg2-binary==2.9.10 +py-vapid==1.9.2 +pyasn1==0.6.1 +pycparser==2.23 +pydantic==2.11.7 +pydantic_core==2.33.2 +python-dotenv==1.1.1 +python-jose==3.5.0 +python-multipart==0.0.20 +pywebpush==2.1.1 +PyYAML==6.0.2 +requests==2.32.5 +rsa==4.9.1 +six==1.17.0 +sniffio==1.3.1 +SQLAlchemy==2.0.43 +starlette==0.47.3 +typing-inspection==0.4.1 +typing_extensions==4.15.0 +urllib3==2.5.0 +uvicorn==0.35.0 +watchfiles==1.1.0 +websockets==15.0.1 +yarl==1.22.0 diff --git a/schemas.py b/schemas.py new file mode 100644 index 0000000..fedc812 --- /dev/null +++ b/schemas.py @@ -0,0 +1,120 @@ +from pydantic import BaseModel +from typing import List +from typing import Literal + + + +class AddressCreate(BaseModel): + user_id: int + flat: str + building: str + landmark: str = None + lat: float + lng: float + address_type: Literal["HOME", "WORK", "OTHER"] = "HOME" + frontend_ip: str + +class OrderItemRequest(BaseModel): + menu_item_id: int + quantity: int + vendor_id: int + + +class OrderCreate(BaseModel): + frontend_ip: str | None = None + + user_id: int + lat: float + lng: float + items: List[OrderItemRequest] + + +class AssignDelivery(BaseModel): + order_id: int + delivery_boy_id: int + + +class UpdateDeliveryStatus(BaseModel): + order_id: int + status: str # ASSIGNED / PICKED / DELIVERED + +class DeliveryPayment(BaseModel): + order_id: int + payment_method: str + payment_proof: str | None = None + amount: float + + +# User Login + +class UserCreate(BaseModel): + name: str + phone: str + email: str + +class SendOTP(BaseModel): + phone: str + +class VerifyOTP(BaseModel): + phone: str + otp: str + + name: str = None + email: str = None + + # โœ… NEW + frontend_ip: str | None = None + +# ================= ADMIN ================= + +class AdminLogin(BaseModel): + email: str + password: str + + +class CreateZonalAdmin(BaseModel): + name: str + email: str + password: str + zone: str + +class VendorCreate(BaseModel): + name: str + location: str + + flat: str = None + building: str = None + landmark: str = None + lat: float | None = None + lng: float | None = None + + email: str + password: str + +class DeliveryBoyCreate(BaseModel): + name: str + email: str + phone: str + password: str + +class DeliveryBoyLogin(BaseModel): + email: str + password: str + + frontend_ip: str | None = None + +class ReviewCreate(BaseModel): + user_id: int + order_id: int + rating: int + comment: str | None = None + +# ========================= +# SUPPORT TICKET +# ========================= + +class SupportTicketCreate(BaseModel): + user_id: int + message: str + frontend_ip: str | None = None + \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..2b1436b --- /dev/null +++ b/utils.py @@ -0,0 +1,76 @@ +import math +from fastapi import HTTPException + +def haversine(lat1, lon1, lat2, lon2): + R = 6371 # Earth radius in KM + + dLat = math.radians(lat2 - lat1) + dLon = math.radians(lon2 - lon1) + + a = ( + math.sin(dLat / 2) ** 2 + + math.cos(math.radians(lat1)) * + math.cos(math.radians(lat2)) * + math.sin(dLon / 2) ** 2 + ) + + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + return R * c + + +def calculate_eta(distance): + prep_time = 10 # mins + travel_time = distance * 5 # mins per km + + eta = prep_time + travel_time + + return f"{int(eta)}-{int(eta + 5)} mins" + +# ================= AUTH ================= +from passlib.context import CryptContext +from jose import jwt +from datetime import datetime, timedelta + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +SECRET_KEY = "secret123" +ALGORITHM = "HS256" + + +def hash_password(password: str): + password = password[:72] # ๐Ÿ”ฅ FIX + return pwd_context.hash(password) + + +def verify_password(plain, hashed): + plain = plain[:72] + return pwd_context.verify(plain, hashed) + + +def create_token(data: dict): + to_encode = data.copy() + to_encode["exp"] = datetime.utcnow() + timedelta(days=30) + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +# Admin +from fastapi import Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +security = HTTPBearer() + +def get_current_admin( + credentials: HTTPAuthorizationCredentials = Depends(security) +): + try: + payload = jwt.decode( + credentials.credentials, + SECRET_KEY, + algorithms=[ALGORITHM] + ) + return payload + except Exception as e: + raise HTTPException( + status_code=401, + detail="Invalid token" + ) \ No newline at end of file