remove dead my-sea invite accept/decline endpoints — acceptance is now implicit on bud-page sea-btn visit (accept-on-GET)

The @mailman Post's OK/BYE block + _invite_actions.html were dropped by the
bud-landing-page sprint; with accept-on-GET on my_sea_visit shipped in f5ee83b
the explicit endpoints have no trigger left. Removes:

- my_sea_invite_accept / my_sea_invite_decline views + the _sea_invite_for_request
  / _redirect_to_invite_log helpers they alone used (gameboard/views.py)
- the my-sea/invite/accept + my-sea/invite/decline URL routes (gameboard/urls.py)
- _invite_actions.html partial (already un-included from post.html)
- MySeaInviteAcceptDeclineTest (gameboard ITs); MySeaInvitePostRenderTest now
  asserts the form actions are gone by literal path/class instead of reverse()

There is no decline surface now — an un-clicked invite simply lapses after 24h.
post.html comment trimmed to match. 515 gameboard+billboard ITs/UTs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-29 11:52:38 -04:00
parent f5ee83be0a
commit 3bf35ad539
5 changed files with 22 additions and 169 deletions

View File

@@ -1,14 +1,12 @@
"""ITs for the my-sea invite endpoints + post.html OK/BYE render — Phase A """ITs for the my-sea invite send endpoint + the @mailman Post render.
(A5/A6) of [[my-sea-invite-voice-blueprint]].
Covers the real `my_sea_invite` (replaces the "coming soon" stub), the Covers the real `my_sea_invite` (replaces the "coming soon" stub) and the
`my_sea_invite_accept` / `my_sea_invite_decline` transitions + auth gating, prose-only render of the invitee's "Acceptances & rejections" Post. The
and the interactive OK/BYE / status-badge render in the invitee's explicit accept/decline endpoints were removed 2026-05-29 — acceptance is now
"Acceptances & rejections" Post. implicit on the bud-page sea-btn visit (see test_sea_visit.py's accept-on-GET
coverage); an un-clicked invite simply lapses after 24h.
""" """
from datetime import timedelta
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
@@ -80,63 +78,6 @@ class MySeaInviteViewTest(TestCase):
self.assertIsNone(resp.json()["brief"]) 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): class MySeaInvitePostRenderTest(TestCase):
"""post.html — the @mailman invite Line carries a post-attribution """post.html — the @mailman invite Line carries a post-attribution
anchor around the owner's handle whose href routes to the owner's anchor around the owner's handle whose href routes to the owner's
@@ -154,16 +95,15 @@ class MySeaInvitePostRenderTest(TestCase):
self.invite.refresh_from_db() self.invite.refresh_from_db()
self.client.force_login(self.bud) self.client.force_login(self.bud)
self.post_url = reverse("billboard:view_post", args=[self.post.id]) 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_post_attribution_anchor_not_buttons(self): def test_pending_invite_renders_post_attribution_anchor_not_buttons(self):
content = self.client.get(self.post_url).content.decode() content = self.client.get(self.post_url).content.decode()
# OK/BYE buttons + accept/decline form actions migrated onto bud.html. # OK/BYE buttons + the accept/decline form actions are gone entirely.
self.assertNotIn("invite-ok-btn", content) self.assertNotIn("invite-ok-btn", content)
self.assertNotIn("invite-bye-btn", content) self.assertNotIn("invite-bye-btn", content)
self.assertNotIn(self.accept_url, content) self.assertNotIn("invite-action-form", content)
self.assertNotIn(self.decline_url, content) self.assertNotIn("my-sea/invite/accept", content)
self.assertNotIn("my-sea/invite/decline", content)
# Anchor wraps the owner's handle, routing to their bud landing page. # Anchor wraps the owner's handle, routing to their bud landing page.
self.assertIn('class="post-attribution"', content) self.assertIn('class="post-attribution"', content)
self.assertIn(f"/billboard/buds/{self.owner.id}/", content) self.assertIn(f"/billboard/buds/{self.owner.id}/", content)
@@ -175,14 +115,14 @@ class MySeaInvitePostRenderTest(TestCase):
content = self.client.get(self.post_url).content.decode() content = self.client.get(self.post_url).content.decode()
# No "Accepted <date>" badge — the sweep is unconditional. # No "Accepted <date>" badge — the sweep is unconditional.
self.assertNotIn("invite-badge--accepted", content) self.assertNotIn("invite-badge--accepted", content)
self.assertNotIn(self.accept_url, content) self.assertNotIn("invite-action-form", content)
def test_declined_invite_renders_no_badge(self): def test_declined_invite_renders_no_badge(self):
self.invite.status = SeaInvite.DECLINED self.invite.status = SeaInvite.DECLINED
self.invite.save() self.invite.save()
content = self.client.get(self.post_url).content.decode() content = self.client.get(self.post_url).content.decode()
self.assertNotIn("invite-badge--declined", content) self.assertNotIn("invite-badge--declined", content)
self.assertNotIn(self.accept_url, content) self.assertNotIn("invite-action-form", content)
def test_mailman_line_renders_as_system_with_handles(self): def test_mailman_line_renders_as_system_with_handles(self):
content = self.client.get(self.post_url).content.decode() content = self.client.get(self.post_url).content.decode()

View File

@@ -21,10 +21,6 @@ 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/visit/<uuid:owner_id>/', views.my_sea_visit, name='my_sea_visit'), path('my-sea/visit/<uuid:owner_id>/', views.my_sea_visit, name='my_sea_visit'),
path('my-sea/visit/<uuid:owner_id>/gate/', views.my_sea_visit_gate, path('my-sea/visit/<uuid:owner_id>/gate/', views.my_sea_visit_gate,
name='my_sea_visit_gate'), name='my_sea_visit_gate'),

View File

@@ -818,62 +818,11 @@ def my_sea_invite(request):
}) })
def _sea_invite_for_request(request, invite_id): # Explicit accept/decline endpoints (the @mailman Post's OK/BYE forms +
"""Fetch a SeaInvite + decide whether the requester is its invitee. Matches # `_invite_actions.html`) were removed 2026-05-29: acceptance is now implicit
on the invitee FK when set, else on email (handles an invite created # — clicking the bud-page sea sub-btn lands on `my_sea_visit`, which accepts a
before the recipient registered). Returns ``(invite, is_invitee)``.""" # still-pending invite on GET. There is no decline surface; an un-clicked
from .models import SeaInvite # invite simply lapses after 24h (`SeaInvite.is_expired`).
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)
# ── Phase B — my-sea spectator (invitee) surfaces ─────────────────────────── # ── Phase B — my-sea spectator (invitee) surfaces ───────────────────────────

View File

@@ -1,31 +0,0 @@
{% 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>

View File

@@ -43,12 +43,11 @@
<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 / mailman-authored Lines (note unlock, share invite, tax ledger, invite cascade) may carry HTML anchors (note-ref / post-attribution). 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> <span class="post-line-text">{# adman / taxman / mailman-authored Lines (note unlock, share invite, tax ledger, invite cascade) may carry HTML anchors (note-ref / post-attribution). 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>
{# Pre-sprint @mailman invite Lines carried an in-line OK/BYE #} {# @mailman invite Lines have no in-line OK/BYE block — the #}
{# block via _invite_actions.html. Bud landing page sprint #} {# Line's prose embeds a post-attribution anchor (see #}
{# 2026-05-27 migrates that interaction onto bud.html — the #} {# apps.billboard.mail.INVITE_TEMPLATE) routing to the owner's #}
{# Line's prose now embeds a post-attribution anchor (see #} {# bud page, where clicking the glowing sea sub-btn accepts #}
{# apps.billboard.mail.INVITE_TEMPLATE) that routes to the #} {# the invite on visit. No explicit accept/decline surface. #}
{# owner's bud page where accept/decline/spectator live. #}
</li> </li>
{% endfor %} {% endfor %}
<li class="post-line-buffer" aria-hidden="true"></li> <li class="post-line-buffer" aria-hidden="true"></li>