Move test directory, add TxRequest

* ./tests is moved to ./trading/tests
* Remove trading/tests.py
* Add TxRequest model
* Add tests for TxRequests

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2020-01-16 20:58:09 -05:00
parent 3b511798db
commit f5ccf8c695
6 changed files with 228 additions and 63 deletions

View File

@@ -1,26 +0,0 @@
from django.test import TestCase
from trading.models import User, Commodity, Tx, BalanceError
class TestTransactions(TestCase):
def test_user_balances(self):
# Create users
user1 = User.objects.create_user(username="test1", email="test1@test.test")
user2 = User.objects.create_user(username="test2", email="test2@test.test")
commodity = Commodity(created_by=user1, in_circulation=1000, name="commodity")
commodity.save()
self.assertEqual(user1.balances(), {commodity: 1000})
self.assertEqual(user2.balances(), {})
Tx(source=user1, dest=user2, amount=100, commodity=commodity).save()
self.assertEqual(user1.balances(), {commodity: 900})
self.assertEqual(user2.balances(), {commodity: 100})
with self.assertRaises(BalanceError):
Tx(source=user2, dest=user1, amount=1000, commodity=commodity).save()
self.assertEqual(user1.balances(), {commodity: 900})
self.assertEqual(user2.balances(), {commodity: 100})

View File

@@ -1,4 +1,4 @@
# Generated by Django 2.2 on 2020-01-16 01:11
# Generated by Django 2.2 on 2020-01-17 01:24
from django.conf import settings
from django.db import migrations, models
@@ -45,7 +45,7 @@ class Migration(migrations.Migration):
('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True, verbose_name='commodity name')),
('in_circulation', models.PositiveIntegerField()),
('symbol', models.CharField(blank=True, max_length=6, unique=True, verbose_name='symbol')),
('symbol', models.CharField(blank=True, max_length=6, verbose_name='symbol')),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
@@ -54,16 +54,15 @@ class Migration(migrations.Migration):
},
),
migrations.CreateModel(
name='TxRequests',
name='TxRequest',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('WAIT', 'Waiting'), ('DECL', 'Declined'), ('REV', 'Revised')], default='WAIT', max_length=2)),
('status', models.CharField(choices=[('OPEN', 'Open'), ('DECL', 'Declined'), ('ACC', 'Accepted')], default='OPEN', max_length=2)),
('source_amount', models.PositiveIntegerField()),
('dest_amount', models.PositiveIntegerField()),
('dest', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_received', to=settings.AUTH_USER_MODEL)),
('dest', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_received', to=settings.AUTH_USER_MODEL)),
('dest_sends', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='trading.Commodity')),
('previous_request', models.OneToOneField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='revised_request', to='trading.TxRequests')),
('source', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_sent', to=settings.AUTH_USER_MODEL)),
('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_sent', to=settings.AUTH_USER_MODEL)),
('source_sends', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='trading.Commodity')),
],
),
@@ -75,6 +74,7 @@ class Migration(migrations.Migration):
('amount', models.PositiveIntegerField(editable=False, verbose_name='amount')),
('commodity', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to='trading.Commodity')),
('dest', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='credits', related_query_name='credit_tx', to=settings.AUTH_USER_MODEL, verbose_name='destination')),
('request', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='trading.TxRequest')),
('source', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='debits', related_query_name='debit_tx', to=settings.AUTH_USER_MODEL, verbose_name='source')),
],
options={

View File

@@ -87,12 +87,26 @@ class User(PermissionsMixin, AbstractBaseUser):
row["pk"]: row["in_circulation"]
for row in self.commodity_set.values("pk", "in_circulation")
}
keys = set(ipos.keys()) | set(credits.keys()) | set(debits.keys())
tx_requests = {
row["source_sends"]: row["source_amount__sum"]
for row in self.transaction_requests_sent.filter(
status=TxRequest.OPEN
).values("source_sends").annotate(Sum("source_amount"))
}
keys = (
set(ipos.keys())
| set(credits.keys())
| set(debits.keys())
| set(tx_requests.keys())
)
return frozendict(
[
(
Commodity.objects.get(pk=pk),
credits.get(pk, 0) + ipos.get(pk, 0) - debits.get(pk, 0),
credits.get(pk, 0)
+ ipos.get(pk, 0)
- debits.get(pk, 0)
- tx_requests.get(pk, 0),
)
for pk in keys
]
@@ -142,9 +156,7 @@ class Commodity(models.Model):
)
in_circulation = models.PositiveIntegerField()
created_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
symbol = models.CharField(
blank=True, unique=True, max_length=6, verbose_name=_("symbol")
)
symbol = models.CharField(blank=True, max_length=6, verbose_name=_("symbol"))
class Meta:
verbose_name = _("commodity")
@@ -157,7 +169,7 @@ class Commodity(models.Model):
if self.symbol:
return f"<span>{self.symbol}</span>"
else:
return f"<span>units of {self.name}"
return f"<span>units of {self.name}</span>"
class MaxCommodityError(Exception):
@@ -191,51 +203,51 @@ class Tx(models.Model):
)
amount = models.PositiveIntegerField(verbose_name=_("amount"), editable=False)
commodity = models.ForeignKey(Commodity, on_delete=models.PROTECT, editable=False)
request = models.ForeignKey(
"TxRequest", null=True, default=None, on_delete=models.SET_NULL
)
class Meta:
verbose_name = _("transaction")
verbose_name_plural = _("transactions")
class TxRequests(models.Model):
class TxRequest(models.Model):
"""
A transaction request between two users.
"""
WAITING = "WAIT"
OPEN = "OPEN"
DECLINED = "DECL"
REVISED = "REV"
# REVISED = "REV"
ACCEPTED = "ACC"
STATUS_CHOICES = [
(WAITING, "Waiting"),
(OPEN, "Open"),
(DECLINED, "Declined"),
(REVISED, "Revised"),
# (REVISED, "Revised"),
(ACCEPTED, "Accepted"),
]
status = models.CharField(
max_length=max([len(c) for c in STATUS_CHOICES]),
choices=STATUS_CHOICES,
default=WAITING,
)
previous_request = models.OneToOneField(
"TxRequests",
null=True,
default=None,
on_delete=models.SET_NULL,
related_name="revised_request",
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,
null=True,
related_name="transaction_requests_sent",
User, on_delete=models.CASCADE, related_name="transaction_requests_sent",
)
dest = models.ForeignKey(
User,
on_delete=models.CASCADE,
null=True,
related_name="transaction_requests_received",
User, on_delete=models.CASCADE, related_name="transaction_requests_received",
)
source_sends = models.ForeignKey(
@@ -248,6 +260,81 @@ class TxRequests(models.Model):
source_amount = models.PositiveIntegerField()
dest_amount = models.PositiveIntegerField()
@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 accept(self):
"""
Accepts an open transaction request.
"""
assert self.status == TxRequest.OPEN
# ensure destination balance before continuing
dest_balance = self.dest.balance_of(self.dest_sends)
if dest_balance < self.dest_amount:
raise BalanceError(
dest_balance, self.dest, self.source, self.dest_amount, self.dest_sends
)
# update status
self.status = TxRequest.ACCEPTED
# Create source transaction
source_tx = Tx.objects.create(
source=self.source,
dest=self.dest,
amount=self.source_amount,
commodity=self.source_sends,
request=self,
)
# Create dest transaction
dest_tx = Tx.objects.create(
source=self.dest,
dest=self.source,
amount=self.dest_amount,
commodity=self.dest_sends,
request=self,
)
# Save everything
source_tx.save()
dest_tx.save()
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__(

View File

@@ -1,3 +0,0 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,107 @@
from django.test import TestCase
from trading.models import User, Commodity, Tx, TxRequest, BalanceError
class TestTransactions(TestCase):
def test_user_balances(self):
# Create users
user1 = User.objects.create_user(username="test1", email="test1@test.test")
user2 = User.objects.create_user(username="test2", email="test2@test.test")
# Create test commodity
commodity = Commodity(created_by=user1, in_circulation=1000, name="commodity")
commodity.save()
# Confirm balances
self.assertEqual(user1.balances(), {commodity: 1000})
self.assertEqual(user1.balance_of(commodity), 1000)
self.assertEqual(user2.balances(), {})
self.assertEqual(user2.balance_of(commodity), 0)
# Create transaction
Tx(source=user1, dest=user2, amount=100, commodity=commodity).save()
# Confirm balances
self.assertEqual(user1.balances(), {commodity: 900})
self.assertEqual(user2.balances(), {commodity: 100})
# Ensure we can't trade when there's missing balance
with self.assertRaises(BalanceError):
Tx(source=user2, dest=user1, amount=1000, commodity=commodity).save()
# Confirm balances
self.assertEqual(user1.balances(), {commodity: 900})
self.assertEqual(user2.balances(), {commodity: 100})
def test_tx_requests(self):
# Create users
user1 = User.objects.create_user(username="test1", email="test1@test.test")
user2 = User.objects.create_user(username="test2", email="test2@test.test")
# Create test commodity for user1
com1 = Commodity(created_by=user1, in_circulation=1000, name="test1")
com1.save()
# Create test commodity for user2
com2 = Commodity(created_by=user2, in_circulation=1000, name="test2")
com2.save()
# Create TX request
req1 = TxRequest.open(user1, user2, com1, com2, 10, 10)
# Confirm balances
self.assertEqual(user1.balance_of(com1), 990)
self.assertEqual(user1.balance_of(com2), 0)
self.assertEqual(user2.balance_of(com1), 0)
self.assertEqual(user2.balance_of(com2), 1000)
# Accept TX request
req1.accept()
# Confirm balances
self.assertEqual(user1.balance_of(com1), 990)
self.assertEqual(user1.balance_of(com2), 10)
self.assertEqual(user2.balance_of(com1), 10)
self.assertEqual(user2.balance_of(com2), 990)
# Create TX request
req2 = TxRequest.open(user2, user1, com2, com1, 10, 10)
# Confirm balances
self.assertEqual(user1.balance_of(com1), 990)
self.assertEqual(user1.balance_of(com2), 10)
self.assertEqual(user2.balance_of(com1), 10)
self.assertEqual(user2.balance_of(com2), 980)
# Decline TX request
req2.decline()
# Confirm balances
self.assertEqual(user1.balance_of(com1), 990)
self.assertEqual(user1.balance_of(com2), 10)
self.assertEqual(user2.balance_of(com1), 10)
self.assertEqual(user2.balance_of(com2), 990)
# Ensure assertions on already created TXs
with self.assertRaises(AssertionError):
req1.accept()
with self.assertRaises(AssertionError):
req1.decline()
with self.assertRaises(AssertionError):
req2.accept()
with self.assertRaises(AssertionError):
req2.decline()
# Balance checking
# Ensure whoever opens the request has the balance available
with self.assertRaises(BalanceError):
TxRequest.open(user1, user2, com2, com1, 100, 100)
# Ensure whoever tries to accept the request has the balance available
req3 = TxRequest.open(user1, user2, com2, com1, 10, 100)
with self.assertRaises(BalanceError):
req3.accept()
# And make sure you can still decline it after failing to accept it.
req3.decline()