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:
@@ -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})
|
|
||||||
@@ -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.conf import settings
|
||||||
from django.db import migrations, models
|
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)),
|
('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')),
|
('name', models.CharField(max_length=100, unique=True, verbose_name='commodity name')),
|
||||||
('in_circulation', models.PositiveIntegerField()),
|
('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)),
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
options={
|
options={
|
||||||
@@ -54,16 +54,15 @@ class Migration(migrations.Migration):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='TxRequests',
|
name='TxRequest',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('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()),
|
('source_amount', models.PositiveIntegerField()),
|
||||||
('dest_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')),
|
('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(on_delete=django.db.models.deletion.CASCADE, related_name='transaction_requests_sent', to=settings.AUTH_USER_MODEL)),
|
||||||
('source', models.ForeignKey(null=True, 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')),
|
('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')),
|
('amount', models.PositiveIntegerField(editable=False, verbose_name='amount')),
|
||||||
('commodity', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to='trading.Commodity')),
|
('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')),
|
('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')),
|
('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={
|
options={
|
||||||
|
|||||||
@@ -87,12 +87,26 @@ class User(PermissionsMixin, AbstractBaseUser):
|
|||||||
row["pk"]: row["in_circulation"]
|
row["pk"]: row["in_circulation"]
|
||||||
for row in self.commodity_set.values("pk", "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(
|
return frozendict(
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
Commodity.objects.get(pk=pk),
|
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
|
for pk in keys
|
||||||
]
|
]
|
||||||
@@ -142,9 +156,7 @@ class Commodity(models.Model):
|
|||||||
)
|
)
|
||||||
in_circulation = models.PositiveIntegerField()
|
in_circulation = models.PositiveIntegerField()
|
||||||
created_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
|
created_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
|
||||||
symbol = models.CharField(
|
symbol = models.CharField(blank=True, max_length=6, verbose_name=_("symbol"))
|
||||||
blank=True, unique=True, max_length=6, verbose_name=_("symbol")
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("commodity")
|
verbose_name = _("commodity")
|
||||||
@@ -157,7 +169,7 @@ class Commodity(models.Model):
|
|||||||
if self.symbol:
|
if self.symbol:
|
||||||
return f"<span>{self.symbol}</span>"
|
return f"<span>{self.symbol}</span>"
|
||||||
else:
|
else:
|
||||||
return f"<span>units of {self.name}"
|
return f"<span>units of {self.name}</span>"
|
||||||
|
|
||||||
|
|
||||||
class MaxCommodityError(Exception):
|
class MaxCommodityError(Exception):
|
||||||
@@ -191,51 +203,51 @@ class Tx(models.Model):
|
|||||||
)
|
)
|
||||||
amount = models.PositiveIntegerField(verbose_name=_("amount"), editable=False)
|
amount = models.PositiveIntegerField(verbose_name=_("amount"), editable=False)
|
||||||
commodity = models.ForeignKey(Commodity, on_delete=models.PROTECT, 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:
|
class Meta:
|
||||||
verbose_name = _("transaction")
|
verbose_name = _("transaction")
|
||||||
verbose_name_plural = _("transactions")
|
verbose_name_plural = _("transactions")
|
||||||
|
|
||||||
|
|
||||||
class TxRequests(models.Model):
|
class TxRequest(models.Model):
|
||||||
"""
|
"""
|
||||||
A transaction request between two users.
|
A transaction request between two users.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
WAITING = "WAIT"
|
OPEN = "OPEN"
|
||||||
DECLINED = "DECL"
|
DECLINED = "DECL"
|
||||||
REVISED = "REV"
|
# REVISED = "REV"
|
||||||
|
ACCEPTED = "ACC"
|
||||||
|
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
(WAITING, "Waiting"),
|
(OPEN, "Open"),
|
||||||
(DECLINED, "Declined"),
|
(DECLINED, "Declined"),
|
||||||
(REVISED, "Revised"),
|
# (REVISED, "Revised"),
|
||||||
|
(ACCEPTED, "Accepted"),
|
||||||
]
|
]
|
||||||
|
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
max_length=max([len(c) for c in STATUS_CHOICES]),
|
max_length=max([len(c) for c in STATUS_CHOICES]),
|
||||||
choices=STATUS_CHOICES,
|
choices=STATUS_CHOICES,
|
||||||
default=WAITING,
|
default=OPEN,
|
||||||
)
|
|
||||||
previous_request = models.OneToOneField(
|
|
||||||
"TxRequests",
|
|
||||||
null=True,
|
|
||||||
default=None,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
related_name="revised_request",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# previous_request = models.OneToOneField(
|
||||||
|
# "TxRequest",
|
||||||
|
# null=True,
|
||||||
|
# default=None,
|
||||||
|
# on_delete=models.SET_NULL,
|
||||||
|
# related_name="revised_request",
|
||||||
|
# )
|
||||||
|
|
||||||
source = models.ForeignKey(
|
source = models.ForeignKey(
|
||||||
User,
|
User, on_delete=models.CASCADE, related_name="transaction_requests_sent",
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
related_name="transaction_requests_sent",
|
|
||||||
)
|
)
|
||||||
dest = models.ForeignKey(
|
dest = models.ForeignKey(
|
||||||
User,
|
User, on_delete=models.CASCADE, related_name="transaction_requests_received",
|
||||||
on_delete=models.CASCADE,
|
|
||||||
null=True,
|
|
||||||
related_name="transaction_requests_received",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
source_sends = models.ForeignKey(
|
source_sends = models.ForeignKey(
|
||||||
@@ -248,6 +260,81 @@ class TxRequests(models.Model):
|
|||||||
source_amount = models.PositiveIntegerField()
|
source_amount = models.PositiveIntegerField()
|
||||||
dest_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):
|
class BalanceError(Exception):
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
||||||
107
trading/tests/test_models.py
Normal file
107
trading/tests/test_models.py
Normal 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()
|
||||||
|
|
||||||
Reference in New Issue
Block a user