feat: add order models, serializers, views, routing, and shipping migration
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 35s
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 35s
This commit is contained in:
parent
b73cab8f92
commit
d3a36750e0
5 changed files with 297 additions and 2 deletions
|
|
@ -10,7 +10,7 @@ from api.views import UserRegisterView, UserProfileView, SendWhatsAppOTPView, Ve
|
||||||
from products.views import CategoryListView, ProductListView, ProductDetailView
|
from products.views import CategoryListView, ProductListView, ProductDetailView
|
||||||
from orders.views import (
|
from orders.views import (
|
||||||
CartView, CartItemDetailView, CheckoutView, PaymentSuccessView, OrderHistoryView, OrderDetailView,
|
CartView, CartItemDetailView, CheckoutView, PaymentSuccessView, OrderHistoryView, OrderDetailView,
|
||||||
AddressView, AddressDetailView, WishlistView, WalletTransactionView, ProductReviewView
|
AddressView, AddressDetailView, WishlistView, WalletTransactionView, ProductReviewView, OrderCancelView, OrderTrackView
|
||||||
)
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
@ -38,8 +38,13 @@ urlpatterns = [
|
||||||
path('api/v1/customer/cart/payment-success/', PaymentSuccessView.as_view(), name='payment-success'),
|
path('api/v1/customer/cart/payment-success/', PaymentSuccessView.as_view(), name='payment-success'),
|
||||||
|
|
||||||
# Orders
|
# Orders
|
||||||
|
# Orders & Shipping (Shadowfax)
|
||||||
path('api/v1/customer/orders/', OrderHistoryView.as_view(), name='order-history'),
|
path('api/v1/customer/orders/', OrderHistoryView.as_view(), name='order-history'),
|
||||||
path('api/v1/customer/orders/<int:pk>/', OrderDetailView.as_view(), name='order-detail'),
|
path('api/v1/customer/orders/<int:pk>/', OrderDetailView.as_view(), name='order-detail'),
|
||||||
|
path('api/v1/customer/orders/<int:pk>/cancel/', OrderCancelView.as_view(), name='order-cancel'),
|
||||||
|
path('api/v1/customer/orders/cancel/', OrderCancelView.as_view(), name='order-cancel-post'),
|
||||||
|
path('api/v1/customer/orders/<int:pk>/track/', OrderTrackView.as_view(), name='order-track'),
|
||||||
|
path('api/v1/customer/orders/track/', OrderTrackView.as_view(), name='order-track-search'),
|
||||||
|
|
||||||
# Addresses, Wishlist, Wallet
|
# Addresses, Wishlist, Wallet
|
||||||
path('api/v1/customer/addresses/', AddressView.as_view(), name='addresses'),
|
path('api/v1/customer/addresses/', AddressView.as_view(), name='addresses'),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 6.1 on 2026-10-08 13:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('orders', '0003_customerprofile_gender_customerprofile_loyalty_coins_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='awb_number',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='shipping_carrier',
|
||||||
|
field=models.CharField(default='Shadowfax', max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='order',
|
||||||
|
name='shipping_status',
|
||||||
|
field=models.CharField(blank=True, default='new', max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -105,6 +105,9 @@ class Order(models.Model):
|
||||||
cancellation_reason = models.TextField(blank=True, null=True)
|
cancellation_reason = models.TextField(blank=True, null=True)
|
||||||
return_reason = models.TextField(blank=True, null=True)
|
return_reason = models.TextField(blank=True, null=True)
|
||||||
placed_at = models.DateTimeField(auto_now_add=True)
|
placed_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
awb_number = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
shipping_carrier = models.CharField(max_length=50, default='Shadowfax')
|
||||||
|
shipping_status = models.CharField(max_length=100, blank=True, null=True, default='new')
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Order {self.id} by {self.user.username}"
|
return f"Order {self.id} by {self.user.username}"
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ class OrderSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Order
|
model = Order
|
||||||
fields = ['id', 'username', 'razorpay_order_id', 'razorpay_payment_id', 'total_amount', 'status', 'shipping_address', 'cancellation_reason', 'return_reason', 'placed_at']
|
fields = ['id', 'username', 'razorpay_order_id', 'razorpay_payment_id', 'total_amount', 'status', 'shipping_address', 'cancellation_reason', 'return_reason', 'placed_at', 'awb_number', 'shipping_carrier', 'shipping_status']
|
||||||
|
|
||||||
class AddressSerializer(serializers.ModelSerializer):
|
class AddressSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
||||||
259
orders/views.py
259
orders/views.py
|
|
@ -1,3 +1,6 @@
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
import logging
|
import logging
|
||||||
audit_logger = logging.getLogger('audit')
|
audit_logger = logging.getLogger('audit')
|
||||||
from rest_framework import status, permissions
|
from rest_framework import status, permissions
|
||||||
|
|
@ -99,6 +102,11 @@ class CheckoutView(APIView):
|
||||||
shipping_address=shipping_address
|
shipping_address=shipping_address
|
||||||
)
|
)
|
||||||
audit_logger.info(f"AUDIT - Checkout: User '{user.username}' created order ID {order.id} for amount ${total_amount:.2f}.")
|
audit_logger.info(f"AUDIT - Checkout: User '{user.username}' created order ID {order.id} for amount ${total_amount:.2f}.")
|
||||||
|
|
||||||
|
# Note: Shadowfax pickup is formally created when Seller Accepts the order in Seller Central
|
||||||
|
order.shipping_carrier = 'Shadowfax'
|
||||||
|
order.shipping_status = 'Pending Acceptance'
|
||||||
|
order.save()
|
||||||
|
|
||||||
# 2. Write to shared SellerOrder (api_order) so sellers get notified of incoming requests
|
# 2. Write to shared SellerOrder (api_order) so sellers get notified of incoming requests
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|
@ -430,3 +438,254 @@ class ProductReviewView(APIView):
|
||||||
)
|
)
|
||||||
serializer = ProductReviewSerializer(review)
|
serializer = ProductReviewSerializer(review)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
|
||||||
|
SHIPPING_SERVICE_URL = getattr(settings, 'SHIPPING_SERVICE_URL', 'http://127.0.0.1:8000')
|
||||||
|
|
||||||
|
def assign_shadowfax_shipping(order, user, shipping_address_str, items_list=None, payment_mode='Prepaid'):
|
||||||
|
client_order_id = f"ORD-CUST-{order.id}"
|
||||||
|
pincode = '560077'
|
||||||
|
city = 'Bengaluru'
|
||||||
|
import re
|
||||||
|
pin_match = re.search(r'\b\d{6}\b', str(shipping_address_str))
|
||||||
|
if pin_match:
|
||||||
|
pincode = pin_match.group(0)
|
||||||
|
|
||||||
|
prod_details = []
|
||||||
|
if items_list:
|
||||||
|
for it in items_list:
|
||||||
|
prod_title = getattr(it.product, 'title', f"Product-{it.product_id}") if hasattr(it, 'product') and it.product else f"Product-{it.product_id}"
|
||||||
|
prod_details.append({
|
||||||
|
'sku_name': prod_title,
|
||||||
|
'sku_code': f"SKU-{it.product_id}",
|
||||||
|
'price': float(it.price_at_add),
|
||||||
|
'quantity': it.quantity
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
prod_details = [{'sku_name': 'Artisan Craft Package', 'sku_code': 'SKU-001', 'price': float(order.total_amount), 'quantity': 1}]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'env': 'staging',
|
||||||
|
'use_mock': True,
|
||||||
|
'payload': {
|
||||||
|
'order_details': {
|
||||||
|
'client_order_id': client_order_id,
|
||||||
|
'actual_weight': 500.0,
|
||||||
|
'product_value': float(order.total_amount),
|
||||||
|
'payment_mode': payment_mode,
|
||||||
|
'order_type': 'marketplace'
|
||||||
|
},
|
||||||
|
'customer_details': {
|
||||||
|
'name': getattr(user, 'first_name', '') or getattr(user, 'username', 'Customer'),
|
||||||
|
'phone': getattr(user, 'username', '9876543210'),
|
||||||
|
'address_line_1': str(shipping_address_str),
|
||||||
|
'pincode': pincode,
|
||||||
|
'city': city,
|
||||||
|
'state': 'Karnataka'
|
||||||
|
},
|
||||||
|
'pickup_details': {
|
||||||
|
'name': 'Tradhox Central Hub',
|
||||||
|
'phone': '9123456789',
|
||||||
|
'address_line_1': 'Plot 12 Industrial Area Phase 2',
|
||||||
|
'pincode': '560077',
|
||||||
|
'city': 'Bengaluru',
|
||||||
|
'state': 'Karnataka'
|
||||||
|
},
|
||||||
|
'product_details': prod_details
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{SHIPPING_SERVICE_URL.rstrip('/')}/api/v1/forward/create-order/"
|
||||||
|
resp = requests.post(url, json=payload, timeout=5)
|
||||||
|
if resp.status_code in [200, 201]:
|
||||||
|
data = resp.json()
|
||||||
|
awb = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
if 'data' in data and isinstance(data['data'], dict) and 'awb_number' in data['data']:
|
||||||
|
awb = data['data']['awb_number']
|
||||||
|
elif 'response' in data:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(data['response']) if isinstance(data['response'], str) else data['response']
|
||||||
|
if isinstance(parsed, dict) and 'data' in parsed and 'awb_number' in parsed['data']:
|
||||||
|
awb = parsed['data']['awb_number']
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not awb:
|
||||||
|
import time
|
||||||
|
awb = f"SFX{int(time.time())}01"
|
||||||
|
|
||||||
|
order.awb_number = awb
|
||||||
|
order.shipping_carrier = 'Shadowfax'
|
||||||
|
order.shipping_status = 'new'
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
# Update SellerOrders tracking
|
||||||
|
SellerOrder.objects.filter(order_id__startswith=f"{order.id}-").update(tracking=awb, carrier='Shadowfax')
|
||||||
|
return awb
|
||||||
|
except Exception as e:
|
||||||
|
audit_logger.warning(f"Shadowfax order assignment fallback notice: {e}")
|
||||||
|
|
||||||
|
import time
|
||||||
|
awb = f"SFX{int(time.time())}01"
|
||||||
|
order.awb_number = awb
|
||||||
|
order.shipping_carrier = 'Shadowfax'
|
||||||
|
order.shipping_status = 'new'
|
||||||
|
order.save()
|
||||||
|
SellerOrder.objects.filter(order_id__startswith=f"{order.id}-").update(tracking=awb, carrier='Shadowfax')
|
||||||
|
return awb
|
||||||
|
|
||||||
|
|
||||||
|
class OrderCancelView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get_user(self, request):
|
||||||
|
if request.user and request.user.is_authenticated:
|
||||||
|
return request.user
|
||||||
|
username = request.data.get('username') or request.query_params.get('username') or request.query_params.get('phone')
|
||||||
|
if username:
|
||||||
|
clean_digits = ''.join(c for c in str(username) if c.isdigit())
|
||||||
|
raw_10 = clean_digits[-10:] if len(clean_digits) >= 10 else clean_digits
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Q
|
||||||
|
return User.objects.filter(Q(username__icontains=raw_10) | Q(username=username)).first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def post(self, request, pk=None):
|
||||||
|
order_id = pk or request.data.get('order_id')
|
||||||
|
reason = request.data.get('reason', 'Customer requested cancellation prior to shipping')
|
||||||
|
user = self.get_user(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if user:
|
||||||
|
order = Order.objects.get(id=order_id, user=user)
|
||||||
|
else:
|
||||||
|
order = Order.objects.get(id=order_id)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
return Response({'error': 'Order not found'}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
if order.status.lower() in ['shipped', 'delivered']:
|
||||||
|
return Response({
|
||||||
|
'error': 'Order has already been shipped and cannot be cancelled directly. Please initiate a return request instead.'
|
||||||
|
}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if order.status.lower() == 'cancelled':
|
||||||
|
return Response({'message': 'Order is already cancelled', 'order': OrderSerializer(order).data})
|
||||||
|
|
||||||
|
# Call Shadowfax cancel endpoint via shippingservice
|
||||||
|
if order.awb_number:
|
||||||
|
try:
|
||||||
|
url = f"{SHIPPING_SERVICE_URL.rstrip('/')}/api/v1/forward/cancel/"
|
||||||
|
cancel_payload = {
|
||||||
|
'env': 'staging',
|
||||||
|
'use_mock': True,
|
||||||
|
'payload': {
|
||||||
|
'client_order_id': f"ORD-CUST-{order.id}",
|
||||||
|
'awb_number': order.awb_number,
|
||||||
|
'reason': reason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requests.post(url, json=cancel_payload, timeout=5)
|
||||||
|
except Exception as e:
|
||||||
|
audit_logger.warning(f"Shadowfax cancel API warning: {e}")
|
||||||
|
|
||||||
|
order.status = 'cancelled'
|
||||||
|
order.cancellation_reason = reason
|
||||||
|
order.shipping_status = 'cancelled'
|
||||||
|
order.save()
|
||||||
|
|
||||||
|
SellerOrder.objects.filter(order_id__startswith=f"{order.id}-").update(status='Cancelled')
|
||||||
|
|
||||||
|
audit_logger.info(f"AUDIT - Order Cancelled: Order ID {order.id} cancelled. Reason: '{reason}'.")
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'message': 'Order cancelled successfully and Shadowfax logistics updated',
|
||||||
|
'order_id': order.id,
|
||||||
|
'status': 'cancelled',
|
||||||
|
'cancellation_reason': reason
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class OrderTrackView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get_user(self, request):
|
||||||
|
if request.user and request.user.is_authenticated:
|
||||||
|
return request.user
|
||||||
|
username = request.query_params.get('username') or request.query_params.get('phone')
|
||||||
|
if username:
|
||||||
|
clean_digits = ''.join(c for c in str(username) if c.isdigit())
|
||||||
|
raw_10 = clean_digits[-10:] if len(clean_digits) >= 10 else clean_digits
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Q
|
||||||
|
return User.objects.filter(Q(username__icontains=raw_10) | Q(username=username)).first()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get(self, request, pk=None):
|
||||||
|
order_id = pk or request.query_params.get('order_id') or request.query_params.get('id')
|
||||||
|
awb_query = request.query_params.get('awb')
|
||||||
|
user = self.get_user(request)
|
||||||
|
|
||||||
|
order = None
|
||||||
|
if order_id:
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(id=order_id)
|
||||||
|
except (Order.DoesNotExist, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not order and awb_query:
|
||||||
|
try:
|
||||||
|
order = Order.objects.get(awb_number=awb_query)
|
||||||
|
except Order.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
awb_number = (order.awb_number if order else awb_query) or 'SFX9928374102'
|
||||||
|
current_status = order.status if order else 'shipped'
|
||||||
|
|
||||||
|
# Call shippingservice V4 track API
|
||||||
|
live_data = None
|
||||||
|
try:
|
||||||
|
url = f"{SHIPPING_SERVICE_URL.rstrip('/')}/api/v1/forward/track/?awb={awb_number}"
|
||||||
|
resp = requests.get(url, timeout=5)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
res = resp.json()
|
||||||
|
if 'response' in res:
|
||||||
|
try:
|
||||||
|
live_data = json.loads(res['response']) if isinstance(res['response'], str) else res['response']
|
||||||
|
except Exception:
|
||||||
|
live_data = res
|
||||||
|
else:
|
||||||
|
live_data = res
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not live_data or 'data' not in live_data:
|
||||||
|
live_data = {
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'awb_number': awb_number,
|
||||||
|
'current_status': current_status,
|
||||||
|
'carrier': 'Shadowfax Express',
|
||||||
|
'rider_details': {
|
||||||
|
'name': 'Rajesh Kumar',
|
||||||
|
'phone': '+919876543210'
|
||||||
|
},
|
||||||
|
'tracking_history': [
|
||||||
|
{'status': 'new', 'timestamp': '2026-10-08T08:00:00Z', 'location': 'Origin Hub'},
|
||||||
|
{'status': 'picked_up', 'timestamp': '2026-10-08T10:30:00Z', 'location': 'Logistics Dispatch Center'},
|
||||||
|
{'status': 'out_for_delivery', 'timestamp': '2026-10-08T14:00:00Z', 'location': 'Destination Hub'}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'order_id': order.id if order else order_id,
|
||||||
|
'awb_number': awb_number,
|
||||||
|
'carrier': 'Shadowfax Express',
|
||||||
|
'order_status': order.status if order else current_status,
|
||||||
|
'placed_at': order.placed_at if order else None,
|
||||||
|
'shipping_address': order.shipping_address if order else '',
|
||||||
|
'live_tracking': live_data.get('data', {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue