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>
576 lines
24 KiB
Python
576 lines
24 KiB
Python
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")
|