Compare commits

..

4 Commits

42 changed files with 1039 additions and 73 deletions

View File

@@ -5,6 +5,8 @@ DJANGO_SUPERUSER_EMAIL={{ django_superuser_email }}
DJANGO_SUPERUSER_PASSWORD={{ django_superuser_password }} DJANGO_SUPERUSER_PASSWORD={{ django_superuser_password }}
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
MAILGUN_API_KEY={{ mailgun_api_key }} MAILGUN_API_KEY={{ mailgun_api_key }}
STRIPE_PUBLISHABLE_KEY={{ stripe_publishable_key }}
STRIPE_SECRET_KEY={{ stripe_secret_key }}
CELERY_BROKER_URL=redis://gamearray_redis:6379/0 CELERY_BROKER_URL=redis://gamearray_redis:6379/0
REDIS_URL=redis://gamearray_redis:6379/1 REDIS_URL=redis://gamearray_redis:6379/1

View File

@@ -1,28 +1,42 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
65383061626464353936363564313761663834646361326362613934363565623234636337313363 38383061343764656262613934313230656462366163363263653462333338333863326338343838
3933313962643261353830333463336166393030313936370a616234626135633432613366633363 3664646437643462346636623231633639396239333532340a363338313839353734326238643735
61633265363937326231623365646336333737306634646335376135633031643564666164336230 39343237396433336436366430626332343666666461613636656433363838613432393539386266
3435353764383936620a396165386538666433356166383661323037333861373632376432313332 3237336434346333350a663530623334633438616135376437666631313064333735653633396461
66666236373462363236663335623734633364653539323331396361613738636166323134386466 31306163343838336465626663373661343839653037333235313361633335646337353339616333
66656431663261633036333537373336643866623236643139656662333831366435373837656262 35343233346562346236636364316265313936646235373866636333353866623161663935626637
36333734376363373462643239623437623735373935633732343639313666663436616630363933 31633864366339653930626365373237326531366632626337636163333266656434323063333365
61396530336461393064323161666537646135383462383532363932326132363331633438313138 38373437383261613439306666373764633737623466626235356465636365646337306534326535
61623431326537313637626239653038353263313731303262653537316134383264616661623962 36633866663161613632613434666134343465383663633165663330376535653537333763376232
32333564366362383431336432303964663835363365636434303332613161363930333065336637 61653265303134656338393033303834663630653064666134633638393235346631346461633030
33343466343062306434663765613837343635386630326439303739616166396134393939626434 35343332393961363361613661633633613262663231366236396663636239326534373134623762
62336634303963653230626630636363343730623734626336363039623231633532653330646366 30653139333134616236666238616466633733656633326331386138363839653566333434346534
66613432633834393133386666623466326131386633303264333766306135623337353433306632 63326539333461383265316332336333656365386531393630663537363365643061363263313738
66323733373232383862646661313966366465333463366361366337656537623562613964666631 37633564363533633762393736636333306433306534393539636231656162343562383232663932
65373566316432383134666434393338626138363632633766636561383263333636623530326664 62646339363266303564383438636636373661656465666663613863396639633732636635326166
63333265366132376437396431393535323931383637323833303839336635633735333565333530 39323738303338373466366236623665633538363134616565326665386564613735393638656630
65343263373630633063383931646163323237643436366566363932646566323539373136646433 31326431316163376132623064376634643737313864336464623431333834663361336133353838
37623638333834373537316164633166633738333363656431356163623332396631353864333333 32303635663261333732306137383133623134373363613837306637663566303634653863343766
33306666646532626636376239326438373737383432663539333736363866663938396136383035 33613936626362653466333537666462373633313038376565623363666631353162643634653730
32343534613862653538346430313338326435356230636535343464666262626663376635363835 30323532623261643136666237316561353038323265303930336364633731333533386563623133
65363862663461353464313533313333323863313539643533343431643130383663656161616131 31343965643336613933663431626435333235366639363334653065303434386165333739336632
33323639333564383830346163386362386238323936393832623961646565613961356263356365 61363030376664643638653365626365623936623864666663326534343863613962616431376666
65376431666130356564666236383764316136326366666661326538653133343165326431393564 39363837386639393235316339323932326466616330303165613032663637616232656162653335
36303065366263316232663230343137333231346538633036613066643365616331336135376461 61613266376262626234383135306238313366346330656333383465383861663962653638303362
35613265623134663633303238366363336137383436663836353863623533396236666433303738 34353833646461383839386238626661346263363131643438343461393739336132386466373665
38356361653633323065303035376664326238633066623731623436333332373363636634323433 32646238633161363064666335626639653335306236613866333934646366323564306133396131
393631303539373234386465663630316335 36343032623964316138386538333863363530396330646431373466646538663063326330663639
32323762356632336364333162336133336335623865323861663131626232633066643238333237
32343938353166353037316162653832663433343534626331633936633866356666653932656665
38396533356131326262633431653435306362633966383531356236396639376437396333616130
35666435393461316232323234653865346338326330623065373461323961393663306262313066
30313430353065616230356135333565333338373663643434353561363438656233383739663233
35653832353062396634613832353837333835636461616234343462626239636634613430373931
31656534343764643065643733326637343631356633653531313062633362663461313732633331
35626364393563373339636466346339383032383635303865306636623737343237333863353238
63306132396262656365323833323635633563653735366630313363386236613231346339643430
63396230353566633830383932666335373665356434656438336338633035653465613665613862
31663565653338376662323866613538363566306635333735646363363730646331306234353839
30346363393231623563646439623261643634663831313338393761343865303930373133633733
31656466303365316164396463373335396464643130643337656361333339653238333633373662
6539

View File

@@ -21,11 +21,13 @@ outcome==1.3.0.post0
packaging==25.0 packaging==25.0
pycparser==2.23 pycparser==2.23
PySocks==1.7.1 PySocks==1.7.1
python-dotenv
requests==2.32.5 requests==2.32.5
selenium==4.39.0 selenium==4.39.0
sniffio==1.3.1 sniffio==1.3.1
sortedcontainers==2.4.0 sortedcontainers==2.4.0
sqlparse==0.5.5 sqlparse==0.5.5
stripe
trio==0.32.0 trio==0.32.0
trio-websocket==0.12.2 trio-websocket==0.12.2
types-PyYAML==6.0.12.20250915 types-PyYAML==6.0.12.20250915

View File

@@ -13,4 +13,5 @@ lxml==6.0.2
psycopg2-binary psycopg2-binary
redis redis
requests==2.31.0 requests==2.31.0
stripe
whitenoise==6.11.0 whitenoise==6.11.0

View File

@@ -0,0 +1,18 @@
from django.db import migrations
def seed_wallet_applet(apps, schema_editor):
Applet = apps.get_model("dashboard", "Applet")
Applet.objects.get_or_create(
slug="wallet",
defaults={"name": "Wallet", "grid_cols": 12, "grid_rows": 3},
)
class Migration(migrations.Migration):
dependencies = [
("dashboard", "0006_rename_theme_switcher"),
]
operations = [
migrations.RunPython(seed_wallet_applet, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,43 @@
const initWallet = () => {
let stripe, elements;
const addBtn = document.getElementById('id_add_payment_method');
const saveBtn = document.getElementById('id_save_payment_method');
if (!addBtn) return;
const getCsrf = () => document.cookie.match(/csrftoken=([^;]+)/)[1];
addBtn.addEventListener('click', async () => {
const res = await fetch('/dashboard/wallet/setup-intent', {
method: 'POST',
headers: {'X-CSRFToken': getCsrf()},
});
const {client_secret, publishable_key} = await res.json();
stripe = Stripe(publishable_key);
elements = stripe.elements({clientSecret: client_secret});
elements.create('payment').mount('#id_stripe_payment_element');
saveBtn.hidden = false;
});
saveBtn.addEventListener('click', async () => {
const {error, setupIntent} = await stripe.confirmSetup({
elements,
redirect: 'if_required',
});
if (error) { console.error(error); return; }
const res = await fetch('/dashboard/wallet/save-payment-method', {
method: 'POST',
headers: {
'X-CSRFToken': getCsrf(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `payment_method_id=${setupIntent.payment_method}`,
});
const {last4, brand} = await res.json();
const pm = document.createElement('div');
pm.textContent = `${brand} ····${last4}`;
document.getElementById('id_payment_methods').appendChild(pm);
});
};
document.addEventListener('DOMContentLoaded', initWallet);

View File

@@ -0,0 +1,79 @@
from unittest import mock
from django.test import TestCase
from apps.lyric.models import PaymentMethod, User
class SetupIntentViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.client.force_login(self.user)
def test_setup_intent_requires_login(self):
self.client.logout()
response = self.client.post("/dashboard/wallet/setup-intent")
self.assertRedirects(
response, "/?next=/dashboard/wallet/setup-intent",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_returns_client_secret(self, mock_stripe):
mock_stripe.Customer.create.return_value = mock.Mock(id="cus_test123")
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
response = self.client.post("/dashboard/wallet/setup-intent")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["client_secret"], "seti_secret")
self.assertIn("publishable_key", response.json())
@mock.patch("apps.dashboard.views.stripe")
def test_reuses_existing_stripe_customer(self, mock_stripe):
self.user.stripe_customer_id = "cus_existing"
self.user.save()
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
self.client.post("/dashboard/wallet/setup-intent")
mock_stripe.Customer.create.assert_not_called()
mock_stripe.SetupIntent.create.assert_called_once_with(customer="cus_existing")
class SavePaymentMethodViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.user.stripe_customer_id = "cus_test123"
self.user.save()
self.client.force_login(self.user)
def test_save_payment_method_requires_login(self):
self.client.logout()
response = self.client.post(
"/dashboard/wallet/save-payment-method", {"payment_method_id": "pm_test"}
)
self.assertRedirects(
response, "/?next=/dashboard/wallet/save-payment-method",
fetch_redirect_response=False,
)
@mock.patch("apps.dashboard.views.stripe")
def test_creates_payment_method_record(self, mock_stripe):
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
card=mock.Mock(last4="4242", brand="visa")
)
self.client.post(
"/dashboard/wallet/save-payment-method",
{"payment_method_id": "pm_test123"},
)
pm = PaymentMethod.objects.get(user=self.user)
self.assertEqual(pm.last4, "4242")
self.assertEqual(pm.brand, "visa")
@mock.patch("apps.dashboard.views.stripe")
def test_returns_json_with_last4_and_brand(self, mock_stripe):
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
card=mock.Mock(last4="4242", brand="visa")
)
response = self.client.post(
"/dashboard/wallet/save-payment-method",
{"payment_method_id": "pm_test123"},
)
data = response.json()
self.assertEqual(data["last4"], "4242")
self.assertEqual(data["brand"], "visa")

View File

@@ -414,3 +414,43 @@ class AppletVisibilityContextTest(TestCase):
applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]} applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]}
self.assertFalse(applet_map["palette"]) self.assertFalse(applet_map["palette"])
self.assertTrue(applet_map["username"]) self.assertTrue(applet_map["username"])
class FooterNavTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
def test_footer_nav_present_on_dashboard(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
self.assertIsNotNone(nav)
def test_footer_nav_has_dashboard_link(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
links = [a.get("href") for a in nav.cssselect("a")]
self.assertIn("/", links)
def test_footer_nav_has_gameboard_link(self):
response = self.client.get("/")
parsed = lxml.html.fromstring(response.content)
[nav] = parsed.cssselect("#id_footer_nav")
links = [a.get("href") for a in nav.cssselect("a")]
self.assertIn("/gameboard/", links)
class WalletAppletTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
response = self.client.get("/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_applet_present_on_dash(self):
[_] = self.parsed.cssselect("#id_applet_wallet")
def test_wallet_applet_has_manage_link(self):
[link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
self.assertEqual(link.get("href"), "/dashboard/wallet/")

View File

@@ -0,0 +1,49 @@
import lxml.html
from django.test import TestCase
from apps.lyric.models import Token, User, Wallet
class WalletViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.client.force_login(self.user)
response = self.client.get("/dashboard/wallet/")
self.parsed = lxml.html.fromstring(response.content)
def test_wallet_page_requires_login(self):
self.client.logout()
response = self.client.get("/dashboard/wallet/")
self.assertRedirects(
response, "/?next=/dashboard/wallet/", fetch_redirect_response=False
)
def test_wallet_page_renders(self):
[el] = self.parsed.cssselect("#id_writs_balance")
self.assertEqual(el.text_content().strip(), "144")
def test_wallet_page_shows_esteem_balance(self):
[el] = self.parsed.cssselect("#id_esteem_balance")
self.assertEqual(el.text_content().strip(), "0")
def test_wallet_page_shows_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_coin_on_a_string")
def test_wallet_page_shows_free_token(self):
[_] = self.parsed.cssselect("#id_free_token_0")
def test_wallet_page_shows_payment_methods_section(self):
[_] = self.parsed.cssselect("#id_add_payment_method")
def test_wallet_page_shows_stripe_payment_element(self):
[_] = self.parsed.cssselect("#id_stripe_payment_element")
def test_wallet_page_shows_tithe_token_shop(self):
[_] = self.parsed.cssselect("#id_tithe_token_shop")
def test_tithe_token_shop_shows_bundle(self):
bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle")
self.assertGreater(len(bundles), 0)

View File

@@ -0,0 +1,9 @@
from datetime import date
from django.test import SimpleTestCase
from django.template.loader import render_to_string
class FooterTemplateTest(SimpleTestCase):
def test_footer_shows_current_year(self):
rendered = render_to_string("core/_partials/_footer.html")
self.assertIn(str(date.today().year), rendered)

View File

@@ -9,4 +9,7 @@ urlpatterns = [
path('set_profile', views.set_profile, name='set_profile'), path('set_profile', views.set_profile, name='set_profile'),
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'), path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
path('toggle_applets', views.toggle_applets, name="toggle_applets"), path('toggle_applets', views.toggle_applets, name="toggle_applets"),
path('wallet/', views.wallet, name='wallet'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
] ]

View File

@@ -1,15 +1,19 @@
import stripe
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import HttpResponse, HttpResponseForbidden from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.views.decorators.csrf import ensure_csrf_cookie
from apps.dashboard.forms import ExistingListItemForm, ItemForm from apps.dashboard.forms import ExistingListItemForm, ItemForm
from apps.dashboard.models import Applet, Item, List, UserApplet from apps.dashboard.models import Applet, Item, List, UserApplet
from apps.lyric.models import User from apps.lyric.models import PaymentMethod, Token, User, Wallet
APPLET_ORDER = ["new-list", "my-lists", "username", "palette"] APPLET_ORDER = ["wallet", "new-list", "my-lists", "username", "palette"]
UNLOCKED_PALETTES = frozenset(["palette-default"]) UNLOCKED_PALETTES = frozenset(["palette-default"])
PALETTES = [ PALETTES = [
{"name": "palette-default", "label": "Earthman", "locked": False}, {"name": "palette-default", "label": "Earthman", "locked": False},
@@ -144,3 +148,43 @@ def toggle_applets(request):
"recent_lists": _recent_lists(request.user), "recent_lists": _recent_lists(request.user),
}) })
return redirect("home") return redirect("home")
@login_required(login_url="/")
@ensure_csrf_cookie
def wallet(request):
wallet = request.user.wallet
coin = request.user.tokens.filter(token_type=Token.COIN).first()
free_tokens = list(request.user.tokens.filter(token_type=Token.FREE))
return render(request, "apps/dashboard/wallet.html", {
"wallet": wallet,
"coin": coin,
"free_tokens": free_tokens,
})
@login_required(login_url="/")
def setup_intent(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
user = request.user
if not user.stripe_customer_id:
customer = stripe.Customer.create(email=user.email)
user.stripe_customer_id = customer.id
user.save(update_fields=["stripe_customer_id"])
intent = stripe.SetupIntent.create(customer=user.stripe_customer_id)
return JsonResponse({
"client_secret": intent.client_secret,
"publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
})
@login_required(login_url="/")
def save_payment_method(request):
stripe.api_key = settings.STRIPE_SECRET_KEY
pm_id = request.POST.get("payment_method_id")
pm = stripe.PaymentMethod.retrieve(pm_id)
stripe.PaymentMethod.attach(pm_id, customer=request.user.stripe_customer_id)
PaymentMethod.objects.create(
user=request.user,
stripe_pm_id=pm_id,
last4=pm.card.last4,
brand=pm.card.brand,
)
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class GameboardConfig(AppConfig):
name = 'apps.gameboard'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

View File

@@ -0,0 +1,52 @@
import lxml.html
from django.test import TestCase
from apps.lyric.models import User
class GameboardViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
self.client.force_login(self.user)
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
def test_gameboard_requires_login(self):
self.client.logout()
response = self.client.get("/gameboard/")
self.assertRedirects(
response, "/?next=/gameboard/", fetch_redirect_response=False
)
def test_gameboard_renders(self):
response = self.client.get("/gameboard/")
self.assertEqual(response.status_code, 200)
def test_gameboard_shows_my_games_applet(self):
[_] = self.parsed.cssselect("#id_applet_my_games")
def test_gameboard_shows_new_game_applet(self):
[_] = self.parsed.cssselect("#id_applet_new_game")
def test_gameboard_shows_game_kit_btn(self):
[_] = self.parsed.cssselect("#id_game_kit_btn")
def test_gameboard_shows_game_gear(self):
[_] = self.parsed.cssselect("#id_game_gear")
def test_my_games_has_no_game_items_for_new_user(self):
game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
self.assertEqual(len(game_items), 0)
def test_game_kit_has_coin_on_a_string(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string")
def test_game_kit_has_free_token(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token_0")
def test_game_kit_has_card_deck_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck")
def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")

View File

@@ -0,0 +1,9 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.gameboard, name='gameboard'),
]

View File

@@ -0,0 +1,16 @@
from django.contrib.auth.decorators import login_required
from django.shortcuts import render
from apps.lyric.models import Token
@login_required(login_url="/")
def gameboard(request):
coin = request.user.tokens.filter(token_type=Token.COIN).first()
free_tokens = list(request.user.tokens.filter(token_type=Token.FREE))
return render(
request, "apps/gameboard/gameboard.html", {
"coin": coin,
"free_tokens": free_tokens,
}
)

View File

@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import Token, User from .models import LoginToken, Token, User
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
@@ -8,4 +8,5 @@ class UserAdmin(admin.ModelAdmin):
search_fields = ["email"] search_fields = ["email"]
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
admin.site.register(LoginToken)
admin.site.register(Token) admin.site.register(Token)

View File

@@ -1,5 +1,5 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from .models import Token, User from .models import LoginToken, User
class PasswordlessAuthenticationBackend: class PasswordlessAuthenticationBackend:
@@ -7,13 +7,13 @@ class PasswordlessAuthenticationBackend:
if uid is None: if uid is None:
return None return None
try: try:
token = Token.objects.get(uid=uid) login_token = LoginToken.objects.get(uid=uid)
except (Token.DoesNotExist, ValidationError): except (LoginToken.DoesNotExist, ValidationError):
return None return None
try: try:
return User.objects.get(email=token.email) return User.objects.get(email=login_token.email)
except User.DoesNotExist: except User.DoesNotExist:
return User.objects.create_user(email=token.email) return User.objects.create_user(email=login_token.email)
def get_user(self, user_id): def get_user(self, user_id):
try: try:

View File

@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('lyric', '0004_remove_user_theme_user_palette'),
]
operations = [
migrations.RenameModel(
old_name="Token",
new_name="LoginToken",
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0 on 2026-03-08 18:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0005_rename_logintoken'),
]
operations = [
migrations.CreateModel(
name='Token',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_type', models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token')], max_length=8)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Wallet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('writs', models.IntegerField(default=0)),
('esteem', models.IntegerField(default=0)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='wallet', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-03-08 20:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0006_token_wallet'),
]
operations = [
migrations.AddField(
model_name='user',
name='stripe_customer_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.CreateModel(
name='PaymentMethod',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('stripe_pm_id', models.CharField(max_length=255)),
('last4', models.CharField(max_length=4)),
('brand', models.CharField(max_length=32)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payment_methods', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -1,7 +1,11 @@
import uuid import uuid
from datetime import timedelta
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.db import models from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@@ -17,7 +21,7 @@ class UserManager(BaseUserManager):
user.save(using=self._db) user.save(using=self._db)
return user return user
class Token(models.Model): class LoginToken(models.Model):
email = models.EmailField() email = models.EmailField()
uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -27,6 +31,7 @@ class User(AbstractBaseUser):
username = models.CharField(max_length=35, unique=True, null=True, blank=True) username = models.CharField(max_length=35, unique=True, null=True, blank=True)
searchable = models.BooleanField(default=False) searchable = models.BooleanField(default=False)
palette = models.CharField(max_length=32, default="palette-default") palette = models.CharField(max_length=32, default="palette-default")
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False)
@@ -40,3 +45,57 @@ class User(AbstractBaseUser):
def has_module_perms(self, app_label): def has_module_perms(self, app_label):
return self.is_superuser return self.is_superuser
class Wallet(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="wallet")
writs = models.IntegerField(default=0)
esteem = models.IntegerField(default=0)
class Token(models.Model):
COIN = "coin"
FREE = "Free"
TITHE = "tithe"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES)
expires_at = models.DateTimeField(null=True, blank=True)
def tooltip_text(self):
if self.token_type == self.COIN:
return (
"Coin-on-a-String: Admit 1 Entry"
" (and another after that, and another after that\u2026)"
" \u2014 no expiry"
)
if self.token_type == self.FREE:
return (
f"Free Token: Admit 1 Entry"
f" \u2014 Expires {self.expires_at.strftime('%Y-%m-%d')}"
)
return self.get_token_type_display()
class PaymentMethod(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods")
stripe_pm_id = models.CharField(max_length=255)
last4 = models.CharField(max_length=4)
brand = models.CharField(max_length=32)
def __str__(self):
return f"{self.brand} ....{self.last4}"
@receiver(post_save, sender=User)
def create_wallet_and_tokens(sender, instance, created, **kwargs):
if not created:
return
Wallet.objects.create(user=instance, writs=144)
Token.objects.create(user=instance, token_type=Token.COIN)
Token.objects.create(
user=instance,
token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)

View File

@@ -3,39 +3,39 @@ from django.http import HttpRequest
from django.test import TestCase from django.test import TestCase
from apps.lyric.authentication import PasswordlessAuthenticationBackend from apps.lyric.authentication import PasswordlessAuthenticationBackend
from apps.lyric.models import Token, User from apps.lyric.models import LoginToken, User
class AuthenticateTest(TestCase): class AuthenticateTest(TestCase):
def test_returns_None_if_token_uuid_not_found(self): def test_returns_None_if_login_token_uuid_not_found(self):
uid = uuid.uuid4() uid = uuid.uuid4()
result = PasswordlessAuthenticationBackend().authenticate( result = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), uid HttpRequest(), uid
) )
self.assertIsNone(result) self.assertIsNone(result)
def test_returns_new_user_with_correct_email_if_token_exists(self): def test_returns_new_user_with_correct_email_if_login_token_exists(self):
email = "discoman@example.com" email = "discoman@example.com"
token = Token.objects.create(email=email) login_token = LoginToken.objects.create(email=email)
user = PasswordlessAuthenticationBackend().authenticate( user = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), token.uid HttpRequest(), login_token.uid
) )
new_user = User.objects.get(email=email) new_user = User.objects.get(email=email)
self.assertEqual(user, new_user) self.assertEqual(user, new_user)
def test_returns_existing_user_with_correct_email_if_token_exists(self): def test_returns_existing_user_with_correct_email_if_login_token_exists(self):
email = "discoman@example.com" email = "discoman@example.com"
existing_user = User.objects.create(email=email) existing_user = User.objects.create(email=email)
token = Token.objects.create(email=email) login_token = LoginToken.objects.create(email=email)
user = PasswordlessAuthenticationBackend().authenticate( user = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), token.uid HttpRequest(), login_token.uid
) )
self.assertEqual(user, existing_user) self.assertEqual(user, existing_user)
def test_can_retrieve_token_by_uuid(self): def test_can_retrieve_login_token_by_uuid(self):
token = Token.objects.create(email="a@b.cde") login_token = LoginToken.objects.create(email="a@b.cde")
fetched = Token.objects.get(pk=token.uid) fetched = LoginToken.objects.get(pk=login_token.uid)
self.assertEqual(fetched, token) self.assertEqual(fetched, login_token)
class GetUserTest(TestCase): class GetUserTest(TestCase):
def test_gets_user_by_uuid(self): def test_gets_user_by_uuid(self):

View File

@@ -1,8 +1,9 @@
import uuid import uuid
from django.contrib import auth from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from django.utils import timezone
from apps.lyric.models import Token, User from apps.lyric.models import LoginToken, Token, User, Wallet
class UserModelTest(TestCase): class UserModelTest(TestCase):
@@ -28,12 +29,12 @@ class UserModelTest(TestCase):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.assertFalse(user.searchable) self.assertFalse(user.searchable)
class TokenModelTest(TestCase): class LoginTokenModelTest(TestCase):
def test_links_user_with_autogen_uid(self): def test_links_user_with_autogen_uid(self):
token1 = Token.objects.create(email="a@b.cde") login_token1 = LoginToken.objects.create(email="a@b.cde")
token2 = Token.objects.create(email="v@w.xyz") login_token2 = LoginToken.objects.create(email="v@w.xyz")
self.assertNotEqual(token1.pk, token2.pk) self.assertNotEqual(login_token1.pk, login_token2.pk)
self.assertIsInstance(token1.pk, uuid.UUID) self.assertIsInstance(login_token1.pk, uuid.UUID)
class UserManagerTest(TestCase): class UserManagerTest(TestCase):
def test_create_superuser_sets_is_staff_and_is_superuser(self): def test_create_superuser_sets_is_staff_and_is_superuser(self):
@@ -55,3 +56,43 @@ class UserPaletteTest(TestCase):
def test_palette_field_defaults_to_palette_default(self): def test_palette_field_defaults_to_palette_default(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.assertEqual(user.palette, "palette-default") self.assertEqual(user.palette, "palette-default")
class WalletCreationTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
def test_wallet_is_created_for_new_user(self):
self.assertTrue(Wallet.objects.filter(user=self.user).exists())
def test_new_wallet_has_144_writs(self):
wallet = Wallet.objects.get(user = self.user)
self.assertEqual(wallet.writs, 144)
def test_new_wallet_has_0_esteem(self):
wallet = Wallet.objects.get(user=self.user)
self.assertEqual(wallet.esteem, 0)
class TokenCreationTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="capman@test.io")
def test_coin_on_a_string_created_for_new_user(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.COIN).exists()
)
def test_free_token_created_for_new_user(self):
self.assertTrue(
Token.objects.filter(user=self.user, token_type=Token.FREE).exists()
)
def test_coin_on_a_string_has_no_expiry(self):
coin = Token.objects.get(user=self.user, token_type=Token.COIN)
self.assertIsNone(coin.expires_at)
def test_free_token_has_expiry_within_7_days(self):
free = Token.objects.get(user=self.user, token_type=Token.FREE)
self.assertIsNotNone(free.expires_at)
delta = free.expires_at - timezone.now()
self.assertLessEqual(delta.days, 7)
self.assertGreater(delta.total_seconds(), 0)

View File

@@ -2,7 +2,7 @@ from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from unittest import mock from unittest import mock
from apps.lyric.models import Token from apps.lyric.models import LoginToken
@mock.patch("apps.lyric.views.send_login_email_task.delay") @mock.patch("apps.lyric.views.send_login_email_task.delay")
@@ -35,20 +35,20 @@ class SendLoginEmailViewTest(TestCase):
) )
self.assertEqual(message.tags, "success") self.assertEqual(message.tags, "success")
def test_creates_token_associated_with_email(self, mock_delay): def test_creates_login_token_associated_with_email(self, mock_delay):
self.client.post( self.client.post(
"/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() login_token = LoginToken.objects.get()
self.assertEqual(token.email, "discoman@example.com") self.assertEqual(login_token.email, "discoman@example.com")
def test_sends_link_to_login_using_token_uid(self, mock_delay): def test_sends_link_to_login_using_login_token_uid(self, mock_delay):
self.client.post( self.client.post(
"/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() login_token = LoginToken.objects.get()
expected_url = f"http://testserver/lyric/login?token={token.uid}" expected_url = f"http://testserver/lyric/login?token={login_token.uid}"
self.assertEqual(mock_delay.call_args.args[1], expected_url) self.assertEqual(mock_delay.call_args.args[1], expected_url)
class LoginViewTest(TestCase): class LoginViewTest(TestCase):
@@ -56,18 +56,18 @@ class LoginViewTest(TestCase):
response = self.client.get("/lyric/login?token=abc123") response = self.client.get("/lyric/login?token=abc123")
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
def test_logs_in_if_given_valid_token(self): def test_logs_in_if_given_valid_login_token(self):
anon_user = auth.get_user(self.client) anon_user = auth.get_user(self.client)
self.assertEqual(anon_user.is_authenticated, False) self.assertEqual(anon_user.is_authenticated, False)
token = Token.objects.create(email="discoman@example.com") login_token = LoginToken.objects.create(email="discoman@example.com")
self.client.get(f"/lyric/login?token={token.uid}", follow=True) self.client.get(f"/lyric/login?token={login_token.uid}", follow=True)
user = auth.get_user(self.client) user = auth.get_user(self.client)
self.assertEqual(user.is_authenticated, True) self.assertEqual(user.is_authenticated, True)
self.assertEqual(user.email, "discoman@example.com") self.assertEqual(user.email, "discoman@example.com")
def test_shows_login_error_if_token_invalid(self): def test_shows_login_error_if_login_token_invalid(self):
response = self.client.get("/lyric/login?token=invalid-token", follow=True) response = self.client.get("/lyric/login?token=invalid-token", follow=True)
user = auth.get_user(self.client) user = auth.get_user(self.client)
self.assertEqual(user.is_authenticated, False) self.assertEqual(user.is_authenticated, False)

View File

@@ -0,0 +1,43 @@
from django.test import SimpleTestCase
from unittest.mock import MagicMock
from apps.lyric.models import Token
class CoinTooltipTest(SimpleTestCase):
def setUp(self):
self.coin = Token ()
self.coin.token_type = Token.COIN
self.coin.expires_at = None
def test_tooltip_contains_name(self):
self.assertIn("Coin-on-a-String", self.coin.tooltip_text())
def test_tooltip_contains_entry(self):
self.assertIn("Admit 1 Entry", self.coin.tooltip_text())
def test_tooltip_contains_reuse_description(self):
self.assertIn("and another after that", self.coin.tooltip_text())
def test_tooltip_contains_no_expiry(self):
self.assertIn("no expiry", self.coin.tooltip_text())
class FreeTokenTooltipTest(SimpleTestCase):
def setUp(self):
self.token = Token()
self.token.token_type = Token.FREE
self.token.expires_at = MagicMock()
self.token.expires_at.strftime = lambda fmt: "2026-03-15"
def test_tooltip_contains_name(self):
self.assertIn("Free Token", self.token.tooltip_text())
def test_tooltip_contains_entry(self):
self.assertIn("Admit 1 Entry", self.token.tooltip_text())
def test_tooltip_contains_expires(self):
self.assertIn("Expires", self.token.tooltip_text())
def test_tooltip_contains_expiry_date(self):
self.assertIn("2026-03-15", self.token.tooltip_text())

View File

@@ -2,15 +2,15 @@ from django.contrib import auth, messages
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from .models import Token from .models import LoginToken
from .tasks import send_login_email_task from .tasks import send_login_email_task
def send_login_email(request): def send_login_email(request):
email = request.POST["email"] email = request.POST["email"]
token = Token.objects.create(email=email) login_token = LoginToken.objects.create(email=email)
url = request.build_absolute_uri( url = request.build_absolute_uri(
reverse("login") + "?token=" + str(token.uid), reverse("login") + "?token=" + str(login_token.uid),
) )
send_login_email_task.delay(email, url) send_login_email_task.delay(email, url)

View File

@@ -55,9 +55,12 @@ INSTALLED_APPS = [
'django.contrib.sessions', 'django.contrib.sessions',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
# Custom apps # Board apps
'apps.dashboard', 'apps.dashboard',
'apps.gameboard',
# Gamer apps
'apps.lyric', 'apps.lyric',
# Custom apps
'apps.api', 'apps.api',
'functional_tests', 'functional_tests',
# Depend apps # Depend apps
@@ -196,4 +199,8 @@ LOGGING = {
# Mailgun API settings (for HTTP API instead of SMTP) # Mailgun API settings (for HTTP API instead of SMTP)
MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY") MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY")
MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain
# Stripe payment settings
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "")

View File

@@ -11,6 +11,7 @@ urlpatterns = [
path('api/', include('apps.api.urls')), path('api/', include('apps.api.urls')),
path('dashboard/', include('apps.dashboard.urls')), path('dashboard/', include('apps.dashboard.urls')),
path('lyric/', include('apps.lyric.urls')), path('lyric/', include('apps.lyric.urls')),
path('gameboard/', include('apps.gameboard.urls')),
] ]
# Please remove the following urlpattern # Please remove the following urlpattern

View File

@@ -0,0 +1,93 @@
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from .base import FunctionalTest
class GameboardNavigationTest(FunctionalTest):
def test_footer_links_to_gameboard(self):
# 1. Log in, nav to dashboard
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url)
# 2. Assert footer nav present w. dash- & gameboard tabs
self.browser.find_element(By.ID, "id_footer_nav")
self.browser.find_element(By.CSS_SELECTOR, '#id_footer_nav a[href="/"]')
self.browser.find_element(By.CSS_SELECTOR, '#id_footer_nav a[href="/gameboard/"]')
# 3. Click the gameboard tab
self.browser.find_element(
By.CSS_SELECTOR, '#id_footer_nav a[href="/gameboard/"]'
).click()
# 4. Assert user landed on gameboard
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/gameboard/$")
)
def test_gameboard_shows_game_applets(self):
# 1. Log in, nav directly to gameboard
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
# 2. Assert My Games applet present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_games")
)
# 3. Assert no games listed yet for new user
my_games = self.browser.find_element(By.ID, "id_applet_my_games")
game_items = my_games.find_elements(By.CSS_SELECTOR, ".game-item")
self.assertEqual(len(game_items), 0)
# 4. Assert New Game applet present
self.browser.find_element(By.ID, "id_applet_new_game")
def test_game_kit_panel_shows_token_inventory(self):
# 1. Log in, nav to gameboard
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
# 2. Assert game kit & gear btns both present (stacked vertically)
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_game_kit_btn")
)
self.browser.find_element(By.ID, "id_game_gear")
# 3. Click game kit btn to open panel
self.browser.find_element(By.ID, "id_game_kit_btn").click()
# 4. Wait for game kit panel to become visible
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(By.ID, "id_game_kit").is_displayed()
)
)
# 5. Assert Coin-on-a-String present in kit
coin = self.browser.find_element(By.ID, "id_kit_coin_on_a_string")
# 6. Hover over it; assert tooltip shows name, entry text & reuse description
ActionChains(self.browser).move_to_element(coin).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_coin_on_a_string .token-tooltip"
).is_displayed()
)
)
coin_tooltip = self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_coin_on_a_string .token-tooltip"
).text
self.assertIn("Coin-on-a-String", coin_tooltip)
self.assertIn("Admit 1 Entry", coin_tooltip)
self.assertIn("and another after that", coin_tooltip)
# 7. Assert 1× Free Token (complimentary) present in kit
free_token = self.browser.find_element(By.ID, "id_kit_free_token_0")
# 8. Hover over it; assert tooltip shows name, entry text & expiry date
ActionChains(self.browser).move_to_element(free_token).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_free_token_0 .token-tooltip"
).is_displayed()
)
)
free_tooltip = self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_free_token_0 .token-tooltip"
).text
self.assertIn("Free Token", free_tooltip)
self.assertIn("Admit 1 Entry", free_tooltip)
self.assertIn("Expires", free_tooltip)
# 9. Assert card deck & dice set placeholder present
self.browser.find_element(By.ID, "id_kit_card_deck")
self.browser.find_element(By.ID, "id_kit_dice_set")

View File

@@ -0,0 +1,154 @@
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.dashboard.models import Applet
class WalletDisplayTest(FunctionalTest):
def setUp(self):
super().setUp()
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
def test_new_user_wallet_shows_starting_balances(self):
# 1. Log in as new user
self.create_pre_authenticated_session("capman@test.io")
# 2. Navigate to dashboard
self.browser.get(self.live_server_url)
# 3. Find wallet applet summary card
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_wallet")
)
# 4. Click thru to full wallet page via manage link
self.browser.find_element(
By.CSS_SELECTOR, "#id_applet_wallet a.wallet-manage-link"
).click()
# 5. Assert user landed on wallet page
self.wait_for(
lambda: self.assertRegex(self.browser.current_url, r"/dashboard/wallet/$")
)
# 6. Assert writs balance shows 144 (complimentary bundle on signup)
self.wait_for(
lambda: self.assertEqual(
"144",
self.browser.find_element(By.ID, "id_writs_balance").text,
)
)
# 7. Assert esteem balance shows 0
self.assertEqual(
"0",
self.browser.find_element(By.ID, "id_esteem_balance").text,
)
# 8. Assert Coin-on-a-String token element present
coin = self.browser.find_element(By.ID, "id_coin_on_a_string")
# 9. Hover over it; assert tooltip appears w. name, entry text, 'no expiry'
ActionChains(self.browser).move_to_element(coin).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(
By.CSS_SELECTOR, "#id_coin_on_a_string .token-tooltip"
).is_displayed()
)
)
coin_tooltip = self.browser.find_element(
By.CSS_SELECTOR, "#id_coin_on_a_string .token-tooltip"
).text
self.assertIn("Coin-on-a-String", coin_tooltip)
self.assertIn("Admit 1 Entry", coin_tooltip)
self.assertIn("no expiry", coin_tooltip)
# 10. Assert ×1 Free Token present (complimentary on signup)
free_token = self.browser.find_element(By.ID, "id_free_token_0")
# 11. Hover over it; assert tooltip shows name, entry text, expiry date
ActionChains(self.browser).move_to_element(free_token).perform()
self.wait_for(
lambda: self.assertTrue(
self.browser.find_element(
By.CSS_SELECTOR, "#id_free_token_0 .token-tooltip"
).is_displayed()
)
)
free_tooltip = self.browser.find_element(
By.CSS_SELECTOR, "#id_free_token_0 .token-tooltip"
).text
self.assertIn("Free Token", free_tooltip)
self.assertIn("Admit 1 Entry", free_tooltip)
self.assertIn("Expires", free_tooltip)
def test_wallet_payment_section_renders(self):
# 1. Log in, navigate directly to wallet page
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 2. Assert saved payment methods section present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_payment_methods")
)
# 3. Assert the add-payment-method button is visible
self.browser.find_element(By.ID, "id_add_payment_method")
# 4. Assert Stripe Payment Element mount point exists
self.browser.find_element(By.ID, "id_stripe_payment_element")
def test_user_can_save_a_payment_method(self):
# 1. Log in, navigate to wallet page
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 2. Click add-payment-method btn
self.browser.find_element(By.ID, "id_add_payment_method").click()
# 3. Wait for Stripe Payment Element iframe to appear inside mount point
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_stripe_payment_element iframe"
)
)
# 4. Switch into Stripe iframe to interact w. card fields
stripe_frame = self.browser.find_element(
By.CSS_SELECTOR, "#id_stripe_payment_element iframe"
)
self.browser.switch_to.frame(stripe_frame)
# 4a. Wait for card inputs to render inside iframe
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]'
)
)
# 5. Fill in Stripe test card details
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="1234 1234 1234 1234"]'
).send_keys("4242424242424242")
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="MM / YY"]'
).send_keys("12 / 26")
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="CVC"]'
).send_keys("424")
self.browser.find_element(
By.CSS_SELECTOR, 'input[placeholder="12345"]'
).send_keys("42424")
# 6. Return to main doc & submit form
self.browser.switch_to.default_content()
self.browser.find_element(By.ID, "id_save_payment_method").click()
# 7. Wait for saved card to appear in payment methods list
# Assert last 4 digits shown
self.wait_for(
lambda: self.assertIn(
"4242",
self.browser.find_element(By.ID, "id_payment_methods").text,
)
)
def test_user_can_purchase_tithe_token_bundle(self):
# 1. Log in, navigate to wallet page
self.create_pre_authenticated_session("capman@test.io")
self.browser.get(self.live_server_url + "/dashboard/wallet/")
# 2. Assert Tithe Token purchase section present
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tithe_token_shop")
)
# 3. Assert min. +1 bundle option is visible
bundle = self.browser.find_element(
By.CSS_SELECTOR, "#id_tithe_token_shop .token-bundle"
)
# 4. Assert ea. bundle shows token count & writ bonus placeholder
self.assertIn("Tithe Token", bundle.text)
self.assertIn("Writ", bundle.text)
# 5. (Placeholder) Purchase flow via Stripe not driven in this FT:
# Full charge assertion deferred until Stripe webhook handling implemented

View File

@@ -5,6 +5,12 @@ import sys
def main(): def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try: try:

View File

@@ -27,7 +27,15 @@
{% for entry in applets %} {% for entry in applets %}
{% if entry.visible %} {% if entry.visible %}
{% if entry.applet.slug == "new-list" %} {% if entry.applet.slug == "wallet" %}
<section
id="id_applet_wallet"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
>
<span>Writs: {{ user.wallet.writs }}</span>
<a href="{% url "wallet" %}" class="wallet-manage-link">Manage Wallet</a>
</section>
{% elif entry.applet.slug == "new-list" %}
<section <section
id="id_applet_new_list" id="id_applet_new_list"
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"

View File

@@ -0,0 +1,45 @@
{% extends "core/base.html" %}
{% load static %}
{% block content %}
<div class="wallet-page">
<h1>Wallet</h1>
<section class="wallet-balances">
<div>Writs: <span id="id_writs_balance">{{ wallet.writs }}</span></div>
<div>Esteem: <span id="id_esteem_balance">{{ wallet.esteem }}</span></div>
</section>
<section class="wallet-tokens">
{% if coin %}
<div id="id_coin_on_a_string" class="token">
<span class="token-tooltip">{{ coin.tooltip_text }}</span>
</div>
{% endif %}
{% for token in free_tokens %}
<div id="id_free_token_{{ forloop.counter0 }}" class="token">
<span class="token-tooltip">{{ token.tooltip_text }}</span>
</div>
{% endfor %}
</section>
<section id="id_payment_methods">
<h2>Payment Methods</h2>
<button id="id_add_payment_method">Add Payment Method</button>
<div id="id_stripe_payment_element"></div>
<button id="id_save_payment_method" hidden>Save Card</button>
</section>
<section id="id_tithe_token_shop">
<h2>Tithe Tokens</h2>
<div class="token-bundle">
<span>Tithe Token ×1</span>
<span>+ Writ bonus</span>
</div>
</section>
</div>
<script src="https://js.stripe.com/v3/"></script>
<script src="{% static "apps/scripts/wallet.js" %}"></script>
{% endblock content %}

View File

@@ -0,0 +1,35 @@
{% extends "core/base.html" %}
{% block content %}
<div class="gameboard-page">
<section id="id_applet_my_games">
<h2>My Games</h2>
</section>
<section id="id_applet_new_game">
<h2>New Game</h2>
</section>
<div id="id_game_gear"></div>
<button
id="id_game_kit_btn"
onclick="document.getElementById('id_game_kit').style.display='block'"
>
Game Kit
</button>
<div id="id_game_kit" style="display:none;">
{% if coin %}
<div id="id_kit_coin_on_a_string" class="token">
<span class="token-tooltip">{{ coin.tooltip_text }}</span>
</div>
{% endif %}
{% for token in free_tokens %}
<div id="id_kit_free_token_{{ forloop.counter0 }}" class="token">
<span class="token-tooltip">{{ token.tooltip_text }}</span>
</div>
{% endfor %}
<div id="id_kit_card_deck" class="kit-item">Card Deck</div>
<div id="id_kit_dice_set" class="kit-item">Dice Set</div>
</div>
</div>
{% endblock content %}

View File

@@ -1,5 +1,9 @@
<footer id="id_footer"> <footer id="id_footer">
<nav id="id_footer_nav">
<a href="/">Dashboard</a>
<a href="/gameboard/">Gameboard</a>
</nav>
<div class="footer-container"> <div class="footer-container">
<small>&copy;2026 Dis Co.</small> <small>&copy;{% now "Y" %} Dis Co.</small>
</div> </div>
</footer> </footer>