Files
python-tdd/src/apps/lyric/models.py
Disco DeDisco 849ef3c310 feat: ShopItem + Purchase models + seed tithe-1 / tithe-5 / band-1 + wallet-shop Applet — Chunk 2 of [[project-wallet-shop-expansion]]. ShopItem is the admin-managed catalog: slug, name, description, icon (FA class), badge_text (eg "×5"), price_cents, granted_token_type (any Token type), granted_count, granted_writs (default 0), max_owned (nullable; BAND=1), display_order, active. is_available_for(user) enforces max_owned by comparing user's owned-count of the granted token type. price_display() renders cents → "$1" / "$4.20" for tooltip prose. Purchase is the per-tx audit trail: user + shop_item + stripe_payment_intent_id (unique) + status (PENDING/SUCCEEDED/FAILED/REFUNDED) + amount_cents snapshot + granted_writs snapshot + granted_token_ids JSONField (PKs of minted tokens) + created_at + succeeded_at. fulfill() is idempotent — short-circuits if status==SUCCEEDED + refuses non-PENDING rows so a webhook + sync /shop/confirm racing each other can't double-mint. Schema migration lyric/0008_shopitem_purchase autogenerated. Seed migration lyric/0009_seed_shop_items populates the 3 starting items per locked decisions: tithe-1 ($1 → 1 TITHE + 144 writs, no cap, order=10); tithe-5 ($4 → 5 TITHE + 750 writs, no cap, badge "×5", order=20); band-1 ($20 → 1 BAND + 0 writs, max_owned=1, order=30). Applet migration applets/0011_seed_wallet_shop_applet adds the wallet-shop Applet (context=wallet, 12 cols × 3 rows). Stub _applet-wallet-shop.html lands w. just <section id="id_wallet_shop"> + <h2>Shop</h2>_applets.html's auto-include-by-slug pattern would 500 the wallet page on TemplateDoesNotExist otherwise (caught mid-Chunk-2 by the full app suite). Chunk 4 fills in the shop-tile grid + BUY-ITEM microtooltip + Stripe.js wiring. TDD — 22 ITs in test_shop_models.py: ShopItemModelTest (9 cases — minimal create, defaults for granted_writs / max_owned / active, is_available_for w/ + w/o max_owned cap, str repr), PurchaseModelTest (8 cases — minimal create, PI ID uniqueness constraint, fulfill mints tokens + grants writs + marks SUCCEEDED + records granted_token_ids + is idempotent on re-fire + creates N tokens for bundle), SeededShopCatalogTest (4 cases pin tithe-1 / tithe-5 / band-1 row shapes + display_order ascending), SeededWalletShopAppletTest (1 case pins Applet seeded). 1191 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:30:59 -04:00

484 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import uuid
from datetime import timedelta
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
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
# ── 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"]
# ── 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",
)
# Asymmetric self M2M — `user.buds.all()` = people I've explicitly
# added (or implicitly via post-share / game-invite, which auto-adds
# 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",
)
active_title = models.ForeignKey(
"drama.Note", null=True, blank=True,
on_delete=models.SET_NULL, related_name="+",
)
# 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 = models.CharField(
max_length=16, choices=PRONOUN_CHOICES, default="pluralism",
)
objects = UserManager()
REQUIRED_FIELDS = []
USERNAME_FIELD = "email"
@property
def active_title_display(self):
"""Render-ready string for "{username} the {title}" attributions —
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."""
if self.active_title_id:
return self.active_title.display_name
return "Earthman"
@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"
BAND = "band"
CARTE = "carte"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
(PASS, "Backstage Pass"),
(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)
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"
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):
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?"
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}"
class ShopItem(models.Model):
"""A purchasable bundle in the wallet's Shop applet — admin-managed
catalog. Each row defines (price → granted Tokens + writs); the
`Purchase.fulfill()` flow mints `granted_count` tokens of
`granted_token_type` + bumps `Wallet.writs` by `granted_writs`.
See [[project-wallet-shop-expansion]] for the broader design + the
3 starting catalog items (tithe-1, tithe-5, band-1) seeded in
`lyric/0009_seed_shop_items`."""
slug = models.SlugField(unique=True)
name = models.CharField(max_length=100)
description = models.TextField(blank=True, default="")
icon = models.CharField(max_length=50) # FA icon class (eg "fa-piggy-bank")
badge_text = models.CharField(max_length=8, blank=True, default="") # eg "×5"; "" = no badge
price_cents = models.PositiveIntegerField()
granted_token_type = models.CharField(
max_length=8, choices=Token.TOKEN_TYPE_CHOICES,
)
granted_count = models.PositiveSmallIntegerField(default=1)
granted_writs = models.PositiveIntegerField(default=0)
# `max_owned=None` → unlimited stock per user. `max_owned=1` → BAND-style
# "you can only have one of these" — the shop UI disables BUY w. an
# "Already owned" microtooltip when the user's owned-count of the granted
# token type has reached this cap.
max_owned = models.PositiveSmallIntegerField(null=True, blank=True)
display_order = models.PositiveSmallIntegerField(default=100)
active = models.BooleanField(default=True)
class Meta:
ordering = ["display_order", "slug"]
def __str__(self):
return self.name
def is_available_for(self, user):
"""True iff the user can purchase another of this item right now.
Honors `max_owned` (compares to user's owned-count of the granted
token type). Items w. `max_owned=None` are always available."""
if self.max_owned is None:
return True
owned = user.tokens.filter(token_type=self.granted_token_type).count()
return owned < self.max_owned
def price_display(self):
"""Render-ready dollar string for tooltips. Cents trimmed for whole
dollars; otherwise two decimals."""
dollars = self.price_cents / 100
if dollars == int(dollars):
return f"${int(dollars)}"
return f"${dollars:.2f}"
class Purchase(models.Model):
"""Audit-trail row for one shop transaction. Created at PENDING on
Stripe PaymentIntent creation, advanced to SUCCEEDED via `fulfill()`
(called from EITHER the synchronous `/shop/confirm` view OR the
`/stripe/webhook` handler — whichever wins). `fulfill()` is idempotent
so the race is harmless.
`granted_token_ids` snapshots the PKs of every Token row this purchase
minted so we can audit / refund / rebuild later without re-deriving
from `created_at`."""
PENDING = "PENDING"
SUCCEEDED = "SUCCEEDED"
FAILED = "FAILED"
REFUNDED = "REFUNDED"
STATUS_CHOICES = [
(PENDING, "Pending"),
(SUCCEEDED, "Succeeded"),
(FAILED, "Failed"),
(REFUNDED, "Refunded"),
]
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="purchases")
shop_item = models.ForeignKey(ShopItem, on_delete=models.PROTECT, related_name="purchases")
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
amount_cents = models.PositiveIntegerField() # snapshot — ShopItem price may change later
granted_writs = models.PositiveIntegerField(default=0) # snapshot
granted_token_ids = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
succeeded_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"Purchase({self.user_id}, {self.shop_item.slug}, {self.status})"
def fulfill(self):
"""Mint tokens + grant writs. Idempotent — re-firing on a row that's
already SUCCEEDED is a safe no-op (the webhook + the sync
`/shop/confirm` view both call this; whichever lands first wins).
Failures elsewhere shouldn't reach this method — `status=FAILED`
rows stay FAILED + don't fulfill."""
from django.db import transaction
if self.status == self.SUCCEEDED:
return
if self.status not in (self.PENDING,):
# FAILED / REFUNDED — refuse to fulfill.
return
item = self.shop_item
with transaction.atomic():
granted_ids = []
for _ in range(item.granted_count):
t = Token.objects.create(
user=self.user,
token_type=item.granted_token_type,
)
granted_ids.append(t.pk)
if self.granted_writs:
wallet = self.user.wallet
wallet.writs = wallet.writs + self.granted_writs
wallet.save(update_fields=["writs"])
self.granted_token_ids = granted_ids
self.status = self.SUCCEEDED
self.succeeded_at = timezone.now()
self.save(update_fields=[
"granted_token_ids", "status", "succeeded_at",
])
@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")