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
|
NOTE_UNLOCK_POST_TITLE_HINT = "Notes & recognitions" # see drama.NOTE_UNLOCK_POST_TITLE; copy lives there
|
||||||
TAX_LEDGER_POST_TITLE = "Debits & credits"
|
TAX_LEDGER_POST_TITLE = "Debits & credits"
|
||||||
|
MAIL_ACCEPTANCE_POST_TITLE = "Acceptances & rejections"
|
||||||
|
|
||||||
|
|
||||||
class Post(models.Model):
|
class Post(models.Model):
|
||||||
@@ -20,11 +21,18 @@ class Post(models.Model):
|
|||||||
# `apps.billboard.tax.log_tax_debit`. Mirrors the NOTE_UNLOCK Post pattern:
|
# `apps.billboard.tax.log_tax_debit`. Mirrors the NOTE_UNLOCK Post pattern:
|
||||||
# one Post per user, system-authored, readonly textarea in post.html.
|
# one Post per user, system-authored, readonly textarea in post.html.
|
||||||
KIND_TAX_LEDGER = "tax_ledger"
|
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_CHOICES = [
|
||||||
(KIND_NOTE_UNLOCK, "Note unlocks"),
|
(KIND_NOTE_UNLOCK, "Note unlocks"),
|
||||||
(KIND_USER_POST, "User post"),
|
(KIND_USER_POST, "User post"),
|
||||||
(KIND_SHARE_INVITE, "Share invites"),
|
(KIND_SHARE_INVITE, "Share invites"),
|
||||||
(KIND_TAX_LEDGER, "Debits & credits"),
|
(KIND_TAX_LEDGER, "Debits & credits"),
|
||||||
|
(KIND_MAIL_ACCEPTANCE, "Acceptances & rejections"),
|
||||||
]
|
]
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
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
|
# brief endpoint so the dismissal persists per-cycle (see
|
||||||
# `dismiss_url` in `to_banner_dict`).
|
# `dismiss_url` in `to_banner_dict`).
|
||||||
KIND_TAX_LEDGER = "tax_ledger"
|
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_CHOICES = [
|
||||||
(KIND_NOTE_UNLOCK, "Note unlock"),
|
(KIND_NOTE_UNLOCK, "Note unlock"),
|
||||||
(KIND_USER_POST, "User post"),
|
(KIND_USER_POST, "User post"),
|
||||||
(KIND_SHARE_INVITE, "Share invite"),
|
(KIND_SHARE_INVITE, "Share invite"),
|
||||||
(KIND_GAME_INVITE, "Game invite"),
|
(KIND_GAME_INVITE, "Game invite"),
|
||||||
(KIND_TAX_LEDGER, "Tax ledger"),
|
(KIND_TAX_LEDGER, "Tax ledger"),
|
||||||
|
(KIND_MAIL_ACCEPTANCE, "Mail acceptance"),
|
||||||
]
|
]
|
||||||
|
|
||||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
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 ──────────────
|
# ── Listener: nuke unsolicited Lines on system-author Posts ──────────────
|
||||||
# Defense-in-depth alongside view_post's POST guard. A Line saved on a
|
# 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
|
# NOTE_UNLOCK / TAX_LEDGER / MAIL_ACCEPTANCE Post that lacks
|
||||||
# ORM-level write or an API path that bypasses the view) gets deleted right
|
# admin_solicited=True (e.g. a stray ORM-level write or an API path that
|
||||||
# after the save. `Note.grant_if_new` + `apps.billboard.tax.log_tax_debit`
|
# bypasses the view) gets deleted right after the save. `Note.grant_if_new`,
|
||||||
# both set admin_solicited=True on their Lines so legitimate system prose
|
# `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.
|
# 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)
|
@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(
|
return MySeaDraw.objects.filter(
|
||||||
user=user, created_at__gte=cutoff,
|
user=user, created_at__gte=cutoff,
|
||||||
).order_by("-created_at").first()
|
).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/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/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', 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,
|
path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief,
|
||||||
name='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,
|
path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_POST
|
from django.views.decorators.http import require_POST
|
||||||
@@ -737,23 +737,134 @@ def my_sea_dismiss_paid_draw_brief(request):
|
|||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@require_POST
|
@require_POST
|
||||||
def my_sea_invite(request):
|
def my_sea_invite(request):
|
||||||
"""Sprint 6 iter 6c — bud-btn invite stub on my-sea gatekeeper.
|
"""Invite a bud to the owner's my-sea table (Phase A of
|
||||||
Async multi-user invite is deferred to a later sprint; this endpoint
|
[[my-sea-invite-voice-blueprint]] — replaces the iter-6c "coming soon"
|
||||||
just returns a Brief banner announcing "coming soon" so the bud-btn
|
stub). Resolves the recipient (email OR username) to a registered User
|
||||||
panel has a non-broken success path."""
|
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 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({
|
return JsonResponse({
|
||||||
"brief": {
|
"brief": {
|
||||||
"title": "Multiplayer my-sea",
|
"title": "Invite sent",
|
||||||
"line_text": "Look!—multiplayer my-sea is coming soon. Stay tuned.",
|
"line_text": f"Look!—your invite is on its way to {recipient_display}.",
|
||||||
"post_url": reverse("gameboard"),
|
"post_url": reverse("my_sea"),
|
||||||
"created_at": "",
|
"created_at": "",
|
||||||
"kind": "NUDGE",
|
"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):
|
def _my_sea_deck_data(user, exclude_id=None):
|
||||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||||
picker's card-draw mechanic. Card payload shape is whatever
|
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
|
# username and existing tests assign it; revisit if/when other-entity
|
||||||
# impersonation becomes a concrete concern.
|
# impersonation becomes a concrete concern.
|
||||||
|
|
||||||
RESERVED_USERNAMES = frozenset({"adman", "taxman"})
|
RESERVED_USERNAMES = frozenset({"adman", "taxman", "mailman"})
|
||||||
|
|
||||||
|
|
||||||
def is_reserved_username(name, current_user=None):
|
def is_reserved_username(name, current_user=None):
|
||||||
@@ -99,6 +99,26 @@ def get_or_create_taxman():
|
|||||||
return 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):
|
class UserManager(BaseUserManager):
|
||||||
def create_user(self, email):
|
def create_user(self, email):
|
||||||
user = self.model(email=email)
|
user = self.model(email=email)
|
||||||
|
|||||||
@@ -1783,12 +1783,12 @@ class MySeaSeatOnePersistenceTest(FunctionalTest):
|
|||||||
self.assertNotIn("seated", seat1.get_attribute("class"))
|
self.assertNotIn("seated", seat1.get_attribute("class"))
|
||||||
|
|
||||||
|
|
||||||
class MySeaBudBtnStubTest(FunctionalTest):
|
class MySeaBudBtnInviteTest(FunctionalTest):
|
||||||
"""Sprint 6 iter 6c — bud-btn invite panel rendered on the
|
"""bud-btn invite panel on the my-sea gatekeeper. Panel opens +
|
||||||
gatekeeper. Panel opens + autocomplete works (reuses billboard:
|
autocomplete works (reuses billboard:search_buds); the OK btn now sends a
|
||||||
search_buds), but the OK btn is a no-op stub — POSTs return a
|
REAL invite (Phase A of [[my-sea-invite-voice-blueprint]]) — POSTs create
|
||||||
'Multiplayer my-sea coming soon' Brief banner. Async invite is
|
a SeaInvite + return an "invite sent" Brief banner. (Was a coming-soon
|
||||||
deferred to a future sprint."""
|
stub through iter 6c.)"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
@@ -1820,9 +1820,10 @@ class MySeaBudBtnStubTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
self.browser.find_element(By.ID, "id_recipient")
|
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
|
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")
|
_U.objects.create(email="friend@test.io")
|
||||||
self.create_pre_authenticated_session(self.email)
|
self.create_pre_authenticated_session(self.email)
|
||||||
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
|
||||||
@@ -1847,11 +1848,47 @@ class MySeaBudBtnStubTest(FunctionalTest):
|
|||||||
self.browser.find_element(
|
self.browser.find_element(
|
||||||
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
|
||||||
).click()
|
).click()
|
||||||
# Brief banner appears w. coming-soon copy.
|
# Brief banner confirms the invite is on its way.
|
||||||
brief = self.wait_for(
|
brief = self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
|
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):
|
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">
|
<ul id="id_post_table" class="post-lines">
|
||||||
{% for line in post.lines.all %}
|
{% 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-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>
|
<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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<li class="post-line-buffer" aria-hidden="true"></li>
|
<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_ #}
|
{# the user can't respond, and the placeholder calls that out. View_ #}
|
||||||
{# post hard-rejects POSTs to these kinds; the post_save Line signal #}
|
{# 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. #}
|
{# 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">
|
<form id="id_post_line_form" class="post-line-form">
|
||||||
<input
|
<input
|
||||||
id="id_post_line_text"
|
id="id_post_line_text"
|
||||||
@@ -85,7 +89,7 @@
|
|||||||
{# Bud btn (bottom-left) + slide-out recipient field — async share. #}
|
{# Bud btn (bottom-left) + slide-out recipient field — async share. #}
|
||||||
{# Suppressed on system-author Posts (note unlock + tax ledger threads) #}
|
{# Suppressed on system-author Posts (note unlock + tax ledger threads) #}
|
||||||
{# since friend-invites don't apply to system-authored 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" %}
|
{% include "apps/billboard/_partials/_bud_panel.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user