diff --git a/src/apps/epic/migrations/0003_roominvite.py b/src/apps/epic/migrations/0003_roominvite.py new file mode 100644 index 0000000..4a5f9d6 --- /dev/null +++ b/src/apps/epic/migrations/0003_roominvite.py @@ -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')), + ], + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 70d8754..0743240 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -41,6 +41,7 @@ class Room(models.Model): board_state = models.JSONField(default=dict) seed_count = models.IntegerField(default=12) + class GateSlot(models.Model): EMPTY = "EMPTY" RESERVED = "RESERVED" @@ -66,6 +67,25 @@ class GateSlot(models.Model): 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) def create_gate_slots(sender, instance, created, **kwargs): if created: diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 5dff2d0..24f6f99 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -1,9 +1,10 @@ from datetime import timedelta +from django.db.models import Q from django.test import TestCase from django.urls import reverse 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): @@ -62,3 +63,31 @@ class CoinTokenInUseTest(TestCase): html = self.coin.tooltip_room_html() self.assertIn(f'href="{room_url}"', 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) diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index 7220da8..40db1a8 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -8,5 +8,6 @@ urlpatterns = [ path('rooms/create_room', views.create_room, name='create_room'), path('room//gate/', views.gatekeeper, name='gatekeeper'), path('room//gate//drop_token', views.drop_token, name='drop_token'), + path('room//gate/invite', views.invite_gamer, name='invite_gamer'), ] diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index b126dae..5e1991d 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1,7 +1,7 @@ from django.contrib.auth.decorators import login_required 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 @@ -17,9 +17,14 @@ def create_room(request): def gatekeeper(request, room_id): room = Room.objects.get(id=room_id) 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", { - 'room': room, - 'slots': slots, + "room": room, + "slots": slots, + "user_has_slot": user_has_slot, }) @login_required @@ -35,3 +40,17 @@ def drop_token(request, room_id, slot_number): if token: debit_token(request.user, slot, token) 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) diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 15b9a86..b65b5b6 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -4,7 +4,7 @@ from django.shortcuts import redirect, render from apps.applets.utils import applet_context 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 @@ -26,7 +26,9 @@ def gameboard(request): "applets": applet_context(request.user, "gameboard"), "page_class": "page-gameboard", "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(), } ) @@ -46,7 +48,9 @@ def toggle_game_applets(request): "coin": request.user.tokens.filter(token_type=Token.COIN).first(), "free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)), "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(), }) return redirect("gameboard") diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 6143ae8..ff7a558 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -90,3 +90,47 @@ class GatekeeperTest(FunctionalTest): lambda: self.browser.find_element(By.ID, "id_applet_my_games") ) 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 + ) + ) diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 67726a0..4525141 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -17,7 +17,7 @@ {% else %} empty {% 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 %}
{% csrf_token %} @@ -26,4 +26,11 @@ {% endfor %} + {% if request.user == room.owner %} + + {% csrf_token %} + + +
+ {% endif %} \ No newline at end of file