@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,23 @@
# Generated by Django 6.0 on 2026-05-26 19:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('billboard', '0007_brief_room_alter_brief_kind_alter_brief_post'),
]
operations = [
migrations.AlterField(
model_name='brief',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite'), ('tax_ledger', 'Tax ledger')], default='user_post', max_length=32),
),
migrations.AlterField(
model_name='post',
name='kind',
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites'), ('tax_ledger', 'Debits & credits')], default='user_post', max_length=32),
),
]

View File

@@ -7,14 +7,24 @@ from django.urls import reverse
from django.utils import timezone
NOTE_UNLOCK_POST_TITLE_HINT = "Notes & recognitions" # see drama.NOTE_UNLOCK_POST_TITLE; copy lives there
TAX_LEDGER_POST_TITLE = "Debits & credits"
class Post(models.Model):
KIND_NOTE_UNLOCK = "note_unlock"
KIND_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite"
# Per-user @taxman-authored ledger (user-spec 2026-05-26). Each FREE/PAID
# DRAW spend at /gameboard/my-sea/ appends one Line via
# `apps.billboard.tax.log_tax_debit`. Mirrors the NOTE_UNLOCK Post pattern:
# one Post per user, system-authored, readonly textarea in post.html.
KIND_TAX_LEDGER = "tax_ledger"
KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlocks"),
(KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invites"),
(KIND_TAX_LEDGER, "Debits & credits"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -77,6 +87,23 @@ class Line(models.Model):
def __str__(self):
return self.text
@property
def display_text(self):
"""User-facing line text. For TAX_LEDGER lines, strips the leading
`[<ISO timestamp>] ` prefix that the `apps.billboard.tax.log_tax_
debit` helper bakes in to satisfy `unique_together = (post, text)`
on repeat-slug spends — the Brief carries `created_at` and the
Post line carries `created_at` independently, so embedding a third
timestamp in the prose is noise.
For non-tax lines this is identity (returns `text` unchanged) —
Note-unlock + user-typed Lines have no prefix to strip."""
if self.post.kind == Post.KIND_TAX_LEDGER and self.text.startswith("["):
close = self.text.find("] ")
if close != -1:
return self.text[close + 2:]
return self.text
class Brief(models.Model):
"""A slide-down notification record. Owner = whose attention; post = where
@@ -95,11 +122,17 @@ class Brief(models.Model):
KIND_USER_POST = "user_post"
KIND_SHARE_INVITE = "share_invite"
KIND_GAME_INVITE = "game_invite"
# Tax-ledger Briefs (FREE/PAID DRAW spend, user-spec 2026-05-26). FYI
# navigates to the user's TAX_LEDGER Post. NVM POSTs to the dismiss-
# brief endpoint so the dismissal persists per-cycle (see
# `dismiss_url` in `to_banner_dict`).
KIND_TAX_LEDGER = "tax_ledger"
KIND_CHOICES = [
(KIND_NOTE_UNLOCK, "Note unlock"),
(KIND_USER_POST, "User post"),
(KIND_SHARE_INVITE, "Share invite"),
(KIND_GAME_INVITE, "Game invite"),
(KIND_TAX_LEDGER, "Tax ledger"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
@@ -160,8 +193,14 @@ class Brief(models.Model):
carries a square_url pointing at /billboard/my-notes/ so the
thumbnail-square inside the banner jumps direct to the user's Note
collection. GAME_INVITE kind has no Post — the FYI link navigates
to the gatekeeper page for the brief's Room instead."""
to the gatekeeper page for the brief's Room instead.
`dismiss_url` (TAX_LEDGER only — user-spec 2026-05-26): the POST
endpoint the banner's NVM btn fires to so the dismissal persists
per-cycle (FREE DRAW until next FREE DRAW spend, PAID DRAW until
next PAID DRAW commit). Empty for kinds with no persistence."""
square_url = ""
dismiss_url = ""
if self.kind == self.KIND_NOTE_UNLOCK:
square_url = reverse("billboard:my_notes")
if self.post_id:
@@ -171,26 +210,36 @@ class Brief(models.Model):
else:
post_url = ""
return {
"id": str(self.id),
"kind": self.kind,
"title": self.title,
"line_text": self.line.text if self.line else "",
"post_url": post_url,
"square_url": square_url,
"created_at": self.created_at.isoformat(),
"id": str(self.id),
"kind": self.kind,
"title": self.title,
# `display_text` strips the `[<iso timestamp>] ` prefix on
# TAX_LEDGER lines (the prefix exists only to satisfy Line's
# `unique_together = (post, text)` invariant on repeat-slug
# spends — the Brief's own `created_at` slot below covers the
# user-facing timestamp). Identity for all other line kinds.
"line_text": self.line.display_text if self.line else "",
"post_url": post_url,
"square_url": square_url,
"dismiss_url": dismiss_url,
"created_at": self.created_at.isoformat(),
}
# ── Listener: nuke unsolicited Lines on NOTE_UNLOCK Posts ─────────────────
# ── Listener: nuke unsolicited Lines on system-author Posts ──────────────
# Defense-in-depth alongside view_post's POST guard. A Line saved on a
# NOTE_UNLOCK Post that lacks admin_solicited=True (e.g. a stray ORM-level
# write or an API path that bypasses the view) gets deleted right after
# the save. Note.grant_if_new sets admin_solicited=True on its Lines so
# legitimate system prose survives.
# NOTE_UNLOCK / TAX_LEDGER Post that lacks admin_solicited=True (e.g. a stray
# ORM-level write or an API path that bypasses the view) gets deleted right
# after the save. `Note.grant_if_new` + `apps.billboard.tax.log_tax_debit`
# both set admin_solicited=True on their Lines so legitimate system prose
# survives.
_SYSTEM_AUTHOR_POST_KINDS = (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER)
@receiver(post_save, sender=Line)
def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
if not created:
return
if instance.post.kind == Post.KIND_NOTE_UNLOCK and not instance.admin_solicited:
if instance.post.kind in _SYSTEM_AUTHOR_POST_KINDS and not instance.admin_solicited:
instance.delete()

100
src/apps/billboard/tax.py Normal file
View File

@@ -0,0 +1,100 @@
"""@taxman-authored "Debits & credits" ledger — user-spec 2026-05-26.
`log_tax_debit(user, slug)` appends one Line + spawns one Brief on the user's
single TAX_LEDGER Post for each FREE/PAID DRAW spend at /gameboard/my-sea/.
Parallels `apps.drama.models.Note.grant_if_new` for Note unlocks; the same
post_save guard in `billboard.models` nukes any Line saved on a TAX_LEDGER
Post w.o. admin_solicited=True.
Two debit slugs today:
free_draw_locked → my_sea_lock first-card-of-cycle
paid_draw_locked → my_sea_paid_draw commit
The Brief that spawns here is rendered via the existing slide-down banner
(note.js `Brief.showBanner`); its FYI .btn-info navigates to the user's
ledger Post; its NVM stamps the matching `User.{free,paid}_draw_brief_
dismissed_at` field via the dismiss-brief gameboard endpoints.
Line text is timestamp-prefixed so a second identical-slug spend doesn't
collide w. `Line.Meta.unique_together = ("post", "text")`."""
from django.utils import timezone
from apps.billboard.models import (
Brief,
Line,
Post,
TAX_LEDGER_POST_TITLE,
)
from apps.lyric.models import get_or_create_taxman
# Canonical Line text per slug. Replaces the prior `_showFreeDrawLockedBrief`
# helper's wording in my_sea.html — the ledger Line IS the source of truth
# for both the persistent log surface (Debits & credits Post) AND the slide-
# down Brief banner (via Brief.line.text in to_banner_dict).
#
# `.btn-pri-name` spans wrap canonical .btn-primary button labels referenced
# inline (FREE DRAW, PAID DRAW, GATE VIEW per user-spec 2026-05-26). SCSS
# (`_billboard.scss` under `.post-line--system .post-line-text`) styles the
# spans so the user sees the same `--quaUser` colour + 700-weight on the
# token in prose as they would on the actual button. Rendered as HTML via
# `Line.display_text|safe` in post.html (the system-author branch).
TAX_DEBIT_TEMPLATES = {
"free_draw_locked": (
'Look!—my_sea.html <span class="btn-pri-name">FREE DRAW</span> '
'is locked. Next free draw available 24h from the production of this log.'
),
"paid_draw_locked": (
'Look!—my_sea.html <span class="btn-pri-name">PAID DRAW</span> '
'is locked. Another may be unlocked by depositing a Token in '
'<span class="btn-pri-name">GATE VIEW</span>.'
),
}
def log_tax_debit(user, slug):
"""Append a Line to the user's "Debits & credits" Post (creating the Post
on first call) + spawn a Brief that the next page-load surfaces as a
slide-down banner.
Returns ``(post, line, brief)``. Raises ``KeyError`` for unknown slugs.
Line text is prefixed with `[<ISO timestamp>] ` so successive spends of
the same slug produce distinct rows (each one survives `Line.Meta.
unique_together = ("post", "text")`)."""
if slug not in TAX_DEBIT_TEMPLATES:
raise KeyError(f"Unknown tax debit slug: {slug!r}")
post, _ = Post.objects.get_or_create(
owner=user,
kind=Post.KIND_TAX_LEDGER,
defaults={"title": TAX_LEDGER_POST_TITLE},
)
# Existing TAX_LEDGER Posts (pre-feature migration) might lack a title;
# heal once on next debit. Mirrors the Note.grant_if_new title heal.
if post.title != TAX_LEDGER_POST_TITLE:
post.title = TAX_LEDGER_POST_TITLE
post.save(update_fields=["title"])
body = TAX_DEBIT_TEMPLATES[slug]
# Sub-second timestamp prefix — keeps text unique even when two debits
# land in the same wallclock second (defensive vs auto-draw paths that
# could conceivably commit free + paid in rapid succession).
stamp = timezone.now().isoformat(timespec="microseconds")
text = f"[{stamp}] {body}"
line = Line.objects.create(
post=post,
text=text,
author=get_or_create_taxman(),
admin_solicited=True,
)
brief = Brief.objects.create(
owner=user,
post=post,
line=line,
kind=Brief.KIND_TAX_LEDGER,
title=TAX_LEDGER_POST_TITLE,
)
return post, line, brief

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")

View File

@@ -396,11 +396,12 @@ def view_post(request, post_id):
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden()
# Admin-Post (note-unlock thread) hard write-rejection — the per-Line
# signal in billboard.models nukes any Line that bypasses this guard,
# but at the view level we want a clean 403 so the FT/IT contract is
# explicit and the client never sees a silent line vanish.
if our_post.kind == Post.KIND_NOTE_UNLOCK and request.method == "POST":
# System-author Post hard write-rejection (note unlock + tax ledger
# threads) — the per-Line signal in billboard.models nukes any Line
# that bypasses this guard, but at the view level we want a clean 403
# so the FT/IT contract is explicit and the client never sees a silent
# line vanish.
if our_post.kind in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER) and request.method == "POST":
return HttpResponseForbidden()
form = ExistingPostLineForm(for_post=our_post)
@@ -465,7 +466,7 @@ def my_posts(request, user_id):
def delete_post(request, post_id):
if request.method == "POST":
post = Post.objects.get(id=post_id)
if request.user == post.owner and post.kind != Post.KIND_NOTE_UNLOCK:
if request.user == post.owner and post.kind not in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER):
post.delete()
return redirect("billboard:my_posts", user_id=request.user.id)
@@ -474,7 +475,7 @@ def delete_post(request, post_id):
def abandon_post(request, post_id):
if request.method == "POST":
post = Post.objects.get(id=post_id)
if post.kind != Post.KIND_NOTE_UNLOCK:
if post.kind not in (Post.KIND_NOTE_UNLOCK, Post.KIND_TAX_LEDGER):
post.shared_with.remove(request.user)
return redirect("billboard:my_posts", user_id=request.user.id)

View File

@@ -48,6 +48,22 @@ const Brief = (() => {
'<a href="' + _esc(brief.post_url) + '" class="btn btn-info note-banner__fyi">FYI</a>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
// Persistent-NVM kinds (TAX_LEDGER FREE/PAID DRAW per user-spec
// 2026-05-26) carry a `dismiss_url` — POST it so the dismissal
// anchor is stamped server-side + the Brief stays suppressed on
// future page loads until the cycle resets. Fire-and-forget; the
// banner removal is unconditional so the user gets immediate
// feedback regardless of network state.
if (brief.dismiss_url) {
var csrfMatch = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/);
fetch(brief.dismiss_url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'X-CSRFToken': csrfMatch ? decodeURIComponent(csrfMatch[1]) : '',
},
});
}
banner.remove();
});

View File

@@ -0,0 +1,250 @@
"""ITs for the @taxman ledger + Brief NVM-persistence wiring in the
my_sea views (user-spec 2026-05-26).
Covers:
- `my_sea_lock` first-card-of-cycle emits a TAX_LEDGER ledger entry +
spawns a Brief + clears `User.free_draw_brief_dismissed_at`.
- `my_sea_paid_draw` commit emits a TAX_LEDGER ledger entry + spawns a
Brief + clears `User.paid_draw_brief_dismissed_at`.
- `my_sea_dismiss_free_draw_brief` POST stamps the user's anchor; idem
for `my_sea_dismiss_paid_draw_brief`.
- my_sea view context: `free_draw_brief_payload` / `paid_draw_brief_
payload` populated only when the user's dismissal anchor is older than
the latest TAX_LEDGER Brief of that slug-marker."""
import json
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.billboard.models import Brief, Line, Post
from apps.epic.models import personal_sig_cards
from apps.gameboard.models import MySeaDraw
from apps.lyric.models import Token, User
def _build_hand_payload(target_id, spread="situation-action-outcome"):
"""Single-card hand payload — enough for the FREE-DRAW first-card-of-
cycle path, which is all we need to fire the tax debit."""
return {
"spread": spread,
"hand": [{
"position": "lay", "card_id": target_id,
"reversed": False, "polarity": "gravity",
}],
}
class MySeaLockEmitsFreeDrawDebitTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="freedebit@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_first_card_of_cycle_creates_tax_ledger_post_and_brief(self):
# No TAX_LEDGER post initially
self.assertEqual(
Post.objects.filter(owner=self.user, kind=Post.KIND_TAX_LEDGER).count(), 0,
)
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(self.target.id)),
content_type="application/json",
)
post = Post.objects.get(owner=self.user, kind=Post.KIND_TAX_LEDGER)
self.assertEqual(post.lines.count(), 1)
self.assertIn("FREE DRAW", post.lines.first().text)
brief = Brief.objects.get(owner=self.user, kind=Brief.KIND_TAX_LEDGER)
self.assertEqual(brief.post_id, post.id)
def test_first_card_of_cycle_clears_free_draw_dismissed_anchor(self):
# Pre-stamp a dismissal anchor as though the user had NVM-ed a
# prior cycle's Brief.
self.user.free_draw_brief_dismissed_at = (
timezone.now() - timedelta(days=2)
)
self.user.save(update_fields=["free_draw_brief_dismissed_at"])
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(self.target.id)),
content_type="application/json",
)
self.user.refresh_from_db()
self.assertIsNone(self.user.free_draw_brief_dismissed_at)
def test_mid_cycle_lock_does_not_emit_extra_debit(self):
# First-card-of-cycle is the ONLY moment a FREE DRAW debit fires.
# A second /lock POST on an existing row (mid-draw upsert) must NOT
# append another debit line — the cycle hasn't restarted.
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(self.target.id)),
content_type="application/json",
)
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(
self.target.id, spread="situation-action-outcome",
)),
content_type="application/json",
)
post = Post.objects.get(owner=self.user, kind=Post.KIND_TAX_LEDGER)
self.assertEqual(
post.lines.count(), 1,
"Mid-cycle upsert must not emit a second FREE DRAW debit",
)
class MySeaPaidDrawEmitsDebitTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="paiddebit@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
# Pre-existing FREE-DRAW row w. a deposit reserved at the gate so
# `my_sea_paid_draw` can commit it.
self.user.last_free_draw_at = timezone.now() - timedelta(hours=2)
self.user.save(update_fields=["significator", "last_free_draw_at"])
token = Token.objects.create(user=self.user, token_type=Token.COIN)
self.draw = MySeaDraw.objects.create(
user=self.user,
spread="situation-action-outcome",
significator_id=self.target.id,
hand=[],
deposit_token_id=token.pk,
deposit_reserved_at=timezone.now(),
)
def test_paid_draw_commit_creates_tax_ledger_debit_and_brief(self):
self.client.post(reverse("my_sea_paid_draw"))
post = Post.objects.get(owner=self.user, kind=Post.KIND_TAX_LEDGER)
self.assertEqual(post.lines.count(), 1)
self.assertIn("PAID DRAW", post.lines.first().text)
self.assertEqual(
Brief.objects.filter(owner=self.user, kind=Brief.KIND_TAX_LEDGER).count(),
1,
)
def test_paid_draw_commit_clears_paid_draw_dismissed_anchor(self):
self.user.paid_draw_brief_dismissed_at = (
timezone.now() - timedelta(hours=3)
)
self.user.save(update_fields=["paid_draw_brief_dismissed_at"])
self.client.post(reverse("my_sea_paid_draw"))
self.user.refresh_from_db()
self.assertIsNone(self.user.paid_draw_brief_dismissed_at)
class DismissBriefEndpointsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="dismiss@test.io")
self.client.force_login(self.user)
def test_dismiss_free_draw_brief_stamps_user(self):
self.assertIsNone(self.user.free_draw_brief_dismissed_at)
before = timezone.now()
response = self.client.post(reverse("my_sea_dismiss_free_draw_brief"))
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertIsNotNone(self.user.free_draw_brief_dismissed_at)
self.assertGreaterEqual(self.user.free_draw_brief_dismissed_at, before)
def test_dismiss_paid_draw_brief_stamps_user(self):
before = timezone.now()
response = self.client.post(reverse("my_sea_dismiss_paid_draw_brief"))
self.assertEqual(response.status_code, 204)
self.user.refresh_from_db()
self.assertIsNotNone(self.user.paid_draw_brief_dismissed_at)
self.assertGreaterEqual(self.user.paid_draw_brief_dismissed_at, before)
def test_dismiss_endpoints_require_login(self):
self.client.logout()
for name in (
"my_sea_dismiss_free_draw_brief",
"my_sea_dismiss_paid_draw_brief",
):
response = self.client.post(reverse(name))
self.assertEqual(response.status_code, 302, name)
def test_dismiss_endpoints_reject_get(self):
for name in (
"my_sea_dismiss_free_draw_brief",
"my_sea_dismiss_paid_draw_brief",
):
response = self.client.get(reverse(name))
self.assertEqual(response.status_code, 405, name)
class MySeaContextBriefPayloadTest(TestCase):
"""The my_sea view exposes `free_draw_brief_payload` /
`paid_draw_brief_payload` to the template — None when no
eligible brief OR when dismissed anchor is recent enough."""
def setUp(self):
self.user = User.objects.create(email="payload@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
def test_payload_none_until_first_free_draw(self):
response = self.client.get(reverse("my_sea"))
self.assertIsNone(response.context["free_draw_brief_payload"])
self.assertIsNone(response.context["paid_draw_brief_payload"])
def test_free_draw_brief_payload_populated_after_first_card(self):
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(self.target.id)),
content_type="application/json",
)
response = self.client.get(reverse("my_sea"))
payload = response.context["free_draw_brief_payload"]
self.assertIsNotNone(payload)
self.assertIn("FREE DRAW", payload["line_text"])
# dismiss_url is the new endpoint URL
self.assertEqual(
payload["dismiss_url"],
reverse("my_sea_dismiss_free_draw_brief"),
)
def test_free_draw_brief_payload_suppressed_after_dismiss(self):
# Spend a free draw, then dismiss the brief — subsequent GET
# to /my-sea/ should omit the payload.
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(self.target.id)),
content_type="application/json",
)
self.client.post(reverse("my_sea_dismiss_free_draw_brief"))
response = self.client.get(reverse("my_sea"))
self.assertIsNone(response.context["free_draw_brief_payload"])
def test_payload_returns_after_fresh_cycle_resets_dismissal(self):
# First cycle: spend + dismiss → suppressed.
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(self.target.id)),
content_type="application/json",
)
self.client.post(reverse("my_sea_dismiss_free_draw_brief"))
# Simulate 25h passing + the user starting a fresh FREE DRAW.
# `_lock` will clear the dismissal anchor + emit a new debit.
self.user.last_free_draw_at = timezone.now() - timedelta(hours=25)
self.user.save(update_fields=["last_free_draw_at"])
# The post-DEL flow leaves the row alive — clear hand so the next
# /lock POST takes the first-card-of-cycle branch. Easiest: delete
# the row to simulate cycle reset; the view then re-creates one.
MySeaDraw.objects.filter(user=self.user).delete()
self.client.post(
reverse("my_sea_lock"),
data=json.dumps(_build_hand_payload(self.target.id)),
content_type="application/json",
)
response = self.client.get(reverse("my_sea"))
# New brief emitted; dismissal anchor cleared → payload present.
self.assertIsNotNone(response.context["free_draw_brief_payload"])

View File

@@ -1919,33 +1919,26 @@ class MySeaViewWithSavedDrawTest(TestCase):
self.assertIn('data-phase="picker"', html)
def test_view_triggers_brief_banner_when_active_draw_exists(self):
# Brief is rendered client-side via Brief.showBanner (standard
# `.note-banner` w. Gaussian-glass bg, portaled atop the h2
# same UX as my-notes / my-sign default-deck-warning Briefs).
# Server emits a `window._showFreeDrawLockedBrief("<iso>")` call
# gated on active_draw; ISO timestamp (`|date:'c'`) is re-used
# as both `created_at` AND the source for the human-formatted
# display string note.js renders in the `.note-banner__timestamp`
# slot — single source of truth, no "Invalid Date" on bad input.
# Brief is rendered client-side via `Brief.showBanner` (standard
# `.note-banner` w. Gaussian-glass bg, portaled atop the h2). The
# server emits the @taxman ledger Brief payload as a JSON script
# tag (`id_free_draw_brief_payload`); `_showTaxBrief` reads + dispatches.
# Payload is None (script tag absent) when the user has NVM-dismissed
# since the cycle began — see test_view_suppresses_brief_when_dismissed.
# The setUp's row was created directly via ORM (no `my_sea_lock` POST)
# so we seed a Brief here to mirror what `my_sea_lock` would emit.
from apps.billboard.tax import log_tax_debit
log_tax_debit(self.user, "free_draw_locked")
response = self.client.get(reverse("my_sea"))
# Match the call form w. opening quote — the bare token
# `_showFreeDrawLockedBrief(` also appears in the function
# definition emitted unconditionally inside the picker IIFE.
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
# The ISO format produced by Django's `|date:'c'` starts with the
# full year + ISO-style T separator — pin a representative token.
from django.utils import timezone
from datetime import timedelta
expected_year = (timezone.now() + timedelta(hours=24)).strftime("%Y")
self.assertContains(response, '_showFreeDrawLockedBrief("' + expected_year)
self.assertContains(response, 'id="id_free_draw_brief_payload"')
def test_view_does_not_trigger_brief_banner_without_active_draw(self):
# Definition of `_showFreeDrawLockedBrief` is always emitted;
# only the CALL is gated on active_draw. Pin the call form.
# No active draw row → no Brief payload regardless of any prior
# `_tax_brief_payload` query result.
from apps.gameboard.models import MySeaDraw
MySeaDraw.objects.all().delete()
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'window._showFreeDrawLockedBrief("')
self.assertNotContains(response, 'id="id_free_draw_brief_payload"')
def test_view_wires_del_button_to_shared_guard_portal_when_active_draw(self):
# No my-sea-specific guard markup — the picker IIFE calls
@@ -2040,8 +2033,14 @@ class MySeaViewWithEmptyHandTest(TestCase):
# Quota's still committed (row exists, 24h clock still running) →
# the Brief banner is part of the saved-draw context, regardless
# of hand state. Informs the user when the next free draw is.
# Server emits the @taxman ledger Brief payload via json_script
# when active_draw exists + a TAX_LEDGER brief is present for the
# current cycle. Seed one explicitly (the setUp creates the row
# directly via ORM, bypassing `my_sea_lock`'s tax-debit emission).
from apps.billboard.tax import log_tax_debit
log_tax_debit(self.user, "free_draw_locked")
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
self.assertContains(response, 'id="id_free_draw_brief_payload"')
class MySeaViewWithPartialHandTest(TestCase):
@@ -2421,11 +2420,13 @@ class MySeaCooldownAnchoredToFreeDrawTest(TestCase):
"user-spec 2026-05-23")
def test_brief_next_free_draw_at_uses_user_anchor_not_paid_row(self):
# The view passes `next_free_draw_at` to the template as ISO
# — the Brief script in my_sea.html surfaces this directly.
# Anchor: user's last_free_draw_at + 24h, NOT row.created_at
# + 24h (which after PAID DRAW would point 24h past the paid
# commit, not 24h past the free draw).
# The view passes `next_free_draw_at` to the template context
# (still consumed by SCSS / inline JS for "next free draw at"
# affordances even though the @taxman Brief copy itself no longer
# embeds an absolute timestamp — user-spec 2026-05-26). Anchor:
# user's last_free_draw_at + 24h, NOT row.created_at + 24h (which
# after PAID DRAW would point 24h past the paid commit, not 24h
# past the free draw).
original_anchor = timezone.now() - timedelta(hours=6)
self._seed_used_free_draw(when=original_anchor)
# The active row's `created_at` matches the seed time (free
@@ -2438,13 +2439,15 @@ class MySeaCooldownAnchoredToFreeDrawTest(TestCase):
row.created_at = timezone.now() # "PAID DRAW just created me"
row.save(update_fields=["created_at"])
response = self.client.get(reverse("my_sea"))
# The user's next_free_draw_at = anchor + 24h, NOT row.created_at
# + 24h. Differs by ~6h; check that the rendered ISO matches the
# user-level anchor (truncated to date+hour for stability).
expected_user_iso = (
original_anchor + timedelta(hours=24)
).isoformat()[:13] # "YYYY-MM-DDTHH" — date + hour
self.assertIn(expected_user_iso, response.content.decode())
expected = original_anchor + timedelta(hours=24)
actual = response.context["next_free_draw_at"]
# Allow up to 1s wobble (timestamps stamp at slightly different
# ticks); anchor MUST track user.last_free_draw_at, not row.created_at
# (which would put `actual` ~6h later than `expected`).
self.assertLess(
abs((actual - expected).total_seconds()), 1.0,
"next_free_draw_at must derive from User.last_free_draw_at + 24h",
)
def test_paid_draw_commit_makes_landing_show_paid_draw_btn(self):
# End-to-end of the user-reported bug: deposit → PAID DRAW commit

View File

@@ -21,5 +21,9 @@ urlpatterns = [
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/invite', views.my_sea_invite, name='my_sea_invite'),
path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief,
name='my_sea_dismiss_free_draw_brief'),
path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief,
name='my_sea_dismiss_paid_draw_brief'),
]

View File

@@ -306,6 +306,27 @@ def my_sea(request):
"name": c.name if c else "",
}
# @taxman Brief payloads w. NVM-persistence (user-spec 2026-05-26). The
# FREE DRAW Brief surfaces ONLY when an active draw exists AND the user
# hasn't NVM-dismissed since the cycle began. PAID DRAW Brief is
# independent — surfaces while a PAID DRAW commit has happened in this
# cycle (paid_through_at OR a prior commit) AND the paid-draw NVM
# anchor is older than the latest paid-draw brief.
from django.urls import reverse
free_draw_brief_payload = None
paid_draw_brief_payload = None
if active_draw is not None:
free_draw_brief_payload = _tax_brief_payload(
request.user, "FREE DRAW",
request.user.free_draw_brief_dismissed_at,
reverse("my_sea_dismiss_free_draw_brief"),
)
paid_draw_brief_payload = _tax_brief_payload(
request.user, "PAID DRAW",
request.user.paid_draw_brief_dismissed_at,
reverse("my_sea_dismiss_paid_draw_brief"),
)
return render(request, "apps/gameboard/my_sea.html", {
"user_has_sig": user_has_sig,
"no_equipped_deck": no_equipped_deck,
@@ -332,6 +353,9 @@ def my_sea(request):
"deposit_reserved": deposit_reserved,
"paid_through": paid_through,
"hand_non_empty": hand_non_empty,
# TAX_LEDGER brief payloads (None when not eligible to show)
"free_draw_brief_payload": free_draw_brief_payload,
"paid_draw_brief_payload": paid_draw_brief_payload,
"page_class": "page-gameboard page-my-sea",
})
@@ -347,6 +371,31 @@ def _resolve_sig(user, active_draw):
return user.significator, user.significator_reversed
def _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url):
"""Return `Brief.to_banner_dict()` (w. `dismiss_url` populated) for the
user's latest TAX_LEDGER Brief whose Line text contains `slug_marker`
(e.g. "FREE DRAW" / "PAID DRAW"), IF the user's `dismissed_at` anchor
is None OR strictly less than the brief's `created_at`. Returns None
otherwise — used by the my_sea view to drive conditional Brief render
in the template w. NVM-persistence semantics per user-spec 2026-05-26.
Slug-marker filtering on Line text keeps the Brief model schema flat
(no sub_kind discriminator); FREE/PAID DRAW are the only two TAX_
LEDGER markers today + their wordings are non-overlapping."""
from apps.billboard.models import Brief
brief = (Brief.objects
.filter(owner=user, kind=Brief.KIND_TAX_LEDGER,
line__text__contains=slug_marker)
.order_by("-created_at").first())
if brief is None:
return None
if dismissed_at is not None and dismissed_at >= brief.created_at:
return None
payload = brief.to_banner_dict()
payload["dismiss_url"] = dismiss_url
return payload
@login_required(login_url="/")
@require_POST
def my_sea_lock(request):
@@ -429,7 +478,15 @@ def my_sea_lock(request):
if not request.user.free_draw_cooldown_active:
request.user.last_free_draw_at = timezone.now()
request.user.save(update_fields=["last_free_draw_at"])
# Fresh FREE DRAW cycle → clear the NVM-dismissal anchor so the
# newly-emitted Brief surfaces on the next page load. User-spec
# 2026-05-26: NVM dismissal persists ONLY until the next FREE
# DRAW spend; once that lands, the new ledger entry re-opens the
# Brief surface for the new cycle.
request.user.free_draw_brief_dismissed_at = None
request.user.save(update_fields=[
"last_free_draw_at", "free_draw_brief_dismissed_at",
])
draw = MySeaDraw.objects.create(
user=request.user,
@@ -438,6 +495,16 @@ def my_sea_lock(request):
significator_id=sig_id,
significator_reversed=request.user.significator_reversed,
)
# Append the @taxman ledger entry + spawn the Brief. Response carries
# the Brief payload so the picker IIFE can surface the banner in-place
# w.o. a page reload — same affordance the prior in-template
# `_showFreeDrawLockedBrief` provided, just w. server-authored copy +
# NVM-persistence via `dismiss_url`.
from django.urls import reverse
from apps.billboard.tax import log_tax_debit
_, _, brief = log_tax_debit(request.user, "free_draw_locked")
brief_payload = brief.to_banner_dict()
brief_payload["dismiss_url"] = reverse("my_sea_dismiss_free_draw_brief")
return JsonResponse({
"ok": True,
"next_free_draw_at": (
@@ -445,6 +512,7 @@ def my_sea_lock(request):
if request.user.next_free_draw_at else None
),
"hand_complete": draw.is_hand_complete,
"free_draw_brief_payload": brief_payload,
})
@@ -601,9 +669,43 @@ def my_sea_paid_draw(request):
"deposit_token_id", "deposit_reserved_at",
"paid_through_at", "hand",
])
# Fresh PAID DRAW commit → clear the PAID DRAW NVM-dismissal anchor +
# append the @taxman ledger entry / spawn the Brief. Per user-spec
# 2026-05-26 the PAID DRAW Brief NVM-persistence is independent from
# FREE DRAW's; each cycle's dismissal lifts on its own next-spend.
request.user.paid_draw_brief_dismissed_at = None
request.user.save(update_fields=["paid_draw_brief_dismissed_at"])
from apps.billboard.tax import log_tax_debit
log_tax_debit(request.user, "paid_draw_locked")
return redirect(reverse("my_sea") + "?phase=picker")
@login_required(login_url="/")
@require_POST
def my_sea_dismiss_free_draw_brief(request):
"""Stamp `User.free_draw_brief_dismissed_at` so the FREE DRAW Brief
stays suppressed on subsequent page loads until the next FREE DRAW is
spent (`my_sea_lock` clears the anchor on a fresh-cycle commit, re-
opening the Brief surface for the new cycle).
User-spec 2026-05-26: NVM-persistence for the FREE DRAW Brief. Fire-
and-forget POST from `note.js`'s NVM click handler; returns 204."""
request.user.free_draw_brief_dismissed_at = timezone.now()
request.user.save(update_fields=["free_draw_brief_dismissed_at"])
return HttpResponse(status=204)
@login_required(login_url="/")
@require_POST
def my_sea_dismiss_paid_draw_brief(request):
"""Stamp `User.paid_draw_brief_dismissed_at` so the PAID DRAW Brief
stays suppressed on subsequent page loads until the next PAID DRAW
commit. Mirror of `my_sea_dismiss_free_draw_brief`."""
request.user.paid_draw_brief_dismissed_at = timezone.now()
request.user.save(update_fields=["paid_draw_brief_dismissed_at"])
return HttpResponse(status=204)
@login_required(login_url="/")
@require_POST
def my_sea_invite(request):

View File

@@ -0,0 +1,47 @@
# Generated 2026-05-26 — fields for FREE/PAID DRAW Brief NVM-persistence +
# seed the @taxman system user (Debits & credits ledger author). Pairs w.
# `billboard/0008_tax_ledger_kind` for the new Post/Brief kind registration.
from django.contrib.auth.hashers import make_password
from django.db import migrations, models
def seed_taxman(apps, schema_editor):
"""Mirror `0003_seed_adman` for the new @taxman system user — authors
the Debits & credits ledger Lines per user-spec 2026-05-26."""
User = apps.get_model("lyric", "User")
User.objects.get_or_create(
username="taxman",
defaults={
"email": "taxman@earthmanrpg.local",
"password": make_password(None),
"is_staff": False,
"is_superuser": False,
"searchable": False,
},
)
def reverse_noop(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('lyric', '0013_user_last_free_draw_at'),
]
operations = [
migrations.AddField(
model_name='user',
name='free_draw_brief_dismissed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='user',
name='paid_draw_brief_dismissed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.RunPython(seed_taxman, reverse_noop),
]

View File

@@ -47,7 +47,7 @@ def resolve_pronouns(pronouns_key):
# username and existing tests assign it; revisit if/when other-entity
# impersonation becomes a concrete concern.
RESERVED_USERNAMES = frozenset({"adman"})
RESERVED_USERNAMES = frozenset({"adman", "taxman"})
def is_reserved_username(name, current_user=None):
@@ -79,6 +79,26 @@ def get_or_create_adman():
return adman
def get_or_create_taxman():
"""Idempotent fetch of the sitewide `taxman` User — system-author for
Debits & credits ledger Lines (FREE/PAID DRAW spend log per user-spec
2026-05-26). Parallels `get_or_create_adman` exactly; production
migration `lyric/0014_seed_taxman_and_brief_dismissal_fields` seeds
the row once, this helper backstops TransactionTestCase flushes."""
from django.contrib.auth.hashers import make_password
taxman, _ = User.objects.get_or_create(
username="taxman",
defaults={
"email": "taxman@earthmanrpg.local",
"password": make_password(None),
"is_staff": False,
"is_superuser": False,
"searchable": False,
},
)
return taxman
class UserManager(BaseUserManager):
def create_user(self, email):
user = self.model(email=email)
@@ -144,6 +164,16 @@ class User(AbstractBaseUser):
# banner's next-free-draw timestamp + the landing-button state machine
# (FREE DRAW vs GATE VIEW vs PAID DRAW).
last_free_draw_at = models.DateTimeField(null=True, blank=True)
# NVM-dismissal anchors for the FREE/PAID DRAW "locked" Briefs (user-spec
# 2026-05-26). When the user clicks NVM on either Brief, the corresponding
# timestamp gets stamped via the dismiss-brief POST endpoints; the my_sea
# view then suppresses the Brief on subsequent page loads as long as the
# dismissal is more recent than the cycle's anchor moment. `my_sea_lock`
# clears `free_draw_brief_dismissed_at` when a fresh FREE DRAW lands;
# `my_sea_paid_draw` clears `paid_draw_brief_dismissed_at` on commit —
# so each fresh spend re-opens the Brief surface for that cycle.
free_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True)
paid_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True)
ap_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="")

View File

@@ -0,0 +1,61 @@
"""FT — @taxman "Debits & credits" Post detail (user-spec 2026-05-26).
After a FREE/PAID DRAW spend at /gameboard/my-sea/, the @taxman ledger
Post is reachable at /billboard/post/<uuid>/. The detail page renders the
ledger Line(s) with the @taxman handle, the readonly "No response needed
at this time" input (parallel to the @adman Note-unlock Post), and the
display strips the `[<iso timestamp>] ` prefix that the underlying Line
text carries to satisfy `unique_together = (post, text)`.
Mirrors `test_bill_post_gear.py`'s setup shape — seed via the ORM (the
`log_tax_debit` helper) instead of walking the my_sea picker UI, so the FT
focuses on the post-detail surface, not the spend trigger."""
from selenium.webdriver.common.by import By
from apps.billboard.models import Post
from apps.billboard.tax import log_tax_debit
from apps.lyric.models import User
from .base import FunctionalTest
class DebitsAndCreditsPostTest(FunctionalTest):
def setUp(self):
super().setUp()
self.user = User.objects.create(email="taxpayer@test.io")
# Seed two debits — covers both slug templates.
log_tax_debit(self.user, "free_draw_locked")
log_tax_debit(self.user, "paid_draw_locked")
self.post = Post.objects.get(
owner=self.user, kind=Post.KIND_TAX_LEDGER,
)
self.create_pre_authenticated_session("taxpayer@test.io")
def test_debits_credits_post_renders_readonly_input_with_no_response_placeholder(self):
self.browser.get(
self.live_server_url + f"/billboard/post/{self.post.id}/"
)
# Page text already decodes "Debits &amp; credits" → "Debits & credits".
self.wait_for(
lambda: self.assertIn(
"Debits & credits",
self.browser.find_element(By.TAG_NAME, "body").text,
)
)
# The response input is readonly w. the system-author placeholder.
field = self.browser.find_element(By.ID, "id_post_line_text")
self.assertEqual(
field.get_attribute("placeholder"),
"No response needed at this time",
)
self.assertTrue(field.get_attribute("readonly"))
# Both seeded debit Lines render w. the canonical body text, NOT
# the raw `[<iso>] ` prefix (Line.display_text strips it).
page_text = self.browser.find_element(By.TAG_NAME, "body").text
self.assertIn("FREE DRAW is locked", page_text)
self.assertIn("PAID DRAW is locked", page_text)
self.assertNotIn("[20", page_text,
"The `[<iso timestamp>] ` prefix on Line.text must NOT display "
"— Line.display_text strips it for TAX_LEDGER posts")
# @taxman attribution renders on each line.
self.assertIn("@taxman", page_text)

View File

@@ -290,11 +290,23 @@ body.page-billposts {
white-space: nowrap;
}
// System-authored Lines (adman) get a subtler typographic key
// — the inline `<a class="note-ref">` carries the emphasis.
// System-authored Lines (adman + taxman) get a subtler typographic
// key — the inline `<a class="note-ref">` (adman) or `<span class
// ="btn-pri-name">` (taxman) carries the emphasis.
&.post-line--system .post-line-text {
font-style: italic;
opacity: 0.85;
// .btn-pri-name — inline emphasis on canonical .btn-primary
// button labels referenced in @taxman ledger prose (FREE
// DRAW, PAID DRAW, GATE VIEW). Mirrors the user-facing
// affordance so the reader recognizes the same token they
// see on the actual button. User-spec 2026-05-26.
.btn-pri-name {
color: rgba(var(--quaUser), 1);
font-weight: 700;
font-style: normal;
}
}
}

View File

@@ -39,20 +39,20 @@
<ul id="id_post_table" class="post-lines">
{% for line in post.lines.all %}
<li class="post-line {% if line.author.username == 'adman' %}post-line--system{% endif %}">
<li class="post-line {% if line.author.username == 'adman' or line.author.username == 'taxman' %}post-line--system{% endif %}">
<span class="post-line-author">{{ line.author|at_handle }}</span>
<span class="post-line-text">{# adman-authored Lines (note unlock + share invite system prose) carry an `<a class="note-ref">` anchor that needs to render as HTML. User-typed Lines stay escaped. #}{% if line.author.username == 'adman' %}{{ line.text|safe }}{% else %}{{ line.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' %}{{ 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>
</li>
{% endfor %}
<li class="post-line-buffer" aria-hidden="true"></li>
</ul>
{# Admin-Post (note-unlock thread) input is read-only: the user can't #}
{# respond, and the placeholder calls that out. View_post hard-rejects #}
{# POSTs to NOTE_UNLOCK posts; the post_save Line signal is the safety #}
{# net for ORM-level / API writes that bypass the view. #}
{% if post.kind == 'note_unlock' %}
{# System-author Posts (note unlocks, tax ledger) — input is read-only:#}
{# 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' %}
<form id="id_post_line_form" class="post-line-form">
<input
id="id_post_line_text"
@@ -83,9 +83,9 @@
{% endif %}
{# Bud btn (bottom-left) + slide-out recipient field — async share. #}
{# Suppressed on admin Posts (note unlock thread) since friend-invites #}
{# don't apply to system-authored threads. #}
{% if post.kind != 'note_unlock' %}
{# 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' %}
{% include "apps/billboard/_partials/_bud_panel.html" %}
{% endif %}

View File

@@ -769,46 +769,36 @@
return DAYS[d.getDay()] + ', ' + MONTHS[d.getMonth()]
+ ' ' + d.getDate() + ' @ ' + h + ':' + mm + ' ' + ampm;
}
window._showFreeDrawLockedBrief = function (iso) {
// Standard Brief banner — portaled atop the h2 w.
// Gaussian-glass bg (see [[note.js]] showBanner). The
// next-free-draw moment is passed as an ISO string +
// re-used as `created_at` so note.js's `<time
// class="note-banner__timestamp">` slot renders the
// datetime instead of "Invalid Date" (which it does
// for empty/invalid input). The `line_text` carries
// only the contextual prose now — the dedicated slot
// owns the timestamp display.
if (!window.Brief || !Brief.showBanner) return;
Brief.showBanner({
title: 'Free draw locked',
line_text:
'Look!&mdash;your free draw is locked in. ' +
'Next free draw available at:',
post_url: '{% url "gameboard" %}',
created_at: iso,
kind: 'NUDGE',
});
// Render an arbitrary TAX_LEDGER Brief payload as a slide-down
// banner. Server-driven via `free_draw_brief_payload` /
// `paid_draw_brief_payload` from the my_sea view's context;
// the payload is exactly `Brief.to_banner_dict()` + a populated
// `dismiss_url`. `note.js`'s NVM handler POSTs to dismiss_url
// on click → server stamps the user's anchor → suppressed on
// future loads until the cycle resets. User-spec 2026-05-26.
window._showTaxBrief = function (payload, bannerClass) {
if (!payload || !window.Brief || !Brief.showBanner) return;
Brief.showBanner(payload);
var banner = document.querySelector('.note-banner');
if (banner) {
banner.classList.add('my-sea-locked-banner');
// note.js renders the timestamp as `toLocaleDateString`
// (e.g., "May 20, 2026") — short-form, no time. Our
// use case wants the full `D, M j @ g:i A` shape
// (e.g., "Wed, May 20 @ 11:57 PM") so the user sees
// both the date AND the precise unlock hour. Overwrite
// the rendered text in-place (leaves the `datetime=`
// attribute intact for accessibility tooling).
banner.classList.add(bannerClass);
// Full date+time stamp (D, M j @ g:i A) — note.js
// renders a short `toLocaleDateString` by default;
// these Briefs want the precise log moment visible.
var ts = banner.querySelector('.note-banner__timestamp');
if (ts && iso) ts.textContent = _formatTimestamp(iso);
// No FYI on this Brief — it's an informational nudge
// (locked draw status), not a navigation target. The
// NVM button + timestamp slot carry all the affordance
// the user needs.
var fyi = banner.querySelector('.note-banner__fyi');
if (fyi) fyi.remove();
if (ts && payload.created_at) {
ts.textContent = _formatTimestamp(payload.created_at);
}
}
};
// Backwards-compat shim used by `_postLock` below (which
// surfaces the freshly-emitted Brief in-place without a page
// reload). The view's `my_sea_lock` response carries the new
// Brief payload — `_postLock` retrieves the latest one via
// a follow-up GET (cheaper than refetching the full page).
window._showFreeDrawLockedBrief = function (payload) {
window._showTaxBrief(payload, 'my-sea-locked-banner');
};
function _postLock(hand) {
// Returns the parsed JSON body promise so callers (e.g.
// _autoDraw) can chain animation onto server commit.
@@ -830,9 +820,13 @@
}).then(function (r) {
return r.ok ? r.json() : null;
}).then(function (body) {
if (body && body.next_free_draw_at
// First-card-of-cycle response carries the freshly-
// emitted Brief payload — surface it in-place w.o. a
// page reload. Subsequent mid-draw POSTs omit the
// payload (no new debit fires) so this no-ops.
if (body && body.free_draw_brief_payload
&& !document.querySelector('.my-sea-locked-banner')) {
window._showFreeDrawLockedBrief(body.next_free_draw_at);
window._showFreeDrawLockedBrief(body.free_draw_brief_payload);
}
return body;
});
@@ -1015,19 +1009,31 @@
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
{# any other Briefs on the page. note.js itself is hoisted #}
{# to the top of {% block content %} (single load per page). #}
{% if active_draw %}
{# Iter 4b — saved-draw Brief. Standard portaled banner via #}
{# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}
{# the on-LOCK-success path inside the picker IIFE calls the #}
{# same `window._showFreeDrawLockedBrief` so a freshly-locked #}
{# hand gets the identical UX without a page reload. Pass an #}
{# ISO timestamp (`|date:'c'`) so note.js's `<time>` slot #}
{# parses cleanly instead of rendering "Invalid Date". #}
{# @taxman Briefs — server-driven render. Payloads are populated by #}
{# the my_sea view's `_tax_brief_payload` helper, which returns #}
{# None when the user's NVM-dismissal anchor is more recent than #}
{# the latest TAX_LEDGER Brief of that slug (FREE/PAID DRAW). User- #}
{# spec 2026-05-26: Briefs persist-dismiss until the next spend #}
{# resets the cycle. `dismiss_url` is in the payload — note.js #}
{# POSTs it on NVM click + the my_sea_lock/paid_draw views clear #}
{# the matching User.*_brief_dismissed_at on the next spend. #}
{% if free_draw_brief_payload %}
{{ free_draw_brief_payload|json_script:"id_free_draw_brief_payload" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
if (window._showFreeDrawLockedBrief) {
window._showFreeDrawLockedBrief("{{ next_free_draw_at|date:'c' }}");
}
var el = document.getElementById('id_free_draw_brief_payload');
if (!el || !window._showTaxBrief) return;
window._showTaxBrief(JSON.parse(el.textContent), 'my-sea-locked-banner');
});
</script>
{% endif %}
{% if paid_draw_brief_payload %}
{{ paid_draw_brief_payload|json_script:"id_paid_draw_brief_payload" }}
<script>
document.addEventListener('DOMContentLoaded', function () {
var el = document.getElementById('id_paid_draw_brief_payload');
if (!el || !window._showTaxBrief) return;
window._showTaxBrief(JSON.parse(el.textContent), 'my-sea-paid-locked-banner');
});
</script>
{% endif %}