Files
market/trading/models.py
Alek Ratzloff c320f81181 Remove Tx model
There was a split between a transaction request and an actual
transaction. This was kind of annoying because transactions were one-way
only, while transaction requests were two-way - which is what I believe
most transactions will be using. Tx model has been removed and the
responsibilities of it are covered by TxRequest. It simplifies
everything surrounding transactions, since we have only one model to
deal with instead of two.

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
2020-03-26 12:49:46 -04:00

359 lines
11 KiB
Python

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"<span>{self.symbol}</span>"
else:
return f"<span>units of {self.name}</span>"
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)