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:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
Reference in New Issue
Block a user