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:
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"files.autoSave": "afterDelay",
|
||||
"files.autoSaveDelay": 1000
|
||||
"files.autoSaveDelay": 1000,
|
||||
"python.analysis.autoImportCompletions": true
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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!"})
|
||||
|
||||
|
||||
3
backend/thirdparty/zasilkovna/client.py
vendored
3
backend/thirdparty/zasilkovna/client.py
vendored
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user