my-sea bud-invite Phase A: SeaInvite model + @mailman log + OK/BYE accept/decline — TDD
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 <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
92
src/apps/billboard/mail.py
Normal file
92
src/apps/billboard/mail.py
Normal file
@@ -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
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
185
src/apps/billboard/tests/integrated/test_mail.py
Normal file
185
src/apps/billboard/tests/integrated/test_mail.py
Normal file
@@ -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))
|
||||
37
src/apps/gameboard/migrations/0004_seainvite.py
Normal file
37
src/apps/gameboard/migrations/0004_seainvite.py
Normal file
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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-<owner_id>`).
|
||||
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
|
||||
)
|
||||
|
||||
183
src/apps/gameboard/tests/integrated/test_sea_invite_views.py
Normal file
183
src/apps/gameboard/tests/integrated/test_sea_invite_views.py
Normal file
@@ -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
|
||||
99
src/apps/gameboard/tests/unit/test_sea_invite.py
Normal file
99
src/apps/gameboard/tests/unit/test_sea_invite.py
Normal file
@@ -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)
|
||||
@@ -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/<int:invite_id>', views.my_sea_invite_accept,
|
||||
name='my_sea_invite_accept'),
|
||||
path('my-sea/invite/decline/<int:invite_id>', 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
38
src/apps/lyric/migrations/0015_seed_mailman.py
Normal file
38
src/apps/lyric/migrations/0015_seed_mailman.py
Normal file
@@ -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),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
31
src/templates/apps/billboard/_partials/_invite_actions.html
Normal file
31
src/templates/apps/billboard/_partials/_invite_actions.html
Normal file
@@ -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 %}
|
||||
<span class="invite-actions invite-actions--{{ line.sea_invite.status|lower }}">
|
||||
{% if line.sea_invite.status == 'PENDING' and not line.sea_invite.is_expired %}
|
||||
<form class="invite-action-form" method="POST" action="{% url 'my_sea_invite_accept' line.sea_invite.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-confirm invite-ok-btn">OK</button>
|
||||
</form>
|
||||
<form class="invite-action-form" method="POST" action="{% url 'my_sea_invite_decline' line.sea_invite.id %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-abandon invite-bye-btn">BYE</button>
|
||||
</form>
|
||||
{% elif line.sea_invite.status == 'ACCEPTED' %}
|
||||
<span class="invite-badge invite-badge--accepted">Accepted {{ line.sea_invite.accepted_at|date:'M j' }}</span>
|
||||
{% elif line.sea_invite.status == 'DECLINED' %}
|
||||
<span class="invite-badge invite-badge--declined">Declined</span>
|
||||
{% elif line.sea_invite.status == 'LEFT' %}
|
||||
<span class="invite-badge invite-badge--left">Left {{ line.sea_invite.left_at|date:'M j' }}</span>
|
||||
{% else %}
|
||||
<span class="invite-badge invite-badge--expired">Expired</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -39,10 +39,14 @@
|
||||
|
||||
<ul id="id_post_table" class="post-lines">
|
||||
{% for line in post.lines.all %}
|
||||
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' %}post-line--system{% endif %}">
|
||||
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}post-line--system{% endif %}">
|
||||
<span class="post-line-author">{{ line.author|at_handle }}</span>
|
||||
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
|
||||
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. `display_text` strips the `[<iso timestamp>] ` prefix that tax-ledger Lines carry to satisfy `unique_together = (post, text)` — the per-line `created_at` timestamp on the right renders the user-facing moment. #}{% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
|
||||
<time class="post-line-time" datetime="{{ line.created_at|date:'c' }}">{{ line.created_at|relative_ts }}</time>
|
||||
{# @mailman invite Lines carry an OK/BYE action block driven by #}
|
||||
{# the linked SeaInvite's status (my-sea invite flow). Non-invite #}
|
||||
{# system + user Lines have no `sea_invite`, so this is skipped. #}
|
||||
{% if line.sea_invite %}{% include "apps/billboard/_partials/_invite_actions.html" %}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li class="post-line-buffer" aria-hidden="true"></li>
|
||||
@@ -52,7 +56,7 @@
|
||||
{# the user can't respond, and the placeholder calls that out. View_ #}
|
||||
{# post hard-rejects POSTs to these kinds; the post_save Line signal #}
|
||||
{# is the safety net for ORM-level / API writes that bypass the view. #}
|
||||
{% if post.kind == 'note_unlock' or post.kind == 'tax_ledger' %}
|
||||
{% if post.kind == 'note_unlock' or post.kind == 'tax_ledger' or post.kind == 'mail_acceptance' %}
|
||||
<form id="id_post_line_form" class="post-line-form">
|
||||
<input
|
||||
id="id_post_line_text"
|
||||
@@ -85,7 +89,7 @@
|
||||
{# Bud btn (bottom-left) + slide-out recipient field — async share. #}
|
||||
{# Suppressed on system-author Posts (note unlock + tax ledger threads) #}
|
||||
{# since friend-invites don't apply to system-authored threads. #}
|
||||
{% if post.kind != 'note_unlock' and post.kind != 'tax_ledger' %}
|
||||
{% if post.kind != 'note_unlock' and post.kind != 'tax_ledger' and post.kind != 'mail_acceptance' %}
|
||||
{% include "apps/billboard/_partials/_bud_panel.html" %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user