Update commodity and transaction details and list

Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
2020-03-26 15:18:41 -04:00
parent c320f81181
commit cf2b4ab06d
9 changed files with 211 additions and 31 deletions

View File

@@ -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.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -43,9 +43,9 @@ class Migration(migrations.Migration):
name='Commodity', name='Commodity',
fields=[ fields=[
('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(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()), ('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)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
], ],
options={ options={
@@ -58,6 +58,8 @@ class Migration(migrations.Migration):
fields=[ fields=[
('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)),
('status', models.CharField(choices=[('OPEN', 'Open'), ('DECL', 'Declined'), ('ACC', 'Accepted')], default='OPEN', max_length=2)), ('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)), ('source_amount', models.PositiveIntegerField(editable=False)),
('dest_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)), ('dest', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='tx_received', to=settings.AUTH_USER_MODEL)),

View File

@@ -1,11 +1,12 @@
from datetime import date from datetime import datetime
from typing import Mapping, Optional, Sequence from typing import Mapping, Optional, Sequence
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models 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.db.models.signals import post_save, pre_save
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from frozendict import frozendict from frozendict import frozendict
from guardian.shortcuts import assign_perm from guardian.shortcuts import assign_perm
@@ -75,8 +76,6 @@ class User(PermissionsMixin, AbstractBaseUser):
return self.balances().get(commodity, 0) return self.balances().get(commodity, 0)
def balances(self) -> Mapping["Commodity", int]: def balances(self) -> Mapping["Commodity", int]:
from django.db.models import Sum
# We are the source, receiving from the destination # We are the source, receiving from the destination
credits_sent = { credits_sent = {
row["dest_sends"]: row["dest_amount__sum"] row["dest_sends"]: row["dest_amount__sum"]
@@ -172,24 +171,82 @@ class Commodity(models.Model):
id = HashidAutoField(primary_key=True) id = HashidAutoField(primary_key=True)
name = models.CharField( 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() 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(blank=True, max_length=6, verbose_name=_("symbol"))
class Meta: class Meta:
verbose_name = _("commodity") verbose_name = _("commodity")
verbose_name_plural = _("commodities") verbose_name_plural = _("commodities")
def get_absolute_url(self): def trading_volume(self, since: Optional[datetime] = None) -> int:
return "/c/detail/%s" % self.pk """
Gets the amount of this commodity traded since the given cutoff, inclusive.
def html_symbol(self) -> str: If no cutoff is given, then it returns the volume of all transactions.
if self.symbol: """
return f"<span>{self.symbol}</span>" source_filt = {"source_sends": self, "status": TxRequest.ACCEPTED}
else: dest_filt = {"dest_sends": self, "status": TxRequest.ACCEPTED}
return f"<span>units of {self.name}</span>" 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'<a href="{reverse("trading:commodity_detail", args=[self.id])}">{self.symbol}</a>'
class MaxCommodityError(Exception): class MaxCommodityError(Exception):
@@ -230,6 +287,9 @@ class TxRequest(models.Model):
# related_name="revised_request", # related_name="revised_request",
# ) # )
create_time = models.DateTimeField(auto_now_add=True, editable=False)
update_time = models.DateTimeField(auto_now=True)
source = models.ForeignKey( source = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="tx_sent", editable=False, User, on_delete=models.CASCADE, related_name="tx_sent", editable=False,
) )

View File

@@ -1,5 +1,6 @@
{% extends "trading/base.html" %} {% extends "trading/base.html" %}
{% load bootstrap4 %} {% load bootstrap4 %}
{% load humanize %}
{% block title %} {% block title %}
{% with title="Commodity detail" %} {% with title="Commodity detail" %}
@@ -13,4 +14,46 @@
<h4>{{object.name}}</h4> <h4>{{object.name}}</h4>
</div> </div>
</div> </div>
<div class="row">
<div class="col-sm">
<table class="table">
<tbody>
<tr>
<td scope="row">Created by</td>
<td>{{object.created_by.username}}</td>
</tr>
{% if object.symbol %}
<tr>
<td scope="row">Trading symbol</td>
<td>{{object.symbol}}</td>
</tr>
{% endif %}
<tr>
<td scope="row">In circulation</td>
<td>{{object.in_circulation|intcomma}}</td>
</tr>
<tr>
<td scope="row">Held in pending transactions</td>
<td>{{object.amount_pending|intcomma}}</td>
</tr>
<tr>
<td scope="row">Trading volume (all time)</td>
<td>{{trading_volume_all|intcomma}}</td>
</tr>
<tr>
<td scope="row">Trading volume (past 24 hours)</td>
<td>{{trading_volume_day|intcomma}}</td>
</tr>
<tr>
<td scope="row">Trading volume (past hour)</td>
<td>{{trading_volume_hour|intcomma}}</td>
</tr>
<tr>
<td scope="row">Market spread</td>
<td>{{object.market_spread|intcomma}} user{{object.market_spread|pluralize}} ({{market_spread_ratio|intcomma}}%)</td>
</tr>
</tbody>
</table>
</div>
</div>
{% endblock %} {% endblock %}

View File

@@ -14,10 +14,12 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Transaction ID</th>
<th>Commodity sent</th> <th>Commodity sent</th>
<th>Commodity received</th> <th>Commodity received</th>
<th>Amount sent</th>
<th>Amount received</th>
<th>Status</th> <th>Status</th>
<th>ID</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -1,12 +1,18 @@
{% load humanize %}
<tr> <tr>
<td><a href="{% url "trading:tx_detail" object.id %}">{{object.id}}</a></td>
{% if object.source == request.user %} {% if object.source == request.user %}
<td>{{object.source_amount}}x {{object.source_sends.name}}</td> <td>{{object.source_sends.url|safe}}</td>
<td>{{object.dest_amount}}x {{object.dest_sends.name}}</td> <td>{{object.source_amount|intcomma}}</td>
<td>{{object.dest_sends.url|safe}}</td>
<td>{{object.dest_amount|intcomma}}</td>
{% else %} {% else %}
<td>{{object.dest_amount}}x {{object.dest_sends.name}}</td> <td>{{object.dest_sends.url|safe}}</td>
<td>{{object.source_amount}}x {{object.source_sends.name}}</td> <td>{{object.dest_amount|intcomma}}</td>
<td>{{object.source_sends.url|safe}}</td>
<td>{{object.source_amount|intcomma}}</td>
{% endif %} {% endif %}
<td>{{object.status | title}}</td> <td>{{object.status | title}}</td>
<td><a href="{% url "trading:tx_detail" object.id %}">{{object.id}}</a></td>
</tr> </tr>

View File

@@ -3,7 +3,7 @@
{% load humanize %} {% load humanize %}
{% block title %} {% block title %}
{% with title="Transaction detail" %} {% with title="Transactions" %}
{{ block.super }} {{ block.super }}
{% endwith %} {% endwith %}
{% endblock title %} {% endblock title %}
@@ -14,10 +14,12 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th>Transaction ID</th>
<th>Commodity sent</th> <th>Commodity sent</th>
<th>Commodity received</th> <th>Commodity received</th>
<th>Amount sent</th>
<th>Amount received</th>
<th>Status</th> <th>Status</th>
<th>ID</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>

View File

@@ -9,11 +9,11 @@ class TestTransactions(TestCase):
user2 = User.objects.create_user(username="test2", email="test2@test.test") user2 = User.objects.create_user(username="test2", email="test2@test.test")
# Create test commodity for user1 # 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() com1.save()
# Create test commodity for user2 # 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() com2.save()
# Create TX request # Create TX request
@@ -75,3 +75,47 @@ class TestTransactions(TestCase):
# And make sure you can still decline it after failing to accept it. # And make sure you can still decline it after failing to accept it.
req3.decline() 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)

View File

@@ -69,12 +69,11 @@ urlpatterns = [
path("u/profile/<int:pk>/", UserProfileView.as_view(), name="user_profile"), path("u/profile/<int:pk>/", UserProfileView.as_view(), name="user_profile"),
# t/ for tx # t/ for tx
path("t/detail/<str:pk>/", TxRequestDetailView.as_view(), name="tx_detail"), path("t/<str:pk>/", TxRequestDetailView.as_view(), name="tx_detail"),
path("t/", TxRequestListView.as_view(), name="tx_list"), path("t/", TxRequestListView.as_view(), name="tx_list"),
# c/ for commodities # c/ for commodities
path("c/create/", CommodityCreateView.as_view(), name="commodity_create"), path("c/create/", CommodityCreateView.as_view(), name="commodity_create"),
path("c/", CommodityListView.as_view(), name="commodity_list"),
#path("c/list/", CommodityListView.as_view(), name="commodity_list"), path("c/<str:pk>/", CommodityDetailView.as_view(), name="commodity_detail"),
path("c/detail/<str:pk>/", CommodityDetailView.as_view(), name="commodity_detail"),
] ]

View File

@@ -12,6 +12,7 @@ from django.views.generic.edit import CreateView, UpdateView
from django.views.generic.list import ListView from django.views.generic.list import ListView
from django.urls import reverse, reverse_lazy from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from guardian.mixins import PermissionRequiredMixin
from trading.models import User, Commodity, MaxCommodityError, TxRequest from trading.models import User, Commodity, MaxCommodityError, TxRequest
@@ -27,6 +28,26 @@ class CommodityDetailView(DetailView):
template_name = "trading/c/detail.html" template_name = "trading/c/detail.html"
model = Commodity 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): class CommodityCreateView(LoginRequiredMixin, CreateView):
template_name = "trading/c/create.html" template_name = "trading/c/create.html"
@@ -53,9 +74,10 @@ class CommodityCreateView(LoginRequiredMixin, CreateView):
# Tx views # Tx views
################################################################################ ################################################################################
class TxRequestDetailView(DetailView): class TxRequestDetailView(PermissionRequiredMixin, DetailView):
template_name = "trading/t/detail.html" template_name = "trading/t/detail.html"
model = TxRequest model = TxRequest
permission_required = "view_txrequest"
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)