Files
python-tdd/src/apps/lyric/models.py
Disco DeDisco 668105aeeb my-sea voice: persist mute across in-sea nav/refresh + 3-min muted auto-disconnect; fix first-connect glow/mute race — TDD
MUTE PERSISTENCE (user-spec 2026-05-30) — a voice mute used to vanish on any
in-sea navigation/refresh (the mesh tears down + auto-rejoins unmuted). Now the
mute is stamped server-side + re-applied on rejoin, with a 3-min muted →
auto-disconnect window:
- `User.voice_muted_at` (timestamp, not a bare bool, so the 3-min window anchors
  here) + migration. Per-user, not per-seat: the owner has no seat row, and a
  user is in ≤1 voice room at a time, so this uniformly covers owner + visitor.
- POST `/voice/mute` {muted} sets/clears it (new voice app views.py + urls.py,
  mounted at `voice/` in core/urls). my_sea + my_sea_visit pass the timestamp to
  `#id_voice_btn` as `data-voice-muted-at`.
- voice-mesh.js gains `setMuted(m)` (set vs. toggleMute's flip), honoured by
  join's post-getUserMedia `_applyMute`. burger-btn.js: a mute toggle POSTs the
  state + arms a client timer; the auto-rejoin re-applies the persisted mute +
  re-arms the timer from the stored timestamp (so the 3-min spans navigations,
  not resets); an elapsed window on rejoin auto-disconnects instead of rejoining;
  a fresh manual join clears any stale mute. On timeout: leave voice + clear.

FIRST-CONNECT GLOW/MUTE RACE (user-reported) — `setOnStateChange` pushes the
current state immediately on subscribe, and voice-glow.js often subscribes
MID-JOIN (getUserMedia pending → inCall=false). Its `setVoiceState` only ever
DELETED `voice.dataset.inCall` (never re-set it) — wiping the join-vs-mute flag
burger-btn.js had just set, so the next click re-joined instead of muting (which
also dropped the peer + killed the equalizer). Two fixes:
- voice-glow keeps `dataset.inCall` SYMMETRIC (set on true, delete on false), so
  the mid-join false is restored once the stream resolves → mute works on first
  connect.
- voice-glow subscribes reliably on AUTO-REJOIN too (no click to trigger its
  poll): voice-mesh.js dispatches `voiceroom:ready` on singleton creation +
  voice-glow listens, so the glow is mesh-driven (peer-count equalizer) after a
  refresh, not just the in-call-class fallback.

Coverage:
- ITs: VoiceMuteViewTest (login/405/invalid-json guards, stamp on true, clear on
  false, re-mute restamps, missing-key=false). voice+lyric 164 green.
- Jasmine: BurgerSpec mute persistence (muteRemainingMs window, rejoin re-mute,
  expired-window auto-disconnect, toggle-persists + 3-min fires, manual-join
  clears); VoiceGlowSpec dataset.inCall sync (sets on in-call, clears on not,
  restores after a mid-join false→true). All green.
- Live multi-party voice (mic/2-device) left to manual verification.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:41:30 -04:00

605 lines
26 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", "mailman"})
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
def get_or_create_mailman():
"""Idempotent fetch of the sitewide `mailman` User — system-author for the
"Acceptances & rejections" invite log Lines (my-sea bud-invite flow, see
[[my-sea-invite-voice-blueprint]]). Parallels `get_or_create_taxman`
exactly; production migration `lyric/0015_seed_mailman` seeds the row once,
this helper backstops TransactionTestCase flushes."""
from django.contrib.auth.hashers import make_password
mailman, _ = User.objects.get_or_create(
username="mailman",
defaults={
"email": "mailman@earthmanrpg.local",
"password": make_password(None),
"is_staff": False,
"is_superuser": False,
"searchable": False,
},
)
return mailman
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)
# My-Sea voice mute persistence (user-spec 2026-05-30). The user's CURRENT
# voice mute state, stored as a TIMESTAMP (not a bare bool) so the 3-min
# "muted too long → auto-disconnect" window anchors here. Null = not muted.
# Stamped when the user mutes in the mesh; the voice auto-rejoin reads it so
# the mute carries across in-sea navigation/refresh; cleared on unmute, on a
# fresh manual join, + when the 3-min window elapses (client leaves voice).
# Per-user (not per-seat): the owner has no seat row, and a user is in at
# most one voice room at a time, so this uniformly covers owner + visitor.
voice_muted_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")