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:
2026-01-19 02:13:47 +01:00
parent e78baf746c
commit 2a26edac80
9 changed files with 1055 additions and 133 deletions

View File

@@ -0,0 +1,506 @@
"""
E-commerce Analytics Module
Provides comprehensive business intelligence for the e-commerce platform.
All analytics functions return data structures suitable for frontend charts/graphs.
"""
from django.db.models import Sum, Count, Avg, Q, F
from django.utils import timezone
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Dict, List, Any, Optional
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek
from .models import Order, Product, OrderItem, Payment, Carrier, Review, Cart, CartItem
from configuration.models import SiteConfiguration
class SalesAnalytics:
"""Sales and revenue analytics"""
@staticmethod
def revenue_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
period: str = "daily"
) -> Dict[str, Any]:
"""
Get revenue overview with configurable date range and period
Args:
start_date: Start date for analysis (default: last 30 days)
end_date: End date for analysis (default: today)
period: "daily", "weekly", "monthly" (default: daily)
Returns:
Dict with total_revenue, order_count, avg_order_value, and time_series data
"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
# Base queryset for completed orders
orders = Order.objects.filter(
status=Order.OrderStatus.COMPLETED,
created_at__range=(start_date, end_date)
)
# Aggregate totals
totals = orders.aggregate(
total_revenue=Sum('total_price'),
order_count=Count('id'),
avg_order_value=Avg('total_price')
)
# Time series data based on period
trunc_function = {
'daily': TruncDate,
'weekly': TruncWeek,
'monthly': TruncMonth,
}.get(period, TruncDate)
time_series = (
orders
.annotate(period=trunc_function('created_at'))
.values('period')
.annotate(
revenue=Sum('total_price'),
orders=Count('id')
)
.order_by('period')
)
return {
'total_revenue': totals['total_revenue'] or Decimal('0'),
'order_count': totals['order_count'] or 0,
'avg_order_value': totals['avg_order_value'] or Decimal('0'),
'time_series': list(time_series),
'period': period,
'date_range': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
}
}
@staticmethod
def payment_methods_breakdown(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get breakdown of payment methods usage"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
payment_stats = (
Payment.objects
.filter(order__created_at__range=(start_date, end_date))
.values('payment_method')
.annotate(
count=Count('id'),
revenue=Sum('order__total_price')
)
.order_by('-revenue')
)
return [
{
'method': item['payment_method'],
'method_display': dict(Payment.PAYMENT.choices).get(item['payment_method'], item['payment_method']),
'count': item['count'],
'revenue': item['revenue'] or Decimal('0'),
'percentage': 0 # Will be calculated in the view
}
for item in payment_stats
]
class ProductAnalytics:
"""Product performance analytics"""
@staticmethod
def top_selling_products(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 10
) -> List[Dict[str, Any]]:
"""Get top selling products by quantity and revenue"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
top_products = (
OrderItem.objects
.filter(order__created_at__range=(start_date, end_date))
.select_related('product')
.values('product__id', 'product__name', 'product__price')
.annotate(
total_quantity=Sum('quantity'),
total_revenue=Sum(F('quantity') * F('product__price')),
order_count=Count('order', distinct=True)
)
.order_by('-total_revenue')[:limit]
)
return [
{
'product_id': item['product__id'],
'product_name': item['product__name'],
'unit_price': item['product__price'],
'total_quantity': item['total_quantity'],
'total_revenue': item['total_revenue'],
'order_count': item['order_count'],
'avg_quantity_per_order': round(item['total_quantity'] / item['order_count'], 2)
}
for item in top_products
]
@staticmethod
def category_performance(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get category performance breakdown"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
category_stats = (
OrderItem.objects
.filter(order__created_at__range=(start_date, end_date))
.select_related('product__category')
.values('product__category__id', 'product__category__name')
.annotate(
total_quantity=Sum('quantity'),
total_revenue=Sum(F('quantity') * F('product__price')),
product_count=Count('product', distinct=True),
order_count=Count('order', distinct=True)
)
.order_by('-total_revenue')
)
return [
{
'category_id': item['product__category__id'],
'category_name': item['product__category__name'],
'total_quantity': item['total_quantity'],
'total_revenue': item['total_revenue'],
'product_count': item['product_count'],
'order_count': item['order_count']
}
for item in category_stats
]
@staticmethod
def inventory_analysis() -> Dict[str, Any]:
"""Get inventory status and low stock alerts"""
total_products = Product.objects.filter(is_active=True).count()
out_of_stock = Product.objects.filter(is_active=True, stock=0).count()
low_stock = Product.objects.filter(
is_active=True,
stock__gt=0,
stock__lte=10 # Consider configurable threshold
).count()
low_stock_products = (
Product.objects
.filter(is_active=True, stock__lte=10)
.select_related('category')
.values('id', 'name', 'stock', 'category__name')
.order_by('stock')[:20]
)
return {
'total_products': total_products,
'out_of_stock_count': out_of_stock,
'low_stock_count': low_stock,
'in_stock_count': total_products - out_of_stock,
'low_stock_products': list(low_stock_products),
'stock_distribution': {
'out_of_stock': out_of_stock,
'low_stock': low_stock,
'in_stock': total_products - out_of_stock - low_stock
}
}
class CustomerAnalytics:
"""Customer behavior and demographics analytics"""
@staticmethod
def customer_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""Get customer acquisition and behavior overview"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
# New vs returning customers
period_orders = Order.objects.filter(created_at__range=(start_date, end_date))
# First-time customers (users with their first order in this period)
first_time_customers = period_orders.filter(
user__orders__created_at__lt=start_date
).values('user').distinct().count()
# Returning customers
total_customers = period_orders.values('user').distinct().count()
returning_customers = total_customers - first_time_customers
# Customer lifetime value (simplified)
customer_stats = (
Order.objects
.filter(user__isnull=False)
.values('user')
.annotate(
total_orders=Count('id'),
total_spent=Sum('total_price'),
avg_order_value=Avg('total_price')
)
)
avg_customer_ltv = customer_stats.aggregate(
avg_ltv=Avg('total_spent')
)['avg_ltv'] or Decimal('0')
return {
'total_customers': total_customers,
'new_customers': first_time_customers,
'returning_customers': returning_customers,
'avg_customer_lifetime_value': avg_customer_ltv,
'date_range': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
}
}
@staticmethod
def cart_abandonment_analysis() -> Dict[str, Any]:
"""Analyze cart abandonment rates"""
# Active carts (updated in last 7 days)
week_ago = timezone.now() - timedelta(days=7)
active_carts = Cart.objects.filter(updated_at__gte=week_ago)
# Completed orders from carts
completed_orders = Order.objects.filter(
user__cart__in=active_carts,
created_at__gte=week_ago
).count()
total_carts = active_carts.count()
abandoned_carts = max(0, total_carts - completed_orders)
abandonment_rate = (abandoned_carts / total_carts * 100) if total_carts > 0 else 0
return {
'total_active_carts': total_carts,
'completed_orders': completed_orders,
'abandoned_carts': abandoned_carts,
'abandonment_rate': round(abandonment_rate, 2),
'analysis_period': '7 days'
}
class ShippingAnalytics:
"""Shipping and logistics analytics"""
@staticmethod
def shipping_methods_breakdown(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> List[Dict[str, Any]]:
"""Get breakdown of shipping methods usage"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
shipping_stats = (
Carrier.objects
.filter(order__created_at__range=(start_date, end_date))
.values('shipping_method', 'state')
.annotate(
count=Count('id'),
total_shipping_cost=Sum('shipping_price')
)
.order_by('-count')
)
return [
{
'shipping_method': item['shipping_method'],
'method_display': dict(Carrier.SHIPPING.choices).get(item['shipping_method'], item['shipping_method']),
'state': item['state'],
'state_display': dict(Carrier.STATE.choices).get(item['state'], item['state']),
'count': item['count'],
'total_cost': item['total_shipping_cost'] or Decimal('0')
}
for item in shipping_stats
]
@staticmethod
def deutsche_post_analytics() -> Dict[str, Any]:
"""Get Deutsche Post shipping analytics and pricing info"""
try:
# Import Deutsche Post models
from thirdparty.deutschepost.models import DeutschePostOrder
# Get Deutsche Post orders statistics
dp_orders = DeutschePostOrder.objects.all()
total_dp_orders = dp_orders.count()
# Get configuration for pricing
config = SiteConfiguration.get_solo()
dp_default_price = config.deutschepost_shipping_price
# Status breakdown (if available in the model)
# Note: This depends on actual DeutschePostOrder model structure
return {
'total_deutsche_post_orders': total_dp_orders,
'default_shipping_price': dp_default_price,
'api_configured': bool(config.deutschepost_client_id and config.deutschepost_client_secret),
'api_endpoint': config.deutschepost_api_url,
'analysis_note': 'Detailed Deutsche Post analytics require API integration'
}
except ImportError:
return {
'error': 'Deutsche Post module not available',
'total_deutsche_post_orders': 0,
'default_shipping_price': Decimal('0')
}
class ReviewAnalytics:
"""Product review and rating analytics"""
@staticmethod
def review_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""Get review statistics and sentiment overview"""
if not start_date:
start_date = timezone.now() - timedelta(days=30)
if not end_date:
end_date = timezone.now()
reviews = Review.objects.filter(created_at__range=(start_date, end_date))
rating_distribution = (
reviews
.values('rating')
.annotate(count=Count('id'))
.order_by('rating')
)
avg_rating = reviews.aggregate(avg=Avg('rating'))['avg'] or 0
total_reviews = reviews.count()
# Top rated products
top_rated_products = (
Review.objects
.filter(created_at__range=(start_date, end_date))
.select_related('product')
.values('product__id', 'product__name')
.annotate(
avg_rating=Avg('rating'),
review_count=Count('id')
)
.filter(review_count__gte=3) # At least 3 reviews
.order_by('-avg_rating')[:10]
)
return {
'total_reviews': total_reviews,
'average_rating': round(avg_rating, 2),
'rating_distribution': [
{
'rating': item['rating'],
'count': item['count'],
'percentage': round(item['count'] / total_reviews * 100, 1) if total_reviews > 0 else 0
}
for item in rating_distribution
],
'top_rated_products': list(top_rated_products),
'date_range': {
'start': start_date.isoformat(),
'end': end_date.isoformat()
}
}
class AnalyticsAggregator:
"""Main analytics aggregator for dashboard views"""
@staticmethod
def dashboard_overview(
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None
) -> Dict[str, Any]:
"""Get comprehensive dashboard data"""
return {
'sales': SalesAnalytics.revenue_overview(start_date, end_date),
'products': {
'top_selling': ProductAnalytics.top_selling_products(start_date, end_date, limit=5),
'inventory': ProductAnalytics.inventory_analysis()
},
'customers': CustomerAnalytics.customer_overview(start_date, end_date),
'shipping': {
'methods': ShippingAnalytics.shipping_methods_breakdown(start_date, end_date),
'deutsche_post': ShippingAnalytics.deutsche_post_analytics()
},
'reviews': ReviewAnalytics.review_overview(start_date, end_date),
'generated_at': timezone.now().isoformat()
}
def get_predefined_date_ranges() -> Dict[str, Dict[str, datetime]]:
"""Get predefined date ranges for easy frontend integration"""
now = timezone.now()
return {
'today': {
'start': now.replace(hour=0, minute=0, second=0, microsecond=0),
'end': now
},
'yesterday': {
'start': (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0),
'end': (now - timedelta(days=1)).replace(hour=23, minute=59, second=59)
},
'last_7_days': {
'start': now - timedelta(days=7),
'end': now
},
'last_30_days': {
'start': now - timedelta(days=30),
'end': now
},
'last_90_days': {
'start': now - timedelta(days=90),
'end': now
},
'this_month': {
'start': now.replace(day=1, hour=0, minute=0, second=0, microsecond=0),
'end': now
},
'last_month': {
'start': (now.replace(day=1) - timedelta(days=1)).replace(day=1, hour=0, minute=0, second=0, microsecond=0),
'end': (now.replace(day=1) - timedelta(days=1)).replace(hour=23, minute=59, second=59)
},
'this_year': {
'start': now.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0),
'end': now
},
'last_year': {
'start': (now.replace(month=1, day=1) - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0),
'end': (now.replace(month=1, day=1) - timedelta(days=1)).replace(hour=23, minute=59, second=59)
}
}

View File

@@ -7,6 +7,11 @@ from django.template.loader import render_to_string
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
try:
from weasyprint import HTML
except ImportError:
HTML = None
import os import os
@@ -62,7 +67,18 @@ class Product(models.Model):
category = models.ForeignKey(Category, related_name='products', on_delete=models.PROTECT) 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) url = models.SlugField(unique=True)
@@ -83,8 +99,30 @@ class Product(models.Model):
def available(self): def available(self):
return self.is_active and self.stock > 0 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): 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 #obrázek pro produkty
class ProductImage(models.Model): class ProductImage(models.Model):
@@ -120,7 +158,7 @@ class Order(models.Model):
) )
# Stored order grand total; recalculated on save # 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") currency = models.CharField(max_length=10, default="CZK")
# fakturační údaje (zkopírované z user profilu při objednávce) # fakturační údaje (zkopírované z user profilu při objednávce)
@@ -145,7 +183,7 @@ class Order(models.Model):
carrier = models.OneToOneField( carrier = models.OneToOneField(
"Carrier", "Carrier",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="orders", related_name="order",
null=True, null=True,
blank=True blank=True
) )
@@ -153,7 +191,7 @@ class Order(models.Model):
payment = models.OneToOneField( payment = models.OneToOneField(
"Payment", "Payment",
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="orders", related_name="order",
null=True, null=True,
blank=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í") 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): def save(self, *args, **kwargs):
if self.pk is None: if self.pk is None:
@@ -254,7 +292,7 @@ class Carrier(models.Model):
if self.pk: if self.pk:
if self.STATE == self.STATE.READY_TO_PICKUP and self.shipping_method == self.SHIPPING.STORE: 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 pass
else: else:
pass pass
@@ -278,7 +316,7 @@ class Carrier(models.Model):
self.returning = False self.returning = False
self.save() 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: elif self.shipping_method == self.SHIPPING.DEUTSCHEPOST:
# Import here to avoid circular imports # Import here to avoid circular imports
@@ -297,7 +335,7 @@ class Carrier(models.Model):
self.state = self.STATE.READY_TO_PICKUP self.state = self.STATE.READY_TO_PICKUP
self.save() 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: else:
raise ValidationError("Tato metoda dopravy nepodporuje objednání přepravy.") 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.Model):
class PAYMENT(models.TextChoices): 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" STRIPE = "stripe", "cz#Platební Brána", "de#Zahlungsgateway"
CASH_ON_DELIVERY = "cash_on_delivery", "cz#Dobírka", "de#Nachnahme" 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ě #FIXME: potvrdit že logika platby funguje správně
#veškera logika a interakce bude na stripu (třeba aktualizovaní objednávky na zaplacenou apod.) #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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=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 ------------------ # ------------------ SLEVOVÉ KÓDY ------------------
@@ -540,7 +584,8 @@ class Refund(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Automaticky aktualizovat stav objednávky na "vráceno" # Automaticky aktualizovat stav objednávky na "vráceno"
if self.pk is None: 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"]) self.order.save(update_fields=["status", "updated_at"])
super().save(*args, **kwargs) 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: 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.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"]) self.order.save(update_fields=["status", "updated_at"])
@@ -619,16 +664,13 @@ class Invoice(models.Model):
def generate_invoice_pdf(self): def generate_invoice_pdf(self):
order = Order.objects.get(invoice=self) order = Order.objects.get(invoice=self)
# Render HTML # 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 # Import WeasyPrint lazily to avoid startup failures when system
# libraries (Pango/GObject) are not present on Windows. # libraries (Pango/GObject) are not present on Windows.
try: if HTML is None:
from weasyprint import HTML
except Exception as e:
raise RuntimeError( raise RuntimeError(
"WeasyPrint is not available. Install its system dependencies (Pango/GTK) or run the backend in Docker." "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() pdf_bytes = HTML(string=html_string).write_pdf()

View File

@@ -12,7 +12,7 @@ from django.utils import timezone
def delete_expired_orders(): def delete_expired_orders():
Order = apps.get_model('commerce', 'Order') Order = apps.get_model('commerce', 'Order')
expired_orders = Order.objects.filter(status=Order.STATUS_CHOICES.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24)) expired_orders = Order.objects.filter(status=Order.OrderStatus.CANCELLED, created_at__lt=timezone.now() - timezone.timedelta(hours=24))
count = expired_orders.count() count = expired_orders.count()
expired_orders.delete() expired_orders.delete()
return count return count
@@ -30,10 +30,9 @@ def notify_zasilkovna_sended(order = None, user = None, **kwargs):
print("Additional kwargs received in notify_order_sended:", kwargs) print("Additional kwargs received in notify_order_sended:", kwargs)
send_email_with_context( send_email_with_context(
user.email, recipients=user.email,
subject="Your order has been shipped", subject="Your order has been shipped",
template_name="emails/order_sended.txt", template_path="email/order_sended.html",
html_template_name="emails/order_sended.html",
context={ context={
"user": user, "user": user,
"order": order, "order": order,
@@ -52,10 +51,9 @@ def notify_Ready_to_pickup(order = None, user = None, **kwargs):
print("Additional kwargs received in notify_order_sended:", kwargs) print("Additional kwargs received in notify_order_sended:", kwargs)
send_email_with_context( send_email_with_context(
user.email, recipients=user.email,
subject="Your order has been shipped", subject="Your order is ready for pickup",
template_name="emails/order_sended.txt", template_path="email/order_ready_pickup.html",
html_template_name="emails/order_sended.html",
context={ context={
"user": user, "user": user,
"order": order, "order": order,
@@ -75,10 +73,9 @@ def notify_order_successfuly_created(order = None, user = None, **kwargs):
print("Additional kwargs received in notify_order_successfuly_created:", kwargs) print("Additional kwargs received in notify_order_successfuly_created:", kwargs)
send_email_with_context( send_email_with_context(
user.email, recipients=user.email,
subject="Your order has been successfully created", subject="Your order has been successfully created",
template_name="emails/order_created.txt", template_path="email/order_created.html",
html_template_name="emails/order_created.html",
context={ context={
"user": user, "user": user,
"order": order, "order": order,
@@ -96,10 +93,9 @@ def notify_about_missing_payment(order = None, user = None, **kwargs):
print("Additional kwargs received in notify_about_missing_payment:", kwargs) print("Additional kwargs received in notify_about_missing_payment:", kwargs)
send_email_with_context( send_email_with_context(
user.email, recipients=user.email,
subject="Payment missing for your order", subject="Payment missing for your order",
template_name="emails/order_missing_payment.txt", template_path="email/order_missing_payment.html",
html_template_name="emails/order_missing_payment.html",
context={ context={
"user": user, "user": user,
"order": order, "order": order,
@@ -116,10 +112,9 @@ def notify_refund_items_arrived(order = None, user = None, **kwargs):
print("Additional kwargs received in notify_refund_items_arrived:", kwargs) print("Additional kwargs received in notify_refund_items_arrived:", kwargs)
send_email_with_context( send_email_with_context(
user.email, recipients=user.email,
subject="Your refund items have arrived", subject="Your refund items have arrived",
template_name="emails/order_refund_items_arrived.txt", template_path="email/order_refund_items_arrived.html",
html_template_name="emails/order_refund_items_arrived.html",
context={ context={
"user": user, "user": user,
"order": order, "order": order,
@@ -138,10 +133,9 @@ def notify_refund_accepted(order = None, user = None, **kwargs):
print("Additional kwargs received in notify_refund_accepted:", kwargs) print("Additional kwargs received in notify_refund_accepted:", kwargs)
send_email_with_context( send_email_with_context(
user.email, recipients=user.email,
subject="Your refund has been accepted", subject="Your refund has been accepted",
template_name="emails/order_refund_accepted.txt", template_path="email/order_refund_accepted.html",
html_template_name="emails/order_refund_accepted.html",
context={ context={
"user": user, "user": user,
"order": order, "order": order,

View File

@@ -3,9 +3,16 @@ from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
import base64 import base64
from datetime import datetime
from django.utils.dateparse import parse_datetime
from .models import Refund, Review from .models import Refund, Review
from .serializers import RefundCreatePublicSerializer, ReviewSerializerPublic from .serializers import RefundCreatePublicSerializer, ReviewSerializerPublic
from .analytics import (
SalesAnalytics, ProductAnalytics, CustomerAnalytics,
ShippingAnalytics, ReviewAnalytics, AnalyticsAggregator,
get_predefined_date_ranges
)
from django.db import transaction from django.db import transaction
@@ -854,66 +861,237 @@ class AdminWishlistViewSet(viewsets.ModelViewSet):
ordering = ["-updated_at"] ordering = ["-updated_at"]
# ---------- ANALYTICS PLACEHOLDER ---------- # ---------- ANALYTICS SYSTEM ----------
# TODO: Implement comprehensive analytics system class AnalyticsView(APIView):
# Ideas:
# - Sales analytics (daily/monthly/yearly revenue)
# - Product performance (most sold, most viewed, most wishlisted)
# - User behavior analytics (cart abandonment, conversion rates)
# - Inventory reports (low stock alerts, bestsellers)
# - Regional sales data
# - Seasonal trends analysis
# - Customer lifetime value
# - Refund/return analytics
@extend_schema_view(
list=extend_schema(tags=["commerce", "analytics"], summary="Analytics dashboard (admin)"),
)
class AnalyticsViewSet(viewsets.GenericViewSet):
""" """
Analytics and reporting endpoints for business intelligence Comprehensive analytics and reporting API for business intelligence.
TODO: Implement comprehensive analytics Supports configurable date ranges and various analytics modules.
GET /analytics/ - Complete dashboard overview
GET /analytics/?type=sales - Sales analytics
GET /analytics/?type=products - Product analytics
GET /analytics/?type=customers - Customer analytics
GET /analytics/?type=shipping - Shipping analytics
GET /analytics/?type=reviews - Review analytics
GET /analytics/?type=inventory - Inventory analytics
GET /analytics/?type=ranges - Available date ranges
POST /analytics/ - Generate custom analytics reports
""" """
permission_classes = [permissions.IsAdminUser] permission_classes = [permissions.IsAdminUser]
@action(detail=False, methods=['get'], url_path='sales-overview') def _parse_date_params(self, request):
"""Parse date parameters from request"""
start_date = request.query_params.get('start_date')
end_date = request.query_params.get('end_date')
period = request.query_params.get('period', 'last_30_days')
if start_date:
start_date = parse_datetime(start_date)
if end_date:
end_date = parse_datetime(end_date)
# If no dates provided, use predefined period
if not start_date or not end_date:
predefined_ranges = get_predefined_date_ranges()
if period in predefined_ranges:
date_range = predefined_ranges[period]
start_date = start_date or date_range['start']
end_date = end_date or date_range['end']
return start_date, end_date, period
@extend_schema( @extend_schema(
tags=["commerce", "analytics"], tags=["commerce", "analytics"],
summary="Sales overview (TODO)", summary="Get analytics data",
responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}}, description="Get various analytics based on type parameter",
parameters=[
{"name": "type", "in": "query", "schema": {"type": "string", "enum": ["dashboard", "sales", "products", "customers", "shipping", "reviews", "inventory", "ranges"]}, "description": "Type of analytics to retrieve"},
{"name": "start_date", "in": "query", "schema": {"type": "string", "format": "date-time"}},
{"name": "end_date", "in": "query", "schema": {"type": "string", "format": "date-time"}},
{"name": "period", "in": "query", "schema": {"type": "string", "enum": ["today", "yesterday", "last_7_days", "last_30_days", "last_90_days", "this_month", "last_month", "this_year", "last_year"]}},
{"name": "limit", "in": "query", "schema": {"type": "integer", "minimum": 1, "maximum": 100}, "description": "Limit for product analytics"}
]
) )
def sales_overview(self, request): def get(self, request):
"""TODO: Implement sales analytics""" """Get analytics data based on type parameter"""
return Response({"message": "Sales analytics coming soon!"}) analytics_type = request.query_params.get('type', 'dashboard')
start_date, end_date, period = self._parse_date_params(request)
if analytics_type == 'dashboard':
return self._get_dashboard_analytics(start_date, end_date, period)
elif analytics_type == 'sales':
return self._get_sales_analytics(start_date, end_date, period)
elif analytics_type == 'products':
return self._get_product_analytics(request, start_date, end_date)
elif analytics_type == 'customers':
return self._get_customer_analytics(start_date, end_date)
elif analytics_type == 'shipping':
return self._get_shipping_analytics(start_date, end_date)
elif analytics_type == 'reviews':
return self._get_review_analytics(start_date, end_date)
elif analytics_type == 'inventory':
return self._get_inventory_analytics()
elif analytics_type == 'ranges':
return self._get_date_ranges()
else:
return Response({'error': 'Invalid analytics type'}, status=400)
def _get_dashboard_analytics(self, start_date, end_date, period):
"""Get complete analytics dashboard overview"""
dashboard_data = AnalyticsAggregator.dashboard_overview(start_date, end_date)
dashboard_data['query_params'] = {
'start_date': start_date.isoformat() if start_date else None,
'end_date': end_date.isoformat() if end_date else None,
'period': period
}
return Response(dashboard_data)
def _get_sales_analytics(self, start_date, end_date, period):
"""Get detailed sales and revenue analytics"""
sales_data = SalesAnalytics.revenue_overview(start_date, end_date, period)
payment_data = SalesAnalytics.payment_methods_breakdown(start_date, end_date)
# Calculate percentages for payment methods
total_revenue = sum(item['revenue'] for item in payment_data)
for item in payment_data:
item['percentage'] = round(
(item['revenue'] / total_revenue * 100) if total_revenue > 0 else 0, 2
)
return Response({
'sales': sales_data,
'payment_methods': payment_data
})
def _get_product_analytics(self, request, start_date, end_date):
"""Get product performance analytics"""
limit = int(request.query_params.get('limit', 20))
top_products = ProductAnalytics.top_selling_products(start_date, end_date, limit)
category_performance = ProductAnalytics.category_performance(start_date, end_date)
return Response({
'top_products': top_products,
'category_performance': category_performance
})
def _get_customer_analytics(self, start_date, end_date):
"""Get customer behavior analytics"""
customer_overview = CustomerAnalytics.customer_overview(start_date, end_date)
cart_analysis = CustomerAnalytics.cart_abandonment_analysis()
return Response({
'customer_overview': customer_overview,
'cart_abandonment': cart_analysis
})
def _get_shipping_analytics(self, start_date, end_date):
"""Get shipping methods and Deutsche Post analytics"""
shipping_methods = ShippingAnalytics.shipping_methods_breakdown(start_date, end_date)
deutsche_post_data = ShippingAnalytics.deutsche_post_analytics()
return Response({
'shipping_methods': shipping_methods,
'deutsche_post': deutsche_post_data
})
def _get_review_analytics(self, start_date, end_date):
"""Get review and rating analytics"""
review_data = ReviewAnalytics.review_overview(start_date, end_date)
return Response(review_data)
def _get_inventory_analytics(self):
"""Get comprehensive inventory analytics"""
inventory_data = ProductAnalytics.inventory_analysis()
return Response(inventory_data)
def _get_date_ranges(self):
"""Get available predefined date ranges"""
ranges = get_predefined_date_ranges()
return Response({
'available_ranges': list(ranges.keys()),
'ranges': {
key: {
'start': value['start'].isoformat(),
'end': value['end'].isoformat(),
'display_name': key.replace('_', ' ').title()
}
for key, value in ranges.items()
}
})
@action(detail=False, methods=['get'], url_path='product-performance')
@extend_schema( @extend_schema(
tags=["commerce", "analytics"], tags=["commerce", "analytics"],
summary="Product performance analytics (TODO)", summary="Generate custom analytics report",
responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}}, description="Generate custom analytics based on specified modules and parameters",
request={
"type": "object",
"properties": {
"modules": {"type": "array", "items": {"type": "string", "enum": ["sales", "products", "customers", "shipping", "reviews"]}},
"start_date": {"type": "string", "format": "date-time"},
"end_date": {"type": "string", "format": "date-time"},
"period": {"type": "string", "enum": ["daily", "weekly", "monthly"]},
"options": {"type": "object"}
}
}
) )
def product_performance(self, request): def post(self, request):
"""TODO: Implement product performance analytics""" """Generate custom analytics report based on specified modules"""
return Response({"message": "Product analytics coming soon!"}) modules = request.data.get('modules', ['sales', 'products'])
start_date = request.data.get('start_date')
end_date = request.data.get('end_date')
period = request.data.get('period', 'daily')
options = request.data.get('options', {})
@action(detail=False, methods=['get'], url_path='inventory-report') if start_date:
@extend_schema( start_date = parse_datetime(start_date)
tags=["commerce", "analytics"], if end_date:
summary="Inventory status report (TODO)", end_date = parse_datetime(end_date)
responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}},
) # If no dates provided, default to last 30 days
def inventory_report(self, request): if not start_date or not end_date:
"""TODO: Implement inventory management features""" predefined_ranges = get_predefined_date_ranges()
# TODO Ideas: date_range = predefined_ranges.get('last_30_days')
# - Low stock alerts (products with stock < threshold) start_date = start_date or date_range['start']
# - Out of stock products list end_date = end_date or date_range['end']
# - Bestsellers vs slow movers
# - Stock value calculation report_data = {}
# - Automated reorder suggestions
# - Stock movement history if 'sales' in modules:
# - Bulk stock updates report_data['sales'] = {
# - Supplier management integration 'revenue': SalesAnalytics.revenue_overview(start_date, end_date, period),
return Response({"message": "Inventory analytics coming soon!"}) 'payment_methods': SalesAnalytics.payment_methods_breakdown(start_date, end_date)
}
if 'products' in modules:
limit = options.get('product_limit', 20)
report_data['products'] = {
'top_selling': ProductAnalytics.top_selling_products(start_date, end_date, limit),
'categories': ProductAnalytics.category_performance(start_date, end_date)
}
if 'customers' in modules:
report_data['customers'] = CustomerAnalytics.customer_overview(start_date, end_date)
if 'shipping' in modules:
report_data['shipping'] = {
'methods': ShippingAnalytics.shipping_methods_breakdown(start_date, end_date),
'deutsche_post': ShippingAnalytics.deutsche_post_analytics()
}
if 'reviews' in modules:
report_data['reviews'] = ReviewAnalytics.review_overview(start_date, end_date)
return Response({
'report': report_data,
'parameters': {
'modules': modules,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'period': period,
'generated_at': datetime.now().isoformat()
}
})

View File

@@ -1,3 +1,59 @@
from django.contrib import admin from django.contrib import admin
from .models import SiteConfiguration, VATRate
# Register your models here. # Register your models here.
@admin.register(SiteConfiguration)
class SiteConfigurationAdmin(admin.ModelAdmin):
fieldsets = (
('Basic Information', {
'fields': ('name', 'logo', 'favicon', 'currency')
}),
('Contact Information', {
'fields': ('contact_email', 'contact_phone', 'contact_address', 'opening_hours')
}),
('Social Media', {
'fields': ('facebook_url', 'instagram_url', 'youtube_url', 'tiktok_url', 'whatsapp_number')
}),
('Shipping Settings', {
'fields': ('zasilkovna_shipping_price', 'deutschepost_shipping_price', 'free_shipping_over')
}),
('API Credentials', {
'fields': ('zasilkovna_api_key', 'zasilkovna_api_password', 'deutschepost_client_id', 'deutschepost_client_secret', 'deutschepost_customer_ekp'),
'classes': ('collapse',)
}),
('Coupon Settings', {
'fields': ('multiplying_coupons', 'addition_of_coupons_amount')
}),
)
@admin.register(VATRate)
class VATRateAdmin(admin.ModelAdmin):
list_display = ('name', 'rate', 'is_default', 'is_active', 'description')
list_filter = ('is_active', 'is_default')
search_fields = ('name', 'description')
list_editable = ('is_active',)
def get_readonly_fields(self, request, obj=None):
# Make is_default read-only in change form to prevent conflicts
if obj: # editing an existing object
return ('is_default',) if not obj.is_default else ()
return ()
actions = ['make_default']
def make_default(self, request, queryset):
if queryset.count() != 1:
self.message_user(request, "Select exactly one VAT rate to make default.", level='ERROR')
return
vat_rate = queryset.first()
# Clear existing defaults
VATRate.objects.filter(is_default=True).update(is_default=False)
# Set new default
vat_rate.is_default = True
vat_rate.save()
self.message_user(request, f"'{vat_rate.name}' is now the default VAT rate.")
make_default.short_description = "Make selected VAT rate the default"

View File

@@ -1,4 +1,7 @@
import decimal
from django.db import models from django.db import models
from decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
# Create your models here. # Create your models here.
@@ -21,20 +24,20 @@ class SiteConfiguration(models.Model):
whatsapp_number = models.CharField(max_length=20, blank=True, null=True) whatsapp_number = models.CharField(max_length=20, blank=True, null=True)
#zasilkovna settings #zasilkovna settings
zasilkovna_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=50) zasilkovna_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("50.00"))
#FIXME: není implementováno ↓↓↓ #FIXME: není implementováno ↓↓↓
zasilkovna_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API klíč pro přístup k Zásilkovna API (zatím není využito)") zasilkovna_api_key = models.CharField(max_length=255, blank=True, null=True, help_text="API klíč pro přístup k Zásilkovna API (zatím není využito)")
#FIXME: není implementováno ↓↓↓ #FIXME: není implementováno ↓↓↓
zasilkovna_api_password = models.CharField(max_length=255, blank=True, null=True, help_text="API heslo pro přístup k Zásilkovna API (zatím není využito)") zasilkovna_api_password = models.CharField(max_length=255, blank=True, null=True, help_text="API heslo pro přístup k Zásilkovna API (zatím není využito)")
#FIXME: není implementováno ↓↓↓ #FIXME: není implementováno ↓↓↓
free_shipping_over = models.DecimalField(max_digits=10, decimal_places=2, default=2000) free_shipping_over = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("2000.00"))
# Deutsche Post settings # Deutsche Post settings
deutschepost_api_url = models.URLField(max_length=255, default="https://gw.sandbox.deutschepost.com", help_text="Deutsche Post API URL (sandbox/production)") deutschepost_api_url = models.URLField(max_length=255, default="https://gw.sandbox.deutschepost.com", help_text="Deutsche Post API URL (sandbox/production)")
deutschepost_client_id = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client ID") deutschepost_client_id = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client ID")
deutschepost_client_secret = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client Secret") deutschepost_client_secret = models.CharField(max_length=255, blank=True, null=True, help_text="Deutsche Post OAuth Client Secret")
deutschepost_customer_ekp = models.CharField(max_length=20, blank=True, null=True, help_text="Deutsche Post Customer EKP number") deutschepost_customer_ekp = models.CharField(max_length=20, blank=True, null=True, help_text="Deutsche Post Customer EKP number")
deutschepost_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=150, help_text="Default Deutsche Post shipping price") deutschepost_shipping_price = models.DecimalField(max_digits=10, decimal_places=2, default=decimal.Decimal("6.00"), help_text="Default Deutsche Post shipping price in EUR")
#coupon settings #coupon settings
multiplying_coupons = models.BooleanField(default=True, help_text="Násobení kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón") multiplying_coupons = models.BooleanField(default=True, help_text="Násobení kupónů v objednávce (ano/ne), pokud ne tak se použije pouze nejvyšší slevový kupón")
@@ -58,3 +61,61 @@ class SiteConfiguration(models.Model):
def get_solo(cls): def get_solo(cls):
obj, _ = cls.objects.get_or_create(pk=1) obj, _ = cls.objects.get_or_create(pk=1)
return obj return obj
class VATRate(models.Model):
"""Business owner configurable VAT rates"""
name = models.CharField(
max_length=100,
help_text="E.g. 'German Standard', 'German Reduced', 'Czech Standard'"
)
description = models.TextField(
blank=True,
help_text="Optional description: 'Standard rate for most products', 'Books and food', etc."
)
rate = models.DecimalField(
max_digits=5,
decimal_places=4, # Allows rates like 19.5000%
validators=[MinValueValidator(Decimal('0')), MaxValueValidator(Decimal('100'))],
help_text="VAT rate as percentage (e.g. 19.00 for 19%)"
)
is_default = models.BooleanField(
default=False,
help_text="Default rate for new products"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
verbose_name = "VAT Rate"
verbose_name_plural = "VAT Rates"
ordering = ['-is_default', 'rate', 'name']
def __str__(self):
return f"{self.name} ({self.rate}%)"
def save(self, *args, **kwargs):
# Ensure only one default rate
if self.is_default:
VATRate.objects.filter(is_default=True).update(is_default=False)
super().save(*args, **kwargs)
# If no default exists, make first active one default
if not VATRate.objects.filter(is_default=True).exists():
first_active = VATRate.objects.filter(is_active=True).first()
if first_active:
first_active.is_default = True
first_active.save()
@property
def rate_decimal(self):
"""Returns rate as decimal for calculations (19.00% -> 0.19)"""
return self.rate / Decimal('100')
@classmethod
def get_default(cls):
"""Get the default VAT rate"""
return cls.objects.filter(is_default=True, is_active=True).first()

View File

@@ -1,8 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from .models import SiteConfiguration from .models import SiteConfiguration, VATRate
class SiteConfigurationAdminSerializer(serializers.ModelSerializer): class SiteConfigurationSerializer(serializers.ModelSerializer):
"""Site configuration serializer - sensitive fields only for admins"""
class Meta: class Meta:
model = SiteConfiguration model = SiteConfiguration
fields = [ fields = [
@@ -22,33 +24,77 @@ class SiteConfigurationAdminSerializer(serializers.ModelSerializer):
"zasilkovna_shipping_price", "zasilkovna_shipping_price",
"zasilkovna_api_key", "zasilkovna_api_key",
"zasilkovna_api_password", "zasilkovna_api_password",
"deutschepost_api_url",
"deutschepost_client_id",
"deutschepost_client_secret",
"deutschepost_customer_ekp",
"deutschepost_shipping_price",
"free_shipping_over", "free_shipping_over",
"multiplying_coupons", "multiplying_coupons",
"addition_of_coupons_amount", "addition_of_coupons_amount",
"currency", "currency",
] ]
def to_representation(self, instance):
"""Hide sensitive fields from non-admin users"""
data = super().to_representation(instance)
request = self.context.get('request')
class SiteConfigurationPublicSerializer(serializers.ModelSerializer): # If user is not admin, remove sensitive fields
class Meta: if not (request and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'):
model = SiteConfiguration sensitive_fields = [
# Expose only non-sensitive fields 'zasilkovna_api_key',
fields = [ 'zasilkovna_api_password',
"id", 'deutschepost_client_id',
"name", 'deutschepost_client_secret',
"logo", 'deutschepost_customer_ekp',
"favicon", 'deutschepost_api_url',
"contact_email",
"contact_phone",
"contact_address",
"opening_hours",
"facebook_url",
"instagram_url",
"youtube_url",
"tiktok_url",
# Exclude API keys/passwords
"zasilkovna_shipping_price",
"free_shipping_over",
"currency",
] ]
for field in sensitive_fields:
data.pop(field, None)
return data
class VATRateSerializer(serializers.ModelSerializer):
"""VAT Rate serializer - admin fields only visible to admins"""
rate_decimal = serializers.ReadOnlyField(help_text="VAT rate as decimal (e.g., 0.19 for 19%)")
class Meta:
model = VATRate
fields = [
'id',
'name',
'rate',
'rate_decimal',
'description',
'is_active',
'is_default',
'created_at',
]
read_only_fields = ['id', 'created_at', 'rate_decimal']
def to_representation(self, instance):
"""Hide admin-only fields from non-admin users"""
data = super().to_representation(instance)
request = self.context.get('request')
# If user is not admin, remove admin-only fields
if not (request and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'):
admin_fields = ['is_active', 'is_default']
for field in admin_fields:
data.pop(field, None)
return data
def validate(self, attrs):
"""Custom validation for VAT rates"""
# Ensure rate is reasonable (0-100%)
rate = attrs.get('rate')
if rate is not None and (rate < 0 or rate > 100):
raise serializers.ValidationError(
{'rate': 'VAT rate must be between 0% and 100%'}
)
return attrs

View File

@@ -1,7 +1,10 @@
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import SiteConfigurationAdminViewSet, SiteConfigurationPublicViewSet from .views import (
SiteConfigurationViewSet,
VATRateViewSet,
)
router = DefaultRouter() router = DefaultRouter()
router.register(r"admin/shop-configuration", SiteConfigurationAdminViewSet, basename="shop-config-admin") router.register(r"shop-configuration", SiteConfigurationViewSet, basename="shop-config")
router.register(r"public/shop-configuration", SiteConfigurationPublicViewSet, basename="shop-config-public") router.register(r"vat-rates", VATRateViewSet, basename="vat-rates")
urlpatterns = router.urls urlpatterns = router.urls

View File

@@ -1,10 +1,12 @@
from rest_framework import viewsets, mixins from rest_framework import viewsets, mixins
from rest_framework.permissions import IsAdminUser, AllowAny from rest_framework.decorators import action
from rest_framework.response import Response
from drf_spectacular.utils import extend_schema, extend_schema_view from drf_spectacular.utils import extend_schema, extend_schema_view
from .models import SiteConfiguration from account.permissions import AdminWriteOnlyOrReadOnly
from .models import SiteConfiguration, VATRate
from .serializers import ( from .serializers import (
SiteConfigurationAdminSerializer, SiteConfigurationSerializer,
SiteConfigurationPublicSerializer, VATRateSerializer,
) )
@@ -17,22 +19,56 @@ class _SingletonQuerysetMixin:
@extend_schema_view( @extend_schema_view(
list=extend_schema(tags=["configuration"], summary="List site configuration (admin)"), list=extend_schema(tags=["configuration"], summary="List site configuration"),
retrieve=extend_schema(tags=["configuration"], summary="Retrieve site configuration (admin)"), retrieve=extend_schema(tags=["configuration"], summary="Retrieve site configuration"),
create=extend_schema(tags=["configuration"], summary="Create site configuration (admin)"), create=extend_schema(tags=["configuration"], summary="Create site configuration (admin only)"),
partial_update=extend_schema(tags=["configuration"], summary="Update site configuration (admin)"), partial_update=extend_schema(tags=["configuration"], summary="Update site configuration (admin only)"),
update=extend_schema(tags=["configuration"], summary="Replace site configuration (admin)"), update=extend_schema(tags=["configuration"], summary="Replace site configuration (admin only)"),
destroy=extend_schema(tags=["configuration"], summary="Delete site configuration (admin)"), destroy=extend_schema(tags=["configuration"], summary="Delete site configuration (admin only)"),
) )
class SiteConfigurationAdminViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet): class SiteConfigurationViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
permission_classes = [IsAdminUser] permission_classes = [AdminWriteOnlyOrReadOnly]
serializer_class = SiteConfigurationAdminSerializer serializer_class = SiteConfigurationSerializer
@extend_schema_view( @extend_schema_view(
list=extend_schema(tags=["configuration", "public"], summary="List site configuration (public)"), list=extend_schema(tags=["configuration"], summary="List VAT rates"),
retrieve=extend_schema(tags=["configuration", "public"], summary="Retrieve site configuration (public)"), retrieve=extend_schema(tags=["configuration"], summary="Retrieve VAT rate"),
create=extend_schema(tags=["configuration"], summary="Create VAT rate (admin only)"),
partial_update=extend_schema(tags=["configuration"], summary="Update VAT rate (admin only)"),
update=extend_schema(tags=["configuration"], summary="Replace VAT rate (admin only)"),
destroy=extend_schema(tags=["configuration"], summary="Delete VAT rate (admin only)"),
) )
class SiteConfigurationPublicViewSet(_SingletonQuerysetMixin, viewsets.ReadOnlyModelViewSet): class VATRateViewSet(viewsets.ModelViewSet):
permission_classes = [AllowAny] """VAT rate management - read for all, write for admins only"""
serializer_class = SiteConfigurationPublicSerializer permission_classes = [AdminWriteOnlyOrReadOnly]
serializer_class = VATRateSerializer
queryset = VATRate.objects.filter(is_active=True)
def get_queryset(self):
"""Admins see all rates, others see only active ones"""
if self.request.user.is_authenticated and getattr(self.request.user, 'role', None) == 'admin':
return VATRate.objects.all()
return VATRate.objects.filter(is_active=True)
@extend_schema(
tags=["configuration"],
summary="Make VAT rate the default (admin only)",
description="Set this VAT rate as the default for new products"
)
@action(detail=True, methods=['post'])
def make_default(self, request, pk=None):
"""Make this VAT rate the default"""
vat_rate = self.get_object()
# Clear existing defaults
VATRate.objects.filter(is_default=True).update(is_default=False)
# Set new default
vat_rate.is_default = True
vat_rate.save()
return Response({
'message': f'"{vat_rate.name}" is now the default VAT rate',
'default_rate_id': vat_rate.id
})