Files
vontor-cz/backend/commerce/models.py
Brunobrno ca62e8895a Add order status email notifications and templates
Introduces email notifications for order status changes (created, cancelled, completed, paid, missing payment, refund events) and adds corresponding HTML email templates. Refactors notification tasks to use only the order object and updates model logic to trigger notifications on relevant status changes.
2026-01-25 22:21:00 +01:00

948 lines
35 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from ast import Or
import dis
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, validate_email
try:
from weasyprint import HTML
except ImportError:
HTML = None
import os
from configuration.models import SiteConfiguration
from thirdparty.zasilkovna.models import ZasilkovnaPacket
from thirdparty.stripe.models import StripeModel
from .tasks import notify_refund_accepted, notify_Ready_to_pickup, notify_zasilkovna_sended
#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
#TODO: přidate brand model pro produkty (značky)
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,
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)
# -- CENA --
price = models.DecimalField(max_digits=10, decimal_places=2, help_text="Net price (without VAT)")
# Currency is now global from SiteConfiguration, not per-product
# VAT rate - configured by business owner in configuration app!!!
vat_rate = models.ForeignKey(
'configuration.VATRate',
on_delete=models.PROTECT,
null=True,
blank=True,
help_text="VAT rate for this product. Leave empty to use default rate."
)
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)
#TODO: delete
default_carrier = models.ForeignKey(
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
)
include_in_week_summary_email = models.BooleanField(default=False)
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 get_vat_rate(self):
"""Get the VAT rate for this product (from configuration or default)"""
if self.vat_rate:
return self.vat_rate
# Import here to avoid circular imports
from configuration.models import VATRate
return VATRate.get_default()
def get_price_with_vat(self):
"""Get price including VAT"""
vat_rate = self.get_vat_rate()
if not vat_rate:
return self.price # No VAT configured
return self.price * (Decimal('1') + vat_rate.rate_decimal)
def get_vat_amount(self):
"""Get the VAT amount for this product"""
vat_rate = self.get_vat_rate()
if not vat_rate:
return Decimal('0')
return self.price * vat_rate.rate_decimal
def __str__(self):
config = SiteConfiguration.get_solo()
return f"{self.name} ({self.get_price_with_vat()} {config.currency} inkl. MwSt)"
#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)
order = models.PositiveIntegerField(default=0, help_text="Display order (lower numbers first)")
class Meta:
ordering = ['order', '-is_main', 'id']
def __str__(self):
return f"{self.product.name} image"
# ------------------ OBJEDNÁVKY ------------------
class Order(models.Model):
class OrderStatus(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=OrderStatus.choices, null=True, blank=True, default=OrderStatus.CREATED
)
# Stored order grand total; recalculated on save
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
# Currency - captured from site configuration at creation time, never changes
currency = models.CharField(max_length=10, default="", help_text="Order currency - captured from site configuration at order creation and never changes")
# 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="order",
null=True,
blank=True
)
payment = models.OneToOneField(
"Payment",
on_delete=models.CASCADE,
related_name="order",
null=True,
blank=True
)
invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True)
#FIXME: změnnit název na discount_code
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) - using VAT-inclusive prices
for item in self.items.all():
total = total + (item.product.get_price_with_vat() * 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 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 get_currency(self):
"""Get order currency - falls back to site configuration if not set"""
if self.currency:
return self.currency
config = SiteConfiguration.get_solo()
return config.currency
def save(self, *args, **kwargs):
is_new = self.pk is None
old_status = None
# Track old status for change detection
if not is_new:
try:
old_instance = Order.objects.get(pk=self.pk)
old_status = old_instance.status
except Order.DoesNotExist:
pass
# CRITICAL: Set currency from site configuration ONLY at creation time
# Once set, currency should NEVER change to maintain order integrity
if is_new and not self.currency:
config = SiteConfiguration.get_solo()
self.currency = config.currency
# Keep total_price always in sync with items and discount
self.total_price = self.calculate_total_price()
if self.user and is_new:
self.import_data_from_user()
super().save(*args, **kwargs)
# Send email notification for new orders
if is_new:
from .tasks import notify_order_successfuly_created
notify_order_successfuly_created.delay(order=self)
# Send email notification when status changes to CANCELLED
if not is_new and old_status != self.OrderStatus.CANCELLED and self.status == self.OrderStatus.CANCELLED:
from .tasks import notify_order_cancelled
notify_order_cancelled.delay(order=self)
# Send email notification when status changes to COMPLETED
if not is_new and old_status != self.OrderStatus.COMPLETED and self.status == self.OrderStatus.COMPLETED:
from .tasks import notify_order_completed
notify_order_completed.delay(order=self)
def cancel_order(self):
"""Cancel the order if possible"""
if self.status == self.OrderStatus.CREATED:
self.status = self.OrderStatus.CANCELLED
self.save()
#TODO: udělat ještě kontrolu jestli už nebyla odeslána zásilka a pokud bude už zaplacena tak se uděla refundace a pokud nebude zaplacena tak se zruší brána.
else:
raise ValidationError("Only orders in 'created' status can be cancelled.")
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
class Carrier(models.Model):
class SHIPPING(models.TextChoices):
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", "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"
)
# Deutsche Post integration (same pattern as zasilkovna)
deutschepost = models.ManyToManyField(
"deutschepost.DeutschePostOrder", 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í")
shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
def save(self, *args, **kwargs):
# 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:
old_carrier = Carrier.objects.filter(pk=self.pk).first()
old_state = old_carrier.state if old_carrier else None
super().save(*args, **kwargs)
# 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)
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
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!!!
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()
notify_zasilkovna_sended.delay(order=self.order)
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
# Import here to avoid circular imports
from thirdparty.deutschepost.models import DeutschePostOrder
# Create new Deutsche Post order and add to carrier (same pattern as zasilkovna)
dp_order = DeutschePostOrder.objects.create()
self.deutschepost.add(dp_order)
self.returning = False
self.save()
# Order shipping through Deutsche Post API
dp_order.order_shippment()
elif self.shipping_method == self.SHIPPING.STORE:
self.state = self.STATE.READY_TO_PICKUP
self.save()
notify_Ready_to_pickup.delay(order=self.order)
else:
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
#... další logika pro jiné způsoby dopravy (do budoucna!)
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", "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ě
#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"
)
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"""
# 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 ------------------
class DiscountCode(models.Model):
code = models.CharField(max_length=50, unique=True)
description = models.CharField(max_length=255, blank=True)
# sleva v procentech (0100)
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="Fixed discount amount in site currency")
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("commerce.Product", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField(default=1)
def get_total_price(self, discounts: list[DiscountCode] = list()):
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
Logika dle SiteConfiguration:
- 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.
"""
# Use VAT-inclusive price for customer-facing calculations
base_price = self.product.get_price_with_vat() * self.quantity
if not discounts or discounts == []:
return base_price
config = SiteConfiguration.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:
# 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.")
# Validate stock availability
if self.product.stock < self.quantity:
raise ValueError(f"Insufficient stock for product {self.product.name}. Available: {self.product.stock}")
# Reduce stock
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=40, 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 save(self, *args, **kwargs):
# Automaticky aktualizovat stav objednávky na "vráceno"
if self.pk is None:
if self.order.status != Order.OrderStatus.REFUNDING:
self.order.status = Order.OrderStatus.REFUNDING
self.order.save(update_fields=["status", "updated_at"])
super().save(*args, **kwargs)
def refund_completed(self):
# Aktualizovat stav objednávky na "vráceno"
if self.order.payment and self.order.payment.payment_method == Payment.PAYMENT.STRIPE:
self.order.payment.stripe.refund() # Vrácení pěnez přes stripe
self.order.status = Order.OrderStatus.REFUNDED
self.order.save(update_fields=["status", "updated_at"])
notify_refund_accepted.delay(order=self.order)
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)
# 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
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/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:
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()
# Save directly to FileField
self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes))
self.save()
class Review(models.Model):
product = models.ForeignKey(Product, related_name="reviews", on_delete=models.CASCADE)
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews"
)
rating = models.PositiveIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
comment = models.TextField(blank=True)
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}"
# ------------------ SHOPPING CART ------------------
class Cart(models.Model):
"""Shopping cart for both authenticated and anonymous users"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="cart"
)
session_key = models.CharField(
max_length=40,
null=True,
blank=True,
help_text="Session key for anonymous users"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Cart"
verbose_name_plural = "Carts"
def __str__(self):
if self.user:
return f"Cart for {self.user.email}"
return f"Anonymous cart ({self.session_key})"
def get_total(self):
"""Calculate total price of all items in cart including VAT"""
total = Decimal('0.0')
for item in self.items.all():
total += item.get_subtotal()
return total
def get_items_count(self):
"""Get total number of items in cart"""
return sum(item.quantity for item in self.items.all())
class CartItem(models.Model):
"""Individual items in a shopping cart"""
cart = models.ForeignKey(Cart, related_name='items', on_delete=models.CASCADE)
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
added_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "Cart Item"
verbose_name_plural = "Cart Items"
unique_together = ('cart', 'product') # Prevent duplicate products in same cart
def __str__(self):
return f"{self.quantity}x {self.product.name} in cart"
def get_subtotal(self):
"""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"""
if self.product.stock < self.quantity:
raise ValidationError(f"Not enough stock for {self.product.name}. Available: {self.product.stock}")
def save(self, *args, **kwargs):
self.clean()
super().save(*args, **kwargs)
# ------------------ WISHLIST ------------------
class Wishlist(models.Model):
"""User's wishlist for saving favorite products"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="wishlist"
)
products = models.ManyToManyField(
Product,
blank=True,
related_name="wishlisted_by",
help_text="Products saved by the user"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Wishlist"
verbose_name_plural = "Wishlists"
def __str__(self):
return f"Wishlist for {self.user.email}"
def add_product(self, product):
"""Add a product to wishlist"""
self.products.add(product)
def remove_product(self, product):
"""Remove a product from wishlist"""
self.products.remove(product)
def has_product(self, product):
"""Check if product is in wishlist"""
return self.products.filter(pk=product.pk).exists()
def get_products_count(self):
"""Get count of products in wishlist"""
return self.products.count()