0
trading/__init__.py
Normal file
0
trading/__init__.py
Normal file
3
trading/admin.py
Normal file
3
trading/admin.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
5
trading/apps.py
Normal file
5
trading/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TradingConfig(AppConfig):
|
||||
name = 'trading'
|
||||
0
trading/forms.py
Normal file
0
trading/forms.py
Normal file
31
trading/managers.py
Normal file
31
trading/managers.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
use_in_migrations = True
|
||||
|
||||
def _create_user(self, username, email, password, **extra_fields):
|
||||
"""
|
||||
Creates and saves a User with the given email and password.
|
||||
"""
|
||||
if not email:
|
||||
raise ValueError('The given email must be set')
|
||||
if not username:
|
||||
raise ValueError('The given username must be set')
|
||||
# TODO: validate username
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(username=username, email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_user(self, username, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_superuser', False)
|
||||
return self._create_user(username, email, password, **extra_fields)
|
||||
|
||||
def create_superuser(self, username, email, password, **extra_fields):
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
|
||||
if extra_fields.get('is_superuser') is not True:
|
||||
raise ValueError('Superuser must have is_superuser=True.')
|
||||
|
||||
return self._create_user(username, email, password, **extra_fields)
|
||||
97
trading/migrations/0001_initial.py
Normal file
97
trading/migrations/0001_initial.py
Normal file
@@ -0,0 +1,97 @@
|
||||
# Generated by Django 2.2 on 2020-01-16 01:11
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import hashid_field.field
|
||||
import trading.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0011_update_proxy_permissions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('username', models.CharField(max_length=30, unique=True)),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('is_superuser', models.BooleanField(default=False)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('date_joined', models.DateTimeField(auto_now_add=True, verbose_name='date joined')),
|
||||
('max_commodities_allowed', models.PositiveIntegerField(default=1, null=True, verbose_name='number of commodities this user is allowed to create')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
},
|
||||
managers=[
|
||||
('objects', trading.managers.UserManager()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
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')),
|
||||
('in_circulation', models.PositiveIntegerField()),
|
||||
('symbol', models.CharField(blank=True, max_length=6, unique=True, verbose_name='symbol')),
|
||||
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'commodity',
|
||||
'verbose_name_plural': 'commodities',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TxRequests',
|
||||
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)),
|
||||
('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_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_sends', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='trading.Commodity')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Tx',
|
||||
fields=[
|
||||
('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)),
|
||||
('instant', models.DateTimeField(auto_now_add=True)),
|
||||
('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')),
|
||||
('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={
|
||||
'verbose_name': 'transaction',
|
||||
'verbose_name_plural': 'transactions',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Invite',
|
||||
fields=[
|
||||
('id', hashid_field.field.HashidAutoField(alphabet='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', min_length=7, primary_key=True, serialize=False)),
|
||||
('accepted_user', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='accepted user')),
|
||||
('inviter', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'invite',
|
||||
'verbose_name_plural': 'invites',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
trading/migrations/__init__.py
Normal file
0
trading/migrations/__init__.py
Normal file
279
trading/models.py
Normal file
279
trading/models.py
Normal file
@@ -0,0 +1,279 @@
|
||||
from datetime import date
|
||||
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.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from frozendict import frozendict
|
||||
from hashid_field import HashidAutoField
|
||||
from trading.managers import UserManager
|
||||
|
||||
|
||||
class User(PermissionsMixin, AbstractBaseUser):
|
||||
"""
|
||||
Base user model, with auth information already filled in.
|
||||
"""
|
||||
|
||||
username = models.CharField(max_length=30, unique=True)
|
||||
email = models.EmailField(unique=True)
|
||||
is_superuser = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
date_joined = models.DateTimeField(_("date joined"), auto_now_add=True)
|
||||
|
||||
max_commodities_allowed = models.PositiveIntegerField(
|
||||
default=settings.TRADING_FREE_COMMODITIES,
|
||||
null=True,
|
||||
verbose_name=_("number of commodities this user is allowed to create"),
|
||||
)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = "username"
|
||||
EMAIL_FIELD = "email"
|
||||
REQUIRED_FIELDS = (EMAIL_FIELD,)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
def get_full_name(self):
|
||||
"""
|
||||
Returns the first_name plus the last_name, with a space in between.
|
||||
"""
|
||||
return self.username
|
||||
|
||||
def get_short_name(self):
|
||||
"""
|
||||
Returns the short name for the user.
|
||||
"""
|
||||
return self.username
|
||||
|
||||
def email_user(self, subject, message, from_email=None, **kwargs):
|
||||
"""
|
||||
Sends an email to this User.
|
||||
"""
|
||||
send_mail(subject, message, from_email, [self.email], **kwargs)
|
||||
|
||||
def can_create_commodity(self) -> bool:
|
||||
if self.max_commodities_allowed is None:
|
||||
return True
|
||||
return self.max_commodities_allowed > self.commodity_set.count()
|
||||
|
||||
def unused_commodities(self) -> Optional[int]:
|
||||
if self.max_commodities_allowed is None:
|
||||
return None
|
||||
return self.max_commodities_allowed - self.commodity_set.count()
|
||||
|
||||
def unused_invites(self) -> Sequence["Invite"]:
|
||||
return self.invite_set.filter(accepted_user__isnull=True)
|
||||
|
||||
def balance_of(self, commodity: "Commodity") -> int:
|
||||
return self.balances().get(commodity, 0)
|
||||
|
||||
def balances(self) -> Mapping["Commodity", int]:
|
||||
from django.db.models import Sum
|
||||
|
||||
credits = {
|
||||
row["commodity"]: row["amount__sum"]
|
||||
for row in self.credits.values("commodity").annotate(Sum("amount"))
|
||||
}
|
||||
debits = {
|
||||
row["commodity"]: row["amount__sum"]
|
||||
for row in self.debits.values("commodity").annotate(Sum("amount"))
|
||||
}
|
||||
ipos = {
|
||||
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())
|
||||
return frozendict(
|
||||
[
|
||||
(
|
||||
Commodity.objects.get(pk=pk),
|
||||
credits.get(pk, 0) + ipos.get(pk, 0) - debits.get(pk, 0),
|
||||
)
|
||||
for pk in keys
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def _user_post_save(sender, instance, created, **kwargs):
|
||||
# Create invites for the user, if they are permitted invites
|
||||
if created and settings.TRADING_FREE_INVITES:
|
||||
for _ in range(settings.TRADING_FREE_INVITES):
|
||||
Invite.objects.create(inviter=instance).save()
|
||||
|
||||
|
||||
class Invite(models.Model):
|
||||
"""
|
||||
An invitation that is given out to a user.
|
||||
"""
|
||||
|
||||
id = HashidAutoField(primary_key=True)
|
||||
inviter = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
accepted_user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
default=None,
|
||||
related_name="+",
|
||||
verbose_name=_("accepted user"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("invite")
|
||||
verbose_name_plural = _("invites")
|
||||
|
||||
|
||||
class Commodity(models.Model):
|
||||
"""
|
||||
A commodity with some kind of market cap.
|
||||
|
||||
A commodity has an associated "owner" account, which determines where the IPO for commodity
|
||||
goes.
|
||||
"""
|
||||
|
||||
id = HashidAutoField(primary_key=True)
|
||||
name = models.CharField(
|
||||
blank=False, unique=True, max_length=100, verbose_name=_("commodity name")
|
||||
)
|
||||
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")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("commodity")
|
||||
verbose_name_plural = _("commodities")
|
||||
|
||||
def get_absolute_url(self):
|
||||
return "/c/detail/%s" % self.pk
|
||||
|
||||
def html_symbol(self) -> str:
|
||||
if self.symbol:
|
||||
return f"<span>{self.symbol}</span>"
|
||||
else:
|
||||
return f"<span>units of {self.name}"
|
||||
|
||||
|
||||
class MaxCommodityError(Exception):
|
||||
def __init__(self, user: User):
|
||||
self.user = user
|
||||
super().__init__("maximum commodity count reached")
|
||||
|
||||
|
||||
class Tx(models.Model):
|
||||
"""
|
||||
A transaction from one account to another.
|
||||
"""
|
||||
|
||||
id = HashidAutoField(primary_key=True)
|
||||
instant = models.DateTimeField(auto_now_add=True, editable=False)
|
||||
source = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="debits",
|
||||
related_query_name="debit_tx",
|
||||
verbose_name=_("source"),
|
||||
editable=False,
|
||||
)
|
||||
dest = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="credits",
|
||||
related_query_name="credit_tx",
|
||||
verbose_name=_("destination"),
|
||||
editable=False,
|
||||
)
|
||||
amount = models.PositiveIntegerField(verbose_name=_("amount"), editable=False)
|
||||
commodity = models.ForeignKey(Commodity, on_delete=models.PROTECT, editable=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("transaction")
|
||||
verbose_name_plural = _("transactions")
|
||||
|
||||
|
||||
class TxRequests(models.Model):
|
||||
"""
|
||||
A transaction request between two users.
|
||||
"""
|
||||
|
||||
WAITING = "WAIT"
|
||||
DECLINED = "DECL"
|
||||
REVISED = "REV"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(WAITING, "Waiting"),
|
||||
(DECLINED, "Declined"),
|
||||
(REVISED, "Revised"),
|
||||
]
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
source = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="transaction_requests_sent",
|
||||
)
|
||||
dest = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="transaction_requests_received",
|
||||
)
|
||||
|
||||
source_sends = models.ForeignKey(
|
||||
Commodity, on_delete=models.CASCADE, related_name="+", null=True,
|
||||
)
|
||||
dest_sends = models.ForeignKey(
|
||||
Commodity, on_delete=models.CASCADE, related_name="+", null=True,
|
||||
)
|
||||
|
||||
source_amount = models.PositiveIntegerField()
|
||||
dest_amount = models.PositiveIntegerField()
|
||||
|
||||
|
||||
class BalanceError(Exception):
|
||||
def __init__(
|
||||
self, balance: int, source: User, dest: User, amount: int, commodity: Commodity
|
||||
) -> None:
|
||||
self.balance = balance
|
||||
self.source = source
|
||||
self.dest = dest
|
||||
self.amount = amount
|
||||
self.commodity = commodity
|
||||
super().__init__("balance too low to create transaction")
|
||||
|
||||
|
||||
@receiver(pre_save, sender=Tx)
|
||||
def _tx_pre_save(sender, instance, *args, **kwargs):
|
||||
"""
|
||||
Ensures that a transaction cannot be created if the source user doesn't have enough of the given
|
||||
commodity in their account.
|
||||
"""
|
||||
if instance.pk is None:
|
||||
balance = instance.source.balance_of(instance.commodity)
|
||||
if instance.amount > balance:
|
||||
raise BalanceError(
|
||||
balance,
|
||||
instance.source,
|
||||
instance.dest,
|
||||
instance.amount,
|
||||
instance.commodity,
|
||||
)
|
||||
25
trading/templates/trading/account/login.html
Normal file
25
trading/templates/trading/account/login.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Login" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4>Log in</h4>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_messages %}
|
||||
{% bootstrap_form form %}
|
||||
{% buttons submit="Log in" %}{% endbuttons %}
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{next}}"/>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
trading/templates/trading/account/password_change.html
Normal file
27
trading/templates/trading/account/password_change.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Password change" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4>Update your password</h4>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" type="submit">Change password</button>
|
||||
<a class="btn btn-link" href="{% url "trading:index" %}" role="button">Cancel</a>
|
||||
</div>
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{next}}"/>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
27
trading/templates/trading/account/password_change_done.html
Normal file
27
trading/templates/trading/account/password_change_done.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "trading/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Password change - success" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h2>Your password has been changed.</h2>
|
||||
<p>
|
||||
You will be redirected in 3 seconds.
|
||||
<a href="{{next|default:"/"}}" target="_blank">Click here if you are not automatically redirected.</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
setTimeout(function() {
|
||||
document.location = "{{next|default:"/"}}";
|
||||
}, 3000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
24
trading/templates/trading/account/password_reset.html
Normal file
24
trading/templates/trading/account/password_reset.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Password reset" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4 class="text-center">Reset your password</h4>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
{% buttons submit="Reset password" %}{% endbuttons %}
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{next}}"/>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "trading/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Password reset" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4>Success</h4>
|
||||
Your password has successfully been reset. You may now use your new password to
|
||||
<a href="{% url 'trading:login'%}"> log in</a>.
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Reset" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
{% if form %}
|
||||
<h4 class="text-center">Reset your password</h4>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
{% buttons submit="Reset password" %}{% endbuttons %}
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{next}}"/>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
<h4 class="text-center">Password reset error</h4>
|
||||
<p>
|
||||
The password reset link you are trying to use is either invalid
|
||||
or has expired. If you need to reset your password,
|
||||
<a href="{% url 'trading:password_reset' %}">click here</a> to
|
||||
do so.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
20
trading/templates/trading/account/password_reset_done.html
Normal file
20
trading/templates/trading/account/password_reset_done.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends "trading/base.html" %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Password reset" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4>Check your email</h4>
|
||||
<p>
|
||||
A password reset link has been sent to your email address. Please check your email
|
||||
and follow the instructions to finish resetting your password.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
75
trading/templates/trading/account/settings.html
Normal file
75
trading/templates/trading/account/settings.html
Normal file
@@ -0,0 +1,75 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Settings" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h2>User settings</h2>
|
||||
<strong> (<a href="{% url "trading:user_profile" object.id %}">view profile</a>)
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" type="submit">Update</button>
|
||||
</div>
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{next}}"/>
|
||||
{% endif %}
|
||||
</form>
|
||||
<p>
|
||||
<a href="{% url "trading:password_change" %}">Change your password here</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<h4>Invites</h4>
|
||||
{% if object.unused_invites %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Invite #</th>
|
||||
<th>Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for invite in object.invite_set.all %}
|
||||
{% if not invite.accepted_user %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>TODO Copy link script</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
No invites available.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% comment %}
|
||||
<script>
|
||||
const copyToClipboard = str => {
|
||||
const el = document.createElement('textarea');
|
||||
el.value = str;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
};
|
||||
</script>
|
||||
{% endcomment %}
|
||||
34
trading/templates/trading/base.html
Normal file
34
trading/templates/trading/base.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% load staticfiles %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{# TODO l8n #}
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="{% static 'trading/css/bootstrap.min.css' %}" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T">
|
||||
<title>{% block title %}Trading{% if title %} - {{ title }}{% endif %}{% endblock title %}</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block navbar %}
|
||||
{% include "trading/navbar.html" %}
|
||||
{% endblock navbar %}
|
||||
|
||||
|
||||
<main>
|
||||
<div class="container mt-5"> <!-- -->
|
||||
{% block messages %}
|
||||
{% bootstrap_messages %}
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="{% static 'trading/js/jquery-3.3.1.slim.min.js' %}" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"></script>
|
||||
<script src="{% static 'trading/js/popper.min.js' %}" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"></script>
|
||||
<script src="{% static 'trading/js/bootstrap.min.js' %}" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
18
trading/templates/trading/c/balances.html
Normal file
18
trading/templates/trading/c/balances.html
Normal file
@@ -0,0 +1,18 @@
|
||||
{% load humanize %}
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commodity</th>
|
||||
<th>Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for commodity, balance in object.balances.items %}
|
||||
<tr>
|
||||
<td>{{commodity.name}}</td>
|
||||
<td>{{balance|intcomma}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
40
trading/templates/trading/c/create.html
Normal file
40
trading/templates/trading/c/create.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Create commodity" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4>Create commodity</h4>
|
||||
{% if object.can_create_commodity %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form %}
|
||||
<div class="form-group">
|
||||
<div class="alert alert-light">
|
||||
<strong>Note:</strong> You have <strong>{{ object.unused_commodities|default:"unlimited" }}</strong>
|
||||
{% if object.unused_commodities %}
|
||||
commodit{{ object.unused_commodities|pluralize:"y,ies"}}
|
||||
{% else %}
|
||||
commodities
|
||||
{% endif %} available to create.
|
||||
</div>
|
||||
<button class="btn btn-primary" type="submit">Create</button>
|
||||
<a class="btn btn-link" href="{% url "trading:index" %}" role="button">Cancel</a>
|
||||
</div>
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{next}}"/>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
<p>You have created the maximum number of commodities you are allowed to create.</p>
|
||||
<p><a href="{% url "trading:settings" %}">Click here to view your settings.</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
trading/templates/trading/c/detail.html
Normal file
16
trading/templates/trading/c/detail.html
Normal file
@@ -0,0 +1,16 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Commodity detail" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4>{{object.name}}</h4>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
15
trading/templates/trading/index.html
Normal file
15
trading/templates/trading/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
{% extends "trading/base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h4>Balances</h4>
|
||||
<p>{% include "trading/c/balances.html" with object=request.user %}</p>
|
||||
{% if request.user.can_create_commodity %}
|
||||
<p><a href="{% url "trading:commodity_create" %}">Create a new commodity</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
3
trading/templates/trading/navbar.html
Normal file
3
trading/templates/trading/navbar.html
Normal file
@@ -0,0 +1,3 @@
|
||||
{% load staticfiles %}
|
||||
<nav class="navbar navbar-expand-lg">
|
||||
</nav>
|
||||
51
trading/templates/trading/u/profile.html
Normal file
51
trading/templates/trading/u/profile.html
Normal file
@@ -0,0 +1,51 @@
|
||||
{% extends "trading/base.html" %}
|
||||
{% load bootstrap4 %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}
|
||||
{% with title="Profile" %}
|
||||
{{ block.super }}
|
||||
{% endwith %}
|
||||
{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<h2>{{ object.username }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>{{ object_status }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date joined</td>
|
||||
<td>{{ object.date_joined.date }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-sm">
|
||||
<h4>Commodities owned by this user</h4>
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Commodity</th>
|
||||
<th>Circulation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for commodity in object.commodity_set.all %}
|
||||
<tr>
|
||||
<td><a href="{% url "trading:commodity_detail" commodity.pk %}">{{ commodity.name }}</a></td>
|
||||
<td>{{ commodity.in_circulation|intcomma }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
3
trading/tests.py
Normal file
3
trading/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
77
trading/urls.py
Normal file
77
trading/urls.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from django.urls import path, include
|
||||
from django.views.generic.base import RedirectView
|
||||
from django.contrib.auth.views import *
|
||||
from django.urls import reverse_lazy
|
||||
from trading.views import *
|
||||
|
||||
|
||||
app_name = "trading"
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", IndexView.as_view(), name="index"),
|
||||
path("login/", RedirectView.as_view(url=reverse_lazy("trading:login"))),
|
||||
path("logout/", RedirectView.as_view(url=reverse_lazy("trading:logout"))),
|
||||
path(
|
||||
"accounts/login/",
|
||||
LoginView.as_view(template_name="trading/account/login.html",),
|
||||
name="login",
|
||||
),
|
||||
path("accounts/logout/", LogoutView.as_view(), name="logout"),
|
||||
path(
|
||||
"accounts/password_reset/",
|
||||
PasswordResetView.as_view(
|
||||
template_name="trading/account/password_reset.html",
|
||||
success_url=reverse_lazy("trading:password_reset_done"),
|
||||
),
|
||||
name="password_reset",
|
||||
),
|
||||
path(
|
||||
"accounts/password_reset_done/",
|
||||
PasswordResetDoneView.as_view(
|
||||
template_name="trading/account/password_reset_done.html"
|
||||
),
|
||||
name="password_reset_done",
|
||||
),
|
||||
path(
|
||||
"accounts/password_reset_confirm/",
|
||||
PasswordResetConfirmView.as_view(
|
||||
template_name="trading/account/password_reset_confirm.html",
|
||||
success_url=reverse_lazy("trading:password_reset_complete"),
|
||||
),
|
||||
name="password_reset_confirm",
|
||||
),
|
||||
path(
|
||||
"accounts/password_reset_complete/",
|
||||
PasswordResetCompleteView.as_view(
|
||||
template_name="trading/account/password_reset_complete.html"
|
||||
),
|
||||
name="password_reset_complete",
|
||||
),
|
||||
path(
|
||||
"accounts/password_change/",
|
||||
PasswordChangeView.as_view(
|
||||
template_name="trading/account/password_change.html",
|
||||
success_url=reverse_lazy("trading:password_change_done"),
|
||||
),
|
||||
name="password_change",
|
||||
),
|
||||
path(
|
||||
"accounts/password_change_done/",
|
||||
PasswordChangeDoneView.as_view(
|
||||
template_name="trading/account/password_change_done.html"
|
||||
),
|
||||
name="password_change_done",
|
||||
),
|
||||
path("accounts/settings/", UserSettingsView.as_view(), name="settings"),
|
||||
|
||||
# u/ for users
|
||||
path("u/profile/<int:pk>/", UserProfileView.as_view(), name="user_profile"),
|
||||
|
||||
# t/ for tx
|
||||
# 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"),
|
||||
]
|
||||
80
trading/views.py
Normal file
80
trading/views.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from django import forms
|
||||
from django.contrib.auth import views as auth_views, password_validation
|
||||
from django.contrib.auth.forms import UsernameField
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.views.generic import TemplateView
|
||||
from django.views.generic.detail import DetailView
|
||||
from django.views.generic.edit import CreateView, UpdateView
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from trading.models import User, Commodity, MaxCommodityError
|
||||
|
||||
|
||||
class IndexView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "trading/index.html"
|
||||
|
||||
|
||||
class CommodityDetailView(DetailView):
|
||||
template_name = "trading/c/detail.html"
|
||||
model = Commodity
|
||||
|
||||
|
||||
class CommodityCreateView(LoginRequiredMixin, CreateView):
|
||||
template_name = "trading/c/create.html"
|
||||
model = Commodity
|
||||
fields = (
|
||||
"name",
|
||||
"in_circulation",
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["object"] = self.request.user
|
||||
return context
|
||||
|
||||
def form_valid(self, form):
|
||||
user = self.request.user
|
||||
if not user.can_create_commodity():
|
||||
raise MaxCommodityError(user)
|
||||
form.instance.created_by = user
|
||||
return super(CreateView, self).form_valid(form)
|
||||
|
||||
|
||||
class UserProfileView(DetailView):
|
||||
template_name = "trading/u/profile.html"
|
||||
model = User
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
if self.object.is_active:
|
||||
status = "Active"
|
||||
else:
|
||||
status = "Inactive"
|
||||
context["object_status"] = status
|
||||
return context
|
||||
|
||||
|
||||
class UserUpdateSettingsForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
"username",
|
||||
"email",
|
||||
)
|
||||
field_classes = {"username": UsernameField}
|
||||
|
||||
def __init__(self, user, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class UserSettingsView(LoginRequiredMixin, UpdateView):
|
||||
template_name = "trading/account/settings.html"
|
||||
model = User
|
||||
fields = ("username", "email")
|
||||
success_url = reverse_lazy("trading:settings")
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
return self.request.user
|
||||
Reference in New Issue
Block a user