Introduces RefundPublicView for public refund creation via email and invoice/order ID, returning refund info and a base64-encoded PDF slip. Adds RefundCreatePublicSerializer for validation and creation, implements PDF generation in Refund model, and provides a customer-facing HTML template for the return slip. Updates URLs to expose the new endpoint.
565 lines
20 KiB
Python
565 lines
20 KiB
Python
from django.db import models
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
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 StripeModel
|
||
|
||
#FIXME: přidat soft delete pro všchny modely !!!!
|
||
|
||
class Category(models.Model):
|
||
name = models.CharField(max_length=100)
|
||
|
||
#adresa kategorie např: /category/elektronika/mobily/
|
||
url = models.SlugField(unique=True)
|
||
|
||
#kategorie se můžou skládat pod sebe
|
||
parent = models.ForeignKey(
|
||
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='subcategories'
|
||
)
|
||
|
||
description = models.TextField(blank=True)
|
||
|
||
#ikona
|
||
image = models.ImageField(upload_to='categories/', blank=True)
|
||
|
||
class Meta:
|
||
verbose_name_plural = "Categories"
|
||
|
||
def __str__(self):
|
||
return self.name
|
||
|
||
|
||
|
||
class Product(models.Model):
|
||
name = models.CharField(max_length=200)
|
||
description = models.TextField(blank=True)
|
||
|
||
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)
|
||
|
||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||
|
||
url = models.SlugField(unique=True)
|
||
|
||
stock = models.PositiveIntegerField(default=0)
|
||
is_active = models.BooleanField(default=True)
|
||
|
||
#časový limit (volitelné)
|
||
limited_to = models.DateTimeField(null=True, blank=True)
|
||
|
||
default_carrier = models.ForeignKey(
|
||
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
@property
|
||
def available(self):
|
||
return self.is_active and self.stock > 0
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.price} {self.currency.upper()})"
|
||
|
||
#obrázek pro produkty
|
||
class ProductImage(models.Model):
|
||
product = models.ForeignKey(Product, related_name='images', on_delete=models.CASCADE)
|
||
|
||
image = models.ImageField(upload_to='products/')
|
||
|
||
alt_text = models.CharField(max_length=150, blank=True)
|
||
is_main = models.BooleanField(default=False)
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name} image"
|
||
|
||
|
||
|
||
# ------------------ OBJEDNÁVKY ------------------
|
||
|
||
class Order(models.Model):
|
||
class Status(models.TextChoices):
|
||
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, 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)
|
||
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
|
||
)
|
||
|
||
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):
|
||
carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0")
|
||
|
||
if self.discount.exists():
|
||
discounts = list(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(discounts)
|
||
|
||
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)
|
||
|
||
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.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")
|
||
|
||
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ž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 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í.")
|
||
|
||
# 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 ------------------
|
||
|
||
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)
|
||
|
||
#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(
|
||
StripeModel, on_delete=models.CASCADE, null=True, blank=True, related_name="payment"
|
||
)
|
||
|
||
def save(self, *args, **kwargs):
|
||
|
||
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)
|
||
|
||
|
||
# ------------------ SLEVOVÉ KÓDY ------------------
|
||
|
||
class DiscountCode(models.Model):
|
||
code = models.CharField(max_length=50, unique=True)
|
||
description = models.CharField(max_length=255, blank=True)
|
||
|
||
# sleva v procentech (0–100)
|
||
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")
|
||
|
||
valid_from = models.DateTimeField(default=timezone.now)
|
||
valid_to = models.DateTimeField(null=True, blank=True)
|
||
active = models.BooleanField(default=True)
|
||
|
||
#max počet použití
|
||
usage_limit = models.PositiveIntegerField(null=True, blank=True)
|
||
used_count = models.PositiveIntegerField(default=0)
|
||
|
||
|
||
specific_products = models.ManyToManyField(
|
||
Product, blank=True, related_name="discount_codes"
|
||
)
|
||
specific_categories = models.ManyToManyField(
|
||
Category, blank=True, related_name="discount_codes"
|
||
)
|
||
|
||
def is_valid(self):
|
||
now = timezone.now()
|
||
if not self.active:
|
||
return False
|
||
|
||
if self.valid_to and self.valid_to < now:
|
||
return False
|
||
|
||
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)"
|
||
|
||
|
||
# ------------------ 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, discounts: list[DiscountCode] = None):
|
||
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
|
||
|
||
Logika dle ShopConfiguration:
|
||
- multiplying_coupons=True: procentuální slevy se násobí (sekvenčně)
|
||
P * (1 - p1) -> výsledné * (1 - p2) ...
|
||
jinak se použije pouze nejlepší (nejvyšší procento).
|
||
- addition_of_coupons_amount=True: fixní částky (amount) se sčítají,
|
||
jinak se použije pouze nejvyšší částka.
|
||
- Kombinace: nejprve procentuální část, poté odečtení fixní částky.
|
||
- Sleva se nikdy nesmí dostat pod 0.
|
||
"""
|
||
base_price = self.product.price * self.quantity
|
||
|
||
if not discounts:
|
||
return base_price
|
||
|
||
|
||
config = ShopConfiguration.get_solo()
|
||
|
||
#seznám slev
|
||
applicable_percent_discounts: list[int] = []
|
||
applicable_amount_discounts: list[Decimal] = []
|
||
|
||
#procházení kupónů a určení, které se aplikují
|
||
for discount in set(discounts):
|
||
if not discount:
|
||
continue
|
||
|
||
if not discount.is_valid():
|
||
raise ValueError("Invalid discount code.")
|
||
|
||
#defaulting
|
||
applies = False
|
||
|
||
# Určení, zda kupon platí pro produkt/kategorii
|
||
# prázdný produkt a kategorie = globální kupon
|
||
if discount.specific_products.exists() or discount.specific_categories.exists():
|
||
if (self.product in discount.specific_products.all() or self.product.category in discount.specific_categories.all()):
|
||
applies = True
|
||
|
||
else:
|
||
applies = True #global
|
||
|
||
if not applies:
|
||
continue
|
||
|
||
if discount.percent is not None:
|
||
applicable_percent_discounts.append(discount.percent)
|
||
elif discount.amount is not None:
|
||
applicable_amount_discounts.append(discount.amount)
|
||
|
||
final_price = base_price
|
||
|
||
# Procentuální slevy
|
||
if applicable_percent_discounts:
|
||
|
||
if config.multiplying_coupons:
|
||
for pct in applicable_percent_discounts:
|
||
factor = (Decimal('1') - (Decimal(pct) / Decimal('100')))
|
||
final_price = final_price * factor
|
||
else:
|
||
best_pct = max(applicable_percent_discounts)
|
||
factor = (Decimal('1') - (Decimal(best_pct) / Decimal('100')))
|
||
final_price = final_price * factor
|
||
|
||
# Fixní částky
|
||
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'):
|
||
final_price = Decimal('0')
|
||
|
||
return final_price.quantize(Decimal('0.01'))
|
||
|
||
def __str__(self):
|
||
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"])
|
||
|
||
def generate_refund_pdf_for_customer(self):
|
||
"""Vygeneruje PDF formulář k vrácení zboží pro zákazníka.
|
||
|
||
Šablona refund/customer_in_package_returning_form.html očekává:
|
||
- order: objekt objednávky
|
||
- items: seznam položek (dict) s klíči product_name, sku, quantity, variant, options, reason
|
||
- return_reason: textový důvod vrácení (kombinace reason_text / reason_choice)
|
||
|
||
Návratová hodnota: bytes (PDF obsah). Uložení necháváme na volající logice.
|
||
"""
|
||
order = self.order
|
||
|
||
# Připravíme položky pro šablonu (důvody per položku zatím None – lze rozšířit)
|
||
prepared_items: list[dict] = []
|
||
for item in order.items.select_related('product'):
|
||
prepared_items.append({
|
||
"product_name": getattr(item.product, "name", "Item"),
|
||
"name": getattr(item.product, "name", "Item"), # fallbacky pro různé názvy v šabloně
|
||
"sku": getattr(item.product, "code", None),
|
||
"quantity": item.quantity,
|
||
"variant": None, # lze doplnit pokud existují varianty
|
||
"options": None, # lze doplnit pokud existují volby
|
||
"reason": None, # per-item reason (zatím nepodporováno)
|
||
})
|
||
|
||
return_reason = self.reason_text or self.get_reason_choice_display()
|
||
|
||
context = {
|
||
"order": order,
|
||
"items": prepared_items,
|
||
"return_reason": return_reason,
|
||
}
|
||
|
||
html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
|
||
pdf_bytes = HTML(string=html_string).write_pdf()
|
||
return pdf_bytes
|
||
|
||
|
||
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() |