Add a shop sales dashboard with 30-day charts.

Portal Retail gets a Sales page for paid/fulfilled volume, daily bars, and top products so the storefront can show performance without a third-party analytics tool.

Closes #3
This commit is contained in:
2026-09-06 10:34:45 -05:00
parent b2029b6c0f
commit 9f2d349974
10 changed files with 513 additions and 1 deletions
+109 -1
View File
@@ -1,4 +1,5 @@
import struct
from datetime import timedelta
from decimal import Decimal
from io import BytesIO
from pathlib import Path
@@ -13,10 +14,11 @@ from django.core.files.uploadedfile import SimpleUploadedFile, TemporaryUploaded
from django.db import models
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from PIL import Image
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 (
ShopError,
add_to_cart,
@@ -29,6 +31,7 @@ from shop.services import (
store_product_image,
store_product_stl,
)
from shop.stats import sales_dashboard
def _tiny_png() -> bytes:
image = Image.new("RGBA", (8, 8), (200, 40, 40, 255))
@@ -591,3 +594,108 @@ class ShopPortalTests(TestCase):
self.assertFalse(tmp_path.exists())
self.assertEqual(bytes(stored.data), _tiny_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"')