diff --git a/README.md b/README.md index 1476b16..06a6edf 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Vite prints the frontend URL when it starts (normally ; 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. ## Testing diff --git a/bbs/management/commands/seed_demo_data.py b/bbs/management/commands/seed_demo_data.py index 0ccbfd7..9e2ff87 100644 --- a/bbs/management/commands/seed_demo_data.py +++ b/bbs/management/commands/seed_demo_data.py @@ -1,31 +1,128 @@ import os +import random from datetime import datetime, time, timedelta from decimal import Decimal +from urllib.parse import quote 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.utils import timezone 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-ada", "Ada", "Nord", "ada@example.test", Decimal("300.0")), ("demo-bjørn", "Bjørn", "Berg", "bjorn@example.test", Decimal("250.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 = ( - ("Demo Cola", Decimal("25.0"), Decimal("30.0"), 30), - ("Demo Kaffe", Decimal("15.0"), Decimal("20.0"), 30), - ("Demo Sjokolade", Decimal("20.0"), Decimal("25.0"), 30), - ("Demo Energidrikk", Decimal("35.0"), Decimal("40.0"), 30), +# name, volume_liter, alcohol_percent, sale_price_int, sale_price_ext +BEERS = ( + ("Frydenlund Fatøl", "0.5", "4.5", "25", "35"), + ("Ringnes Pilsner", "0.5", "4.7", "25", "35"), + ("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): 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 users = self.seed_users() products = self.seed_products(users[0][0]) @@ -34,6 +131,22 @@ class Command(BaseCommand): self.update_balances(users) 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.")) if not os.environ.get("BBS_SEED_ADMIN_PASSWORD"): self.stdout.write("Create an admin login with: uv run manage.py createsuperuser") @@ -47,21 +160,23 @@ class Command(BaseCommand): username=username, 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)) return users @staticmethod def seed_products(stock_user): 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( name=name, defaults={ - "sale_price_int": sale_price_int, - "sale_price_ext": sale_price_ext, - "inventory_amount": stock, - "description": "Demo product for local development.", + "sale_price_int": Decimal(sale_price_int), + "sale_price_ext": Decimal(sale_price_ext), + "volume_liter": Decimal(volume), + "alcohol_percent": Decimal(alcohol), + "inventory_amount": DEMO_STOCK, + "description": DESCRIPTIONS[index % len(DESCRIPTIONS)], }, ) products[name] = product @@ -70,7 +185,7 @@ class Command(BaseCommand): user=stock_user, product=product, 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 @@ -87,14 +202,7 @@ class Command(BaseCommand): deposit.save(update_fields=["created"]) def seed_orders(self, users, products): - 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: + for user_index, days_ago, lines in DEMO_ORDERS: user = users[user_index][0] created_at = self.timestamp(days_ago=days_ago) total = sum( @@ -149,3 +257,132 @@ class Command(BaseCommand): def timestamp(days_ago: int) -> datetime: date = timezone.localdate() - timedelta(days=days_ago) 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"]) diff --git a/bbs/tests/test_seed_demo_data.py b/bbs/tests/test_seed_demo_data.py index 763932b..f00ade4 100644 --- a/bbs/tests/test_seed_demo_data.py +++ b/bbs/tests/test_seed_demo_data.py @@ -4,7 +4,10 @@ from django.core.management import call_command from django.test import Client 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 @@ -12,12 +15,14 @@ def test_seed_demo_data_is_repeatable(): call_command("seed_demo_data") call_command("seed_demo_data") - assert get_user_model().objects.filter(username__startswith="demo-").count() == 3 - assert Product.objects.filter(name__startswith="Demo ").count() == 4 - assert Order.objects.count() == 5 - assert Transaction.objects.count() == 3 - assert InventoryTransaction.objects.count() == 4 - assert sum(product.inventory_amount for product in Product.objects.filter(name__startswith="Demo ")) == 109 + assert get_user_model().objects.filter(username__startswith="demo-").count() == 9 + assert Product.objects.filter(name__in=DEMO_PRODUCT_NAMES).count() == 6 + assert Order.objects.count() == 11 + assert Transaction.objects.count() == 9 + assert InventoryTransaction.objects.count() == 6 + 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 @@ -38,8 +43,8 @@ def test_demo_data_is_available_when_an_admin_has_no_profile(): assert not result.errors assert result.data - assert len(result.data["allProducts"]) == 4 - assert len(result.data["allUsers"]) == 3 + assert len(result.data["allProducts"]) == 6 + assert len(result.data["allUsers"]) == 9 ada = user_model.objects.get(username="demo-ada") 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 detail_result.data["user"]["profile"]["topMonths"] 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