diff --git a/src/apps/billboard/migrations/0007_brief_room_alter_brief_kind_alter_brief_post.py b/src/apps/billboard/migrations/0007_brief_room_alter_brief_kind_alter_brief_post.py new file mode 100644 index 0000000..209072a --- /dev/null +++ b/src/apps/billboard/migrations/0007_brief_room_alter_brief_kind_alter_brief_post.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0 on 2026-05-09 04:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billboard', '0006_alter_line_options'), + ('epic', '0008_blades_reversal_fickle'), + ] + + operations = [ + migrations.AddField( + model_name='brief', + name='room', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='epic.room'), + ), + migrations.AlterField( + model_name='brief', + name='kind', + field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite')], default='user_post', max_length=32), + ), + migrations.AlterField( + model_name='brief', + name='post', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post'), + ), + ] diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py index d42c7b5..10befca 100644 --- a/src/apps/billboard/models.py +++ b/src/apps/billboard/models.py @@ -94,10 +94,12 @@ class Brief(models.Model): KIND_NOTE_UNLOCK = "note_unlock" KIND_USER_POST = "user_post" KIND_SHARE_INVITE = "share_invite" + KIND_GAME_INVITE = "game_invite" KIND_CHOICES = [ (KIND_NOTE_UNLOCK, "Note unlock"), (KIND_USER_POST, "User post"), (KIND_SHARE_INVITE, "Share invite"), + (KIND_GAME_INVITE, "Game invite"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -106,10 +108,25 @@ class Brief(models.Model): related_name="briefs", on_delete=models.CASCADE, ) + # Post is nullable now: KIND_GAME_INVITE briefs ride on a Room FK + # instead of a Post (the gatekeeper invite confirmation has no post + # to navigate to). Post FKs only set for note_unlock / user_post / + # share_invite kinds. post = models.ForeignKey( Post, related_name="briefs", on_delete=models.CASCADE, + null=True, + blank=True, + ) + # Room FK — set only on KIND_GAME_INVITE briefs; FYI navigates to + # the gatekeeper page for that room. + room = models.ForeignKey( + "epic.Room", + related_name="briefs", + on_delete=models.CASCADE, + null=True, + blank=True, ) # Line is nullable because a share_invite-style Brief can race ahead of its # async-appended Line write; the post FK alone is enough to navigate. @@ -142,16 +159,23 @@ class Brief(models.Model): """Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind carries a square_url pointing at /billboard/my-notes/ so the thumbnail-square inside the banner jumps direct to the user's Note - collection — other kinds get an empty square_url.""" + collection. GAME_INVITE kind has no Post — the FYI link navigates + to the gatekeeper page for the brief's Room instead.""" square_url = "" if self.kind == self.KIND_NOTE_UNLOCK: square_url = reverse("billboard:my_notes") + if self.post_id: + post_url = self.post.get_absolute_url() + elif self.room_id: + post_url = reverse("epic:gatekeeper", args=[self.room_id]) + else: + post_url = "" return { "id": str(self.id), "kind": self.kind, "title": self.title, "line_text": self.line.text if self.line else "", - "post_url": self.post.get_absolute_url(), + "post_url": post_url, "square_url": square_url, "created_at": self.created_at.isoformat(), } diff --git a/src/apps/billboard/tests/integrated/test_brief.py b/src/apps/billboard/tests/integrated/test_brief.py index 4362556..f9d464a 100644 --- a/src/apps/billboard/tests/integrated/test_brief.py +++ b/src/apps/billboard/tests/integrated/test_brief.py @@ -40,14 +40,14 @@ class BriefModelTest(TestCase): b = Brief.objects.create(owner=self.user, post=self.post) self.assertIsNone(b.line) - def test_brief_owner_post_required(self): - """Brief without owner OR post is invalid; both are the load-bearing - FKs (owner = whose attention; post = where FYI navigates).""" + def test_brief_owner_required(self): + """Brief without owner is invalid (load-bearing for "whose + attention"). Post used to be required too, but became nullable + when GAME_INVITE briefs landed (those use Brief.room instead of + Brief.post). The view layer enforces "post XOR room" per kind.""" from django.db import IntegrityError, transaction with transaction.atomic(), self.assertRaises(IntegrityError): Brief.objects.create(post=self.post, line=self.line) - with transaction.atomic(), self.assertRaises(IntegrityError): - Brief.objects.create(owner=self.user, line=self.line) def test_brief_carries_title(self): b = Brief.objects.create( diff --git a/src/apps/epic/tests/integrated/test_invite_gamer.py b/src/apps/epic/tests/integrated/test_invite_gamer.py new file mode 100644 index 0000000..5ea184f --- /dev/null +++ b/src/apps/epic/tests/integrated/test_invite_gamer.py @@ -0,0 +1,143 @@ +"""ITs for the gatekeeper invite_gamer view post-bud-btn refactor. + +The legacy form-submit path (POST `invitee_email` + 302 redirect) still +works for any pre-existing caller; the new bud-btn slide-out POSTs +`recipient` (email OR username) w. Accept: application/json and gets +back {brief, recipient_display}. On registered recipients we auto-add +both directions of the buds graph (mirrors share_post per the Phase 2 +spec) and spawn a Brief w. kind=GAME_INVITE pointing at the room. +""" +from django.test import TestCase +from django.urls import reverse + +from apps.billboard.models import Brief +from apps.epic.models import Room, RoomInvite +from apps.lyric.models import User + + +class InviteGamerAjaxTest(TestCase): + """Bud-btn flow: POST /room//gate/invite w. Accept: application/json.""" + + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.client.force_login(self.owner) + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.room = Room.objects.create(name="Bingobango", owner=self.owner) + + def _invite(self, recipient): + return self.client.post( + reverse("epic:invite_gamer", args=[self.room.id]), + data={"recipient": recipient}, + HTTP_ACCEPT="application/json", + ) + + def test_ajax_invite_returns_brief_payload(self): + response = self._invite("alice@test.io") + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertIn("brief", body) + self.assertIn("recipient_display", body) + self.assertIsNotNone(body["brief"]) + + def test_ajax_invite_creates_room_invite(self): + self._invite("alice@test.io") + self.assertTrue(RoomInvite.objects.filter( + room=self.room, inviter=self.owner, invitee_email="alice@test.io", + ).exists()) + + def test_ajax_invite_resolves_username_to_email(self): + """Username-typed recipient stores the resolved User's email.""" + self._invite("alice") + self.assertTrue(RoomInvite.objects.filter( + room=self.room, invitee_email="alice@test.io", + ).exists()) + + def test_ajax_invite_creates_brief_with_game_invite_kind_and_room_fk(self): + self._invite("alice@test.io") + brief = Brief.objects.get(owner=self.owner) + self.assertEqual(brief.kind, Brief.KIND_GAME_INVITE) + self.assertEqual(brief.room, self.room) + self.assertIsNone(brief.post) + self.assertTrue(brief.is_unread) + + def test_ajax_invite_brief_banner_dict_links_to_room_gatekeeper(self): + body = self._invite("alice@test.io").json() + self.assertEqual( + body["brief"]["post_url"], + reverse("epic:gatekeeper", args=[self.room.id]), + ) + + def test_ajax_invite_auto_adds_recipient_to_inviter_buds(self): + self._invite("alice@test.io") + self.assertIn(self.alice, self.owner.buds.all()) + + def test_ajax_invite_auto_adds_inviter_to_recipient_buds_symmetric(self): + """Per Phase 2 spec: shared events imply mutual buds graph link.""" + self._invite("alice@test.io") + self.assertIn(self.owner, self.alice.buds.all()) + + def test_ajax_invite_unregistered_email_creates_invite_no_buds_add(self): + """Privacy + correctness: unregistered recipient still gets a + RoomInvite (so they can accept after registration), but the + inviter's buds list isn't touched (we don't auto-add a non-User).""" + response = self._invite("ghost@test.io") + self.assertEqual(response.status_code, 200) + self.assertTrue(RoomInvite.objects.filter( + room=self.room, invitee_email="ghost@test.io", + ).exists()) + self.assertEqual(self.owner.buds.count(), 0) + + def test_ajax_invite_self_is_silent_noop(self): + """Inviting yourself: brief=null, no RoomInvite, no buds touch.""" + response = self._invite("owner@test.io") + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.json()["brief"]) + self.assertFalse(RoomInvite.objects.filter(room=self.room).exists()) + + def test_ajax_invite_blank_recipient_is_silent_noop(self): + response = self._invite("") + self.assertEqual(response.status_code, 200) + self.assertIsNone(response.json()["brief"]) + self.assertFalse(RoomInvite.objects.filter(room=self.room).exists()) + + def test_ajax_invite_recipient_display_is_username_when_registered(self): + body = self._invite("alice@test.io").json() + self.assertEqual(body["recipient_display"], "alice") + + def test_ajax_invite_recipient_display_is_null_when_unregistered(self): + body = self._invite("ghost@test.io").json() + self.assertIsNone(body["recipient_display"]) + + +class InviteGamerLegacyFormTest(TestCase): + """Form-submit path (no Accept: application/json) preserved — older + callers still get a 302 redirect to the gatekeeper. The legacy + `invitee_email` field name is also still accepted for full backwards + compat, even though the new bud-btn uses `recipient`.""" + + def setUp(self): + self.owner = User.objects.create(email="owner@test.io") + self.client.force_login(self.owner) + self.room = Room.objects.create(name="Test", owner=self.owner) + + def test_form_submit_with_invitee_email_redirects(self): + response = self.client.post( + reverse("epic:invite_gamer", args=[self.room.id]), + data={"invitee_email": "alice@test.io"}, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual( + response["Location"], + reverse("epic:gatekeeper", args=[self.room.id]), + ) + + def test_form_submit_with_recipient_field_also_works(self): + """The bud-btn field name (recipient) also works on form submit.""" + response = self.client.post( + reverse("epic:invite_gamer", args=[self.room.id]), + data={"recipient": "alice@test.io"}, + ) + self.assertEqual(response.status_code, 302) + self.assertTrue(RoomInvite.objects.filter( + room=self.room, invitee_email="alice@test.io", + ).exists()) diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 60d81c7..83cf9da 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -656,16 +656,81 @@ def pick_roles(request, 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} - ) + """Gatekeeper invite flow. Backwards-compatible w. the legacy + `invitee_email` form-submit (still POSTs from any old caller); also + serves the new bud-btn slide-out which sends `recipient` (email OR + username) + Accept: application/json. Bud-btn flow: + • Resolves recipient via _resolve_recipient (registered → User; else None). + • Stores RoomInvite using the resolved email (or raw input if unregistered). + • Auto-adds inviter ↔ recipient to each others' buds (symmetric, per + share_post precedent — registered recipients only). + • Spawns a Brief w. kind=GAME_INVITE + room=room (post=null). + • Returns JSON {brief, recipient_display} when Accept matches; else + redirects to gatekeeper as before.""" + if request.method != "POST": + return redirect("epic:gatekeeper", room_id=room_id) + + from apps.billboard.models import Brief + from apps.billboard.views import _resolve_recipient + + room = Room.objects.get(id=room_id) + is_ajax = "application/json" in request.headers.get("Accept", "") + + # New bud-btn field name is `recipient`; legacy form uses `invitee_email`. + raw = ( + request.POST.get("recipient") + or request.POST.get("invitee_email") + or "" + ).strip() + + if not raw: + if is_ajax: + return JsonResponse({"brief": None, "recipient_display": None}) + return redirect("epic:gatekeeper", room_id=room_id) + + candidate = _resolve_recipient(raw) + is_self = candidate is not None and candidate == request.user + if is_self: + if is_ajax: + return JsonResponse({"brief": None, "recipient_display": None}) + return redirect("epic:gatekeeper", room_id=room_id) + + # RoomInvite uses the resolved User's email when available (so a + # username-typed invite doesn't store the raw username as if it were + # an email); falls back to the raw input for unregistered addresses. + invitee_email = candidate.email if candidate else raw + RoomInvite.objects.get_or_create( + room=room, + inviter=request.user, + invitee_email=invitee_email, + defaults={"status": RoomInvite.PENDING}, + ) + + # Buds graph: symmetric auto-add on registered recipients (mirrors + # share_post). Idempotent on M2M; no-op on unregistered recipients. + if candidate is not None: + request.user.buds.add(candidate) + candidate.buds.add(request.user) + + # Brief: confirmation banner for the inviter. Brief.post stays null; + # banner FYI navigates to the room's gatekeeper page via Brief.room. + brief = Brief.objects.create( + owner=request.user, + post=None, + room=room, + kind=Brief.KIND_GAME_INVITE, + title="Invite sent", + ) + + if is_ajax: + recipient_display = None + if candidate is not None: + recipient_display = candidate.username or candidate.email + return JsonResponse({ + "brief": brief.to_banner_dict(), + "recipient_display": recipient_display, + }) + return redirect("epic:gatekeeper", room_id=room_id) diff --git a/src/functional_tests/test_gatekeeper_bud_btn.py b/src/functional_tests/test_gatekeeper_bud_btn.py new file mode 100644 index 0000000..80ca300 --- /dev/null +++ b/src/functional_tests/test_gatekeeper_bud_btn.py @@ -0,0 +1,116 @@ +"""FT for the gatekeeper invite via #id_bud_btn slide-out. + +Replaces the legacy inline `
` panel inside +the gatekeeper modal. The bud-btn lives at the upper-right corner of +the right sidebar (footer in landscape); slide-out hosts the email/ +username field + OK btn. Submit fires async POST to +epic:invite_gamer w. Accept: application/json — server returns +{brief, recipient_display}, JS shows the slide-down Brief banner. +""" +from selenium.webdriver.common.by import By + +from apps.billboard.models import Brief +from apps.epic.models import Room, RoomInvite +from apps.lyric.models import User + +from .base import FunctionalTest + + +class GatekeeperBudBtnPresenceTest(FunctionalTest): + """The bud-btn renders for the room owner during gate phase, and is + absent for non-owners (friend invites are owner-only).""" + + def setUp(self): + super().setUp() + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.gamer = User.objects.create(email="gamer@test.io", username="gamer") + self.room = Room.objects.create(name="Bingobango", owner=self.owner) + self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/" + + def test_bud_btn_renders_for_owner(self): + self.create_pre_authenticated_session("owner@test.io") + self.browser.get(self.room_url) + self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) + + def test_bud_btn_absent_for_non_owner(self): + # A registered non-owner viewer doesn't see the invite affordance. + self.create_pre_authenticated_session("gamer@test.io") + self.browser.get(self.room_url) + # Gatekeeper-specific element confirms page rendered + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal")) + self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn")) + + def test_legacy_invite_email_input_is_gone(self): + """Sanity: the old inline form has been removed.""" + self.create_pre_authenticated_session("owner@test.io") + self.browser.get(self.room_url) + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal")) + self.assertFalse(self.browser.find_elements(By.ID, "id_invite_email")) + + +class GatekeeperBudBtnAsyncInviteTest(FunctionalTest): + """OK on the bud-btn slide-out fires the async invite — RoomInvite + persisted, Brief w/ kind=GAME_INVITE created, slide-down banner shown.""" + + def setUp(self): + super().setUp() + self.owner = User.objects.create(email="owner@test.io", username="owner") + self.alice = User.objects.create(email="alice@test.io", username="alice") + self.room = Room.objects.create(name="Bingobango", owner=self.owner) + self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/" + self.create_pre_authenticated_session("owner@test.io") + self.browser.get(self.room_url) + + def _open_panel_and_invite(self, recipient): + bud_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn")) + bud_btn.click() + recipient_input = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_recipient") + ) + recipient_input.send_keys(recipient) + self.browser.find_element( + By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm" + ).click() + return bud_btn + + def test_invite_creates_room_invite(self): + self._open_panel_and_invite("alice@test.io") + self.wait_for(lambda: self.assertEqual( + RoomInvite.objects.filter( + room=self.room, invitee_email="alice@test.io" + ).count(), + 1, + )) + + def test_invite_spawns_game_invite_brief(self): + self._open_panel_and_invite("alice@test.io") + self.wait_for(lambda: self.assertEqual( + Brief.objects.filter( + owner=self.owner, kind=Brief.KIND_GAME_INVITE, + ).count(), + 1, + )) + + def test_invite_renders_slide_down_banner(self): + self._open_panel_and_invite("alice@test.io") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")) + + def test_invite_closes_panel_after_success(self): + bud_btn = self._open_panel_and_invite("alice@test.io") + self.wait_for(lambda: self.assertNotIn("active", bud_btn.get_attribute("class"))) + + def test_invite_username_resolves_to_user_email(self): + """Username-typed invite stores the resolved User's email.""" + self._open_panel_and_invite("alice") + self.wait_for(lambda: self.assertEqual( + RoomInvite.objects.filter( + room=self.room, invitee_email="alice@test.io" + ).count(), + 1, + )) + + def test_invite_auto_adds_recipient_to_owner_buds(self): + self._open_panel_and_invite("alice@test.io") + self.wait_for(lambda: self.assertIn( + self.alice, list(self.owner.buds.all()) + )) diff --git a/src/functional_tests/test_room_gatekeeper.py b/src/functional_tests/test_room_gatekeeper.py index d27e84f..0f238fa 100644 --- a/src/functional_tests/test_room_gatekeeper.py +++ b/src/functional_tests/test_room_gatekeeper.py @@ -127,12 +127,19 @@ class GatekeeperTest(FunctionalTest): self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-slot.filled") ) - # 2. Founder invites friend - invite_input = self.wait_for( - lambda: self.browser.find_element(By.ID, "id_invite_email") + # 2. Founder invites friend via the bud-btn slide-out (replaces + # the legacy inline #id_invite_email form post-bud-btn refactor). + bud_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_bud_btn") ) - invite_input.send_keys("friend@test.io") - self.browser.find_element(By.ID, "id_invite_btn").click() + bud_btn.click() + recipient = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_recipient") + ) + recipient.send_keys("friend@test.io") + self.browser.find_element( + By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm" + ).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/") diff --git a/src/templates/apps/billboard/_partials/_bud_invite_panel.html b/src/templates/apps/billboard/_partials/_bud_invite_panel.html new file mode 100644 index 0000000..2956dc3 --- /dev/null +++ b/src/templates/apps/billboard/_partials/_bud_invite_panel.html @@ -0,0 +1,130 @@ +{% load static %} +{% load lyric_extras %} +{# ─────────────────────────────────────────────────────────────────────── #} +{# _bud_invite_panel.html — bud btn + slide-out for the gatekeeper game- #} +{# invite flow. Replaces the legacy `` #} +{# inline panel inside _gatekeeper.html. #} +{# #} +{# Differences from the post-share variant (_bud_panel.html): #} +{# • POSTs to epic:invite_gamer instead of billboard:share_post. #} +{# • Server returns {brief, recipient_display} — no line_text (no Post #} +{# to append a Line to). JS just shows the Brief banner. #} +{# #} +{# Caller must pass `room` in context. #} +{# ─────────────────────────────────────────────────────────────────────── #} + + + +
+ + +
+ +{# Autocomplete suggestions — sibling because the panel has overflow:hidden #} +{# for the slide-in scaleX animation. Pulls from request.user.buds. #} + + + + + + diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index fddc27b..0f78216 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -59,16 +59,9 @@ - {% if request.user == room.owner %} -
-

Invite Friend

- - {% csrf_token %} - - - -
- {% endif %} + {# Legacy gate-invite-panel retired in favour of #id_bud_btn at #} + {# the upper-right of the footer (room.html includes the bud #} + {# invite panel partial when the viewer owns the room). #} diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 1f7414a..aa93a56 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -92,6 +92,12 @@ {% endif %} {% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %} + {# Owner-only invite affordance: handshake btn at the upper-right #} + {# of the right sidebar w. slide-out + autocomplete. Replaces the #} + {# legacy inline `
` panel. #} + {% if request.user == room.owner %} + {% include "apps/billboard/_partials/_bud_invite_panel.html" %} + {% endif %} {% endif %} {% if room.table_status %}
@@ -117,6 +123,9 @@ {% endblock content %} {% block scripts %} + {# Brief module — needed by _bud_invite_panel's OK handler so the #} + {# slide-down banner shows up on a successful gatekeeper invite. #} +