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
|
# 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 -->
|
<!-- BEGIN RELEASE v0.2.9 -->
|
||||||
## Version 0.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
|
# Reflect the change in the user object
|
||||||
user.balance = old_balance + amount
|
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:
|
def check_receipt_due(self, user: User) -> bool:
|
||||||
if not isinstance(user.receipt_pref, ReceiptPreference):
|
if not isinstance(user.receipt_pref, ReceiptPreference):
|
||||||
raise TypeError()
|
raise TypeError()
|
||||||
|
|
|
@ -339,6 +339,36 @@ class DatabaseTest(unittest.TestCase):
|
||||||
# Should fail, user id -1 does not exist
|
# Should fail, user id -1 does not exist
|
||||||
db.deposit(user, 42)
|
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:
|
def test_consumption(self) -> None:
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
# Set up test case
|
# Set up test case
|
||||||
|
|
|
@ -10,6 +10,7 @@ from .logout import logout
|
||||||
from .touchkey import touchkey_page
|
from .touchkey import touchkey_page
|
||||||
from .buy import buy
|
from .buy import buy
|
||||||
from .deposit import deposit
|
from .deposit import deposit
|
||||||
|
from .transfer import transfer
|
||||||
from .admin import admin
|
from .admin import admin
|
||||||
from .metrics import metrics
|
from .metrics import metrics
|
||||||
from .moduser import moduser
|
from .moduser import moduser
|
||||||
|
|
|
@ -19,13 +19,14 @@ def main_page():
|
||||||
uid: int = session.get(session_id, 'authenticated_user')
|
uid: int = session.get(session_id, 'authenticated_user')
|
||||||
authlevel: int = session.get(session_id, 'authentication_level')
|
authlevel: int = session.get(session_id, 'authentication_level')
|
||||||
# Fetch the user object from the database (for name display, price calculation and admin check)
|
# Fetch the user object from the database (for name display, price calculation and admin check)
|
||||||
|
users = db.list_users()
|
||||||
user = db.get_user(uid)
|
user = db.get_user(uid)
|
||||||
# Fetch the list of products to display
|
# Fetch the list of products to display
|
||||||
products = db.list_products()
|
products = db.list_products()
|
||||||
# Prepare a response with a jinja2 template
|
# Prepare a response with a jinja2 template
|
||||||
return template.render('productlist.html',
|
return template.render('productlist.html',
|
||||||
authuser=user, products=products, authlevel=authlevel, stock=get_stock_provider(),
|
authuser=user, users=users, products=products, authlevel=authlevel,
|
||||||
setupname=config['InstanceName'])
|
stock=get_stock_provider(), setupname=config['InstanceName'])
|
||||||
else:
|
else:
|
||||||
# If there are no admin users registered, jump to the admin creation procedure
|
# If there are no admin users registered, jump to the admin creation procedure
|
||||||
if not db.has_admin_users():
|
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
|
Package: matemat
|
||||||
Version: 0.2.9
|
Version: 0.2.10
|
||||||
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
|
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
|
||||||
Section: web
|
Section: web
|
||||||
Priority: optional
|
Priority: optional
|
||||||
|
|
|
@ -85,7 +85,7 @@
|
||||||
margin: auto;
|
margin: auto;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
height: 540px;
|
height: 540px;
|
||||||
grid-template-columns: 100px 100px 100px;
|
grid-template-columns: 100px 100px 100px 200px;
|
||||||
grid-template-rows: 100px 100px 100px 100px 100px;
|
grid-template-rows: 100px 100px 100px 100px 100px;
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
row-gap: 10px;
|
row-gap: 10px;
|
||||||
|
@ -196,3 +196,76 @@
|
||||||
grid-row: 5;
|
grid-row: 5;
|
||||||
background: #60f060;
|
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,39 +4,99 @@ Number.prototype.pad = function(size) {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Mode = {
|
||||||
|
Deposit: 0,
|
||||||
|
Buy: 1,
|
||||||
|
Transfer: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mode = Mode.Deposit;
|
||||||
let product_id = null;
|
let product_id = null;
|
||||||
|
let target_user = null;
|
||||||
|
let target_user_li = null;
|
||||||
let deposit = '0';
|
let deposit = '0';
|
||||||
let button = document.createElement('div');
|
let button = document.createElement('div');
|
||||||
|
let button_transfer = document.createElement('div');
|
||||||
let input = document.getElementById('deposit-wrapper');
|
let input = document.getElementById('deposit-wrapper');
|
||||||
let amount = document.getElementById('deposit-amount');
|
let amount = document.getElementById('deposit-amount');
|
||||||
let title = document.getElementById('deposit-title');
|
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('thumblist-item');
|
||||||
button.classList.add('fakelink');
|
button.classList.add('fakelink');
|
||||||
button.innerText = 'Deposit';
|
button.innerText = 'Deposit';
|
||||||
button.onclick = (ev) => {
|
button.onclick = (ev) => {
|
||||||
|
mode = Mode.Deposit;
|
||||||
product_id = null;
|
product_id = null;
|
||||||
|
target_user = null;
|
||||||
deposit = '0';
|
deposit = '0';
|
||||||
title.innerText = 'Deposit';
|
title.innerText = 'Deposit';
|
||||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
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');
|
||||||
|
};
|
||||||
|
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) => {
|
setup_custom_price = (pid, pname) => {
|
||||||
|
mode = Mode.Buy;
|
||||||
product_id = pid;
|
product_id = pid;
|
||||||
|
target_user = null;
|
||||||
title.innerText = pname;
|
title.innerText = pname;
|
||||||
deposit = '0';
|
deposit = '0';
|
||||||
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
|
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) => {
|
deposit_key = (k) => {
|
||||||
if (k == 'ok') {
|
if (k == 'ok') {
|
||||||
if (product_id === null) {
|
switch (mode) {
|
||||||
|
case Mode.Deposit:
|
||||||
window.location.href = '/deposit?n=' + parseInt(deposit);
|
window.location.href = '/deposit?n=' + parseInt(deposit);
|
||||||
} else {
|
break;
|
||||||
|
case Mode.Buy:
|
||||||
window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit);
|
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';
|
deposit = '0';
|
||||||
product_id = null;
|
product_id = null;
|
||||||
|
target_user = null;
|
||||||
input.classList.remove('show');
|
input.classList.remove('show');
|
||||||
|
userlist.classList.remove('show');
|
||||||
} else if (k == 'del') {
|
} else if (k == 'del') {
|
||||||
if (deposit == '0') {
|
if (deposit == '0') {
|
||||||
product_id = null;
|
product_id = null;
|
||||||
|
@ -60,3 +120,4 @@ deposit_key = (k) => {
|
||||||
let list = document.getElementById('depositlist');
|
let list = document.getElementById('depositlist');
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
list.appendChild(button);
|
list.appendChild(button);
|
||||||
|
list.appendChild(button_transfer);
|
||||||
|
|
|
@ -33,6 +33,15 @@
|
||||||
{% 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', '✓')] %}
|
{% 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>
|
<div class="numpad" id="numpad-{{ i.0 }}" onclick="deposit_key('{{ i.0 }}');">{{ i.1 }}</div>
|
||||||
{% endfor %}
|
{% 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>
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/depositlist.js"></script>
|
<script src="/static/js/depositlist.js"></script>
|
||||||
|
|
Loading…
Reference in a new issue