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,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()
|
||||
Reference in New Issue
Block a user