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": , "order_detail_id": } """ 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": , "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 )