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