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:
37
src/apps/gameboard/migrations/0004_seainvite.py
Normal file
37
src/apps/gameboard/migrations/0004_seainvite.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 6.0 on 2026-05-27 15:59
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('billboard', '0008_tax_ledger_kind'),
|
||||
('gameboard', '0003_myseadraw_paid_through_at'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SeaInvite',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('invitee_email', models.EmailField(max_length=254)),
|
||||
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('DECLINED', 'Declined'), ('EXPIRED', 'Expired'), ('LEFT', 'Left')], default='PENDING', max_length=8)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('accepted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('token_deposited_at', models.DateTimeField(blank=True, null=True)),
|
||||
('voice_until', models.DateTimeField(blank=True, null=True)),
|
||||
('left_at', models.DateTimeField(blank=True, null=True)),
|
||||
('invitee', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sea_invites_received', to=settings.AUTH_USER_MODEL)),
|
||||
('line', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sea_invite', to='billboard.line')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sea_invites_sent', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -301,3 +301,121 @@ def active_draw_for(user):
|
||||
return MySeaDraw.objects.filter(
|
||||
user=user, created_at__gte=cutoff,
|
||||
).order_by("-created_at").first()
|
||||
|
||||
|
||||
# ── My-Sea bud-invite relationship ──────────────────────────────────────
|
||||
SEA_INVITE_EXPIRE_HOURS = 24
|
||||
|
||||
|
||||
class SeaInvite(models.Model):
|
||||
"""Single source of truth for a my-sea bud-invite relationship — Phase A
|
||||
of [[my-sea-invite-voice-blueprint]].
|
||||
|
||||
my-sea has no Room; everything keys on the owner (`mysea-<owner_id>`).
|
||||
One SeaInvite row carries the whole lifecycle: the @mailman log Line's
|
||||
OK/BYE buttons, the owner-table seat-2 glow, the spectator-access guard,
|
||||
and the WebRTC voice window all derive from this row's status + timestamps.
|
||||
|
||||
Lifecycle:
|
||||
PENDING → owner invited a bud; @mailman logged a Line w. OK/BYE.
|
||||
ACCEPTED → bud clicked OK; lands on owner's my-sea as a spectator.
|
||||
DECLINED → bud clicked BYE on a PENDING invite.
|
||||
EXPIRED → PENDING lapsed 24h with no token deposited (lazy-computed
|
||||
on read via `is_expired`; no Celery sweep yet — same shape
|
||||
as the parked SIG-SELECT timeout).
|
||||
LEFT → present bud clicked the gear-menu BYE; seat-2 freed, voice
|
||||
killed.
|
||||
|
||||
Per user-spec a token deposit makes presence persistent — once
|
||||
`token_deposited_at` is set the invite no longer expires (`is_expired`
|
||||
requires it to be None).
|
||||
"""
|
||||
|
||||
PENDING = "PENDING"
|
||||
ACCEPTED = "ACCEPTED"
|
||||
DECLINED = "DECLINED"
|
||||
EXPIRED = "EXPIRED"
|
||||
LEFT = "LEFT"
|
||||
STATUS_CHOICES = [
|
||||
(PENDING, "Pending"),
|
||||
(ACCEPTED, "Accepted"),
|
||||
(DECLINED, "Declined"),
|
||||
(EXPIRED, "Expired"),
|
||||
(LEFT, "Left"),
|
||||
]
|
||||
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="sea_invites_sent",
|
||||
)
|
||||
# Recipient may be unregistered at invite time — resolved to `invitee` on
|
||||
# accept (or later on registration, mirroring the deferred RoomInvite
|
||||
# linkage).
|
||||
invitee_email = models.EmailField()
|
||||
invitee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="sea_invites_received",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=8, choices=STATUS_CHOICES, default=PENDING,
|
||||
)
|
||||
created_at = models.DateTimeField(default=timezone.now)
|
||||
accepted_at = models.DateTimeField(null=True, blank=True)
|
||||
# Token deposit at the visitor gate (Phase B). Setting this makes the
|
||||
# invite non-expiring + (with ACCEPTED and no left_at) marks seat-2 present.
|
||||
token_deposited_at = models.DateTimeField(null=True, blank=True)
|
||||
# Voice window — a 24h slide from the token deposit (Phase C).
|
||||
voice_until = models.DateTimeField(null=True, blank=True)
|
||||
left_at = models.DateTimeField(null=True, blank=True)
|
||||
# The @mailman "Acceptances & rejections" Line whose OK/BYE buttons render
|
||||
# from this row's status (Phase A5). OneToOne so `line.sea_invite`
|
||||
# reverse-resolves in post.html; SET_NULL so a Line delete never cascades
|
||||
# away the relationship row.
|
||||
line = models.OneToOneField(
|
||||
"billboard.Line",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="sea_invite",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"SeaInvite({self.owner_id} → {self.invitee_email}, {self.status})"
|
||||
|
||||
@property
|
||||
def expires_at(self):
|
||||
"""When a PENDING invite lapses (created_at + 24h)."""
|
||||
return self.created_at + timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS)
|
||||
|
||||
@property
|
||||
def is_expired(self):
|
||||
"""True iff this is a stale PENDING invite past its 24h window with no
|
||||
token deposited. A token deposit makes the invite non-expiring
|
||||
(user-spec) — presence persists past the original window."""
|
||||
return (
|
||||
self.status == self.PENDING
|
||||
and self.token_deposited_at is None
|
||||
and timezone.now() > self.expires_at
|
||||
)
|
||||
|
||||
@property
|
||||
def voice_active(self):
|
||||
"""True iff the 24h voice window (from token deposit) is still open."""
|
||||
return self.voice_until is not None and self.voice_until > timezone.now()
|
||||
|
||||
@property
|
||||
def is_present(self):
|
||||
"""True iff the invitee currently occupies the owner's seat 2C — they
|
||||
accepted, deposited a token, and haven't left."""
|
||||
return (
|
||||
self.status == self.ACCEPTED
|
||||
and self.token_deposited_at is not None
|
||||
and self.left_at is None
|
||||
)
|
||||
|
||||
183
src/apps/gameboard/tests/integrated/test_sea_invite_views.py
Normal file
183
src/apps/gameboard/tests/integrated/test_sea_invite_views.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""ITs for the my-sea invite endpoints + post.html OK/BYE render — Phase A
|
||||
(A5/A6) of [[my-sea-invite-voice-blueprint]].
|
||||
|
||||
Covers the real `my_sea_invite` (replaces the "coming soon" stub), the
|
||||
`my_sea_invite_accept` / `my_sea_invite_decline` transitions + auth gating,
|
||||
and the interactive OK/BYE / status-badge render in the invitee's
|
||||
"Acceptances & rejections" Post.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.billboard.mail import log_sea_invite
|
||||
from apps.billboard.models import Brief, Post
|
||||
from apps.gameboard.models import SeaInvite
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class MySeaInviteViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.client.force_login(self.owner)
|
||||
self.url = reverse("my_sea_invite")
|
||||
|
||||
def _invite(self, recipient):
|
||||
return self.client.post(
|
||||
self.url, data={"recipient": recipient},
|
||||
HTTP_ACCEPT="application/json",
|
||||
)
|
||||
|
||||
def test_invite_registered_bud_creates_pending_invite(self):
|
||||
resp = self._invite("budster")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
invite = SeaInvite.objects.get(owner=self.owner, invitee=self.bud)
|
||||
self.assertEqual(invite.status, SeaInvite.PENDING)
|
||||
self.assertEqual(invite.invitee_email, self.bud.email)
|
||||
|
||||
def test_invite_logs_mailman_line_and_brief_for_invitee(self):
|
||||
self._invite("budster")
|
||||
post = Post.objects.get(owner=self.bud, kind=Post.KIND_MAIL_ACCEPTANCE)
|
||||
self.assertEqual(post.lines.count(), 1)
|
||||
self.assertTrue(
|
||||
Brief.objects.filter(
|
||||
owner=self.bud, kind=Brief.KIND_MAIL_ACCEPTANCE,
|
||||
).exists()
|
||||
)
|
||||
invite = SeaInvite.objects.get(owner=self.owner, invitee=self.bud)
|
||||
self.assertEqual(invite.line, post.lines.first())
|
||||
|
||||
def test_invite_returns_brief_json(self):
|
||||
resp = self._invite("budster")
|
||||
data = resp.json()
|
||||
self.assertIsNotNone(data["brief"])
|
||||
self.assertEqual(data["recipient_display"], "budster")
|
||||
|
||||
def test_invite_by_email_resolves_user(self):
|
||||
self._invite("bud@test.io")
|
||||
self.assertTrue(
|
||||
SeaInvite.objects.filter(owner=self.owner, invitee=self.bud).exists()
|
||||
)
|
||||
|
||||
def test_duplicate_pending_invite_does_not_create_second(self):
|
||||
self._invite("budster")
|
||||
self._invite("budster")
|
||||
self.assertEqual(
|
||||
SeaInvite.objects.filter(owner=self.owner, invitee=self.bud).count(), 1
|
||||
)
|
||||
|
||||
def test_self_invite_creates_nothing(self):
|
||||
self._invite("discoman")
|
||||
self.assertFalse(SeaInvite.objects.filter(owner=self.owner).exists())
|
||||
|
||||
def test_empty_recipient_creates_nothing(self):
|
||||
resp = self._invite("")
|
||||
self.assertFalse(SeaInvite.objects.exists())
|
||||
self.assertIsNone(resp.json()["brief"])
|
||||
|
||||
|
||||
class MySeaInviteAcceptDeclineTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
)
|
||||
log_sea_invite(self.invite)
|
||||
self.invite.refresh_from_db()
|
||||
|
||||
def test_invitee_accept_flips_to_accepted(self):
|
||||
self.client.force_login(self.bud)
|
||||
resp = self.client.post(
|
||||
reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.ACCEPTED)
|
||||
self.assertIsNotNone(self.invite.accepted_at)
|
||||
self.assertEqual(self.invite.invitee, self.bud)
|
||||
self.assertEqual(resp.status_code, 302)
|
||||
|
||||
def test_non_invitee_cannot_accept(self):
|
||||
stranger = User.objects.create(email="x@test.io", username="x")
|
||||
self.client.force_login(stranger)
|
||||
resp = self.client.post(
|
||||
reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.PENDING)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_invitee_decline_flips_to_declined(self):
|
||||
self.client.force_login(self.bud)
|
||||
self.client.post(reverse("my_sea_invite_decline", args=[self.invite.id]))
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.DECLINED)
|
||||
|
||||
def test_non_invitee_cannot_decline(self):
|
||||
stranger = User.objects.create(email="x@test.io", username="x")
|
||||
self.client.force_login(stranger)
|
||||
resp = self.client.post(
|
||||
reverse("my_sea_invite_decline", args=[self.invite.id])
|
||||
)
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.PENDING)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_expired_invite_cannot_be_accepted(self):
|
||||
SeaInvite.objects.filter(pk=self.invite.pk).update(
|
||||
created_at=timezone.now() - timedelta(hours=25)
|
||||
)
|
||||
self.client.force_login(self.bud)
|
||||
self.client.post(reverse("my_sea_invite_accept", args=[self.invite.id]))
|
||||
self.invite.refresh_from_db()
|
||||
self.assertEqual(self.invite.status, SeaInvite.PENDING) # unchanged
|
||||
|
||||
|
||||
class MySeaInvitePostRenderTest(TestCase):
|
||||
"""post.html (A5) — the @mailman invite Line renders OK/BYE for PENDING +
|
||||
a status badge otherwise, all driven by `line.sea_invite.status`."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@test.io", username="discoman")
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
self.invite = SeaInvite.objects.create(
|
||||
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
|
||||
)
|
||||
self.post, self.line, _ = log_sea_invite(self.invite)
|
||||
self.invite.refresh_from_db()
|
||||
self.client.force_login(self.bud)
|
||||
self.post_url = reverse("billboard:view_post", args=[self.post.id])
|
||||
self.accept_url = reverse("my_sea_invite_accept", args=[self.invite.id])
|
||||
self.decline_url = reverse("my_sea_invite_decline", args=[self.invite.id])
|
||||
|
||||
def test_pending_invite_renders_ok_bye(self):
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("invite-ok-btn", content)
|
||||
self.assertIn("invite-bye-btn", content)
|
||||
self.assertIn(self.accept_url, content)
|
||||
self.assertIn(self.decline_url, content)
|
||||
|
||||
def test_accepted_invite_renders_badge_not_buttons(self):
|
||||
self.invite.status = SeaInvite.ACCEPTED
|
||||
self.invite.accepted_at = timezone.now()
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Accepted", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_declined_invite_renders_declined_badge(self):
|
||||
self.invite.status = SeaInvite.DECLINED
|
||||
self.invite.save()
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("Declined", content)
|
||||
self.assertNotIn(self.accept_url, content)
|
||||
|
||||
def test_mailman_line_renders_as_system_with_handles(self):
|
||||
content = self.client.get(self.post_url).content.decode()
|
||||
self.assertIn("post-line--system", content)
|
||||
self.assertIn("@mailman", content) # author handle column
|
||||
self.assertIn("@discoman", content) # owner handle interpolated in prose
|
||||
99
src/apps/gameboard/tests/unit/test_sea_invite.py
Normal file
99
src/apps/gameboard/tests/unit/test_sea_invite.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""UT for the `SeaInvite` status machine + derived properties — Phase A1 of
|
||||
[[my-sea-invite-voice-blueprint]].
|
||||
|
||||
`SeaInvite` is the single source of truth for the my-sea bud-invite
|
||||
relationship. These tests pin the four derived properties the OK/BYE render,
|
||||
seat-2 glow, spectator guard, and voice window all read from:
|
||||
|
||||
expires_at — created_at + 24h
|
||||
is_expired — PENDING only, past expiry, AND no token deposited
|
||||
voice_active — voice_until set + in the future
|
||||
is_present — ACCEPTED + token deposited + not yet LEFT
|
||||
|
||||
All assertions run on unsaved instances (no FK / DB touched), so this is a
|
||||
`SimpleTestCase` — the properties are pure functions of the row's own fields.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.gameboard.models import SeaInvite
|
||||
|
||||
|
||||
class SeaInviteStatusMachineTest(SimpleTestCase):
|
||||
def _invite(self, **kwargs):
|
||||
"""Build an unsaved SeaInvite with PENDING/now defaults overridable."""
|
||||
defaults = {"status": SeaInvite.PENDING, "created_at": timezone.now()}
|
||||
defaults.update(kwargs)
|
||||
return SeaInvite(**defaults)
|
||||
|
||||
# ── status default ───────────────────────────────────────────────────
|
||||
def test_default_status_is_pending(self):
|
||||
self.assertEqual(SeaInvite().status, SeaInvite.PENDING)
|
||||
|
||||
# ── expires_at ───────────────────────────────────────────────────────
|
||||
def test_expires_at_is_24h_after_created(self):
|
||||
created = timezone.now()
|
||||
inv = self._invite(created_at=created)
|
||||
self.assertEqual(inv.expires_at, created + timedelta(hours=24))
|
||||
|
||||
# ── is_expired ───────────────────────────────────────────────────────
|
||||
def test_is_expired_true_for_stale_pending_without_deposit(self):
|
||||
inv = self._invite(created_at=timezone.now() - timedelta(hours=25))
|
||||
self.assertTrue(inv.is_expired)
|
||||
|
||||
def test_is_expired_false_within_window(self):
|
||||
inv = self._invite(created_at=timezone.now() - timedelta(hours=1))
|
||||
self.assertFalse(inv.is_expired)
|
||||
|
||||
def test_deposit_makes_invite_non_expiring(self):
|
||||
# Per user-spec: once a token is deposited the invite never expires,
|
||||
# even though it's well past the 24h PENDING window.
|
||||
inv = self._invite(
|
||||
created_at=timezone.now() - timedelta(hours=25),
|
||||
token_deposited_at=timezone.now() - timedelta(hours=20),
|
||||
)
|
||||
self.assertFalse(inv.is_expired)
|
||||
|
||||
def test_only_pending_invites_can_expire(self):
|
||||
inv = self._invite(
|
||||
status=SeaInvite.ACCEPTED,
|
||||
created_at=timezone.now() - timedelta(hours=25),
|
||||
)
|
||||
self.assertFalse(inv.is_expired)
|
||||
|
||||
# ── voice_active ─────────────────────────────────────────────────────
|
||||
def test_voice_active_true_when_voice_until_in_future(self):
|
||||
inv = self._invite(voice_until=timezone.now() + timedelta(hours=1))
|
||||
self.assertTrue(inv.voice_active)
|
||||
|
||||
def test_voice_active_false_when_none(self):
|
||||
self.assertFalse(self._invite().voice_active)
|
||||
|
||||
def test_voice_active_false_when_past(self):
|
||||
inv = self._invite(voice_until=timezone.now() - timedelta(minutes=1))
|
||||
self.assertFalse(inv.voice_active)
|
||||
|
||||
# ── is_present ───────────────────────────────────────────────────────
|
||||
def test_is_present_true_when_accepted_deposited_not_left(self):
|
||||
inv = self._invite(
|
||||
status=SeaInvite.ACCEPTED,
|
||||
token_deposited_at=timezone.now(),
|
||||
)
|
||||
self.assertTrue(inv.is_present)
|
||||
|
||||
def test_is_present_false_without_deposit(self):
|
||||
inv = self._invite(status=SeaInvite.ACCEPTED)
|
||||
self.assertFalse(inv.is_present)
|
||||
|
||||
def test_is_present_false_after_left(self):
|
||||
# left_at set (status still ACCEPTED here) isolates the `left_at is
|
||||
# None` clause of the guard.
|
||||
inv = self._invite(
|
||||
status=SeaInvite.ACCEPTED,
|
||||
token_deposited_at=timezone.now() - timedelta(hours=1),
|
||||
left_at=timezone.now(),
|
||||
)
|
||||
self.assertFalse(inv.is_present)
|
||||
@@ -21,6 +21,10 @@ urlpatterns = [
|
||||
path('my-sea/refund', views.my_sea_refund_token, name='my_sea_refund_token'),
|
||||
path('my-sea/paid-draw', views.my_sea_paid_draw, name='my_sea_paid_draw'),
|
||||
path('my-sea/invite', views.my_sea_invite, name='my_sea_invite'),
|
||||
path('my-sea/invite/accept/<int:invite_id>', views.my_sea_invite_accept,
|
||||
name='my_sea_invite_accept'),
|
||||
path('my-sea/invite/decline/<int:invite_id>', views.my_sea_invite_decline,
|
||||
name='my_sea_invite_decline'),
|
||||
path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief,
|
||||
name='my_sea_dismiss_free_draw_brief'),
|
||||
path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_POST
|
||||
@@ -737,23 +737,134 @@ def my_sea_dismiss_paid_draw_brief(request):
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_invite(request):
|
||||
"""Sprint 6 iter 6c — bud-btn invite stub on my-sea gatekeeper.
|
||||
Async multi-user invite is deferred to a later sprint; this endpoint
|
||||
just returns a Brief banner announcing "coming soon" so the bud-btn
|
||||
panel has a non-broken success path."""
|
||||
"""Invite a bud to the owner's my-sea table (Phase A of
|
||||
[[my-sea-invite-voice-blueprint]] — replaces the iter-6c "coming soon"
|
||||
stub). Resolves the recipient (email OR username) to a registered User
|
||||
when possible, dedups against an outstanding PENDING/ACCEPTED invite for
|
||||
the same (owner, invitee_email), creates a SeaInvite(PENDING), and logs
|
||||
the @mailman "Acceptances & rejections" Line + invitee Brief via
|
||||
`apps.billboard.mail.log_sea_invite`.
|
||||
|
||||
Returns JSON `{brief, recipient_display}` for the inviter's sent-
|
||||
confirmation banner (bud-btn.js `onSuccess` renders it). The inviter's
|
||||
confirmation is a transient banner; the invitee's notification IS
|
||||
persisted (log_sea_invite spawns the Brief)."""
|
||||
from django.urls import reverse
|
||||
from apps.billboard.mail import log_sea_invite
|
||||
from apps.billboard.views import _resolve_recipient
|
||||
from .models import SeaInvite
|
||||
|
||||
raw = (request.POST.get("recipient") or "").strip()
|
||||
if not raw:
|
||||
return JsonResponse({"brief": None, "recipient_display": None})
|
||||
|
||||
candidate = _resolve_recipient(raw)
|
||||
if candidate is not None and candidate == request.user:
|
||||
return JsonResponse({"brief": None, "recipient_display": None}) # no self-invite
|
||||
|
||||
invitee_email = candidate.email if candidate else raw
|
||||
recipient_display = (
|
||||
(candidate.username or candidate.email) if candidate else raw
|
||||
)
|
||||
|
||||
# Dedup: an outstanding (PENDING/ACCEPTED) invite for this recipient is
|
||||
# "already invited". Terminal-state invites (DECLINED/EXPIRED/LEFT) don't
|
||||
# block a fresh re-invite.
|
||||
already = SeaInvite.objects.filter(
|
||||
owner=request.user,
|
||||
invitee_email=invitee_email,
|
||||
status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED],
|
||||
).exists()
|
||||
if already:
|
||||
return JsonResponse({
|
||||
"brief": {
|
||||
"title": "Already invited",
|
||||
"line_text": f"Look!—{recipient_display} is already invited to your Sea.",
|
||||
"post_url": reverse("my_sea"),
|
||||
"created_at": "",
|
||||
"kind": "NUDGE",
|
||||
},
|
||||
"recipient_display": recipient_display,
|
||||
})
|
||||
|
||||
invite = SeaInvite.objects.create(
|
||||
owner=request.user,
|
||||
invitee=candidate,
|
||||
invitee_email=invitee_email,
|
||||
status=SeaInvite.PENDING,
|
||||
)
|
||||
log_sea_invite(invite)
|
||||
|
||||
return JsonResponse({
|
||||
"brief": {
|
||||
"title": "Multiplayer my-sea",
|
||||
"line_text": "Look!—multiplayer my-sea is coming soon. Stay tuned.",
|
||||
"post_url": reverse("gameboard"),
|
||||
"title": "Invite sent",
|
||||
"line_text": f"Look!—your invite is on its way to {recipient_display}.",
|
||||
"post_url": reverse("my_sea"),
|
||||
"created_at": "",
|
||||
"kind": "NUDGE",
|
||||
},
|
||||
"recipient_display": (request.POST.get("recipient") or "").strip(),
|
||||
"recipient_display": recipient_display,
|
||||
})
|
||||
|
||||
|
||||
def _sea_invite_for_request(request, invite_id):
|
||||
"""Fetch a SeaInvite + decide whether the requester is its invitee. Matches
|
||||
on the invitee FK when set, else on email (handles an invite created
|
||||
before the recipient registered). Returns ``(invite, is_invitee)``."""
|
||||
from .models import SeaInvite
|
||||
invite = get_object_or_404(SeaInvite, id=invite_id)
|
||||
if invite.invitee_id is not None:
|
||||
is_invitee = invite.invitee_id == request.user.id
|
||||
else:
|
||||
is_invitee = (
|
||||
(invite.invitee_email or "").lower() == (request.user.email or "").lower()
|
||||
)
|
||||
return invite, is_invitee
|
||||
|
||||
|
||||
def _redirect_to_invite_log(invite):
|
||||
"""Redirect to the invitee's "Acceptances & rejections" Post (where the
|
||||
invite Line lives) so the OK/BYE line re-renders post-transition. Falls
|
||||
back to /gameboard/ if the invite has no linked line/post yet."""
|
||||
from django.urls import reverse
|
||||
if invite.line_id and invite.line.post_id:
|
||||
return redirect(reverse("billboard:view_post", args=[invite.line.post_id]))
|
||||
return redirect("gameboard")
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_invite_accept(request, invite_id):
|
||||
"""Invitee accepts a PENDING my-sea invite → ACCEPTED. Links the invitee
|
||||
FK + stamps accepted_at. Phase B will redirect to the owner's spectator
|
||||
table (`my_sea_visit`); for now we redirect back to the invite log Post so
|
||||
the line re-renders with its Accepted badge."""
|
||||
from .models import SeaInvite
|
||||
invite, is_invitee = _sea_invite_for_request(request, invite_id)
|
||||
if not is_invitee:
|
||||
return HttpResponseForbidden()
|
||||
if invite.status == SeaInvite.PENDING and not invite.is_expired:
|
||||
invite.status = SeaInvite.ACCEPTED
|
||||
invite.accepted_at = timezone.now()
|
||||
invite.invitee = request.user
|
||||
invite.save(update_fields=["status", "accepted_at", "invitee"])
|
||||
return _redirect_to_invite_log(invite)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
@require_POST
|
||||
def my_sea_invite_decline(request, invite_id):
|
||||
"""Invitee declines a PENDING my-sea invite → DECLINED."""
|
||||
from .models import SeaInvite
|
||||
invite, is_invitee = _sea_invite_for_request(request, invite_id)
|
||||
if not is_invitee:
|
||||
return HttpResponseForbidden()
|
||||
if invite.status == SeaInvite.PENDING:
|
||||
invite.status = SeaInvite.DECLINED
|
||||
invite.save(update_fields=["status"])
|
||||
return _redirect_to_invite_log(invite)
|
||||
|
||||
|
||||
def _my_sea_deck_data(user, exclude_id=None):
|
||||
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
||||
picker's card-draw mechanic. Card payload shape is whatever
|
||||
|
||||
Reference in New Issue
Block a user