From b279ac36d5aecdf98df0e2b40fb54ff7fdfacee7 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Sat, 17 Jan 2026 02:38:02 +0100 Subject: [PATCH] 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. --- backend/commerce/admin.py | 25 +- ...e_options_carrier_deutschepost_and_more.py | 83 +++++ backend/commerce/models.py | 85 ++++- backend/commerce/serializers.py | 57 ++- backend/commerce/urls.py | 8 +- backend/commerce/views.py | 339 +++++++++++++++++- ...iguration_deutschepost_api_url_and_more.py | 38 ++ .../social/chat/migrations/0001_initial.py | 77 ++++ .../deutschepost/migrations/0001_initial.py | 62 ++++ 9 files changed, 753 insertions(+), 21 deletions(-) create mode 100644 backend/commerce/migrations/0002_alter_productimage_options_carrier_deutschepost_and_more.py create mode 100644 backend/configuration/migrations/0002_siteconfiguration_deutschepost_api_url_and_more.py create mode 100644 backend/social/chat/migrations/0001_initial.py create mode 100644 backend/thirdparty/deutschepost/migrations/0001_initial.py diff --git a/backend/commerce/admin.py b/backend/commerce/admin.py index df93c51..2fd9788 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 + Carrier, Payment, DiscountCode, Refund, Invoice, Cart, CartItem ) @@ -80,3 +80,26 @@ class InvoiceAdmin(admin.ModelAdmin): list_display = ("invoice_number", "issued_at", "due_date") search_fields = ("invoice_number",) readonly_fields = ("issued_at",) + + +class CartItemInline(admin.TabularInline): + model = CartItem + extra = 0 + readonly_fields = ("product", "quantity", "added_at") + + +@admin.register(Cart) +class CartAdmin(admin.ModelAdmin): + list_display = ("id", "user", "session_key", "created_at", "updated_at") + list_filter = ("created_at", "updated_at") + search_fields = ("user__email", "session_key") + readonly_fields = ("created_at", "updated_at") + inlines = [CartItemInline] + + +@admin.register(CartItem) +class CartItemAdmin(admin.ModelAdmin): + list_display = ("cart", "product", "quantity", "added_at") + list_filter = ("added_at",) + search_fields = ("cart__id", "product__name") + readonly_fields = ("added_at",) diff --git a/backend/commerce/migrations/0002_alter_productimage_options_carrier_deutschepost_and_more.py b/backend/commerce/migrations/0002_alter_productimage_options_carrier_deutschepost_and_more.py new file mode 100644 index 0000000..41016c5 --- /dev/null +++ b/backend/commerce/migrations/0002_alter_productimage_options_carrier_deutschepost_and_more.py @@ -0,0 +1,83 @@ +# Generated by Django 5.2.7 on 2026-01-17 01:37 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('commerce', '0001_initial'), + ('deutschepost', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='productimage', + options={'ordering': ['order', '-is_main', 'id']}, + ), + migrations.AddField( + model_name='carrier', + name='deutschepost', + field=models.ManyToManyField(blank=True, related_name='carriers', to='deutschepost.deutschepostorder'), + ), + migrations.AddField( + model_name='productimage', + name='order', + field=models.PositiveIntegerField(default=0, help_text='Display order (lower numbers first)'), + ), + migrations.AlterField( + model_name='carrier', + name='shipping_method', + field=models.CharField(choices=[('packeta', 'cz#Zásilkovna'), ('deutschepost', 'cz#Deutsche Post'), ('store', 'cz#Osobní odběr')], default='store', max_length=20), + ), + migrations.AlterField( + model_name='product', + name='variants', + field=models.ManyToManyField(blank=True, help_text='Symetrické varianty produktu: pokud přidáte variantu A → B, Django automaticky přidá i variantu B → A. Všechny varianty jsou rovnocenné a zobrazí se vzájemně.', to='commerce.product'), + ), + migrations.CreateModel( + name='Cart', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('session_key', models.CharField(blank=True, help_text='Session key for anonymous users', max_length=40, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cart', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Cart', + 'verbose_name_plural': 'Carts', + }, + ), + migrations.CreateModel( + name='Review', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rating', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)])), + ('comment', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='commerce.product')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CartItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.PositiveIntegerField(default=1)), + ('added_at', models.DateTimeField(auto_now_add=True)), + ('cart', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='commerce.cart')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='commerce.product')), + ], + options={ + 'verbose_name': 'Cart Item', + 'verbose_name_plural': 'Cart Items', + 'unique_together': {('cart', 'product')}, + }, + ), + ] diff --git a/backend/commerce/models.py b/backend/commerce/models.py index bd4e7fc..a5b5f02 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -94,6 +94,10 @@ class ProductImage(models.Model): alt_text = models.CharField(max_length=150, blank=True) is_main = models.BooleanField(default=False) + order = models.PositiveIntegerField(default=0, help_text="Display order (lower numbers first)") + + class Meta: + ordering = ['order', '-is_main', 'id'] def __str__(self): return f"{self.product.name} image" @@ -196,11 +200,18 @@ class Order(models.Model): # Keep total_price always in sync with items and discount self.total_price = self.calculate_total_price() - if self.user and self.pk is None: + is_new = self.pk is None + + if self.user and is_new: self.import_data_from_user() super().save(*args, **kwargs) + # Send email notification for new orders + if is_new and self.user: + from .tasks import notify_order_successfuly_created + notify_order_successfuly_created.delay(order=self, user=self.user) + # ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------ @@ -634,4 +645,74 @@ class Review(models.Model): updated_at = models.DateTimeField(auto_now=True) def __str__(self): - return f"Review for {self.product.name} by {self.user.username}" \ No newline at end of file + return f"Review for {self.product.name} by {self.user.username}" + + +# ------------------ SHOPPING CART ------------------ + +class Cart(models.Model): + """Shopping cart for both authenticated and anonymous users""" + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="cart" + ) + session_key = models.CharField( + max_length=40, + null=True, + blank=True, + help_text="Session key for anonymous users" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Cart" + verbose_name_plural = "Carts" + + def __str__(self): + if self.user: + return f"Cart for {self.user.email}" + return f"Anonymous cart ({self.session_key})" + + def get_total(self): + """Calculate total price of all items in cart""" + total = Decimal('0.0') + for item in self.items.all(): + total += item.get_subtotal() + return total + + def get_items_count(self): + """Get total number of items in cart""" + return sum(item.quantity for item in self.items.all()) + + +class CartItem(models.Model): + """Individual items in a shopping cart""" + cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE) + product = models.ForeignKey(Product, on_delete=models.CASCADE) + quantity = models.PositiveIntegerField(default=1) + added_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Cart Item" + verbose_name_plural = "Cart Items" + unique_together = ('cart', 'product') # Prevent duplicate products in same cart + + def __str__(self): + return f"{self.quantity}x {self.product.name} in cart" + + def get_subtotal(self): + """Calculate subtotal for this cart item""" + return self.product.price * self.quantity + + def clean(self): + """Validate that product has enough stock""" + if self.product.stock < self.quantity: + raise ValidationError(f"Not enough stock for {self.product.name}. Available: {self.product.stock}") + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) \ No newline at end of file diff --git a/backend/commerce/serializers.py b/backend/commerce/serializers.py index 68fa3f8..e924d09 100644 --- a/backend/commerce/serializers.py +++ b/backend/commerce/serializers.py @@ -85,6 +85,8 @@ from .models import ( OrderItem, Carrier, Payment, + Cart, + CartItem, ) from thirdparty.stripe.models import StripeModel @@ -466,8 +468,59 @@ class OrderReadSerializer(serializers.ModelSerializer): -class ReviewSerializerPublic(serializers.Serializer): +class ReviewSerializerPublic(serializers.ModelSerializer): class Meta: model = Review - fields = "__all__" \ No newline at end of file + fields = "__all__" + read_only_fields = ['user', 'created_at', 'updated_at'] + + +# ----------------- CART SERIALIZERS ----------------- + +class CartItemSerializer(serializers.ModelSerializer): + product_name = serializers.CharField(source='product.name', read_only=True) + product_price = serializers.DecimalField(source='product.price', max_digits=10, decimal_places=2, read_only=True) + subtotal = serializers.SerializerMethodField() + + class Meta: + model = CartItem + fields = ['id', 'product', 'product_name', 'product_price', 'quantity', 'subtotal', 'added_at'] + read_only_fields = ['id', 'added_at'] + + def get_subtotal(self, obj): + return obj.get_subtotal() + + def validate_quantity(self, value): + if value < 1: + raise serializers.ValidationError("Quantity must be at least 1") + return value + + +class CartItemCreateSerializer(serializers.Serializer): + product_id = serializers.IntegerField() + quantity = serializers.IntegerField(min_value=1, default=1) + + 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 + + +class CartSerializer(serializers.ModelSerializer): + items = CartItemSerializer(many=True, read_only=True) + total = serializers.SerializerMethodField() + items_count = serializers.SerializerMethodField() + + class Meta: + model = Cart + fields = ['id', 'user', 'items', 'total', 'items_count', 'created_at', 'updated_at'] + read_only_fields = ['id', 'user', 'created_at', 'updated_at'] + + def get_total(self, obj): + return obj.get_total() + + def get_items_count(self, obj): + return obj.get_items_count() \ No newline at end of file diff --git a/backend/commerce/urls.py b/backend/commerce/urls.py index 2289a24..b1a6d07 100644 --- a/backend/commerce/urls.py +++ b/backend/commerce/urls.py @@ -8,6 +8,9 @@ from .views import ( DiscountCodeViewSet, RefundViewSet, RefundPublicView, + ReviewPostPublicView, + ReviewPublicViewSet, + CartViewSet, ) router = DefaultRouter() @@ -17,10 +20,11 @@ router.register(r'categories', CategoryViewSet, basename='category') router.register(r'product-images', ProductImageViewSet, basename='product-image') 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') urlpatterns = [ path('', include(router.urls)), path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'), + path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'), ] - -# NOTE: Other endpoints (categories/products/discounts) can be added later diff --git a/backend/commerce/views.py b/backend/commerce/views.py index 59d9be2..8936618 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -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[^/.]+)') + @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[^/.]+)') + @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[^/.]+)') + @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) + \ No newline at end of file diff --git a/backend/configuration/migrations/0002_siteconfiguration_deutschepost_api_url_and_more.py b/backend/configuration/migrations/0002_siteconfiguration_deutschepost_api_url_and_more.py new file mode 100644 index 0000000..76d1c32 --- /dev/null +++ b/backend/configuration/migrations/0002_siteconfiguration_deutschepost_api_url_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.7 on 2026-01-17 01:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('configuration', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='siteconfiguration', + name='deutschepost_api_url', + field=models.URLField(default='https://gw.sandbox.deutschepost.com', help_text='Deutsche Post API URL (sandbox/production)', max_length=255), + ), + migrations.AddField( + model_name='siteconfiguration', + name='deutschepost_client_id', + field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client ID', max_length=255, null=True), + ), + migrations.AddField( + model_name='siteconfiguration', + name='deutschepost_client_secret', + field=models.CharField(blank=True, help_text='Deutsche Post OAuth Client Secret', max_length=255, null=True), + ), + migrations.AddField( + model_name='siteconfiguration', + name='deutschepost_customer_ekp', + field=models.CharField(blank=True, help_text='Deutsche Post Customer EKP number', max_length=20, null=True), + ), + migrations.AddField( + model_name='siteconfiguration', + name='deutschepost_shipping_price', + field=models.DecimalField(decimal_places=2, default=150, help_text='Default Deutsche Post shipping price', max_digits=10), + ), + ] diff --git a/backend/social/chat/migrations/0001_initial.py b/backend/social/chat/migrations/0001_initial.py new file mode 100644 index 0000000..54b4b7a --- /dev/null +++ b/backend/social/chat/migrations/0001_initial.py @@ -0,0 +1,77 @@ +# Generated by Django 5.2.7 on 2026-01-17 01:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Chat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('members', models.ManyToManyField(blank=True, related_name='chats', to=settings.AUTH_USER_MODEL)), + ('moderators', models.ManyToManyField(blank=True, related_name='moderated_chats', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_chats', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(blank=True)), + ('is_edited', models.BooleanField(default=False)), + ('edited_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')), + ('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replies', to='chat.message')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='MessageFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='chat_uploads/%Y/%m/%d/')), + ('media_type', models.CharField(choices=[('IMAGE', 'Image'), ('VIDEO', 'Video'), ('FILE', 'File')], default='FILE', max_length=20)), + ('uploaded_at', models.DateTimeField(auto_now_add=True)), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='chat.message')), + ], + ), + migrations.CreateModel( + name='MessageHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('old_content', models.TextField()), + ('archived_at', models.DateTimeField(auto_now_add=True)), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edit_history', to='chat.message')), + ], + options={ + 'ordering': ['-archived_at'], + }, + ), + migrations.CreateModel( + name='MessageReaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('emoji', models.CharField(max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reactions', to='chat.message')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('message', 'user')}, + }, + ), + ] diff --git a/backend/thirdparty/deutschepost/migrations/0001_initial.py b/backend/thirdparty/deutschepost/migrations/0001_initial.py new file mode 100644 index 0000000..d8a25e6 --- /dev/null +++ b/backend/thirdparty/deutschepost/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2.7 on 2026-01-17 01:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DeutschePostOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('state', models.CharField(choices=[('CREATED', 'cz#Vytvořeno'), ('FINALIZED', 'cz#Dokončeno'), ('SHIPPED', 'cz#Odesláno'), ('DELIVERED', 'cz#Doručeno'), ('CANCELLED', 'cz#Zrušeno'), ('ERROR', 'cz#Chyba')], default='CREATED', max_length=20)), + ('order_id', models.CharField(blank=True, help_text='Deutsche Post order ID from API', max_length=50, null=True)), + ('customer_ekp', models.CharField(blank=True, max_length=20, null=True)), + ('recipient_name', models.CharField(max_length=200)), + ('recipient_phone', models.CharField(blank=True, max_length=20)), + ('recipient_email', models.EmailField(blank=True, max_length=254)), + ('address_line1', models.CharField(max_length=255)), + ('address_line2', models.CharField(blank=True, max_length=255)), + ('address_line3', models.CharField(blank=True, max_length=255)), + ('city', models.CharField(max_length=100)), + ('address_state', models.CharField(blank=True, help_text='State/Province for shipping address', max_length=100)), + ('postal_code', models.CharField(max_length=20)), + ('destination_country', models.CharField(help_text='ISO 2-letter country code', max_length=2)), + ('product_type', models.CharField(default='GPT', help_text='Deutsche Post product type (GPT, GMP, etc.)', max_length=10)), + ('service_level', models.CharField(default='PRIORITY', help_text='PRIORITY, STANDARD', max_length=20)), + ('shipment_gross_weight', models.PositiveIntegerField(help_text='Weight in grams')), + ('shipment_amount', models.DecimalField(decimal_places=2, default=0, max_digits=10)), + ('shipment_currency', models.CharField(default='EUR', max_length=3)), + ('sender_tax_id', models.CharField(blank=True, help_text='IOSS number or sender tax ID', max_length=50)), + ('importer_tax_id', models.CharField(blank=True, help_text='IOSS number or importer tax ID', max_length=50)), + ('return_item_wanted', models.BooleanField(default=False)), + ('cust_ref', models.CharField(blank=True, help_text='Customer reference', max_length=100)), + ('awb_number', models.CharField(blank=True, help_text='Air Waybill number', max_length=50, null=True)), + ('barcode', models.CharField(blank=True, help_text='Item barcode', max_length=50, null=True)), + ('tracking_url', models.URLField(blank=True, null=True)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')), + ('last_error', models.TextField(blank=True, help_text='Last API error message')), + ], + ), + migrations.CreateModel( + name='DeutschePostBulkOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('CREATED', 'cz#Vytvořeno'), ('PROCESSING', 'cz#Zpracovává se'), ('COMPLETED', 'cz#Dokončeno'), ('ERROR', 'cz#Chyba')], default='CREATED', max_length=20)), + ('bulk_order_id', models.CharField(blank=True, help_text='Deutsche Post bulk order ID from API', max_length=50, null=True)), + ('bulk_order_type', models.CharField(default='MIXED_BAG', help_text='MIXED_BAG, etc.', max_length=20)), + ('description', models.CharField(blank=True, max_length=255)), + ('metadata', models.JSONField(blank=True, default=dict, help_text='Raw API response data')), + ('last_error', models.TextField(blank=True, help_text='Last API error message')), + ('deutschepost_orders', models.ManyToManyField(blank=True, related_name='bulk_orders', to='deutschepost.deutschepostorder')), + ], + ), + ]