@taxman Debits & credits ledger + NVM-persistent FREE/PAID DRAW Briefs — TDD

User-spec 2026-05-26 for /gameboard/my-sea/. The transient "Free draw locked" Brief that re-appeared on every page load is replaced by a server-driven Brief whose NVM dismissal persists per-cycle, AND every spend now lands a permanent line on a new @taxman-authored "Debits & credits" Post (so the info goes somewhere instead of vanishing on dismiss). Same NVM-persistence treatment for the new PAID DRAW Brief.

Lyric:
- RESERVED_USERNAMES adds "taxman"; get_or_create_taxman() parallels get_or_create_adman() (username=taxman, email=taxman@earthmanrpg.local, unusable password, searchable=False).
- New nullable User.{free,paid}_draw_brief_dismissed_at DateTimeFields — anchor stamps for the NVM-persistence semantics. Cleared by my_sea_lock (free) / my_sea_paid_draw (paid) on each fresh spend so the new cycle re-opens the Brief surface.
- Migration 0014_brief_dismissal_fields adds the fields + RunPython seeds @taxman (mirror of 0003_seed_adman).

Billboard:
- Post.KIND_TAX_LEDGER + TAX_LEDGER_POST_TITLE = "Debits & credits"; Brief.KIND_TAX_LEDGER for routing.
- _delete_unsolicited_admin_post_lines extended via _SYSTEM_AUTHOR_POST_KINDS tuple — TAX_LEDGER joins NOTE_UNLOCK in the post_save guard that nukes any Line w.o. admin_solicited=True.
- Brief.to_banner_dict adds dismiss_url slot (empty by default; populated by the gameboard view for TAX_LEDGER briefs) + uses line.display_text instead of line.text so the prefix is stripped on the banner too.
- Line.display_text property — strips the leading "[iso-timestamp] " prefix that log_tax_debit bakes into TAX_LEDGER Lines (the prefix exists ONLY to satisfy unique_together = (post, text) on repeat-slug spends; the per-Brief + per-Line created_at slots already render the user-facing moment). Identity for non-tax Lines.
- view_post / delete_post / abandon_post guards extended to treat TAX_LEDGER like NOTE_UNLOCK (POST forbidden, can't delete, can't bye).
- Migration 0008_tax_ledger_kind registers the new choices on Post.kind + Brief.kind.

Billboard tax module (new apps/billboard/tax.py):
- TAX_DEBIT_TEMPLATES — canonical body text per slug, with FREE DRAW / PAID DRAW / GATE VIEW button-labels wrapped in .btn-pri-name spans:
  - free_draw_locked → "Look!—my_sea.html [FREE DRAW] is locked. Next free draw available 24h from the production of this log."
  - paid_draw_locked → "Look!—my_sea.html [PAID DRAW] is locked. Another may be unlocked by depositing a Token in [GATE VIEW]."
- log_tax_debit(user, slug) — get-or-creates the user's TAX_LEDGER Post, appends a timestamp-prefixed Line authored by @taxman w. admin_solicited=True, spawns a Brief. Returns (post, line, brief).

Gameboard:
- my_sea_lock first-card-of-cycle branch calls log_tax_debit(user, "free_draw_locked") + clears free_draw_brief_dismissed_at. Response now includes free_draw_brief_payload (Brief.to_banner_dict w. dismiss_url populated) so the picker IIFE can surface the new Brief in-place w.o. a page reload — same affordance the prior _showFreeDrawLockedBrief provided, w. server-authored copy + NVM-persistence.
- my_sea_paid_draw after paid_through_at stamp calls log_tax_debit(user, "paid_draw_locked") + clears paid_draw_brief_dismissed_at. Next-page-load surfaces the new Brief via the context payload.
- New my_sea_dismiss_free_draw_brief + my_sea_dismiss_paid_draw_brief POST endpoints stamp the matching User anchor field; return 204. URLs at /gameboard/my-sea/brief/{free,paid}-draw/dismiss.
- my_sea view's context computes {free,paid}_draw_brief_payload via the new _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url) helper — returns the latest TAX_LEDGER Brief's to_banner_dict IF (dismissal anchor is None OR anchor < brief.created_at). Slug discrimination via line__text__contains="FREE DRAW" / "PAID DRAW" (kept the Brief schema flat — only two markers today, non-overlapping wordings).

Frontend (apps/dashboard/static/apps/dashboard/note.js):
- Brief.showBanner NVM handler now fires a fire-and-forget POST to brief.dismiss_url (if present) before removing the banner. Persistent-NVM kinds (TAX_LEDGER) supply it; transient kinds leave the field empty + the handler no-ops to the existing dismiss-only behavior. CSRF token pulled from the csrftoken cookie.

SCSS (static_src/scss/_billboard.scss):
- .post-line--system .post-line-text .btn-pri-name — inline emphasis (color: --quaUser, font-weight: 700, font-style: normal) on canonical .btn-primary button labels referenced in @taxman ledger prose. User-spec 2026-05-26 mid-flight clarification: log surface only, not the actual buttons.

Templates:
- templates/apps/gameboard/my_sea.html: replaces the inline _showFreeDrawLockedBrief({{ next_free_draw_at|date:'c' }}) invocation w. two {% if *_brief_payload %} blocks that json_script the payload + dispatch via a new _showTaxBrief(payload, bannerClass) helper. _postLock updated to call _showFreeDrawLockedBrief(body.free_draw_brief_payload) so freshly-emitted Briefs surface in-place w.o. a reload (same affordance as before, w. server payload).
- templates/apps/billboard/post.html: readonly-textarea / system-author-styling / bud-panel-suppression branches all extended to cover post.kind == 'tax_ledger' (parallel to existing 'note_unlock' cases). Line-text rendering uses line.display_text (strips the iso prefix) + treats @taxman the same as @adman (allow HTML rendering for the system-author safe text — required so the .btn-pri-name spans aren't escaped).

Tests:

UTs (apps/billboard/tests/integrated/test_tax.py — 11 specs):
- log_tax_debit creates Post/Line/Brief w. correct kind + author + admin_solicited.
- Both slug templates produce expected text (assertions tolerant of inline .btn-pri-name span HTML).
- Two spends share one Post w. two distinct Lines (timestamp prefix keeps unique_together happy).
- Unknown slug raises KeyError.
- post_save guard nukes unsolicited Lines on TAX_LEDGER Posts; solicited Lines survive.
- "taxman" is reserved (case-insensitive); get_or_create_taxman idempotent.

ITs (apps/gameboard/tests/integrated/test_tax_briefs.py — 13 specs):
- my_sea_lock first-card creates TAX_LEDGER Post + Line + Brief; mid-cycle upserts do NOT emit extra debits; clears free_draw_brief_dismissed_at.
- my_sea_paid_draw commit creates a separate TAX_LEDGER entry; clears paid_draw_brief_dismissed_at.
- Dismiss endpoints stamp the matching User anchor; reject GET (405); require login (302).
- my_sea context: *_brief_payload is None until first spend; populated after; suppressed after NVM-dismiss; returns after cycle reset.

Existing ITs adjusted (apps/gameboard/tests/integrated/test_views.py):
- test_view_triggers_brief_banner_when_active_draw_exists + test_empty_hand_brief_banner_still_triggered + test_view_does_not_trigger_brief_banner_without_active_draw — assertions retargeted from window._showFreeDrawLockedBrief(" to id="id_free_draw_brief_payload" (the new json_script payload tag).
- test_brief_next_free_draw_at_uses_user_anchor_not_paid_row — switched from HTML-substring assertion against the rendered ISO (now absent from the page) to a direct response.context["next_free_draw_at"] comparison. Same underlying invariant; cleaner assertion shape.

FT (functional_tests/test_bill_post_debits_credits.py — 1 spec):
- After two seeded debits, /billboard/post/<uuid>/ renders the "Debits & credits" title, both Line bodies (FREE DRAW + PAID DRAW), @taxman attribution, readonly input w. "No response needed at this time" placeholder, AND verifies the "[iso] " prefix is stripped from display.

All 1340 IT+UT green; new FT green; existing FTs unaffected by these changes.

Pending follow-up (recorded for next sprint):

Per user 2026-05-26 in-flight ask: refactor @adman concerns into apps/billboard/ad.py (paralleling the new apps/billboard/tax.py) — extract Note.grant_if_new's billboard-side concerns (Post/Line/Brief creation, prose templates) out of apps/drama/models.py into the same shape log_tax_debit now follows. Notated for after this sprint lands.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-26 16:26:42 -04:00
parent 7f6c0c2883
commit f44a282007
16 changed files with 942 additions and 117 deletions

View File

@@ -0,0 +1,121 @@
"""ITs for `apps.billboard.tax.log_tax_debit` — the @taxman-authored
"Debits & credits" ledger (user-spec 2026-05-26).
Mirrors the shape of `apps.drama.tests.integrated.test_note_brief` for
`Note.grant_if_new` — each spend appends a Line + spawns a Brief on the
user's single TAX_LEDGER Post."""
from django.test import TestCase
from apps.billboard.models import Brief, Line, Post, TAX_LEDGER_POST_TITLE
from apps.billboard.tax import (
TAX_DEBIT_TEMPLATES,
log_tax_debit,
)
from apps.lyric.models import User, get_or_create_taxman
class LogTaxDebitTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="tax@test.io")
def test_free_draw_locked_creates_post_line_brief(self):
post, line, brief = log_tax_debit(self.user, "free_draw_locked")
# Post created with the canonical title + correct kind
self.assertEqual(post.owner, self.user)
self.assertEqual(post.kind, Post.KIND_TAX_LEDGER)
self.assertEqual(post.title, TAX_LEDGER_POST_TITLE)
# Line authored by @taxman + admin_solicited
self.assertEqual(line.post, post)
self.assertEqual(line.author, get_or_create_taxman())
self.assertTrue(line.admin_solicited)
# Brief points at the Post + Line w. correct kind
self.assertEqual(brief.owner, self.user)
self.assertEqual(brief.post, post)
self.assertEqual(brief.line, line)
self.assertEqual(brief.kind, Brief.KIND_TAX_LEDGER)
self.assertEqual(brief.title, TAX_LEDGER_POST_TITLE)
def test_paid_draw_locked_uses_paid_template_text(self):
_, line, _ = log_tax_debit(self.user, "paid_draw_locked")
self.assertIn("PAID DRAW", line.text)
# `GATE VIEW` is wrapped in a `.btn-pri-name` span for inline styling
# parity w. the actual button label (user-spec 2026-05-26), so the
# raw text has HTML between "depositing a Token in " + "GATE VIEW".
self.assertIn("depositing a Token in", line.text)
self.assertIn("GATE VIEW", line.text)
def test_free_draw_locked_uses_free_template_text(self):
_, line, _ = log_tax_debit(self.user, "free_draw_locked")
self.assertIn("FREE DRAW", line.text)
self.assertIn("24h from the production of this log", line.text)
def test_two_spends_share_one_post_with_two_lines(self):
"""Like Note unlocks: one Post per user, growing thread of Lines."""
log_tax_debit(self.user, "free_draw_locked")
log_tax_debit(self.user, "paid_draw_locked")
posts = Post.objects.filter(owner=self.user, kind=Post.KIND_TAX_LEDGER)
self.assertEqual(posts.count(), 1)
self.assertEqual(posts.first().lines.count(), 2)
def test_two_free_draws_produce_distinct_lines(self):
"""Each spend produces a UNIQUE Line — text is timestamp-prefixed so
a second identical-slug spend doesn't collide with `unique_together
= (post, text)`."""
log_tax_debit(self.user, "free_draw_locked")
log_tax_debit(self.user, "free_draw_locked")
post = Post.objects.get(owner=self.user, kind=Post.KIND_TAX_LEDGER)
line_texts = list(post.lines.values_list("text", flat=True))
self.assertEqual(len(line_texts), 2)
self.assertNotEqual(line_texts[0], line_texts[1])
def test_unknown_slug_raises(self):
with self.assertRaises(KeyError):
log_tax_debit(self.user, "no_such_slug")
def test_template_keys_cover_both_known_slugs(self):
self.assertIn("free_draw_locked", TAX_DEBIT_TEMPLATES)
self.assertIn("paid_draw_locked", TAX_DEBIT_TEMPLATES)
class UnsolicitedLineGuardTest(TestCase):
"""post_save guard nukes any Line saved on a TAX_LEDGER Post w.o.
admin_solicited=True — mirrors the NOTE_UNLOCK defense-in-depth."""
def setUp(self):
self.user = User.objects.create(email="guard@test.io")
self.taxman = get_or_create_taxman()
self.post = Post.objects.create(
owner=self.user,
kind=Post.KIND_TAX_LEDGER,
title=TAX_LEDGER_POST_TITLE,
)
def test_unsolicited_line_on_tax_ledger_gets_deleted(self):
Line.objects.create(
post=self.post, text="impostor", author=self.taxman,
admin_solicited=False,
)
self.assertEqual(self.post.lines.count(), 0)
def test_solicited_line_on_tax_ledger_survives(self):
Line.objects.create(
post=self.post, text="legit", author=self.taxman,
admin_solicited=True,
)
self.assertEqual(self.post.lines.count(), 1)
class TaxmanReservedUsernameTest(TestCase):
"""`taxman` joins `adman` as a reserved system-author handle."""
def test_taxman_is_reserved(self):
from apps.lyric.models import is_reserved_username
self.assertTrue(is_reserved_username("taxman"))
self.assertTrue(is_reserved_username("TAXMAN")) # case-insensitive
def test_get_or_create_taxman_is_idempotent(self):
a = get_or_create_taxman()
b = get_or_create_taxman()
self.assertEqual(a.pk, b.pk)
self.assertEqual(a.email, "taxman@earthmanrpg.local")