From fb8563eed2a6849cf3da6f971a8c8298f69dbcde Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 27 May 2026 13:14:06 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20bud-invite=20Phase=20A:=20SeaInvite=20?= =?UTF-8?q?model=20+=20@mailman=20log=20+=20OK/BYE=20accept/decline=20?= =?UTF-8?q?=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of the my-sea invite → @mailman → spectator → voice blueprint (magical-dancing-quasar.md). Pure Django; no new infra this phase (the coturn droplet lands in Phase C5). Mirrors the @taxman ledger shape throughout. - A1: SeaInvite model (gameboard) — single source of truth for a my-sea invite (owner / invitee / status / timestamps + OneToOne FK to its @mailman Line). is_expired / voice_active / is_present / expires_at properties; 12 UTs. created_at uses default=timezone.now (MySeaDraw precedent) for testable 24h expiry; a token deposit makes the invite non-expiring per spec. - A2: reserved @mailman system user — get_or_create_mailman + "mailman" added to RESERVED_USERNAMES + seed migration lyric/0015. Email domain confirmed w. user as mailman@earthmanrpg.local (matches adman/taxman). - A3: billboard KIND_MAIL_ACCEPTANCE on Post + Brief; extends the post_save unsolicited-line guard (_SYSTEM_AUTHOR_POST_KINDS) + migration billboard/0009. - A4: apps/billboard/mail.py log_sea_invite — appends one interactive Line + invitee Brief on the invitee's "Acceptances & rejections" Post, links the Line back onto the SeaInvite; "Listen!—@owner invites you to {poss} drawing table" prose via at_handle + resolve_pronouns. Unregistered invitee no-ops. - A5: post.html renders OK .btn-confirm / BYE .btn-abandon (PENDING) or a status badge (ACCEPTED / DECLINED / LEFT / EXPIRED) from line.sea_invite.status via new _partials/_invite_actions.html; 'mailman' added to the system-author |safe + read-only-input + bud-panel-suppression branches. - A6: real my_sea_invite (replaces the coming-soon stub) — resolves recipient, dedups outstanding PENDING/ACCEPTED, creates SeaInvite + logs the @mailman line; new my_sea_invite_accept / my_sea_invite_decline endpoints (invitee-only, redirect back to the invite-log Post; accept links invitee FK + stamps accepted_at). 16 ITs. - A7: updated MySeaBudBtnInviteTest (stub→real invite) + new MySeaInviteAcceptanceLogTest FT (invitee opens their log Post, sees the line + OK/BYE). Both green. 457 IT/UT green. Phase B (invitee spectator seat-2 + visitor token gate) + Phase C (WebRTC mesh voice + coturn droplet) to follow. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- src/apps/billboard/mail.py | 92 +++++++++ .../0009_alter_brief_kind_alter_post_kind.py | 23 +++ src/apps/billboard/models.py | 45 +++-- .../billboard/tests/integrated/test_mail.py | 185 ++++++++++++++++++ .../gameboard/migrations/0004_seainvite.py | 37 ++++ src/apps/gameboard/models.py | 118 +++++++++++ .../tests/integrated/test_sea_invite_views.py | 183 +++++++++++++++++ .../gameboard/tests/unit/test_sea_invite.py | 99 ++++++++++ src/apps/gameboard/urls.py | 4 + src/apps/gameboard/views.py | 129 +++++++++++- .../lyric/migrations/0015_seed_mailman.py | 38 ++++ src/apps/lyric/models.py | 22 ++- src/functional_tests/test_game_my_sea.py | 57 +++++- .../billboard/_partials/_invite_actions.html | 31 +++ src/templates/apps/billboard/post.html | 12 +- 15 files changed, 1037 insertions(+), 38 deletions(-) create mode 100644 src/apps/billboard/mail.py create mode 100644 src/apps/billboard/migrations/0009_alter_brief_kind_alter_post_kind.py create mode 100644 src/apps/billboard/tests/integrated/test_mail.py create mode 100644 src/apps/gameboard/migrations/0004_seainvite.py create mode 100644 src/apps/gameboard/tests/integrated/test_sea_invite_views.py create mode 100644 src/apps/gameboard/tests/unit/test_sea_invite.py create mode 100644 src/apps/lyric/migrations/0015_seed_mailman.py create mode 100644 src/templates/apps/billboard/_partials/_invite_actions.html diff --git a/src/apps/billboard/mail.py b/src/apps/billboard/mail.py new file mode 100644 index 0000000..196f97d --- /dev/null +++ b/src/apps/billboard/mail.py @@ -0,0 +1,92 @@ +"""@mailman-authored "Acceptances & rejections" invite log — Phase A of +[[my-sea-invite-voice-blueprint]]. + +`log_sea_invite(sea_invite)` appends one interactive Line + spawns one Brief +on the invitee's single MAIL_ACCEPTANCE Post when an owner invites a bud to +their my-sea table. Models on `apps.billboard.tax.log_tax_debit` (the @taxman +ledger); the one genuinely new wrinkle is that the Line is *stateful* — its +OK/BYE buttons render from the linked `SeaInvite.status` (see post.html, A5), +so the single line transforms in place rather than appending accept/decline +lines. + +Unlike the tax ledger, the prose carries no timestamp prefix (one invite = +one line; the A6 view dedups duplicate PENDING/ACCEPTED invites before +calling here, so the `Line.unique_together = (post, text)` invariant isn't +stressed by repeat identical prose). `Line.display_text` therefore needs no +MAIL_ACCEPTANCE branch. + +The post_save guard in `billboard.models` nukes any Line on a MAIL_ACCEPTANCE +Post lacking admin_solicited=True, so this helper sets it True. +""" + +from apps.billboard.models import ( + Brief, + Line, + MAIL_ACCEPTANCE_POST_TITLE, + Post, +) +from apps.lyric.models import get_or_create_mailman, resolve_pronouns +from apps.lyric.templatetags.lyric_extras import at_handle + + +# Invite prose shown to the invitee. `{handle}` = the owner's `@username` +# (via at_handle — falls back to truncated email for handle-less owners); +# `{poss}` = the owner's possessive pronoun ("their"/"his"/"her"/…), so the +# table reads as the owner's. Em dash matches the @taxman "Look!—" house style. +INVITE_TEMPLATE = ( + "Listen!—{handle} invites you to {poss} drawing table. " + "This invite will expire 24h from the time it was extended." +) + + +def log_sea_invite(sea_invite): + """Append a Line to the invitee's "Acceptances & rejections" Post (creating + the Post on first invite) + spawn a Brief that the invitee's next page-load + surfaces as a slide-down banner. Links the new Line back onto the SeaInvite + so its OK/BYE buttons render from `sea_invite.status`. + + Returns ``(post, line, brief)``. For an unregistered invitee (``invitee`` + FK still None) there is no per-user log surface yet — returns + ``(None, None, None)`` (linking on registration is deferred, mirroring the + long-standing RoomInvite-on-registration gap).""" + invitee = sea_invite.invitee + if invitee is None: + return None, None, None + + owner = sea_invite.owner + + post, _ = Post.objects.get_or_create( + owner=invitee, + kind=Post.KIND_MAIL_ACCEPTANCE, + defaults={"title": MAIL_ACCEPTANCE_POST_TITLE}, + ) + # Heal a title-less pre-feature Post once on next invite (mirrors the + # tax-ledger / Note.grant_if_new title heal). + if post.title != MAIL_ACCEPTANCE_POST_TITLE: + post.title = MAIL_ACCEPTANCE_POST_TITLE + post.save(update_fields=["title"]) + + text = INVITE_TEMPLATE.format( + handle=at_handle(owner), + poss=resolve_pronouns(owner.pronouns)[2], + ) + + line = Line.objects.create( + post=post, + text=text, + author=get_or_create_mailman(), + admin_solicited=True, + ) + # Link the Line onto the invite so post.html resolves OK/BYE state via the + # `line.sea_invite` OneToOne reverse accessor. + sea_invite.line = line + sea_invite.save(update_fields=["line"]) + + brief = Brief.objects.create( + owner=invitee, + post=post, + line=line, + kind=Brief.KIND_MAIL_ACCEPTANCE, + title=MAIL_ACCEPTANCE_POST_TITLE, + ) + return post, line, brief diff --git a/src/apps/billboard/migrations/0009_alter_brief_kind_alter_post_kind.py b/src/apps/billboard/migrations/0009_alter_brief_kind_alter_post_kind.py new file mode 100644 index 0000000..d95563b --- /dev/null +++ b/src/apps/billboard/migrations/0009_alter_brief_kind_alter_post_kind.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0 on 2026-05-27 16:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billboard', '0008_tax_ledger_kind'), + ] + + operations = [ + 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'), ('tax_ledger', 'Tax ledger'), ('mail_acceptance', 'Mail acceptance')], default='user_post', max_length=32), + ), + migrations.AlterField( + model_name='post', + name='kind', + field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites'), ('tax_ledger', 'Debits & credits'), ('mail_acceptance', 'Acceptances & rejections')], default='user_post', max_length=32), + ), + ] diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py index 4b4d6b2..25506a4 100644 --- a/src/apps/billboard/models.py +++ b/src/apps/billboard/models.py @@ -9,6 +9,7 @@ from django.utils import timezone NOTE_UNLOCK_POST_TITLE_HINT = "Notes & recognitions" # see drama.NOTE_UNLOCK_POST_TITLE; copy lives there TAX_LEDGER_POST_TITLE = "Debits & credits" +MAIL_ACCEPTANCE_POST_TITLE = "Acceptances & rejections" class Post(models.Model): @@ -20,11 +21,18 @@ class Post(models.Model): # `apps.billboard.tax.log_tax_debit`. Mirrors the NOTE_UNLOCK Post pattern: # one Post per user, system-authored, readonly textarea in post.html. KIND_TAX_LEDGER = "tax_ledger" + # Per-user @mailman-authored "Acceptances & rejections" log (my-sea bud- + # invite flow, see [[my-sea-invite-voice-blueprint]]). Each invite appends + # one interactive Line (OK/BYE buttons) via `apps.billboard.mail. + # log_sea_invite`. Mirrors the TAX_LEDGER Post pattern: one Post per + # invitee, system-authored, with admin_solicited Lines. + KIND_MAIL_ACCEPTANCE = "mail_acceptance" KIND_CHOICES = [ - (KIND_NOTE_UNLOCK, "Note unlocks"), - (KIND_USER_POST, "User post"), - (KIND_SHARE_INVITE, "Share invites"), - (KIND_TAX_LEDGER, "Debits & credits"), + (KIND_NOTE_UNLOCK, "Note unlocks"), + (KIND_USER_POST, "User post"), + (KIND_SHARE_INVITE, "Share invites"), + (KIND_TAX_LEDGER, "Debits & credits"), + (KIND_MAIL_ACCEPTANCE, "Acceptances & rejections"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -127,12 +135,16 @@ class Brief(models.Model): # brief endpoint so the dismissal persists per-cycle (see # `dismiss_url` in `to_banner_dict`). KIND_TAX_LEDGER = "tax_ledger" + # Surfaces the invitee's slide-down notification when an owner invites them + # to a my-sea table; FYI navigates to their "Acceptances & rejections" Post. + KIND_MAIL_ACCEPTANCE = "mail_acceptance" KIND_CHOICES = [ - (KIND_NOTE_UNLOCK, "Note unlock"), - (KIND_USER_POST, "User post"), - (KIND_SHARE_INVITE, "Share invite"), - (KIND_GAME_INVITE, "Game invite"), - (KIND_TAX_LEDGER, "Tax ledger"), + (KIND_NOTE_UNLOCK, "Note unlock"), + (KIND_USER_POST, "User post"), + (KIND_SHARE_INVITE, "Share invite"), + (KIND_GAME_INVITE, "Game invite"), + (KIND_TAX_LEDGER, "Tax ledger"), + (KIND_MAIL_ACCEPTANCE, "Mail acceptance"), ] id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) @@ -228,13 +240,18 @@ class Brief(models.Model): # ── Listener: nuke unsolicited Lines on system-author Posts ────────────── # Defense-in-depth alongside view_post's POST guard. A Line saved on a -# NOTE_UNLOCK / TAX_LEDGER Post that lacks admin_solicited=True (e.g. a stray -# ORM-level write or an API path that bypasses the view) gets deleted right -# after the save. `Note.grant_if_new` + `apps.billboard.tax.log_tax_debit` -# both set admin_solicited=True on their Lines so legitimate system prose +# NOTE_UNLOCK / TAX_LEDGER / MAIL_ACCEPTANCE Post that lacks +# admin_solicited=True (e.g. a stray ORM-level write or an API path that +# bypasses the view) gets deleted right after the save. `Note.grant_if_new`, +# `apps.billboard.tax.log_tax_debit`, and `apps.billboard.mail.log_sea_invite` +# all set admin_solicited=True on their Lines so legitimate system prose # survives. -_SYSTEM_AUTHOR_POST_KINDS = (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER) +_SYSTEM_AUTHOR_POST_KINDS = ( + Post.KIND_NOTE_UNLOCK, + Post.KIND_TAX_LEDGER, + Post.KIND_MAIL_ACCEPTANCE, +) @receiver(post_save, sender=Line) diff --git a/src/apps/billboard/tests/integrated/test_mail.py b/src/apps/billboard/tests/integrated/test_mail.py new file mode 100644 index 0000000..b50a025 --- /dev/null +++ b/src/apps/billboard/tests/integrated/test_mail.py @@ -0,0 +1,185 @@ +"""ITs for the @mailman "Acceptances & rejections" log — Phase A of +[[my-sea-invite-voice-blueprint]]. + +Mirrors `apps.billboard.tests.integrated.test_tax` (the @taxman ledger): a +reserved system-author user (`mailman`) authors the interactive invite log +Lines via `apps.billboard.mail.log_sea_invite`. + +This file grows in A4 with the `log_sea_invite` Post/Line/Brief tests; A2 +lands only the reserved-username + idempotency contract for `mailman`, +mirroring `test_tax.TaxmanReservedUsernameTest`. +""" + +from django.test import TestCase + +from apps.billboard.mail import INVITE_TEMPLATE, log_sea_invite +from apps.billboard.models import ( + Brief, + Line, + MAIL_ACCEPTANCE_POST_TITLE, + Post, +) +from apps.gameboard.models import SeaInvite +from apps.lyric.models import ( + User, + get_or_create_mailman, + is_reserved_username, +) + + +class LogSeaInviteTest(TestCase): + """`log_sea_invite` appends one interactive Line + spawns a Brief on the + invitee's single "Acceptances & rejections" Post, and links the Line back + to the SeaInvite (powering the OK/BYE render in A5).""" + + def setUp(self): + self.owner = User.objects.create( + email="owner@test.io", username="discoman", + ) + self.invitee = User.objects.create( + email="bud@test.io", username="budster", + ) + self.invite = SeaInvite.objects.create( + owner=self.owner, + invitee=self.invitee, + invitee_email=self.invitee.email, + ) + + def test_creates_post_line_brief_on_invitee(self): + post, line, brief = log_sea_invite(self.invite) + # Post owned by the INVITEE (it's their notification surface) + self.assertEqual(post.owner, self.invitee) + self.assertEqual(post.kind, Post.KIND_MAIL_ACCEPTANCE) + self.assertEqual(post.title, MAIL_ACCEPTANCE_POST_TITLE) + # Line authored by @mailman + admin_solicited (survives the guard) + self.assertEqual(line.post, post) + self.assertEqual(line.author, get_or_create_mailman()) + self.assertTrue(line.admin_solicited) + # Brief points at the Post + Line w. correct kind + self.assertEqual(brief.owner, self.invitee) + self.assertEqual(brief.post, post) + self.assertEqual(brief.line, line) + self.assertEqual(brief.kind, Brief.KIND_MAIL_ACCEPTANCE) + self.assertEqual(brief.title, MAIL_ACCEPTANCE_POST_TITLE) + + def test_links_line_to_invite_both_directions(self): + _, line, _ = log_sea_invite(self.invite) + self.invite.refresh_from_db() + self.assertEqual(self.invite.line, line) + # OneToOne reverse accessor used by post.html (A5) + self.assertEqual(line.sea_invite, self.invite) + + def test_prose_interpolates_owner_handle_and_default_possessive(self): + _, line, _ = log_sea_invite(self.invite) + self.assertIn("@discoman", line.text) + # pluralism (default) possessive = "their" + self.assertIn("their drawing table", line.text) + self.assertIn("expire 24h", line.text) + + def test_possessive_follows_owner_pronouns(self): + self.owner.pronouns = "misogyny" # he/him/his + self.owner.save() + _, line, _ = log_sea_invite(self.invite) + self.assertIn("his drawing table", line.text) + + def test_line_survives_post_save_guard(self): + _, line, _ = log_sea_invite(self.invite) + self.assertTrue(Line.objects.filter(pk=line.pk).exists()) + + def test_two_inviters_share_invitees_one_post(self): + other_owner = User.objects.create( + email="other@test.io", username="amigo", + ) + other_invite = SeaInvite.objects.create( + owner=other_owner, invitee=self.invitee, + invitee_email=self.invitee.email, + ) + log_sea_invite(self.invite) + log_sea_invite(other_invite) + posts = Post.objects.filter( + owner=self.invitee, kind=Post.KIND_MAIL_ACCEPTANCE, + ) + self.assertEqual(posts.count(), 1) + self.assertEqual(posts.first().lines.count(), 2) + + def test_unregistered_invitee_creates_no_log(self): + # An unregistered recipient has no per-user Post surface yet — linking + # on registration is deferred. log_sea_invite no-ops to (None,None,None). + invite = SeaInvite.objects.create( + owner=self.owner, invitee=None, + invitee_email="stranger@nowhere.io", + ) + self.assertEqual(log_sea_invite(invite), (None, None, None)) + + def test_template_uses_listen_hook(self): + self.assertTrue(INVITE_TEMPLATE.startswith("Listen!")) + + +class MailAcceptanceKindTest(TestCase): + """The new MAIL_ACCEPTANCE kind is registered on both Post + Brief.""" + + def test_post_kind_registered(self): + self.assertEqual(Post.KIND_MAIL_ACCEPTANCE, "mail_acceptance") + kinds = dict(Post.KIND_CHOICES) + self.assertIn(Post.KIND_MAIL_ACCEPTANCE, kinds) + + def test_brief_kind_registered(self): + self.assertEqual(Brief.KIND_MAIL_ACCEPTANCE, "mail_acceptance") + kinds = dict(Brief.KIND_CHOICES) + self.assertIn(Brief.KIND_MAIL_ACCEPTANCE, kinds) + + def test_post_title_constant(self): + self.assertEqual(MAIL_ACCEPTANCE_POST_TITLE, "Acceptances & rejections") + + +class MailAcceptanceGuardTest(TestCase): + """post_save guard nukes any Line saved on a MAIL_ACCEPTANCE Post w.o. + admin_solicited=True — same defense-in-depth as NOTE_UNLOCK / TAX_LEDGER. + `log_sea_invite` sets admin_solicited=True so legitimate invite Lines + survive.""" + + def setUp(self): + self.user = User.objects.create(email="guard_mail@test.io") + self.mailman = get_or_create_mailman() + self.post = Post.objects.create( + owner=self.user, + kind=Post.KIND_MAIL_ACCEPTANCE, + title=MAIL_ACCEPTANCE_POST_TITLE, + ) + + def test_unsolicited_line_on_mail_acceptance_gets_deleted(self): + Line.objects.create( + post=self.post, text="impostor", author=self.mailman, + admin_solicited=False, + ) + self.assertEqual(self.post.lines.count(), 0) + + def test_solicited_line_on_mail_acceptance_survives(self): + Line.objects.create( + post=self.post, text="legit", author=self.mailman, + admin_solicited=True, + ) + self.assertEqual(self.post.lines.count(), 1) + + +class MailmanReservedUsernameTest(TestCase): + """`mailman` joins `adman` + `taxman` as a reserved system-author handle.""" + + def test_mailman_is_reserved(self): + self.assertTrue(is_reserved_username("mailman")) + self.assertTrue(is_reserved_username("MAILMAN")) # case-insensitive + + def test_get_or_create_mailman_is_idempotent(self): + a = get_or_create_mailman() + b = get_or_create_mailman() + self.assertEqual(a.pk, b.pk) + self.assertEqual(a.email, "mailman@earthmanrpg.local") + + def test_mailman_is_not_searchable(self): + # System users never surface in bud / recipient autocomplete. + self.assertFalse(get_or_create_mailman().searchable) + + def test_existing_username_owner_is_not_blocked(self): + # A user who already holds a name isn't blocked from re-saving it. + u = User.objects.create(email="m@test.io", username="mailman_fan") + self.assertFalse(is_reserved_username("mailman_fan", current_user=u)) diff --git a/src/apps/gameboard/migrations/0004_seainvite.py b/src/apps/gameboard/migrations/0004_seainvite.py new file mode 100644 index 0000000..dd1d29c --- /dev/null +++ b/src/apps/gameboard/migrations/0004_seainvite.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0 on 2026-05-27 15:59 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billboard', '0008_tax_ledger_kind'), + ('gameboard', '0003_myseadraw_paid_through_at'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SeaInvite', + 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'), ('EXPIRED', 'Expired'), ('LEFT', 'Left')], default='PENDING', max_length=8)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('accepted_at', models.DateTimeField(blank=True, null=True)), + ('token_deposited_at', models.DateTimeField(blank=True, null=True)), + ('voice_until', models.DateTimeField(blank=True, null=True)), + ('left_at', models.DateTimeField(blank=True, null=True)), + ('invitee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sea_invites_received', to=settings.AUTH_USER_MODEL)), + ('line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sea_invite', to='billboard.line')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sea_invites_sent', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index 548ebf5..8007645 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -301,3 +301,121 @@ def active_draw_for(user): return MySeaDraw.objects.filter( user=user, created_at__gte=cutoff, ).order_by("-created_at").first() + + +# ── My-Sea bud-invite relationship ────────────────────────────────────── +SEA_INVITE_EXPIRE_HOURS = 24 + + +class SeaInvite(models.Model): + """Single source of truth for a my-sea bud-invite relationship — Phase A + of [[my-sea-invite-voice-blueprint]]. + + my-sea has no Room; everything keys on the owner (`mysea-`). + One SeaInvite row carries the whole lifecycle: the @mailman log Line's + OK/BYE buttons, the owner-table seat-2 glow, the spectator-access guard, + and the WebRTC voice window all derive from this row's status + timestamps. + + Lifecycle: + PENDING → owner invited a bud; @mailman logged a Line w. OK/BYE. + ACCEPTED → bud clicked OK; lands on owner's my-sea as a spectator. + DECLINED → bud clicked BYE on a PENDING invite. + EXPIRED → PENDING lapsed 24h with no token deposited (lazy-computed + on read via `is_expired`; no Celery sweep yet — same shape + as the parked SIG-SELECT timeout). + LEFT → present bud clicked the gear-menu BYE; seat-2 freed, voice + killed. + + Per user-spec a token deposit makes presence persistent — once + `token_deposited_at` is set the invite no longer expires (`is_expired` + requires it to be None). + """ + + PENDING = "PENDING" + ACCEPTED = "ACCEPTED" + DECLINED = "DECLINED" + EXPIRED = "EXPIRED" + LEFT = "LEFT" + STATUS_CHOICES = [ + (PENDING, "Pending"), + (ACCEPTED, "Accepted"), + (DECLINED, "Declined"), + (EXPIRED, "Expired"), + (LEFT, "Left"), + ] + + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="sea_invites_sent", + ) + # Recipient may be unregistered at invite time — resolved to `invitee` on + # accept (or later on registration, mirroring the deferred RoomInvite + # linkage). + invitee_email = models.EmailField() + invitee = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.CASCADE, + related_name="sea_invites_received", + ) + status = models.CharField( + max_length=8, choices=STATUS_CHOICES, default=PENDING, + ) + created_at = models.DateTimeField(default=timezone.now) + accepted_at = models.DateTimeField(null=True, blank=True) + # Token deposit at the visitor gate (Phase B). Setting this makes the + # invite non-expiring + (with ACCEPTED and no left_at) marks seat-2 present. + token_deposited_at = models.DateTimeField(null=True, blank=True) + # Voice window — a 24h slide from the token deposit (Phase C). + voice_until = models.DateTimeField(null=True, blank=True) + left_at = models.DateTimeField(null=True, blank=True) + # The @mailman "Acceptances & rejections" Line whose OK/BYE buttons render + # from this row's status (Phase A5). OneToOne so `line.sea_invite` + # reverse-resolves in post.html; SET_NULL so a Line delete never cascades + # away the relationship row. + line = models.OneToOneField( + "billboard.Line", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="sea_invite", + ) + + class Meta: + ordering = ["-created_at"] + + def __str__(self): + return f"SeaInvite({self.owner_id} → {self.invitee_email}, {self.status})" + + @property + def expires_at(self): + """When a PENDING invite lapses (created_at + 24h).""" + return self.created_at + timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS) + + @property + def is_expired(self): + """True iff this is a stale PENDING invite past its 24h window with no + token deposited. A token deposit makes the invite non-expiring + (user-spec) — presence persists past the original window.""" + return ( + self.status == self.PENDING + and self.token_deposited_at is None + and timezone.now() > self.expires_at + ) + + @property + def voice_active(self): + """True iff the 24h voice window (from token deposit) is still open.""" + return self.voice_until is not None and self.voice_until > timezone.now() + + @property + def is_present(self): + """True iff the invitee currently occupies the owner's seat 2C — they + accepted, deposited a token, and haven't left.""" + return ( + self.status == self.ACCEPTED + and self.token_deposited_at is not None + and self.left_at is None + ) diff --git a/src/apps/gameboard/tests/integrated/test_sea_invite_views.py b/src/apps/gameboard/tests/integrated/test_sea_invite_views.py new file mode 100644 index 0000000..444b77e --- /dev/null +++ b/src/apps/gameboard/tests/integrated/test_sea_invite_views.py @@ -0,0 +1,183 @@ +"""ITs for the my-sea invite endpoints + post.html OK/BYE render — Phase A +(A5/A6) of [[my-sea-invite-voice-blueprint]]. + +Covers the real `my_sea_invite` (replaces the "coming soon" stub), the +`my_sea_invite_accept` / `my_sea_invite_decline` transitions + auth gating, +and the interactive OK/BYE / status-badge render in the invitee's +"Acceptances & rejections" Post. +""" + +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.billboard.mail import log_sea_invite +from apps.billboard.models import Brief, Post +from apps.gameboard.models import SeaInvite +from apps.lyric.models import User + + +class MySeaInviteViewTest(TestCase): + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="discoman") + self.bud = User.objects.create(email="bud@test.io", username="budster") + self.client.force_login(self.owner) + self.url = reverse("my_sea_invite") + + def _invite(self, recipient): + return self.client.post( + self.url, data={"recipient": recipient}, + HTTP_ACCEPT="application/json", + ) + + def test_invite_registered_bud_creates_pending_invite(self): + resp = self._invite("budster") + self.assertEqual(resp.status_code, 200) + invite = SeaInvite.objects.get(owner=self.owner, invitee=self.bud) + self.assertEqual(invite.status, SeaInvite.PENDING) + self.assertEqual(invite.invitee_email, self.bud.email) + + def test_invite_logs_mailman_line_and_brief_for_invitee(self): + self._invite("budster") + post = Post.objects.get(owner=self.bud, kind=Post.KIND_MAIL_ACCEPTANCE) + self.assertEqual(post.lines.count(), 1) + self.assertTrue( + Brief.objects.filter( + owner=self.bud, kind=Brief.KIND_MAIL_ACCEPTANCE, + ).exists() + ) + invite = SeaInvite.objects.get(owner=self.owner, invitee=self.bud) + self.assertEqual(invite.line, post.lines.first()) + + def test_invite_returns_brief_json(self): + resp = self._invite("budster") + data = resp.json() + self.assertIsNotNone(data["brief"]) + self.assertEqual(data["recipient_display"], "budster") + + def test_invite_by_email_resolves_user(self): + self._invite("bud@test.io") + self.assertTrue( + SeaInvite.objects.filter(owner=self.owner, invitee=self.bud).exists() + ) + + def test_duplicate_pending_invite_does_not_create_second(self): + self._invite("budster") + self._invite("budster") + self.assertEqual( + SeaInvite.objects.filter(owner=self.owner, invitee=self.bud).count(), 1 + ) + + def test_self_invite_creates_nothing(self): + self._invite("discoman") + self.assertFalse(SeaInvite.objects.filter(owner=self.owner).exists()) + + def test_empty_recipient_creates_nothing(self): + resp = self._invite("") + self.assertFalse(SeaInvite.objects.exists()) + self.assertIsNone(resp.json()["brief"]) + + +class MySeaInviteAcceptDeclineTest(TestCase): + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="discoman") + self.bud = User.objects.create(email="bud@test.io", username="budster") + self.invite = SeaInvite.objects.create( + owner=self.owner, invitee=self.bud, invitee_email=self.bud.email, + ) + log_sea_invite(self.invite) + self.invite.refresh_from_db() + + def test_invitee_accept_flips_to_accepted(self): + self.client.force_login(self.bud) + resp = self.client.post( + reverse("my_sea_invite_accept", args=[self.invite.id]) + ) + self.invite.refresh_from_db() + self.assertEqual(self.invite.status, SeaInvite.ACCEPTED) + self.assertIsNotNone(self.invite.accepted_at) + self.assertEqual(self.invite.invitee, self.bud) + self.assertEqual(resp.status_code, 302) + + def test_non_invitee_cannot_accept(self): + stranger = User.objects.create(email="x@test.io", username="x") + self.client.force_login(stranger) + resp = self.client.post( + reverse("my_sea_invite_accept", args=[self.invite.id]) + ) + self.invite.refresh_from_db() + self.assertEqual(self.invite.status, SeaInvite.PENDING) + self.assertEqual(resp.status_code, 403) + + def test_invitee_decline_flips_to_declined(self): + self.client.force_login(self.bud) + self.client.post(reverse("my_sea_invite_decline", args=[self.invite.id])) + self.invite.refresh_from_db() + self.assertEqual(self.invite.status, SeaInvite.DECLINED) + + def test_non_invitee_cannot_decline(self): + stranger = User.objects.create(email="x@test.io", username="x") + self.client.force_login(stranger) + resp = self.client.post( + reverse("my_sea_invite_decline", args=[self.invite.id]) + ) + self.invite.refresh_from_db() + self.assertEqual(self.invite.status, SeaInvite.PENDING) + self.assertEqual(resp.status_code, 403) + + def test_expired_invite_cannot_be_accepted(self): + SeaInvite.objects.filter(pk=self.invite.pk).update( + created_at=timezone.now() - timedelta(hours=25) + ) + self.client.force_login(self.bud) + self.client.post(reverse("my_sea_invite_accept", args=[self.invite.id])) + self.invite.refresh_from_db() + self.assertEqual(self.invite.status, SeaInvite.PENDING) # unchanged + + +class MySeaInvitePostRenderTest(TestCase): + """post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING + + a status badge otherwise, all driven by `line.sea_invite.status`.""" + + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="discoman") + self.bud = User.objects.create(email="bud@test.io", username="budster") + self.invite = SeaInvite.objects.create( + owner=self.owner, invitee=self.bud, invitee_email=self.bud.email, + ) + self.post, self.line, _ = log_sea_invite(self.invite) + self.invite.refresh_from_db() + self.client.force_login(self.bud) + self.post_url = reverse("billboard:view_post", args=[self.post.id]) + self.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id]) + self.decline_url = reverse("my_sea_invite_decline", args=[self.invite.id]) + + def test_pending_invite_renders_ok_bye(self): + content = self.client.get(self.post_url).content.decode() + self.assertIn("invite-ok-btn", content) + self.assertIn("invite-bye-btn", content) + self.assertIn(self.accept_url, content) + self.assertIn(self.decline_url, content) + + def test_accepted_invite_renders_badge_not_buttons(self): + self.invite.status = SeaInvite.ACCEPTED + self.invite.accepted_at = timezone.now() + self.invite.save() + content = self.client.get(self.post_url).content.decode() + self.assertIn("Accepted", content) + self.assertNotIn(self.accept_url, content) + + def test_declined_invite_renders_declined_badge(self): + self.invite.status = SeaInvite.DECLINED + self.invite.save() + content = self.client.get(self.post_url).content.decode() + self.assertIn("Declined", content) + self.assertNotIn(self.accept_url, content) + + def test_mailman_line_renders_as_system_with_handles(self): + content = self.client.get(self.post_url).content.decode() + self.assertIn("post-line--system", content) + self.assertIn("@mailman", content) # author handle column + self.assertIn("@discoman", content) # owner handle interpolated in prose diff --git a/src/apps/gameboard/tests/unit/test_sea_invite.py b/src/apps/gameboard/tests/unit/test_sea_invite.py new file mode 100644 index 0000000..22443c3 --- /dev/null +++ b/src/apps/gameboard/tests/unit/test_sea_invite.py @@ -0,0 +1,99 @@ +"""UT for the `SeaInvite` status machine + derived properties — Phase A1 of +[[my-sea-invite-voice-blueprint]]. + +`SeaInvite` is the single source of truth for the my-sea bud-invite +relationship. These tests pin the four derived properties the OK/BYE render, +seat-2 glow, spectator guard, and voice window all read from: + + expires_at — created_at + 24h + is_expired — PENDING only, past expiry, AND no token deposited + voice_active — voice_until set + in the future + is_present — ACCEPTED + token deposited + not yet LEFT + +All assertions run on unsaved instances (no FK / DB touched), so this is a +`SimpleTestCase` — the properties are pure functions of the row's own fields. +""" + +from datetime import timedelta + +from django.test import SimpleTestCase +from django.utils import timezone + +from apps.gameboard.models import SeaInvite + + +class SeaInviteStatusMachineTest(SimpleTestCase): + def _invite(self, **kwargs): + """Build an unsaved SeaInvite with PENDING/now defaults overridable.""" + defaults = {"status": SeaInvite.PENDING, "created_at": timezone.now()} + defaults.update(kwargs) + return SeaInvite(**defaults) + + # ── status default ─────────────────────────────────────────────────── + def test_default_status_is_pending(self): + self.assertEqual(SeaInvite().status, SeaInvite.PENDING) + + # ── expires_at ─────────────────────────────────────────────────────── + def test_expires_at_is_24h_after_created(self): + created = timezone.now() + inv = self._invite(created_at=created) + self.assertEqual(inv.expires_at, created + timedelta(hours=24)) + + # ── is_expired ─────────────────────────────────────────────────────── + def test_is_expired_true_for_stale_pending_without_deposit(self): + inv = self._invite(created_at=timezone.now() - timedelta(hours=25)) + self.assertTrue(inv.is_expired) + + def test_is_expired_false_within_window(self): + inv = self._invite(created_at=timezone.now() - timedelta(hours=1)) + self.assertFalse(inv.is_expired) + + def test_deposit_makes_invite_non_expiring(self): + # Per user-spec: once a token is deposited the invite never expires, + # even though it's well past the 24h PENDING window. + inv = self._invite( + created_at=timezone.now() - timedelta(hours=25), + token_deposited_at=timezone.now() - timedelta(hours=20), + ) + self.assertFalse(inv.is_expired) + + def test_only_pending_invites_can_expire(self): + inv = self._invite( + status=SeaInvite.ACCEPTED, + created_at=timezone.now() - timedelta(hours=25), + ) + self.assertFalse(inv.is_expired) + + # ── voice_active ───────────────────────────────────────────────────── + def test_voice_active_true_when_voice_until_in_future(self): + inv = self._invite(voice_until=timezone.now() + timedelta(hours=1)) + self.assertTrue(inv.voice_active) + + def test_voice_active_false_when_none(self): + self.assertFalse(self._invite().voice_active) + + def test_voice_active_false_when_past(self): + inv = self._invite(voice_until=timezone.now() - timedelta(minutes=1)) + self.assertFalse(inv.voice_active) + + # ── is_present ─────────────────────────────────────────────────────── + def test_is_present_true_when_accepted_deposited_not_left(self): + inv = self._invite( + status=SeaInvite.ACCEPTED, + token_deposited_at=timezone.now(), + ) + self.assertTrue(inv.is_present) + + def test_is_present_false_without_deposit(self): + inv = self._invite(status=SeaInvite.ACCEPTED) + self.assertFalse(inv.is_present) + + def test_is_present_false_after_left(self): + # left_at set (status still ACCEPTED here) isolates the `left_at is + # None` clause of the guard. + inv = self._invite( + status=SeaInvite.ACCEPTED, + token_deposited_at=timezone.now() - timedelta(hours=1), + left_at=timezone.now(), + ) + self.assertFalse(inv.is_present) diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index d72b5e2..43b09b1 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -21,6 +21,10 @@ urlpatterns = [ path('my-sea/refund', views.my_sea_refund_token, name='my_sea_refund_token'), path('my-sea/paid-draw', views.my_sea_paid_draw, name='my_sea_paid_draw'), path('my-sea/invite', views.my_sea_invite, name='my_sea_invite'), + path('my-sea/invite/accept/', views.my_sea_invite_accept, + name='my_sea_invite_accept'), + path('my-sea/invite/decline/', views.my_sea_invite_decline, + name='my_sea_invite_decline'), path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief, name='my_sea_dismiss_free_draw_brief'), path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief, diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 59dfd73..8635311 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -1,7 +1,7 @@ import json from django.contrib.auth.decorators import login_required -from django.http import HttpResponse, JsonResponse +from django.http import HttpResponse, HttpResponseForbidden, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils import timezone from django.views.decorators.http import require_POST @@ -737,23 +737,134 @@ def my_sea_dismiss_paid_draw_brief(request): @login_required(login_url="/") @require_POST def my_sea_invite(request): - """Sprint 6 iter 6c — bud-btn invite stub on my-sea gatekeeper. - Async multi-user invite is deferred to a later sprint; this endpoint - just returns a Brief banner announcing "coming soon" so the bud-btn - panel has a non-broken success path.""" + """Invite a bud to the owner's my-sea table (Phase A of + [[my-sea-invite-voice-blueprint]] — replaces the iter-6c "coming soon" + stub). Resolves the recipient (email OR username) to a registered User + when possible, dedups against an outstanding PENDING/ACCEPTED invite for + the same (owner, invitee_email), creates a SeaInvite(PENDING), and logs + the @mailman "Acceptances & rejections" Line + invitee Brief via + `apps.billboard.mail.log_sea_invite`. + + Returns JSON `{brief, recipient_display}` for the inviter's sent- + confirmation banner (bud-btn.js `onSuccess` renders it). The inviter's + confirmation is a transient banner; the invitee's notification IS + persisted (log_sea_invite spawns the Brief).""" from django.urls import reverse + from apps.billboard.mail import log_sea_invite + from apps.billboard.views import _resolve_recipient + from .models import SeaInvite + + raw = (request.POST.get("recipient") or "").strip() + if not raw: + return JsonResponse({"brief": None, "recipient_display": None}) + + candidate = _resolve_recipient(raw) + if candidate is not None and candidate == request.user: + return JsonResponse({"brief": None, "recipient_display": None}) # no self-invite + + invitee_email = candidate.email if candidate else raw + recipient_display = ( + (candidate.username or candidate.email) if candidate else raw + ) + + # Dedup: an outstanding (PENDING/ACCEPTED) invite for this recipient is + # "already invited". Terminal-state invites (DECLINED/EXPIRED/LEFT) don't + # block a fresh re-invite. + already = SeaInvite.objects.filter( + owner=request.user, + invitee_email=invitee_email, + status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED], + ).exists() + if already: + return JsonResponse({ + "brief": { + "title": "Already invited", + "line_text": f"Look!—{recipient_display} is already invited to your Sea.", + "post_url": reverse("my_sea"), + "created_at": "", + "kind": "NUDGE", + }, + "recipient_display": recipient_display, + }) + + invite = SeaInvite.objects.create( + owner=request.user, + invitee=candidate, + invitee_email=invitee_email, + status=SeaInvite.PENDING, + ) + log_sea_invite(invite) + return JsonResponse({ "brief": { - "title": "Multiplayer my-sea", - "line_text": "Look!—multiplayer my-sea is coming soon. Stay tuned.", - "post_url": reverse("gameboard"), + "title": "Invite sent", + "line_text": f"Look!—your invite is on its way to {recipient_display}.", + "post_url": reverse("my_sea"), "created_at": "", "kind": "NUDGE", }, - "recipient_display": (request.POST.get("recipient") or "").strip(), + "recipient_display": recipient_display, }) +def _sea_invite_for_request(request, invite_id): + """Fetch a SeaInvite + decide whether the requester is its invitee. Matches + on the invitee FK when set, else on email (handles an invite created + before the recipient registered). Returns ``(invite, is_invitee)``.""" + from .models import SeaInvite + invite = get_object_or_404(SeaInvite, id=invite_id) + if invite.invitee_id is not None: + is_invitee = invite.invitee_id == request.user.id + else: + is_invitee = ( + (invite.invitee_email or "").lower() == (request.user.email or "").lower() + ) + return invite, is_invitee + + +def _redirect_to_invite_log(invite): + """Redirect to the invitee's "Acceptances & rejections" Post (where the + invite Line lives) so the OK/BYE line re-renders post-transition. Falls + back to /gameboard/ if the invite has no linked line/post yet.""" + from django.urls import reverse + if invite.line_id and invite.line.post_id: + return redirect(reverse("billboard:view_post", args=[invite.line.post_id])) + return redirect("gameboard") + + +@login_required(login_url="/") +@require_POST +def my_sea_invite_accept(request, invite_id): + """Invitee accepts a PENDING my-sea invite → ACCEPTED. Links the invitee + FK + stamps accepted_at. Phase B will redirect to the owner's spectator + table (`my_sea_visit`); for now we redirect back to the invite log Post so + the line re-renders with its Accepted badge.""" + from .models import SeaInvite + invite, is_invitee = _sea_invite_for_request(request, invite_id) + if not is_invitee: + return HttpResponseForbidden() + if invite.status == SeaInvite.PENDING and not invite.is_expired: + invite.status = SeaInvite.ACCEPTED + invite.accepted_at = timezone.now() + invite.invitee = request.user + invite.save(update_fields=["status", "accepted_at", "invitee"]) + return _redirect_to_invite_log(invite) + + +@login_required(login_url="/") +@require_POST +def my_sea_invite_decline(request, invite_id): + """Invitee declines a PENDING my-sea invite → DECLINED.""" + from .models import SeaInvite + invite, is_invitee = _sea_invite_for_request(request, invite_id) + if not is_invitee: + return HttpResponseForbidden() + if invite.status == SeaInvite.PENDING: + invite.status = SeaInvite.DECLINED + invite.save(update_fields=["status"]) + return _redirect_to_invite_log(invite) + + def _my_sea_deck_data(user, exclude_id=None): """Build the shuffled deck (levity + gravity halves) for the my-sea picker's card-draw mechanic. Card payload shape is whatever diff --git a/src/apps/lyric/migrations/0015_seed_mailman.py b/src/apps/lyric/migrations/0015_seed_mailman.py new file mode 100644 index 0000000..d2800c3 --- /dev/null +++ b/src/apps/lyric/migrations/0015_seed_mailman.py @@ -0,0 +1,38 @@ +# Generated 2026-05-27 — seed the @mailman system user (author of the +# "Acceptances & rejections" my-sea invite log Lines). Mirrors +# `0014`'s seed_taxman / `0003_seed_adman`. Pairs w. `billboard/0009_ +# mail_acceptance_kind` for the new Post/Brief kind registration. + +from django.contrib.auth.hashers import make_password +from django.db import migrations + + +def seed_mailman(apps, schema_editor): + """Mirror `0003_seed_adman` for the @mailman system user — authors the + interactive OK/BYE invite log Lines per [[my-sea-invite-voice-blueprint]].""" + User = apps.get_model("lyric", "User") + User.objects.get_or_create( + username="mailman", + defaults={ + "email": "mailman@earthmanrpg.local", + "password": make_password(None), + "is_staff": False, + "is_superuser": False, + "searchable": False, + }, + ) + + +def reverse_noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("lyric", "0014_brief_dismissal_fields"), + ] + + operations = [ + migrations.RunPython(seed_mailman, reverse_noop), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 7bb4e02..17292c7 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -47,7 +47,7 @@ def resolve_pronouns(pronouns_key): # username and existing tests assign it; revisit if/when other-entity # impersonation becomes a concrete concern. -RESERVED_USERNAMES = frozenset({"adman", "taxman"}) +RESERVED_USERNAMES = frozenset({"adman", "taxman", "mailman"}) def is_reserved_username(name, current_user=None): @@ -99,6 +99,26 @@ def get_or_create_taxman(): return taxman +def get_or_create_mailman(): + """Idempotent fetch of the sitewide `mailman` User — system-author for the + "Acceptances & rejections" invite log Lines (my-sea bud-invite flow, see + [[my-sea-invite-voice-blueprint]]). Parallels `get_or_create_taxman` + exactly; production migration `lyric/0015_seed_mailman` seeds the row once, + this helper backstops TransactionTestCase flushes.""" + from django.contrib.auth.hashers import make_password + mailman, _ = User.objects.get_or_create( + username="mailman", + defaults={ + "email": "mailman@earthmanrpg.local", + "password": make_password(None), + "is_staff": False, + "is_superuser": False, + "searchable": False, + }, + ) + return mailman + + class UserManager(BaseUserManager): def create_user(self, email): user = self.model(email=email) diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index fb64d1b..172e4f0 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -1783,12 +1783,12 @@ class MySeaSeatOnePersistenceTest(FunctionalTest): self.assertNotIn("seated", seat1.get_attribute("class")) -class MySeaBudBtnStubTest(FunctionalTest): - """Sprint 6 iter 6c — bud-btn invite panel rendered on the - gatekeeper. Panel opens + autocomplete works (reuses billboard: - search_buds), but the OK btn is a no-op stub — POSTs return a - 'Multiplayer my-sea coming soon' Brief banner. Async invite is - deferred to a future sprint.""" +class MySeaBudBtnInviteTest(FunctionalTest): + """bud-btn invite panel on the my-sea gatekeeper. Panel opens + + autocomplete works (reuses billboard:search_buds); the OK btn now sends a + REAL invite (Phase A of [[my-sea-invite-voice-blueprint]]) — POSTs create + a SeaInvite + return an "invite sent" Brief banner. (Was a coming-soon + stub through iter 6c.)""" def setUp(self): super().setUp() @@ -1820,9 +1820,10 @@ class MySeaBudBtnStubTest(FunctionalTest): ) self.browser.find_element(By.ID, "id_recipient") - def test_bud_btn_ok_renders_coming_soon_brief(self): + def test_bud_btn_ok_sends_real_invite(self): from apps.lyric.models import User as _U - # Seed a friend so the OK click has a recipient to "invite". + from apps.gameboard.models import SeaInvite + # Seed a friend so the OK click has a recipient to invite. _U.objects.create(email="friend@test.io") self.create_pre_authenticated_session(self.email) self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/") @@ -1847,11 +1848,47 @@ class MySeaBudBtnStubTest(FunctionalTest): self.browser.find_element( By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm" ).click() - # Brief banner appears w. coming-soon copy. + # Brief banner confirms the invite is on its way. brief = self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner") ) - self.assertIn("coming soon", brief.text.lower()) + self.assertIn("on its way", brief.text.lower()) + # A real SeaInvite row now exists for the invited friend. + self.assertTrue( + SeaInvite.objects.filter(invitee_email="friend@test.io").exists() + ) + + +class MySeaInviteAcceptanceLogTest(FunctionalTest): + """Phase A of [[my-sea-invite-voice-blueprint]] — an invited bud opens + their @mailman "Acceptances & rejections" Post and sees the invite line + with interactive OK / BYE buttons.""" + + def setUp(self): + super().setUp() + self.email = "invited_bud@test.io" + self.invitee = User.objects.create(email=self.email) + + def test_invitee_sees_invite_line_with_ok_bye(self): + from django.urls import reverse + from apps.billboard.mail import log_sea_invite + from apps.gameboard.models import SeaInvite + owner = User.objects.create(email="owner@test.io", username="discoman") + invite = SeaInvite.objects.create( + owner=owner, invitee=self.invitee, invitee_email=self.email, + ) + post, _, _ = log_sea_invite(invite) + self.create_pre_authenticated_session(self.email) + self.browser.get( + self.live_server_url + reverse("billboard:view_post", args=[post.id]) + ) + body = self.browser.find_element(By.TAG_NAME, "body") + # @mailman prose + interactive OK/BYE both render on the invite line. + self.wait_for(lambda: self.assertIn("invites you to", body.text)) + ok = self.browser.find_element(By.CSS_SELECTOR, ".invite-ok-btn") + bye = self.browser.find_element(By.CSS_SELECTOR, ".invite-bye-btn") + self.assertEqual(ok.text.upper(), "OK") + self.assertEqual(bye.text.upper(), "BYE") class MySeaGearBtnTest(FunctionalTest): diff --git a/src/templates/apps/billboard/_partials/_invite_actions.html b/src/templates/apps/billboard/_partials/_invite_actions.html new file mode 100644 index 0000000..2d32fdf --- /dev/null +++ b/src/templates/apps/billboard/_partials/_invite_actions.html @@ -0,0 +1,31 @@ +{% comment %} + Interactive OK/BYE block for a @mailman invite Line — Phase A of the my-sea + invite flow ([[my-sea-invite-voice-blueprint]]). Renders entirely from + `line.sea_invite.status`; the {% if line.sea_invite %} guard lives in + post.html so this partial is only reached for invite Lines. + + PENDING (not expired) → OK / BYE form buttons (POST accept / decline). + ACCEPTED → "Accepted {date}" badge. (VISIT link to the owner's table is + added in Phase B once `my_sea_visit` exists.) + DECLINED → "Declined" · LEFT → "Left {date}" · else → "Expired". +{% endcomment %} + +{% if line.sea_invite.status == 'PENDING' and not line.sea_invite.is_expired %} +
+ {% csrf_token %} + +
+
+ {% csrf_token %} + +
+{% elif line.sea_invite.status == 'ACCEPTED' %} + Accepted {{ line.sea_invite.accepted_at|date:'M j' }} +{% elif line.sea_invite.status == 'DECLINED' %} + Declined +{% elif line.sea_invite.status == 'LEFT' %} + Left {{ line.sea_invite.left_at|date:'M j' }} +{% else %} + Expired +{% endif %} +
diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html index eec91c8..949cbd5 100644 --- a/src/templates/apps/billboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -39,10 +39,14 @@