Introduces a weekly summary email for newly added products, including a new email template and Celery periodic task. Adds 'include_in_week_summary_email' to Product and 'newsletter' to CustomUser. Provides an admin endpoint to manually trigger the weekly email, updates Celery Beat schedule, and adds email templates for verification and password reset.
903 lines
33 KiB
Python
903 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
|
||
|
||
|
||
|
||
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)
|
||
|
||
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)
|
||
|
||
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() |