CartItemView was registered on both /cart/items/ and
/cart/items/{item_uuid}/, so swagger listed POST/PATCH/DELETE on both
paths even though 3 of those 6 combinations raised a TypeError at
runtime (missing/unexpected item_uuid). Split into CartItemView (POST,
list path) and CartItemDetailView (PATCH/DELETE, detail path).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
236 lines
9.2 KiB
Python
236 lines
9.2 KiB
Python
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework import mixins, status, viewsets
|
||
from rest_framework.decorators import action
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.views import APIView
|
||
|
||
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
|
||
|
||
from apps.core.permissions import IsStoreOwner
|
||
|
||
from . import services
|
||
from .models import Cart, CartItem, Notification, Order, OrderGroup
|
||
from .serializers import (
|
||
CartItemSerializer,
|
||
CartItemWriteSerializer,
|
||
CartSerializer,
|
||
CheckoutSerializer,
|
||
NotificationSerializer,
|
||
OrderCancelSerializer,
|
||
OrderGroupSerializer,
|
||
OrderSerializer,
|
||
OrderStatusActionSerializer,
|
||
)
|
||
|
||
|
||
class CartView(APIView):
|
||
"""The authenticated user's cart, grouped by store (C07)."""
|
||
|
||
schema_tags = ['Cart']
|
||
serializer_class = CartSerializer
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {
|
||
"GET": [[]],
|
||
}
|
||
|
||
def get(self, request):
|
||
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||
return Response(CartSerializer(cart, context={'request': request}).data)
|
||
|
||
@extend_schema(responses=None)
|
||
def delete(self, request):
|
||
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||
cart.items.all().delete()
|
||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
class CartItemView(APIView):
|
||
"""Add a product line to the authenticated user's cart."""
|
||
|
||
schema_tags = ['Cart']
|
||
serializer_class = CartItemWriteSerializer
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {
|
||
"POST": [[]],
|
||
}
|
||
|
||
@extend_schema(request=CartItemWriteSerializer, responses=CartItemSerializer)
|
||
def post(self, request):
|
||
serializer = CartItemWriteSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||
product = serializer.validated_data['product']
|
||
quantity = serializer.validated_data['quantity']
|
||
|
||
item, created = CartItem.objects.get_or_create(
|
||
cart=cart, product=product, defaults={'quantity': quantity},
|
||
)
|
||
if not created:
|
||
item.quantity += quantity
|
||
item.save(update_fields=['quantity', 'updated_at'])
|
||
|
||
return Response(CartItemSerializer(item).data, status=status.HTTP_201_CREATED)
|
||
|
||
|
||
class CartItemDetailView(APIView):
|
||
"""Update/remove a single product line in the authenticated user's cart."""
|
||
|
||
schema_tags = ['Cart']
|
||
serializer_class = CartItemWriteSerializer
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {}
|
||
|
||
@extend_schema(responses=CartItemSerializer)
|
||
def patch(self, request, item_uuid):
|
||
item = get_object_or_404(CartItem, uuid=item_uuid, cart__user=request.user)
|
||
quantity = request.data.get('quantity')
|
||
if quantity is None or int(quantity) < 1:
|
||
return Response({'quantity': 'مقدار باید حداقل ۱ باشد.'}, status=status.HTTP_400_BAD_REQUEST)
|
||
item.quantity = int(quantity)
|
||
item.save(update_fields=['quantity', 'updated_at'])
|
||
return Response(CartItemSerializer(item).data)
|
||
|
||
@extend_schema(responses=None)
|
||
def delete(self, request, item_uuid):
|
||
item = get_object_or_404(CartItem, uuid=item_uuid, cart__user=request.user)
|
||
item.delete()
|
||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||
|
||
|
||
class CheckoutView(APIView):
|
||
"""Splits the authenticated customer's multi-store cart into per-store orders (C08)."""
|
||
|
||
schema_tags = ['Checkout']
|
||
serializer_class = CheckoutSerializer
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {
|
||
"POST": [[]],
|
||
}
|
||
|
||
@extend_schema(request=CheckoutSerializer, responses=OrderGroupSerializer)
|
||
def post(self, request):
|
||
serializer = CheckoutSerializer(data=request.data, context={'request': request})
|
||
serializer.is_valid(raise_exception=True)
|
||
order_group = services.checkout(user=request.user, **serializer.validated_data)
|
||
data = OrderGroupSerializer(order_group).data
|
||
return Response(data, status=status.HTTP_201_CREATED)
|
||
|
||
|
||
class OrderGroupViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||
"""Customer order history — each group may contain orders from several stores."""
|
||
|
||
schema_tags = ['Orders']
|
||
serializer_class = OrderGroupSerializer
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {
|
||
"POST": [[]],
|
||
}
|
||
|
||
def get_queryset(self):
|
||
if getattr(self, 'swagger_fake_view', False):
|
||
return OrderGroup.objects.none()
|
||
return OrderGroup.objects.filter(user=self.request.user).prefetch_related('orders__items')
|
||
|
||
|
||
class OrderViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||
"""Customer-facing single-order tracking (C09) + cancel."""
|
||
|
||
schema_tags = ['Orders']
|
||
serializer_class = OrderSerializer
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {
|
||
"POST": [[]],
|
||
}
|
||
|
||
def get_queryset(self):
|
||
if getattr(self, 'swagger_fake_view', False):
|
||
return Order.objects.none()
|
||
return Order.objects.filter(customer=self.request.user).select_related('store')
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def cancel(self, request, pk=None):
|
||
order = self.get_object()
|
||
serializer = OrderCancelSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
services.cancel_order(order, changed_by=request.user, reason=serializer.validated_data['reason'])
|
||
return Response(OrderSerializer(order).data)
|
||
|
||
|
||
class SellerOrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||
"""Seller order management (S09 list w/ status tabs, S10 detail + stepper actions)."""
|
||
|
||
schema_tags = ['Seller · Orders']
|
||
serializer_class = OrderSerializer
|
||
# permission_classes = [IsAuthenticated, IsStoreOwner]
|
||
# TODO: IsStoreOwner
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {
|
||
"POST": [[]],
|
||
}
|
||
filterset_fields = ['status']
|
||
|
||
def get_queryset(self):
|
||
if getattr(self, 'swagger_fake_view', False):
|
||
return Order.objects.none()
|
||
return Order.objects.filter(store=self.request.user.store).select_related('store').prefetch_related('items')
|
||
|
||
def _apply_transition(self, request, to_status):
|
||
order = self.get_object()
|
||
serializer = OrderStatusActionSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
services.advance_status(order, to_status, changed_by=request.user, **serializer.validated_data)
|
||
return Response(OrderSerializer(order).data)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def confirm(self, request, pk=None):
|
||
return self._apply_transition(request, Order.Status.PREPARING)
|
||
|
||
@action(detail=True, methods=['post'], url_path='mark-ready')
|
||
def mark_ready(self, request, pk=None):
|
||
return self._apply_transition(request, Order.Status.READY_TO_SHIP)
|
||
|
||
@action(detail=True, methods=['post'], url_path='mark-shipped')
|
||
def mark_shipped(self, request, pk=None):
|
||
return self._apply_transition(request, Order.Status.HANDED_TO_COURIER)
|
||
|
||
@action(detail=True, methods=['post'], url_path='mark-delivered')
|
||
def mark_delivered(self, request, pk=None):
|
||
return self._apply_transition(request, Order.Status.DELIVERED)
|
||
|
||
@action(detail=True, methods=['post'])
|
||
def cancel(self, request, pk=None):
|
||
order = self.get_object()
|
||
serializer = OrderCancelSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
services.cancel_order(order, changed_by=request.user, reason=serializer.validated_data['reason'])
|
||
return Response(OrderSerializer(order).data)
|
||
|
||
|
||
class NotificationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||
"""Shared notification feed (S16 for sellers; same model serves the customer app)."""
|
||
|
||
schema_tags = ['Notifications']
|
||
serializer_class = NotificationSerializer
|
||
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
|
||
required_alternate_scopes = {
|
||
"POST": [[]],
|
||
}
|
||
|
||
def get_queryset(self):
|
||
if getattr(self, 'swagger_fake_view', False):
|
||
return Notification.objects.none()
|
||
return Notification.objects.filter(recipient=self.request.user)
|
||
|
||
@action(detail=True, methods=['post'], url_path='mark-read')
|
||
def mark_read(self, request, pk=None):
|
||
notification = self.get_object()
|
||
notification.is_read = True
|
||
notification.save(update_fields=['is_read', 'updated_at'])
|
||
return Response(NotificationSerializer(notification).data)
|
||
|
||
@action(detail=False, methods=['post'], url_path='mark-all-read')
|
||
def mark_all_read(self, request):
|
||
self.get_queryset().filter(is_read=False).update(is_read=True)
|
||
return Response(status=204)
|