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