from datetime import date from typing import Mapping, Optional, Sequence from django.conf import settings from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin from django.db import models from django.db.models import Q from django.db.models.signals import post_save, pre_save from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ from frozendict import frozendict from guardian.shortcuts import assign_perm from hashid_field import HashidAutoField from trading.managers import UserManager class User(PermissionsMixin, AbstractBaseUser): """ Base user model, with auth information already filled in. """ username = models.CharField(max_length=30, unique=True) email = models.EmailField(unique=True) is_superuser = models.BooleanField(default=False) is_active = models.BooleanField(default=True) date_joined = models.DateTimeField(_("date joined"), auto_now_add=True) max_commodities_allowed = models.PositiveIntegerField( default=settings.TRADING_FREE_COMMODITIES, null=True, verbose_name=_("number of commodities this user is allowed to create"), ) objects = UserManager() USERNAME_FIELD = "username" EMAIL_FIELD = "email" REQUIRED_FIELDS = (EMAIL_FIELD,) class Meta: verbose_name = _("user") verbose_name_plural = _("users") def get_full_name(self): """ Returns the first_name plus the last_name, with a space in between. """ return self.username def get_short_name(self): """ Returns the short name for the user. """ return self.username def email_user(self, subject, message, from_email=None, **kwargs): """ Sends an email to this User. """ send_mail(subject, message, from_email, [self.email], **kwargs) def can_create_commodity(self) -> bool: if self.max_commodities_allowed is None: return True return self.max_commodities_allowed > self.commodity_set.count() def unused_commodities(self) -> Optional[int]: if self.max_commodities_allowed is None: return None return self.max_commodities_allowed - self.commodity_set.count() def unused_invites(self) -> Sequence["Invite"]: return self.invite_set.filter(accepted_user__isnull=True) def balance_of(self, commodity: "Commodity") -> int: return self.balances().get(commodity, 0) def balances(self) -> Mapping["Commodity", int]: from django.db.models import Sum # We are the source, receiving from the destination credits_sent = { row["dest_sends"]: row["dest_amount__sum"] for row in self.tx_sent.filter(status=TxRequest.ACCEPTED) .values("dest_sends") .annotate(Sum("dest_amount")) } # We are the destination, accepting from the source credits_received = { row["source_sends"]: row["source_amount__sum"] for row in self.tx_received.filter(status=TxRequest.ACCEPTED) .values("source_sends") .annotate(Sum("source_amount")) } # We are the source, sending to the destination debits_sent = { row["source_sends"]: row["source_amount__sum"] for row in self.tx_sent.filter(~Q(status=TxRequest.DECLINED)) .values("source_sends") .annotate(Sum("source_amount")) } # We are the desination, purchasing from the source debits_received = { row["dest_sends"]: row["dest_amount__sum"] for row in self.tx_received.filter(status=TxRequest.ACCEPTED) .values("dest_sends") .annotate(Sum("dest_amount")) } ipos = { row["pk"]: row["in_circulation"] for row in self.commodity_set.values("pk", "in_circulation") } keys = ( set(ipos.keys()) | set(credits_sent.keys()) | set(credits_received.keys()) | set(debits_sent.keys()) | set(debits_received.keys()) # | set(tx_requests.keys()) ) return frozendict( [ ( Commodity.objects.get(pk=pk), credits_sent.get(pk, 0) + credits_received.get(pk, 0) + ipos.get(pk, 0) - debits_sent.get(pk, 0) - debits_received.get(pk, 0) # - tx_requests.get(pk, 0), ) for pk in keys ] ) @receiver(post_save, sender=User) def _user_post_save(sender, instance, created, **kwargs): # Create invites for the user, if they are permitted invites if created and settings.TRADING_FREE_INVITES: for _ in range(settings.TRADING_FREE_INVITES): Invite.objects.create(inviter=instance).save() class Invite(models.Model): """ An invitation that is given out to a user. """ id = HashidAutoField(primary_key=True) inviter = models.ForeignKey(User, on_delete=models.SET_NULL, null=True) accepted_user = models.ForeignKey( User, on_delete=models.CASCADE, null=True, default=None, related_name="+", verbose_name=_("accepted user"), ) class Meta: verbose_name = _("invite") verbose_name_plural = _("invites") class Commodity(models.Model): """ A commodity with some kind of market cap. A commodity has an associated "owner" account, which determines where the IPO for commodity goes. """ id = HashidAutoField(primary_key=True) name = models.CharField( blank=False, unique=True, max_length=100, verbose_name=_("commodity name") ) in_circulation = models.PositiveIntegerField() created_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) symbol = models.CharField(blank=True, max_length=6, verbose_name=_("symbol")) class Meta: verbose_name = _("commodity") verbose_name_plural = _("commodities") def get_absolute_url(self): return "/c/detail/%s" % self.pk def html_symbol(self) -> str: if self.symbol: return f"{self.symbol}" else: return f"units of {self.name}" class MaxCommodityError(Exception): def __init__(self, user: User): self.user = user super().__init__("maximum commodity count reached") class TxRequest(models.Model): """ A transaction request between two users. """ OPEN = "OPEN" DECLINED = "DECL" # REVISED = "REV" ACCEPTED = "ACC" STATUS_CHOICES = [ (OPEN, "Open"), (DECLINED, "Declined"), # (REVISED, "Revised"), (ACCEPTED, "Accepted"), ] id = HashidAutoField(primary_key=True) status = models.CharField( max_length=max([len(c) for c in STATUS_CHOICES]), choices=STATUS_CHOICES, default=OPEN, ) # previous_request = models.OneToOneField( # "TxRequest", # null=True, # default=None, # on_delete=models.SET_NULL, # related_name="revised_request", # ) source = models.ForeignKey( User, on_delete=models.CASCADE, related_name="tx_sent", editable=False, ) dest = models.ForeignKey( User, on_delete=models.CASCADE, related_name="tx_received", editable=False, ) source_sends = models.ForeignKey( Commodity, on_delete=models.CASCADE, related_name="+", null=True, editable=False, ) dest_sends = models.ForeignKey( Commodity, on_delete=models.CASCADE, related_name="+", null=True, editable=False, ) source_amount = models.PositiveIntegerField(editable=False) dest_amount = models.PositiveIntegerField(editable=False) @staticmethod def open( source: User, dest: User, source_sends: Commodity, dest_sends: Commodity, source_amount: int, dest_amount: int, ) -> "TxRequest": """ Opens a new transaction request. """ # Check balance source_balance = source.balance_of(source_sends) if source_balance < source_amount: raise BalanceError( source_balance, source, dest, source_amount, source_sends ) req = TxRequest.objects.create( status=TxRequest.OPEN, source=source, dest=dest, source_sends=source_sends, dest_sends=dest_sends, source_amount=source_amount, dest_amount=dest_amount, ) req.save() return req def can_accept(self) -> bool: """ Gets whether this request can be accepted. """ dest_balance = self.dest.balance_of(self.dest_sends) return self.status == TxRequest.OPEN and dest_balance >= self.dest_amount def accept(self): """ Accepts an open transaction request. """ assert self.status == TxRequest.OPEN # Ensure destination balance before continuing if not self.can_accept(): dest_balance = self.dest.balance_of(self.dest_sends) raise BalanceError( dest_balance, self.dest, self.source, self.dest_amount, self.dest_sends ) # Update status self.status = TxRequest.ACCEPTED self.save() def decline(self): """ Declines an open transaction request. """ assert self.status == TxRequest.OPEN self.status = TxRequest.DECLINED self.save() class BalanceError(Exception): def __init__( self, balance: int, source: User, dest: User, amount: int, commodity: Commodity ) -> None: self.balance = balance self.source = source self.dest = dest self.amount = amount self.commodity = commodity super().__init__("balance too low to create transaction") @receiver(pre_save, sender=TxRequest) def __tx_pre_save(sender, instance, *args, **kwargs): """ Ensures that a transaction cannot be created if the source user doesn't have enough of the given commodity in their account. """ if instance.pk is None: source_balance = instance.source.balance_of(instance.source_sends) if instance.source_amount > source_balance: raise BalanceError( source_balance, instance.source, instance.dest, instance.source_amount, instance.source_sends, ) # Do not check dest balance - could be a side channel for determining another account's # funds which is not something we want @receiver(post_save, sender=TxRequest) def __tx_post_save(sender, instance, created, **kwargs): if not created: return assign_perm("view_txrequest", instance.source, instance) assign_perm("view_txrequest", instance.dest, instance)