diff --git a/backend/commerce/analytics.py b/backend/commerce/analytics.py new file mode 100644 index 0000000..0aa793d --- /dev/null +++ b/backend/commerce/analytics.py @@ -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) + } + } \ No newline at end of file diff --git a/backend/commerce/models.py b/backend/commerce/models.py index 4f1e630..cb53fd2 100644 --- a/backend/commerce/models.py +++ b/backend/commerce/models.py @@ -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() diff --git a/backend/commerce/tasks.py b/backend/commerce/tasks.py index 76094d3..ac1d5e6 100644 --- a/backend/commerce/tasks.py +++ b/backend/commerce/tasks.py @@ -12,7 +12,7 @@ from django.utils import timezone def delete_expired_orders(): 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() expired_orders.delete() return count @@ -30,10 +30,9 @@ def notify_zasilkovna_sended(order = None, user = None, **kwargs): print("Additional kwargs received in notify_order_sended:", kwargs) send_email_with_context( - user.email, + recipients=user.email, subject="Your order has been shipped", - template_name="emails/order_sended.txt", - html_template_name="emails/order_sended.html", + template_path="email/order_sended.html", context={ "user": user, "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) send_email_with_context( - user.email, - subject="Your order has been shipped", - template_name="emails/order_sended.txt", - html_template_name="emails/order_sended.html", + recipients=user.email, + subject="Your order is ready for pickup", + template_path="email/order_ready_pickup.html", context={ "user": user, "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) send_email_with_context( - user.email, + recipients=user.email, subject="Your order has been successfully created", - template_name="emails/order_created.txt", - html_template_name="emails/order_created.html", + template_path="email/order_created.html", context={ "user": user, "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) send_email_with_context( - user.email, + recipients=user.email, subject="Payment missing for your order", - template_name="emails/order_missing_payment.txt", - html_template_name="emails/order_missing_payment.html", + template_path="email/order_missing_payment.html", context={ "user": user, "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) send_email_with_context( - user.email, + recipients=user.email, subject="Your refund items have arrived", - template_name="emails/order_refund_items_arrived.txt", - html_template_name="emails/order_refund_items_arrived.html", + template_path="email/order_refund_items_arrived.html", context={ "user": user, "order": order, @@ -138,10 +133,9 @@ def notify_refund_accepted(order = None, user = None, **kwargs): print("Additional kwargs received in notify_refund_accepted:", kwargs) send_email_with_context( - user.email, + recipients=user.email, subject="Your refund has been accepted", - template_name="emails/order_refund_accepted.txt", - html_template_name="emails/order_refund_accepted.html", + template_path="email/order_refund_accepted.html", context={ "user": user, "order": order, diff --git a/backend/commerce/views.py b/backend/commerce/views.py index df869c7..cf9ab0c 100644 --- a/backend/commerce/views.py +++ b/backend/commerce/views.py @@ -3,9 +3,16 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework import status import base64 +from datetime import datetime +from django.utils.dateparse import parse_datetime from .models import Refund, Review from .serializers import RefundCreatePublicSerializer, ReviewSerializerPublic +from .analytics import ( + SalesAnalytics, ProductAnalytics, CustomerAnalytics, + ShippingAnalytics, ReviewAnalytics, AnalyticsAggregator, + get_predefined_date_ranges +) from django.db import transaction @@ -854,66 +861,237 @@ class AdminWishlistViewSet(viewsets.ModelViewSet): ordering = ["-updated_at"] -# ---------- ANALYTICS PLACEHOLDER ---------- +# ---------- ANALYTICS SYSTEM ---------- -# TODO: Implement comprehensive analytics system -# 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): +class AnalyticsView(APIView): """ - Analytics and reporting endpoints for business intelligence - TODO: Implement comprehensive analytics + Comprehensive analytics and reporting API for business intelligence. + 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] - @action(detail=False, methods=['get'], url_path='sales-overview') - @extend_schema( - tags=["commerce", "analytics"], - summary="Sales overview (TODO)", - responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}}, - ) - def sales_overview(self, request): - """TODO: Implement sales analytics""" - return Response({"message": "Sales analytics coming soon!"}) + 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 - @action(detail=False, methods=['get'], url_path='product-performance') @extend_schema( tags=["commerce", "analytics"], - summary="Product performance analytics (TODO)", - responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}}, + summary="Get analytics data", + 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 product_performance(self, request): - """TODO: Implement product performance analytics""" - return Response({"message": "Product analytics coming soon!"}) + def get(self, request): + """Get analytics data based on type parameter""" + 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='inventory-report') @extend_schema( tags=["commerce", "analytics"], - summary="Inventory status report (TODO)", - responses={200: {"type": "object", "properties": {"message": {"type": "string"}}}}, + summary="Generate custom analytics report", + 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 inventory_report(self, request): - """TODO: Implement inventory management features""" - # TODO Ideas: - # - Low stock alerts (products with stock < threshold) - # - Out of stock products list - # - Bestsellers vs slow movers - # - Stock value calculation - # - Automated reorder suggestions - # - Stock movement history - # - Bulk stock updates - # - Supplier management integration - return Response({"message": "Inventory analytics coming soon!"}) + def post(self, request): + """Generate custom analytics report based on specified modules""" + 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', {}) + + if start_date: + start_date = parse_datetime(start_date) + if end_date: + end_date = parse_datetime(end_date) + + # If no dates provided, default to last 30 days + if not start_date or not end_date: + predefined_ranges = get_predefined_date_ranges() + date_range = predefined_ranges.get('last_30_days') + start_date = start_date or date_range['start'] + end_date = end_date or date_range['end'] + + report_data = {} + + if 'sales' in modules: + report_data['sales'] = { + 'revenue': SalesAnalytics.revenue_overview(start_date, end_date, period), + '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() + } + }) \ No newline at end of file diff --git a/backend/configuration/admin.py b/backend/configuration/admin.py index 8c38f3f..b31a779 100644 --- a/backend/configuration/admin.py +++ b/backend/configuration/admin.py @@ -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" diff --git a/backend/configuration/models.py b/backend/configuration/models.py index 0a139d6..6857af6 100644 --- a/backend/configuration/models.py +++ b/backend/configuration/models.py @@ -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 \ No newline at end of file + 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() \ No newline at end of file diff --git a/backend/configuration/serializers.py b/backend/configuration/serializers.py index 4b7af80..a03c65d 100644 --- a/backend/configuration/serializers.py +++ b/backend/configuration/serializers.py @@ -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 diff --git a/backend/configuration/urls.py b/backend/configuration/urls.py index b3cdfd9..c13dcd3 100644 --- a/backend/configuration/urls.py +++ b/backend/configuration/urls.py @@ -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 diff --git a/backend/configuration/views.py b/backend/configuration/views.py index 63b666d..b587244 100644 --- a/backend/configuration/views.py +++ b/backend/configuration/views.py @@ -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 \ No newline at end of file +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 + }) \ No newline at end of file