new migrations in apps.epic & apps.lyric apps; new Token fields of latter articulate upon Room model helper fns of former; new FTs, ITs & UTs capture new behavior accordingly; new template partial content in templates/apps/gameboard

This commit is contained in:
Disco DeDisco
2026-03-13 17:31:52 -04:00
parent 5773462b4c
commit 6a42b91420
12 changed files with 239 additions and 9 deletions

View File

@@ -0,0 +1,19 @@
# Generated by Django 6.0 on 2026-03-13 20:32
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='room',
name='renewal_period',
field=models.DurationField(blank=True, default=datetime.timedelta(days=7), null=True),
),
]

View File

@@ -1,9 +1,13 @@
import uuid import uuid
from datetime import timedelta
from django.db import models from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.conf import settings from django.conf import settings
from django.utils import timezone
from apps.lyric.models import Token
class Room(models.Model): class Room(models.Model):
@@ -32,7 +36,7 @@ class Room(models.Model):
) )
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE) visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING) gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
renewal_period = models.DurationField(null=True, blank=True) renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
board_state = models.JSONField(default=dict) board_state = models.JSONField(default=dict)
seed_count = models.IntegerField(default=12) seed_count = models.IntegerField(default=12)
@@ -67,3 +71,16 @@ def create_gate_slots(sender, instance, created, **kwargs):
if created: if created:
for i in range(1, 7): for i in range(1, 7):
GateSlot.objects.create(room=instance, slot_number=i) GateSlot.objects.create(room=instance, slot_number=i)
def debit_token(user, slot, token):
if token.token_type == Token.COIN:
token.current_room = slot.room
token.next_ready_at = timezone.now() + slot.room.renewal_period
token.save()
else:
token.delete()
slot.gamer = user
slot.status = GateSlot.FILLED
slot.filled_at = timezone.now()
slot.save()

View File

@@ -1,7 +1,9 @@
from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from apps.lyric.models import User from apps.lyric.models import Token, User
from apps.epic.models import Room, GateSlot from apps.epic.models import Room, GateSlot, debit_token
class RoomCreationTest(TestCase): class RoomCreationTest(TestCase):
@@ -9,3 +11,54 @@ class RoomCreationTest(TestCase):
owner = User.objects.create(email="founder@example.com") owner = User.objects.create(email="founder@example.com")
room = Room.objects.create(name="Test Room", owner=owner) room = Room.objects.create(name="Test Room", owner=owner)
self.assertEqual(GateSlot.objects.filter(room=room).count(), 6) self.assertEqual(GateSlot.objects.filter(room=room).count(), 6)
class DebitTokenTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(
name="Test Room",
owner=self.owner,
renewal_period=timedelta(days=7)
)
self.slot = self.room.gate_slots.get(slot_number=1)
def test_debit_free_token_consumes_token_and_fills_slot(self):
free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
debit_token(self.owner, self.slot, free_token)
self.assertFalse(Token.objects.filter(pk=free_token.pk).exists())
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner)
def test_debit_coin_does_not_consume_token(self):
coin_token = Token.objects.get(user=self.owner, token_type=Token.COIN)
debit_token(self.owner, self.slot, coin_token)
self.assertTrue(Token.objects.filter(pk=coin_token.pk).exists())
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
self.assertEqual(self.slot.gamer, self.owner)
class CoinTokenInUseTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(
name="Dragon's Den",
owner=self.owner,
renewal_period=timedelta(days=7),
)
self.slot = self.room.gate_slots.get(slot_number=1)
self.coin = Token.objects.get(user=self.owner, token_type=Token.COIN)
debit_token(self.owner, self.slot, self.coin)
self.coin.refresh_from_db()
def test_coin_tooltip_expiry_shows_next_ready_date(self):
expected_date = self.coin.next_ready_at.strftime("%Y-%m-%d")
self.assertIn(expected_date, self.coin.tooltip_expiry())
def test_coin_tooltip_room_html_contains_anchor(self):
room_url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
html = self.coin.tooltip_room_html()
self.assertIn(f'href="{room_url}"', html)
self.assertIn(self.room.name, html)

View File

@@ -29,3 +29,24 @@ class RoomCreationViewTest(TestCase):
reverse("epic:create_room"), reverse("epic:create_room"),
data={"name": "Test Room"}, data={"name": "Test Room"},
) )
class MyGamesContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@example.com")
self.client.force_login(self.user)
def test_gameboard_context_includes_owned_rooms(self):
room = Room.objects.create(name="Durango", owner=self.user)
response = self.client.get("/gameboard/")
self.assertIn(room, response.context["my_games"])
def test_gameboard_context_includes_rooms_with_filled_slot(self):
other = User.objects.create(email="friend@example.com")
room = Room.objects.create(name="Their Room", owner=other)
slot = room.gate_slots.get(slot_number=2)
slot.gamer = self.user
slot.status = "FILLED"
slot.save()
response = self.client.get("/gameboard/")
self.assertIn(room, response.context["my_games"])

View File

@@ -7,5 +7,6 @@ app_name = 'epic'
urlpatterns = [ urlpatterns = [
path('rooms/create_room', views.create_room, name='create_room'), path('rooms/create_room', views.create_room, name='create_room'),
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'), path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
path('room/<uuid:room_id>/gate/<int:slot_number>/drop_token', views.drop_token, name='drop_token'),
] ]

View File

@@ -1,7 +1,8 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from apps.epic.models import Room from apps.epic.models import Room, debit_token
from apps.lyric.models import Token
@login_required @login_required
@@ -20,3 +21,17 @@ def gatekeeper(request, room_id):
'room': room, 'room': room,
'slots': slots, 'slots': slots,
}) })
@login_required
def drop_token(request, room_id, slot_number):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot = room.gate_slots.get(slot_number=slot_number)
token = (
request.user.tokens.filter(token_type=Token.COIN).first()
or request.user.tokens.filter(token_type=Token.FREE).first()
or request.user.tokens.filter(token_type=Token.TITHE).first()
)
if token:
debit_token(request.user, slot, token)
return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -1,8 +1,10 @@
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from apps.applets.utils import applet_context from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.epic.models import Room
from apps.lyric.models import Token from apps.lyric.models import Token
@@ -23,6 +25,9 @@ def gameboard(request):
"free_tokens": free_tokens, "free_tokens": free_tokens,
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
"page_class": "page-gameboard", "page_class": "page-gameboard",
"my_games": Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
).distinct(),
} }
) )
@@ -40,5 +45,8 @@ def toggle_game_applets(request):
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
"my_games": Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
).distinct(),
}) })
return redirect("gameboard") return redirect("gameboard")

View File

@@ -0,0 +1,25 @@
# Generated by Django 6.0 on 2026-03-13 20:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0001_initial'),
('lyric', '0007_user_stripe_customer_id_paymentmethod'),
]
operations = [
migrations.AddField(
model_name='token',
name='current_room',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='coin_tokens', to='epic.room'),
),
migrations.AddField(
model_name='token',
name='next_ready_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -5,6 +5,7 @@ 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.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -79,6 +80,11 @@ class Token(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens") user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES) token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES)
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True)
current_room = models.ForeignKey(
"epic.Room", null=True, blank=True,
on_delete=models.SET_NULL, related_name="coin_tokens"
)
next_ready_at = models.DateTimeField(null=True, blank=True)
def tooltip_name(self): def tooltip_name(self):
return self.get_token_type_display() return self.get_token_type_display()
@@ -92,10 +98,18 @@ class Token(models.Model):
def tooltip_expiry(self): def tooltip_expiry(self):
if self.token_type == self.COIN: if self.token_type == self.COIN:
if self.next_ready_at:
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry" return "no expiry"
if self.expires_at: if self.expires_at:
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}" return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
return "" return ""
def tooltip_room_html(self):
if not self.current_room_id:
return ""
url = reverse("epic:gatekeeper", kwargs={"room_id": self.current_room_id})
return f'<a href="{url}">{self.current_room.name}</a>'
def tooltip_shoptalk(self): def tooltip_shoptalk(self):
if self.token_type == self.COIN: if self.token_type == self.COIN:

View File

@@ -10,6 +10,9 @@ class GatekeeperTest(FunctionalTest):
Applet.objects.get_or_create( Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"} slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
) )
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
def test_founder_creates_room_and_sees_gatekeeper(self): def test_founder_creates_room_and_sees_gatekeeper(self):
# 1. Log in, navigate to gameboard # 1. Log in, navigate to gameboard
@@ -40,3 +43,50 @@ class GatekeeperTest(FunctionalTest):
slot_1.find_element(By.CSS_SELECTOR, ".drop-token-btn") slot_1.find_element(By.CSS_SELECTOR, ".drop-token-btn")
for slot in slots[1:]: for slot in slots[1:]:
self.assertIn("empty", slot.get_attribute("class")) self.assertIn("empty", slot.get_attribute("class"))
def test_founder_drops_token_and_slot_fills(self):
# 1. Set up: log in, create room, arrive at gatekeeper
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_new_game_name")
)
self.browser.find_element(By.ID, "id_new_game_name").send_keys("Dragon's Den")
self.browser.find_element(By.ID, "id_create_game_btn").click()
self.wait_for(
lambda: self.assertIn("/gate/", self.browser.current_url)
)
# 2. Founder clicks Drop Token on slot 1
drop_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn")
)
drop_btn.click()
# 3. Slot 1 now filled; drop btn gone
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot")
self.assertIn("filled", slots[0].get_attribute("class"))
self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".drop-token-btn")), 0
)
def test_room_appears_in_my_games_after_creation(self):
# 1. Set up founder, game room, name
self.create_pre_authenticated_session("founder@test.io")
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(
lambda: self.browser.find_element(By.ID, "id_new_game_name")
)
self.browser.find_element(By.ID, "id_new_game_name").send_keys("Dragon's Den")
self.browser.find_element(By.ID, "id_create_game_btn").click()
self.wait_for(
lambda: self.assertIn("/gate/", self.browser.current_url)
)
# 2. Navigate back to gameboard
self.browser.get(self.live_server_url + "/gameboard/")
my_games = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_applet_my_games")
)
self.assertIn("Dragon's Den", my_games.text)

View File

@@ -4,6 +4,10 @@
> >
<h2>My Games</h2> <h2>My Games</h2>
<ul class="game-list"> <ul class="game-list">
<small>[feature forthcoming]</small> {% for room in my_games %}
<li><a href="{% url 'epic:gatekeeper' room.id %}">{{ room.name }}</a></li>
{% empty %}
<li><small>No games yet</small></li>
{% endfor %}
</ul> </ul>
</section> </section>

View File

@@ -7,7 +7,7 @@
<div class="gate-slots"> <div class="gate-slots">
{% for slot in slots %} {% for slot in slots %}
<div <div
class="gate-slot{% if slot.status == 'EMPTY' %} empty{% endif %}" class="gate-slot{% if slot.status == 'EMPTY' %} empty{% elif slot.status == 'FILLED' %} filled{% endif %}"
data-slot="{{ slot.slot_number }}" data-slot="{{ slot.slot_number }}"
> >
<span class="slot-number">{{ slot.slot_number }}</span> <span class="slot-number">{{ slot.slot_number }}</span>
@@ -17,9 +17,12 @@
{% else %} {% else %}
<span class="slot-gamer">empty</span> <span class="slot-gamer">empty</span>
{% endif %} {% endif %}
{% if slot.slot_number == 1 and request.user == room.owner %} {% if slot.slot_number == 1 and request.user == room.owner and slot.status == 'EMPTY' %}
<button class="drop-token-btn">Drop Token</button> <form method="POST" action="{% url "epic:drop_token" room.id slot.slot_number %}">
{% endif %} {% csrf_token %}
<button type="submit" class="btn drop-token-btn btn-primary btn-xl">Drop Token</button>
</form>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>