new apps.epic app migrations for token expiration & cooldown; reject token renamed to return token everywhere; new mapps.epic.models & .views for expiration & cooldown; new apps.dash.views to manage stacking of like Token types not just in the kit bag but in the Gameboard's Game Kit applet & in the Dashwallet's Tokens applet; Free Tokens now display correctly in kit bag; apps.lyric.admin now ensures superuser cannot grant Free Tokens without an expiration date; corresponding tests in .tests.integrated.test_admin.TokenAdminFormTest; screendumps occurring for every test, regardless of passfail status, after one fail fixed in FTs.base; FTs.test_gatekeeper.GameKitInsertTest.test_free_token_insert_via_kit_consumed_on_confirm, for test purposes only, ensures starting Free Token deleted before fresh one assigned w. full 7d expiration battery

This commit is contained in:
Disco DeDisco
2026-03-15 16:08:34 -04:00
parent 18ba242647
commit 2e24175ec8
16 changed files with 244 additions and 69 deletions

View File

@@ -0,0 +1,21 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0004_alter_room_gate_status'),
]
operations = [
migrations.AddField(
model_name='gateslot',
name='debited_token_type',
field=models.CharField(max_length=8, null=True, blank=True),
),
migrations.AddField(
model_name='gateslot',
name='debited_token_expires_at',
field=models.DateTimeField(null=True, blank=True),
),
]

View File

@@ -65,6 +65,8 @@ class GateSlot(models.Model):
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
reserved_at = models.DateTimeField(null=True, blank=True)
filled_at = models.DateTimeField(null=True, blank=True)
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
class RoomInvite(models.Model):
@@ -111,12 +113,14 @@ def select_token(user):
def debit_token(user, slot, token):
slot.debited_token_type = token.token_type
if token.token_type == Token.COIN:
token.current_room = slot.room
period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period
token.save()
elif token.token_type != Token.PASS:
slot.debited_token_expires_at = token.expires_at
token.delete()
slot.gamer = user
slot.status = GateSlot.FILLED

View File

@@ -1,3 +1,4 @@
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
@@ -189,7 +190,7 @@ class ConfirmTokenViewTest(TestCase):
self.assertEqual(self.slot.status, GateSlot.EMPTY)
class RejectTokenViewTest(TestCase):
class ReturnTokenViewTest(TestCase):
def setUp(self):
self.gamer = User.objects.create(email="gamer@test.io")
self.client.force_login(self.gamer)
@@ -201,33 +202,73 @@ class RejectTokenViewTest(TestCase):
self.slot.reserved_at = timezone.now()
self.slot.save()
def test_reject_clears_reserved_slot(self):
def test_return_clears_reserved_slot(self):
self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer)
self.assertIsNone(self.slot.reserved_at)
def test_reject_after_confirm_clears_filled_slot(self):
def test_return_after_confirm_clears_filled_slot(self):
self.slot.status = GateSlot.FILLED
self.slot.save()
self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer)
def test_reject_redirects_to_gatekeeper(self):
def test_return_redirects_to_gatekeeper(self):
response = self.client.post(
reverse("epic:reject_token", kwargs={"room_id": self.room.id})
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.assertRedirects(
response, reverse("epic:gatekeeper", args=[self.room.id])
)
def test_return_restores_coin_token(self):
coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
coin.current_room = self.room
coin.next_ready_at = timezone.now() + timedelta(days=7)
coin.save()
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.COIN
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
coin.refresh_from_db()
self.assertIsNone(coin.current_room)
self.assertIsNone(coin.next_ready_at)
def test_return_restores_free_token(self):
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
expires = timezone.now() + timedelta(days=3)
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.FREE
self.slot.debited_token_expires_at = expires
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
restored = Token.objects.filter(user=self.gamer, token_type=Token.FREE).first()
self.assertIsNotNone(restored)
self.assertEqual(restored.expires_at, expires)
def test_return_restores_tithe_token(self):
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.TITHE
self.slot.save()
self.client.post(
reverse("epic:return_token", kwargs={"room_id": self.room.id})
)
self.assertTrue(
Token.objects.filter(user=self.gamer, token_type=Token.TITHE).exists()
)
class DropTokenAvailabilityViewTest(TestCase):
def setUp(self):

View File

@@ -9,7 +9,7 @@ urlpatterns = [
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
path('room/<uuid:room_id>/gate/reject_token', views.reject_token, name='reject_token'),
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),

View File

@@ -6,6 +6,7 @@ from django.shortcuts import redirect, render
from django.utils import timezone
from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token
from apps.lyric.models import Token
RESERVE_TIMEOUT = timedelta(seconds=60)
@@ -117,7 +118,7 @@ def confirm_token(request, room_id):
@login_required
def reject_token(request, room_id):
def return_token(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
slot = room.gate_slots.filter(
@@ -125,10 +126,28 @@ def reject_token(request, room_id):
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
).first()
if slot:
if slot.status == GateSlot.FILLED:
if slot.debited_token_type == Token.COIN:
coin = request.user.tokens.filter(
token_type=Token.COIN, current_room=room
).first()
if coin:
coin.current_room = None
coin.next_ready_at = None
coin.save()
elif slot.debited_token_type in (Token.FREE, Token.TITHE):
Token.objects.create(
user=request.user,
token_type=slot.debited_token_type,
expires_at=slot.debited_token_expires_at,
)
request.session.pop("kit_token_id", None)
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.reserved_at = None
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
return redirect("epic:gatekeeper", room_id=room_id)