new apps/dashboard/wallet.html for stripe payment integration and user's consumables; nav added to _footer.html & also dynamic copyright year with django now Y template; new apps.dash.tests ITs & UTs reflect new wallet functionality in .urls & .views

This commit is contained in:
Disco DeDisco
2026-03-08 15:14:41 -04:00
parent 571f659b19
commit 076d75effe
17 changed files with 362 additions and 42 deletions

View File

@@ -414,3 +414,28 @@ 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)

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,5 @@ 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'),
] ]

View File

@@ -6,7 +6,7 @@ from django.shortcuts import redirect, render
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 Token, User, Wallet
APPLET_ORDER = ["new-list", "my-lists", "username", "palette"] APPLET_ORDER = ["new-list", "my-lists", "username", "palette"]
@@ -144,3 +144,14 @@ 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="/")
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,
})

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, User
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
@@ -8,4 +8,4 @@ class UserAdmin(admin.ModelAdmin):
search_fields = ["email"] search_fields = ["email"]
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
admin.site.register(Token) admin.site.register(LoginToken)

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

@@ -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)
@@ -40,3 +44,48 @@ 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()
@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

@@ -0,0 +1,41 @@
{% extends "core/base.html" %}
{% 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>
</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>
{% 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>