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'{self.current_room.name}' 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")