Refactored email sending to use a single HTML template with a base layout, removed plain text email templates, and updated all related backend logic. Introduced a new ContactMe model, serializer, Celery task, and API endpoints for handling contact form submissions, including email notifications. Renamed ShopConfiguration to SiteConfiguration throughout the backend for consistency. Updated frontend to remove unused components, add a new Services section, and adjust navigation and contact form integration.
587 lines
20 KiB
Python
587 lines
20 KiB
Python
from django.db import models
|
||
from django.conf import settings
|
||
from django.utils import timezone
|
||
from django.core.exceptions import ValidationError
|
||
from decimal import Decimal
|
||
from django.template.loader import render_to_string
|
||
from django.core.files.base import ContentFile
|
||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||
|
||
from weasyprint import HTML
|
||
import os
|
||
|
||
from configuration.models import SiteConfiguration
|
||
|
||
from thirdparty.zasilkovna.models import ZasilkovnaPacket
|
||
from thirdparty.stripe.models import StripeModel
|
||
|
||
from .tasks import notify_refund_accepted, notify_order_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,
|
||
related_name="variant_of",
|
||
help_text=(
|
||
"Symetrické varianty produktu: pokud přidáte variantu A → B, "
|
||
"Django automaticky přidá i variantu B → A. "
|
||
"Všechny varianty jsou rovnocenné a zobrazí se vzájemně."
|
||
),
|
||
)
|
||
|
||
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT)
|
||
|
||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||
|
||
url = models.SlugField(unique=True)
|
||
|
||
stock = models.PositiveIntegerField(default=0)
|
||
is_active = models.BooleanField(default=True)
|
||
|
||
#časový limit (volitelné)
|
||
limited_to = models.DateTimeField(null=True, blank=True)
|
||
|
||
default_carrier = models.ForeignKey(
|
||
"Carrier", on_delete=models.SET_NULL, null=True, blank=True, related_name="default_for_products"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
@property
|
||
def available(self):
|
||
return self.is_active and self.stock > 0
|
||
|
||
def __str__(self):
|
||
return f"{self.name} ({self.price} {self.currency.upper()})"
|
||
|
||
#obrázek pro produkty
|
||
class ProductImage(models.Model):
|
||
product = models.ForeignKey(Product, related_name='images', on_delete=models.CASCADE)
|
||
|
||
image = models.ImageField(upload_to='products/')
|
||
|
||
alt_text = models.CharField(max_length=150, blank=True)
|
||
is_main = models.BooleanField(default=False)
|
||
|
||
def __str__(self):
|
||
return f"{self.product.name} image"
|
||
|
||
|
||
|
||
# ------------------ OBJEDNÁVKY ------------------
|
||
|
||
class Order(models.Model):
|
||
class Status(models.TextChoices):
|
||
CREATED = "created", "cz#Vytvořeno"
|
||
CANCELLED = "cancelled", "cz#Zrušeno"
|
||
COMPLETED = "completed", "cz#Dokončeno"
|
||
|
||
REFUNDING = "refunding", "cz#Vrácení v procesu"
|
||
REFUNDED = "refunded", "cz#Vráceno"
|
||
|
||
status = models.CharField(
|
||
max_length=20, choices=Status.choices, null=True, blank=True, default=Status.CREATED
|
||
)
|
||
|
||
# Stored order grand total; recalculated on save
|
||
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||
currency = models.CharField(max_length=10, default="CZK")
|
||
|
||
# fakturační údaje (zkopírované z user profilu při objednávce)
|
||
user = models.ForeignKey(
|
||
settings.AUTH_USER_MODEL, on_delete=models.DO_NOTHING, related_name="orders", null=True, blank=True
|
||
)
|
||
|
||
first_name = models.CharField(max_length=100)
|
||
last_name = models.CharField(max_length=100)
|
||
email = models.EmailField()
|
||
phone = models.CharField(max_length=20, blank=True)
|
||
address = models.CharField(max_length=255)
|
||
city = models.CharField(max_length=100)
|
||
postal_code = models.CharField(max_length=20)
|
||
country = models.CharField(max_length=100, default="Czech Republic")
|
||
|
||
note = models.TextField(blank=True)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
carrier = models.OneToOneField(
|
||
"Carrier",
|
||
on_delete=models.CASCADE,
|
||
related_name="orders",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
payment = models.OneToOneField(
|
||
"Payment",
|
||
on_delete=models.CASCADE,
|
||
related_name="orders",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
invoice = models.OneToOneField("Invoice", on_delete=models.CASCADE, related_name="order", null=True, blank=True)
|
||
|
||
discount = models.ManyToManyField("DiscountCode", blank=True, related_name="orders")
|
||
|
||
def calculate_total_price(self):
|
||
carrier_price = self.carrier.get_price() if self.carrier else Decimal("0.0")
|
||
|
||
if self.discount.exists():
|
||
discounts = list(self.discount.all())
|
||
|
||
total = Decimal('0.0')
|
||
|
||
# getting all prices from order items (with discount applied if valid)
|
||
for item in self.items.all():
|
||
total = total + item.get_total_price(discounts)
|
||
|
||
return total + carrier_price
|
||
|
||
else:
|
||
total = Decimal('0.0')
|
||
# getting all prices from order items (without discount)
|
||
|
||
for item in self.items.all():
|
||
total = total + (item.product.price * item.quantity)
|
||
|
||
return total + carrier_price
|
||
|
||
def import_data_from_user(self):
|
||
"""Import user data into order for billing purposes."""
|
||
self.first_name = self.user.first_name
|
||
self.last_name = self.user.last_name
|
||
self.email = self.user.email
|
||
self.phone = self.user.phone
|
||
self.address = f"{self.user.street} {self.user.street_number}"
|
||
self.city = self.user.city
|
||
self.postal_code = self.user.postal_code
|
||
self.country = self.user.country
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Keep total_price always in sync with items and discount
|
||
self.total_price = self.calculate_total_price()
|
||
|
||
if self.user and self.pk is None:
|
||
self.import_data_from_user()
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
# ------------------ DOPRAVCI A ZPŮSOBY DOPRAVY ------------------
|
||
|
||
class Carrier(models.Model):
|
||
class SHIPPING(models.TextChoices):
|
||
ZASILKOVNA = "packeta", "cz#Zásilkovna"
|
||
STORE = "store", "cz#Osobní odběr"
|
||
shipping_method = models.CharField(max_length=20, choices=SHIPPING.choices, default=SHIPPING.STORE)
|
||
|
||
class STATE(models.TextChoices):
|
||
PREPARING = "ordered", "cz#Objednávka se připravuje"
|
||
SHIPPED = "shipped", "cz#Odesláno"
|
||
DELIVERED = "delivered", "cz#Doručeno"
|
||
READY_TO_PICKUP = "ready_to_pickup", "cz#Připraveno k vyzvednutí"
|
||
#RETURNING = "returning", "Vracení objednávky"
|
||
state = models.CharField(max_length=20, choices=STATE.choices, default=STATE.PREPARING)
|
||
|
||
# prodejce to přidá později
|
||
zasilkovna = models.ManyToManyField(
|
||
ZasilkovnaPacket, blank=True, related_name="carriers"
|
||
)
|
||
|
||
weight = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Hmotnost zásilky v kg")
|
||
|
||
returning = models.BooleanField(default=False, help_text="Zda je tato zásilka na vrácení")
|
||
|
||
shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||
|
||
def save(self, *args, **kwargs):
|
||
if self.pk is None:
|
||
|
||
if self.shipping_price is None:
|
||
self.shipping_price = self.get_price()
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
def get_price(self):
|
||
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||
return SiteConfiguration.get_solo().zasilkovna_shipping_price
|
||
else:
|
||
return Decimal('0.0')
|
||
|
||
|
||
#tohle bude vyvoláno pomocí admina přes api!!!
|
||
def start_ordering_shipping(self):
|
||
if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||
# Uživatel může objednat více zásilek pokud potřeba
|
||
self.zasilkovna.add(ZasilkovnaPacket.objects.create())
|
||
self.returning = False
|
||
self.save()
|
||
|
||
elif self.shipping_method == self.SHIPPING.STORE:
|
||
self.state = self.STATE.READY_TO_PICKUP
|
||
self.save()
|
||
|
||
else:
|
||
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
|
||
|
||
|
||
notify_order_sended.delay(order=self.orders.first(), user=self.orders.first().user)
|
||
|
||
#... další logika pro jiné způsoby dopravy
|
||
#TODO: přidat notifikace uživateli, jak pro zásilkovnu, tak pro vyzvednutí v obchodě!
|
||
|
||
def ready_to_pickup(self):
|
||
if self.shipping_method == self.SHIPPING.STORE:
|
||
self.state = self.STATE.READY_TO_PICKUP
|
||
self.save()
|
||
else:
|
||
raise ValidationError("Tato metoda dopravy nepodporuje připravení k vyzvednutí.")
|
||
|
||
# def returning_shipping(self, int:id):
|
||
# self.returning = True
|
||
|
||
# if self.shipping_method == self.SHIPPING.ZASILKOVNA:
|
||
# #volá se na api Zásilkovny
|
||
# self.zasilkovna.get(id=id).returning_packet()
|
||
|
||
|
||
# ------------------ PLATEBNÍ MODELY ------------------
|
||
|
||
class Payment(models.Model):
|
||
class PAYMENT(models.TextChoices):
|
||
Site = "Site", "cz#Platba v obchodě"
|
||
STRIPE = "stripe", "cz#Bankovní převod"
|
||
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka"
|
||
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.Site)
|
||
|
||
#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"
|
||
)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
updated_at = models.DateTimeField(auto_now=True)
|
||
|
||
|
||
# ------------------ SLEVOVÉ KÓDY ------------------
|
||
|
||
class DiscountCode(models.Model):
|
||
code = models.CharField(max_length=50, unique=True)
|
||
description = models.CharField(max_length=255, blank=True)
|
||
|
||
# sleva v procentech (0–100)
|
||
percent = models.PositiveIntegerField(
|
||
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
||
help_text="Procento sleva 0-100",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
# nebo fixní částka
|
||
amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True, help_text="Fixní sleva v CZK")
|
||
|
||
valid_from = models.DateTimeField(default=timezone.now)
|
||
valid_to = models.DateTimeField(null=True, blank=True)
|
||
active = models.BooleanField(default=True)
|
||
|
||
#max počet použití
|
||
usage_limit = models.PositiveIntegerField(null=True, blank=True)
|
||
used_count = models.PositiveIntegerField(default=0)
|
||
|
||
|
||
specific_products = models.ManyToManyField(
|
||
Product, blank=True, related_name="discount_codes"
|
||
)
|
||
specific_categories = models.ManyToManyField(
|
||
Category, blank=True, related_name="discount_codes"
|
||
)
|
||
|
||
def is_valid(self):
|
||
now = timezone.now()
|
||
if not self.active:
|
||
return False
|
||
|
||
if self.valid_to and self.valid_to < now:
|
||
return False
|
||
|
||
if self.usage_limit and self.used_count >= self.usage_limit:
|
||
return False
|
||
|
||
return True
|
||
|
||
def __str__(self):
|
||
return f"{self.code} ({self.percent}% or {self.amount} CZK)"
|
||
|
||
|
||
# ------------------ OBJEDNANÉ POLOŽKY ------------------
|
||
|
||
class OrderItem(models.Model):
|
||
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
|
||
product = models.ForeignKey("products.Product", on_delete=models.PROTECT)
|
||
quantity = models.PositiveIntegerField(default=1)
|
||
|
||
def get_total_price(self, discounts: list[DiscountCode] = None):
|
||
"""Vrátí celkovou cenu položky po aplikaci relevantních kupónů.
|
||
|
||
Logika dle 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.
|
||
"""
|
||
base_price = self.product.price * self.quantity
|
||
|
||
if not 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:
|
||
if self.order.payment.payment_method:
|
||
raise ValueError("Nelze upravit položky z objednávky s již zvolenou platební metodou.")
|
||
|
||
else:
|
||
#nová položka objednávky, snížit skladové zásoby
|
||
if self.product.stock < self.quantity:
|
||
raise ValueError("Nedostatečný skladový zásob pro produkt.")
|
||
|
||
self.product.stock -= self.quantity
|
||
self.product.save(update_fields=["stock"])
|
||
|
||
super().save(*args, **kwargs)
|
||
|
||
|
||
class Refund(models.Model):
|
||
order = models.ForeignKey(Order, related_name="refunds", on_delete=models.CASCADE)
|
||
|
||
class Reason(models.TextChoices):
|
||
RETUNING_PERIOD = "retuning_before_fourteen_day_period", "cz#Vrácení před uplynutím 14-ti denní lhůty"
|
||
DAMAGED_PRODUCT = "damaged_product", "cz#Poškozený produkt"
|
||
WRONG_ITEM = "wrong_item", "cz#Špatná položka"
|
||
OTHER = "other", "cz#Jiný důvod"
|
||
reason_choice = models.CharField(max_length=30, choices=Reason.choices)
|
||
|
||
reason_text = models.TextField(blank=True)
|
||
|
||
verified = models.BooleanField(default=False)
|
||
|
||
created_at = models.DateTimeField(auto_now_add=True)
|
||
|
||
#VRACENÍ ZÁSILKY, LOGIKA (DISABLED FOR NOW)
|
||
# def save(self, *args, **kwargs):
|
||
# # Automaticky aktualizovat stav objednávky na "vráceno"
|
||
# if self.pk is None:
|
||
# self.order.status = Order.Status.REFUNDING
|
||
# self.order.save(update_fields=["status", "updated_at"])
|
||
|
||
# shipping_method = self.order.carrier.shipping_method
|
||
|
||
# if shipping_method == Carrier.SHIPPING.ZASILKOVNA:
|
||
|
||
# carrier = self.order.carrier;
|
||
|
||
# # poslední odeslána/vytvořená zásilka
|
||
# # Iniciovat vrácení přes Zásilkovnu
|
||
# carrier.zasilkovna.latest('created_at').returning_packet()
|
||
# carrier.save()
|
||
|
||
# else:
|
||
# # Logika pro jiné způsoby dopravy
|
||
# pass
|
||
|
||
# super().save(*args, **kwargs)
|
||
|
||
def save(self, *args, **kwargs):
|
||
# Automaticky aktualizovat stav objednávky na "vráceno"
|
||
if self.pk is None:
|
||
if self.order.status != Order.Status.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.Status.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,
|
||
}
|
||
|
||
|
||
carrier = models.OneToOneField(
|
||
"Carrier",
|
||
on_delete=models.CASCADE,
|
||
related_name="refund",
|
||
null=True,
|
||
blank=True
|
||
)
|
||
|
||
|
||
html_string = render_to_string("refund/customer_in_package_returning_form.html", context)
|
||
pdf_bytes = HTML(string=html_string).write_pdf()
|
||
return pdf_bytes
|
||
|
||
|
||
class Invoice(models.Model):
|
||
invoice_number = models.CharField(max_length=50, unique=True)
|
||
|
||
issued_at = models.DateTimeField(auto_now_add=True)
|
||
due_date = models.DateTimeField()
|
||
|
||
pdf_file = models.FileField(upload_to='invoices/')
|
||
|
||
def __str__(self):
|
||
return f"Invoice {self.invoice_number} for Order {self.order.id}"
|
||
|
||
def generate_invoice_pdf(self):
|
||
order = Order.objects.get(invoice=self)
|
||
# Render HTML
|
||
html_string = render_to_string("invoice/invoice.html", {"invoice": self})
|
||
pdf_bytes = HTML(string=html_string).write_pdf()
|
||
|
||
# Save directly to FileField
|
||
self.pdf_file.save(f"{self.invoice_number}.pdf", ContentFile(pdf_bytes))
|
||
self.save() |