2026-01-30 16:21:32 -05:00
|
|
|
import uuid
|
2026-02-19 20:31:29 -05:00
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
from datetime import timedelta
|
2026-02-19 20:31:29 -05:00
|
|
|
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
2026-01-29 15:21:54 -05:00
|
|
|
from django.db import models
|
2026-03-08 15:14:41 -04:00
|
|
|
from django.db.models.signals import post_save
|
|
|
|
|
from django.dispatch import receiver
|
2026-03-13 17:31:52 -04:00
|
|
|
from django.urls import reverse
|
2026-03-08 15:14:41 -04:00
|
|
|
from django.utils import timezone
|
2026-01-29 15:21:54 -05:00
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
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
|
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
class LoginToken(models.Model):
|
2026-01-30 16:21:32 -05:00
|
|
|
email = models.EmailField()
|
2026-01-30 19:10:17 -05:00
|
|
|
uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
2026-01-30 16:21:32 -05:00
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
class User(AbstractBaseUser):
|
2026-02-07 20:15:27 -05:00
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
2026-01-30 15:04:47 -05:00
|
|
|
email = models.EmailField(unique=True)
|
2026-03-01 21:19:12 -05:00
|
|
|
username = models.CharField(max_length=35, unique=True, null=True, blank=True)
|
|
|
|
|
searchable = models.BooleanField(default=False)
|
2026-03-05 14:45:55 -05:00
|
|
|
palette = models.CharField(max_length=32, default="palette-default")
|
2026-03-09 01:07:16 -04:00
|
|
|
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
|
2026-03-16 00:07:52 -04:00
|
|
|
equipped_trinket = models.ForeignKey(
|
|
|
|
|
"Token", null=True, blank=True,
|
|
|
|
|
on_delete=models.SET_NULL, related_name="+",
|
|
|
|
|
)
|
2026-03-24 21:07:01 -04:00
|
|
|
equipped_deck = models.ForeignKey(
|
|
|
|
|
"epic.DeckVariant", null=True, blank=True,
|
|
|
|
|
on_delete=models.SET_NULL, related_name="+",
|
|
|
|
|
)
|
2026-03-24 22:25:25 -04:00
|
|
|
unlocked_decks = models.ManyToManyField(
|
|
|
|
|
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
|
|
|
|
)
|
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
|
|
|
# Asymmetric self M2M — `user.buddies.all()` = people I've explicitly
|
|
|
|
|
# added (or implicitly via post-share / game-invite, which auto-adds
|
|
|
|
|
# the recipient to the inviter's buddies list). `user.added_as_buddy`
|
|
|
|
|
# = the inverse (people who have me in their buddies list); useful
|
|
|
|
|
# for the future "buddy changed username" snapshot-accept flow.
|
|
|
|
|
buddies = models.ManyToManyField(
|
|
|
|
|
"self", symmetrical=False, blank=True, related_name="added_as_buddy",
|
|
|
|
|
)
|
2026-04-23 01:44:58 -04:00
|
|
|
active_title = models.ForeignKey(
|
|
|
|
|
"drama.Note", null=True, blank=True,
|
|
|
|
|
on_delete=models.SET_NULL, related_name="+",
|
|
|
|
|
)
|
2026-04-02 15:22:04 -04:00
|
|
|
ap_public_key = models.TextField(blank=True, default="")
|
|
|
|
|
ap_private_key = models.TextField(blank=True, default="")
|
2026-03-02 13:57:03 -05:00
|
|
|
|
2026-04-16 03:03:19 -04:00
|
|
|
# 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)
|
2026-04-21 21:54:34 -04:00
|
|
|
sky_birth_tz = models.CharField(max_length=64, blank=True)
|
2026-04-16 03:03:19 -04:00
|
|
|
sky_house_system = models.CharField(max_length=1, blank=True, default="O")
|
|
|
|
|
sky_chart_data = models.JSONField(null=True, blank=True)
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
is_staff = models.BooleanField(default=False)
|
|
|
|
|
is_superuser = models.BooleanField(default=False)
|
2026-01-30 15:04:47 -05:00
|
|
|
|
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",
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
objects = UserManager()
|
2026-01-30 15:04:47 -05:00
|
|
|
REQUIRED_FIELDS = []
|
|
|
|
|
USERNAME_FIELD = "email"
|
2026-02-19 20:31:29 -05:00
|
|
|
|
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 —
|
|
|
|
|
returns the donned Note's recognition title, or 'Earthman' when no
|
|
|
|
|
Note is donned. The 'Earthman' default mirrors the dashboard greeting
|
|
|
|
|
fallback in dashboard/views.home_page."""
|
|
|
|
|
if self.active_title_id:
|
|
|
|
|
return self.active_title.display_title
|
|
|
|
|
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]
|
|
|
|
|
|
2026-04-02 15:22:04 -04:00
|
|
|
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"])
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
def has_perm(self, perm, obj=None):
|
|
|
|
|
return self.is_superuser
|
2026-01-30 21:33:30 -05:00
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
def has_module_perms(self, app_label):
|
|
|
|
|
return self.is_superuser
|
2026-03-08 15:14:41 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-03-10 14:11:53 -04:00
|
|
|
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()}"
|
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
class Token(models.Model):
|
|
|
|
|
COIN = "coin"
|
|
|
|
|
FREE = "Free"
|
|
|
|
|
TITHE = "tithe"
|
2026-03-14 22:00:16 -04:00
|
|
|
PASS = "pass"
|
2026-03-16 00:07:52 -04:00
|
|
|
CARTE = "carte"
|
2026-03-08 15:14:41 -04:00
|
|
|
TOKEN_TYPE_CHOICES = [
|
|
|
|
|
(COIN, "Coin-on-a-String"),
|
|
|
|
|
(FREE, "Free Token"),
|
|
|
|
|
(TITHE, "Tithe Token"),
|
2026-03-14 22:00:16 -04:00
|
|
|
(PASS, "Backstage Pass"),
|
2026-03-16 00:07:52 -04:00
|
|
|
(CARTE, "Carte Blanche"),
|
2026-03-08 15:14:41 -04:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-13 17:31:52 -04:00
|
|
|
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)
|
2026-03-16 00:07:52 -04:00
|
|
|
slots_claimed = models.PositiveSmallIntegerField(default=0, blank=True)
|
2026-03-08 15:14:41 -04:00
|
|
|
|
2026-03-09 23:48:20 -04:00
|
|
|
def tooltip_name(self):
|
2026-03-08 15:14:41 -04:00
|
|
|
return self.get_token_type_display()
|
|
|
|
|
|
2026-03-09 23:48:20 -04:00
|
|
|
def tooltip_description(self):
|
2026-03-15 17:54:58 -04:00
|
|
|
if self.token_type in (self.COIN, self.FREE):
|
2026-03-09 23:48:20 -04:00
|
|
|
return "Admit 1 Entry"
|
2026-03-15 17:54:58 -04:00
|
|
|
if self.token_type == self.PASS:
|
|
|
|
|
return "Admit All Entry"
|
2026-03-11 00:58:24 -04:00
|
|
|
if self.token_type == self.TITHE:
|
|
|
|
|
return "+ Writ bonus"
|
2026-03-16 00:07:52 -04:00
|
|
|
if self.token_type == self.CARTE:
|
|
|
|
|
return "Admit up to +6"
|
2026-03-09 23:48:20 -04:00
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
def tooltip_expiry(self):
|
2026-03-16 00:07:52 -04:00
|
|
|
if self.token_type in (self.COIN, self.PASS, self.CARTE):
|
2026-03-14 22:00:16 -04:00
|
|
|
if self.token_type == self.COIN and self.next_ready_at:
|
2026-03-13 17:31:52 -04:00
|
|
|
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
|
2026-03-09 23:48:20 -04:00
|
|
|
return "no expiry"
|
|
|
|
|
if self.expires_at:
|
|
|
|
|
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
|
|
|
|
|
return ""
|
2026-03-13 17:31:52 -04:00
|
|
|
|
|
|
|
|
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>'
|
2026-03-09 23:48:20 -04:00
|
|
|
|
|
|
|
|
def tooltip_shoptalk(self):
|
|
|
|
|
if self.token_type == self.COIN:
|
|
|
|
|
return "\u2026and another after that, and another after that\u2026"
|
2026-03-16 00:07:52 -04:00
|
|
|
if self.token_type == self.FREE:
|
|
|
|
|
return "a spot of good fortune"
|
2026-03-15 01:17:09 -04:00
|
|
|
if self.token_type == self.PASS:
|
2026-03-15 01:46:11 -04:00
|
|
|
return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?"
|
2026-03-16 00:07:52 -04:00
|
|
|
if self.token_type == self.CARTE:
|
|
|
|
|
return "No, I\u2019m afraid we\u2019ll be taking over from here."
|
2026-03-09 23:48:20 -04:00
|
|
|
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
|
|
|
|
|
|
2026-03-09 01:07:16 -04:00
|
|
|
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}"
|
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
@receiver(post_save, sender=User)
|
|
|
|
|
def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
|
|
|
|
if not created:
|
|
|
|
|
return
|
2026-03-24 21:07:01 -04:00
|
|
|
from apps.epic.models import DeckVariant
|
2026-03-08 15:14:41 -04:00
|
|
|
Wallet.objects.create(user=instance, writs=144)
|
2026-03-16 00:07:52 -04:00
|
|
|
coin = Token.objects.create(user=instance, token_type=Token.COIN)
|
2026-03-08 15:14:41 -04:00
|
|
|
Token.objects.create(
|
|
|
|
|
user=instance,
|
|
|
|
|
token_type=Token.FREE,
|
|
|
|
|
expires_at=timezone.now() + timedelta(days=7),
|
|
|
|
|
)
|
2026-03-14 22:00:16 -04:00
|
|
|
if instance.is_staff:
|
2026-03-16 00:07:52 -04:00
|
|
|
pass_token = Token.objects.create(user=instance, token_type=Token.PASS)
|
|
|
|
|
instance.equipped_trinket = pass_token
|
|
|
|
|
else:
|
|
|
|
|
instance.equipped_trinket = coin
|
2026-03-24 21:07:01 -04:00
|
|
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
|
|
|
|
instance.equipped_deck = earthman
|
|
|
|
|
instance.save(update_fields=['equipped_trinket', 'equipped_deck'])
|
2026-03-24 22:25:25 -04:00
|
|
|
if earthman:
|
|
|
|
|
instance.unlocked_decks.add(earthman)
|
2026-04-28 01:30:02 -04:00
|
|
|
if instance.is_superuser:
|
|
|
|
|
from apps.drama.models import Note
|
|
|
|
|
Note.grant_if_new(instance, "super-schizo")
|
|
|
|
|
Note.grant_if_new(instance, "super-nomad")
|