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')), + ], + ), + ]