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
+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"')