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:
19
src/apps/epic/migrations/0002_alter_room_renewal_period.py
Normal file
19
src/apps/epic/migrations/0002_alter_room_renewal_period.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -1,9 +1,13 @@
|
||||
import uuid
|
||||
|
||||
from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.lyric.models import Token
|
||||
|
||||
|
||||
class Room(models.Model):
|
||||
@@ -32,7 +36,7 @@ class Room(models.Model):
|
||||
)
|
||||
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
|
||||
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)
|
||||
board_state = models.JSONField(default=dict)
|
||||
seed_count = models.IntegerField(default=12)
|
||||
@@ -67,3 +71,16 @@ def create_gate_slots(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
for i in range(1, 7):
|
||||
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()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import timedelta
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.lyric.models import User
|
||||
from apps.epic.models import Room, GateSlot
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import Room, GateSlot, debit_token
|
||||
|
||||
|
||||
class RoomCreationTest(TestCase):
|
||||
@@ -9,3 +11,54 @@ class RoomCreationTest(TestCase):
|
||||
owner = User.objects.create(email="founder@example.com")
|
||||
room = Room.objects.create(name="Test Room", owner=owner)
|
||||
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)
|
||||
|
||||
@@ -29,3 +29,24 @@ class RoomCreationViewTest(TestCase):
|
||||
reverse("epic:create_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"])
|
||||
|
||||
@@ -7,5 +7,6 @@ app_name = 'epic'
|
||||
urlpatterns = [
|
||||
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/<int:slot_number>/drop_token', views.drop_token, name='drop_token'),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
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
|
||||
@@ -20,3 +21,17 @@ def gatekeeper(request, room_id):
|
||||
'room': room,
|
||||
'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)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.applets.utils import applet_context
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import Token
|
||||
|
||||
|
||||
@@ -23,6 +25,9 @@ def gameboard(request):
|
||||
"free_tokens": free_tokens,
|
||||
"applets": applet_context(request.user, "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"),
|
||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||
"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")
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -5,6 +5,7 @@ from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
@@ -79,6 +80,11 @@ class Token(models.Model):
|
||||
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)
|
||||
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):
|
||||
return self.get_token_type_display()
|
||||
@@ -92,11 +98,19 @@ class Token(models.Model):
|
||||
|
||||
def tooltip_expiry(self):
|
||||
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"
|
||||
if self.expires_at:
|
||||
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
|
||||
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):
|
||||
if self.token_type == self.COIN:
|
||||
return "\u2026and another after that, and another after that\u2026"
|
||||
|
||||
@@ -10,6 +10,9 @@ class GatekeeperTest(FunctionalTest):
|
||||
Applet.objects.get_or_create(
|
||||
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):
|
||||
# 1. Log in, navigate to gameboard
|
||||
@@ -40,3 +43,50 @@ class GatekeeperTest(FunctionalTest):
|
||||
slot_1.find_element(By.CSS_SELECTOR, ".drop-token-btn")
|
||||
for slot in slots[1:]:
|
||||
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)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
>
|
||||
<h2>My Games</h2>
|
||||
<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>
|
||||
</section>
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="gate-slots">
|
||||
{% for slot in slots %}
|
||||
<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 }}"
|
||||
>
|
||||
<span class="slot-number">{{ slot.slot_number }}</span>
|
||||
@@ -17,8 +17,11 @@
|
||||
{% else %}
|
||||
<span class="slot-gamer">empty</span>
|
||||
{% endif %}
|
||||
{% if slot.slot_number == 1 and request.user == room.owner %}
|
||||
<button class="drop-token-btn">Drop Token</button>
|
||||
{% if slot.slot_number == 1 and request.user == room.owner and slot.status == 'EMPTY' %}
|
||||
<form method="POST" action="{% url "epic:drop_token" room.id slot.slot_number %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn drop-token-btn btn-primary btn-xl">Drop Token</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
Reference in New Issue
Block a user