Refactor commerce models and add configuration app

Major refactor of commerce models: restructured Carrier, Payment, and DiscountCode models, improved order total calculation, and integrated Zasilkovna and Stripe logic. Added new configuration Django app for shop settings, updated Zasilkovna and Stripe models, and fixed Zasilkovna client WSDL URL. Removed unused serializers and views in commerce, and registered new apps in settings.
This commit is contained in:
2025-11-14 02:21:20 +01:00
parent 052f7ab533
commit f14c09bf7a
16 changed files with 249 additions and 275 deletions

View File

@@ -1,11 +1,14 @@
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.utils.translation import gettext_lazy as _
from decimal import Decimal
from thirdparty.zasilkovna.models import PacketaShipment
from configuration.models import ShopConfiguration
from thirdparty.zasilkovna.models import ZasilkovnaPacket
from thirdparty.stripe.models import StripePayment
#FIXME: přidat soft delete pro všchny modely !!!!
class Category(models.Model):
name = models.CharField(max_length=100)
@@ -77,31 +80,175 @@ class ProductImage(models.Model):
return f"{self.product.name} image"
# ------------------ OBJENDÁVKOVÉ MODELY (dole) ------------------
# ------------------ OBJEDNÁVKY ------------------
# Dopravci a způsoby dopravy
class Carrier(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")
class Role(models.TextChoices):
ZASILKOVNA = "packeta", "Zásilkovna"
STORE = "store", "Osobní odběr"
choice = models.CharField(max_length=20, choices=Role.choices, default=Role.STORE)
# prodejce to přidá později
zasilkovna = models.ForeignKey(
'thirdparty.zasilkovna.Zasilkovna', on_delete=models.DO_NOTHING, null=True, blank=True, related_name="carriers"
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.PENDING
)
def __str__(self):
return f"{self.name} ({self.base_price} Kč)"
# 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
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
email = models.EmailField()
phone = models.CharField(max_length=20, blank=True)
address = models.CharField(max_length=255)
city = models.CharField(max_length=100)
postal_code = models.CharField(max_length=20)
country = models.CharField(max_length=100, default="Czech Republic")
note = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
carrier = models.OneToOneField(
"Carrier",
on_delete=models.CASCADE,
related_name="orders",
null=True,
blank=True
)
payment = models.OneToOneField(
"Payment",
on_delete=models.CASCADE,
related_name="orders",
null=True,
blank=True
)
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
def calculate_total_price(self):
carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0")
if self.discount.exists():
for discount in self.discount.all():
total = Decimal('0.0')
# getting all prices from order items (with discount applied if valid)
for item in self.items.all():
total = total + item.get_total_price(discount)
return total + carrier_price
else:
total = Decimal('0.0')
# getting all prices from order items (without discount)
for item in self.items.all():
total = total + (item.product.price * item.quantity)
return total + carrier_price
def import_data_from_user(self):
"""Import user data into order for billing purposes."""
self.first_name = self.user.first_name
self.last_name = self.user.last_name
self.email = self.user.email
self.phone = self.user.phone
self.address = f"{self.user.street} {self.user.street_number}"
self.city = self.user.city
self.postal_code = self.user.postal_code
self.country = self.user.country
def save(self, *args, **kwargs):
# 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:
self.import_data_from_user()
super().save(*args, **kwargs)
# ------------------ 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)
# prodejce to přidá později
zasilkovna = models.ForeignKey(
ZasilkovnaPacket, on_delete=models.DO_NOTHING, null=True, 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")
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):
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
return ShopConfiguration.get_solo().zasilkovna_shipping_price
else:
return Decimal('0.0')
#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()
#... 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
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
#volá se na api Zásilkovny
self.zasilkovna.returning_packet()
# ------------------ PLATEBNÍ MODELY ------------------
class Payment(models.Model):
class PAYMENT(models.TextChoices):
SHOP = "shop", "Platba v obchodě"
STRIPE = "stripe", "Bankovní převod"
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"
)
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.")
super().save(*args, **kwargs)
# ------------------ SLEVOVÉ KÓDY ------------------
class DiscountCode(models.Model):
code = models.CharField(max_length=50, unique=True)
@@ -139,102 +286,17 @@ class DiscountCode(models.Model):
def __str__(self):
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
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")
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.PENDING
)
carrier = models.ForeignKey(
Carrier, on_delete=models.CASCADE, null=True, blank=True, related_name="orders"
)
#itemy
order_items = models.ManyToManyField(
'OrderItem', related_name='orders',
)
# 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)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
)
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
email = models.EmailField()
phone = models.CharField(max_length=20, blank=True)
address = models.CharField(max_length=255)
city = models.CharField(max_length=100)
postal_code = models.CharField(max_length=20)
country = models.CharField(max_length=100, default="Czech Republic")
note = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# Oprava: ManyToOneRel není pole. Potřebujeme iterovat přes self.discount.all(), proto ManyToManyField.
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
def __str__(self):
return f"Order #{self.id} - {self.user.email} ({self.status})"
def calculate_total_price(self):
if self.discount.exists():
for discount in self.discount.all():
total = Decimal('0.0')
# getting all prices from order items (with discount applied if valid)
for item in self.items.all():
total = total + item.get_total_price(discount)
return total
else:
total = Decimal('0.0')
# getting all prices from order items (without discount)
for item in self.items.all():
total = total + (item.product.price * item.quantity)
return total
def import_data_from_user(self):
"""Import user data into order for billing purposes."""
self.first_name = self.user.first_name
self.last_name = self.user.last_name
self.email = self.user.email
self.phone = self.user.phone
self.address = f"{self.user.street} {self.user.street_number}"
self.city = self.user.city
self.postal_code = self.user.postal_code
self.country = self.user.country
def save(self, *args, **kwargs):
# Keep total_price always in sync with items and discount
self.total_price = self.calculate_total_price()
super().save(*args, **kwargs)
# ------------------ OBJEDNANÉ POLOŽKY ------------------
class OrderItem(models.Model):
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
product = models.ForeignKey("products.Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
def get_total_price(self, discount_object:DiscountCode):
def get_total_price(self, discount_object:DiscountCode = None):
#FIXME: přidat logiku pro slevové kódy
"""Calculate total price for this item, applying discount if valid."""
if discount_object and discount_object.is_valid():
@@ -253,20 +315,7 @@ class OrderItem(models.Model):
return self.quantity * self.product.price
else:
return ValueError("Invalid discount code.")
raise ValueError("Invalid discount code.")
def __str__(self):
return f"{self.product.name} x{self.quantity}"
class Returning_order(models.Model):
#FIXME: dodělat !!!
order = models.ForeignKey(Order, related_name="returning_orders", on_delete=models.CASCADE)
reason = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Returning Order #{self.order.id} - {self.created_at.strftime('%Y-%m-%d')}"
return f"{self.product.name} x{self.quantity}"

View File

@@ -2,89 +2,3 @@ from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from .models import Category, Product, ProductImage, DiscountCode, Order, OrderItem, Carrier
# NOTE: Carrier intentionally skipped per request (TODO below)
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", "image", "alt_text", "is_main"]
class ProductSerializer(serializers.ModelSerializer):
images = ProductImageSerializer(many=True, read_only=True)
available = serializers.BooleanField(read_only=True)
class Meta:
model = Product
fields = [
"id","name","description","code","category","price","currency","url","stock","is_active","limited_to","default_carrier","available","images","created_at","updated_at"
]
read_only_fields = ["created_at","updated_at","available"]
class DiscountCodeSerializer(serializers.ModelSerializer):
is_valid = serializers.SerializerMethodField()
class Meta:
model = DiscountCode
fields = ["id","code","description","percent","amount","valid_from","valid_to","active","usage_limit","used_count","specific_products","specific_categories","is_valid"]
read_only_fields = ["used_count","is_valid"]
def get_is_valid(self, obj):
return obj.is_valid()
class OrderItemSerializer(serializers.ModelSerializer):
product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all())
line_total = serializers.SerializerMethodField()
class Meta:
model = OrderItem
fields = ["id","product","quantity","line_total"]
read_only_fields = ["line_total"]
def get_line_total(self, obj):
# Uses existing model logic for discount via order context (kept minimal)
# Since discount resolution logic is custom & currently incomplete, just returns base price * qty
return obj.product.price * obj.quantity
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True)
total_price = serializers.DecimalField(max_digits=10, decimal_places=2, read_only=True)
class Meta:
model = Order
fields = [
"id","user","status","total_price","currency","first_name","last_name","email","phone","address","city","postal_code","country","note","discount","items","created_at","updated_at"
]
read_only_fields = ["total_price","created_at","updated_at"]
def create(self, validated_data):
items_data = validated_data.pop("items", [])
order = Order.objects.create(**validated_data)
for item in items_data:
OrderItem.objects.create(order=order, **item)
order.total_price = order.calculate_total_price() if hasattr(order, "calculate_total_price") else order.total_price
order.save()
return order
def update(self, instance, validated_data):
items_data = validated_data.pop("items", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
if items_data is not None:
instance.items.all().delete()
for item in items_data:
OrderItem.objects.create(order=instance, **item)
instance.total_price = instance.calculate_total_price() if hasattr(instance, "calculate_total_price") else instance.total_price
instance.save()
return instance
# TODO: CarrierSerializer (Carrier API not requested yet)

View File

@@ -2,70 +2,3 @@ from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from drf_spectacular.utils import extend_schema, extend_schema_view
from .models import Category, Product, ProductImage, DiscountCode, Order, OrderItem, Carrier
from .serializers import (
CategorySerializer,
ProductSerializer,
ProductImageSerializer,
DiscountCodeSerializer,
OrderSerializer,
)
@extend_schema_view(
list=extend_schema(tags=["Commerce", "Categories"], summary="List categories"),
retrieve=extend_schema(tags=["Commerce", "Categories"], summary="Retrieve category"),
create=extend_schema(tags=["Commerce", "Categories"], summary="Create category"),
update=extend_schema(tags=["Commerce", "Categories"], summary="Update category"),
partial_update=extend_schema(tags=["Commerce", "Categories"], summary="Partial update category"),
destroy=extend_schema(tags=["Commerce", "Categories"], summary="Delete category"),
)
class CategoryViewSet(viewsets.ModelViewSet):
queryset = Category.objects.all()
serializer_class = CategorySerializer
permission_classes = [AllowAny]
@extend_schema_view(
list=extend_schema(tags=["Commerce", "Products"], summary="List products"),
retrieve=extend_schema(tags=["Commerce", "Products"], summary="Retrieve product"),
create=extend_schema(tags=["Commerce", "Products"], summary="Create product"),
update=extend_schema(tags=["Commerce", "Products"], summary="Update product"),
partial_update=extend_schema(tags=["Commerce", "Products"], summary="Partial update product"),
destroy=extend_schema(tags=["Commerce", "Products"], summary="Delete product"),
)
class ProductViewSet(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
permission_classes = [AllowAny]
@extend_schema_view(
list=extend_schema(tags=["Commerce", "Discounts"], summary="List discount codes"),
retrieve=extend_schema(tags=["Commerce", "Discounts"], summary="Retrieve discount code"),
create=extend_schema(tags=["Commerce", "Discounts"], summary="Create discount code"),
update=extend_schema(tags=["Commerce", "Discounts"], summary="Update discount code"),
partial_update=extend_schema(tags=["Commerce", "Discounts"], summary="Partial update discount code"),
destroy=extend_schema(tags=["Commerce", "Discounts"], summary="Delete discount code"),
)
class DiscountCodeViewSet(viewsets.ModelViewSet):
queryset = DiscountCode.objects.all()
serializer_class = DiscountCodeSerializer
permission_classes = [AllowAny]
@extend_schema_view(
list=extend_schema(tags=["Commerce", "Orders"], summary="List orders"),
retrieve=extend_schema(tags=["Commerce", "Orders"], summary="Retrieve order"),
create=extend_schema(tags=["Commerce", "Orders"], summary="Create order"),
update=extend_schema(tags=["Commerce", "Orders"], summary="Update order"),
partial_update=extend_schema(tags=["Commerce", "Orders"], summary="Partial update order"),
destroy=extend_schema(tags=["Commerce", "Orders"], summary="Delete order"),
)
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.all()
serializer_class = OrderSerializer
permission_classes = [AllowAny]
# TODO: CarrierViewSet & CarrierSerializer when requested