import uuid from datetime import timedelta from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager 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.buddies.all()` = people I've explicitly # added (or implicitly via post-share / game-invite, which auto-adds # the recipient to the inviter's buddies list). `user.added_as_buddy` # = the inverse (people who have me in their buddies list); useful # for the future "buddy changed username" snapshot-accept flow. buddies = models.ManyToManyField( "self", symmetrical=False, blank=True, related_name="added_as_buddy", ) active_title = models.ForeignKey( "drama.Note", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) 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 recognition title, or 'Earthman' when no Note is donned. The 'Earthman' default mirrors the dashboard greeting fallback in dashboard/views.home_page.""" if self.active_title_id: return self.active_title.display_title 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" CARTE = "carte" TOKEN_TYPE_CHOICES = [ (COIN, "Coin-on-a-String"), (FREE, "Free Token"), (TITHE, "Tithe Token"), (PASS, "Backstage Pass"), (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 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 == self.PASS: 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.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.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}" @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")