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:
@@ -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()
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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 ───────────────────────────
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user