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

@@ -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'],
},
),
]

View File

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

View 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

View 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)

View File

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

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