Files
python-tdd/src/apps/lyric/models.py
Disco DeDisco f44a282007 @taxman Debits & credits ledger + NVM-persistent FREE/PAID DRAW Briefs — TDD
User-spec 2026-05-26 for /gameboard/my-sea/. The transient "Free draw locked" Brief that re-appeared on every page load is replaced by a server-driven Brief whose NVM dismissal persists per-cycle, AND every spend now lands a permanent line on a new @taxman-authored "Debits & credits" Post (so the info goes somewhere instead of vanishing on dismiss). Same NVM-persistence treatment for the new PAID DRAW Brief.

Lyric:
- RESERVED_USERNAMES adds "taxman"; get_or_create_taxman() parallels get_or_create_adman() (username=taxman, email=taxman@earthmanrpg.local, unusable password, searchable=False).
- New nullable User.{free,paid}_draw_brief_dismissed_at DateTimeFields — anchor stamps for the NVM-persistence semantics. Cleared by my_sea_lock (free) / my_sea_paid_draw (paid) on each fresh spend so the new cycle re-opens the Brief surface.
- Migration 0014_brief_dismissal_fields adds the fields + RunPython seeds @taxman (mirror of 0003_seed_adman).

Billboard:
- Post.KIND_TAX_LEDGER + TAX_LEDGER_POST_TITLE = "Debits & credits"; Brief.KIND_TAX_LEDGER for routing.
- _delete_unsolicited_admin_post_lines extended via _SYSTEM_AUTHOR_POST_KINDS tuple — TAX_LEDGER joins NOTE_UNLOCK in the post_save guard that nukes any Line w.o. admin_solicited=True.
- Brief.to_banner_dict adds dismiss_url slot (empty by default; populated by the gameboard view for TAX_LEDGER briefs) + uses line.display_text instead of line.text so the prefix is stripped on the banner too.
- Line.display_text property — strips the leading "[iso-timestamp] " prefix that log_tax_debit bakes into TAX_LEDGER Lines (the prefix exists ONLY to satisfy unique_together = (post, text) on repeat-slug spends; the per-Brief + per-Line created_at slots already render the user-facing moment). Identity for non-tax Lines.
- view_post / delete_post / abandon_post guards extended to treat TAX_LEDGER like NOTE_UNLOCK (POST forbidden, can't delete, can't bye).
- Migration 0008_tax_ledger_kind registers the new choices on Post.kind + Brief.kind.

Billboard tax module (new apps/billboard/tax.py):
- TAX_DEBIT_TEMPLATES — canonical body text per slug, with FREE DRAW / PAID DRAW / GATE VIEW button-labels wrapped in .btn-pri-name spans:
  - free_draw_locked → "Look!—my_sea.html [FREE DRAW] is locked. Next free draw available 24h from the production of this log."
  - paid_draw_locked → "Look!—my_sea.html [PAID DRAW] is locked. Another may be unlocked by depositing a Token in [GATE VIEW]."
- log_tax_debit(user, slug) — get-or-creates the user's TAX_LEDGER Post, appends a timestamp-prefixed Line authored by @taxman w. admin_solicited=True, spawns a Brief. Returns (post, line, brief).

Gameboard:
- my_sea_lock first-card-of-cycle branch calls log_tax_debit(user, "free_draw_locked") + clears free_draw_brief_dismissed_at. Response now includes free_draw_brief_payload (Brief.to_banner_dict w. dismiss_url populated) so the picker IIFE can surface the new Brief in-place w.o. a page reload — same affordance the prior _showFreeDrawLockedBrief provided, w. server-authored copy + NVM-persistence.
- my_sea_paid_draw after paid_through_at stamp calls log_tax_debit(user, "paid_draw_locked") + clears paid_draw_brief_dismissed_at. Next-page-load surfaces the new Brief via the context payload.
- New my_sea_dismiss_free_draw_brief + my_sea_dismiss_paid_draw_brief POST endpoints stamp the matching User anchor field; return 204. URLs at /gameboard/my-sea/brief/{free,paid}-draw/dismiss.
- my_sea view's context computes {free,paid}_draw_brief_payload via the new _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url) helper — returns the latest TAX_LEDGER Brief's to_banner_dict IF (dismissal anchor is None OR anchor < brief.created_at). Slug discrimination via line__text__contains="FREE DRAW" / "PAID DRAW" (kept the Brief schema flat — only two markers today, non-overlapping wordings).

Frontend (apps/dashboard/static/apps/dashboard/note.js):
- Brief.showBanner NVM handler now fires a fire-and-forget POST to brief.dismiss_url (if present) before removing the banner. Persistent-NVM kinds (TAX_LEDGER) supply it; transient kinds leave the field empty + the handler no-ops to the existing dismiss-only behavior. CSRF token pulled from the csrftoken cookie.

SCSS (static_src/scss/_billboard.scss):
- .post-line--system .post-line-text .btn-pri-name — inline emphasis (color: --quaUser, font-weight: 700, font-style: normal) on canonical .btn-primary button labels referenced in @taxman ledger prose. User-spec 2026-05-26 mid-flight clarification: log surface only, not the actual buttons.

Templates:
- templates/apps/gameboard/my_sea.html: replaces the inline _showFreeDrawLockedBrief({{ next_free_draw_at|date:'c' }}) invocation w. two {% if *_brief_payload %} blocks that json_script the payload + dispatch via a new _showTaxBrief(payload, bannerClass) helper. _postLock updated to call _showFreeDrawLockedBrief(body.free_draw_brief_payload) so freshly-emitted Briefs surface in-place w.o. a reload (same affordance as before, w. server payload).
- templates/apps/billboard/post.html: readonly-textarea / system-author-styling / bud-panel-suppression branches all extended to cover post.kind == 'tax_ledger' (parallel to existing 'note_unlock' cases). Line-text rendering uses line.display_text (strips the iso prefix) + treats @taxman the same as @adman (allow HTML rendering for the system-author safe text — required so the .btn-pri-name spans aren't escaped).

Tests:

UTs (apps/billboard/tests/integrated/test_tax.py — 11 specs):
- log_tax_debit creates Post/Line/Brief w. correct kind + author + admin_solicited.
- Both slug templates produce expected text (assertions tolerant of inline .btn-pri-name span HTML).
- Two spends share one Post w. two distinct Lines (timestamp prefix keeps unique_together happy).
- Unknown slug raises KeyError.
- post_save guard nukes unsolicited Lines on TAX_LEDGER Posts; solicited Lines survive.
- "taxman" is reserved (case-insensitive); get_or_create_taxman idempotent.

ITs (apps/gameboard/tests/integrated/test_tax_briefs.py — 13 specs):
- my_sea_lock first-card creates TAX_LEDGER Post + Line + Brief; mid-cycle upserts do NOT emit extra debits; clears free_draw_brief_dismissed_at.
- my_sea_paid_draw commit creates a separate TAX_LEDGER entry; clears paid_draw_brief_dismissed_at.
- Dismiss endpoints stamp the matching User anchor; reject GET (405); require login (302).
- my_sea context: *_brief_payload is None until first spend; populated after; suppressed after NVM-dismiss; returns after cycle reset.

Existing ITs adjusted (apps/gameboard/tests/integrated/test_views.py):
- test_view_triggers_brief_banner_when_active_draw_exists + test_empty_hand_brief_banner_still_triggered + test_view_does_not_trigger_brief_banner_without_active_draw — assertions retargeted from window._showFreeDrawLockedBrief(" to id="id_free_draw_brief_payload" (the new json_script payload tag).
- test_brief_next_free_draw_at_uses_user_anchor_not_paid_row — switched from HTML-substring assertion against the rendered ISO (now absent from the page) to a direct response.context["next_free_draw_at"] comparison. Same underlying invariant; cleaner assertion shape.

FT (functional_tests/test_bill_post_debits_credits.py — 1 spec):
- After two seeded debits, /billboard/post/<uuid>/ renders the "Debits & credits" title, both Line bodies (FREE DRAW + PAID DRAW), @taxman attribution, readonly input w. "No response needed at this time" placeholder, AND verifies the "[iso] " prefix is stripped from display.

All 1340 IT+UT green; new FT green; existing FTs unaffected by these changes.

Pending follow-up (recorded for next sprint):

Per user 2026-05-26 in-flight ask: refactor @adman concerns into apps/billboard/ad.py (paralleling the new apps/billboard/tax.py) — extract Note.grant_if_new's billboard-side concerns (Post/Line/Brief creation, prose templates) out of apps/drama/models.py into the same shape log_tax_debit now follows. Notated for after this sprint lands.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:26:42 -04:00

576 lines
24 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", "taxman"})
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
def get_or_create_taxman():
"""Idempotent fetch of the sitewide `taxman` User — system-author for
Debits & credits ledger Lines (FREE/PAID DRAW spend log per user-spec
2026-05-26). Parallels `get_or_create_adman` exactly; production
migration `lyric/0014_seed_taxman_and_brief_dismissal_fields` seeds
the row once, this helper backstops TransactionTestCase flushes."""
from django.contrib.auth.hashers import make_password
taxman, _ = User.objects.get_or_create(
username="taxman",
defaults={
"email": "taxman@earthmanrpg.local",
"password": make_password(None),
"is_staff": False,
"is_superuser": False,
"searchable": False,
},
)
return taxman
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)
# My Sea free-draw cooldown anchor — the timestamp of the user's most
# recent TOKENLESS first-card-draw of a 24h cycle. Set when the user
# creates a MySeaDraw row via the FREE DRAW path (button on the my-sea
# landing); PAID DRAW deliberately does NOT update it, so the next
# free draw is always anchored to the original free-draw moment, not
# the most recent paid one (user-spec 2026-05-23). Drives the Brief
# banner's next-free-draw timestamp + the landing-button state machine
# (FREE DRAW vs GATE VIEW vs PAID DRAW).
last_free_draw_at = models.DateTimeField(null=True, blank=True)
# NVM-dismissal anchors for the FREE/PAID DRAW "locked" Briefs (user-spec
# 2026-05-26). When the user clicks NVM on either Brief, the corresponding
# timestamp gets stamped via the dismiss-brief POST endpoints; the my_sea
# view then suppresses the Brief on subsequent page loads as long as the
# dismissal is more recent than the cycle's anchor moment. `my_sea_lock`
# clears `free_draw_brief_dismissed_at` when a fresh FREE DRAW lands;
# `my_sea_paid_draw` clears `paid_draw_brief_dismissed_at` on commit —
# so each fresh spend re-opens the Brief surface for that cycle.
free_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True)
paid_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True)
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"
# ── My Sea free-draw cooldown helpers ────────────────────────────────
# Pair w. `last_free_draw_at` above. The cooldown anchors to the FREE
# DRAW moment (NOT to any subsequent paid draws), so the Brief banner
# surfaces "next free draw at" relative to the user's actual cycle
# start. PAID DRAWs commit their tokens against this same cooldown
# window — they don't reset it.
@property
def sig_face(self):
"""Rendering payload for the saved sig in `_applet-my-sign.html`.
`significator_reversed` is the POLARITY axis (FLIP — True ↔ levity,
per [[feedback-significator-reversed-is-polarity]]); the SPIN /
orientation axis is never persisted, so the saved sig is always
rendered upright in its polarity (reversed=False to `applet_face`).
Returns `None` when no sig is saved."""
if self.significator is None:
return None
polarity = 'levity' if self.significator_reversed else 'gravity'
return self.significator.applet_face(polarity, reversed=False)
@property
def free_draw_cooldown_active(self):
"""True iff the user is currently inside the 24h cooldown window
triggered by their last tokenless free draw. False for fresh users
(never free-drew) and for users whose cooldown has elapsed."""
from django.utils import timezone
from datetime import timedelta
if self.last_free_draw_at is None:
return False
return self.last_free_draw_at + timedelta(hours=24) > timezone.now()
@property
def next_free_draw_at(self):
"""Datetime when the user's next free draw becomes available
(`last_free_draw_at + 24h`). Returns None if the user has never
free-drawn."""
from datetime import timedelta
if self.last_free_draw_at is None:
return None
return self.last_free_draw_at + timedelta(hours=24)
@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="")
# `shoptalk` is the italic flavor line that mirrors `Token.tooltip_shoptalk` —
# rendered via the `.tt-shoptalk` SCSS class (DRY w. the wallet's Token row).
# Blank → the `{% if item.shoptalk %}` slot in the template is skipped.
shoptalk = models.CharField(max_length=200, 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}"
def tooltip_expiry(self):
"""All shop items are eternal stock (no time-bound listings yet) so
the tooltip's `.tt-expiry` slot always shows 'no expiry' — same
red-callout styling as PASS/BAND/CARTE token tooltips. If a future
seasonal item needs a real expiry, override on the row + return
the formatted string here."""
return "no expiry"
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")