Add a shop sales dashboard with 30-day charts (#4)
CI / test (pull_request) Successful in 33s

## Summary
- Closes #3
- Portal Retail **Sales** page: 30-day orders/revenue/units/AOV, daily charts, top products
- Home dashboard cards for 30-day shop sales and revenue
- Stacks on #2. Merge that first, then retarget `master` if needed.

## Test plan
- [ ] Sales page requires login; empty state with no paid orders
- [ ] Paid/fulfilled in the last 30 days counted; unpaid/old excluded
- [ ] Home dashboard shows 30-day sales count
- [ ] `manage.py test shop`

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-09-06 18:35:20 -07:00
parent b2029b6c0f
commit c7d78fada8
15 changed files with 522 additions and 6 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: CI
on: on:
pull_request: pull_request:
branches: [main] branches: [master]
jobs: jobs:
test: test:
+2 -1
View File
@@ -45,7 +45,7 @@ jobs:
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \ COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true DJANGO_ALLOWED_HOSTS || true
PROJECT="client-ci-${{ gitea.sha }}" PROJECT="print-forge-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; } cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT trap cleanup EXIT
@@ -66,6 +66,7 @@ jobs:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps: steps:
# --app must match server-infra app_catalog (print_forge).
- name: Deploy print_forge beta to all webservers - name: Deploy print_forge beta to all webservers
run: | run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \ "$SERVER_INFRA_ROOT/scripts/deploy.sh" \
+2 -1
View File
@@ -43,7 +43,7 @@ jobs:
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \ COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true DJANGO_ALLOWED_HOSTS || true
PROJECT="client-ci-${{ gitea.sha }}" PROJECT="print-forge-ci-${{ gitea.sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; } cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT trap cleanup EXIT
@@ -64,6 +64,7 @@ jobs:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy ANSIBLE_PRIVATE_KEY_FILE: /home/westfarn/.ssh/ansible_deploy
steps: steps:
# --app must match server-infra app_catalog (print_forge).
- name: Deploy print_forge prod to all webservers - name: Deploy print_forge prod to all webservers
run: | run: |
"$SERVER_INFRA_ROOT/scripts/deploy.sh" \ "$SERVER_INFRA_ROOT/scripts/deploy.sh" \
+3 -1
View File
@@ -45,7 +45,9 @@ cp .env.prod.example ~/Documents/secrets/print_forge/print_forge_prod.env
./scripts/validate-env.sh ~/Documents/secrets/print_forge/print_forge_prod.env ./scripts/validate-env.sh ~/Documents/secrets/print_forge/print_forge_prod.env
``` ```
Infra: Postgres on 10.0.0.230, server-infra app_catalog, NPM, CI. Infra: Postgres on 10.0.0.230, server-infra `app_catalog.print_forge`, NPM, CI.
Gitea deploy calls `deploy.sh --app print_forge` (must match that catalog key).
Secrets: `~/Documents/secrets/print_forge/print_forge_{prod,beta}.env`.
Create a portal user with `createsuperuser` after first deploy. Create a portal user with `createsuperuser` after first deploy.
## Stack ## Stack
+1 -1
View File
@@ -128,7 +128,7 @@ done
if ((${#missing[@]} > 0)); then if ((${#missing[@]} > 0)); then
echo "Missing required environment variables in $ENV_FILE:" >&2 echo "Missing required environment variables in $ENV_FILE:" >&2
printf ' - %s\n' "${missing[@]}" >&2 printf ' - %s\n' "${missing[@]}" >&2
echo "Copy .env.prod.example to ~/Documents/secrets/<slug>_site/<slug>_site_${DJANGO_ENV}.env and set values." >&2 echo "Copy .env.prod.example to ~/Documents/secrets/print_forge/print_forge_${DJANGO_ENV}.env and set values." >&2
exit 1 exit 1
fi fi
+1
View File
@@ -123,6 +123,7 @@ class InstalledOptionalAppsTests(TestCase):
self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/")) self.assertTrue(reverse("social_ai:generate").startswith("/portal/social/api/generate/"))
self.assertTrue(reverse("shop:list").startswith("/shop/")) self.assertTrue(reverse("shop:list").startswith("/shop/"))
self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/")) self.assertTrue(reverse("shop_portal:product_list").startswith("/portal/shop/"))
self.assertTrue(reverse("shop_portal:sales").startswith("/portal/shop/sales/"))
self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/")) self.assertTrue(reverse("pos_sync:event_list").startswith("/portal/pos/"))
self.assertTrue(reverse("events:list").startswith("/events/")) self.assertTrue(reverse("events:list").startswith("/events/"))
self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/")) self.assertTrue(reverse("events_portal:event_list").startswith("/portal/events/"))
@@ -32,6 +32,14 @@
<div class="label">Open shop orders</div> <div class="label">Open shop orders</div>
<div class="value">{{ open_shop_orders|default:0 }}</div> <div class="value">{{ open_shop_orders|default:0 }}</div>
</div> </div>
<div class="stat-card">
<div class="label">Sales (30 days)</div>
<div class="value">{{ shop_sales_30d|default:0 }}</div>
</div>
<div class="stat-card">
<div class="label">Shop revenue (30 days)</div>
<div class="value" style="font-size:22px">{{ shop_revenue_30d|default:0 }}</div>
</div>
<div class="stat-card"> <div class="stat-card">
<div class="label">Low stock</div> <div class="label">Low stock</div>
<div class="value">{{ low_stock_products|default:0 }}</div> <div class="value">{{ low_stock_products|default:0 }}</div>
+57
View File
@@ -264,6 +264,63 @@ body.portal {
line-height: 1.2; line-height: 1.2;
white-space: nowrap; white-space: nowrap;
} }
.chart-placeholder.chart-daily {
gap: 3px;
padding: 16px 10px 8px;
overflow-x: auto;
}
.chart-placeholder.chart-daily .chart-bar-col {
flex: 1 0 8px;
min-width: 6px;
}
.chart-placeholder.chart-daily .chart-bar-col .bar {
min-height: 0;
}
.chart-placeholder.chart-daily .chart-bar-col .bar.is-zero {
height: 0 !important;
min-height: 0;
opacity: 0.2;
}
.chart-placeholder.chart-daily .chart-bar-label {
font-size: 10px;
}
.chart-placeholder.chart-revenue .chart-bar-col .bar {
background: var(--monica-primary-light);
}
.hbar-chart {
display: flex;
flex-direction: column;
gap: 12px;
}
.hbar-row {
display: grid;
grid-template-columns: minmax(72px, 160px) 1fr auto;
gap: 12px;
align-items: center;
}
.hbar-label {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hbar-track {
height: 16px;
background: #e8f4f5;
overflow: hidden;
}
.hbar-fill {
height: 100%;
background: var(--monica-primary);
opacity: 0.8;
min-width: 0;
}
.hbar-value {
font-size: 13px;
font-weight: 600;
min-width: 2.5ch;
text-align: right;
}
.consent-pills { display: flex; gap: 6px; flex-wrap: wrap; } .consent-pills { display: flex; gap: 6px; flex-wrap: wrap; }
.preview-pane { .preview-pane {
+7
View File
@@ -77,6 +77,13 @@ def tianji_tracking(request):
nav_section = "social_accounts" nav_section = "social_accounts"
elif namespace == "social": elif namespace == "social":
nav_section = "social" nav_section = "social"
elif namespace == "shop_portal":
if url_name.startswith("product"):
nav_section = "shop_products"
elif url_name.startswith("order"):
nav_section = "shop_orders"
elif url_name == "sales":
nav_section = "shop_sales"
elif namespace == "shop": elif namespace == "shop":
nav_section = "shop" nav_section = "shop"
+11
View File
@@ -9,6 +9,13 @@ from core.registry import (
def register() -> None: def register() -> None:
register_feature("shop") register_feature("shop")
register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30) register_public_nav(section="shop", label="Shop", url_name="shop:list", order=30)
register_portal_nav(
section="shop_sales",
label="Sales",
url_name="shop_portal:sales",
group="Retail",
order=5,
)
register_portal_nav( register_portal_nav(
section="shop_products", section="shop_products",
label="Products", label="Products",
@@ -30,7 +37,9 @@ def _dashboard(request) -> dict:
from django.db.models import Q from django.db.models import Q
from shop.models import Order, Product from shop.models import Order, Product
from shop.stats import sales_summary
sales = sales_summary()
return { return {
"open_shop_orders": Order.objects.filter( "open_shop_orders": Order.objects.filter(
status__in=[Order.Status.OPEN, Order.Status.PAID] status__in=[Order.Status.OPEN, Order.Status.PAID]
@@ -43,4 +52,6 @@ def _dashboard(request) -> dict:
.filter(Q(colors__stock_qty__lte=3) | Q(colors__isnull=True, stock_qty__lte=3)) .filter(Q(colors__stock_qty__lte=3) | Q(colors__isnull=True, stock_qty__lte=3))
.distinct() .distinct()
.count(), .count(),
"shop_sales_30d": sales["order_count"],
"shop_revenue_30d": sales["revenue"],
} }
+1
View File
@@ -5,6 +5,7 @@ from shop import views
app_name = "shop_portal" app_name = "shop_portal"
urlpatterns = [ urlpatterns = [
path("sales/", views.portal_sales, name="sales"),
path("products/", views.portal_product_list, name="product_list"), path("products/", views.portal_product_list, name="product_list"),
path("products/new/", views.portal_product_edit, name="product_new"), path("products/new/", views.portal_product_edit, name="product_new"),
path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"), path("products/<uuid:pk>/", views.portal_product_edit, name="product_edit"),
+166
View File
@@ -0,0 +1,166 @@
"""Sales dashboard aggregates for FEATURE_SHOP."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from django.conf import settings
from django.db.models import Count, F, Max, Q, Sum
from django.utils import timezone
from shop.models import Order, OrderItem
SOLD_STATUSES = (Order.Status.PAID, Order.Status.FULFILLED)
SALES_WINDOW_DAYS = 30
def _money(value) -> Decimal:
return (value or Decimal("0")).quantize(Decimal("0.01"))
def _window_start(days: int):
today = timezone.localdate()
start_date = today - timedelta(days=days - 1)
start_dt = timezone.make_aware(
datetime.combine(start_date, time.min),
timezone.get_current_timezone(),
)
return today, start_date, start_dt
def _sold_orders(start_dt=None):
qs = Order.objects.filter(status__in=SOLD_STATUSES)
if start_dt is None:
return qs
return qs.filter(
Q(paid_at__gte=start_dt) | Q(paid_at__isnull=True, created_at__gte=start_dt)
)
def _sale_date(order) -> date:
when = order.paid_at or order.created_at
return timezone.localtime(when).date()
def _bar_pct(value: Decimal | int | float, peak: Decimal | int | float) -> int:
if not peak:
return 0
if not value:
return 0
return max(8, int(round((float(value) / float(peak)) * 100)))
def sales_summary(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Order count and revenue for the rolling sales window."""
_, _, start_dt = _window_start(days)
totals = _sold_orders(start_dt).aggregate(
order_count=Count("id"), revenue=Sum("amount")
)
return {
"order_count": int(totals["order_count"] or 0),
"revenue": _money(totals["revenue"]),
}
def sales_dashboard(*, days: int = SALES_WINDOW_DAYS) -> dict:
"""Paid/fulfilled order stats, daily series, and top products."""
_, start_date, start_dt = _window_start(days)
sold = _sold_orders(start_dt)
totals = sold.aggregate(order_count=Count("id"), revenue=Sum("amount"))
order_count = int(totals["order_count"] or 0)
revenue = _money(totals["revenue"])
units = int(
OrderItem.objects.filter(order__in=sold).aggregate(total=Sum("quantity"))[
"total"
]
or 0
)
aov = _money(revenue / order_count) if order_count else Decimal("0.00")
currency = (settings.STRIPE_CURRENCY or "usd").lower()
by_day: dict = {
start_date + timedelta(days=offset): {
"count": 0,
"revenue": Decimal("0.00"),
}
for offset in range(days)
}
for order in sold.only("paid_at", "created_at", "amount"):
day = _sale_date(order)
bucket = by_day.get(day)
if bucket is None:
continue
bucket["count"] += 1
bucket["revenue"] += order.amount or Decimal("0")
peak_count = max((row["count"] for row in by_day.values()), default=0)
peak_revenue = max((row["revenue"] for row in by_day.values()), default=Decimal("0"))
daily_sales = []
daily_revenue = []
for index, (day, row) in enumerate(by_day.items()):
tick = index == 0 or index == days - 1 or day.weekday() == 0
label = f"{day.strftime('%b')} {day.day}"
daily_sales.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["count"], peak_count),
"tick": tick,
"tick_label": label if tick else "",
}
)
daily_revenue.append(
{
"date": day,
"label": label,
"count": row["count"],
"revenue": _money(row["revenue"]),
"pct": _bar_pct(row["revenue"], peak_revenue),
"tick": tick,
"tick_label": label if tick else "",
}
)
product_rows = list(
OrderItem.objects.filter(order__in=sold)
.values("sku")
.annotate(
units=Sum("quantity"),
revenue=Sum(F("unit_price") * F("quantity")),
product_name=Max("product__name"),
item_name=Max("name"),
)
.order_by("-units", "-revenue")[:8]
)
peak_units = max((int(row["units"] or 0) for row in product_rows), default=0)
top_products = []
for row in product_rows:
units_sold = int(row["units"] or 0)
top_products.append(
{
"sku": row["sku"],
"name": row["product_name"] or row["item_name"] or row["sku"],
"units": units_sold,
"revenue": _money(row["revenue"]),
"bar_pct": _bar_pct(units_sold, peak_units),
}
)
recent_orders = list(sold.order_by("-paid_at", "-created_at")[:8])
return {
"days": days,
"currency": currency,
"order_count": order_count,
"revenue": revenue,
"units_sold": units,
"aov": aov,
"daily_sales": daily_sales,
"daily_revenue": daily_revenue,
"top_products": top_products,
"recent_orders": recent_orders,
"has_sales": order_count > 0,
}
+147
View File
@@ -0,0 +1,147 @@
{% extends "portal_base.html" %}
{% block title %}Sales · Portal{% endblock %}
{% block topbar_title %}Sales{% endblock %}
{% block portal_content %}
<div class="stat-row">
<div class="stat-card">
<div class="label">Orders ({{ days }} days)</div>
<div class="value">{{ order_count }}</div>
</div>
<div class="stat-card">
<div class="label">Revenue ({{ days }} days)</div>
<div class="value">{{ revenue }} {{ currency|upper }}</div>
</div>
<div class="stat-card">
<div class="label">Units sold</div>
<div class="value">{{ units_sold }}</div>
</div>
<div class="stat-card">
<div class="label">Average order</div>
<div class="value">{{ aov }} {{ currency|upper }}</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Orders per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily" role="img"
aria-label="Paid orders per day for the last {{ days }} days">
{% for bar in daily_sales %}
<div class="chart-bar-col">
<div class="bar{% if not bar.count %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.count }} sale{{ bar.count|pluralize }} · {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
{% if not has_sales %}
<p class="empty-state" style="padding-top:12px">No paid orders in this window.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Revenue per day</h2>
<span class="muted">Last {{ days }} days</span>
</div>
<div class="panel-b">
<div class="chart-placeholder chart-daily chart-revenue" role="img"
aria-label="Revenue per day for the last {{ days }} days">
{% for bar in daily_revenue %}
<div class="chart-bar-col">
<div class="bar{% if not bar.revenue %} is-zero{% endif %}"
style="height:{{ bar.pct }}%"
title="{{ bar.label }}: {{ bar.revenue }} {{ currency|upper }}"></div>
<div class="chart-bar-meta">
<span class="chart-bar-label">{% if bar.tick %}{{ bar.tick_label }}{% else %}&nbsp;{% endif %}</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="split">
<div class="panel">
<div class="panel-h">
<h2>Top products</h2>
<span class="muted">By units sold</span>
</div>
<div class="panel-b">
{% if top_products %}
<div class="hbar-chart" role="img" aria-label="Top products by units sold">
{% for row in top_products %}
<div class="hbar-row" title="{{ row.name }} · {{ row.units }} sold · {{ row.revenue }} {{ currency|upper }}">
<div class="hbar-label">{{ row.name }}</div>
<div class="hbar-track">
<div class="hbar-fill" style="width:{{ row.bar_pct }}%"></div>
</div>
<div class="hbar-value">{{ row.units }}</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state" style="margin:0;padding:0">No product sales yet.</p>
{% endif %}
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Product mix</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:product_list' %}">Products</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Product</th><th>SKU</th><th>Units</th><th>Revenue</th></tr>
</thead>
<tbody>
{% for row in top_products %}
<tr>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.units }}</td>
<td>{{ row.revenue }} {{ currency|upper }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid line items in the last {{ days }} days.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<div class="panel">
<div class="panel-h">
<h2>Recent sales</h2>
<a class="btn btn-sm btn-ghost" href="{% url 'shop_portal:order_list' %}">All orders</a>
</div>
<div class="panel-b" style="padding:0">
<table class="table">
<thead>
<tr><th>Number</th><th>Customer</th><th>Amount</th><th>Status</th></tr>
</thead>
<tbody>
{% for order in recent_orders %}
<tr>
<td><a href="{% url 'shop_portal:order_detail' order.pk %}">{{ order.number }}</a></td>
<td>{{ order.customer_name|default:order.email }}</td>
<td>{{ order.amount }} {{ order.currency|upper }}</td>
<td>{{ order.get_status_display }}</td>
</tr>
{% empty %}
<tr><td colspan="4" class="empty-state">No paid orders yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+109 -1
View File
@@ -1,4 +1,5 @@
import struct import struct
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from io import BytesIO from io import BytesIO
from pathlib import Path from pathlib import Path
@@ -13,10 +14,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploaded
from django.db import models from django.db import models
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from PIL import Image from PIL import Image
from core.models import StoredFile from core.models import StoredFile
from shop.models import Order, Product, ProductColor, ProductImage from shop.models import Order, OrderItem, Product, ProductColor, ProductImage
from shop.services import ( from shop.services import (
ShopError, ShopError,
add_to_cart, add_to_cart,
@@ -29,6 +31,7 @@ from shop.services import (
store_product_image, store_product_image,
store_product_stl, store_product_stl,
) )
from shop.stats import sales_dashboard
def _tiny_png() -> bytes: def _tiny_png() -> bytes:
image = Image.new("RGBA", (8, 8), (200, 40, 40, 255)) image = Image.new("RGBA", (8, 8), (200, 40, 40, 255))
@@ -591,3 +594,108 @@ class ShopPortalTests(TestCase):
self.assertFalse(tmp_path.exists()) self.assertFalse(tmp_path.exists())
self.assertEqual(bytes(stored.data), _tiny_stl()) self.assertEqual(bytes(stored.data), _tiny_stl())
self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_STL) self.assertEqual(stored.kind, StoredFile.Kind.PRODUCT_STL)
def _sold_order(product, *, qty=1, paid_at=None, status=None, number=None):
n = Order.objects.count() + 1
order = Order.objects.create(
number=number or f"ORD-TEST-{n:04d}",
email=f"buyer{n}@example.com",
customer_name="Pat",
status=status or Order.Status.PAID,
amount=product.price * qty,
currency="usd",
paid_at=paid_at if paid_at is not None else timezone.now(),
)
OrderItem.objects.create(
order=order,
product=product,
name=product.name,
sku=product.sku,
quantity=qty,
unit_price=product.price,
)
return order
class ShopSalesDashboardTests(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user("merchant", password="test-pass-123")
self.client = Client()
self.client.login(username="merchant", password="test-pass-123")
self.dragon = _product(name="Dragon", sku="DRAGON", price=Decimal("18.00"))
self.fox = _product(name="Fox", sku="FOX", price=Decimal("12.00"))
def test_sales_requires_login(self):
anon = Client()
self.assertEqual(anon.get(reverse("shop_portal:sales")).status_code, 302)
def test_empty_dashboard(self):
data = sales_dashboard()
self.assertEqual(data["order_count"], 0)
self.assertEqual(data["revenue"], Decimal("0.00"))
self.assertEqual(len(data["daily_sales"]), 30)
self.assertFalse(data["has_sales"])
response = self.client.get(reverse("shop_portal:sales"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "No paid orders in this window")
self.assertContains(response, "Sales")
self.assertContains(response, "Orders per day")
self.assertContains(response, "Top products")
def test_counts_paid_and_fulfilled_in_window(self):
now = timezone.now()
_sold_order(self.dragon, qty=2, paid_at=now)
_sold_order(self.fox, qty=5, paid_at=now - timedelta(days=2))
_sold_order(
self.dragon,
qty=1,
paid_at=now - timedelta(days=1),
status=Order.Status.FULFILLED,
)
_sold_order(
self.fox,
qty=9,
paid_at=now - timedelta(days=40),
)
open_order = _sold_order(self.dragon, qty=3, paid_at=None, status=Order.Status.OPEN)
open_order.paid_at = None
open_order.save(update_fields=["paid_at"])
cancelled = _sold_order(
self.fox, qty=4, paid_at=now, status=Order.Status.CANCELLED
)
self.assertEqual(cancelled.status, Order.Status.CANCELLED)
data = sales_dashboard()
self.assertEqual(data["order_count"], 3)
self.assertEqual(data["units_sold"], 8)
self.assertEqual(data["revenue"], Decimal("114.00"))
self.assertEqual(data["aov"], Decimal("38.00"))
self.assertEqual([row["sku"] for row in data["top_products"]], ["FOX", "DRAGON"])
self.assertEqual(data["top_products"][0]["units"], 5)
self.assertEqual(data["top_products"][1]["units"], 3)
today_bar = data["daily_sales"][-1]
self.assertEqual(today_bar["count"], 1)
response = self.client.get(reverse("shop_portal:sales"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Dragon")
self.assertContains(response, "Fox")
self.assertContains(response, "114.00")
self.assertContains(response, "Orders per day")
self.assertContains(response, "Revenue per day")
def test_portal_nav_and_home_stats(self):
_sold_order(self.dragon, qty=1)
home = self.client.get(reverse("dashboard:home"))
self.assertEqual(home.status_code, 200)
self.assertEqual(home.context["shop_sales_30d"], 1)
self.assertEqual(home.context["shop_revenue_30d"], Decimal("18.00"))
self.assertContains(home, reverse("shop_portal:sales"))
self.assertContains(home, "Sales (30 days)")
products = self.client.get(reverse("shop_portal:product_list"))
self.assertContains(products, "Sales")
sales = self.client.get(reverse("shop_portal:sales"))
self.assertContains(sales, 'class="active"')
+6
View File
@@ -34,6 +34,7 @@ from shop.services import (
store_product_stl, store_product_stl,
sync_product_colors, sync_product_colors,
) )
from shop.stats import sales_dashboard
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -344,6 +345,11 @@ def portal_stock_adjust(request, pk):
return redirect("shop_portal:product_edit", pk=product.pk) return redirect("shop_portal:product_edit", pk=product.pk)
@login_required
def portal_sales(request):
return render(request, "shop/portal/sales.html", sales_dashboard())
@login_required @login_required
def portal_order_list(request): def portal_order_list(request):
orders = Order.objects.all()[:200] orders = Order.objects.all()[:200]