From 1b35f4ea7ddcb20755a9a131dea63d6c434bb8a6 Mon Sep 17 00:00:00 2001 From: s3lph <1375407-s3lph@users.noreply.gitlab.com> Date: Sat, 16 Jul 2022 19:15:25 +0200 Subject: [PATCH] Allow users to transfer founds to another account --- CHANGELOG.md | 13 +++ matemat/__init__.py | 2 +- matemat/db/facade.py | 82 ++++++++++++++++++ matemat/db/test/test_facade.py | 30 +++++++ matemat/webserver/pagelets/__init__.py | 1 + matemat/webserver/pagelets/main.py | 5 +- matemat/webserver/pagelets/transfer.py | 34 ++++++++ package/debian/matemat/DEBIAN/control | 2 +- static/css/matemat.css | 75 ++++++++++++++++- static/js/depositlist.js | 111 +++++++++++++++++++------ templates/productlist.html | 27 ++++-- 11 files changed, 343 insertions(+), 39 deletions(-) create mode 100644 matemat/webserver/pagelets/transfer.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a93d38..54ae939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Matemat Changelog + +## Version 0.2.10 + +Feature release + +### Changes + + +- Feature: Let users transfer funds to another account + + + + ## Version 0.2.9 diff --git a/matemat/__init__.py b/matemat/__init__.py index f63c866..a0f6762 100644 --- a/matemat/__init__.py +++ b/matemat/__init__.py @@ -1,2 +1,2 @@ -__version__ = '0.2.9' +__version__ = '0.2.10' diff --git a/matemat/db/facade.py b/matemat/db/facade.py index 8839b6c..3572ff9 100644 --- a/matemat/db/facade.py +++ b/matemat/db/facade.py @@ -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() diff --git a/matemat/db/test/test_facade.py b/matemat/db/test/test_facade.py index 0210ecd..18fcef6 100644 --- a/matemat/db/test/test_facade.py +++ b/matemat/db/test/test_facade.py @@ -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 diff --git a/matemat/webserver/pagelets/__init__.py b/matemat/webserver/pagelets/__init__.py index 96b8234..d363652 100644 --- a/matemat/webserver/pagelets/__init__.py +++ b/matemat/webserver/pagelets/__init__.py @@ -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 diff --git a/matemat/webserver/pagelets/main.py b/matemat/webserver/pagelets/main.py index 3207e48..17ae4a0 100644 --- a/matemat/webserver/pagelets/main.py +++ b/matemat/webserver/pagelets/main.py @@ -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(): diff --git a/matemat/webserver/pagelets/transfer.py b/matemat/webserver/pagelets/transfer.py new file mode 100644 index 0000000..c2bdad5 --- /dev/null +++ b/matemat/webserver/pagelets/transfer.py @@ -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('/') diff --git a/package/debian/matemat/DEBIAN/control b/package/debian/matemat/DEBIAN/control index 1758e15..10edf70 100644 --- a/package/debian/matemat/DEBIAN/control +++ b/package/debian/matemat/DEBIAN/control @@ -1,5 +1,5 @@ Package: matemat -Version: 0.2.9 +Version: 0.2.10 Maintainer: s3lph Section: web Priority: optional diff --git a/static/css/matemat.css b/static/css/matemat.css index 774ef32..e9d89fe 100644 --- a/static/css/matemat.css +++ b/static/css/matemat.css @@ -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; +} \ No newline at end of file diff --git a/static/js/depositlist.js b/static/js/depositlist.js index 026a800..2a2a16f 100644 --- a/static/js/depositlist.js +++ b/static/js/depositlist.js @@ -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); diff --git a/templates/productlist.html b/templates/productlist.html index b1d2eca..104856d 100644 --- a/templates/productlist.html +++ b/templates/productlist.html @@ -25,15 +25,24 @@
-
-
- - 0.00 -
- {% 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', '✓')] %} -
{{ i.1 }}
- {% endfor %} -
+
+
+ + 0.00 +
+ {% 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', '✓')] %} +
{{ i.1 }}
+ {% endfor %} +
+ +
    + {% for user in ((users if user != authuser) | sort(attribute='name')) %} +
  • {{ user.name }}
  • + {% endfor %} +
+ +
+