diff --git a/tests/test_models.py b/tests/test_models.py
deleted file mode 100644
index e050ce3..0000000
--- a/tests/test_models.py
+++ /dev/null
@@ -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})
diff --git a/trading/migrations/0001_initial.py b/trading/migrations/0001_initial.py
index 55d92cf..ea7df37 100644
--- a/trading/migrations/0001_initial.py
+++ b/trading/migrations/0001_initial.py
@@ -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={
diff --git a/trading/models.py b/trading/models.py
index 1c997c3..f0069df 100644
--- a/trading/models.py
+++ b/trading/models.py
@@ -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"{self.symbol}"
else:
- return f"units of {self.name}"
+ return f"units of {self.name}"
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__(
diff --git a/trading/tests.py b/trading/tests.py
deleted file mode 100644
index 7ce503c..0000000
--- a/trading/tests.py
+++ /dev/null
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
diff --git a/tests/__init__.py b/trading/tests/__init__.py
similarity index 100%
rename from tests/__init__.py
rename to trading/tests/__init__.py
diff --git a/trading/tests/test_models.py b/trading/tests/test_models.py
new file mode 100644
index 0000000..6d49ded
--- /dev/null
+++ b/trading/tests/test_models.py
@@ -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()
+