diff --git a/src/apps/epic/migrations/0004_alter_room_gate_status.py b/src/apps/epic/migrations/0004_alter_room_gate_status.py new file mode 100644 index 0000000..c1e1143 --- /dev/null +++ b/src/apps/epic/migrations/0004_alter_room_gate_status.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-03-15 00:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0003_roominvite'), + ] + + operations = [ + migrations.AlterField( + model_name='room', + name='gate_status', + field=models.CharField(choices=[('GATHERING', 'GATHERING GAMERS'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20), + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index fde5edf..7943d32 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -93,13 +93,30 @@ def create_gate_slots(sender, instance, created, **kwargs): GateSlot.objects.create(room=instance, slot_number=i) +def select_token(user): + if user.is_staff: + pass_token = user.tokens.filter(token_type=Token.PASS).first() + if pass_token: + return pass_token + coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first() + if coin: + return coin + free = user.tokens.filter( + token_type=Token.FREE, + expires_at__gt=timezone.now(), + ).order_by("expires_at").first() + if free: + return free + return user.tokens.filter(token_type=Token.TITHE).first() + + def debit_token(user, slot, token): 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() - else: + elif token.token_type != Token.PASS: token.delete() slot.gamer = user slot.status = GateSlot.FILLED diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 1dd5a00..1fc2357 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -2,9 +2,10 @@ from datetime import timedelta from django.db.models import Q from django.test import TestCase from django.urls import reverse +from django.utils import timezone from apps.lyric.models import Token, User -from apps.epic.models import GateSlot, Room, RoomInvite, debit_token +from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token class RoomCreationTest(TestCase): @@ -77,6 +78,60 @@ class CoinTokenInUseTest(TestCase): self.assertIn(self.room.name, html) +class SelectTokenTest(TestCase): + def setUp(self): + self.user = User.objects.create(email="gamer@test.io") + self.other_room = Room.objects.create(name="Other Room", owner=self.user) + self.coin = Token.objects.get(user=self.user, token_type=Token.COIN) + + def test_returns_coin_when_available(self): + token = select_token(self.user) + self.assertEqual(token.token_type, Token.COIN) + + def test_returns_free_token_when_coin_in_use(self): + self.coin.current_room = self.other_room + self.coin.save() + token = select_token(self.user) + self.assertEqual(token.token_type, Token.FREE) + + def test_free_token_selection_is_fefo(self): + self.coin.current_room = self.other_room + self.coin.save() + Token.objects.filter(user=self.user, token_type=Token.FREE).delete() + soon = Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=2), + ) + Token.objects.create( + user=self.user, token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=6), + ) + token = select_token(self.user) + self.assertEqual(token.pk, soon.pk) + + def test_returns_tithe_when_coin_in_use_and_no_free_tokens(self): + self.coin.current_room = self.other_room + self.coin.save() + Token.objects.filter(user=self.user, token_type=Token.FREE).delete() + tithe = Token.objects.create(user=self.user, token_type=Token.TITHE) + token = select_token(self.user) + self.assertEqual(token.pk, tithe.pk) + + def test_returns_none_when_all_depleted(self): + self.coin.current_room = self.other_room + self.coin.save() + Token.objects.filter(user=self.user, token_type=Token.FREE).delete() + token = select_token(self.user) + self.assertIsNone(token) + + def test_returns_pass_for_staff(self): + self.user.is_staff = True + self.user.save() + pass_token = Token.objects.create(user=self.user, token_type=Token.PASS) + token = select_token(self.user) + self.assertEqual(token.token_type, Token.PASS) + + class RoomInviteTest(TestCase): def setUp(self): self.founder = User.objects.create(email="founder@example.com") diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 827a0eb..7b73707 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -229,6 +229,81 @@ class RejectTokenViewTest(TestCase): ) +class DropTokenAvailabilityViewTest(TestCase): + def setUp(self): + self.gamer = User.objects.create(email="gamer@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + self.other_room = Room.objects.create(name="Other Room", owner=owner) + self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN) + + def test_drop_reserves_slot_when_tokens_available(self): + self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id})) + slot = self.room.gate_slots.get(slot_number=1) + self.assertEqual(slot.status, GateSlot.RESERVED) + # token not debited yet — that happens at confirm + self.coin.refresh_from_db() + self.assertIsNone(self.coin.current_room) + + def test_drop_returns_402_when_all_tokens_depleted(self): + self.coin.current_room = self.other_room + self.coin.save() + Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete() + response = self.client.post( + reverse("epic:drop_token", kwargs={"room_id": self.room.id}) + ) + self.assertEqual(response.status_code, 402) + + +class ConfirmTokenPriorityViewTest(TestCase): + def setUp(self): + self.gamer = User.objects.create(email="gamer@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + self.other_room = Room.objects.create(name="Other Room", owner=owner) + self.slot = self.room.gate_slots.get(slot_number=1) + self.slot.gamer = self.gamer + self.slot.status = GateSlot.RESERVED + self.slot.reserved_at = timezone.now() + self.slot.save() + self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN) + + def test_confirm_leases_coin_to_room(self): + self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id})) + self.coin.refresh_from_db() + self.assertEqual(self.coin.current_room, self.room) + self.assertTrue(Token.objects.filter(pk=self.coin.pk).exists()) + + def test_confirm_uses_free_token_when_coin_in_use(self): + self.coin.current_room = self.other_room + self.coin.save() + self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id})) + self.assertEqual( + Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0 + ) + self.coin.refresh_from_db() + self.assertEqual(self.coin.current_room, self.other_room) + + def test_confirm_uses_tithe_when_free_tokens_exhausted(self): + self.coin.current_room = self.other_room + self.coin.save() + Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete() + tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE) + self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id})) + self.assertFalse(Token.objects.filter(pk=tithe.pk).exists()) + + def test_pass_not_consumed_and_coin_not_leased(self): + self.gamer.is_staff = True + self.gamer.save() + pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS) + self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id})) + self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists()) + self.coin.refresh_from_db() + self.assertIsNone(self.coin.current_room) + + class RoomActionsViewTest(TestCase): def setUp(self): self.owner = User.objects.create(email="owner@test.io") diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 7d7c226..419e215 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -5,8 +5,7 @@ from django.http import HttpResponse from django.shortcuts import redirect, render from django.utils import timezone -from apps.epic.models import GateSlot, Room, RoomInvite, debit_token -from apps.lyric.models import Token +from apps.epic.models import GateSlot, Room, RoomInvite, debit_token, select_token RESERVE_TIMEOUT = timedelta(seconds=60) @@ -29,12 +28,14 @@ def _gate_context(room, user): if user.is_authenticated: user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first() user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first() - can_drop = ( + eligible = ( user.is_authenticated and pending_slot is None and user_reserved_slot is None and user_filled_slot is None ) + token_depleted = eligible and select_token(user) is None + can_drop = eligible and not token_depleted is_last_slot = ( user_reserved_slot is not None and slots.filter(status=GateSlot.EMPTY).count() == 0 @@ -46,6 +47,7 @@ def _gate_context(room, user): "user_reserved_slot": user_reserved_slot, "user_filled_slot": user_filled_slot, "can_drop": can_drop, + "token_depleted": token_depleted, "is_last_slot": is_last_slot, "user_can_reject": user_can_reject, } @@ -76,6 +78,8 @@ def drop_token(request, room_id): return redirect("epic:gatekeeper", room_id=room_id) if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists(): return redirect("epic:gatekeeper", room_id=room_id) + if select_token(request.user) is None: + return HttpResponse(status=402) slot = room.gate_slots.filter( status=GateSlot.EMPTY ).order_by("slot_number").first() @@ -95,11 +99,7 @@ def confirm_token(request, room_id): gamer=request.user, status=GateSlot.RESERVED ).first() if slot: - 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() - ) + token = select_token(request.user) if token: debit_token(request.user, slot, token) return redirect("epic:gatekeeper", room_id=room_id) diff --git a/src/apps/lyric/migrations/0009_alter_token_token_type.py b/src/apps/lyric/migrations/0009_alter_token_token_type.py new file mode 100644 index 0000000..1433be3 --- /dev/null +++ b/src/apps/lyric/migrations/0009_alter_token_token_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-03-15 00:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('lyric', '0008_token_current_room_token_next_ready_at'), + ] + + operations = [ + migrations.AlterField( + model_name='token', + name='token_type', + field=models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass')], max_length=8), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 99eb8d8..a771ef6 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -71,10 +71,12 @@ class Token(models.Model): COIN = "coin" FREE = "Free" TITHE = "tithe" + PASS = "pass" TOKEN_TYPE_CHOICES = [ (COIN, "Coin-on-a-String"), (FREE, "Free Token"), (TITHE, "Tithe Token"), + (PASS, "Backstage Pass"), ] user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens") @@ -97,8 +99,8 @@ class Token(models.Model): return "" def tooltip_expiry(self): - if self.token_type == self.COIN: - if self.next_ready_at: + if self.token_type in (self.COIN, self.PASS): + if self.token_type == self.COIN and self.next_ready_at: return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}" return "no expiry" if self.expires_at: @@ -143,3 +145,5 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs): token_type=Token.FREE, expires_at=timezone.now() + timedelta(days=7), ) + if instance.is_staff: + Token.objects.create(user=instance, token_type=Token.PASS) diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index 7f7b953..98971bc 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -97,6 +97,30 @@ class TokenCreationTest(TestCase): self.assertLessEqual(delta.days, 7) self.assertGreater(delta.total_seconds(), 0) + def test_no_pass_token_for_regular_user(self): + self.assertFalse( + Token.objects.filter(user=self.user, token_type=Token.PASS).exists() + ) + +class SuperuserTokenCreationTest(TestCase): + def setUp(self): + self.user = User.objects.create_superuser( + email="admin@test.io", password="secret" + ) + + def test_pass_token_created_for_superuser(self): + self.assertTrue( + Token.objects.filter(user=self.user, token_type=Token.PASS).exists() + ) + + def test_superuser_also_gets_coin_and_free_token(self): + self.assertTrue( + Token.objects.filter(user=self.user, token_type=Token.COIN).exists() + ) + self.assertTrue( + Token.objects.filter(user=self.user, token_type=Token.FREE).exists() + ) + class WalletTooltipTest(TestCase): def setUp(self): diff --git a/src/apps/lyric/tests/unit/test_tokens.py b/src/apps/lyric/tests/unit/test_tokens.py index 9844d90..5d33bed 100644 --- a/src/apps/lyric/tests/unit/test_tokens.py +++ b/src/apps/lyric/tests/unit/test_tokens.py @@ -41,3 +41,17 @@ class FreeTokenTooltipTest(SimpleTestCase): def test_tooltip_contains_expiry_date(self): self.assertIn("2026-03-15", self.token.tooltip_text()) + +class PassTokenTooltipTest(SimpleTestCase): + def setUp(self): + self.token = Token() + self.token.token_type = Token.PASS + self.token.expires_at = None + self.token.next_ready_at = None + + def test_tooltip_contains_name(self): + self.assertIn("Backstage Pass", self.token.tooltip_text()) + + def test_tooltip_contains_no_expiry(self): + self.assertIn("no expiry", self.token.tooltip_text()) + diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 64d6421..bae50a6 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -1,13 +1,13 @@ import time +from datetime import timedelta from django.utils import timezone - from selenium.webdriver.common.by import By from .base import FunctionalTest from apps.applets.models import Applet -from apps.epic.models import Room, GateSlot -from apps.lyric.models import User +from apps.epic.models import Room, GateSlot, select_token +from apps.lyric.models import Token, User class GatekeeperTest(FunctionalTest): @@ -39,7 +39,7 @@ class GatekeeperTest(FunctionalTest): ) # 4. Page shows room name, GATHERING status body = self.browser.find_element(By.TAG_NAME, "body") - self.assertIn("Test Room", body.text) + self.assertIn("TEST ROOM", body.text) self.assertIn("GATHERING GAMERS", body.text) # 5. Six token slot circles are visible, all empty slots = self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot") @@ -82,7 +82,7 @@ class GatekeeperTest(FunctionalTest): 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, ".btn-confirm")), 0 + len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0 ) def test_room_appears_in_my_games_after_creation(self): @@ -308,7 +308,7 @@ class CoinSlotTest(FunctionalTest): lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") ) self.assertEqual( - len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 + len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0 ) slot = self.room.gate_slots.get(slot_number=1) slot.refresh_from_db() @@ -374,5 +374,130 @@ class CoinSlotTest(FunctionalTest): ) ) self.assertEqual( - len(self.browser.find_elements(By.CSS_SELECTOR, ".btn-confirm")), 0 + len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot .btn-confirm")), 0 ) + + +class TokenPriorityTest(FunctionalTest): + def setUp(self): + super().setUp() + 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"} + ) + self.create_pre_authenticated_session("gamer@test.io") + self.gamer = User.objects.get(email="gamer@test.io") + self.room = Room.objects.create(name="Token Room", owner=self.gamer) + self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/" + self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN) + + def test_coin_is_used_by_default(self): + # 1. COIN token created at signup, not yet leased to a room + self.assertEqual(self.coin.token_type, Token.COIN) + self.assertIsNone(self.coin.current_room) + # 2. Gamer drops token and confirms + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") + ) + # 3. Coin is now leased to this room, page not refreshed + self.assertEqual(self.browser.current_url, self.gate_url) + self.coin.refresh_from_db() + self.assertEqual(self.coin.current_room, self.room) + + def test_free_token_used_when_coin_in_use(self): + # 1. Coin already leased to another room + other_room = Room.objects.create(name="Other Room", owner=self.gamer) + self.coin.current_room = other_room + self.coin.save() + # 2. Gamer has one unexpired free token (signup gives one; delete it and add fresh) + Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete() + Token.objects.create( + user=self.gamer, + token_type=Token.FREE, + expires_at=timezone.now() + timedelta(days=7), + ) + # 3. Gamer drops token → Free Token consumed + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") + ) + self.assertEqual( + Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0 + ) + # 4. Coin untouched, still leased to other room + self.assertEqual(self.browser.current_url, self.gate_url) + self.coin.refresh_from_db() + self.assertEqual(self.coin.current_room, other_room) + + def test_tithe_token_used_when_free_tokens_exhausted(self): + # 1. Coin in use, no Free Tokens, one Tithe Token + other_room = Room.objects.create(name="Other Room", owner=self.gamer) + self.coin.current_room = other_room + self.coin.save() + Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete() + tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE) + # 2. Gamer drops token → tithe consumed + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") + ) + # Tithe row deleted, page hasn't refreshed + self.assertEqual(self.browser.current_url, self.gate_url) + self.assertFalse(Token.objects.filter(pk=tithe.pk).exists()) + + def test_slot_blocked_when_no_tokens_available(self): + # Coin in use, no Free Tokens, no Tithe Tokens → depleted state + other_room = Room.objects.create(name="Other Room", owner=self.gamer) + self.coin.current_room = other_room + self.coin.save() + Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete() + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-slot.depleted") + ) + self.assertEqual( + len(self.browser.find_elements(By.CSS_SELECTOR, "button.token-rails")), 0 + ) + + def test_staff_backstage_pass_bypasses_token_cost(self): + # 1. Staff user has a PASS token + self.gamer.is_staff = True + self.gamer.save() + pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS) + # 2. Drops token, confirms as normal + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "button.token-rails") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm") + ).click() + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") + ) + # 3. Pass not consumed, coin not leased; no reload + self.assertEqual(self.browser.current_url, self.gate_url) + self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists()) + self.coin.refresh_from_db() + self.assertIsNone(self.coin.current_room) diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index e19b36c..07ab2ab 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -30,14 +30,33 @@ $gate-line: 2px; display: flex; flex-direction: column; align-items: center; - gap: 2rem; padding: 2rem; + border: 0.1rem solid rgba(var(--terUser), 0.5); border-radius: 1rem; background-color: rgba(var(--priUser), 1); .gate-header { text-align: center; - h1 { margin: 0 0 0.5rem; } + + h1 { + font-size: 2rem; + color: rgba(var(--secUser), 0.6); + margin-bottom: 1rem; + text-align: justify; + text-align-last: justify; + text-justify: inter-character; + text-transform: uppercase; + text-shadow: + 1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left) + -0.125rem -0.125rem 0 rgba(0, 0, 0, 0.8) // shadow (down-right) + ; + + span { + color: rgba(var(--quaUser), 0.6); + } + margin: 0 0 0.5rem; + } + .gate-status-wrap { display: flex; justify-content: center; @@ -46,6 +65,7 @@ $gate-line: 2px; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.15em; + margin-bottom: 1rem; .status-dots { display: inline-flex; diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 0c6a65c..4cd3f66 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -17,7 +17,7 @@ -
+
{% if can_drop %}
{% csrf_token %} @@ -45,7 +45,7 @@ {% endif %}
-
+
{% for slot in slots %}
{% if request.user == room.owner %} - - {% csrf_token %} - - - +
+

Invite Friend

+
+ {% 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 29202f2..3bf34d3 100644 --- a/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html +++ b/src/templates/apps/wallet/_partials/_applet-wallet-tokens.html @@ -29,6 +29,15 @@

{{ token.tooltip_expiry }}

+ {% empty %} +
+ +
+

Free Token

+

0 owned

+

find one around

+
+
{% endfor %} {% for token in tithe_tokens %}