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>
359 lines
11 KiB
Python
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)
|