Major refactor of commerce and Stripe integration

Refactored commerce models to support refunds, invoices, and improved carrier/payment logic. Added new serializers and viewsets for products, categories, images, discount codes, and refunds. Introduced Stripe client integration and removed legacy Stripe admin/model code. Updated Dockerfile for PDF generation dependencies. Removed obsolete migration files and updated configuration app initialization. Added invoice template and tasks for order cleanup.
This commit is contained in:
2025-11-18 01:00:03 +01:00
parent 7a715efeda
commit b8a1a594b2
35 changed files with 1215 additions and 332 deletions

View File

@@ -1,41 +0,0 @@
# Generated by Django 5.2.7 on 2025-10-28 22:28
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Carrier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('base_price', models.DecimalField(decimal_places=2, default=0, max_digits=10)),
('delivery_time', models.CharField(blank=True, max_length=100)),
('is_active', models.BooleanField(default=True)),
('logo', models.ImageField(blank=True, null=True, upload_to='carriers/')),
('external_id', models.CharField(blank=True, max_length=50, null=True)),
],
),
migrations.CreateModel(
name='Product',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200)),
('description', models.TextField(blank=True)),
('price', models.DecimalField(decimal_places=2, max_digits=10)),
('currency', models.CharField(default='czk', max_length=10)),
('stock', models.PositiveIntegerField(default=0)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('default_carrier', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='default_for_products', to='commerce.carrier')),
],
),
]

View File

@@ -1,12 +1,18 @@
from django.db import models
from django.conf import settings
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from decimal import Decimal
from django.template.loader import render_to_string
from django.core.files.base import ContentFile
from django.core.validators import MaxValueValidator, MinValueValidator
from weasyprint import HTML
import os
from configuration.models import ShopConfiguration
from thirdparty.zasilkovna.models import ZasilkovnaPacket
from thirdparty.stripe.models import StripePayment
from thirdparty.stripe.models import StripeModel
#FIXME: přidat soft delete pro všchny modely !!!!
@@ -40,6 +46,17 @@ class Product(models.Model):
code = models.CharField(max_length=100, unique=True, blank=True, null=True)
variants = models.ManyToManyField(
"self",
symmetrical=True,
blank=True,
related_name="variant_of",
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ě."
),
)
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT)
@@ -50,7 +67,7 @@ class Product(models.Model):
stock = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
#limitka (volitelné)
#časový limit (volitelné)
limited_to = models.DateTimeField(null=True, blank=True)
default_carrier = models.ForeignKey(
@@ -85,21 +102,22 @@ class ProductImage(models.Model):
class Order(models.Model):
class Status(models.TextChoices):
PENDING = "pending", _("Čeká na platbu")
PAID = "paid", _("Zaplaceno")
CANCELLED = "cancelled", _("Zrušeno")
SHIPPED = "shipped", _("Odesláno")
#COMPLETED = "completed", _("Dokončeno")
CREATED = "created", "Vytvořeno"
CANCELLED = "cancelled", "Zrušeno"
COMPLETED = "completed", "Dokončeno"
REFUNDING = "refunding", "Vrácení v procesu"
REFUNDED = "refunded", "Vráceno"
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.PENDING
max_length=20, choices=Status.choices, null=True, blank=True, default=Status.CREATED
)
# Stored order grand total; recalculated on save
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
currency = models.CharField(max_length=10, default="CZK")
# fakturační údaje (zkopírované z user profilu při objednávce) FIXME: rozhodnout se co dát do on_delete
# fakturační údaje (zkopírované z user profilu při objednávce)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
)
@@ -134,6 +152,8 @@ class Order(models.Model):
blank=True
)
invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True)
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
def calculate_total_price(self):
@@ -181,15 +201,24 @@ class Order(models.Model):
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
class Carrier(models.Model):
class SHIPPING(models.TextChoices):
ZASILKOVNA = "packeta", "Zásilkovna"
STORE = "store", "Osobní odběr"
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
class STATE(models.TextChoices):
PREPARING = "ordered", "Objednávka se připravuje"
SHIPPED = "shipped", "Odesláno"
DELIVERED = "delivered", "Doručeno"
READY_TO_PICKUP = "ready_to_pickup", "Připraveno k vyzvednutí"
#RETURNING = "returning", "Vracení objednávky"
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING)
# prodejce to přidá později
zasilkovna = models.ForeignKey(
ZasilkovnaPacket, on_delete=models.DO_NOTHING, null=True, blank=True, related_name="carriers"
zasilkovna = models.ManyToManyField(
ZasilkovnaPacket, blank=True, related_name="carriers"
)
weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg")
@@ -197,7 +226,6 @@ class Carrier(models.Model):
returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení")
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
def get_price(self):
@@ -210,18 +238,30 @@ class Carrier(models.Model):
#tohle bude vyvoláno pomocí admina přes api!!!
def start_ordering_shipping(self):
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
#už při vytvoření se volá na api Zásilkovny
self.zasilkovna = ZasilkovnaPacket.objects.create()
# Uživatel může objednat více zásilek pokud potřeba
self.zasilkovna.add(ZasilkovnaPacket.objects.create())
self.returning = False
self.save()
else:
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
#... další logika pro jiné způsoby dopravy
#TODO: přidat notifikace uživateli, jak pro zásilkovnu, tak pro vyzvednutí v obchodě!
def returning_shipping(self):
self.returning = True
def ready_to_pickup(self):
if self.shipping_method == self.SHIPPING.STORE:
self.state = self.STATE.READY_TO_PICKUP
self.save()
else:
raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.")
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
#volá se na api Zásilkovny
self.zasilkovna.returning_packet()
# def returning_shipping(self, int:id):
# self.returning = True
# if self.shipping_method == self.SHIPPING.ZASILKOVNA:
# #volá se na api Zásilkovny
# self.zasilkovna.get(id=id).returning_packet()
# ------------------ PLATEBNÍ MODELY ------------------
@@ -233,20 +273,33 @@ class Payment(models.Model):
CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
#active = models.BooleanField(default=True)
#FIXME: potvrdit že logika platby funguje správně
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.)
stripe = models.OneToOneField(
StripePayment, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
)
def save(self, *args, **kwargs):
order = Order.objects.get(payment=self)
if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE:
raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.")
if self.order:
order = Order.objects.get(payment=self)
#validace platebních metod
if self.payment_method == self.PAYMENT.SHOP and Carrier.objects.get(orders=order).shipping_method != Carrier.SHIPPING.STORE:
raise ValueError("Platba v obchodě je možná pouze pro osobní odběr.")
#validace dobírky (jestli není použitá pro osobní odběr)
elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY and Carrier.objects.get(orders=order).shipping_method == Carrier.SHIPPING.STORE:
raise ValueError("Dobírka není možná pro osobní odběr.")
#vytvoření platebních metod pokud nový objekt
if not self.pk:
if self.payment_method == self.PAYMENT.STRIPE:
self.stripe = StripePayment.objects.create(amount=order.total_price)
else:
self.stripe = None
super().save(*args, **kwargs)
@@ -257,7 +310,13 @@ class DiscountCode(models.Model):
description = models.CharField(max_length=255, blank=True)
# sleva v procentech (0100)
percent = models.PositiveIntegerField(max_value=100, help_text="Procento sleva 0-100", null=True, blank=True)
percent = models.PositiveIntegerField(
validators=[MinValueValidator(0), MaxValueValidator(100)],
help_text="Procento sleva 0-100",
null=True,
blank=True
)
# nebo fixní částka
amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK")
@@ -288,6 +347,8 @@ class DiscountCode(models.Model):
if self.usage_limit and self.used_count >= self.usage_limit:
return False
return True
def __str__(self):
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
@@ -369,8 +430,10 @@ class OrderItem(models.Model):
if applicable_amount_discounts:
if config.addition_of_coupons_amount:
total_amount = sum(applicable_amount_discounts)
else:
total_amount = max(applicable_amount_discounts)
final_price = final_price - total_amount
if final_price < Decimal('0'):
@@ -379,4 +442,87 @@ class OrderItem(models.Model):
return final_price.quantize(Decimal('0.01'))
def __str__(self):
return f"{self.product.name} x{self.quantity}"
return f"{self.product.name} x{self.quantity}"
def save(self, *args, **kwargs):
if self.pk is None:
if self.order.payment.payment_method:
raise ValueError("Nelze upravit položky z objednávky s již zvolenou platební metodou.")
else:
#nová položka objednávky, snížit skladové zásoby
if self.product.stock < self.quantity:
raise ValueError("Nedostatečný skladový zásob pro produkt.")
self.product.stock -= self.quantity
self.product.save(update_fields=["stock"])
super().save(*args, **kwargs)
class Refund(models.Model):
order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE)
class Reason(models.TextChoices):
RETUNING_PERIOD = "retuning_before_fourteen_day_period", "Vrácení před uplynutím 14-ti denní lhůty"
DAMAGED_PRODUCT = "damaged_product", "Poškozený produkt"
WRONG_ITEM = "wrong_item", "Špatná položka"
OTHER = "other", "Jiný důvod"
reason_choice = models.CharField(max_length=30, choices=Reason.choices)
reason_text = models.TextField(blank=True)
verified = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
#VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW)
# def save(self, *args, **kwargs):
# # Automaticky aktualizovat stav objednávky na "vráceno"
# if self.pk is None:
# self.order.status = Order.Status.REFUNDING
# self.order.save(update_fields=["status", "updated_at"])
# shipping_method = self.order.carrier.shipping_method
# if shipping_method == Carrier.SHIPPING.ZASILKOVNA:
# carrier = self.order.carrier;
# # poslední odeslána/vytvořená zásilka
# # Iniciovat vrácení přes Zásilkovnu
# carrier.zasilkovna.latest('created_at').returning_packet()
# carrier.save()
# else:
# # Logika pro jiné způsoby dopravy
# pass
# super().save(*args, **kwargs)
def refund_completed(self):
# Aktualizovat stav objednávky na "vráceno"
self.order.payment.stripe.refund()
self.order.status = Order.Status.REFUNDED
self.order.save(update_fields=["status", "updated_at"])
class Invoice(models.Model):
invoice_number = models.CharField(max_length=50, unique=True)
issued_at = models.DateTimeField(auto_now_add=True)
due_date = models.DateTimeField()
pdf_file = models.FileField(upload_to='invoices/')
def __str__(self):
return f"Invoice {self.invoice_number} for Order {self.order.id}"
def generate_invoice_pdf(self):
order = Order.objects.get(invoice=self)
# Render HTML
html_string = render_to_string("invoice/invoice.html", {"invoice": self})
pdf_bytes = HTML(string=html_string).write_pdf()
# Save directly to FileField
self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes))
self.save()

View File

@@ -1,4 +1,249 @@
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from .models import Category, Product, ProductImage, DiscountCode, Order, OrderItem, Carrier
from decimal import Decimal
from django.contrib.auth import get_user_model
from .models import (
Category,
Product,
ProductImage,
DiscountCode,
Refund,
Order,
OrderItem,
Carrier,
Payment,
)
User = get_user_model()
class UserBriefSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "first_name", "last_name", "email"]
class ProductBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ["id", "name", "price"]
class OrderItemReadSerializer(serializers.ModelSerializer):
product = ProductBriefSerializer(read_only=True)
total_price = serializers.SerializerMethodField()
def get_total_price(self, obj):
return obj.get_total_price(list(obj.order.discount.all()) if getattr(obj, "order", None) else None)
class Meta:
model = OrderItem
fields = ["id", "product", "quantity", "total_price"]
read_only_fields = ["id", "total_price"]
class CarrierReadSerializer(serializers.ModelSerializer):
class Meta:
model = Carrier
fields = ["id", "shipping_method", "weight", "returning"]
read_only_fields = ["id"]
class PaymentReadSerializer(serializers.Serializer):
payment_method = serializers.CharField(read_only=True)
stripe_id = serializers.IntegerField(source="stripe.id", read_only=True, allow_null=True)
class OrderReadSerializer(serializers.ModelSerializer):
items = OrderItemReadSerializer(many=True, read_only=True)
carrier = CarrierReadSerializer(read_only=True)
payment = PaymentReadSerializer(source="payment", read_only=True)
user = serializers.SerializerMethodField()
def get_user(self, obj):
request = self.context.get("request") if hasattr(self, "context") else None
if request and getattr(request, "user", None) and request.user.is_authenticated and obj.user:
return UserBriefSerializer(obj.user).data
return None
class Meta:
model = Order
fields = [
"id",
"status",
"total_price",
"currency",
"first_name",
"last_name",
"email",
"phone",
"address",
"city",
"postal_code",
"country",
"note",
"created_at",
"updated_at",
"carrier",
"payment",
"user",
"items",
]
read_only_fields = [
"id",
"status",
"total_price",
"currency",
"created_at",
"updated_at",
]
class OrderMiniSerializer(serializers.ModelSerializer):
amount = serializers.DecimalField(max_digits=10, decimal_places=2, source="total_price", read_only=True)
shipping_method = serializers.CharField(source="carrier.shipping_method", read_only=True)
class Meta:
model = Order
fields = ["id", "amount", "status", "email", "shipping_method"]
read_only_fields = fields
# ----------------- CREATE PAYLOAD SERIALIZERS (PUBLIC) -----------------
class OrderItemCreateSerializer(serializers.Serializer):
product_id = serializers.IntegerField(label="Product ID")
quantity = serializers.IntegerField(min_value=1, label="Quantity")
class CarrierCreateSerializer(serializers.Serializer):
shipping_method = serializers.ChoiceField(
choices=Carrier.SHIPPING.choices,
label="Shipping Method",
help_text="Choose 'store' (pickup) or 'packeta'",
)
weight = serializers.DecimalField(
required=False,
max_digits=10,
decimal_places=2,
label="Weight (kg)",
)
class PaymentCreateSerializer(serializers.Serializer):
payment_method = serializers.ChoiceField(
choices=Payment.PAYMENT.choices,
label="Payment Method",
help_text="Choose 'shop', 'stripe' or 'cash_on_delivery'",
)
class OrderCreateSerializer(serializers.Serializer):
# Customer/billing
first_name = serializers.CharField(max_length=100, label="First Name")
last_name = serializers.CharField(max_length=100, label="Last Name")
email = serializers.EmailField(label="Email")
phone = serializers.CharField(max_length=20, required=False, allow_blank=True, label="Phone")
address = serializers.CharField(max_length=255, label="Address")
city = serializers.CharField(max_length=100, label="City")
postal_code = serializers.CharField(max_length=20, label="Postal Code")
country = serializers.CharField(max_length=100, required=False, default="Czech Republic", label="Country")
note = serializers.CharField(required=False, allow_blank=True, label="Note")
# Nested
items = OrderItemCreateSerializer(many=True)
carrier = CarrierCreateSerializer()
payment = PaymentCreateSerializer()
discount_codes = serializers.ListField(
child=serializers.CharField(), required=False, allow_empty=True, label="Discount Codes"
)
def validate(self, attrs):
if not attrs.get("items"):
raise serializers.ValidationError({"items": "At least one item is required."})
return attrs
# ----------------- ADMIN/READ MODELS -----------------
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
fields = [
"id",
"name",
"url",
"parent",
"description",
"image",
]
class ProductImageSerializer(serializers.ModelSerializer):
class Meta:
model = ProductImage
fields = [
"id",
"product",
"image",
"alt_text",
"is_main",
]
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = [
"id",
"name",
"description",
"code",
"category",
"price",
"url",
"stock",
"is_active",
"limited_to",
"default_carrier",
"created_at",
"updated_at",
]
read_only_fields = ["created_at", "updated_at"]
class DiscountCodeSerializer(serializers.ModelSerializer):
class Meta:
model = DiscountCode
fields = [
"id",
"code",
"description",
"percent",
"amount",
"valid_from",
"valid_to",
"active",
"usage_limit",
"used_count",
"specific_products",
"specific_categories",
]
read_only_fields = ["used_count"]
class RefundSerializer(serializers.ModelSerializer):
class Meta:
model = Refund
fields = [
"id",
"order",
"reason_choice",
"reason_text",
"verified",
"created_at",
]
read_only_fields = ["id", "verified", "created_at"]

View File

@@ -0,0 +1,10 @@
from .models import Order
from django.utils import timezone
def delete_expired_orders():
expired_orders = Order.objects.filter(status=Order.STATUS_CHOICES.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24))
count = expired_orders.count()
expired_orders.delete()
return count

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<title>Faktura {{ invoice.invoice_number }}</title>
<style>
body { font-family: Arial, sans-serif; font-size: 14px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #000; padding: 8px; text-align: left; }
th { background-color: #eee; }
</style>
</head>
<body>
<h1>Faktura {{ invoice.invoice_number }}</h1>
<p>Datum vystavení: {{ invoice.issue_date.strftime("%Y-%m-%d") }}</p>
<p>Zákazník: {{ invoice.order.customer_name }}</p>
<table>
<thead>
<tr>
<th>Produkt</th>
<th>Množství</th>
<th>Cena</th>
<th>Celkem</th>
</tr>
</thead>
<tbody>
{% for item in invoice.order.items.all %}
<tr>
<td>{{ item.product.name }}</td>
<td>{{ item.quantity }}</td>
<td>{{ item.price }}</td>
<td>{{ item.price * item.quantity }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>Celkem k úhradě: {{ invoice.total_amount }} Kč</strong></p>
</body>
</html>

View File

@@ -1,15 +1,24 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import CategoryViewSet, ProductViewSet, DiscountCodeViewSet, OrderViewSet
from .views import (
OrderViewSet,
ProductViewSet,
CategoryViewSet,
ProductImageViewSet,
DiscountCodeViewSet,
RefundViewSet,
)
router = DefaultRouter()
router.register(r'categories', CategoryViewSet)
router.register(r'products', ProductViewSet)
router.register(r'discounts', DiscountCodeViewSet)
router.register(r'orders', OrderViewSet)
router.register(r'products', ProductViewSet, basename='product')
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')
urlpatterns = [
path('', include(router.urls)),
]
# NOTE: Carrier endpoints intentionally omitted (TODO)
# NOTE: Other endpoints (categories/products/discounts) can be added later

View File

@@ -1,4 +1,382 @@
from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from drf_spectacular.utils import extend_schema, extend_schema_view
from django.db import transaction
from rest_framework import viewsets, mixins, status
from rest_framework.permissions import AllowAny, IsAdminUser, SAFE_METHODS
from rest_framework.decorators import action
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiExample
from .models import (
Order,
OrderItem,
Carrier,
Payment,
Product,
DiscountCode,
Category,
ProductImage,
Refund,
)
from .serializers import (
OrderReadSerializer,
OrderMiniSerializer,
OrderCreateSerializer,
OrderItemReadSerializer,
CarrierReadSerializer,
PaymentReadSerializer,
ProductSerializer,
CategorySerializer,
ProductImageSerializer,
DiscountCodeSerializer,
RefundSerializer,
)
@extend_schema_view(
list=extend_schema(tags=["Orders"], summary="List Orders (public)"),
retrieve=extend_schema(tags=["Orders"], summary="Retrieve Order (public)"),
)
class OrderViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
queryset = Order.objects.select_related("carrier", "payment").prefetch_related(
"items__product", "discount"
).order_by("-created_at")
permission_classes = [AllowAny]
def get_serializer_class(self):
if self.action == "mini":
return OrderMiniSerializer
if self.action in ["list", "retrieve"]:
return OrderReadSerializer
if self.action == "create":
return OrderCreateSerializer
return OrderReadSerializer
@extend_schema(
tags=["Orders"],
summary="Create Order (public)",
request=OrderCreateSerializer,
responses={201: OrderReadSerializer},
examples=[
OpenApiExample(
"Create order",
value={
"first_name": "Jan",
"last_name": "Novak",
"email": "jan@example.com",
"phone": "+420123456789",
"address": "Ulice 1",
"city": "Praha",
"postal_code": "11000",
"country": "Czech Republic",
"note": "Prosím doručit odpoledne",
"items": [
{"product_id": 1, "quantity": 2},
{"product_id": 7, "quantity": 1},
],
"carrier": {"shipping_method": "store"},
"payment": {"payment_method": "stripe"},
"discount_codes": ["WELCOME10"],
},
)
],
)
def create(self, request, *args, **kwargs):
serializer = OrderCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
user = request.user
#VELMI DŮLEŽITELÉ: vše vytvořit v transakci, aby se nepřidávaly neúplné objednávky
with transaction.atomic():
# Create base order (customer details only for now)
order = Order.objects.create(
user=user if getattr(user, "is_authenticated", False) else None,
first_name=data["first_name"],
last_name=data["last_name"],
email=data["email"],
phone=data.get("phone", ""),
address=data["address"],
city=data["city"],
postal_code=data["postal_code"],
country=data.get("country", "Czech Republic"),
note=data.get("note", ""),
)
# Carrier
carrier_payload = data["carrier"]
carrier = Carrier.objects.create(
shipping_method=carrier_payload["shipping_method"],
weight=carrier_payload.get("weight"),
)
order.carrier = carrier
order.save(update_fields=["carrier", "updated_at"]) # recalc later after items
# Items
items_payload = data["items"]
order_items = []
for item in items_payload:
product = Product.objects.get(pk=item["product_id"]) # raises 404 if missing
qty = int(item["quantity"])
order_items.append(OrderItem(order=order, product=product, quantity=qty))
OrderItem.objects.bulk_create(order_items)
# Discount codes (optional)
codes = data.get("discount_codes") or []
if codes:
discounts = list(DiscountCode.objects.filter(code__in=codes))
order.discount.add(*discounts)
# Recalculate now that items/discounts/carrier are linked
order.save()
# Payment and validation
pay_payload = data["payment"]
payment_method = pay_payload["payment_method"]
# Validate combos (mirror of Payment.save but here we have order)
if payment_method == Payment.PAYMENT.SHOP and order.carrier.shipping_method != Carrier.SHIPPING.STORE:
return Response(
{"payment": "Platba v obchodě je možná pouze pro osobní odběr."},
status=status.HTTP_400_BAD_REQUEST,
)
if payment_method == Payment.PAYMENT.CASH_ON_DELIVERY and order.carrier.shipping_method == Carrier.SHIPPING.STORE:
return Response(
{"payment": "Dobírka není možná pro osobní odběr."},
status=status.HTTP_400_BAD_REQUEST,
)
# Create payment WITHOUT triggering Payment.save (which expects reverse link first)
payment = Payment(payment_method=payment_method)
# Bypass custom save by bulk_create
Payment.objects.bulk_create([payment])
order.payment = payment
order.save(update_fields=["payment", "updated_at"])
# If Stripe, create StripePayment now and attach
if payment_method == Payment.PAYMENT.STRIPE:
from thirdparty.stripe.models import StripePayment
stripe_obj = StripePayment.objects.create(amount=order.total_price)
payment.stripe = stripe_obj
payment.save(update_fields=["stripe"])
out = self.get_serializer(order)
return Response(out.data, status=status.HTTP_201_CREATED)
# -- List mini orders -- (public) --
@action(detail=False, methods=["get"], url_path="detail")
@extend_schema(
tags=["Orders"],
summary="List mini orders (public)",
responses={200: OrderMiniSerializer(many=True)},
)
def mini(self, request, *args, **kwargs):
qs = self.get_queryset()
page = self.paginate_queryset(qs)
if page is not None:
ser = OrderMiniSerializer(page, many=True)
return self.get_paginated_response(ser.data)
ser = OrderMiniSerializer(qs, many=True)
return Response(ser.data)
# -- Get order items -- (public) --
@action(detail=True, methods=["get"], url_path="items")
@extend_schema(
tags=["Orders"],
summary="List order items (public)",
responses={200: OrderItemReadSerializer(many=True)},
)
def items(self, request, pk=None):
order = self.get_object()
qs = order.items.select_related("product").all()
ser = OrderItemReadSerializer(qs, many=True)
return Response(ser.data)
# -- Get order carrier -- (public) --
@action(detail=True, methods=["get"], url_path="carrier")
@extend_schema(
tags=["Orders"],
summary="Get order carrier (public)",
responses={200: CarrierReadSerializer},
)
def carrier_detail(self, request, pk=None):
order = self.get_object()
ser = CarrierReadSerializer(order.carrier)
return Response(ser.data)
# -- Get order payment -- (public) --
@action(detail=True, methods=["get"], url_path="payment")
@extend_schema(
tags=["Orders"],
summary="Get order payment (public)",
responses={200: PaymentReadSerializer},
)
def payment_detail(self, request, pk=None):
order = self.get_object()
ser = PaymentReadSerializer(order.payment)
return Response(ser.data)
# -- Mark carrier ready to pickup(store) (admin) --
@action(
detail=True,
methods=["patch"],
url_path="carrier/ready-to-pickup",
permission_classes=[IsAdminUser],
)
@extend_schema(
tags=["Orders"],
summary="Mark carrier ready to pickup (admin)",
request=None,
responses={200: CarrierReadSerializer},
)
def carrier_ready_to_pickup(self, request, pk=None):
order = self.get_object()
if not order.carrier:
return Response({"detail": "Carrier not set."}, status=400)
order.carrier.ready_to_pickup()
order.carrier.refresh_from_db()
ser = CarrierReadSerializer(order.carrier)
return Response(ser.data)
# -- Start ordering shipping (admin) --
@action(
detail=True,
methods=["patch"],
url_path="carrier/start-ordering-shipping",
permission_classes=[IsAdminUser],
)
@extend_schema(
tags=["Orders"],
summary="Start ordering shipping (admin)",
request=None,
responses={200: CarrierReadSerializer},
)
def carrier_start_ordering_shipping(self, request, pk=None):
order = self.get_object()
if not order.carrier:
return Response({"detail": "Carrier not set."}, status=400)
order.carrier.start_ordering_shipping()
order.carrier.refresh_from_db()
ser = CarrierReadSerializer(order.carrier)
return Response(ser.data)
# -- Invoice PDF for Order --
class OrderInvoice(viewsets.ViewSet):
@action(detail=True, methods=["get"], url_path="generate-invoice")
@extend_schema(
tags=["Orders"],
summary="Get Invoice PDF for Order (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)
return Response(order.invoice.pdf_file, content_type='application/pdf')
# ---------- Permissions helpers ----------
class AdminWriteOnlyOrReadOnly(AllowAny.__class__):
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return True
return IsAdminUser().has_permission(request, view)
class AdminOnlyForPatchOtherwisePublic(AllowAny.__class__):
def has_permission(self, request, view):
if request.method in SAFE_METHODS or request.method == "POST":
return True
if request.method == "PATCH":
return IsAdminUser().has_permission(request, view)
# default to admin for other unsafe
return IsAdminUser().has_permission(request, view)
# ---------- Public/admin viewsets ----------
# -- Product --
@extend_schema_view(
list=extend_schema(tags=["Products"], summary="List products (public)"),
retrieve=extend_schema(tags=["Products"], summary="Retrieve product (public)"),
create=extend_schema(tags=["Products"], summary="Create product (admin)"),
partial_update=extend_schema(tags=["Products"], summary="Update product (admin)"),
update=extend_schema(tags=["Products"], summary="Replace product (admin)"),
destroy=extend_schema(tags=["Products"], summary="Delete product (admin)"),
)
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all().order_by("-created_at")
serializer_class = ProductSerializer
permission_classes = [AdminWriteOnlyOrReadOnly]
# -- Category --
@extend_schema_view(
list=extend_schema(tags=["Categories"], summary="List categories (public)"),
retrieve=extend_schema(tags=["Categories"], summary="Retrieve category (public)"),
create=extend_schema(tags=["Categories"], summary="Create category (admin)"),
partial_update=extend_schema(tags=["Categories"], summary="Update category (admin)"),
update=extend_schema(tags=["Categories"], summary="Replace category (admin)"),
destroy=extend_schema(tags=["Categories"], summary="Delete category (admin)"),
)
class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all().order_by("name")
serializer_class = CategorySerializer
permission_classes = [AdminWriteOnlyOrReadOnly]
# -- Product Image --
@extend_schema_view(
list=extend_schema(tags=["Product Images"], summary="List product images (public)"),
retrieve=extend_schema(tags=["Product Images"], summary="Retrieve product image (public)"),
create=extend_schema(tags=["Product Images"], summary="Create product image (admin)"),
partial_update=extend_schema(tags=["Product Images"], summary="Update product image (admin)"),
update=extend_schema(tags=["Product Images"], summary="Replace product image (admin)"),
destroy=extend_schema(tags=["Product Images"], summary="Delete product image (admin)"),
)
class ProductImageViewSet(viewsets.ModelViewSet):
queryset = ProductImage.objects.all().order_by("-id")
serializer_class = ProductImageSerializer
permission_classes = [AdminWriteOnlyOrReadOnly]
# -- Discount Code --
@extend_schema_view(
list=extend_schema(tags=["Discount Codes"], summary="List discount codes (public)"),
retrieve=extend_schema(tags=["Discount Codes"], summary="Retrieve discount code (public)"),
create=extend_schema(tags=["Discount Codes"], summary="Create discount code (admin)"),
partial_update=extend_schema(tags=["Discount Codes"], summary="Update discount code (admin)"),
update=extend_schema(tags=["Discount Codes"], summary="Replace discount code (admin)"),
destroy=extend_schema(tags=["Discount Codes"], summary="Delete discount code (admin)"),
)
class DiscountCodeViewSet(viewsets.ModelViewSet):
queryset = DiscountCode.objects.all().order_by("-id")
serializer_class = DiscountCodeSerializer
permission_classes = [AdminWriteOnlyOrReadOnly]
# -- Refund --
@extend_schema_view(
list=extend_schema(tags=["Refunds"], summary="List refunds (public)"),
retrieve=extend_schema(tags=["Refunds"], summary="Retrieve refund (public)"),
create=extend_schema(tags=["Refunds"], summary="Create refund (public)"),
partial_update=extend_schema(tags=["Refunds"], summary="Update refund (admin)"),
)
class RefundViewSet(mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet):
queryset = Refund.objects.select_related("order").all().order_by("-created_at")
serializer_class = RefundSerializer
permission_classes = [AdminOnlyForPatchOtherwisePublic]