Spaces:
Sleeping
Sleeping
| 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) | |
| 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 | |
| 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') | |
| 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) | |
| 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 | |
| ) |