Add shopping cart and product review features
Introduces Cart and CartItem models, admin, serializers, and API endpoints for shopping cart management for both authenticated and anonymous users. Adds Review model, serializers, and endpoints for product reviews, including public creation and retrieval. Updates ProductImage ordering, enhances order save logic with notification, and improves product and order endpoints with new actions and filters. Includes related migrations for commerce, configuration, social chat, and Deutsche Post integration.
This commit is contained in:
@@ -15,6 +15,7 @@ from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExample
|
||||
from rest_framework import filters, permissions
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
from .models import (
|
||||
Order,
|
||||
@@ -26,6 +27,8 @@ from .models import (
|
||||
Category,
|
||||
ProductImage,
|
||||
Refund,
|
||||
Cart,
|
||||
CartItem,
|
||||
)
|
||||
from .serializers import (
|
||||
OrderReadSerializer,
|
||||
@@ -40,6 +43,9 @@ from .serializers import (
|
||||
ProductImageSerializer,
|
||||
DiscountCodeSerializer,
|
||||
RefundSerializer,
|
||||
CartSerializer,
|
||||
CartItemSerializer,
|
||||
CartItemCreateSerializer,
|
||||
)
|
||||
|
||||
#FIXME: uravit view na nový order serializer
|
||||
@@ -197,23 +203,108 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
|
||||
ser = CarrierReadSerializer(order.carrier)
|
||||
return Response(ser.data)
|
||||
|
||||
# -- Get user's own orders (authenticated) --
|
||||
@action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated])
|
||||
@extend_schema(
|
||||
tags=["commerce"],
|
||||
summary="Get authenticated user's orders",
|
||||
responses={200: OrderMiniSerializer(many=True)},
|
||||
)
|
||||
def my_orders(self, request):
|
||||
orders = Order.objects.filter(user=request.user).order_by('-created_at')
|
||||
page = self.paginate_queryset(orders)
|
||||
if page:
|
||||
serializer = OrderMiniSerializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
serializer = OrderMiniSerializer(orders, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
# -- Invoice PDF for Order --
|
||||
class OrderInvoice(viewsets.ViewSet):
|
||||
@action(detail=True, methods=["get"], url_path="generate-invoice")
|
||||
# -- Cancel order (authenticated) --
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.IsAuthenticated])
|
||||
@extend_schema(
|
||||
tags=["commerce"],
|
||||
summary="Cancel order (authenticated user)",
|
||||
responses={200: {"type": "object", "properties": {"status": {"type": "string"}}}},
|
||||
)
|
||||
def cancel(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
|
||||
# Check if user owns order
|
||||
if order.user != request.user:
|
||||
return Response({'detail': 'Not authorized'}, status=403)
|
||||
|
||||
# Can only cancel if not shipped
|
||||
if order.carrier and order.carrier.state != Carrier.STATE.PREPARING:
|
||||
return Response({'detail': 'Cannot cancel shipped order'}, status=400)
|
||||
|
||||
order.status = Order.OrderStatus.CANCELLED
|
||||
order.save()
|
||||
|
||||
# Restore stock
|
||||
for item in order.items.all():
|
||||
item.product.stock += item.quantity
|
||||
item.product.save()
|
||||
|
||||
return Response({'status': 'cancelled'})
|
||||
|
||||
# -- Verify payment (public) --
|
||||
@action(detail=True, methods=['post'], url_path='payment/verify')
|
||||
@extend_schema(
|
||||
tags=["commerce", "public"],
|
||||
summary="Get Invoice PDF for Order (public)",
|
||||
summary="Verify payment status (public)",
|
||||
responses={200: {"type": "object", "properties": {
|
||||
"order_id": {"type": "integer"},
|
||||
"payment_status": {"type": "string"}
|
||||
}}},
|
||||
)
|
||||
def verify_payment(self, request, pk=None):
|
||||
order = self.get_object()
|
||||
if order.payment and order.payment.payment_method == Payment.PAYMENT.STRIPE:
|
||||
if order.payment.stripe:
|
||||
return Response({
|
||||
'order_id': order.id,
|
||||
'payment_status': order.payment.stripe.status
|
||||
})
|
||||
return Response({
|
||||
'order_id': order.id,
|
||||
'payment_status': 'unknown'
|
||||
})
|
||||
|
||||
# -- Download invoice (public) --
|
||||
@action(detail=True, methods=['get'], url_path='invoice')
|
||||
@extend_schema(
|
||||
tags=["commerce", "public"],
|
||||
summary="Download invoice PDF (public)",
|
||||
responses={200: "PDF File"},
|
||||
)
|
||||
def get(order_id, request):
|
||||
try:
|
||||
order = Order.objects.get(pk=order_id)
|
||||
except Order.DoesNotExist:
|
||||
return Response({"detail": "Order not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
def download_invoice(self, request, pk=None):
|
||||
from django.http import FileResponse
|
||||
order = self.get_object()
|
||||
if not order.invoice or not order.invoice.pdf_file:
|
||||
return Response({'detail': 'Invoice not available'}, status=404)
|
||||
|
||||
return Response(order.invoice.pdf_file, content_type='application/pdf')
|
||||
|
||||
return FileResponse(
|
||||
order.invoice.pdf_file.open('rb'),
|
||||
content_type='application/pdf',
|
||||
as_attachment=True,
|
||||
filename=f'invoice_{order.invoice.invoice_number}.pdf'
|
||||
)
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""Override retrieve to filter by user if authenticated and not admin"""
|
||||
order = self.get_object()
|
||||
|
||||
# If user is authenticated and not admin, check if they own the order
|
||||
if request.user.is_authenticated and not request.user.is_staff:
|
||||
if order.user and order.user != request.user:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
# Also check by email for anonymous orders
|
||||
elif not order.user and order.email != request.user.email:
|
||||
return Response({'detail': 'Not found.'}, status=404)
|
||||
|
||||
serializer = self.get_serializer(order)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# ---------- Permissions helpers ----------
|
||||
|
||||
@@ -248,11 +339,55 @@ class ProductViewSet(viewsets.ModelViewSet):
|
||||
queryset = Product.objects.all()
|
||||
serializer_class = ProductSerializer
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ["name", "code"]
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_fields = ['category', 'is_active']
|
||||
search_fields = ["name", "code", "description"]
|
||||
ordering_fields = ["price", "name", "created_at"]
|
||||
ordering = ["price"]
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='availability')
|
||||
@extend_schema(
|
||||
tags=["commerce", "public"],
|
||||
summary="Check product availability (public)",
|
||||
responses={200: {"type": "object", "properties": {
|
||||
"product_id": {"type": "integer"},
|
||||
"available": {"type": "boolean"},
|
||||
"stock": {"type": "integer"},
|
||||
"is_active": {"type": "boolean"}
|
||||
}}},
|
||||
)
|
||||
def check_availability(self, request, pk=None):
|
||||
product = self.get_object()
|
||||
return Response({
|
||||
'product_id': product.id,
|
||||
'available': product.available,
|
||||
'stock': product.stock,
|
||||
'is_active': product.is_active
|
||||
})
|
||||
|
||||
@action(detail=True, methods=['get'], url_path='rating')
|
||||
@extend_schema(
|
||||
tags=["commerce", "public"],
|
||||
summary="Get product rating summary (public)",
|
||||
responses={200: {"type": "object", "properties": {
|
||||
"product_id": {"type": "integer"},
|
||||
"average_rating": {"type": "number"},
|
||||
"review_count": {"type": "integer"}
|
||||
}}},
|
||||
)
|
||||
def rating_summary(self, request, pk=None):
|
||||
from django.db.models import Avg, Count
|
||||
product = self.get_object()
|
||||
stats = product.reviews.aggregate(
|
||||
average=Avg('rating'),
|
||||
count=Count('id')
|
||||
)
|
||||
return Response({
|
||||
'product_id': product.id,
|
||||
'average_rating': round(stats['average'], 2) if stats['average'] else 0,
|
||||
'review_count': stats['count']
|
||||
})
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=["commerce", "public"], summary="List categories (public)"),
|
||||
@@ -417,13 +552,189 @@ class RefundPublicView(APIView):
|
||||
return Response(resp, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class ReviewPostPublicView(APIView):
|
||||
"""Public endpoint to create a review for a product."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@extend_schema(
|
||||
tags=["commerce", "public"],
|
||||
summary="Create a product review (public)",
|
||||
request=ReviewSerializerPublic,
|
||||
responses={201: ReviewSerializerPublic},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = ReviewSerializerPublic(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
review = serializer.save()
|
||||
out = ReviewSerializerPublic(review)
|
||||
return Response(out.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
# readonly public reviews, admin can patch
|
||||
class ReviewPublicViewSet(viewsets.ModelViewSet):
|
||||
queryset = Review.objects.all()
|
||||
serializer_class = ReviewSerializerPublic
|
||||
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||
permission_classes = [AdminOnlyForPatchOtherwisePublic]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||||
search_fields = ["product__name", "user__username", "comment"]
|
||||
ordering_fields = ["rating", "created_at"]
|
||||
ordering = ["-created_at"]
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='product/(?P<product_id>[^/.]+)')
|
||||
@extend_schema(
|
||||
tags=["commerce", "public"],
|
||||
summary="Get reviews for specific product (public)",
|
||||
responses={200: ReviewSerializerPublic(many=True)},
|
||||
)
|
||||
def by_product(self, request, product_id=None):
|
||||
reviews = self.get_queryset().filter(product_id=product_id)
|
||||
page = self.paginate_queryset(reviews)
|
||||
if page:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
serializer = self.get_serializer(reviews, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
# ---------- CART MANAGEMENT ----------
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=["commerce", "cart"], summary="Get current cart (public)"),
|
||||
retrieve=extend_schema(tags=["commerce", "cart"], summary="Get cart by ID (public)"),
|
||||
)
|
||||
class CartViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
Shopping cart management for authenticated and anonymous users.
|
||||
Anonymous users use session_key, authenticated users use their user account.
|
||||
"""
|
||||
queryset = Cart.objects.prefetch_related('items__product')
|
||||
serializer_class = CartSerializer
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get_or_create_cart(self, request):
|
||||
"""Get or create cart for current user/session"""
|
||||
if request.user.is_authenticated:
|
||||
cart, _ = Cart.objects.get_or_create(user=request.user)
|
||||
else:
|
||||
# Use session for anonymous users
|
||||
session_key = request.session.session_key
|
||||
if not session_key:
|
||||
request.session.create()
|
||||
session_key = request.session.session_key
|
||||
cart, _ = Cart.objects.get_or_create(session_key=session_key)
|
||||
return cart
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='current')
|
||||
@extend_schema(
|
||||
tags=["commerce", "cart"],
|
||||
summary="Get current user's cart",
|
||||
responses={200: CartSerializer},
|
||||
)
|
||||
def current(self, request):
|
||||
"""Get the current user's cart"""
|
||||
cart = self.get_or_create_cart(request)
|
||||
serializer = CartSerializer(cart)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='add')
|
||||
@extend_schema(
|
||||
tags=["commerce", "cart"],
|
||||
summary="Add item to cart",
|
||||
request=CartItemCreateSerializer,
|
||||
responses={200: CartSerializer},
|
||||
)
|
||||
def add_item(self, request):
|
||||
"""Add a product to the cart or update quantity if already exists"""
|
||||
serializer = CartItemCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
cart = self.get_or_create_cart(request)
|
||||
product_id = serializer.validated_data['product_id']
|
||||
quantity = serializer.validated_data['quantity']
|
||||
|
||||
product = Product.objects.get(pk=product_id)
|
||||
|
||||
# Check if item already in cart
|
||||
cart_item, created = CartItem.objects.get_or_create(
|
||||
cart=cart,
|
||||
product=product,
|
||||
defaults={'quantity': quantity}
|
||||
)
|
||||
|
||||
if not created:
|
||||
# Update quantity if item already exists
|
||||
cart_item.quantity += quantity
|
||||
cart_item.save()
|
||||
|
||||
cart.refresh_from_db()
|
||||
return Response(CartSerializer(cart).data)
|
||||
|
||||
@action(detail=False, methods=['patch'], url_path='items/(?P<item_id>[^/.]+)')
|
||||
@extend_schema(
|
||||
tags=["commerce", "cart"],
|
||||
summary="Update cart item quantity",
|
||||
request={"type": "object", "properties": {"quantity": {"type": "integer"}}},
|
||||
responses={200: CartSerializer},
|
||||
)
|
||||
def update_item(self, request, item_id=None):
|
||||
"""Update quantity of a cart item"""
|
||||
cart = self.get_or_create_cart(request)
|
||||
|
||||
try:
|
||||
cart_item = CartItem.objects.get(pk=item_id, cart=cart)
|
||||
except CartItem.DoesNotExist:
|
||||
return Response({'detail': 'Cart item not found'}, status=404)
|
||||
|
||||
quantity = request.data.get('quantity')
|
||||
if quantity is None:
|
||||
return Response({'detail': 'Quantity is required'}, status=400)
|
||||
|
||||
try:
|
||||
quantity = int(quantity)
|
||||
if quantity < 1:
|
||||
return Response({'detail': 'Quantity must be at least 1'}, status=400)
|
||||
except ValueError:
|
||||
return Response({'detail': 'Invalid quantity'}, status=400)
|
||||
|
||||
cart_item.quantity = quantity
|
||||
cart_item.save()
|
||||
|
||||
cart.refresh_from_db()
|
||||
return Response(CartSerializer(cart).data)
|
||||
|
||||
@action(detail=False, methods=['delete'], url_path='items/(?P<item_id>[^/.]+)')
|
||||
@extend_schema(
|
||||
tags=["commerce", "cart"],
|
||||
summary="Remove item from cart",
|
||||
responses={200: CartSerializer},
|
||||
)
|
||||
def remove_item(self, request, item_id=None):
|
||||
"""Remove an item from the cart"""
|
||||
cart = self.get_or_create_cart(request)
|
||||
|
||||
try:
|
||||
cart_item = CartItem.objects.get(pk=item_id, cart=cart)
|
||||
cart_item.delete()
|
||||
except CartItem.DoesNotExist:
|
||||
return Response({'detail': 'Cart item not found'}, status=404)
|
||||
|
||||
cart.refresh_from_db()
|
||||
return Response(CartSerializer(cart).data)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='clear')
|
||||
@extend_schema(
|
||||
tags=["commerce", "cart"],
|
||||
summary="Clear all items from cart",
|
||||
responses={200: CartSerializer},
|
||||
)
|
||||
def clear(self, request):
|
||||
"""Remove all items from the cart"""
|
||||
cart = self.get_or_create_cart(request)
|
||||
cart.items.all().delete()
|
||||
cart.refresh_from_db()
|
||||
return Response(CartSerializer(cart).data)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user