diff --git a/src/apps/billboard/migrations/0008_tax_ledger_kind.py b/src/apps/billboard/migrations/0008_tax_ledger_kind.py new file mode 100644 index 0000000..0a36293 --- /dev/null +++ b/src/apps/billboard/migrations/0008_tax_ledger_kind.py @@ -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), + ), + ] diff --git a/src/apps/billboard/models.py b/src/apps/billboard/models.py index 10befca..4b4d6b2 100644 --- a/src/apps/billboard/models.py +++ b/src/apps/billboard/models.py @@ -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 + `[] ` 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 `[] ` 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() diff --git a/src/apps/billboard/tax.py b/src/apps/billboard/tax.py new file mode 100644 index 0000000..49ab734 --- /dev/null +++ b/src/apps/billboard/tax.py @@ -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 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.' + ), +} + + +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 `[] ` 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 diff --git a/src/apps/billboard/tests/integrated/test_tax.py b/src/apps/billboard/tests/integrated/test_tax.py new file mode 100644 index 0000000..77addfd --- /dev/null +++ b/src/apps/billboard/tests/integrated/test_tax.py @@ -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") diff --git a/src/apps/billboard/views.py b/src/apps/billboard/views.py index d2dc297..5e00c26 100644 --- a/src/apps/billboard/views.py +++ b/src/apps/billboard/views.py @@ -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) diff --git a/src/apps/dashboard/static/apps/dashboard/note.js b/src/apps/dashboard/static/apps/dashboard/note.js index 7102c12..f86d10a 100644 --- a/src/apps/dashboard/static/apps/dashboard/note.js +++ b/src/apps/dashboard/static/apps/dashboard/note.js @@ -48,6 +48,22 @@ const Brief = (() => { 'FYI'; 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(); }); diff --git a/src/apps/gameboard/tests/integrated/test_tax_briefs.py b/src/apps/gameboard/tests/integrated/test_tax_briefs.py new file mode 100644 index 0000000..7d5832e --- /dev/null +++ b/src/apps/gameboard/tests/integrated/test_tax_briefs.py @@ -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"]) diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index b381e4a..720d7aa 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -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("")` 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 diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index 4eefe6f..d72b5e2 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -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'), ] diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 0b720fc..ea04656 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -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): diff --git a/src/apps/lyric/migrations/0014_brief_dismissal_fields.py b/src/apps/lyric/migrations/0014_brief_dismissal_fields.py new file mode 100644 index 0000000..a27607d --- /dev/null +++ b/src/apps/lyric/migrations/0014_brief_dismissal_fields.py @@ -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), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index c0e7ef0..7bb4e02 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -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="") diff --git a/src/functional_tests/test_bill_post_debits_credits.py b/src/functional_tests/test_bill_post_debits_credits.py new file mode 100644 index 0000000..c83aade --- /dev/null +++ b/src/functional_tests/test_bill_post_debits_credits.py @@ -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//. 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 `[] ` 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 & 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 `[] ` 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 `[] ` 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) diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index b777f63..9aa82fe 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -290,11 +290,23 @@ body.page-billposts { white-space: nowrap; } - // System-authored Lines (adman) get a subtler typographic key - // — the inline `` carries the emphasis. + // System-authored Lines (adman + taxman) get a subtler typographic + // key — the inline `` (adman) or `` (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; + } } } diff --git a/src/templates/apps/billboard/post.html b/src/templates/apps/billboard/post.html index a1f1a64..eec91c8 100644 --- a/src/templates/apps/billboard/post.html +++ b/src/templates/apps/billboard/post.html @@ -39,20 +39,20 @@ - {# 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' %}
` 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!—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 `