@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:
23
src/apps/billboard/migrations/0008_tax_ledger_kind.py
Normal file
23
src/apps/billboard/migrations/0008_tax_ledger_kind.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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
100
src/apps/billboard/tax.py
Normal 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
|
||||
121
src/apps/billboard/tests/integrated/test_tax.py
Normal file
121
src/apps/billboard/tests/integrated/test_tax.py
Normal 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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
250
src/apps/gameboard/tests/integrated/test_tax_briefs.py
Normal file
250
src/apps/gameboard/tests/integrated/test_tax_briefs.py
Normal 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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
47
src/apps/lyric/migrations/0014_brief_dismissal_fields.py
Normal file
47
src/apps/lyric/migrations/0014_brief_dismissal_fields.py
Normal 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),
|
||||
]
|
||||
@@ -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="")
|
||||
|
||||
|
||||
61
src/functional_tests/test_bill_post_debits_credits.py
Normal file
61
src/functional_tests/test_bill_post_debits_credits.py
Normal 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 & 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)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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!—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 %}
|
||||
|
||||
Reference in New Issue
Block a user