Files
python-tdd/src/apps/lyric/models.py

359 lines
14 KiB
Python
Raw Normal View History

import uuid
from datetime import timedelta
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
fix: `Token.PASS` is now model-enforced as staff-only — `Token.clean`/`save` raise ValidationError when a non-staff user is the FK target. Staging bug 2026-05-21 — admin awarded a PASS to a non-admin via Django admin; row was created + showed in the user's wallet, but every game-side surface (gameboard, game-kit, gate-pad `select_token`, `_select_my_sea_token`) had always filtered PASS behind `is_staff`, so the token was unequippable + unusable. Five `is_staff`-gated PASS surfaces made PASS a deliberate staff-only trinket; the wallet was the lone outlier surfacing it. Bundled: wallet view (+ HTMX toggle partial) now gates `pass_token` behind `is_staff` mirroring the gameboard pattern — defense-in-depth in case any future bypass writes a stray row. TDD — new ITs: `PassTokenStaffOnlyGuardTest` (model raises for non-staff, accepts for staff, leaves other token types unaffected); `WalletPassTokenVisibilityTest` (3 cases pin wallet + HTMX gating); `TokenAdminFormTest.test_pass_token_for_non_staff_user_is_invalid` + `test_pass_token_for_staff_user_is_valid`. Adjusted 2 existing tests that incidentally exercised the now-blocked pattern (`test_paid_draw_with_pass_does_not_consume`, `test_pass_token_is_not_consumed` — both flip `is_staff = True` inline before `Token.objects.create`); dropped PASS from `test_other_token_types_do_not_require_expires_at`'s loop (covered by the new dedicated tests). 1133 IT/UT green. A non-admin "boost-pass" variant lands as a distinct `token_type` later, NEVER by relaxing the staff gate — captured in [[feedback-pass-token-staff-only]] Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:35:55 -04:00
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD - User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns - drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their" - SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched - new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio - card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses - dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE - _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question - billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
# ── Pronoun preference set ────────────────────────────────────────────────
# Drives provenance prose ("embodies as their Significator …") and any other
# user-facing referent. Default is pluralism (singular they) so a brand-new
# account renders neutrally; bawlmorese (yo/yo/yos) is the original Earthman
# default kept available as a Baltimore-flavoured option.
PRONOUN_CHOICES = [
("pluralism", "they/them/their"),
("bawlmorese", "yo/yo/yos"),
("misogyny", "he/him/his"),
("misandry", "she/her/hers"),
("misanthropy", "it/it/its"),
]
PRONOUN_TABLE = {
"pluralism": {"subj": "they", "obj": "them", "poss": "their"},
"bawlmorese": {"subj": "yo", "obj": "yo", "poss": "yos"},
"misogyny": {"subj": "he", "obj": "him", "poss": "his"},
"misandry": {"subj": "she", "obj": "her", "poss": "hers"},
"misanthropy": {"subj": "it", "obj": "it", "poss": "its"},
}
def resolve_pronouns(pronouns_key):
"""Return (subj, obj, poss) for a pronouns key, defaulting to pluralism."""
row = PRONOUN_TABLE.get(pronouns_key) or PRONOUN_TABLE["pluralism"]
return row["subj"], row["obj"], row["poss"]
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD - schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner). - lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed). - drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default. - billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title. - post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio). - SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square. - _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self). - FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors. - rootvars.scss: minor plutonium + fuschia tweaks (pre-existing). - 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
# ── Reserved usernames ────────────────────────────────────────────────────
# Sitewide entities that shouldn't be impersonated by new account names.
# Compared lower-case in username assignment paths (set_profile, etc.).
# `adman` is the system author for Note-unlock + share-invite Lines (seeded
# in lyric/0003_seed_adman). The author's handles (disco, discoman,
# hamildong) are NOT in this set yet — discoman is the founder's actual
# username and existing tests assign it; revisit if/when other-entity
# impersonation becomes a concrete concern.
RESERVED_USERNAMES = frozenset({"adman"})
def is_reserved_username(name, current_user=None):
"""True if `name` is reserved AND not already owned by `current_user`."""
n = (name or "").strip().lower()
if not n:
return False
if current_user is not None and (current_user.username or "").lower() == n:
return False
return n in RESERVED_USERNAMES
def get_or_create_adman():
"""Idempotent fetch of the sitewide `adman` User — system-author for
Note-unlock + share-invite Lines. Production migrations seed it once
(lyric/0003_seed_adman); TransactionTestCase flushes the row between
tests, so view code that authors Lines as adman calls this helper."""
from django.contrib.auth.hashers import make_password
adman, _ = User.objects.get_or_create(
username="adman",
defaults={
"email": "adman@earthmanrpg.local",
"password": make_password(None),
"is_staff": False,
"is_superuser": False,
"searchable": False,
},
)
return adman
class UserManager(BaseUserManager):
def create_user(self, email):
user = self.model(email=email)
user.set_unusable_password()
user.save(using=self._db)
return user
def create_superuser(self, email, password):
user = self.model(email=email, is_staff=True, is_superuser=True)
user.set_password(password)
user.save(using=self._db)
return user
class LoginToken(models.Model):
email = models.EmailField()
uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(unique=True)
username = models.CharField(max_length=35, unique=True, null=True, blank=True)
searchable = models.BooleanField(default=False)
palette = models.CharField(max_length=32, default="palette-default")
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
equipped_trinket = models.ForeignKey(
"Token", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
equipped_deck = models.ForeignKey(
"epic.DeckVariant", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
unlocked_decks = models.ManyToManyField(
"epic.DeckVariant", blank=True, related_name="unlocked_by",
)
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
# Asymmetric self M2M — `user.buds.all()` = people I've explicitly
buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial) - lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design. - applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts). - billboard.views.my_buddies + add_buddy: • my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all(). • add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup). - templates: • _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/). • my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet." • _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add. - SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement). - tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op. - 841 ITs (+14) + 4 my_buddies FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:31:42 -04:00
# added (or implicitly via post-share / game-invite, which auto-adds
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD - lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud. - applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS. - billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor). - global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'. - new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty. - my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds). - my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts). - SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts. - 841 ITs + 5 my_buds/my_posts FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
# the recipient to the inviter's buds list). `user.added_as_bud` is
# the inverse (people who have me in their buds list); useful for
# the future "bud changed username" snapshot-accept flow.
buds = models.ManyToManyField(
"self", symmetrical=False, blank=True, related_name="added_as_bud",
buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial) - lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design. - applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts). - billboard.views.my_buddies + add_buddy: • my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all(). • add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup). - templates: • _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/). • my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet." • _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add. - SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement). - tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op. - 841 ITs (+14) + 4 my_buddies FTs green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:31:42 -04:00
)
active_title = models.ForeignKey(
"drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
# Global personal significator — chosen at /billboard/my-sig/ + persisted
# for reuse across My Sea draws (and eventually other contexts). Single
# FK; the orientation in `significator_reversed` (FLIP btn in the picker
# carousel) determines polarity at draw time.
significator = models.ForeignKey(
"epic.TarotCard", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
significator_reversed = models.BooleanField(default=False)
ap_public_key = models.TextField(blank=True, default="")
ap_private_key = models.TextField(blank=True, default="")
# Personal natal chart (My Sky) — independent of any game room/character
sky_birth_dt = models.DateTimeField(null=True, blank=True)
sky_birth_lat = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
sky_birth_lon = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
sky_birth_place = models.CharField(max_length=255, blank=True)
sky_birth_tz = models.CharField(max_length=64, blank=True)
sky_house_system = models.CharField(max_length=1, blank=True, default="O")
sky_chart_data = models.JSONField(null=True, blank=True)
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD - User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns - drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their" - SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched - new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio - card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses - dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE - _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question - billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
pronouns = models.CharField(
max_length=16, choices=PRONOUN_CHOICES, default="pluralism",
)
objects = UserManager()
REQUIRED_FIELDS = []
USERNAME_FIELD = "email"
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD - schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner). - lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed). - drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default. - billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title. - post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio). - SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square. - _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self). - FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors. - rootvars.scss: minor plutonium + fuschia tweaks (pre-existing). - 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
@property
def active_title_display(self):
"""Render-ready string for "{username} the {title}" attributions —
Baltimorean: post-attribution titles now read "Baltimorean" not "Ard!" — TDD User caught a missed surface on iPhone after the May-18b rename pass (1ccb045): the post.html `.post-attribution` spans still rendered "@disco the Ard!" instead of "@disco the Baltimorean" — six callsites across the post header (author / invitee / shared-self / created-by) plus `_my_buds_applet_item.html`'s bud row body. Same shape on display: navbar DON greeting is the only surface that should keep "Ayo, Ard!", per the May-18b architectural decision ; root cause: `User.active_title_display` at lyric/models.py:152 returned `self.active_title.display_title` ("Ard!" for Baltimorean) instead of `self.active_title.display_name` ("Baltimorean"). The Sprint-18b rename pass swapped the inline `attr_combo` + Brief.title to use `display_name`, but missed this property which is the indirection layer for `.post-attribution` callsites. Navbar uses `{{ user.active_title.display_title }}` directly (no helper-property indirection) so it stays at "Ard!" — that's the intended single Ard! surface ; fix: one-line swap in `active_title_display` from `display_title` to `display_name`. For stargazer / schizo / nomad these two are equal (the Note model's `display_name` property at drama/models.py:262 falls through to `display_title` unless the slug has an override in `_NOTE_DISPLAY[slug]["display_name"]`) — Baltimorean is the only current override w. `{"display_name": "Baltimorean"}`. So this is no-op for every non-Baltimorean Note ; TDD trail: +3 UTs in apps.lyric.tests.integrated.test_models.UserModelTest: `test_active_title_display_returns_earthman_when_no_note_donned` (smoke), `test_active_title_display_uses_display_name_not_display_title` (pins the Baltimorean override path — went red 'Ard! != Baltimorean' before the fix), `test_active_title_display_falls_through_to_display_title_for_non_overridden_slugs` (pins the no-op path for stargazer). Red → green confirmed. Surfaces auto-affected: post.html post-attribution × 5 callsites + `_my_buds_applet_item.html` bud row body (all use `{{ user.active_title_display }}`) ; 1008 IT/UT green in 46s (+3 from 1005) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:42:58 -04:00
returns the donned Note's `display_name`, or 'Earthman' when no
Note is donned. Uses `display_name` (not `display_title`) so the
Baltimorean rename `display_title="Ard!"` (navbar DON greeting
flair only) vs. `display_name="Baltimorean"` (everywhere else)
propagates to inline attributions like `.post-attribution`. For
non-overridden slugs the two are equal so this is a no-op."""
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD - schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner). - lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed). - drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default. - billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title. - post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio). - SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square. - _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self). - FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors. - rootvars.scss: minor plutonium + fuschia tweaks (pre-existing). - 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
if self.active_title_id:
Baltimorean: post-attribution titles now read "Baltimorean" not "Ard!" — TDD User caught a missed surface on iPhone after the May-18b rename pass (1ccb045): the post.html `.post-attribution` spans still rendered "@disco the Ard!" instead of "@disco the Baltimorean" — six callsites across the post header (author / invitee / shared-self / created-by) plus `_my_buds_applet_item.html`'s bud row body. Same shape on display: navbar DON greeting is the only surface that should keep "Ayo, Ard!", per the May-18b architectural decision ; root cause: `User.active_title_display` at lyric/models.py:152 returned `self.active_title.display_title` ("Ard!" for Baltimorean) instead of `self.active_title.display_name` ("Baltimorean"). The Sprint-18b rename pass swapped the inline `attr_combo` + Brief.title to use `display_name`, but missed this property which is the indirection layer for `.post-attribution` callsites. Navbar uses `{{ user.active_title.display_title }}` directly (no helper-property indirection) so it stays at "Ard!" — that's the intended single Ard! surface ; fix: one-line swap in `active_title_display` from `display_title` to `display_name`. For stargazer / schizo / nomad these two are equal (the Note model's `display_name` property at drama/models.py:262 falls through to `display_title` unless the slug has an override in `_NOTE_DISPLAY[slug]["display_name"]`) — Baltimorean is the only current override w. `{"display_name": "Baltimorean"}`. So this is no-op for every non-Baltimorean Note ; TDD trail: +3 UTs in apps.lyric.tests.integrated.test_models.UserModelTest: `test_active_title_display_returns_earthman_when_no_note_donned` (smoke), `test_active_title_display_uses_display_name_not_display_title` (pins the Baltimorean override path — went red 'Ard! != Baltimorean' before the fix), `test_active_title_display_falls_through_to_display_title_for_non_overridden_slugs` (pins the no-op path for stargazer). Red → green confirmed. Surfaces auto-affected: post.html post-attribution × 5 callsites + `_my_buds_applet_item.html` bud row body (all use `{{ user.active_title_display }}`) ; 1008 IT/UT green in 46s (+3 from 1005) Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:42:58 -04:00
return self.active_title.display_name
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD - schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner). - lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed). - drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default. - billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title. - post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio). - SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square. - _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self). - FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors. - rootvars.scss: minor plutonium + fuschia tweaks (pre-existing). - 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
return "Earthman"
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD - User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns - drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their" - SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched - new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio - card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses - dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE - _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question - billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
@property
def pronoun_subj(self):
return resolve_pronouns(self.pronouns)[0]
@property
def pronoun_obj(self):
return resolve_pronouns(self.pronouns)[1]
@property
def pronoun_poss(self):
return resolve_pronouns(self.pronouns)[2]
def ensure_keypair(self):
"""Generate and persist an RSA-2048 keypair if not already set."""
if self.ap_public_key:
return
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
self.ap_public_key = private_key.public_key().public_bytes(
serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
self.ap_private_key = private_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption(),
).decode()
self.save(update_fields=["ap_public_key", "ap_private_key"])
def has_perm(self, perm, obj=None):
return self.is_superuser
def has_module_perms(self, app_label):
return self.is_superuser
class Wallet(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="wallet")
writs = models.IntegerField(default=0)
esteem = models.IntegerField(default=0)
def tooltip_name(self):
return "Wallet"
def tooltip_description(self):
return f"{self.writs} writs · {self.esteem} esteem"
def tooltip_shoptalk(self):
return None
def tooltip_expiry(self):
return None
def tooltip_text(self):
return f"{self.tooltip_name()}: {self.tooltip_description()}"
class Token(models.Model):
COIN = "coin"
FREE = "Free"
TITHE = "tithe"
PASS = "pass"
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
BAND = "band"
CARTE = "carte"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
(PASS, "Backstage Pass"),
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
(BAND, "Wristband"),
(CARTE, "Carte Blanche"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES)
expires_at = models.DateTimeField(null=True, blank=True)
current_room = models.ForeignKey(
"epic.Room", null=True, blank=True,
on_delete=models.SET_NULL, related_name="coin_tokens"
)
next_ready_at = models.DateTimeField(null=True, blank=True)
slots_claimed = models.PositiveSmallIntegerField(default=0, blank=True)
fix: `Token.PASS` is now model-enforced as staff-only — `Token.clean`/`save` raise ValidationError when a non-staff user is the FK target. Staging bug 2026-05-21 — admin awarded a PASS to a non-admin via Django admin; row was created + showed in the user's wallet, but every game-side surface (gameboard, game-kit, gate-pad `select_token`, `_select_my_sea_token`) had always filtered PASS behind `is_staff`, so the token was unequippable + unusable. Five `is_staff`-gated PASS surfaces made PASS a deliberate staff-only trinket; the wallet was the lone outlier surfacing it. Bundled: wallet view (+ HTMX toggle partial) now gates `pass_token` behind `is_staff` mirroring the gameboard pattern — defense-in-depth in case any future bypass writes a stray row. TDD — new ITs: `PassTokenStaffOnlyGuardTest` (model raises for non-staff, accepts for staff, leaves other token types unaffected); `WalletPassTokenVisibilityTest` (3 cases pin wallet + HTMX gating); `TokenAdminFormTest.test_pass_token_for_non_staff_user_is_invalid` + `test_pass_token_for_staff_user_is_valid`. Adjusted 2 existing tests that incidentally exercised the now-blocked pattern (`test_paid_draw_with_pass_does_not_consume`, `test_pass_token_is_not_consumed` — both flip `is_staff = True` inline before `Token.objects.create`); dropped PASS from `test_other_token_types_do_not_require_expires_at`'s loop (covered by the new dedicated tests). 1133 IT/UT green. A non-admin "boost-pass" variant lands as a distinct `token_type` later, NEVER by relaxing the staff gate — captured in [[feedback-pass-token-staff-only]] Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 00:35:55 -04:00
def clean(self):
# PASS is admin-only — game-side surfaces (gameboard, game-kit, gate
# picker) all filter PASS behind user.is_staff, so a non-staff PASS
# row is invisible/unusable and just clutters the wallet. A non-admin
# variant ("Boost Pass" or similar) will land as a distinct token_type
# later — keep the rule strict here so the two never blur.
super().clean()
if self.token_type == self.PASS and self.user_id and not self.user.is_staff:
raise ValidationError(
{"token_type": "PASS is admin-only — staff users only."}
)
def save(self, *args, **kwargs):
if self.token_type == self.PASS and self.user_id and not self.user.is_staff:
raise ValidationError(
{"token_type": "PASS is admin-only — staff users only."}
)
super().save(*args, **kwargs)
def tooltip_name(self):
return self.get_token_type_display()
def tooltip_description(self):
if self.token_type in (self.COIN, self.FREE):
return "Admit 1 Entry"
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
if self.token_type in (self.PASS, self.BAND):
return "Admit All Entry"
if self.token_type == self.TITHE:
return "+ Writ bonus"
if self.token_type == self.CARTE:
return "Admit up to +6"
return ""
def tooltip_expiry(self):
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
if self.token_type in (self.COIN, self.PASS, self.BAND, self.CARTE):
if self.token_type == self.COIN and self.next_ready_at:
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry"
if self.expires_at:
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
return ""
def tooltip_room_html(self):
if not self.current_room_id:
return ""
url = reverse("epic:gatekeeper", kwargs={"room_id": self.current_room_id})
return f'<a href="{url}">{self.current_room.name}</a>'
def tooltip_shoptalk(self):
if self.token_type == self.COIN:
return "\u2026and another after that, and another after that\u2026"
if self.token_type == self.FREE:
return "a spot of good fortune"
if self.token_type == self.PASS:
return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?"
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
if self.token_type == self.BAND:
return "Unlimited free entry (BYOB)"
if self.token_type == self.CARTE:
return "No, I\u2019m afraid we\u2019ll be taking over from here."
return None
def tooltip_text(self):
text = f"{self.tooltip_name()}: {self.tooltip_description()}"
if self.tooltip_shoptalk():
text += f" ({self.tooltip_shoptalk()})"
text += f" \u2014 {self.tooltip_expiry()}"
return text
class PaymentMethod(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods")
stripe_pm_id = models.CharField(max_length=255)
last4 = models.CharField(max_length=4)
brand = models.CharField(max_length=32)
def __str__(self):
return f"{self.brand} ....{self.last4}"
@receiver(post_save, sender=User)
def create_wallet_and_tokens(sender, instance, created, **kwargs):
if not created:
return
from apps.epic.models import DeckVariant
Wallet.objects.create(user=instance, writs=144)
coin = Token.objects.create(user=instance, token_type=Token.COIN)
Token.objects.create(
user=instance,
token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
if instance.is_staff:
pass_token = Token.objects.create(user=instance, token_type=Token.PASS)
instance.equipped_trinket = pass_token
else:
instance.equipped_trinket = coin
earthman = DeckVariant.objects.filter(slug="earthman").first()
instance.equipped_deck = earthman
instance.save(update_fields=['equipped_trinket', 'equipped_deck'])
if earthman:
instance.unlocked_decks.add(earthman)
if instance.is_superuser:
from apps.drama.models import Note
Note.grant_if_new(instance, "super-schizo")
Note.grant_if_new(instance, "super-nomad")