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() +