HTT / store /views.py
Deep
backend
e7b5120
from django.shortcuts import render
from rest_framework.permissions import AllowAny
from django.db.models import ExpressionWrapper, F, IntegerField # F and IntegerField are often used with it
from django.db.models import DecimalField
# Create your views here.
from django.db.models import Count, Sum
from django.utils import timezone
from datetime import timedelta
from rest_framework.views import APIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ModelViewSet
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.views import APIView
from django.contrib.auth import authenticate
from django.utils import timezone
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
from pydantic import BaseModel, Field
from typing import Literal, Optional
from .gemini_classification_service import classify_product_image, verify_product_name_matches_image
from .refund_model_service import get_refund_model_service
import logging
logger = logging.getLogger(__name__)
from .models import (
User,
Address,
Product,
Order,
OrderDetail,
ExchangeLog,
ReturnLog,
VoiceSession,
VoiceMessage,
)
from .serializers import (
UserSerializer,
AddressSerializer,
ProductSerializer,
OrderSerializer,
OrderDetailSerializer,
)
class AdminAnalyticsAPIView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
now = timezone.now()
last_30_days = now - timedelta(days=30)
last_year = now - timedelta(days=365)
# ---- Orders ----
orders_last_30 = Order.objects.filter(order_date__gte=last_30_days)
orders_last_year = Order.objects.filter(order_date__gte=last_year)
orders_last_30_count = orders_last_30.count()
orders_last_year_count = orders_last_year.count()
# ---- Returns & Exchanges ----
returned_items = OrderDetail.objects.exclude(return_status='Not Returned')
exchanged_items = OrderDetail.objects.filter(is_exchanged=True)
total_returns = returned_items.count()
total_exchanges = exchanged_items.count()
total_order_items = OrderDetail.objects.count()
return_rate = (
(total_returns / total_order_items) * 100
if total_order_items > 0 else 0
)
exchange_rate = (
(total_exchanges / total_order_items) * 100
if total_order_items > 0 else 0
)
# ---- Revenue (Last 30 Days) ----
revenue_last_30 = OrderDetail.objects.filter(
order__order_date__gte=last_30_days
).aggregate(
total=Sum(
ExpressionWrapper(
F('order_quantity') * F('product_price'),
output_field=DecimalField()
)
)
)['total'] or 0
# ---- Top 3 Most Returned Products ----
top_returned_products = (
returned_items
.values('product__product_name')
.annotate(count=Count('id'))
.order_by('-count')[:3]
)
# ---- Top 3 Most Exchanged Products ----
top_exchanged_products = (
exchanged_items
.values('product__product_name')
.annotate(count=Count('id'))
.order_by('-count')[:3]
)
# ---- Most Common Return Reasons ----
top_return_reasons = (
returned_items
.values('return_reason')
.annotate(count=Count('id'))
.order_by('-count')[:3]
)
return Response({
"orders_last_30_days": orders_last_30_count,
"orders_last_year": orders_last_year_count,
"total_returns": total_returns,
"total_exchanges": total_exchanges,
"return_rate_percent": round(return_rate, 2),
"exchange_rate_percent": round(exchange_rate, 2),
"revenue_last_30_days": revenue_last_30,
"top_returned_products": list(top_returned_products),
"top_exchanged_products": list(top_exchanged_products),
"top_return_reasons": list(top_return_reasons),
}, status=status.HTTP_200_OK)
class AdminChatsAPIView(APIView):
"""
Return anonymized chat sessions and their messages for admin view.
Does NOT include `user` or other identifying fields.
Optional query param: `session_id` to fetch a single session's messages.
"""
permission_classes = [IsAuthenticated]
def get(self, request):
# restrict to superusers (admin dashboard users)
if not request.user.is_superuser:
return Response({'detail': 'Forbidden'}, status=status.HTTP_403_FORBIDDEN)
session_id = request.query_params.get('session_id')
if session_id:
try:
session = VoiceSession.objects.prefetch_related('messages').get(session_id=session_id)
except VoiceSession.DoesNotExist:
return Response({'detail': 'Session not found'}, status=status.HTTP_404_NOT_FOUND)
messages = []
for m in session.messages.all().order_by('turn_number'):
messages.append({
'turn_number': m.turn_number,
'step': m.step,
'user_text': m.user_text,
'bot_text': m.bot_text,
'created_at': m.created_at,
})
# Return session metadata without user/order/tracking fields
session_data = {
'session_id': session.session_id,
'started_at': session.started_at,
'ended_at': session.ended_at,
'status': session.status,
'request_type': session.request_type,
'message_count': session.messages.count(),
'messages': messages,
}
return Response({'session': session_data}, status=status.HTTP_200_OK)
# list recent sessions (limit to 50)
sessions_qs = VoiceSession.objects.prefetch_related('messages').order_by('-started_at')[:50]
sessions = []
for s in sessions_qs:
msgs = []
for m in s.messages.all().order_by('turn_number'):
msgs.append({
'turn_number': m.turn_number,
'step': m.step,
'user_text': m.user_text,
'bot_text': m.bot_text,
'created_at': m.created_at,
})
sessions.append({
'session_id': s.session_id,
'started_at': s.started_at,
'ended_at': s.ended_at,
'status': s.status,
'request_type': s.request_type,
'message_count': len(msgs),
'last_message_preview': (msgs[-1]['user_text'] or msgs[-1]['bot_text'] or '')[:200] if msgs else None,
'messages': msgs,
})
return Response({'sessions': sessions}, status=status.HTTP_200_OK)
class UserViewSet(ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
permission_classes = [AllowAny]
class AddressViewSet(ModelViewSet):
queryset = Address.objects.all()
serializer_class = AddressSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
# Allow optional user_id query param (frontend extracts from JWT and sends it).
# Only honor it if it matches the authenticated user to avoid spoofing.
qs = Address.objects.select_related('user').filter(user=self.request.user)
user_id = self.request.query_params.get('user_id')
if user_id:
try:
uid = int(user_id)
except (TypeError, ValueError):
return Address.objects.none()
# only return addresses if user_id matches request.user.id
if uid != getattr(self.request.user, 'id', None):
return Address.objects.none()
return qs
return qs
def perform_create(self, serializer):
# set the user to the authenticated user
address = serializer.save(user=self.request.user)
# if this address is marked default, unset other defaults for this user
if address.is_default:
Address.objects.filter(user=self.request.user).exclude(pk=address.pk).update(is_default=False)
@action(detail=True, methods=['post'], url_path='set-default')
def set_default(self, request, pk=None):
address = self.get_object()
if address.user != request.user:
return Response({'message': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
# unset other addresses
Address.objects.filter(user=request.user).update(is_default=False)
address.is_default = True
address.save()
return Response(self.get_serializer(address).data)
def perform_update(self, serializer):
address = serializer.save()
# if updated to default, unset other addresses
if address.is_default:
Address.objects.filter(user=self.request.user).exclude(pk=address.pk).update(is_default=False)
class ProductViewSet(ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
permission_classes = [AllowAny]
def _compute_return_risk_markup(self, request):
"""Check if the authenticated user has high return risk using the ML model."""
if not request.user or not request.user.is_authenticated:
return {'apply': False}
try:
from .views_return_prediction import MODEL_LOADED, model, preprocessor
if not MODEL_LOADED:
return {'apply': False}
import pandas as pd
from scipy.special import expit
user = request.user
account_age_days = (timezone.now() - user.created_at).days if user.created_at else 0
# Get user's recent orders to compute average risk
order_details = OrderDetail.objects.filter(
order__user=user
).select_related('product', 'order').order_by('-order__order_date')[:20]
if not order_details.exists():
return {'apply': False}
rows = []
for od in order_details:
rows.append({
'user_age': user.user_age or 30,
'account_age_days': account_age_days,
'user_gender': user.user_gender or 'Other',
'base_price': float(od.product.base_price),
'stock_quantity': od.product.stock_quantity,
'payment_method': od.order.payment_method or 'Credit Card',
'shipping_method': od.order.shipping_method or 'Standard',
'quantity': od.order_quantity,
'discount_percent': float(od.discount_applied),
})
features_df = pd.DataFrame(rows)
X_processed = preprocessor.transform(features_df)
decision_scores = model.decision_function(X_processed)
probabilities = expit(decision_scores)
avg_prob = float(probabilities.mean())
# Apply markup if average return probability >= 0.5 (High or Very High)
return {'apply': avg_prob >= 0.5, 'avg_probability': avg_prob}
except Exception as e:
logger.warning(f"Return risk markup computation failed: {e}")
return {'apply': False}
def get_serializer_context(self):
context = super().get_serializer_context()
context['return_risk_markup'] = self._compute_return_risk_markup(self.request)
return context
def get_queryset(self):
qs = Product.objects.only('id', 'product_name', 'base_price', 'stock_quantity').all()
min_price = self.request.query_params.get('min_price')
max_price = self.request.query_params.get('max_price')
if min_price is not None:
try:
qs = qs.filter(base_price__gte=min_price)
except Exception:
pass
if max_price is not None:
try:
qs = qs.filter(base_price__lte=max_price)
except Exception:
pass
return qs
@action(detail=False, methods=['get'], url_path='search')
def search(self, request):
q = request.query_params.get('q', '')
qs = self.get_queryset().filter(product_name__icontains=q)
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)
class OrderViewSet(ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
# Orders must be placed by authenticated users
permission_classes = [IsAuthenticated]
def get_queryset(self):
return Order.objects.filter(user=self.request.user).order_by('-order_date').select_related(
'user', 'shipping_address'
).prefetch_related('items', 'items__product')
def create(self, request, *args, **kwargs):
data = request.data
items = data.get('items', [])
shipping_address_id = data.get('shipping_address')
payment_method = data.get('payment_method')
shipping_method = data.get('shipping_method')
# validate shipping address
try:
shipping_address = Address.objects.get(id=shipping_address_id, user=request.user)
except Address.DoesNotExist:
return Response({'message': 'Invalid shipping address'}, status=status.HTTP_400_BAD_REQUEST)
order = Order.objects.create(
user=request.user,
shipping_address=shipping_address,
payment_method=payment_method,
shipping_method=shipping_method,
)
created_items = []
for item in items:
product_id = item.get('product')
order_quantity = item.get('order_quantity')
product_price = item.get('product_price')
discount = item.get('discount_applied', 0)
try:
product = Product.objects.get(id=product_id)
except Product.DoesNotExist:
order.delete()
return Response({'message': f'Product {product_id} not found'}, status=status.HTTP_400_BAD_REQUEST)
od = OrderDetail.objects.create(
order=order,
product=product,
order_quantity=order_quantity,
product_price=product_price,
discount_applied=discount,
)
created_items.append(od)
serializer = self.get_serializer(order)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class OrderDetailViewSet(ModelViewSet):
queryset = OrderDetail.objects.all()
serializer_class = OrderDetailSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return OrderDetail.objects.filter(
order__user=self.request.user
).select_related('order', 'product', 'exchange_order')
@action(detail=True, methods=['post'], url_path='return')
def return_item(self, request, pk=None):
od = self.get_object()
if od.order.user != request.user:
return Response({'message': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
reason = request.data.get('return_reason', '')
# mark as return initiated (will be processed later)
od.return_status = 'Return Initiated'
# record when the return was initiated
od.return_date = timezone.now()
od.return_reason = reason
# compute days_to_return
if od.return_date and od.order.order_date:
delta = od.return_date - od.order.order_date
od.days_to_return = max(0, delta.days)
od.save()
return Response(self.get_serializer(od).data)
@action(detail=True, methods=['post'], url_path='exchange')
def exchange_item(self, request, pk=None):
od = self.get_object()
if od.order.user != request.user:
return Response({'message': 'Not found'}, status=status.HTTP_404_NOT_FOUND)
# Automatically use the same product
product = od.product
# Create new exchange order
exchange_order = Order.objects.create(
user=request.user,
shipping_address=od.order.shipping_address,
payment_method=od.order.payment_method,
shipping_method=od.order.shipping_method,
)
# Create new order detail with SAME product
new_od = OrderDetail.objects.create(
order=exchange_order,
product=product,
order_quantity=od.order_quantity,
product_price=product.base_price,
)
# Mark original as exchanged
od.is_exchanged = True
od.exchange_order = exchange_order
od.return_status = 'Returned'
od.return_date = timezone.now()
od.return_reason = 'Exchange - replacement of same product'
if od.return_date and od.order.order_date:
od.days_to_return = max(0, (od.return_date - od.order.order_date).days)
od.save()
return Response(self.get_serializer(od).data)
class RegisterAPIView(APIView):
permission_classes = [AllowAny]
def post(self, request):
data = request.data
serializer = UserSerializer(data=data)
if not serializer.is_valid():
return Response({'message': 'Validation error', 'errors': serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
user = serializer.create(serializer.validated_data)
refresh = RefreshToken.for_user(user)
return Response(
{
'token': str(refresh.access_token), # ADD THIS for backward compatibility
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': UserSerializer(user).data,
},
status=status.HTTP_201_CREATED,
)
class LoginAPIView(APIView):
permission_classes = [AllowAny]
def post(self, request):
email = request.data.get('email')
password = request.data.get('password')
# Try to get user by email (since USERNAME_FIELD is email)
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
return Response({'message': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED)
# Check password
if not user.check_password(password):
return Response({'message': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED)
refresh = RefreshToken.for_user(user)
return Response(
{
'token': str(refresh.access_token), # ADD THIS for backward compatibility
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': UserSerializer(user).data,
}
)
class AdminLoginAPIView(APIView):
"""
Admin login endpoint - only allows superusers
Uses email authentication (USERNAME_FIELD = 'email')
"""
permission_classes = [AllowAny]
def post(self, request):
email = request.data.get('email')
password = request.data.get('password')
if not email or not password:
return Response(
{'message': 'Email and password are required'},
status=status.HTTP_400_BAD_REQUEST
)
# IMPORTANT: authenticate using email
user = authenticate(request, email=email, password=password)
if user is None:
logger.warning(f"Failed admin login attempt for email: {email}")
return Response(
{'message': 'Invalid credentials'},
status=status.HTTP_401_UNAUTHORIZED
)
if not user.is_superuser:
logger.warning(f"Non-superuser login attempt: {email}")
return Response(
{'message': 'Admin access required'},
status=status.HTTP_403_FORBIDDEN
)
refresh = RefreshToken.for_user(user)
logger.info(f"Successful admin login for email: {email}")
return Response(
{
'access': str(refresh.access_token),
'refresh': str(refresh),
'user': {
'id': user.id,
'username': user.username,
'email': user.email,
'is_superuser': user.is_superuser,
'is_staff': user.is_staff,
}
},
status=status.HTTP_200_OK
)
class CurrentUserAPIView(APIView):
permission_classes = [AllowAny]
def get(self, request):
return Response(UserSerializer(request.user).data)
def patch(self, request):
user = request.user
serializer = UserSerializer(user, data=request.data, partial=True)
if not serializer.is_valid():
return Response({'message': 'Validation error', 'errors': serializer.errors}, status=status.HTTP_400_BAD_REQUEST)
serializer.save()
return Response(serializer.data)
class InferenceAPIView(APIView):
"""
Product condition classification endpoint using Groq Vision AI.
Accepts an image and an order_detail_id.
Step 1: Verifies the uploaded image matches the product name.
Step 2: If matched, classifies product condition (resale / refurb / scrap).
"""
permission_classes = [AllowAny]
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
if 'image' not in request.data:
return Response(
{'message': 'No image provided'},
status=status.HTTP_400_BAD_REQUEST
)
order_detail_id = request.data.get('order_detail_id')
if not order_detail_id:
return Response(
{'message': 'order_detail_id is required'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
image_file = request.data['image']
# Validate image file
if not image_file:
return Response(
{'message': 'Invalid image file'},
status=status.HTTP_400_BAD_REQUEST
)
# Fetch product name from OrderDetail
try:
order_detail = OrderDetail.objects.get(id=order_detail_id)
except OrderDetail.DoesNotExist:
return Response(
{'message': 'Order detail not found'},
status=status.HTTP_404_NOT_FOUND,
)
product_name = order_detail.product.product_name
logger.info(f"Verifying + classifying image for OrderDetail {order_detail_id}, product: {product_name}")
# Step 1: Verify uploaded image matches the product
product_matches = verify_product_name_matches_image(image_file, product_name)
if not product_matches:
logger.warning(f"Product mismatch for OrderDetail {order_detail_id}: image does not match '{product_name}'")
return Response(
{
'status': 'mismatch',
'matches': False,
'message': f'The uploaded image does not match the product "{product_name}". Return/exchange cannot proceed.',
},
status=status.HTTP_400_BAD_REQUEST,
)
# Step 2: Classify product condition
image_file.seek(0)
classification = classify_product_image(image_file)
logger.info(f"Classification result: {classification}")
return Response({'status': classification, 'matches': True})
except ValueError as e:
logger.error(f"Validation error during classification: {str(e)}")
return Response(
{'message': 'Classification service error', 'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except Exception as e:
logger.error(f"Unexpected error during classification: {str(e)}", exc_info=True)
# Return safe default on error
return Response(
{'status': 'refurb', 'matches': True, 'message': 'Classification completed with fallback'},
status=status.HTTP_200_OK
)
class Inference2APIView(APIView):
"""
Product return image verification endpoint
Verifies that the uploaded product image matches the product being returned
and classifies its condition
"""
permission_classes = [AllowAny]
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
"""
Expects:
{
"image": <file>,
"order_detail_id": <int>
}
"""
if 'image' not in request.data:
return Response(
{'message': 'No image provided'},
status=status.HTTP_400_BAD_REQUEST
)
order_detail_id = request.data.get('order_detail_id')
if not order_detail_id:
return Response(
{'message': 'order_detail_id is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
# Fetch the OrderDetail to get product info
order_detail = OrderDetail.objects.get(id=order_detail_id)
product_name = order_detail.product.product_name
image_file = request.data['image']
logger.info(f"Verifying product image for OrderDetail {order_detail_id}, product: {product_name}")
# Step 1: Verify product name matches image
product_matches = verify_product_name_matches_image(image_file, product_name)
if not product_matches:
logger.warning(f"Product name verification failed for OrderDetail {order_detail_id}")
return Response(
{
'message': f'The uploaded image does not match the product "{product_name}". Please upload a correct product image.',
'matches': False
},
status=status.HTTP_400_BAD_REQUEST
)
# Step 2: Classify product condition
image_file.seek(0) # Reset file pointer
classification = classify_product_image(image_file)
logger.info(f"Product verification successful. Classification: {classification}")
return Response(
{
'message': 'Product image verified successfully',
'matches': True,
'status': classification, # 'resale', 'refurb', or 'scrap'
'product_name': product_name
},
status=status.HTTP_200_OK
)
except OrderDetail.DoesNotExist:
logger.error(f"OrderDetail {order_detail_id} not found")
return Response(
{'message': 'Order detail not found'},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
logger.error(f"Error in Inference2APIView: {str(e)}", exc_info=True)
return Response(
{'message': 'Error processing image verification'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class ReturnPickupResponse(BaseModel):
"""Pydantic model enforcing the exact shape of the return-pickup response."""
message: str
inference_status: Literal["resale", "refurb", "scrap"]
product_match: bool = Field(
description="True if the uploaded image matches the product name. "
"Defaults to True when the LLM is unreachable."
)
return_log_id: Optional[int] = None
pickup_date: Optional[str] = None
pickup_time: Optional[str] = None
refund_amount: Optional[str] = None
refund_percentage: Optional[str] = None
express_delivery: bool = False
express_delivery_fee: Optional[str] = None
original_refund_amount: Optional[str] = None
class ProcessReturnPickupAPIView(APIView):
"""
Handles pickup scheduling for return items.
Flow:
1. Verify product image matches product name via Gemini Vision.
- If LLM is unreachable ➜ product_match defaults to True (lenient).
- If image does NOT match ➜ inference_status forced to "scrap", product_match=False.
2. Classify product condition (resale / refurb / scrap).
3. Validate pickup date/time for refurb & resale.
4. Calculate refund amount via ML model.
5. Create ReturnLog and return a ReturnPickupResponse.
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser, MultiPartParser, FormParser]
def post(self, request):
try:
order_detail_id = request.data.get('order_detail_id')
pickup_date = request.data.get('pickup_date')
pickup_time = request.data.get('pickup_time')
image_file = request.data.get('image') # optional uploaded image
express_delivery = str(request.data.get('express_delivery', 'false')).lower() in ('true', '1', 'yes')
if not order_detail_id:
return Response(
{'message': 'order_detail_id is required'},
status=status.HTTP_400_BAD_REQUEST,
)
# Fetch OrderDetail
order_detail = OrderDetail.objects.get(id=order_detail_id)
# Verify user owns the order
if order_detail.order.user != request.user:
return Response(
{'message': 'Unauthorized'},
status=status.HTTP_403_FORBIDDEN,
)
product_name = order_detail.product.product_name
product_match = True # default (lenient when LLM is unreachable)
inference_status: Literal["resale", "refurb", "scrap"] = "refurb"
# ── Step 1 & 2: Image verification + classification ───────────
if image_file:
# Step 1: Verify product name matches image
try:
product_match = verify_product_name_matches_image(
image_file, product_name
)
logger.info(
f"Product match for OrderDetail {order_detail_id}: "
f"{product_match} (product: {product_name})"
)
except Exception as e:
# LLM unreachable → keep product_match=True (lenient)
logger.error(
f"Product verification failed (defaulting to True): {e}"
)
product_match = True
if not product_match:
# Image does NOT match → force scrap, skip classification
inference_status = "scrap"
logger.warning(
f"Image mismatch for OrderDetail {order_detail_id} — "
f"forcing scrap"
)
else:
# Step 2: Classify condition
try:
image_file.seek(0)
inference_status = classify_product_image(image_file)
except Exception as e:
logger.error(f"Classification failed: {e}")
inference_status = "refurb"
else:
# If the caller already passed inference_status (legacy flow)
raw_status = request.data.get('inference_status')
if raw_status in ('resale', 'refurb', 'scrap'):
inference_status = raw_status
else:
return Response(
{'message': 'Either image or inference_status is required'},
status=status.HTTP_400_BAD_REQUEST,
)
# ── Step 3: Validate pickup date/time ─────────────────────────
if inference_status == 'scrap':
pickup_date_obj = None
pickup_time_obj = None
else:
if not pickup_date or not pickup_time:
return Response(
{'message': 'pickup_date and pickup_time are required for refurb/resale'},
status=status.HTTP_400_BAD_REQUEST,
)
from datetime import datetime
pickup_date_obj = datetime.strptime(pickup_date, '%Y-%m-%d').date()
today = timezone.now().date()
delta = (pickup_date_obj - today).days
if delta < 0 or delta > 7:
return Response(
{'message': 'Pickup date must be within the next 7 days'},
status=status.HTTP_400_BAD_REQUEST,
)
pickup_time_obj = datetime.strptime(pickup_time, '%H:%M').time()
if not (9 <= pickup_time_obj.hour < 21):
return Response(
{'message': 'Pickup time must be between 09:00 and 21:00'},
status=status.HTTP_400_BAD_REQUEST,
)
# ── Step 4: Calculate refund via ML model ─────────────────────
try:
refund_service = get_refund_model_service()
refund_amount, refund_percentage = refund_service.calculate_refund_amount(
order_detail
)
logger.info(
f"ML refund: {refund_percentage:.2f}% = ${refund_amount}"
)
except Exception as e:
logger.error(f"ML refund error: {e}", exc_info=True)
refund_amount = order_detail.product_price * order_detail.order_quantity
refund_percentage = 100.0
logger.warning(f"Fallback refund: 100% = ${refund_amount}")
# ── Step 5: Apply express delivery deduction (7%) ─────────
original_refund_amount = refund_amount
express_delivery_fee = None
if express_delivery:
from decimal import Decimal, ROUND_HALF_UP
fee = (Decimal(str(refund_amount)) * Decimal('0.07')).quantize(
Decimal('0.01'), rounding=ROUND_HALF_UP
)
refund_amount = float(Decimal(str(refund_amount)) - fee)
express_delivery_fee = float(fee)
logger.info(
f"Express delivery: fee ₹{express_delivery_fee}, "
f"refund ₹{original_refund_amount} → ₹{refund_amount}"
)
# ── Step 6: Create ReturnLog ──────────────────────────────────
return_log = ReturnLog.objects.create(
order=order_detail.order,
refund_id=f"RET-{order_detail.order.id}-{order_detail.id}",
pickup_date=pickup_date_obj,
pickup_time=pickup_time_obj,
refund_amount=refund_amount,
)
logger.info(
f"ReturnLog {return_log.return_id} created for "
f"OrderDetail {order_detail_id}"
)
# ── Build Pydantic response ───────────────────────────────────
resp = ReturnPickupResponse(
message=(
'Return processed successfully'
if product_match
else f'Image does not match product "{product_name}". Marked as scrap.'
),
inference_status=inference_status,
product_match=product_match,
return_log_id=return_log.return_id,
pickup_date=str(pickup_date_obj) if pickup_date_obj else None,
pickup_time=str(pickup_time_obj) if pickup_time_obj else None,
refund_amount=str(refund_amount),
refund_percentage=f"{refund_percentage:.2f}",
express_delivery=express_delivery,
express_delivery_fee=str(express_delivery_fee) if express_delivery_fee else None,
original_refund_amount=str(original_refund_amount) if express_delivery else None,
)
return Response(resp.model_dump(), status=status.HTTP_201_CREATED)
except OrderDetail.DoesNotExist:
logger.error(f"OrderDetail {order_detail_id} not found")
return Response(
{'message': 'Order detail not found'},
status=status.HTTP_404_NOT_FOUND,
)
except ValueError as e:
logger.error(f"Validation error: {e}")
return Response(
{'message': f'Invalid input: {e}'},
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as e:
logger.error(f"Error in ProcessReturnPickupAPIView: {e}", exc_info=True)
return Response(
{'message': 'Error processing return pickup'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
class ProcessExchangePickupAPIView(APIView):
"""
Handles pickup scheduling for exchange/return items based on inference status
"""
permission_classes = [IsAuthenticated]
def post(self, request):
"""
Expects:
{
"order_detail_id": <int>,
"inference_status": "scrap" | "refurb" | "resale",
"pickup_date": "YYYY-MM-DD" (required for refurb/resale, null for scrap),
"pickup_time": "HH:MM" (required for refurb/resale, null for scrap)
}
"""
try:
order_detail_id = request.data.get('order_detail_id')
inference_status = request.data.get('inference_status')
pickup_date = request.data.get('pickup_date')
pickup_time = request.data.get('pickup_time')
# Get the order detail
try:
order_detail = OrderDetail.objects.get(id=order_detail_id)
except OrderDetail.DoesNotExist:
return Response(
{'message': 'Order detail not found'},
status=status.HTTP_404_NOT_FOUND
)
# Verify user owns this order
if order_detail.order.user != request.user:
return Response(
{'message': 'Unauthorized'},
status=status.HTTP_403_FORBIDDEN
)
# Handle based on inference status
if inference_status == 'scrap':
# Create exchange order (same product)
product = order_detail.product
exchange_order = Order.objects.create(
user=request.user,
shipping_address=order_detail.order.shipping_address,
payment_method=order_detail.order.payment_method,
shipping_method=order_detail.order.shipping_method,
)
# Create new order detail
OrderDetail.objects.create(
order=exchange_order,
product=product,
order_quantity=order_detail.order_quantity,
product_price=product.base_price,
)
# Update original order detail
order_detail.is_exchanged = True
order_detail.exchange_order = exchange_order
order_detail.return_status = 'Returned'
order_detail.save()
# Log exchange with no pickup date/time
ExchangeLog.objects.create(
order=exchange_order,
pickup_date=None,
pickup_time=None,
inference_status='scrap'
)
return Response({
'message': 'The order will not be picked up, but we will issue an exchange',
'exchange_order_id': exchange_order.id,
'status': 'scrap'
})
elif inference_status in ['refurb', 'resale']:
# Validate pickup date and time
if not pickup_date or not pickup_time:
return Response(
{'message': 'Pickup date and time are required for refurb/resale'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate pickup date is within 7 days from now
from datetime import datetime
try:
pickup_dt = datetime.strptime(pickup_date, '%Y-%m-%d').date()
now_date = timezone.now().date()
max_date = now_date + timedelta(days=7)
if pickup_dt < now_date or pickup_dt > max_date:
return Response(
{'message': 'Pickup date must be within the next 7 days'},
status=status.HTTP_400_BAD_REQUEST
)
except ValueError:
return Response(
{'message': 'Invalid pickup date format'},
status=status.HTTP_400_BAD_REQUEST
)
# Validate pickup time is within 9 AM to 9 PM
try:
pickup_time_obj = datetime.strptime(pickup_time, '%H:%M').time()
from datetime import time
if not (time(9, 0) <= pickup_time_obj <= time(21, 0)):
return Response(
{'message': 'Pickup time must be between 9 AM and 9 PM'},
status=status.HTTP_400_BAD_REQUEST
)
except ValueError:
return Response(
{'message': 'Invalid pickup time format'},
status=status.HTTP_400_BAD_REQUEST
)
# Create exchange order
product = order_detail.product
exchange_order = Order.objects.create(
user=request.user,
shipping_address=order_detail.order.shipping_address,
payment_method=order_detail.order.payment_method,
shipping_method=order_detail.order.shipping_method,
)
# Create new order detail
OrderDetail.objects.create(
order=exchange_order,
product=product,
order_quantity=order_detail.order_quantity,
product_price=product.base_price,
)
# Update original order detail
order_detail.is_exchanged = True
order_detail.exchange_order = exchange_order
order_detail.return_status = 'Returned'
order_detail.save()
# Log exchange
ExchangeLog.objects.create(
order=exchange_order,
pickup_date=pickup_date,
pickup_time=pickup_time,
inference_status=inference_status
)
message = f'Item marked for {inference_status.lower()}: we will pick it up on {pickup_date} at {pickup_time} and send a replacement soon.'
return Response({
'message': message,
'exchange_order_id': exchange_order.id,
'status': inference_status
})
else:
return Response(
{'message': f'Unknown inference status: {inference_status}'},
status=status.HTTP_400_BAD_REQUEST
)
except Exception as e:
logger.error(f'Error processing exchange pickup: {str(e)}', exc_info=True)
return Response(
{'message': 'Error processing exchange', 'error': str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)