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:
Disco DeDisco
2026-05-27 13:14:06 -04:00
parent 1c799d35ca
commit fb8563eed2
15 changed files with 1037 additions and 38 deletions

View File

@@ -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