Merge branch 'main' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2025-11-17 15:50:43 +03:00
commit 7907613fdb
5 changed files with 429 additions and 167 deletions

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
{% extends 'admin/base.html' %}
{% load i18n unfold %}
{% load i18n unfold static %}
{% block title %}
{% if subtitle %}
@ -67,14 +67,35 @@
{% trans "Sales vs Returns (30d)" %}
{% endcomponent %}
{% if total and total > 0 %}
{% widthratio gross total 360 as gross_deg %}
{% with net=revenue_net_30|default:0 %}
{% with tax_amt=gross|add:-net %}
{% with returns_capped=returns %}
{% if returns > gross %}
{% with returns_capped=gross %}{% endwith %}
{% endif %}
{% with tax_amt_pos=tax_amt %}
{% if tax_amt_pos < 0 %}
{% with tax_amt_pos=0 %}{% endwith %}
{% endif %}
{% with net_for_pie=gross|add:-tax_amt_pos|add:-returns_capped %}
{% if net_for_pie < 0 %}
{% with net_for_pie=0 %}{% endwith %}
{% endif %}
{# Degrees per slice #}
{% widthratio net_for_pie gross 360 as net_deg %}
{% widthratio tax_amt_pos gross 360 as tax_deg %}
{% widthratio returns_capped gross 360 as ret_deg %}
{% with net_end=net_deg %}
{% with tax_end=net_end|add:tax_deg %}
{% with ret_end=tax_end|add:ret_deg %}
<div class="flex flex-col sm:flex-row items-center gap-6">
<div class="relative w-40 h-40">
<div class="w-40 h-40 rounded-full"
style="background:
conic-gradient(
rgb(34,197,94) 0 {{ gross_deg }}deg,
rgb(239,68,68) {{ gross_deg }}deg 360deg
rgb(34,197,94) 0 {{ net_end }}deg,
rgb(249,115,22) {{ net_end }}deg {{ tax_end }}deg,
rgb(239,68,68) {{ tax_end }}deg 360deg
);">
</div>
</div>
@ -82,17 +103,43 @@
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:rgb(34,197,94)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Gross" %}:</span>
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ gross }}</span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Net" %}:</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ net }}</span>
</div>
<div class="flex items-center gap-2">
{% if tax_amt_pos > 0 %}
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:rgb(249,115,22)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Taxes" %}:</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ tax_amt_pos|floatformat:2 }}</span>
</div>
{% endif %}
<div class="flex items-center gap-2 mb-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:rgb(239,68,68)"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Returns" %}:</span>
<span class="font-semibold">{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns }}</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ returns }}</span>
</div>
<div class="flex items-center gap-2">
<span class="inline-block w-3 h-3 rounded-sm"
style="background:linear-gradient(90deg, rgba(0,0,0,0.15), rgba(0,0,0,0.15))"></span>
<span class="text-sm text-gray-600 dark:text-gray-300">{% trans "Gross (pie total)" %}:</span>
<span class="font-semibold">{% if currency_symbol %}
{{ currency_symbol }}{% endif %}{{ gross }}</span>
</div>
</div>
</div>
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% else %}
{% component "unfold/components/text.html" %}
{% trans "Not enough data for chart yet." %}
@ -117,6 +164,114 @@
{% endcomponent %}
</div>
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Daily sales (30d)" %}
{% endcomponent %}
{% if daily_labels and daily_labels|length > 0 %}
<div class="w-full">
<canvas id="dailySalesChart" height="120"></canvas>
</div>
<script src="{% static 'js/chart.js' %}"></script>
{{ daily_labels|json_script:"daily-labels" }}
{{ daily_orders|json_script:"daily-orders" }}
{{ daily_gross|json_script:"daily-gross" }}
<script>
(function () {
try {
const labels = JSON.parse(document.getElementById('daily-labels').textContent);
const orders = JSON.parse(document.getElementById('daily-orders').textContent);
const gross = JSON.parse(document.getElementById('daily-gross').textContent);
const ctx = document.getElementById('dailySalesChart').getContext('2d');
const green = 'rgb(34,197,94)';
const blue = 'rgb(59,130,246)';
const currency = "{% if currency_symbol %}{{ currency_symbol }}{% endif %}";
new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '{{ _("Orders (FINISHED)") }}',
data: orders,
borderColor: green,
backgroundColor: green,
borderWidth: 2,
tension: 0.25,
pointRadius: 2,
yAxisID: 'yOrders',
},
{
label: '{{ _("Gross revenue") }}',
data: gross,
borderColor: blue,
backgroundColor: blue,
borderWidth: 2,
tension: 0.25,
pointRadius: 2,
yAxisID: 'yRevenue',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {mode: 'index', intersect: false},
plugins: {
legend: {display: true},
tooltip: {
callbacks: {
label: function (context) {
const label = context.dataset.label || '';
const val = context.parsed.y;
if (context.dataset.yAxisID === 'yRevenue') {
return `${label}: ${currency}${val}`;
}
return `${label}: ${val}`;
}
}
}
},
scales: {
x: {
grid: {display: false}
},
yOrders: {
type: 'linear',
position: 'left',
title: {display: true, text: '{{ _("Orders") }}'},
grid: {color: 'rgba(0,0,0,0.06)'},
ticks: {precision: 0}
},
yRevenue: {
type: 'linear',
position: 'right',
title: {display: true, text: '{{ _("Gross") }}'},
grid: {drawOnChartArea: false},
ticks: {
callback: function (value) {
return `${currency}${value}`;
}
}
}
}
}
});
} catch (e) {
console && console.warn && console.warn('Chart init failed', e);
}
})();
</script>
{% else %}
{% component "unfold/components/text.html" %}
{% trans "Not enough data for chart yet." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
@ -128,7 +283,8 @@
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}" class="w-12 h-12 object-cover rounded"/>
<img src="{{ p.image }}" alt="{{ p.name }}"
class="w-12 h-12 object-cover rounded"/>
{% endif %}
<span class="font-medium flex-1 truncate">{{ p.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-base-700/[.06] dark:bg-white/[.06] text-gray-700 dark:text-gray-200">{{ p.count }}</span>
@ -161,7 +317,8 @@
<li class="py-2 first:pt-0 last:pb-0">
<a href="{{ p.admin_url }}" class="flex items-center gap-4">
{% if p.image %}
<img src="{{ p.image }}" alt="{{ p.name }}" class="w-12 h-12 object-cover rounded"/>
<img src="{{ p.image }}" alt="{{ p.name }}"
class="w-12 h-12 object-cover rounded"/>
{% endif %}
<span class="font-medium flex-1 truncate">{{ p.name }}</span>
<span class="text-xs px-2 py-0.5 rounded bg-base-700/[.06] dark:bg-white/[.06] text-gray-700 dark:text-gray-200">{{ p.count }}</span>

View file

@ -1,8 +1,9 @@
from datetime import timedelta
from contextlib import suppress
from datetime import date, timedelta
from constance import config
from django.db.models import F, QuerySet, Sum
from django.db.models.functions import Coalesce
from django.db.models import Count, F, QuerySet, Sum
from django.db.models.functions import Coalesce, TruncDate
from django.utils.timezone import now
from engine.core.models import Order, OrderProduct
@ -29,15 +30,31 @@ def get_revenue(clear: bool = True, period: timedelta = timedelta(days=30)) -> f
except (TypeError, ValueError):
total = 0.0
if clear:
if not clear:
return round(float(total), 2)
try:
tax_rate = float(config.TAX_RATE or 0)
except (TypeError, ValueError):
tax_rate = 0.0
net = total * (1 - tax_rate / 100.0)
return round(net, 2)
tax_included = False
with suppress(Exception):
tax_included = bool(getattr(config, "TAX_INCLUDED", False))
if tax_rate <= 0:
net = total
else:
return round(float(total), 2)
if tax_included:
divisor = 1.0 + (tax_rate / 100.0)
if divisor <= 0:
net = total
else:
net = total / divisor
else:
net = total
return round(float(net or 0.0), 2)
def get_returns(period: timedelta = timedelta(days=30)) -> float:
@ -53,3 +70,43 @@ def get_returns(period: timedelta = timedelta(days=30)) -> float:
def get_total_processed_orders(period: timedelta = timedelta(days=30)) -> int:
return get_period_order_products(period, ["RETURNED", "FINISHED"]).count()
def get_daily_finished_orders_count(period: timedelta = timedelta(days=30)) -> dict[date, int]:
current = now()
period_start = current - period
qs = (
Order.objects.filter(status="FINISHED", buy_time__lte=current, buy_time__gte=period_start)
.annotate(day=TruncDate("buy_time"))
.values("day")
.annotate(cnt=Count("id"))
.order_by("day")
)
result: dict[date, int] = {}
for row in qs:
d = row.get("day")
c = int(row.get("cnt", 0) or 0)
if d:
result[d] = c
return result
def get_daily_gross_revenue(period: timedelta = timedelta(days=30)) -> dict[date, float]:
qs = (
get_period_order_products(period, ["FINISHED"]) # OrderProduct queryset
.annotate(day=TruncDate("order__buy_time"))
.values("day")
.annotate(total=Coalesce(Sum(F("buy_price") * F("quantity")), 0.0))
.order_by("day")
)
result: dict[date, float] = {}
for row in qs:
d = row.get("day")
total = row.get("total") or 0.0
try:
total_f = round(float(total), 2)
except (TypeError, ValueError):
total_f = 0.0
if d:
result[d] = total_f
return result

View file

@ -1,4 +1,5 @@
import logging
from datetime import timedelta
import mimetypes
import os
import traceback
@ -18,6 +19,7 @@ from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import urlsafe_base64_decode
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now as tz_now
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_headers
from django_ratelimit.decorators import ratelimit
@ -57,7 +59,13 @@ from engine.core.serializers import (
)
from engine.core.utils import get_project_parameters, is_url_safe
from engine.core.utils.caching import web_cache
from engine.core.utils.commerce import get_returns, get_revenue, get_total_processed_orders
from engine.core.utils.commerce import (
get_returns,
get_revenue,
get_total_processed_orders,
get_daily_finished_orders_count,
get_daily_gross_revenue,
)
from engine.core.utils.emailing import contact_us_email
from engine.core.utils.languages import get_flag_by_language
from engine.payments.serializers import TransactionProcessSerializer
@ -489,6 +497,30 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
}
)
# Daily stats for the last 30 days
try:
# Build continuous date axis
today = tz_now().date()
days = 30
date_axis = [today - timedelta(days=i) for i in range(days - 1, -1, -1)]
orders_map = get_daily_finished_orders_count(timedelta(days=days))
gross_map = get_daily_gross_revenue(timedelta(days=days))
# Labels in day-month format, e.g., 05 Nov
labels = [d.strftime("%d %b") for d in date_axis]
orders_series = [int(orders_map.get(d, 0) or 0) for d in date_axis]
gross_series = [float(gross_map.get(d, 0.0) or 0.0) for d in date_axis]
context["daily_labels"] = labels
context["daily_orders"] = orders_series
context["daily_gross"] = gross_series
except Exception as e: # pragma: no cover - fail safe
logger.warning("Failed to build daily stats: %s", e)
context["daily_labels"] = []
context["daily_orders"] = []
context["daily_gross"] = []
# Single most popular product (backward compatibility)
most_popular: dict[str, str | int | float | None] | None = None
# Top 10 most popular products by quantity

View file

@ -25,6 +25,7 @@ CONSTANCE_CONFIG = OrderedDict(
("COMPANY_ADDRESS", (getenv("COMPANY_ADDRESS"), _("Address of the company"))),
("COMPANY_PHONE_NUMBER", (getenv("COMPANY_PHONE_NUMBER"), _("Phone number of the company"))),
("TAX_RATE", (0, _("Tax rate in jurisdiction of your company. Leave 0 if you don't want to process taxes."))),
("TAX_INCLUDED", (True, _("Shows if the taxes are already included in product's selling prices"))),
("EXCHANGE_RATE_API_KEY", (getenv("EXCHANGE_RATE_API_KEY", "example token"), _("Exchange rate API key"))),
### Email Options ###
("EMAIL_BACKEND", ("django.core.mail.backends.smtp.EmailBackend", _("!!!DO NOT CHANGE!!!"))),
@ -58,6 +59,7 @@ CONSTANCE_CONFIG_FIELDSETS = OrderedDict(
"COMPANY_ADDRESS",
"COMPANY_PHONE_NUMBER",
"TAX_RATE",
"TAX_INCLUDED",
"EXCHANGE_RATE_API_KEY",
),
_("Email Options"): (