905 lines
33 KiB
Python
905 lines
33 KiB
Python
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 = models.CharField(max_length=3, default="CZK")
|
||
|
||
# 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):
|
||
return f"{self.name} ({self.get_price_with_vat()} {self.currency.upper()} 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 = 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="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 save(self, *args, **kwargs):
|
||
# Keep total_price always in sync with items and discount
|
||
self.total_price = self.calculate_total_price()
|
||
|
||
is_new = self.pk is None
|
||
|
||
if self.user and is_new:
|
||
self.import_data_from_user()
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
# Send email notification for new orders
|
||
if is_new and self.user:
|
||
from .tasks import notify_order_successfuly_created
|
||
notify_order_successfuly_created.delay(order=self, user=self.user)
|
||
|
||
|
||
# ------------------ 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, 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
|
||
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, user=self.order.user)
|
||
|
||
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, user=self.order.user)
|
||
|
||
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 (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("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, user=self.order.user)
|
||
|
||
|
||
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() |