Add Norwegian demo data, avatars and a random mode to seed_demo_data

This commit is contained in:
ponas committed 2026-10-23 02:19:40 +02:00
1 parent 3ade11111b
commit 6fcdaca833
3 files changed
+297 -30

No files matched your search

+2
View File
@@ -27,6 +27,8 @@ Vite prints the frontend URL when it starts (normally <http://localhost:5173/>;
To test the frontend against a different backend, set `VITE_DEV_API_PROXY` when starting Vite, for example `VITE_DEV_API_PROXY=https://bbs.neuf.no mise run frontend-run`. This affects the development proxy only; use caution with a live backend, since app changes and submissions may modify production data. To test the frontend against a different backend, set `VITE_DEV_API_PROXY` when starting Vite, for example `VITE_DEV_API_PROXY=https://bbs.neuf.no mise run frontend-run`. This affects the development proxy only; use caution with a live backend, since app changes and submissions may modify production data.
For more realistic statistics, `uv run manage.py seed_demo_data --size medium` also generates random users, orders and deposits spread over time (`small`, `medium` or `large`; add `--seed N` for reproducible output). It adds more data on every run.
To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` for that command; the command only creates a local `admin` superuser if one does not already exist. To create the admin during data seeding instead, set `BBS_SEED_ADMIN_PASSWORD` for that command; the command only creates a local `admin` superuser if one does not already exist.
## Testing ## Testing
+258 -21
View File
@@ -1,31 +1,128 @@
import os import os
import random
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from urllib.parse import quote
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.management import BaseCommand from django.core.management import BaseCommand, CommandParser
from django.db import transaction as db_transaction
from django.db.models import Sum from django.db.models import Sum
from django.utils import timezone from django.utils import timezone
from bbs.models import InventoryTransaction, Order, OrderLine, Product, Transaction, UserProfile from bbs.models import InventoryTransaction, Order, OrderLine, Product, Transaction, UserProfile
# Some users go by first name only, which the frontend handles differently.
DEMO_USERS = ( DEMO_USERS = (
("demo-ada", "Ada", "Nord", "ada@example.test", Decimal("300.0")), ("demo-ada", "Ada", "Nord", "ada@example.test", Decimal("300.0")),
("demo-bjørn", "Bjørn", "Berg", "bjorn@example.test", Decimal("250.0")), ("demo-bjørn", "Bjørn", "Berg", "bjorn@example.test", Decimal("250.0")),
("demo-casey", "Casey", "Sol", "casey@example.test", Decimal("200.0")), ("demo-casey", "Casey", "Sol", "casey@example.test", Decimal("200.0")),
("demo-kari", "Kari", "Nordmann", "kari@example.test", Decimal("400.0")),
("demo-ole-martin", "Ole-Martin", "Ødegård", "ole-martin@example.test", Decimal("300.0")),
("demo-solveig", "Solveig", "", "solveig@example.test", Decimal("250.0")),
("demo-trygve", "Trygve", "Halvorsen", "trygve@example.test", Decimal("200.0")),
("demo-ingrid", "Ingrid", "Bakken", "ingrid@example.test", Decimal("350.0")),
("demo-håkon", "Håkon", "", "hakon@example.test", Decimal("150.0")),
) )
DEMO_PRODUCTS = ( # name, volume_liter, alcohol_percent, sale_price_int, sale_price_ext
("Demo Cola", Decimal("25.0"), Decimal("30.0"), 30), BEERS = (
("Demo Kaffe", Decimal("15.0"), Decimal("20.0"), 30), ("Frydenlund Fatøl", "0.5", "4.5", "25", "35"),
("Demo Sjokolade", Decimal("20.0"), Decimal("25.0"), 30), ("Ringnes Pilsner", "0.5", "4.7", "25", "35"),
("Demo Energidrikk", Decimal("35.0"), Decimal("40.0"), 30), ("Nøgne Ø India Pale Ale", "0.5", "7.5", "45", "60"),
("Lervig Lucky Jack", "0.33", "4.7", "40", "55"),
("Munkholm Alkoholfri", "0.5", "0.5", "20", "28"),
("Haandbryggeriet Mjød", "0.5", "8.0", "55", "70"),
("Hansa Bayer", "0.5", "4.6", "27", "37"),
("Aass Bayer", "0.5", "4.5", "28", "38"),
("Mack Pilsner", "0.5", "4.7", "28", "38"),
("Dahls Pils", "0.5", "4.5", "25", "35"),
("Borg Bryggeri Pils", "0.5", "4.7", "30", "40"),
("Grans Bare Pils", "0.5", "4.7", "24", "34"),
("CB Pilsner", "0.5", "4.7", "25", "35"),
("7 Fjell Hopyards IPA", "0.33", "6.5", "48", "63"),
("Tuborg Grønn", "0.5", "4.6", "28", "38"),
("Carlsberg Pilsner", "0.5", "4.6", "28", "38"),
("Frydenlund Bayer", "0.5", "4.5", "26", "36"),
("Berentsens Julebrygg", "0.5", "4.7", "32", "42"),
("Ægir Sumble", "0.5", "4.7", "42", "57"),
("Kinn Vestkyst", "0.5", "5.2", "46", "61"),
("Trondhjem Pilsner", "0.5", "4.7", "30", "40"),
("Nøgne Ø Pale Ale", "0.5", "4.5", "44", "59"),
("Voss Bryggeri Pils", "0.5", "4.7", "38", "50"),
("Lervig Pils", "0.33", "4.7", "30", "40"),
) )
DEMO_PRODUCTS = BEERS[:6]
DEMO_STOCK = 30
DESCRIPTIONS = (
"Klassisk norsk pils med frisk og rund smak.",
"Fyldig og maltrik, perfekt på en kald kveld.",
"Lett og forfriskende, godt selskap til maten.",
"Humlerik og bitter, for den som vil ha litt mer.",
"Tradisjonsrik bryggekunst fra ærverdig bryggeri.",
)
# user index, days ago, (product name, amount) lines
DEMO_ORDERS = (
(0, 26, (("Ringnes Pilsner", 2), ("Munkholm Alkoholfri", 1))),
(1, 21, (("Frydenlund Fatøl", 2), ("Lervig Lucky Jack", 1))),
(2, 13, (("Nøgne Ø India Pale Ale", 1),)),
(3, 24, (("Frydenlund Fatøl", 2), ("Munkholm Alkoholfri", 1))),
(4, 19, (("Nøgne Ø India Pale Ale", 2),)),
(5, 15, (("Ringnes Pilsner", 1), ("Lervig Lucky Jack", 1))),
(6, 11, (("Haandbryggeriet Mjød", 1),)),
(7, 8, (("Lervig Lucky Jack", 2), ("Frydenlund Fatøl", 1))),
(3, 4, (("Ringnes Pilsner", 1), ("Frydenlund Fatøl", 1))),
(8, 2, (("Munkholm Alkoholfri", 3),)),
(4, 1, (("Frydenlund Fatøl", 2),)),
)
# Extra random users for --size
FIRST_NAMES = (
"Ola", "Kari", "Per", "Anne", "Lars", "Ingrid", "Bjørn", "Marit", "Geir", "Hanne", "Knut", "Liv",
"Nils", "Solveig", "Erik", "Astrid", "Håkon", "Sigrid", "Trygve", "Randi", "Espen", "Tone", "Magnus",
"Silje", "Henrik", "Maren", "Sander", "Ida", "Jonas", "Emma", "Mathias", "Nora", "Tobias", "Thea",
"Vegard", "Kristin", "Ole-Martin", "Anne-Lise", "Frode", "Gunnhild",
) # fmt: skip
LAST_NAMES = (
"Hansen", "Johansen", "Olsen", "Larsen", "Andersen", "Pedersen", "Nilsen", "Kristiansen", "Jensen",
"Karlsen", "Berg", "Haugen", "Hagen", "Johnsen", "Andreassen", "Jakobsen", "Dahl", "Lund", "Halvorsen",
"Eriksen", "Bakken", "Moen", "Strand", "Ødegård",
) # fmt: skip
SIZE_PRESETS = {
"small": {"users": 6, "orders": 40, "days": 30},
"medium": {"users": 15, "orders": 300, "days": 180},
"large": {"users": 40, "orders": 1500, "days": 365},
}
FIRST_NAME_ONLY_PROB = 0.35 # users registered with just a first name
EXTERNAL_ORDER_PROB = 0.10 # orders with no customer (paid in cash)
WITHDRAWAL_PROB = 0.10 # users who have had money paid back out
def avatar_url(email: str) -> str:
return f"https://i.pravatar.cc/150?u={quote(email)}"
def ascii_name(name: str) -> str:
return name.lower().replace("ø", "o").replace("å", "a").replace("æ", "ae")
class Command(BaseCommand): class Command(BaseCommand):
help = "Create repeatable demo users, products, deposits, and orders for local testing." help = "Create repeatable demo users, products, deposits, and orders for local testing."
def add_arguments(self, parser: CommandParser):
parser.add_argument(
"--size",
choices=sorted(SIZE_PRESETS),
help="Also generate random users, orders and deposits on top of the demo data. Adds more on every run.",
)
parser.add_argument("--users", type=int, help="Number of random users (overrides --size).")
parser.add_argument("--orders", type=int, help="Number of random orders (overrides --size).")
parser.add_argument("--days", type=int, help="Spread random activity over this many past days.")
parser.add_argument("--seed", type=int, help="Seed for the random data, for reproducible output.")
def handle(self, *args, **options): # noqa: ARG002 def handle(self, *args, **options): # noqa: ARG002
users = self.seed_users() users = self.seed_users()
products = self.seed_products(users[0][0]) products = self.seed_products(users[0][0])
@@ -34,6 +131,22 @@ class Command(BaseCommand):
self.update_balances(users) self.update_balances(users)
self.create_admin() self.create_admin()
if options["size"]:
preset = SIZE_PRESETS[options["size"]]
generator = RandomData(
random.Random(options["seed"]), # noqa: S311
days=options["days"] or preset["days"],
)
with db_transaction.atomic():
generator.run(
n_users=options["users"] or preset["users"],
n_orders=options["orders"] or preset["orders"],
)
self.stdout.write(
f"Generated {generator.n_users} random users and {generator.n_orders} random orders "
f"over {generator.days} days.",
)
self.stdout.write(self.style.SUCCESS("Demo data is ready.")) self.stdout.write(self.style.SUCCESS("Demo data is ready."))
if not os.environ.get("BBS_SEED_ADMIN_PASSWORD"): if not os.environ.get("BBS_SEED_ADMIN_PASSWORD"):
self.stdout.write("Create an admin login with: uv run manage.py createsuperuser") self.stdout.write("Create an admin login with: uv run manage.py createsuperuser")
@@ -47,21 +160,23 @@ class Command(BaseCommand):
username=username, username=username,
defaults={"first_name": first_name, "last_name": last_name, "email": email}, defaults={"first_name": first_name, "last_name": last_name, "email": email},
) )
UserProfile.objects.get_or_create(user=user) UserProfile.objects.get_or_create(user=user, defaults={"image": avatar_url(email)})
users.append((user, deposit_amount)) users.append((user, deposit_amount))
return users return users
@staticmethod @staticmethod
def seed_products(stock_user): def seed_products(stock_user):
products = {} products = {}
for name, sale_price_int, sale_price_ext, stock in DEMO_PRODUCTS: for index, (name, volume, alcohol, sale_price_int, sale_price_ext) in enumerate(DEMO_PRODUCTS):
product, created = Product.objects.get_or_create( product, created = Product.objects.get_or_create(
name=name, name=name,
defaults={ defaults={
"sale_price_int": sale_price_int, "sale_price_int": Decimal(sale_price_int),
"sale_price_ext": sale_price_ext, "sale_price_ext": Decimal(sale_price_ext),
"inventory_amount": stock, "volume_liter": Decimal(volume),
"description": "Demo product for local development.", "alcohol_percent": Decimal(alcohol),
"inventory_amount": DEMO_STOCK,
"description": DESCRIPTIONS[index % len(DESCRIPTIONS)],
}, },
) )
products[name] = product products[name] = product
@@ -70,7 +185,7 @@ class Command(BaseCommand):
user=stock_user, user=stock_user,
product=product, product=product,
comment="Demo opening stock", comment="Demo opening stock",
defaults={"amount": stock, "unit_price": Decimal("10.0")}, defaults={"amount": DEMO_STOCK, "unit_price": Decimal(sale_price_int) * Decimal("0.6")},
) )
return products return products
@@ -87,14 +202,7 @@ class Command(BaseCommand):
deposit.save(update_fields=["created"]) deposit.save(update_fields=["created"])
def seed_orders(self, users, products): def seed_orders(self, users, products):
orders = ( for user_index, days_ago, lines in DEMO_ORDERS:
(0, 24, (("Demo Cola", 2), ("Demo Sjokolade", 1))),
(1, 19, (("Demo Kaffe", 2), ("Demo Sjokolade", 1))),
(2, 11, (("Demo Energidrikk", 1),)),
(0, 4, (("Demo Kaffe", 1), ("Demo Cola", 1))),
(1, 1, (("Demo Cola", 2),)),
)
for user_index, days_ago, lines in orders:
user = users[user_index][0] user = users[user_index][0]
created_at = self.timestamp(days_ago=days_ago) created_at = self.timestamp(days_ago=days_ago)
total = sum( total = sum(
@@ -149,3 +257,132 @@ class Command(BaseCommand):
def timestamp(days_ago: int) -> datetime: def timestamp(days_ago: int) -> datetime:
date = timezone.localdate() - timedelta(days=days_ago) date = timezone.localdate() - timedelta(days=days_ago)
return timezone.make_aware(datetime.combine(date, time(hour=12))) return timezone.make_aware(datetime.combine(date, time(hour=12)))
class RandomData:
"""Random users, restocks, orders and deposits spread over time, for realistic stats."""
def __init__(self, rng: random.Random, days: int):
self.rng = rng
self.days = days
self.end = timezone.now()
self.start = self.end - timedelta(days=days)
self.n_users = 0
self.n_orders = 0
def run(self, n_users: int, n_orders: int):
users = self.create_users(n_users)
products = self.create_products()
self.restock(products, users)
spent = self.create_orders(users, products, n_orders)
self.create_money(users, spent)
def random_time(self):
span = int((self.end - self.start).total_seconds())
return self.start + timedelta(seconds=self.rng.randint(0, max(span, 1)))
@staticmethod
def set_created(obj, created):
# created is auto_now_add, so backdate with an update
type(obj).objects.filter(pk=obj.pk).update(created=created)
def create_users(self, n: int) -> list:
user_model = get_user_model()
users = []
for _ in range(n):
first = self.rng.choice(FIRST_NAMES)
last = "" if self.rng.random() < FIRST_NAME_ONLY_PROB else self.rng.choice(LAST_NAMES)
base = ".".join(ascii_name(part) for part in (first, last) if part)
email = f"{base}@example.test"
suffix = 1
while user_model.objects.filter(username=email).exists():
suffix += 1
email = f"{base}{suffix}@example.test"
user = user_model.objects.create_user(username=email, email=email, first_name=first, last_name=last)
UserProfile.objects.create(user=user, image=avatar_url(email))
users.append(user)
self.n_users = len(users)
return users
def create_products(self) -> list:
products = []
for name, volume, alcohol, sale_price_int, sale_price_ext in BEERS:
product, _ = Product.objects.get_or_create(
name=name,
defaults={
"sale_price_int": Decimal(sale_price_int),
"sale_price_ext": Decimal(sale_price_ext),
"volume_liter": Decimal(volume),
"alcohol_percent": Decimal(alcohol),
"description": self.rng.choice(DESCRIPTIONS),
},
)
products.append(product)
return products
def restock(self, products: list, users: list):
for product in products:
wholesale = (product.sale_price_int * Decimal("0.6")).quantize(Decimal("0.1"))
for _ in range(self.rng.randint(1, 3)):
amount = self.rng.choice([24, 48, 60, 120])
restock = InventoryTransaction.objects.create(
user=self.rng.choice(users),
product=product,
amount=amount,
unit_price=wholesale,
comment="Innkjøp",
)
self.set_created(restock, self.random_time())
product.inventory_amount += amount
product.save(update_fields=["inventory_amount"])
def create_orders(self, users: list, products: list, n_orders: int) -> dict:
spent = {user.pk: Decimal(0) for user in users}
for _ in range(n_orders):
customer = None if self.rng.random() < EXTERNAL_ORDER_PROB else self.rng.choice(users)
# Mostly a single product, occasionally two or three
n_lines = self.rng.choices([1, 2, 3], weights=[60, 30, 10])[0]
lines = []
order_sum = Decimal(0)
for product in self.rng.sample(products, n_lines):
amount = self.rng.choices([1, 2, 3, 6], weights=[55, 30, 10, 5])[0]
unit_price = product.sale_price_int if customer else product.sale_price_ext
lines.append(OrderLine(product=product, amount=amount, unit_price=unit_price))
order_sum += unit_price * amount
product.inventory_amount = max(0, product.inventory_amount - amount)
order = Order.objects.create(customer=customer, order_sum=order_sum)
for line in lines:
line.order = order
OrderLine.objects.bulk_create(lines)
self.set_created(order, self.random_time())
if customer:
spent[customer.pk] += order_sum
for product in products:
product.save(update_fields=["inventory_amount"])
self.n_orders = n_orders
return spent
def create_money(self, users: list, spent: dict):
"""Round deposits covering what each user spent, plus rare withdrawals within the leftover buffer."""
for user in users:
buffer = Decimal(self.rng.choice([0, 50, 100, 200, 300, 400]))
remaining = spent[user.pk] + buffer
net = Decimal(0)
while remaining > 0:
amount = min(Decimal(self.rng.choice([100, 200, 200, 300, 500])), remaining)
deposit = Transaction.objects.create(user=user, amount=amount)
self.set_created(deposit, self.random_time())
net += amount
remaining -= amount
if buffer > 0 and self.rng.random() < WITHDRAWAL_PROB:
withdrawal = Decimal(self.rng.randint(1, int(buffer)))
payout = Transaction.objects.create(user=user, amount=-withdrawal)
self.set_created(payout, self.random_time())
net -= withdrawal
user.profile.balance = net - spent[user.pk]
user.profile.save(update_fields=["balance"])
+37 -9
View File
@@ -4,7 +4,10 @@ from django.core.management import call_command
from django.test import Client from django.test import Client
from strawberry.django.test import GraphQLTestClient from strawberry.django.test import GraphQLTestClient
from bbs.models import InventoryTransaction, Order, Product, Transaction from bbs.management.commands.seed_demo_data import DEMO_PRODUCTS
from bbs.models import InventoryTransaction, Order, Product, Transaction, UserProfile
DEMO_PRODUCT_NAMES = [name for name, *_ in DEMO_PRODUCTS]
@pytest.mark.django_db @pytest.mark.django_db
@@ -12,12 +15,14 @@ def test_seed_demo_data_is_repeatable():
call_command("seed_demo_data") call_command("seed_demo_data")
call_command("seed_demo_data") call_command("seed_demo_data")
assert get_user_model().objects.filter(username__startswith="demo-").count() == 3 assert get_user_model().objects.filter(username__startswith="demo-").count() == 9
assert Product.objects.filter(name__startswith="Demo ").count() == 4 assert Product.objects.filter(name__in=DEMO_PRODUCT_NAMES).count() == 6
assert Order.objects.count() == 5 assert Order.objects.count() == 11
assert Transaction.objects.count() == 3 assert Transaction.objects.count() == 9
assert InventoryTransaction.objects.count() == 4 assert InventoryTransaction.objects.count() == 6
assert sum(product.inventory_amount for product in Product.objects.filter(name__startswith="Demo ")) == 109 assert sum(product.inventory_amount for product in Product.objects.filter(name__in=DEMO_PRODUCT_NAMES)) == 155
assert all(profile.balance >= 0 for profile in UserProfile.objects.all())
assert all("pravatar.cc" in profile.image for profile in UserProfile.objects.all())
@pytest.mark.django_db @pytest.mark.django_db
@@ -38,8 +43,8 @@ def test_demo_data_is_available_when_an_admin_has_no_profile():
assert not result.errors assert not result.errors
assert result.data assert result.data
assert len(result.data["allProducts"]) == 4 assert len(result.data["allProducts"]) == 6
assert len(result.data["allUsers"]) == 3 assert len(result.data["allUsers"]) == 9
ada = user_model.objects.get(username="demo-ada") ada = user_model.objects.get(username="demo-ada")
detail_result = gql_client.query( detail_result = gql_client.query(
@@ -63,3 +68,26 @@ def test_demo_data_is_available_when_an_admin_has_no_profile():
assert not detail_result.errors assert not detail_result.errors
assert detail_result.data["user"]["profile"]["topMonths"] assert detail_result.data["user"]["profile"]["topMonths"]
assert isinstance(detail_result.data["user"]["profile"]["topMonths"][0]["sum"], float) assert isinstance(detail_result.data["user"]["profile"]["topMonths"][0]["sum"], float)
@pytest.mark.django_db
def test_seed_demo_data_random_size_adds_consistent_data():
call_command("seed_demo_data", size="small", seed=1, users=5, orders=30)
random_users = get_user_model().objects.exclude(username__startswith="demo-")
assert random_users.count() == 5
assert Order.objects.count() == 11 + 30
for user in random_users:
deposits = sum(t.amount for t in Transaction.objects.filter(user=user))
purchases = sum(o.order_sum for o in Order.objects.filter(customer=user))
assert user.profile.balance == deposits - purchases
assert user.profile.balance >= 0
assert all(product.inventory_amount >= 0 for product in Product.objects.all())
@pytest.mark.django_db
def test_seed_demo_data_random_size_can_run_twice():
call_command("seed_demo_data", size="small", seed=1, users=5, orders=5)
call_command("seed_demo_data", size="small", seed=1, users=5, orders=5)
assert get_user_model().objects.exclude(username__startswith="demo-").count() == 10