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

@@ -39,10 +39,14 @@
<ul id="id_post_table" class="post-lines">
{% for line in post.lines.all %}
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' %}post-line--system{% endif %}">
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' or line.author.username == 'mailman' %}post-line--system{% endif %}">
<span class="post-line-author">{{ line.author|at_handle }}</span>
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. 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' %}{{ line.display_text|safe }}{% else %}{{ line.display_text }}{% endif %}</span>
<span class="post-line-text">{# adman / taxman-authored Lines (note unlock, share invite, tax ledger system prose) may carry an `<a class="note-ref">` anchor that needs to render as HTML. 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>
{# @mailman invite Lines carry an OK/BYE action block driven by #}
{# the linked SeaInvite's status (my-sea invite flow). Non-invite #}
{# system + user Lines have no `sea_invite`, so this is skipped. #}
{% if line.sea_invite %}{% include "apps/billboard/_partials/_invite_actions.html" %}{% endif %}
</li>
{% endfor %}
<li class="post-line-buffer" aria-hidden="true"></li>
@@ -52,7 +56,7 @@
{# the user can't respond, and the placeholder calls that out. View_ #}
{# post hard-rejects POSTs to these kinds; the post_save Line signal #}
{# is the safety net for ORM-level / API writes that bypass the view. #}
{% if post.kind == 'note_unlock' or post.kind == 'tax_ledger' %}
{% if post.kind == 'note_unlock' or post.kind == 'tax_ledger' or post.kind == 'mail_acceptance' %}
<form id="id_post_line_form" class="post-line-form">
<input
id="id_post_line_text"
@@ -85,7 +89,7 @@
{# Bud btn (bottom-left) + slide-out recipient field — async share. #}
{# Suppressed on system-author Posts (note unlock + tax ledger threads) #}
{# since friend-invites don't apply to system-authored threads. #}
{% if post.kind != 'note_unlock' and post.kind != 'tax_ledger' %}
{% if post.kind != 'note_unlock' and post.kind != 'tax_ledger' and post.kind != 'mail_acceptance' %}
{% include "apps/billboard/_partials/_bud_panel.html" %}
{% endif %}