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.
This commit is contained in:
2026-01-17 02:38:02 +01:00
parent 98426f8b05
commit b279ac36d5
9 changed files with 753 additions and 21 deletions

View File

@@ -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",)

View File

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

View File

@@ -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}"
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)

View File

@@ -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__"
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()

View File

@@ -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

View File

@@ -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<product_id>[^/.]+)')
@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<item_id>[^/.]+)')
@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<item_id>[^/.]+)')
@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)

View File

@@ -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),
),
]

View File

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

View File

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