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}}
+
+
+
+
+
+ | Created by |
+ {{object.created_by.username}} |
+
+ {% if object.symbol %}
+
+ | Trading symbol |
+ {{object.symbol}} |
+
+ {% endif %}
+
+ | 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 @@
- | Transaction ID |
Commodity sent |
Commodity received |
+ Amount sent |
+ Amount received |
Status |
+ ID |
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 %}
+
- | {{object.id}} |
{% if object.source == request.user %}
- {{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}} |
{% else %}
- {{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}} |
{% endif %}
{{object.status | title}} |
+ {{object.id}} |
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 received |
+ Amount sent |
+ Amount received |
Status |
+ 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)