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

@@ -1,3 +1,59 @@
from django.contrib import admin
from .models import SiteConfiguration, VATRate
# 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 decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
# Create your models here.
@@ -21,20 +24,20 @@ class SiteConfiguration(models.Model):
whatsapp_number = models.CharField(max_length=20, blank=True, null=True)
#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 ↓↓↓
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 ↓↓↓
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 ↓↓↓
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
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_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_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
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")
@@ -57,4 +60,62 @@ class SiteConfiguration(models.Model):
@classmethod
def get_solo(cls):
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 .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:
model = SiteConfiguration
fields = [
@@ -22,33 +24,77 @@ class SiteConfigurationAdminSerializer(serializers.ModelSerializer):
"zasilkovna_shipping_price",
"zasilkovna_api_key",
"zasilkovna_api_password",
"deutschepost_api_url",
"deutschepost_client_id",
"deutschepost_client_secret",
"deutschepost_customer_ekp",
"deutschepost_shipping_price",
"free_shipping_over",
"multiplying_coupons",
"addition_of_coupons_amount",
"currency",
]
def to_representation(self, instance):
"""Hide sensitive fields from non-admin users"""
data = super().to_representation(instance)
request = self.context.get('request')
# If user is not admin, remove sensitive fields
if not (request and request.user.is_authenticated and getattr(request.user, 'role', None) == 'admin'):
sensitive_fields = [
'zasilkovna_api_key',
'zasilkovna_api_password',
'deutschepost_client_id',
'deutschepost_client_secret',
'deutschepost_customer_ekp',
'deutschepost_api_url',
]
for field in sensitive_fields:
data.pop(field, None)
return data
class SiteConfigurationPublicSerializer(serializers.ModelSerializer):
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 = SiteConfiguration
# Expose only non-sensitive fields
model = VATRate
fields = [
"id",
"name",
"logo",
"favicon",
"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",
'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 .views import SiteConfigurationAdminViewSet, SiteConfigurationPublicViewSet
from .views import (
SiteConfigurationViewSet,
VATRateViewSet,
)
router = DefaultRouter()
router.register(r"admin/shop-configuration", SiteConfigurationAdminViewSet, basename="shop-config-admin")
router.register(r"public/shop-configuration", SiteConfigurationPublicViewSet, basename="shop-config-public")
router.register(r"shop-configuration", SiteConfigurationViewSet, basename="shop-config")
router.register(r"vat-rates", VATRateViewSet, basename="vat-rates")
urlpatterns = router.urls

View File

@@ -1,10 +1,12 @@
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 .models import SiteConfiguration
from account.permissions import AdminWriteOnlyOrReadOnly
from .models import SiteConfiguration, VATRate
from .serializers import (
SiteConfigurationAdminSerializer,
SiteConfigurationPublicSerializer,
SiteConfigurationSerializer,
VATRateSerializer,
)
@@ -17,22 +19,56 @@ class _SingletonQuerysetMixin:
@extend_schema_view(
list=extend_schema(tags=["configuration"], summary="List site configuration (admin)"),
retrieve=extend_schema(tags=["configuration"], summary="Retrieve site configuration (admin)"),
create=extend_schema(tags=["configuration"], summary="Create site configuration (admin)"),
partial_update=extend_schema(tags=["configuration"], summary="Update site configuration (admin)"),
update=extend_schema(tags=["configuration"], summary="Replace site configuration (admin)"),
destroy=extend_schema(tags=["configuration"], summary="Delete site configuration (admin)"),
list=extend_schema(tags=["configuration"], summary="List site configuration"),
retrieve=extend_schema(tags=["configuration"], summary="Retrieve site configuration"),
create=extend_schema(tags=["configuration"], summary="Create site configuration (admin only)"),
partial_update=extend_schema(tags=["configuration"], summary="Update site configuration (admin only)"),
update=extend_schema(tags=["configuration"], summary="Replace site configuration (admin only)"),
destroy=extend_schema(tags=["configuration"], summary="Delete site configuration (admin only)"),
)
class SiteConfigurationAdminViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
permission_classes = [IsAdminUser]
serializer_class = SiteConfigurationAdminSerializer
class SiteConfigurationViewSet(_SingletonQuerysetMixin, viewsets.ModelViewSet):
permission_classes = [AdminWriteOnlyOrReadOnly]
serializer_class = SiteConfigurationSerializer
@extend_schema_view(
list=extend_schema(tags=["configuration", "public"], summary="List site configuration (public)"),
retrieve=extend_schema(tags=["configuration", "public"], summary="Retrieve site configuration (public)"),
list=extend_schema(tags=["configuration"], summary="List VAT rates"),
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):
permission_classes = [AllowAny]
serializer_class = SiteConfigurationPublicSerializer
class VATRateViewSet(viewsets.ModelViewSet):
"""VAT rate management - read for all, write for admins only"""
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
})