forked from s3lph/matemat
Allow users to transfer founds to another account
This commit is contained in:
parent
68a33d1819
commit
1b35f4ea7d
11 changed files with 343 additions and 39 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -1,5 +1,18 @@
|
|||
# Matemat Changelog
|
||||
|
||||
<!-- BEGIN RELEASE v0.2.10 -->
|
||||
## Version 0.2.10
|
||||
|
||||
Feature release
|
||||
|
||||
### Changes
|
||||
|
||||
<!-- BEGIN CHANGES 0.2.10 -->
|
||||
- Feature: Let users transfer funds to another account
|
||||
<!-- END CHANGES 0.2.10 -->
|
||||
|
||||
<!-- END RELEASE v0.2.10 -->
|
||||
|
||||
<!-- BEGIN RELEASE v0.2.9 -->
|
||||
## Version 0.2.9
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
__version__ = '0.2.9'
|
||||
__version__ = '0.2.10'
|
||||
|
|
|
@ -562,6 +562,88 @@ class MatematDatabase(object):
|
|||
# Reflect the change in the user object
|
||||
user.balance = old_balance + amount
|
||||
|
||||
def transfer(self, source: User, dest: User, amount: int) -> None:
|
||||
"""
|
||||
Transfer funds from one account to another.
|
||||
|
||||
:param source: The user account to remove funds from.
|
||||
:param dest: The user account to add funds to.
|
||||
:param amount: The amount to transfer between accounts.
|
||||
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
|
||||
"""
|
||||
if amount < 0:
|
||||
raise ValueError('Cannot transfer a negative value')
|
||||
with self.db.transaction() as c:
|
||||
# First, remove amount from the source user's account
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
|
||||
[source.id])
|
||||
row = c.fetchone()
|
||||
if row is None:
|
||||
raise DatabaseConsistencyError(f'No such user: {source.id}')
|
||||
source_old_balance: int = row[0]
|
||||
c.execute('''
|
||||
INSERT INTO transactions (user_id, value, old_balance)
|
||||
VALUES (:user_id, :value, :old_balance)
|
||||
''', {
|
||||
'user_id': source.id,
|
||||
'value': -amount,
|
||||
'old_balance': source_old_balance
|
||||
})
|
||||
c.execute('''
|
||||
INSERT INTO modifications (ta_id, agent, reason)
|
||||
VALUES (last_insert_rowid(), :user, :reason)
|
||||
''', {
|
||||
'user': source.name,
|
||||
'reason': f'Transfer to {dest.name}'
|
||||
})
|
||||
c.execute('''
|
||||
UPDATE users
|
||||
SET balance = balance - :amount
|
||||
WHERE user_id = :user_id
|
||||
''', {
|
||||
'user_id': source.id,
|
||||
'amount': amount
|
||||
})
|
||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||
if affected != 1:
|
||||
raise DatabaseConsistencyError(f'transfer should affect 1 users row, but affected {affected}')
|
||||
# Then, add the amount to the destination user's account
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
|
||||
[dest.id])
|
||||
row = c.fetchone()
|
||||
if row is None:
|
||||
raise DatabaseConsistencyError(f'No such user: {dest.id}')
|
||||
dest_old_balance: int = row[0]
|
||||
c.execute('''
|
||||
INSERT INTO transactions (user_id, value, old_balance)
|
||||
VALUES (:user_id, :value, :old_balance)
|
||||
''', {
|
||||
'user_id': dest.id,
|
||||
'value': amount,
|
||||
'old_balance': dest_old_balance
|
||||
})
|
||||
c.execute('''
|
||||
INSERT INTO modifications (ta_id, agent, reason)
|
||||
VALUES (last_insert_rowid(), :user, :reason)
|
||||
''', {
|
||||
'user': source.name,
|
||||
'reason': f'Transfer from {source.name}'
|
||||
})
|
||||
c.execute('''
|
||||
UPDATE users
|
||||
SET balance = balance + :amount
|
||||
WHERE user_id = :user_id
|
||||
''', {
|
||||
'user_id': dest.id,
|
||||
'amount': amount
|
||||
})
|
||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||
if affected != 1:
|
||||
raise DatabaseConsistencyError(f'deposit should affect 1 users row, but affected {affected}')
|
||||
# Reflect the change in the user object
|
||||
source.balance = source_old_balance - amount
|
||||
dest.balance = dest_old_balance + amount
|
||||
|
||||
def check_receipt_due(self, user: User) -> bool:
|
||||
if not isinstance(user.receipt_pref, ReceiptPreference):
|
||||
raise TypeError()
|
||||
|
|
|
@ -339,6 +339,36 @@ class DatabaseTest(unittest.TestCase):
|
|||
# Should fail, user id -1 does not exist
|
||||
db.deposit(user, 42)
|
||||
|
||||
def test_transfer(self) -> None:
|
||||
with self.db as db:
|
||||
with db.transaction() as c:
|
||||
user = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com', True, True)
|
||||
user2 = db.create_user('testuser2', 'supersecurepassword', 'testuser@example.com', True, True)
|
||||
user3 = db.create_user('testuser3', 'supersecurepassword', 'testuser@example.com', True, True)
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user.id])
|
||||
self.assertEqual(0, c.fetchone()[0])
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user2.id])
|
||||
self.assertEqual(0, c.fetchone()[0])
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user3.id])
|
||||
self.assertEqual(0, c.fetchone()[0])
|
||||
db.transfer(user, user2, 1337)
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user.id])
|
||||
self.assertEqual(-1337, c.fetchone()[0])
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user2.id])
|
||||
self.assertEqual(1337, c.fetchone()[0])
|
||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user3.id])
|
||||
self.assertEqual(0, c.fetchone()[0])
|
||||
with self.assertRaises(ValueError):
|
||||
# Should fail, negative amount
|
||||
db.transfer(user, user2, -42)
|
||||
user.id = -1
|
||||
with self.assertRaises(DatabaseConsistencyError):
|
||||
# Should fail, user id -1 does not exist
|
||||
db.transfer(user, user2, 42)
|
||||
with self.assertRaises(DatabaseConsistencyError):
|
||||
# Should fail, user id -1 does not exist
|
||||
db.transfer(user2, user, 42)
|
||||
|
||||
def test_consumption(self) -> None:
|
||||
with self.db as db:
|
||||
# Set up test case
|
||||
|
|
|
@ -10,6 +10,7 @@ from .logout import logout
|
|||
from .touchkey import touchkey_page
|
||||
from .buy import buy
|
||||
from .deposit import deposit
|
||||
from .transfer import transfer
|
||||
from .admin import admin
|
||||
from .metrics import metrics
|
||||
from .moduser import moduser
|
||||
|
|
|
@ -19,13 +19,14 @@ def main_page():
|
|||
uid: int = session.get(session_id, 'authenticated_user')
|
||||
authlevel: int = session.get(session_id, 'authentication_level')
|
||||
# Fetch the user object from the database (for name display, price calculation and admin check)
|
||||
users = db.list_users()
|
||||
user = db.get_user(uid)
|
||||
# Fetch the list of products to display
|
||||
products = db.list_products()
|
||||
# Prepare a response with a jinja2 template
|
||||
return template.render('productlist.html',
|
||||
authuser=user, products=products, authlevel=authlevel, stock=get_stock_provider(),
|
||||
setupname=config['InstanceName'])
|
||||
authuser=user, users=users, products=products, authlevel=authlevel,
|
||||
stock=get_stock_provider(), setupname=config['InstanceName'])
|
||||
else:
|
||||
# If there are no admin users registered, jump to the admin creation procedure
|
||||
if not db.has_admin_users():
|
||||
|
|
34
matemat/webserver/pagelets/transfer.py
Normal file
34
matemat/webserver/pagelets/transfer.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
from bottle import get, post, redirect, request
|
||||
|
||||
from matemat.db import MatematDatabase
|
||||
from matemat.webserver import session
|
||||
from matemat.webserver import config as c
|
||||
|
||||
|
||||
@get('/transfer')
|
||||
@post('/transfer')
|
||||
def transfer():
|
||||
"""
|
||||
The transfer mechanism to tranfer funds between accounts.
|
||||
"""
|
||||
config = c.get_app_config()
|
||||
session_id: str = session.start()
|
||||
# If no user is logged in, redirect to the main page, as a purchase must always be bound to a user
|
||||
if not session.has(session_id, 'authenticated_user'):
|
||||
redirect('/')
|
||||
# Connect to the database
|
||||
with MatematDatabase(config['DatabaseFile']) as db:
|
||||
# Fetch the authenticated user from the database
|
||||
uid: int = session.get(session_id, 'authenticated_user')
|
||||
user = db.get_user(uid)
|
||||
if 'target' not in request.params or 'n' not in request.params:
|
||||
redirect('/')
|
||||
return;
|
||||
# Fetch the target user from the database
|
||||
tuid = int(str(request.params.target))
|
||||
transfer_user = db.get_user(tuid)
|
||||
# Read and transfer amount between accounts
|
||||
amount = int(str(request.params.n))
|
||||
db.transfer(user, transfer_user, amount)
|
||||
# Redirect to the main page (where this request should have come from)
|
||||
redirect('/')
|
|
@ -1,5 +1,5 @@
|
|||
Package: matemat
|
||||
Version: 0.2.9
|
||||
Version: 0.2.10
|
||||
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
|
||||
Section: web
|
||||
Priority: optional
|
||||
|
|
|
@ -85,7 +85,7 @@
|
|||
margin: auto;
|
||||
width: 320px;
|
||||
height: 540px;
|
||||
grid-template-columns: 100px 100px 100px;
|
||||
grid-template-columns: 100px 100px 100px 200px;
|
||||
grid-template-rows: 100px 100px 100px 100px 100px;
|
||||
column-gap: 10px;
|
||||
row-gap: 10px;
|
||||
|
@ -196,3 +196,76 @@
|
|||
grid-row: 5;
|
||||
background: #60f060;
|
||||
}
|
||||
|
||||
#numpad-ok.disabled {
|
||||
background: #606060;
|
||||
}
|
||||
|
||||
#transfer-userlist {
|
||||
display: grid;
|
||||
grid-column: 4;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 6;
|
||||
grid-template-columns: 200px;
|
||||
grid-template-rows: 25px 1fr 25px;
|
||||
column-gap: 10px;
|
||||
row-gap: 10px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#transfer-userlist.show {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#transfer-userlist-list {
|
||||
margin: 10px 0;
|
||||
padding: 0;
|
||||
overflow-y: hidden;
|
||||
max-height: calc(100% - 150px);
|
||||
top: 45px;
|
||||
bottom: 45px;
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
#transfer-userlist-list > li {
|
||||
display: block;
|
||||
background: #f0f0f0;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 10px;
|
||||
list-style-type: none;
|
||||
font-family: sans-serif;
|
||||
line-height: 20px;
|
||||
font-size: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
#transfer-userlist > #scroll-up, #transfer-userlist > #scroll-down {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 25px;
|
||||
text-align: center;
|
||||
display: block;
|
||||
background: #f0f0f0;
|
||||
padding: 20px;
|
||||
font-family: sans-serif;
|
||||
line-height: 25px;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
#transfer-userlist > #scroll-up{
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
top: 0;
|
||||
}
|
||||
#transfer-userlist > #scroll-down {
|
||||
grid-row: 2;
|
||||
grid-column: 1;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
#transfer-userlist-list > li.active {
|
||||
background: #60f060;
|
||||
}
|
|
@ -4,59 +4,120 @@ Number.prototype.pad = function(size) {
|
|||
return s;
|
||||
}
|
||||
|
||||
const Mode = {
|
||||
Deposit: 0,
|
||||
Buy: 1,
|
||||
Transfer: 2,
|
||||
}
|
||||
|
||||
let mode = Mode.Deposit;
|
||||
let product_id = null;
|
||||
let target_user = null;
|
||||
let target_user_li = null;
|
||||
let deposit = '0';
|
||||
let button = document.createElement('div');
|
||||
let button_transfer = document.createElement('div');
|
||||
let input = document.getElementById('deposit-wrapper');
|
||||
let amount = document.getElementById('deposit-amount');
|
||||
let title = document.getElementById('deposit-title');
|
||||
let userlist = document.getElementById('transfer-userlist');
|
||||
let userlist_list = document.getElementById('transfer-userlist-list');
|
||||
let ok_button = document.getElementById('numpad-ok');
|
||||
button.classList.add('thumblist-item');
|
||||
button.classList.add('fakelink');
|
||||
button.innerText = 'Deposit';
|
||||
button.onclick = (ev) => {
|
||||
mode = Mode.Deposit;
|
||||
product_id = null;
|
||||
target_user = null;
|
||||
deposit = '0';
|
||||
title.innerText = 'Deposit';
|
||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
||||
input.classList.add('show');
|
||||
userlist.classList.remove('show');
|
||||
ok_button.classList.remove('disabled');
|
||||
};
|
||||
button_transfer.classList.add('thumblist-item');
|
||||
button_transfer.classList.add('fakelink');
|
||||
button_transfer.innerText = 'Transfer';
|
||||
button_transfer.onclick = (ev) => {
|
||||
mode = Mode.Transfer;
|
||||
product_id = null;
|
||||
target_user = null;
|
||||
deposit = '0';
|
||||
title.innerText = 'Transfer';
|
||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
||||
input.classList.add('show');
|
||||
userlist.classList.add('show');
|
||||
ok_button.classList.add('disabled');
|
||||
};
|
||||
setup_custom_price = (pid, pname) => {
|
||||
mode = Mode.Buy;
|
||||
product_id = pid;
|
||||
target_user = null;
|
||||
title.innerText = pname;
|
||||
deposit = '0';
|
||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
||||
input.classList.add('show');
|
||||
input.classList.add('show');
|
||||
userlist.classList.remove('show');
|
||||
ok_button.classList.remove('disabled');
|
||||
};
|
||||
set_transfer_user = (li, uid) => {
|
||||
if (target_user_li != null) {
|
||||
target_user_li.classList.remove('active');
|
||||
}
|
||||
target_user = uid;
|
||||
target_user_li = li;
|
||||
ok_button.classList.remove('disabled');
|
||||
target_user_li.classList.add('active');
|
||||
|
||||
}
|
||||
scrollUserlist = (delta) => {
|
||||
userlist_list.scrollBy(0, delta);
|
||||
}
|
||||
deposit_key = (k) => {
|
||||
if (k == 'ok') {
|
||||
if (product_id === null) {
|
||||
window.location.href = '/deposit?n=' + parseInt(deposit);
|
||||
} else {
|
||||
window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit);
|
||||
}
|
||||
deposit = '0';
|
||||
product_id = null;
|
||||
input.classList.remove('show');
|
||||
if (k == 'ok') {
|
||||
switch (mode) {
|
||||
case Mode.Deposit:
|
||||
window.location.href = '/deposit?n=' + parseInt(deposit);
|
||||
break;
|
||||
case Mode.Buy:
|
||||
window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit);
|
||||
break;
|
||||
case Mode.Transfer:
|
||||
if (target_user == null) {
|
||||
return;
|
||||
}
|
||||
window.location.href = '/transfer?target=' + target_user + '&n=' + parseInt(deposit);
|
||||
break;
|
||||
}
|
||||
mode = Mode.Deposit;
|
||||
deposit = '0';
|
||||
product_id = null;
|
||||
target_user = null;
|
||||
input.classList.remove('show');
|
||||
userlist.classList.remove('show');
|
||||
} else if (k == 'del') {
|
||||
if (deposit == '0') {
|
||||
product_id = null;
|
||||
input.classList.remove('show');
|
||||
}
|
||||
deposit = deposit.substr(0, deposit.length - 1);
|
||||
if (deposit.length == 0) {
|
||||
deposit = '0';
|
||||
}
|
||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
||||
} else {
|
||||
if (deposit == '0') {
|
||||
deposit = k;
|
||||
if (deposit == '0') {
|
||||
product_id = null;
|
||||
input.classList.remove('show');
|
||||
}
|
||||
deposit = deposit.substr(0, deposit.length - 1);
|
||||
if (deposit.length == 0) {
|
||||
deposit = '0';
|
||||
}
|
||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
||||
} else {
|
||||
deposit += k;
|
||||
}
|
||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
||||
if (deposit == '0') {
|
||||
deposit = k;
|
||||
} else {
|
||||
deposit += k;
|
||||
}
|
||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
||||
}
|
||||
};
|
||||
|
||||
let list = document.getElementById('depositlist');
|
||||
list.innerHTML = '';
|
||||
list.appendChild(button);
|
||||
list.appendChild(button_transfer);
|
||||
|
|
|
@ -25,15 +25,24 @@
|
|||
</div>
|
||||
</div>
|
||||
<div id="deposit-wrapper">
|
||||
<div id="deposit-input">
|
||||
<div id="deposit-output">
|
||||
<span id="deposit-title"></span>
|
||||
<span id="deposit-amount">0.00</span>
|
||||
</div>
|
||||
{% for i in [('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('del', '✗'), ('0', '0'), ('ok', '✓')] %}
|
||||
<div class="numpad" id="numpad-{{ i.0 }}" onclick="deposit_key('{{ i.0 }}');">{{ i.1 }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="deposit-input">
|
||||
<div id="deposit-output">
|
||||
<span id="deposit-title"></span>
|
||||
<span id="deposit-amount">0.00</span>
|
||||
</div>
|
||||
{% for i in [('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('del', '✗'), ('0', '0'), ('ok', '✓')] %}
|
||||
<div class="numpad" id="numpad-{{ i.0 }}" onclick="deposit_key('{{ i.0 }}');">{{ i.1 }}</div>
|
||||
{% endfor %}
|
||||
<div id="transfer-userlist">
|
||||
<div id="scroll-up" onclick="scrollUserlist(-130);">▲</div>
|
||||
<ul id="transfer-userlist-list">
|
||||
{% for user in ((users if user != authuser) | sort(attribute='name')) %}
|
||||
<li onclick="set_transfer_user(this, {{ user.id }})">{{ user.name }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div id="scroll-down" onclick="scrollUserlist(+130);">▼</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/depositlist.js"></script>
|
||||
<br/>
|
||||
|
|
Loading…
Reference in a new issue