Add comprehensive analytics and VAT rate management
Introduced a full-featured analytics module for e-commerce business intelligence, including sales, product, customer, shipping, and review analytics, with API endpoints for dashboard and custom reports. Added VAT rate management: new VATRate model, admin interface, serializer, and API endpoints, and integrated VAT logic into Product and pricing calculations. Refactored configuration and admin code to support VAT rates, improved email notification tasks, and updated related serializers, views, and URLs for unified configuration and VAT management.
This commit is contained in:
@@ -7,6 +7,11 @@ from django.template.loader import render_to_string
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
except ImportError:
|
||||
HTML = None
|
||||
|
||||
import os
|
||||
|
||||
|
||||
@@ -62,7 +67,18 @@ class Product(models.Model):
|
||||
|
||||
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT)
|
||||
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||
# -- 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)
|
||||
|
||||
@@ -83,8 +99,30 @@ class Product(models.Model):
|
||||
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.price} {self.currency.upper()})"
|
||||
return f"{self.name} ({self.get_price_with_vat()} {self.currency.upper()} inkl. MwSt)"
|
||||
|
||||
#obrázek pro produkty
|
||||
class ProductImage(models.Model):
|
||||
@@ -120,7 +158,7 @@ class Order(models.Model):
|
||||
)
|
||||
|
||||
# Stored order grand total; recalculated on save
|
||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
||||
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)
|
||||
@@ -145,7 +183,7 @@ class Order(models.Model):
|
||||
carrier = models.OneToOneField(
|
||||
"Carrier",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="orders",
|
||||
related_name="order",
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
@@ -153,7 +191,7 @@ class Order(models.Model):
|
||||
payment = models.OneToOneField(
|
||||
"Payment",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="orders",
|
||||
related_name="order",
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
@@ -244,7 +282,7 @@ class Carrier(models.Model):
|
||||
|
||||
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)
|
||||
shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pk is None:
|
||||
@@ -254,7 +292,7 @@ class Carrier(models.Model):
|
||||
|
||||
if self.pk:
|
||||
if self.STATE == self.STATE.READY_TO_PICKUP and self.shipping_method == self.SHIPPING.STORE:
|
||||
notify_Ready_to_pickup.delay(order=self.orders.first(), user=self.orders.first().user)
|
||||
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
|
||||
pass
|
||||
else:
|
||||
pass
|
||||
@@ -278,7 +316,7 @@ class Carrier(models.Model):
|
||||
self.returning = False
|
||||
self.save()
|
||||
|
||||
notify_zasilkovna_sended.delay(order=self.orders.first(), user=self.orders.first().user)
|
||||
notify_zasilkovna_sended.delay(order=self.order, user=self.order.user)
|
||||
|
||||
elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
|
||||
# Import here to avoid circular imports
|
||||
@@ -297,7 +335,7 @@ class Carrier(models.Model):
|
||||
self.state = self.STATE.READY_TO_PICKUP
|
||||
self.save()
|
||||
|
||||
notify_Ready_to_pickup.delay(order=self.orders.first(), user=self.orders.first().user)
|
||||
notify_Ready_to_pickup.delay(order=self.order, user=self.order.user)
|
||||
|
||||
else:
|
||||
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.")
|
||||
@@ -325,10 +363,10 @@ class Carrier(models.Model):
|
||||
|
||||
class Payment(models.Model):
|
||||
class PAYMENT(models.TextChoices):
|
||||
SITE = "Site", "cz#Platba v obchodě", "de#Bezahlung im Geschäft"
|
||||
SHOP = "shop", "cz#Platba v obchodě", "de#Bezahlung im Geschäft"
|
||||
STRIPE = "stripe", "cz#Platební Brána", "de#Zahlungsgateway"
|
||||
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka", "de#Nachnahme"
|
||||
payment_method = models.CharField(max_length=30, choices=PAYMENT.choices, default=PAYMENT.SITE)
|
||||
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.)
|
||||
@@ -339,6 +377,12 @@ class Payment(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
def clean(self):
|
||||
"""Validate payment and shipping method combinations"""
|
||||
# TODO: Add validation logic for invalid payment/shipping combinations
|
||||
# TODO: Skip GoPay integration for now
|
||||
super().clean()
|
||||
|
||||
|
||||
# ------------------ SLEVOVÉ KÓDY ------------------
|
||||
|
||||
@@ -540,7 +584,8 @@ class Refund(models.Model):
|
||||
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:
|
||||
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)
|
||||
@@ -550,7 +595,7 @@ class Refund(models.Model):
|
||||
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.status = Order.OrderStatus.REFUNDED
|
||||
self.order.save(update_fields=["status", "updated_at"])
|
||||
|
||||
|
||||
@@ -619,16 +664,13 @@ class Invoice(models.Model):
|
||||
def generate_invoice_pdf(self):
|
||||
order = Order.objects.get(invoice=self)
|
||||
# Render HTML
|
||||
html_string = render_to_string("invoice/invoice.html", {"invoice": self})
|
||||
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.
|
||||
try:
|
||||
|
||||
from weasyprint import HTML
|
||||
except Exception as e:
|
||||
if HTML is None:
|
||||
raise RuntimeError(
|
||||
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker."
|
||||
) from e
|
||||
)
|
||||
|
||||
pdf_bytes = HTML(string=html_string).write_pdf()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user