Update commodity and transaction details and list
Signed-off-by: Alek Ratzloff <alekratz@gmail.com>
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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"<span>{self.symbol}</span>"
|
||||
else:
|
||||
return f"<span>units of {self.name}</span>"
|
||||
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'<a href="{reverse("trading:commodity_detail", args=[self.id])}">{self.symbol}</a>'
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Commodity detail" %}
|
||||
@@ -13,4 +14,46 @@
|
||||
<h4>{{object.name}}</h4>
|
||||
</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 %}
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Transaction ID</th>
|
||||
<th>Commodity sent</th>
|
||||
<th>Commodity received</th>
|
||||
<th>Amount sent</th>
|
||||
<th>Amount received</th>
|
||||
<th>Status</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
{% load humanize %}
|
||||
|
||||
<tr>
|
||||
<td><a href="{% url "trading:tx_detail" object.id %}">{{object.id}}</a></td>
|
||||
{% if object.source == request.user %}
|
||||
<td>{{object.source_amount}}x {{object.source_sends.name}}</td>
|
||||
<td>{{object.dest_amount}}x {{object.dest_sends.name}}</td>
|
||||
<td>{{object.source_sends.url|safe}}</td>
|
||||
<td>{{object.source_amount|intcomma}}</td>
|
||||
<td>{{object.dest_sends.url|safe}}</td>
|
||||
<td>{{object.dest_amount|intcomma}}</td>
|
||||
{% else %}
|
||||
<td>{{object.dest_amount}}x {{object.dest_sends.name}}</td>
|
||||
<td>{{object.source_amount}}x {{object.source_sends.name}}</td>
|
||||
<td>{{object.dest_sends.url|safe}}</td>
|
||||
<td>{{object.dest_amount|intcomma}}</td>
|
||||
<td>{{object.source_sends.url|safe}}</td>
|
||||
<td>{{object.source_amount|intcomma}}</td>
|
||||
{% endif %}
|
||||
<td>{{object.status | title}}</td>
|
||||
<td><a href="{% url "trading:tx_detail" object.id %}">{{object.id}}</a></td>
|
||||
</tr>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Transaction detail" %}
|
||||
{% with title="Transactions" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
@@ -14,10 +14,12 @@
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Transaction ID</th>
|
||||
<th>Commodity sent</th>
|
||||
<th>Commodity received</th>
|
||||
<th>Amount sent</th>
|
||||
<th>Amount received</th>
|
||||
<th>Status</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -69,12 +69,11 @@ urlpatterns = [
|
||||
path("u/profile/<int:pk>/", UserProfileView.as_view(), name="user_profile"),
|
||||
|
||||
# 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"),
|
||||
|
||||
# c/ for commodities
|
||||
path("c/create/", CommodityCreateView.as_view(), name="commodity_create"),
|
||||
|
||||
#path("c/list/", CommodityListView.as_view(), name="commodity_list"),
|
||||
path("c/detail/<str:pk>/", CommodityDetailView.as_view(), name="commodity_detail"),
|
||||
path("c/", CommodityListView.as_view(), name="commodity_list"),
|
||||
path("c/<str:pk>/", CommodityDetailView.as_view(), name="commodity_detail"),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user