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:
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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 (0–100)
|
||||
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()
|
||||
@@ -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"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
41
backend/commerce/templates/invoice/Order.html
Normal file
41
backend/commerce/templates/invoice/Order.html
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user