Add wishlist feature and admin/analytics endpoints

Introduces a Wishlist model with related serializers, admin, and API endpoints for users to manage favorite products. Adds admin endpoints for wishlist management and a placeholder AnalyticsViewSet for future business intelligence features. Refactors permissions for commerce views, updates product filtering and ordering, and improves carrier and payment logic. Also includes minor VSCode settings and Zasilkovna client import updates.
This commit is contained in:
2026-01-17 18:04:27 +01:00
parent b279ac36d5
commit e78baf746c
9 changed files with 335 additions and 30 deletions

View File

@@ -1,7 +1,7 @@
from django.contrib import admin
from .models import (
Category, Product, ProductImage, Order, OrderItem,
Carrier, Payment, DiscountCode, Refund, Invoice, Cart, CartItem
Carrier, Payment, DiscountCode, Refund, Invoice, Cart, CartItem, Wishlist
)
@@ -103,3 +103,15 @@ class CartItemAdmin(admin.ModelAdmin):
list_filter = ("added_at",)
search_fields = ("cart__id", "product__name")
readonly_fields = ("added_at",)
@admin.register(Wishlist)
class WishlistAdmin(admin.ModelAdmin):
list_display = ("user", "product_count", "created_at", "updated_at")
search_fields = ("user__email", "user__username")
readonly_fields = ("created_at", "updated_at")
filter_horizontal = ("products",)
def product_count(self, obj):
return obj.products.count()
product_count.short_description = "Products Count"

View File

@@ -252,6 +252,13 @@ class Carrier(models.Model):
if self.shipping_price is None:
self.shipping_price = self.get_price()
if self.pk:
if self.STATE == self.STATE.READY_TO_PICKUP and self.shipping_method == self.SHIPPING.STORE:
notify_Ready_to_pickup.delay(order=self.orders.first(), user=self.orders.first().user)
pass
else:
pass
super().save(*args, **kwargs)
def get_price(self):
@@ -318,10 +325,10 @@ class Carrier(models.Model):
class Payment(models.Model):
class PAYMENT(models.TextChoices):
Site = "Site", "cz#Platba v obchodě"
STRIPE = "stripe", "cz#Bankovní převod"
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.Site)
SITE = "Site", "cz#Platba v obchodě", "de#Bezahlung im Geschäft"
STRIPE = "stripe", "cz#Platební Brána", "de#Zahlungsgateway"
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka", "de#Nachnahme"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SITE)
#FIXME: potvrdit že logika platby funguje správně
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
@@ -715,4 +722,46 @@ class CartItem(models.Model):
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
super().save(*args, **kwargs)
# ------------------ WISHLIST ------------------
class Wishlist(models.Model):
"""User's wishlist for saving favorite products"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="wishlist"
)
products = models.ManyToManyField(
Product,
blank=True,
related_name="wishlisted_by",
help_text="Products saved by the user"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Wishlist"
verbose_name_plural = "Wishlists"
def __str__(self):
return f"Wishlist for {self.user.email}"
def add_product(self, product):
"""Add a product to wishlist"""
self.products.add(product)
def remove_product(self, product):
"""Remove a product from wishlist"""
self.products.remove(product)
def has_product(self, product):
"""Check if product is in wishlist"""
return self.products.filter(pk=product.pk).exists()
def get_products_count(self):
"""Get count of products in wishlist"""
return self.products.count()

View File

@@ -87,6 +87,7 @@ from .models import (
Payment,
Cart,
CartItem,
Wishlist,
)
from thirdparty.stripe.models import StripeModel
@@ -523,4 +524,38 @@ class CartSerializer(serializers.ModelSerializer):
return obj.get_total()
def get_items_count(self, obj):
return obj.get_items_count()
return obj.get_items_count()
# ----------------- WISHLIST SERIALIZERS -----------------
class ProductMiniForWishlistSerializer(serializers.ModelSerializer):
"""Minimal product info for wishlist display"""
class Meta:
model = Product
fields = ['id', 'name', 'price', 'is_active', 'stock']
class WishlistSerializer(serializers.ModelSerializer):
products = ProductMiniForWishlistSerializer(many=True, read_only=True)
products_count = serializers.SerializerMethodField()
class Meta:
model = Wishlist
fields = ['id', 'user', 'products', 'products_count', 'created_at', 'updated_at']
read_only_fields = ['id', 'user', 'created_at', 'updated_at']
def get_products_count(self, obj):
return obj.get_products_count()
class WishlistProductActionSerializer(serializers.Serializer):
"""For adding/removing products from wishlist"""
product_id = serializers.IntegerField()
def validate_product_id(self, value):
try:
Product.objects.get(pk=value, is_active=True)
except Product.DoesNotExist:
raise serializers.ValidationError("Product not found or inactive.")
return value

View File

@@ -107,7 +107,7 @@ def notify_about_missing_payment(order = None, user = None, **kwargs):
pass
@shared_task
def notify_refund_items_arrived(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")

View File

@@ -11,6 +11,9 @@ from .views import (
ReviewPostPublicView,
ReviewPublicViewSet,
CartViewSet,
WishlistViewSet,
AdminWishlistViewSet,
AnalyticsViewSet,
)
router = DefaultRouter()
@@ -22,6 +25,9 @@ router.register(r'discount-codes', DiscountCodeViewSet, basename='discount-code'
router.register(r'refunds', RefundViewSet, basename='refund')
router.register(r'reviews', ReviewPublicViewSet, basename='review')
router.register(r'cart', CartViewSet, basename='cart')
router.register(r'wishlist', WishlistViewSet, basename='wishlist')
router.register(r'admin/wishlists', AdminWishlistViewSet, basename='admin-wishlist')
router.register(r'analytics', AnalyticsViewSet, basename='analytics')
urlpatterns = [
path('', include(router.urls)),

View File

@@ -17,6 +17,8 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExam
from rest_framework import filters, permissions
from django_filters.rest_framework import DjangoFilterBackend
from account.permissions import AdminWriteOnlyOrReadOnly, AdminOnlyForPatchOtherwisePublic
from .models import (
Order,
OrderItem,
@@ -29,6 +31,7 @@ from .models import (
Refund,
Cart,
CartItem,
Wishlist,
)
from .serializers import (
OrderReadSerializer,
@@ -43,9 +46,12 @@ from .serializers import (
ProductImageSerializer,
DiscountCodeSerializer,
RefundSerializer,
CartSerializer,
CartItemSerializer,
CartItemCreateSerializer,
WishlistSerializer,
WishlistProductActionSerializer,
)
#FIXME: uravit view na nový order serializer
@@ -306,25 +312,6 @@ class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.Ge
return Response(serializer.data)
# ---------- Permissions helpers ----------
class AdminWriteOnlyOrReadOnly(AllowAny.__class__):
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return IsAdminUser().has_permission(request, view)
class AdminOnlyForPatchOtherwisePublic(AllowAny.__class__):
def has_permission(self, request, view):
if request.method in SAFE_METHODS or request.method == "POST":
return True
if request.method == "PATCH":
return IsAdminUser().has_permission(request, view)
# default to admin for other unsafe
return IsAdminUser().has_permission(request, view)
# ---------- Public/admin viewsets ----------
@extend_schema_view(
@@ -340,9 +327,9 @@ class ProductViewSet(viewsets.ModelViewSet):
serializer_class = ProductSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
filterset_fields = ['category', 'is_active']
filterset_fields = ['category', 'is_active', 'stock', 'price', 'created_at', 'updated_at', 'limited_to']
search_fields = ["name", "code", "description"]
ordering_fields = ["price", "name", "created_at"]
ordering_fields = ["price", "name", "created_at", "updated_at", "stock"]
ordering = ["price"]
@action(detail=True, methods=['get'], url_path='availability')
@@ -737,4 +724,196 @@ class CartViewSet(viewsets.GenericViewSet):
cart.refresh_from_db()
return Response(CartSerializer(cart).data)
# ---------- WISHLIST MANAGEMENT ----------
@extend_schema_view(
list=extend_schema(tags=["commerce", "wishlist"], summary="Get current user's wishlist (authenticated)"),
retrieve=extend_schema(tags=["commerce", "wishlist"], summary="Get wishlist by ID (authenticated)"),
)
class WishlistViewSet(viewsets.GenericViewSet):
"""
User wishlist management - users can only access their own wishlist
"""
queryset = Wishlist.objects.prefetch_related('products')
serializer_class = WishlistSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
"""Filter to current user's wishlist only"""
return self.queryset.filter(user=self.request.user)
def get_or_create_wishlist(self):
"""Get or create wishlist for current user"""
wishlist, _ = Wishlist.objects.get_or_create(user=self.request.user)
return wishlist
@action(detail=False, methods=['get'], url_path='current')
@extend_schema(
tags=["commerce", "wishlist"],
summary="Get current user's wishlist",
responses={200: WishlistSerializer},
)
def current(self, request):
"""Get the current user's wishlist"""
wishlist = self.get_or_create_wishlist()
serializer = WishlistSerializer(wishlist)
return Response(serializer.data)
@action(detail=False, methods=['post'], url_path='add')
@extend_schema(
tags=["commerce", "wishlist"],
summary="Add product to wishlist",
request=WishlistProductActionSerializer,
responses={200: WishlistSerializer},
)
def add_product(self, request):
"""Add a product to the user's wishlist"""
serializer = WishlistProductActionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
wishlist = self.get_or_create_wishlist()
product_id = serializer.validated_data['product_id']
product = Product.objects.get(pk=product_id)
wishlist.add_product(product)
wishlist.refresh_from_db()
return Response(WishlistSerializer(wishlist).data)
@action(detail=False, methods=['post'], url_path='remove')
@extend_schema(
tags=["commerce", "wishlist"],
summary="Remove product from wishlist",
request=WishlistProductActionSerializer,
responses={200: WishlistSerializer},
)
def remove_product(self, request):
"""Remove a product from the user's wishlist"""
serializer = WishlistProductActionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
wishlist = self.get_or_create_wishlist()
product_id = serializer.validated_data['product_id']
try:
product = Product.objects.get(pk=product_id)
wishlist.remove_product(product)
except Product.DoesNotExist:
return Response({'detail': 'Product not found'}, status=404)
wishlist.refresh_from_db()
return Response(WishlistSerializer(wishlist).data)
@action(detail=False, methods=['post'], url_path='clear')
@extend_schema(
tags=["commerce", "wishlist"],
summary="Clear all products from wishlist",
responses={200: WishlistSerializer},
)
def clear(self, request):
"""Remove all products from the user's wishlist"""
wishlist = self.get_or_create_wishlist()
wishlist.products.clear()
wishlist.refresh_from_db()
return Response(WishlistSerializer(wishlist).data)
@action(detail=False, methods=['get'], url_path='check/(?P<product_id>[^/.]+)')
@extend_schema(
tags=["commerce", "wishlist"],
summary="Check if product is in wishlist",
responses={200: {"type": "object", "properties": {"in_wishlist": {"type": "boolean"}}}},
)
def check_product(self, request, product_id=None):
"""Check if a product is in user's wishlist"""
wishlist = self.get_or_create_wishlist()
try:
product = Product.objects.get(pk=product_id)
in_wishlist = wishlist.has_product(product)
return Response({'in_wishlist': in_wishlist})
except Product.DoesNotExist:
return Response({'detail': 'Product not found'}, status=404)
@extend_schema_view(
list=extend_schema(tags=["commerce", "admin"], summary="List all wishlists (admin)"),
retrieve=extend_schema(tags=["commerce", "admin"], summary="Get wishlist by ID (admin)"),
update=extend_schema(tags=["commerce", "admin"], summary="Update wishlist (admin)"),
partial_update=extend_schema(tags=["commerce", "admin"], summary="Partially update wishlist (admin)"),
destroy=extend_schema(tags=["commerce", "admin"], summary="Delete wishlist (admin)"),
)
class AdminWishlistViewSet(viewsets.ModelViewSet):
"""
Admin-only wishlist management - can view and modify any user's wishlist
"""
queryset = Wishlist.objects.prefetch_related('products').select_related('user')
serializer_class = WishlistSerializer
permission_classes = [AdminOnlyForPatchOtherwisePublic]
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["user__email", "user__first_name", "user__last_name"]
ordering_fields = ["created_at", "updated_at"]
ordering = ["-updated_at"]
# ---------- ANALYTICS PLACEHOLDER ----------
# TODO: Implement comprehensive analytics system
# Ideas:
# - Sales analytics (daily/monthly/yearly revenue)
# - Product performance (most sold, most viewed, most wishlisted)
# - User behavior analytics (cart abandonment, conversion rates)
# - Inventory reports (low stock alerts, bestsellers)
# - Regional sales data
# - Seasonal trends analysis
# - Customer lifetime value
# - Refund/return analytics
@extend_schema_view(
list=extend_schema(tags=["commerce", "analytics"], summary="Analytics dashboard (admin)"),
)
class AnalyticsViewSet(viewsets.GenericViewSet):
"""
Analytics and reporting endpoints for business intelligence
TODO: Implement comprehensive analytics
"""
permission_classes = [permissions.IsAdminUser]
@action(detail=False, methods=['get'], url_path='sales-overview')
@extend_schema(
tags=["commerce", "analytics"],
summary="Sales overview (TODO)",
responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}},
)
def sales_overview(self, request):
"""TODO: Implement sales analytics"""
return Response({"message": "Sales analytics coming soon!"})
@action(detail=False, methods=['get'], url_path='product-performance')
@extend_schema(
tags=["commerce", "analytics"],
summary="Product performance analytics (TODO)",
responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}},
)
def product_performance(self, request):
"""TODO: Implement product performance analytics"""
return Response({"message": "Product analytics coming soon!"})
@action(detail=False, methods=['get'], url_path='inventory-report')
@extend_schema(
tags=["commerce", "analytics"],
summary="Inventory status report (TODO)",
responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}},
)
def inventory_report(self, request):
"""TODO: Implement inventory management features"""
# TODO Ideas:
# - Low stock alerts (products with stock < threshold)
# - Out of stock products list
# - Bestsellers vs slow movers
# - Stock value calculation
# - Automated reorder suggestions
# - Stock movement history
# - Bulk stock updates
# - Supplier management integration
return Response({"message": "Inventory analytics coming soon!"})