new migrations in apps.epic app; new models, urls, views handle the founder of a New Game inviting a friend via email to a game gatekeeper; ea. may drop coin in any of up to 6 avail. slots; FTs & ITs passing

This commit is contained in:
Disco DeDisco
2026-03-13 18:37:19 -04:00
parent 6a42b91420
commit e0d1f51bf1
8 changed files with 159 additions and 8 deletions

View File

@@ -0,0 +1,27 @@
# Generated by Django 6.0 on 2026-03-13 22:19
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0002_alter_room_renewal_period'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RoomInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('invitee_email', models.EmailField(max_length=254)),
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('DECLINED', 'Declined')], default='PENDING', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_invites', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='epic.room')),
],
),
]

View File

@@ -41,6 +41,7 @@ class Room(models.Model):
board_state = models.JSONField(default=dict) board_state = models.JSONField(default=dict)
seed_count = models.IntegerField(default=12) seed_count = models.IntegerField(default=12)
class GateSlot(models.Model): class GateSlot(models.Model):
EMPTY = "EMPTY" EMPTY = "EMPTY"
RESERVED = "RESERVED" RESERVED = "RESERVED"
@@ -66,6 +67,25 @@ class GateSlot(models.Model):
filled_at = models.DateTimeField(null=True, blank=True) filled_at = models.DateTimeField(null=True, blank=True)
class RoomInvite(models.Model):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
DECLINED = "DECLINED"
STATUS_CHOICES = [
(PENDING, "Pending"),
(ACCEPTED, "Accepted"),
(DECLINED, "Declined"),
]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="invites")
inviter = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="sent_invites"
)
invitee_email = models.EmailField()
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
created_at = models.DateTimeField(auto_now_add=True)
@receiver(post_save, sender=Room) @receiver(post_save, sender=Room)
def create_gate_slots(sender, instance, created, **kwargs): def create_gate_slots(sender, instance, created, **kwargs):
if created: if created:

View File

@@ -1,9 +1,10 @@
from datetime import timedelta from datetime import timedelta
from django.db.models import Q
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import Room, GateSlot, debit_token from apps.epic.models import GateSlot, Room, RoomInvite, debit_token
class RoomCreationTest(TestCase): class RoomCreationTest(TestCase):
@@ -62,3 +63,31 @@ class CoinTokenInUseTest(TestCase):
html = self.coin.tooltip_room_html() html = self.coin.tooltip_room_html()
self.assertIn(f'href="{room_url}"', html) self.assertIn(f'href="{room_url}"', html)
self.assertIn(self.room.name, html) self.assertIn(self.room.name, html)
class RoomInviteTest(TestCase):
def setUp(self):
self.founder = User.objects.create(email="founder@example.com")
self.room = Room.objects.create(name="Dragon's Den", owner=self.founder)
def test_founder_can_invite_by_email(self):
invite = RoomInvite.objects.create(
room=self.room,
inviter=self.founder,
invitee_email="friend@example.com",
)
self.assertEqual(invite.status, RoomInvite.PENDING)
def test_invited_room_appears_in_my_games_queryset(self):
friend = User.objects.create(email="friend@example.com")
RoomInvite.objects.create(
room=self.room,
inviter=self.founder,
invitee_email=friend.email,
)
rooms = Room.objects.filter(
Q(owner=friend) |
Q(gate_slots__gamer=friend) |
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
).distinct()
self.assertIn(self.room, rooms)

View File

@@ -8,5 +8,6 @@ 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'), path('room/<uuid:room_id>/gate/<int:slot_number>/drop_token', views.drop_token, name='drop_token'),
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
] ]

View File

@@ -1,7 +1,7 @@
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, debit_token from apps.epic.models import Room, RoomInvite, debit_token
from apps.lyric.models import Token from apps.lyric.models import Token
@@ -17,9 +17,14 @@ def create_room(request):
def gatekeeper(request, room_id): def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
slots = room.gate_slots.order_by("slot_number") slots = room.gate_slots.order_by("slot_number")
user_has_slot = (
request.user.is_authenticated
and room.gate_slots.filter(gamer=request.user).exists()
)
return render(request, "apps/gameboard/room.html", { return render(request, "apps/gameboard/room.html", {
'room': room, "room": room,
'slots': slots, "slots": slots,
"user_has_slot": user_has_slot,
}) })
@login_required @login_required
@@ -35,3 +40,17 @@ def drop_token(request, room_id, slot_number):
if token: if token:
debit_token(request.user, slot, token) debit_token(request.user, slot, token)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
@login_required
def invite_gamer(request, room_id):
if request.method == "POST":
room = Room.objects.get(id=room_id)
email = request.POST.get("invitee_email", "").strip()
if email:
RoomInvite.objects.get_or_create(
room=room,
inviter=request.user,
invitee_email=email,
defaults={"status": RoomInvite.PENDING}
)
return redirect("epic:gatekeeper", room_id=room_id)

View File

@@ -4,7 +4,7 @@ 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.epic.models import Room, RoomInvite
from apps.lyric.models import Token from apps.lyric.models import Token
@@ -26,7 +26,9 @@ def gameboard(request):
"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( "my_games": Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user) Q(owner=request.user) |
Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct(), ).distinct(),
} }
) )
@@ -46,7 +48,9 @@ def toggle_game_applets(request):
"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( "my_games": Room.objects.filter(
Q(owner=request.user) | Q(gate_slots__gamer=request.user) Q(owner=request.user) |
Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct(), ).distinct(),
}) })
return redirect("gameboard") return redirect("gameboard")

View File

@@ -90,3 +90,47 @@ class GatekeeperTest(FunctionalTest):
lambda: self.browser.find_element(By.ID, "id_applet_my_games") lambda: self.browser.find_element(By.ID, "id_applet_my_games")
) )
self.assertIn("Dragon's Den", my_games.text) self.assertIn("Dragon's Den", my_games.text)
def test_second_gamer_drops_token_into_open_slot(self):
# 1. Founder creates room, fills slot 1
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)
)
room_url = self.browser.current_url
self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn").click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled")
)
# 2. Founder invites friend via email (duplicate invite logic from My Notes applet)
invite_input = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_invite_email")
)
invite_input.send_keys("friend@test.io")
self.browser.find_element(By.ID, "id_invite_btn").click()
# 3. Friend logs in, sees invitation in My Games
self.create_pre_authenticated_session("friend@test.io")
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)
# 3. Friend follows link to gatekeeper
self.browser.find_element(By.LINK_TEXT, "Dragon's Den").click()
# 4. Friend sees drop btn on open slot
drop_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".drop-token-btn")
)
drop_btn.click()
# 5. Now two slots filled
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.CSS_SELECTOR, ".gate-slot.filled")), 2
)
)

View File

@@ -17,7 +17,7 @@
{% 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 and slot.status == 'EMPTY' %} {% if slot.status == 'EMPTY' and request.user.is_authenticated and not user_has_slot %}
<form method="POST" action="{% url "epic:drop_token" room.id slot.slot_number %}"> <form method="POST" action="{% url "epic:drop_token" room.id slot.slot_number %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn drop-token-btn btn-primary btn-xl">Drop Token</button> <button type="submit" class="btn drop-token-btn btn-primary btn-xl">Drop Token</button>
@@ -26,4 +26,11 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if request.user == room.owner %}
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}">
{% csrf_token %}
<input type="email" name="invitee_email" id="id_invite_email" placeholder="friend@example.com">
<button type="submit" id="id_invite_btn" class="btn btn-primary btn-xl">Invite</button>
</form>
{% endif %}
</div> </div>