diff --git a/src/apps/dashboard/views.py b/src/apps/dashboard/views.py index 8a48b99..a909500 100644 --- a/src/apps/dashboard/views.py +++ b/src/apps/dashboard/views.py @@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required from django.db.models import Max, Q from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import redirect, render +from django.utils import timezone from django.views.decorators.csrf import ensure_csrf_cookie from apps.applets.models import Applet, UserApplet @@ -148,8 +149,14 @@ def wallet(request): "wallet": request.user.wallet, "pass_token": request.user.tokens.filter(token_type=Token.PASS).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, expires_at__gt=timezone.now() + ).order_by("expires_at")), "tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)), + "free_count": request.user.tokens.filter( + token_type=Token.FREE, expires_at__gt=timezone.now() + ).count(), + "tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(), "applets": applet_context(request.user, "wallet"), "page_class": "page-wallet", }) @@ -158,7 +165,18 @@ def wallet(request): @login_required(login_url="/") def kit_bag(request): tokens = list(request.user.tokens.all()) - return render(request, "core/_partials/_kit_bag_panel.html", {"tokens": tokens}) + free_tokens = sorted( + [t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()], + key=lambda t: t.expires_at, + ) + tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE] + return render(request, "core/_partials/_kit_bag_panel.html", { + "tokens": tokens, + "free_token": free_tokens[0] if free_tokens else None, + "free_count": len(free_tokens), + "tithe_token": tithe_tokens[0] if tithe_tokens else None, + "tithe_count": len(tithe_tokens), + }) @login_required(login_url="/") def toggle_wallet_applets(request): diff --git a/src/apps/epic/migrations/0005_gateslot_debited_token_fields.py b/src/apps/epic/migrations/0005_gateslot_debited_token_fields.py new file mode 100644 index 0000000..c98d026 --- /dev/null +++ b/src/apps/epic/migrations/0005_gateslot_debited_token_fields.py @@ -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), + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 7943d32..9768170 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -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 diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 7b73707..ac43315 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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): diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 868a71e..a985526 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -9,7 +9,7 @@ urlpatterns = [ path('room//gate/', views.gatekeeper, name='gatekeeper'), path('room//gate/drop_token', views.drop_token, name='drop_token'), path('room//gate/confirm_token', views.confirm_token, name='confirm_token'), - path('room//gate/reject_token', views.reject_token, name='reject_token'), + path('room//gate/return_token', views.return_token, name='return_token'), path('room//gate/invite', views.invite_gamer, name='invite_gamer'), path('room//gate/status', views.gate_status, name='gate_status'), path('room//delete', views.delete_room, name='delete_room'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 29399f3..d6aeff2 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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) diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 87bddba..3c46c43 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -1,6 +1,7 @@ from django.contrib.auth.decorators import login_required from django.db.models import Q from django.shortcuts import redirect, render +from django.utils import timezone from apps.applets.utils import applet_context from apps.applets.models import Applet, UserApplet @@ -19,12 +20,15 @@ GAMEBOARD_APPLET_ORDER = [ def gameboard(request): pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None 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, expires_at__gt=timezone.now() + ).order_by("expires_at")) return render( request, "apps/gameboard/gameboard.html", { "pass_token": pass_token, "coin": coin, "free_tokens": free_tokens, + "free_count": len(free_tokens), "applets": applet_context(request.user, "gameboard"), "page_class": "page-gameboard", "my_games": Room.objects.filter( @@ -49,7 +53,12 @@ def toggle_game_applets(request): "applets": applet_context(request.user, "gameboard"), "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None, "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, expires_at__gt=timezone.now() + ).order_by("expires_at")), + "free_count": request.user.tokens.filter( + token_type=Token.FREE, expires_at__gt=timezone.now() + ).count(), "my_games": Room.objects.filter( Q(owner=request.user) | Q(gate_slots__gamer=request.user) | diff --git a/src/apps/lyric/admin.py b/src/apps/lyric/admin.py index 65adafe..018bbff 100644 --- a/src/apps/lyric/admin.py +++ b/src/apps/lyric/admin.py @@ -1,3 +1,4 @@ +from django import forms from django.contrib import admin from .models import LoginToken, Token, User @@ -7,6 +8,23 @@ class UserAdmin(admin.ModelAdmin): list_display = ["email"] search_fields = ["email"] + +class TokenAdminForm(forms.ModelForm): + class Meta: + model = Token + fields = "__all__" + + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get("token_type") == Token.FREE and not cleaned_data.get("expires_at"): + raise forms.ValidationError("Free Tokens must have an expiration date.") + return cleaned_data + + +class TokenAdmin(admin.ModelAdmin): + form = TokenAdminForm + + admin.site.register(User, UserAdmin) admin.site.register(LoginToken) -admin.site.register(Token) +admin.site.register(Token, TokenAdmin) diff --git a/src/apps/lyric/tests/integrated/test_admin.py b/src/apps/lyric/tests/integrated/test_admin.py index e0778fd..54cc26c 100644 --- a/src/apps/lyric/tests/integrated/test_admin.py +++ b/src/apps/lyric/tests/integrated/test_admin.py @@ -1,6 +1,8 @@ from django.test import TestCase +from django.utils import timezone -from apps.lyric.models import User +from apps.lyric.admin import TokenAdminForm +from apps.lyric.models import Token, User class UserAdminTest(TestCase): @@ -23,3 +25,30 @@ class UserAdminTest(TestCase): response = self.client.get("/admin/lyric/user/?q=admin") self.assertContains(response, "admin@example.com") self.assertNotContains(response, "other@example.com") + + +class TokenAdminFormTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@example.com") + + def _form(self, token_type, expires_at=None): + return TokenAdminForm(data={ + "user": self.user.pk, + "token_type": token_type, + "expires_at": expires_at or "", + }) + + def test_free_token_without_expires_at_is_invalid(self): + form = self._form(Token.FREE) + self.assertFalse(form.is_valid()) + self.assertIn("Free Tokens must have an expiration date", str(form.errors)) + + def test_free_token_with_expires_at_is_valid(self): + form = self._form(Token.FREE, expires_at=timezone.now()) + self.assertTrue(form.is_valid()) + + def test_other_token_types_do_not_require_expires_at(self): + for token_type in (Token.COIN, Token.TITHE, Token.PASS): + with self.subTest(token_type=token_type): + form = self._form(token_type) + self.assertTrue(form.is_valid()) diff --git a/src/functional_tests/base.py b/src/functional_tests/base.py index ed19d3d..2133279 100644 --- a/src/functional_tests/base.py +++ b/src/functional_tests/base.py @@ -59,7 +59,10 @@ class FunctionalTest(StaticLiveServerTestCase): super().tearDown() def _test_has_failed(self): - return self._outcome.result.failures or self._outcome.result.errors + return any( + failure[0] == self + for failure in self._outcome.result.failures + self._outcome.result.errors + ) def take_screenshot(self): path = SCREEN_DUMP_LOCATION / self._get_filename("png") diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 67b388b..a6cfc77 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -314,15 +314,15 @@ class CoinSlotTest(FunctionalTest): slot.refresh_from_db() self.assertEqual(slot.status, GateSlot.FILLED) - def test_gamer_can_reject_pending_token(self): - # Drop then reject via Push to Reject → slot remains empty + def test_gamer_can_return_pending_token(self): + # Drop then return via Push to Return → slot remains empty self.browser.get(self.gate_url) self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails") ).click() - # Push to Reject appears in coin slot + # Push to Return appears in coin slot self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-reject-btn") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-return-btn") ).click() # Slot 1 still empty; coin slot active again self.wait_for( @@ -535,6 +535,7 @@ class GameKitInsertTest(FunctionalTest): self.assertEqual(self.browser.current_url, self.gate_url) def test_free_token_insert_via_kit_consumed_on_confirm(self): + self.gamer.tokens.filter(token_type=Token.FREE).delete() token = Token.objects.create( user=self.gamer, token_type=Token.FREE, diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index e500bfb..34cb9d7 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -32,6 +32,11 @@ $gate-line: 2px; gap: 0.5rem; } +// Scroll-lock when gate is open. Uses html (not body) to avoid CSS overflow +// propagation quirk on Linux headless Firefox where body overflow:hidden can +// disrupt pointer events on position:fixed descendants. +// NOTE: may be superfluous — root cause of CI kit-btn failures turned out to be +// game-kit.js missing from git (was in gitignored STATIC_ROOT only). html:has(.gate-overlay) { overflow: hidden; } @@ -68,6 +73,9 @@ body:has(.gate-overlay) { overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; + // Prevents backdrop from intercepting clicks on position:fixed elements + // (e.g. #id_kit_btn) in Linux headless Firefox. + // NOTE: may be superfluous — see html:has comment above. pointer-events: none; } @@ -152,7 +160,7 @@ body:has(.gate-overlay) { 0 0 0.6rem rgba(var(--terUser), 0.5), 0 0 1.4rem rgba(var(--terUser), 0.2), ; - .token-reject-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); } + .token-return-btn { text-shadow: 0 0 0.5rem rgba(var(--terUser), 0.8); } &:hover { border-color: rgba(var(--terUser), 1); @@ -195,7 +203,7 @@ body:has(.gate-overlay) { } } - .token-reject-btn { + .token-return-btn { position: absolute; inset: 0; background: transparent; @@ -232,7 +240,7 @@ body:has(.gate-overlay) { line-height: 1.3; } - .token-reject-label { + .token-return-label { font-size: 0.55em; text-transform: uppercase; letter-spacing: 0.06em; diff --git a/src/templates/apps/gameboard/_partials/_applet-game-kit.html b/src/templates/apps/gameboard/_partials/_applet-game-kit.html index d804afe..2d86d61 100644 --- a/src/templates/apps/gameboard/_partials/_applet-game-kit.html +++ b/src/templates/apps/gameboard/_partials/_applet-game-kit.html @@ -32,21 +32,18 @@ {% endif %} - {% for token in free_tokens %} -
+ {% if free_tokens %} + {% with free_tokens.0 as token %} +
-

{{ token.tooltip_name }}

-

- {{ token.tooltip_description }} -

- {% if token.tooltip_shoptalk %} - {{ token.tooltip_shoptalk }} - {% endif %} +

{{ token.tooltip_name }}{% if free_count > 1 %} (×{{ free_count }}){% endif %}

+

{{ token.tooltip_description }}

{{ token.tooltip_expiry }}

- {% endfor %} + {% endwith %} + {% endif %}
diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 4cd3f66..0a9d591 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -35,12 +35,12 @@
1
INSERT TOKEN TO PLAY - PUSH TO REJECT + PUSH TO RETURN
{% if user_can_reject %} -
+ {% csrf_token %} - +
{% endif %} diff --git a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html index c377c4d..7881f5d 100644 --- a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html +++ b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html @@ -26,19 +26,18 @@ {% endif %} - {% for token in free_tokens %} -
+ {% if free_tokens %} + {% with free_tokens.0 as token %} +
-

{{ token.tooltip_name }}

+

{{ token.tooltip_name }}{% if free_count > 1 %} (×{{ free_count }}){% endif %}

{{ token.tooltip_description }}

- {% if token.tooltip_shoptalk %} - {{ token.tooltip_shoptalk }} - {% endif %}

{{ token.tooltip_expiry }}

- {% empty %} + {% endwith %} + {% else %}
@@ -47,20 +46,19 @@

find one around

- {% endfor %} - {% for token in tithe_tokens %} -
+ {% endif %} + {% if tithe_tokens %} + {% with tithe_tokens.0 as token %} +
-

{{ token.tooltip_name }}

+

{{ token.tooltip_name }}{% if tithe_count > 1 %} (×{{ tithe_count }}){% endif %}

{{ token.tooltip_description }}

- {% if token.tooltip_shoptalk %} - {{ token.tooltip_shoptalk }} - {% endif %}

{{ token.tooltip_expiry }}

- {% empty %} + {% endwith %} + {% else %}
@@ -69,6 +67,6 @@

purchase one above

- {% endfor %} + {% endif %}
diff --git a/src/templates/core/_partials/_kit_bag_panel.html b/src/templates/core/_partials/_kit_bag_panel.html index 4562427..b9faa43 100644 --- a/src/templates/core/_partials/_kit_bag_panel.html +++ b/src/templates/core/_partials/_kit_bag_panel.html @@ -24,23 +24,32 @@
Tokens
- {% for token in tokens %} - {% if token.token_type == "Free" or token.token_type == "tithe" %} -
- {% if token.token_type == "Free" %} - - {% else %} - - {% endif %} - {{ token.tooltip_name }} -
- {% endif %} - {% endfor %} + {% if free_token %} +
+ + + {{ free_token.tooltip_name }}{% if free_count > 1 %} (×{{ free_count }}){% endif %} + +
+ {% endif %} + {% if tithe_token %} +
+ + + {{ tithe_token.tooltip_name }}{% if tithe_count > 1 %} (×{{ tithe_count }}){% endif %} + +
+ {% endif %}
{% else %}