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:
27
src/apps/epic/migrations/0003_roominvite.py
Normal file
27
src/apps/epic/migrations/0003_roominvite.py
Normal 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user