Refactor commerce models and enhance payment logic

Refactored commerce models to remove language prefixes from status choices, improved order and payment validation, and enforced business rules for payment and shipping combinations. Updated order item and cart calculations to use VAT-inclusive prices, added unique constraints and indexes to reviews, and improved stock management logic. Added new Stripe client methods for session and refund management, and updated Zasilkovna and Deutsche Post models for consistency. Minor fixes and improvements across related tasks, URLs, and configuration models.
This commit is contained in:
2026-01-20 23:45:21 +01:00
parent b38d126b6c
commit c0bd24ee5e
10 changed files with 353 additions and 96 deletions

View File

@@ -1,3 +1,5 @@
from ast import Or
import dis
from django.db import models
from django.conf import settings
from django.utils import timezone
@@ -5,7 +7,7 @@ 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 django.core.validators import MaxValueValidator, MinValueValidator, validate_email
try:
from weasyprint import HTML
@@ -146,12 +148,12 @@ class ProductImage(models.Model):
class Order(models.Model):
class OrderStatus(models.TextChoices):
CREATED = "created", "cz#Vytvořeno"
CANCELLED = "cancelled", "cz#Zrušeno"
COMPLETED = "completed", "cz#Dokončeno"
CREATED = "created", "Vytvořeno"
CANCELLED = "cancelled", "Zrušeno"
COMPLETED = "completed", "Dokončeno"
REFUNDING = "refunding", "cz#Vrácení v procesu"
REFUNDED = "refunded", "cz#Vráceno"
REFUNDING = "refunding", "Vrácení v procesu"
REFUNDED = "refunded", "Vráceno"
status = models.CharField(
max_length=20, choices=OrderStatus.choices, null=True, blank=True, default=OrderStatus.CREATED
@@ -216,10 +218,10 @@ class Order(models.Model):
else:
total = Decimal('0.0')
# getting all prices from order items (without discount)
# getting all prices from order items (without discount) - using VAT-inclusive prices
for item in self.items.all():
total = total + (item.product.price * item.quantity)
total = total + (item.product.get_price_with_vat() * item.quantity)
return total + carrier_price
@@ -234,6 +236,24 @@ class Order(models.Model):
self.postal_code = self.user.postal_code
self.country = self.user.country
def clean(self):
"""Validate order data"""
# Validate required fields
required_fields = ['first_name', 'last_name', 'email', 'address', 'city', 'postal_code']
for field in required_fields:
if not getattr(self, field):
raise ValidationError(f"{field.replace('_', ' ').title()} is required.")
# Validate email format
try:
validate_email(self.email)
except ValidationError:
raise ValidationError("Invalid email format.")
# Validate order has items
if self.pk and not self.items.exists():
raise ValidationError("Order must have at least one item.")
def save(self, *args, **kwargs):
# Keep total_price always in sync with items and discount
self.total_price = self.calculate_total_price()
@@ -255,16 +275,16 @@ class Order(models.Model):
class Carrier(models.Model):
class SHIPPING(models.TextChoices):
ZASILKOVNA = "packeta", "cz#Zásilkovna"
DEUTSCHEPOST = "deutschepost", "cz#Deutsche Post"
STORE = "store", "cz#Osobní odběr"
ZASILKOVNA = "packeta", "Zásilkovna"
DEUTSCHEPOST = "deutschepost", "Deutsche Post"
STORE = "store", "Osobní odběr"
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
class STATE(models.TextChoices):
PREPARING = "ordered", "cz#Objednávka se připravuje"
SHIPPED = "shipped", "cz#Odesláno"
DELIVERED = "delivered", "cz#Doručeno"
READY_TO_PICKUP = "ready_to_pickup", "cz#Připraveno k vyzvednutí"
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)
@@ -285,27 +305,44 @@ class Carrier(models.Model):
shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
def save(self, *args, **kwargs):
if self.pk is None:
if self.shipping_price is None:
self.shipping_price = self.get_price()
# Set shipping price for new carriers
if self.pk is None and self.shipping_price is None:
# For new carriers, we might not have an order yet
self.shipping_price = self.get_price(order=None)
# Check if state changed to ready for pickup
old_state = None
if self.pk:
if self.STATE == self.STATE.READY_TO_PICKUP and self.shipping_method == self.SHIPPING.STORE:
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
pass
else:
pass
old_carrier = Carrier.objects.filter(pk=self.pk).first()
old_state = old_carrier.state if old_carrier else None
super().save(*args, **kwargs)
def get_price(self):
# Send notification if state changed to ready for pickup
if (old_state != self.STATE.READY_TO_PICKUP and
self.state == self.STATE.READY_TO_PICKUP and
self.shipping_method == self.SHIPPING.STORE):
if hasattr(self, 'order') and self.order:
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
def get_price(self, order=None):
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
return SiteConfiguration.get_solo().zasilkovna_shipping_price
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
return SiteConfiguration.get_solo().deutschepost_shipping_price
else:
elif self.shipping_method == self.SHIPPING.STORE:
# Store pickup is always free
return Decimal('0.0')
else:
# Check for free shipping based on order total
if order is None:
order = Order.objects.filter(carrier=self).first()
if order and order.total_price >= SiteConfiguration.get_solo().free_shipping_over:
return Decimal('0.0')
else:
return SiteConfiguration.get_solo().default_shipping_price or Decimal('50.0') # fallback price
#tohle bude vyvoláno pomocí admina přes api!!!
@@ -363,9 +400,9 @@ class Carrier(models.Model):
class Payment(models.Model):
class PAYMENT(models.TextChoices):
SHOP = "shop", "cz#Platba v obchodě", "de#Bezahlung im Geschäft"
STRIPE = "stripe", "cz#Platební Brána", "de#Zahlungsgateway"
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka", "de#Nachnahme"
SHOP = "shop", "Platba v obchodě"
STRIPE = "stripe", "Platební Brána"
CASH_ON_DELIVERY = "cash_on_delivery", "Dobírka"
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SHOP)
#FIXME: potvrdit že logika platby funguje správně
@@ -373,16 +410,56 @@ class Payment(models.Model):
stripe = models.OneToOneField(
StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
)
payed_at_shop = models.BooleanField(default=False, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def clean(self):
"""Validate payment and shipping method combinations"""
# TODO: Add validation logic for invalid payment/shipping combinations
# TODO: Skip GoPay integration for now
# Validate payment method consistency
if self.payment_method == self.PAYMENT.STRIPE and not self.stripe:
raise ValidationError("Stripe payment method requires a linked StripeModel instance.")
elif self.payment_method == self.PAYMENT.SHOP and self.stripe:
raise ValidationError("Shop payment method should not have a linked StripeModel instance.")
# Validate payment and shipping compatibility
if self.payment_method == self.PAYMENT.SHOP:
# SHOP payment only works with STORE pickup - customer pays at physical store
if Order.objects.filter(payment=self).exists():
order = Order.objects.get(payment=self)
if order.carrier and order.carrier.shipping_method != Carrier.SHIPPING.STORE:
raise ValidationError(
"Shop payment is only compatible with store pickup. "
"For shipping orders, use Stripe or Cash on Delivery payment methods."
)
elif self.payment_method == self.PAYMENT.CASH_ON_DELIVERY:
# Cash on delivery only works with shipping methods (not store pickup)
if Order.objects.filter(payment=self).exists():
order = Order.objects.get(payment=self)
if order.carrier and order.carrier.shipping_method == Carrier.SHIPPING.STORE:
raise ValidationError(
"Cash on delivery is not compatible with store pickup. "
"For store pickup, use shop payment method."
)
# STRIPE payment works with all shipping methods - no additional validation needed
super().clean()
def payed_manually(self):
"""Mark payment as completed"""
if self.payment_method == self.PAYMENT.SHOP:
self.payed_at_shop = True
self.save()
else:
raise ValidationError("Manuální platba je povolena pouze pro platbu v obchodě.")
# ------------------ SLEVOVÉ KÓDY ------------------
@@ -441,7 +518,7 @@ class OrderItem(models.Model):
product = models.ForeignKey("commerce.Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
def get_total_price(self, discounts: list[DiscountCode] = None):
def get_total_price(self, discounts: list[DiscountCode] = list()):
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
Logika dle SiteConfiguration:
@@ -453,12 +530,12 @@ class OrderItem(models.Model):
- Kombinace: nejprve procentuální část, poté odečtení fixní částky.
- Sleva se nikdy nesmí dostat pod 0.
"""
base_price = self.product.price * self.quantity
# Use VAT-inclusive price for customer-facing calculations
base_price = self.product.get_price_with_vat() * self.quantity
if not discounts:
if not discounts or discounts == []:
return base_price
config = SiteConfiguration.get_solo()
#seznám slev
@@ -527,16 +604,19 @@ class OrderItem(models.Model):
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.")
# Check if order already has a processed payment
if (self.order.payment and
self.order.payment.payment_method and
self.order.payment.payment_method != Payment.PAYMENT.SHOP):
raise ValueError("Cannot modify items from order with processed payment method.")
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.")
# Validate stock availability
if self.product.stock < self.quantity:
raise ValueError(f"Insufficient stock for product {self.product.name}. Available: {self.product.stock}")
self.product.stock -= self.quantity
self.product.save(update_fields=["stock"])
# Reduce stock
self.product.stock -= self.quantity
self.product.save(update_fields=["stock"])
super().save(*args, **kwargs)
@@ -545,10 +625,10 @@ 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", "cz#Vrácení před uplynutím 14-ti denní lhůty"
DAMAGED_PRODUCT = "damaged_product", "cz#Poškozený produkt"
WRONG_ITEM = "wrong_item", "cz#Špatná položka"
OTHER = "other", "cz#Jiný důvod"
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=40, choices=Reason.choices)
reason_text = models.TextField(blank=True)
@@ -635,17 +715,15 @@ class Refund(models.Model):
"return_reason": return_reason,
}
carrier = models.OneToOneField(
"Carrier",
on_delete=models.CASCADE,
related_name="refund",
null=True,
blank=True
)
html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
# Import WeasyPrint lazily to avoid startup failures when system
# libraries (Pango/GObject) are not present on Windows.
if HTML is None:
raise RuntimeError(
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker."
)
pdf_bytes = HTML(string=html_string).write_pdf()
return pdf_bytes
@@ -665,6 +743,7 @@ class Invoice(models.Model):
order = Order.objects.get(invoice=self)
# Render HTML
html_string = render_to_string("invoice/Order.html", {"invoice": self, "order": order})
# Import WeasyPrint lazily to avoid startup failures when system
# libraries (Pango/GObject) are not present on Windows.
if HTML is None:
@@ -693,6 +772,19 @@ class Review(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ('product', 'user') # Prevent multiple reviews per user per product
indexes = [
models.Index(fields=['product', 'rating']),
models.Index(fields=['created_at']),
]
def clean(self):
"""Validate that user hasn't already reviewed this product"""
if self.pk is None: # Only for new reviews
if Review.objects.filter(product=self.product, user=self.user).exists():
raise ValidationError("User has already reviewed this product.")
def __str__(self):
return f"Review for {self.product.name} by {self.user.username}"
@@ -727,7 +819,7 @@ class Cart(models.Model):
return f"Anonymous cart ({self.session_key})"
def get_total(self):
"""Calculate total price of all items in cart"""
"""Calculate total price of all items in cart including VAT"""
total = Decimal('0.0')
for item in self.items.all():
total += item.get_subtotal()
@@ -754,8 +846,8 @@ class CartItem(models.Model):
return f"{self.quantity}x {self.product.name} in cart"
def get_subtotal(self):
"""Calculate subtotal for this cart item"""
return self.product.price * self.quantity
"""Calculate subtotal for this cart item including VAT"""
return self.product.get_price_with_vat() * self.quantity
def clean(self):
"""Validate that product has enough stock"""

View File

@@ -83,6 +83,24 @@ def notify_order_successfuly_created(order = None, user = None, **kwargs):
pass
@shared_task
def notify_order_payed(order = None, user = None, **kwargs):
if not order or not user:
raise ValueError("Order and User must be provided for notification.")
if kwargs:
print("Additional kwargs received in notify_order_paid:", kwargs)
send_email_with_context(
recipients=user.email,
subject="Your order has been paid",
template_path="email/order_paid.html",
context={
"user": user,
"order": order,
})
pass
@shared_task
def notify_about_missing_payment(order = None, user = None, **kwargs):
@@ -103,6 +121,9 @@ def notify_about_missing_payment(order = None, user = None, **kwargs):
pass
# -- NOTIFICATIONS REFUND --
@shared_task
def notify_refund_items_arrived(order = None, user = None, **kwargs):
if not order or not user:
@@ -141,4 +162,7 @@ def notify_refund_accepted(order = None, user = None, **kwargs):
"order": order,
})
pass
pass
#

View File

@@ -13,7 +13,7 @@ from .views import (
CartViewSet,
WishlistViewSet,
AdminWishlistViewSet,
AnalyticsViewSet,
AnalyticsView,
)
router = DefaultRouter()
@@ -27,10 +27,10 @@ router.register(r'reviews', ReviewPublicViewSet, basename='review')
router.register(r'cart', CartViewSet, basename='cart')
router.register(r'wishlist', WishlistViewSet, basename='wishlist')
router.register(r'admin/wishlists', AdminWishlistViewSet, basename='admin-wishlist')
router.register(r'analytics', AnalyticsViewSet, basename='analytics')
urlpatterns = [
path('', include(router.urls)),
path('refunds/public/', RefundPublicView.as_view(), name='RefundPublicView'),
path('reviews/create/', ReviewPostPublicView.as_view(), name='ReviewCreate'),
path('analytics/', AnalyticsView.as_view(), name='analytics'),
]