Merge branch 'main' into storefront-nuxt

This commit is contained in:
Egor Pavlovich Gorbunov 2025-12-10 19:34:47 +03:00
commit f6b1d6d9fc
7 changed files with 298 additions and 33 deletions

View file

@ -2,7 +2,7 @@
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Daily sales (30d)" %}
{{ daily_title|default:_('Daily sales') }}
{% endcomponent %}
<div class="w-full">
<canvas id="dailySalesChart" height="120"></canvas>

View file

@ -0,0 +1,22 @@
{% load i18n unfold %}
{% component "unfold/components/card.html" with class="mb-4" %}
<div class="flex flex-wrap items-center gap-2">
{% component "unfold/components/text.html" with class="text-sm text-gray-500 dark:text-gray-400 mr-2" %}
{% trans "Timeframe" %}
{% endcomponent %}
{% with cur=tf|default:30 %}
<a href="?tf=7"
class="px-3 py-1 rounded-full text-sm border transition-colors {% if cur == 7 %} bg-gray-900 text-white dark:bg-white dark:text-gray-900 border-transparent {% else %} border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 {% endif %}">7d</a>
<a href="?tf=30"
class="px-3 py-1 rounded-full text-sm border transition-colors {% if cur == 30 %} bg-gray-900 text-white dark:bg-white dark:text-gray-900 border-transparent {% else %} border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 {% endif %}">30d</a>
<a href="?tf=90"
class="px-3 py-1 rounded-full text-sm border transition-colors {% if cur == 90 %} bg-gray-900 text-white dark:bg-white dark:text-gray-900 border-transparent {% else %} border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 {% endif %}">90d</a>
<a href="?tf=360"
class="px-3 py-1 rounded-full text-sm border transition-colors {% if cur == 360 %} bg-gray-900 text-white dark:bg-white dark:text-gray-900 border-transparent {% else %} border-gray-300 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 {% endif %}">360d</a>
{% endwith %}
<div class="ml-auto text-xs text-gray-400">
{% trans "TODO: Integrate GA/Yandex.Metrica & Ads metrics" %}
</div>
</div>
{% endcomponent %}

View file

@ -1,39 +1,86 @@
{% load i18n unfold %}
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
{#
KPI cards (mobile-first): GMV, Orders, AOV, Net revenue, Refund rate
Uses `kpi` dict and `currency_symbol` provided by dashboard_callback
#}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4 mb-4">
{# GMV #}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Revenue (gross, 30d)" %}
{% endcomponent %}
<div class="flex items-start justify-between">
{% component "unfold/components/text.html" %}{% trans "GMV" %} ({{ tf|default:30 }}d){% endcomponent %}
{% with d=kpi.gmv.delta_pct|default:0 %}
<span class="text-xs px-2 py-0.5 rounded-full {% if d >= 0 %} bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 {% else %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 {% endif %}">
{% if d >= 0 %}+{% endif %}{{ d }}%
</span>
{% endwith %}
</div>
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_gross_30|default:0 }}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.gmv.value|default:0 }}
{% endcomponent %}
{% endcomponent %}
{# Orders #}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Revenue (net, 30d)" %}
{% endcomponent %}
<div class="flex items-start justify-between">
{% component "unfold/components/text.html" %}{% trans "Orders" %} ({{ tf|default:30 }}d){% endcomponent %}
{% with d=kpi.orders.delta_pct|default:0 %}
<span class="text-xs px-2 py-0.5 rounded-full {% if d >= 0 %} bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 {% else %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 {% endif %}">
{% if d >= 0 %}+{% endif %}{{ d }}%
</span>
{% endwith %}
</div>
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ revenue_net_30|default:0 }}
{{ kpi.orders.value|default:0 }}
{% endcomponent %}
{% endcomponent %}
{# AOV #}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Returns (30d)" %}
{% endcomponent %}
<div class="flex items-start justify-between">
{% component "unfold/components/text.html" %}{% trans "AOV" %} ({{ tf|default:30 }}d){% endcomponent %}
{% with d=kpi.aov.delta_pct|default:0 %}
<span class="text-xs px-2 py-0.5 rounded-full {% if d >= 0 %} bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 {% else %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 {% endif %}">
{% if d >= 0 %}+{% endif %}{{ d }}%
</span>
{% endwith %}
</div>
{% component "unfold/components/title.html" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_30|default:0 }}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.aov.value|default:0 }}
{% endcomponent %}
{% endcomponent %}
{# Net revenue #}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Processed orders (30d)" %}
{% endcomponent %}
<div class="flex items-start justify-between">
{% component "unfold/components/text.html" %}{% trans "Net revenue" %} ({{ tf|default:30 }}d){% endcomponent %}
{% with d=kpi.net.delta_pct|default:0 %}
<span class="text-xs px-2 py-0.5 rounded-full {% if d >= 0 %} bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 {% else %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 {% endif %}">
{% if d >= 0 %}+{% endif %}{{ d }}%
</span>
{% endwith %}
</div>
{% component "unfold/components/title.html" %}
{{ processed_orders_30|default:0 }}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ kpi.net.value|default:0 }}
{% endcomponent %}
{% endcomponent %}
{# Refund rate #}
{% component "unfold/components/card.html" %}
<div class="flex items-start justify-between">
{% component "unfold/components/text.html" %}{% trans "Refund rate" %} ({{ tf|default:30 }}d){% endcomponent %}
{% with d=kpi.refund_rate.delta_pct|default:0 %}
<span class="text-xs px-2 py-0.5 rounded-full {% if d <= 0 %} bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 {% else %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 {% endif %}">
{% if d >= 0 %}+{% endif %}{{ d }}%
</span>
{% endwith %}
</div>
{% component "unfold/components/title.html" %}
{{ kpi.refund_rate.value|default:0 }}%
{% endcomponent %}
{% component "unfold/components/text.html" with class="text-xs text-gray-500 dark:text-gray-400" %}
{% if currency_symbol %}{{ currency_symbol }}{% endif %}{{ returns_amount|default:0 }} {% trans "returned" %}
{% endcomponent %}
{% endcomponent %}
</div>

View file

@ -0,0 +1,32 @@
{% load i18n unfold %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/title.html" %}
{% trans "Low stock" %}
{% endcomponent %}
{% if low_stock_products %}
<div class="divide-y divide-gray-100 dark:divide-gray-800">
{% for p in low_stock_products %}
<a href="{{ p.admin_url }}" class="flex items-center justify-between py-2 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-md px-2 -mx-2">
<div class="min-w-0">
<div class="truncate font-medium">{{ p.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">SKU: {{ p.sku }}</div>
</div>
<div class="text-sm">
<span class="px-2 py-0.5 rounded-full text-xs
{% if p.qty|default:0 <= 0 %} bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300
{% elif p.qty <= 5 %} bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300
{% else %} bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200 {% endif %}">
{{ p.qty }}
</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
{% component "unfold/components/text.html" with class="text-sm text-gray-500 dark:text-gray-400" %}
{% trans "No low stock items." %}
{% endcomponent %}
{% endif %}
{% endcomponent %}

View file

@ -21,14 +21,21 @@
<br/>
{% endcomponent %}
{% include "admin/dashboard/_filters.html" %}
{% include "admin/dashboard/_kpis.html" %}
<div class="grid grid-cols-1 xl:grid-cols-3 gap-6 items-start">
<div class="xl:col-span-2">
{% include "admin/dashboard/_daily_sales.html" %}
</div>
{% include "admin/dashboard/_income_overview.html" %}
{% include "admin/dashboard/_quick_links.html" %}
</div>
{% include "admin/dashboard/_daily_sales.html" %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
{% include "admin/dashboard/_low_stock.html" %}
{% include "admin/dashboard/_most_returned.html" %}
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
{% include "admin/dashboard/_customers_mix.html" %}
@ -38,8 +45,8 @@
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
{% include "admin/dashboard/_most_returned.html" %}
{% include "admin/dashboard/_top_categories.html" %}
{% include "admin/dashboard/_quick_links.html" %}
</div>
{% include "admin/dashboard/_product_lists.html" %}

View file

@ -3,15 +3,16 @@ import mimetypes
import os
import traceback
from contextlib import suppress
from datetime import timedelta
from datetime import date, timedelta
import requests
from constance import config
from django.conf import settings
from django.contrib.sitemaps.views import index as _sitemap_index_view
from django.contrib.sitemaps.views import sitemap as _sitemap_detail_view
from django.core.cache import cache
from django.core.exceptions import BadRequest
from django.db.models import Count, Sum
from django.db.models import Count, Sum, F
from django.http import FileResponse, Http404, HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse
from django.shortcuts import redirect
from django.template import Context
@ -430,10 +431,117 @@ version.__doc__ = _( # type: ignore [assignment]
def dashboard_callback(request: HttpRequest, context: Context) -> Context:
revenue_gross_30: float = get_revenue(clear=False)
revenue_net_30: float = get_revenue(clear=True)
returns_30: float = get_returns()
processed_orders_30: int = get_total_processed_orders()
tf_map: dict[str, int] = {"7": 7, "30": 30, "90": 90, "360": 360}
tf_param = str(request.GET.get("tf", "30") or "30")
period_days: int = tf_map.get(tf_param, 30)
period = timedelta(days=period_days)
now_dt = tz_now()
cur_start = now_dt - period
prev_start = now_dt - timedelta(days=period_days * 2)
prev_end = cur_start
revenue_gross_cur: float = get_revenue(clear=False, period=period)
revenue_net_cur: float = get_revenue(clear=True, period=period)
returns_cur: float = get_returns(period=period)
processed_orders_cur: int = get_total_processed_orders(period=period)
orders_finished_cur = 0
with suppress(Exception):
orders_finished_cur: int = Order.objects.filter(
status="FINISHED", buy_time__lte=now_dt, buy_time__gte=cur_start
).count()
def sum_gross_between(start: date | None, end: date | None) -> float:
result = 0.0
with suppress(Exception):
qs = (
OrderProduct.objects.filter(status__in=["FINISHED"], order__status="FINISHED")
.filter(order__buy_time__lt=end, order__buy_time__gte=start)
.aggregate(total=Sum(F("buy_price") * F("quantity")))
)
total = qs.get("total") or 0.0
result = round(float(total), 2)
return result
def sum_returns_between(start: date | None, end: date | None) -> float:
result = 0.0
with suppress(Exception):
qs = (
OrderProduct.objects.filter(status__in=["RETURNED"]) # returned items
.filter(order__buy_time__lt=end, order__buy_time__gte=start)
.aggregate(total=Sum(F("buy_price") * F("quantity")))
)
total = qs.get("total") or 0.0
result = round(float(total), 2)
return result
def count_finished_orders_between(start: date | None, end: date | None) -> int:
result = 0
with suppress(Exception):
result = Order.objects.filter(status="FINISHED", buy_time__lt=end, buy_time__gte=start).count()
return result
revenue_gross_prev = sum_gross_between(prev_start, prev_end)
returns_prev = sum_returns_between(prev_start, prev_end)
orders_finished_prev = count_finished_orders_between(prev_start, prev_end)
tax_rate = 0.0
tax_included = False
with suppress(Exception):
tax_rate = float(getattr(config, "TAX_RATE", 0.0) or 0.0)
tax_included = bool(getattr(config, "TAX_INCLUDED", False))
if tax_rate <= 0:
revenue_net_prev = revenue_gross_prev
else:
if tax_included:
divisor = 1.0 + (tax_rate / 100.0)
revenue_net_prev = revenue_gross_prev / divisor if divisor > 0 else revenue_gross_prev
else:
revenue_net_prev = revenue_gross_prev
revenue_net_prev = round(float(revenue_net_prev or 0.0), 2)
def pct_delta(cur: float | int, prev: float | int) -> float:
result = 0.0
with suppress(Exception):
cur_f = float(cur or 0)
prev_f = float(prev or 0)
if prev_f == 0:
result = 0.0 if cur_f == 0 else 100.0
result = round(((cur_f - prev_f) / prev_f) * 100.0, 1)
return result
aov_cur: float = round((revenue_gross_cur / orders_finished_cur), 2) if orders_finished_cur > 0 else 0.0
refund_rate_cur: float = round(((returns_cur / revenue_gross_cur) * 100.0), 1) if revenue_gross_cur > 0 else 0.0
aov_prev: float = round((revenue_gross_prev / orders_finished_prev), 2) if orders_finished_prev > 0 else 0.0
refund_rate_prev: float = round(((returns_prev / revenue_gross_prev) * 100.0), 1) if revenue_gross_prev > 0 else 0.0
kpi = {
"gmv": {
"value": revenue_gross_cur,
"prev": revenue_gross_prev,
"delta_pct": pct_delta(revenue_gross_cur, revenue_gross_prev),
},
"orders": {
"value": orders_finished_cur,
"prev": orders_finished_prev,
"delta_pct": pct_delta(orders_finished_cur, orders_finished_prev),
},
"aov": {"value": aov_cur, "prev": aov_prev, "delta_pct": pct_delta(aov_cur, aov_prev)},
"net": {
"value": revenue_net_cur,
"prev": revenue_net_prev,
"delta_pct": pct_delta(revenue_net_cur, revenue_net_prev),
},
"refund_rate": {
"value": refund_rate_cur,
"prev": refund_rate_prev,
"delta_pct": pct_delta(refund_rate_cur, refund_rate_prev),
},
}
currency_symbol: str = ""
with suppress(Exception):
currency_symbol = dict(getattr(settings, "CURRENCIES_WITH_SYMBOLS", ())).get(
@ -499,7 +607,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
try:
today = tz_now().date()
days = 30
days = period_days
date_axis = [today - timedelta(days=i) for i in range(days - 1, -1, -1)]
orders_map = get_daily_finished_orders_count(timedelta(days=days))
@ -512,6 +620,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context["daily_labels"] = labels
context["daily_orders"] = orders_series
context["daily_gross"] = gross_series
context["daily_title"] = f"Revenue & Orders (last {period_days}d)"
except Exception as e:
logger.warning("Failed to build daily stats: %s", e)
context["daily_labels"] = []
@ -519,11 +628,48 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context["daily_gross"] = []
with suppress(Exception):
today = tz_now().date()
days = 30
days = period_days
date_axis = [today - timedelta(days=i) for i in range(days - 1, -1, -1)]
context["daily_labels"] = [d.strftime("%d %b") for d in date_axis]
context["daily_orders"] = [0 for _ in date_axis]
context["daily_gross"] = [0.0 for _ in date_axis]
context["daily_title"] = f"Revenue & Orders (last {period_days}d)"
low_stock_list: list[dict[str, str | int]] = []
with suppress(Exception):
products = (
Product.objects.annotate(total_qty=Sum("stocks__quantity"))
.values("id", "name", "sku", "total_qty")
.order_by("total_qty")[:5]
)
for p in products:
qty = int(p.get("total_qty") or 0)
low_stock_list.append(
{
"name": str(p.get("name") or ""),
"sku": str(p.get("sku") or ""),
"qty": qty,
"admin_url": reverse("admin:core_product_change", args=[p.get("id")]),
}
)
cache_key = f"dashboard_cb:{period_days}"
cached_pack = None
with suppress(Exception):
cached_pack = cache.get(cache_key)
if cached_pack is None:
cached_pack = {
"kpi": kpi,
"revenue_gross": revenue_gross_cur,
"revenue_net": revenue_net_cur,
"returns_amount": returns_cur,
"orders_finished": orders_finished_cur,
"aov": aov_cur,
"refund_rate": refund_rate_cur,
"low_stock_products": low_stock_list,
}
with suppress(Exception):
cache.set(cache_key, cached_pack, 600)
most_popular: dict[str, str | int | float | None] | None = None
most_popular_list: list[dict[str, str | int | float | None]] = []
@ -612,10 +758,19 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
context.update(
{
"custom_variable": "value",
"revenue_gross_30": revenue_gross_30,
"revenue_net_30": revenue_net_30,
"returns_30": returns_30,
"processed_orders_30": processed_orders_30,
"timeframe_days": period_days,
"tf": period_days,
"kpi": kpi,
"revenue_gross": revenue_gross_cur,
"revenue_net": revenue_net_cur,
"returns_amount": returns_cur,
"orders_finished": orders_finished_cur,
"aov": aov_cur,
"refund_rate": refund_rate_cur,
"revenue_gross_30": revenue_gross_cur,
"revenue_net_30": revenue_net_cur,
"returns_30": returns_cur,
"processed_orders_30": processed_orders_cur,
"evibes_version": settings.EVIBES_VERSION,
"quick_links": quick_links,
"most_wished_product": most_wished,
@ -627,6 +782,7 @@ def dashboard_callback(request: HttpRequest, context: Context) -> Context:
"shipped_vs_digital": shipped_vs_digital,
"most_returned_products": most_returned_products,
"top_categories": top_categories,
"low_stock_products": low_stock_list,
}
)

View file

@ -6,6 +6,7 @@ from engine.vibes_auth.models import User
class UserForm(UserChangeForm): # type: ignore [type-arg]
password = ReadOnlyPasswordHashField(label="Password")
class Meta:
model = User
fields = "__all__"