From e78baf746ccb3d824c25bc8057ae4427a50f8c81 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Sat, 17 Jan 2026 18:04:27 +0100 Subject: [PATCH] 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. --- .vscode/settings.json | 3 +- backend/account/permissions.py | 20 +++ backend/commerce/admin.py | 14 +- backend/commerce/models.py | 59 ++++++- backend/commerce/serializers.py | 37 +++- backend/commerce/tasks.py | 2 +- backend/commerce/urls.py | 6 + backend/commerce/views.py | 221 +++++++++++++++++++++--- backend/thirdparty/zasilkovna/client.py | 3 + 9 files changed, 335 insertions(+), 30 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0d7a883..1c023c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "files.autoSave": "afterDelay", - "files.autoSaveDelay": 1000 + "files.autoSaveDelay": 1000, + "python.analysis.autoImportCompletions": true } \ No newline at end of file diff --git a/backend/account/permissions.py b/backend/account/permissions.py index 1ee5b7c..95a879f 100644 --- a/backend/account/permissions.py +++ b/backend/account/permissions.py @@ -55,3 +55,23 @@ class AdminOnly(BasePermission): def has_permission(self, request, view): return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin' + +# Commerce-specific permissions +class AdminWriteOnlyOrReadOnly(BasePermission): + """Allow read for anyone, write only for admins""" + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin' + + +class AdminOnlyForPatchOtherwisePublic(BasePermission): + """Allow GET/POST for anyone, PATCH/PUT/DELETE only for admins""" + def has_permission(self, request, view): + if request.method in SAFE_METHODS or request.method == "POST": + return True + if request.method in ["PATCH", "PUT", "DELETE"]: + return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin' + # Default to admin for other unsafe methods + return request.user and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin' + diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py index 2fd9788..18f3318 100644 --- a/backend/commerce/admin.py +++ b/backend/commerce/admin.py @@ -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" diff --git a/backend/commerce/models.py b/backend/commerce/models.py index a5b5f02..4f1e630 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -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) \ No newline at end of file + 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() \ No newline at end of file diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py index e924d09..3a0e37f 100644 --- a/backend/commerce/serializers.py +++ b/backend/commerce/serializers.py @@ -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() \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/commerce/tasks.py b/backend/commerce/tasks.py index 2393a0b..76094d3 100644 --- a/backend/commerce/tasks.py +++ b/backend/commerce/tasks.py @@ -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.") diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py index b1a6d07..6a8c2da 100644 --- a/backend/commerce/urls.py +++ b/backend/commerce/urls.py @@ -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)), diff --git a/backend/commerce/views.py b/backend/commerce/views.py index 8936618..df869c7 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -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[^/.]+)') + @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!"}) + \ No newline at end of file diff --git a/backend/thirdparty/zasilkovna/client.py b/backend/thirdparty/zasilkovna/client.py index cb2076a..76a3005 100644 --- a/backend/thirdparty/zasilkovna/client.py +++ b/backend/thirdparty/zasilkovna/client.py @@ -3,6 +3,8 @@ from zeep.exceptions import Fault from zeep.transports import Transport from zeep.cache import SqliteCache +from configuration.models import Configuration + import tempfile import base64 import logging @@ -15,6 +17,7 @@ logger = logging.getLogger(__name__) WSDL_URL = os.getenv("PACKETA_WSDL_URL", "https://www.zasilkovna.cz/api/soap.wsdl") PACKETA_API_PASSWORD = os.getenv("PACKETA_API_PASSWORD") +Configuration.g