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:
506
backend/commerce/analytics.py
Normal file
506
backend/commerce/analytics.py
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user