@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:
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):
|
||||
|
||||
Reference in New Issue
Block a user