gatekeeper invite ports to #id_bud_btn slide-out: drop the inline #id_invite_email form, add bud-invite panel for room owner during gate phase, async POST to invite_gamer w. autocomplete + symmetric buds auto-add + slide-down Brief banner — TDD
- Brief schema (billboard/0007): post FK becomes nullable + new room FK to epic.Room + KIND_GAME_INVITE enum value. to_banner_dict resolves post_url to reverse('epic:gatekeeper', room.id) when post is null and room is set.
- epic.invite_gamer view refactor:
• Accepts `recipient` (matches bud-panel field; legacy `invitee_email` still works for full backwards compat).
• Resolves via apps.billboard.views._resolve_recipient (email if "@" present, else username).
• RoomInvite stores the resolved User's email (or raw input if unregistered).
• Auto-adds inviter ↔ recipient to each others' buds (symmetric per Phase 2 spec) when recipient is a registered User.
• Spawns a Brief w. owner=request.user, kind=GAME_INVITE, room=room, post=null.
• Accept: application/json → {brief, recipient_display}; otherwise redirects to gatekeeper as before.
• Self-invite + blank recipient: 200 w. brief=null, no RoomInvite, no buds touch.
- _gatekeeper.html: gate-invite-panel block (lines 62-71) removed.
- new templates/apps/billboard/_partials/_bud_invite_panel.html: clone of _bud_panel.html w. data-invite-url + autocomplete from request.user.buds. JS posts to invite_gamer + Brief.showBanner. room.html includes it owner-only when not table_status and gate_status != RENEWAL_DUE.
- room.html scripts block now loads apps/dashboard/note.js so window.Brief is defined for the slide-down banner.
- Tests: new test_invite_gamer.py (14 ITs) covering ajax + legacy form-submit paths, recipient resolution, RoomInvite creation, Brief w. room FK + GAME_INVITE kind, symmetric buds auto-add, unregistered/self/blank silent no-op cases. New test_gatekeeper_bud_btn.py FT (9 tests) covers presence (owner-only), absence of legacy #id_invite_email, async invite flow end-to-end (RoomInvite, Brief, banner, panel close, username resolve, buds auto-add).
- test_brief.test_brief_owner_post_required relaxed to test_brief_owner_required (post is now nullable).
- test_room_gatekeeper.test_second_gamer_drops_token_into_open_slot updated to drive the bud-btn flow (drops the #id_invite_email/#id_invite_btn references).
- 866 ITs (+14) + 9 gatekeeper FTs + 28 existing room-gatekeeper FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
143
src/apps/epic/tests/integrated/test_invite_gamer.py
Normal file
143
src/apps/epic/tests/integrated/test_invite_gamer.py
Normal file
@@ -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/<id>/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())
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user