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

264 lines
9.8 KiB
Python
Raw Normal View History

import uuid
from datetime import timedelta
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
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"]
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",
)
active_title = models.ForeignKey(
"drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
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"
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"
CARTE = "carte"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
(PASS, "Backstage Pass"),
(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)
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"
if self.token_type == self.PASS:
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):
if self.token_type in (self.COIN, self.PASS, 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?"
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")