diff --git a/trading/migrations/0001_initial.py b/trading/migrations/0001_initial.py index e2e6ea2..63613d7 100644 --- a/trading/migrations/0001_initial.py +++ b/trading/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2 on 2020-03-26 16:27 +# Generated by Django 2.2 on 2020-03-26 19:12 from django.conf import settings from django.db import migrations, models @@ -43,9 +43,9 @@ class Migration(migrations.Migration): name='Commodity', fields=[ ('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(editable=False, max_length=100, unique=True, verbose_name='commodity name')), + ('symbol', models.CharField(editable=False, max_length=6, unique=True, verbose_name='symbol')), ('in_circulation', models.PositiveIntegerField()), - ('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={ @@ -58,6 +58,8 @@ class Migration(migrations.Migration): fields=[ ('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)), ('status', models.CharField(choices=[('OPEN', 'Open'), ('DECL', 'Declined'), ('ACC', 'Accepted')], default='OPEN', max_length=2)), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('update_time', models.DateTimeField(auto_now=True)), ('source_amount', models.PositiveIntegerField(editable=False)), ('dest_amount', models.PositiveIntegerField(editable=False)), ('dest', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='tx_received', to=settings.AUTH_USER_MODEL)), diff --git a/trading/models.py b/trading/models.py index efdf61c..a0e7b0e 100644 --- a/trading/models.py +++ b/trading/models.py @@ -1,11 +1,12 @@ -from datetime import date +from datetime import datetime 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 import Q, Sum from django.db.models.signals import post_save, pre_save from django.dispatch import receiver +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from frozendict import frozendict from guardian.shortcuts import assign_perm @@ -75,8 +76,6 @@ class User(PermissionsMixin, AbstractBaseUser): 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"] @@ -172,24 +171,82 @@ class Commodity(models.Model): id = HashidAutoField(primary_key=True) name = models.CharField( - blank=False, unique=True, max_length=100, verbose_name=_("commodity name") + blank=False, + unique=True, + editable=False, + max_length=100, + verbose_name=_("commodity name"), + ) + symbol = models.CharField( + blank=False, unique=True, editable=False, max_length=6, verbose_name=_("symbol") ) 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 trading_volume(self, since: Optional[datetime] = None) -> int: + """ + Gets the amount of this commodity traded since the given cutoff, inclusive. - def html_symbol(self) -> str: - if self.symbol: - return f"{self.symbol}" - else: - return f"units of {self.name}" + If no cutoff is given, then it returns the volume of all transactions. + """ + source_filt = {"source_sends": self, "status": TxRequest.ACCEPTED} + dest_filt = {"dest_sends": self, "status": TxRequest.ACCEPTED} + if since is not None: + source_filt["update_time__gte"] = since + dest_filt["update_time__gte"] = since + source_query = ( + TxRequest.objects.filter(**source_filt) + .annotate(Sum("source_amount")) + .values("source_amount__sum") + .first() + ) + dest_query = ( + TxRequest.objects.filter(**dest_filt) + .annotate(Sum("dest_amount")) + .values("dest_amount__sum") + .first() + ) + source_amount = source_query["source_amount__sum"] if source_query else 0 + dest_amount = dest_query["dest_amount__sum"] if dest_query else 0 + return source_amount + dest_amount + + @property + def market_spread(self) -> int: + count = 0 + for user in User.objects.all(): + if not user.is_active: + continue + if user.balance_of(self) > 0: + count += 1 + return count + + @property + def market_spread_ratio(self) -> float: + user_count = len(User.objects.all()) + if user_count == 0: + return 0.0 + return self.market_spread / user_count + + @property + def amount_pending(self) -> int: + """ + Gets the amount of this commodity that is currently being held in pending transactions. + """ + pending = ( + TxRequest.objects.filter(source_sends=self, status=TxRequest.OPEN) + .annotate(Sum("source_amount")) + .values("source_amount__sum") + .first() + ) + return pending["source_amount__sum"] if pending else 0 + + @property + def url(self) -> str: + return f'{self.symbol}' class MaxCommodityError(Exception): @@ -230,6 +287,9 @@ class TxRequest(models.Model): # related_name="revised_request", # ) + create_time = models.DateTimeField(auto_now_add=True, editable=False) + update_time = models.DateTimeField(auto_now=True) + source = models.ForeignKey( User, on_delete=models.CASCADE, related_name="tx_sent", editable=False, ) diff --git a/trading/templates/trading/c/detail.html b/trading/templates/trading/c/detail.html index 4b75c7d..a4ed34a 100644 --- a/trading/templates/trading/c/detail.html +++ b/trading/templates/trading/c/detail.html @@ -1,5 +1,6 @@ {% extends "trading/base.html" %} {% load bootstrap4 %} +{% load humanize %} {% block title %} {% with title="Commodity detail" %} @@ -13,4 +14,46 @@

{{object.name}}

+
+
+ + + + + + + {% if object.symbol %} + + + + + {% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + +
Created by{{object.created_by.username}}
Trading symbol{{object.symbol}}
In circulation{{object.in_circulation|intcomma}}
Held in pending transactions{{object.amount_pending|intcomma}}
Trading volume (all time){{trading_volume_all|intcomma}}
Trading volume (past 24 hours){{trading_volume_day|intcomma}}
Trading volume (past hour){{trading_volume_hour|intcomma}}
Market spread{{object.market_spread|intcomma}} user{{object.market_spread|pluralize}} ({{market_spread_ratio|intcomma}}%)
+
+
{% endblock %} diff --git a/trading/templates/trading/t/detail.html b/trading/templates/trading/t/detail.html index eebee8b..347148a 100644 --- a/trading/templates/trading/t/detail.html +++ b/trading/templates/trading/t/detail.html @@ -14,10 +14,12 @@ - + + + diff --git a/trading/templates/trading/t/detail_row.html b/trading/templates/trading/t/detail_row.html index 8931655..ed2d4be 100644 --- a/trading/templates/trading/t/detail_row.html +++ b/trading/templates/trading/t/detail_row.html @@ -1,12 +1,18 @@ +{% load humanize %} + - {% if object.source == request.user %} - - + + + + {% else %} - - + + + + {% endif %} + diff --git a/trading/templates/trading/t/list.html b/trading/templates/trading/t/list.html index 0a43a96..83c557e 100644 --- a/trading/templates/trading/t/list.html +++ b/trading/templates/trading/t/list.html @@ -3,7 +3,7 @@ {% load humanize %} {% block title %} - {% with title="Transaction detail" %} + {% with title="Transactions" %} {{ block.super }} {% endwith %} {% endblock title %} @@ -14,10 +14,12 @@
Transaction ID Commodity sent Commodity receivedAmount sentAmount received StatusID
{{object.id}}{{object.source_amount}}x {{object.source_sends.name}}{{object.dest_amount}}x {{object.dest_sends.name}}{{object.source_sends.url|safe}}{{object.source_amount|intcomma}}{{object.dest_sends.url|safe}}{{object.dest_amount|intcomma}}{{object.dest_amount}}x {{object.dest_sends.name}}{{object.source_amount}}x {{object.source_sends.name}}{{object.dest_sends.url|safe}}{{object.dest_amount|intcomma}}{{object.source_sends.url|safe}}{{object.source_amount|intcomma}}{{object.status | title}}{{object.id}}
- + + + diff --git a/trading/tests/test_models.py b/trading/tests/test_models.py index 5d26f2b..a868cbf 100644 --- a/trading/tests/test_models.py +++ b/trading/tests/test_models.py @@ -9,11 +9,11 @@ class TestTransactions(TestCase): 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 = Commodity(created_by=user1, in_circulation=1000, name="test1", symbol="TEST1") com1.save() # Create test commodity for user2 - com2 = Commodity(created_by=user2, in_circulation=1000, name="test2") + com2 = Commodity(created_by=user2, in_circulation=1000, name="test2", symbol="TEST2") com2.save() # Create TX request @@ -75,3 +75,47 @@ class TestTransactions(TestCase): # And make sure you can still decline it after failing to accept it. req3.decline() + +class TestCommodities(TestCase): + def test_commodity_stats(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", symbol="TEST1") + com1.save() + + # Create test commodity for user2 + com2 = Commodity(created_by=user2, in_circulation=1000, name="test2", symbol="TEST2") + com2.save() + + # Ensure stats are correct + self.assertEqual(com1.amount_pending, 0) + self.assertEqual(com2.amount_pending, 0) + self.assertEqual(com1.trading_volume(), 0) + self.assertEqual(com2.trading_volume(), 0) + self.assertEqual(com1.market_spread, 1) + self.assertEqual(com2.market_spread, 1) + + # Create TX request + req1 = TxRequest.open(user1, user2, com1, com2, 10, 100) + + # Ensure trading volume is correct + self.assertEqual(com1.amount_pending, 10) + self.assertEqual(com2.amount_pending, 0) + self.assertEqual(com1.trading_volume(), 0) + self.assertEqual(com2.trading_volume(), 0) + self.assertEqual(com1.market_spread, 1) + self.assertEqual(com2.market_spread, 1) + + # Accept trade + req1.accept() + + # Ensure amount pending is correct + self.assertEqual(com1.amount_pending, 0) + self.assertEqual(com2.amount_pending, 0) + self.assertEqual(com1.trading_volume(), 10) + self.assertEqual(com2.trading_volume(), 100) + self.assertEqual(com1.market_spread, 2) + self.assertEqual(com2.market_spread, 2) diff --git a/trading/urls.py b/trading/urls.py index 355bd09..6306732 100644 --- a/trading/urls.py +++ b/trading/urls.py @@ -69,12 +69,11 @@ urlpatterns = [ path("u/profile//", UserProfileView.as_view(), name="user_profile"), # t/ for tx - path("t/detail//", TxRequestDetailView.as_view(), name="tx_detail"), + path("t//", TxRequestDetailView.as_view(), name="tx_detail"), path("t/", TxRequestListView.as_view(), name="tx_list"), # c/ for commodities path("c/create/", CommodityCreateView.as_view(), name="commodity_create"), - - #path("c/list/", CommodityListView.as_view(), name="commodity_list"), - path("c/detail//", CommodityDetailView.as_view(), name="commodity_detail"), + path("c/", CommodityListView.as_view(), name="commodity_list"), + path("c//", CommodityDetailView.as_view(), name="commodity_detail"), ] diff --git a/trading/views.py b/trading/views.py index abb92c5..df4e406 100644 --- a/trading/views.py +++ b/trading/views.py @@ -12,6 +12,7 @@ from django.views.generic.edit import CreateView, UpdateView from django.views.generic.list import ListView from django.urls import reverse, reverse_lazy from django.utils.translation import gettext_lazy as _ +from guardian.mixins import PermissionRequiredMixin from trading.models import User, Commodity, MaxCommodityError, TxRequest @@ -27,6 +28,26 @@ class CommodityDetailView(DetailView): template_name = "trading/c/detail.html" model = Commodity + def get_context_data(self, **kwargs): + from django import utils + from datetime import timedelta + DAY = timedelta(days=1) + HOUR = timedelta(hours=1) + context = super().get_context_data(**kwargs) + now = utils.timezone.now() + obj = context["object"] + context["trading_volume_all"] = obj.trading_volume() + context["trading_volume_day"] = obj.trading_volume(now - DAY) + context["trading_volume_hour"] = obj.trading_volume(now - HOUR) + context["market_spread_ratio"] = f"{obj.market_spread_ratio*100:.2f}" + return context + + +class CommodityListView(ListView): + template_name = "trading/c/list.html" + model = Commodity + paginate_by = 100 + class CommodityCreateView(LoginRequiredMixin, CreateView): template_name = "trading/c/create.html" @@ -53,9 +74,10 @@ class CommodityCreateView(LoginRequiredMixin, CreateView): # Tx views ################################################################################ -class TxRequestDetailView(DetailView): +class TxRequestDetailView(PermissionRequiredMixin, DetailView): template_name = "trading/t/detail.html" model = TxRequest + permission_required = "view_txrequest" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs)
Transaction ID Commodity sent Commodity receivedAmount sentAmount received StatusID