forked from s3lph/matemat
Merge branch '21-documentation-and-tests-for-staging-unstable' into 'staging-unstable'
Resolve "Documentation and Tests for staging-unstable" See merge request s3lph/matemat!22
This commit is contained in:
commit
69758ed2b2
20 changed files with 584 additions and 226 deletions
2
doc
2
doc
|
@ -1 +1 @@
|
||||||
Subproject commit da08844f714919a287cd62a9fa90faf3a484d3c3
|
Subproject commit ece840af5b19d2c78b2dbfa14adac145fab79f4f
|
|
@ -232,7 +232,7 @@ class MatematDatabase(object):
|
||||||
'tkhash': tkhash
|
'tkhash': tkhash
|
||||||
})
|
})
|
||||||
|
|
||||||
def change_user(self, user: User, agent: User, **kwargs)\
|
def change_user(self, user: User, agent: Optional[User], **kwargs)\
|
||||||
-> None:
|
-> None:
|
||||||
"""
|
"""
|
||||||
Commit changes to the user in the database. If writing the requested changes succeeded, the values are updated
|
Commit changes to the user in the database. If writing the requested changes succeeded, the values are updated
|
||||||
|
@ -240,7 +240,7 @@ class MatematDatabase(object):
|
||||||
the ID field in the provided user object.
|
the ID field in the provided user object.
|
||||||
|
|
||||||
:param user: The user object to update and to identify the requested user by.
|
:param user: The user object to update and to identify the requested user by.
|
||||||
:param agent: The user that is performing the change.
|
:param agent: The user that is performing the change. Must be present if the balance is changed.
|
||||||
:param kwargs: The properties to change.
|
:param kwargs: The properties to change.
|
||||||
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
|
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
|
||||||
"""
|
"""
|
||||||
|
@ -257,6 +257,8 @@ class MatematDatabase(object):
|
||||||
raise DatabaseConsistencyError(f'User with ID {user.id} does not exist')
|
raise DatabaseConsistencyError(f'User with ID {user.id} does not exist')
|
||||||
oldbalance: int = row[0]
|
oldbalance: int = row[0]
|
||||||
if balance != oldbalance:
|
if balance != oldbalance:
|
||||||
|
if agent is None:
|
||||||
|
raise ValueError('agent must not be None for a balance change')
|
||||||
c.execute('''
|
c.execute('''
|
||||||
INSERT INTO transactions (user_id, value, old_balance)
|
INSERT INTO transactions (user_id, value, old_balance)
|
||||||
VALUES (:user_id, :value, :old_balance)
|
VALUES (:user_id, :value, :old_balance)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
@ -18,71 +17,116 @@ def admin(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[str, bytes, PageletResponse]:
|
-> Union[str, bytes, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The admin panel, shows a user's own settings. Additionally, for administrators, settings to modify other users and
|
||||||
|
products are shown.
|
||||||
|
"""
|
||||||
|
# If no user is logged in, redirect to the login page
|
||||||
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars:
|
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars:
|
||||||
return RedirectResponse('/login')
|
return RedirectResponse('/login')
|
||||||
authlevel: int = session_vars['authentication_level']
|
authlevel: int = session_vars['authentication_level']
|
||||||
uid: int = session_vars['authenticated_user']
|
uid: int = session_vars['authenticated_user']
|
||||||
|
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
|
||||||
if authlevel < 2:
|
if authlevel < 2:
|
||||||
raise HttpException(403)
|
raise HttpException(403)
|
||||||
|
|
||||||
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
|
# Fetch the authenticated user
|
||||||
user = db.get_user(uid)
|
user = db.get_user(uid)
|
||||||
|
# If the POST request contains a "change" parameter, delegate the change handling to the function below
|
||||||
if method == 'POST' and 'change' in args:
|
if method == 'POST' and 'change' in args:
|
||||||
handle_change(args, user, db, config)
|
handle_change(args, user, db, config)
|
||||||
|
# If the POST request contains an "adminchange" parameter, delegate the change handling to the function below
|
||||||
elif method == 'POST' and 'adminchange' in args and user.is_admin:
|
elif method == 'POST' and 'adminchange' in args and user.is_admin:
|
||||||
handle_admin_change(args, db, config)
|
handle_admin_change(args, db, config)
|
||||||
|
|
||||||
|
# Fetch all existing users and products from the database
|
||||||
users = db.list_users()
|
users = db.list_users()
|
||||||
products = db.list_products()
|
products = db.list_products()
|
||||||
|
# Render the "Admin/Settings" page
|
||||||
return TemplateResponse('admin.html',
|
return TemplateResponse('admin.html',
|
||||||
authuser=user, authlevel=authlevel, users=users, products=products,
|
authuser=user, authlevel=authlevel, users=users, products=products,
|
||||||
setupname=config['InstanceName'])
|
setupname=config['InstanceName'])
|
||||||
|
|
||||||
|
|
||||||
def handle_change(args: RequestArguments, user: User, db: MatematDatabase, config: Dict[str, str]) -> None:
|
def handle_change(args: RequestArguments, user: User, db: MatematDatabase, config: Dict[str, str]) -> None:
|
||||||
|
"""
|
||||||
|
Write the changes requested by a user for its own account to the database.
|
||||||
|
|
||||||
|
:param args: The RequestArguments object passed to the pagelet.
|
||||||
|
:param user: The user to edit.
|
||||||
|
:param db: The database facade where changes are written to.
|
||||||
|
:param config: The dictionary of config file entries from the [Pagelets] section.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Read the type of change requested by the user, then switch over it
|
||||||
change = str(args.change)
|
change = str(args.change)
|
||||||
|
|
||||||
|
# The user requested a modification of its general account information (username, email)
|
||||||
if change == 'account':
|
if change == 'account':
|
||||||
|
# Username and email must be set in the request arguments
|
||||||
if 'username' not in args or 'email' not in args:
|
if 'username' not in args or 'email' not in args:
|
||||||
return
|
return
|
||||||
username = str(args.username)
|
username = str(args.username)
|
||||||
email = str(args.email)
|
email = str(args.email)
|
||||||
|
# An empty e-mail field should be interpreted as NULL
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
email = None
|
email = None
|
||||||
|
# Attempt to update username and e-mail
|
||||||
try:
|
try:
|
||||||
db.change_user(user, name=username, email=email)
|
db.change_user(user, agent=None, name=username, email=email)
|
||||||
except DatabaseConsistencyError:
|
except DatabaseConsistencyError:
|
||||||
pass
|
return
|
||||||
|
|
||||||
|
# The user requested a password change
|
||||||
elif change == 'password':
|
elif change == 'password':
|
||||||
|
# The old password and 2x the new password must be present
|
||||||
if 'oldpass' not in args or 'newpass' not in args or 'newpass2' not in args:
|
if 'oldpass' not in args or 'newpass' not in args or 'newpass2' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the passwords from the request arguments
|
||||||
oldpass = str(args.oldpass)
|
oldpass = str(args.oldpass)
|
||||||
newpass = str(args.newpass)
|
newpass = str(args.newpass)
|
||||||
newpass2 = str(args.newpass2)
|
newpass2 = str(args.newpass2)
|
||||||
|
# The two instances of the new password must match
|
||||||
if newpass != newpass2:
|
if newpass != newpass2:
|
||||||
raise ValueError('New passwords don\'t match')
|
raise ValueError('New passwords don\'t match')
|
||||||
|
# Write the new password to the database
|
||||||
db.change_password(user, oldpass, newpass)
|
db.change_password(user, oldpass, newpass)
|
||||||
|
|
||||||
|
# The user requested a touchkey change
|
||||||
elif change == 'touchkey':
|
elif change == 'touchkey':
|
||||||
|
# The touchkey must be present
|
||||||
if 'touchkey' not in args:
|
if 'touchkey' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the touchkey from the request arguments
|
||||||
touchkey = str(args.touchkey)
|
touchkey = str(args.touchkey)
|
||||||
|
# An empty touchkey field should set the touchkey to NULL (disable touchkey login)
|
||||||
if len(touchkey) == 0:
|
if len(touchkey) == 0:
|
||||||
touchkey = None
|
touchkey = None
|
||||||
|
# Write the new touchkey to the database
|
||||||
db.change_touchkey(user, '', touchkey, verify_password=False)
|
db.change_touchkey(user, '', touchkey, verify_password=False)
|
||||||
|
|
||||||
|
# The user requested an avatar change
|
||||||
elif change == 'avatar':
|
elif change == 'avatar':
|
||||||
|
# The new avatar field must be present
|
||||||
if 'avatar' not in args:
|
if 'avatar' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the raw image data from the request
|
||||||
avatar = bytes(args.avatar)
|
avatar = bytes(args.avatar)
|
||||||
|
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
|
||||||
|
if len(avatar) == 0:
|
||||||
|
return
|
||||||
|
# Detect the MIME type
|
||||||
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
|
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
|
||||||
|
# Currently, only image/png is supported, don't process any other formats
|
||||||
if filemagic.mime_type != 'image/png':
|
if filemagic.mime_type != 'image/png':
|
||||||
# TODO: Optionally convert to png
|
# TODO: Optionally convert to png
|
||||||
return
|
return
|
||||||
|
# Create the absolute path of the upload directory
|
||||||
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
|
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
|
||||||
os.makedirs(abspath, exist_ok=True)
|
os.makedirs(abspath, exist_ok=True)
|
||||||
|
# Write the image to the file
|
||||||
with open(os.path.join(abspath, f'{user.id}.png'), 'wb') as f:
|
with open(os.path.join(abspath, f'{user.id}.png'), 'wb') as f:
|
||||||
f.write(avatar)
|
f.write(avatar)
|
||||||
|
|
||||||
|
@ -91,46 +135,76 @@ def handle_change(args: RequestArguments, user: User, db: MatematDatabase, confi
|
||||||
|
|
||||||
|
|
||||||
def handle_admin_change(args: RequestArguments, db: MatematDatabase, config: Dict[str, str]):
|
def handle_admin_change(args: RequestArguments, db: MatematDatabase, config: Dict[str, str]):
|
||||||
|
"""
|
||||||
|
Write the changes requested by an admin for users of products.
|
||||||
|
|
||||||
|
:param args: The RequestArguments object passed to the pagelet.
|
||||||
|
:param db: The database facade where changes are written to.
|
||||||
|
:param config: The dictionary of config file entries from the [Pagelets] section.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Read the type of change requested by the admin, then switch over it
|
||||||
change = str(args.adminchange)
|
change = str(args.adminchange)
|
||||||
|
|
||||||
|
# The user requested to create a new user
|
||||||
if change == 'newuser':
|
if change == 'newuser':
|
||||||
|
# Only create a new user if all required properties of the user are present in the request arguments
|
||||||
if 'username' not in args or 'email' not in args or 'password' not in args:
|
if 'username' not in args or 'email' not in args or 'password' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the properties from the request arguments
|
||||||
username = str(args.username)
|
username = str(args.username)
|
||||||
email = str(args.email)
|
email = str(args.email)
|
||||||
|
# An empty e-mail field should be interpreted as NULL
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
email = None
|
email = None
|
||||||
password = str(args.password)
|
password = str(args.password)
|
||||||
is_member = 'ismember' in args
|
is_member = 'ismember' in args
|
||||||
is_admin = 'isadmin' in args
|
is_admin = 'isadmin' in args
|
||||||
|
# Create the user in the database
|
||||||
db.create_user(username, password, email, member=is_member, admin=is_admin)
|
db.create_user(username, password, email, member=is_member, admin=is_admin)
|
||||||
|
|
||||||
|
# The user requested to create a new product
|
||||||
elif change == 'newproduct':
|
elif change == 'newproduct':
|
||||||
|
# Only create a new product if all required properties of the product are present in the request arguments
|
||||||
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args:
|
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the properties from the request arguments
|
||||||
name = str(args.name)
|
name = str(args.name)
|
||||||
price_member = int(str(args.pricemember))
|
price_member = int(str(args.pricemember))
|
||||||
price_non_member = int(str(args.pricenonmember))
|
price_non_member = int(str(args.pricenonmember))
|
||||||
|
# Create the user in the database
|
||||||
newproduct = db.create_product(name, price_member, price_non_member)
|
newproduct = db.create_product(name, price_member, price_non_member)
|
||||||
|
# If a new product image was uploaded, process it
|
||||||
if 'image' in args:
|
if 'image' in args:
|
||||||
|
# Read the raw image data from the request
|
||||||
image = bytes(args.image)
|
image = bytes(args.image)
|
||||||
|
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
|
||||||
|
if len(image) == 0:
|
||||||
|
return
|
||||||
|
# Detect the MIME type
|
||||||
filemagic: magic.FileMagic = magic.detect_from_content(image)
|
filemagic: magic.FileMagic = magic.detect_from_content(image)
|
||||||
|
# Currently, only image/png is supported, don't process any other formats
|
||||||
if filemagic.mime_type != 'image/png':
|
if filemagic.mime_type != 'image/png':
|
||||||
# TODO: Optionally convert to png
|
# TODO: Optionally convert to png
|
||||||
return
|
return
|
||||||
if len(image) > 0:
|
# Create the absolute path of the upload directory
|
||||||
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
|
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
|
||||||
os.makedirs(abspath, exist_ok=True)
|
os.makedirs(abspath, exist_ok=True)
|
||||||
|
# Write the image to the file
|
||||||
with open(os.path.join(abspath, f'{newproduct.id}.png'), 'wb') as f:
|
with open(os.path.join(abspath, f'{newproduct.id}.png'), 'wb') as f:
|
||||||
f.write(image)
|
f.write(image)
|
||||||
|
|
||||||
|
# The user requested to restock a product
|
||||||
elif change == 'restock':
|
elif change == 'restock':
|
||||||
|
# Only restock a product if all required properties are present in the request arguments
|
||||||
if 'productid' not in args or 'amount' not in args:
|
if 'productid' not in args or 'amount' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the properties from the request arguments
|
||||||
productid = int(str(args.productid))
|
productid = int(str(args.productid))
|
||||||
amount = int(str(args.amount))
|
amount = int(str(args.amount))
|
||||||
|
# Fetch the product to restock from the database
|
||||||
product = db.get_product(productid)
|
product = db.get_product(productid)
|
||||||
|
# Write the new stock count to the database
|
||||||
db.restock(product, amount)
|
db.restock(product, amount)
|
||||||
|
|
||||||
except UnicodeDecodeError:
|
except UnicodeDecodeError:
|
||||||
|
|
|
@ -12,13 +12,22 @@ def buy(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[str, bytes, PageletResponse]:
|
-> Union[str, bytes, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The purchasing mechanism. Called by the user clicking an item on the product list.
|
||||||
|
"""
|
||||||
|
# If no user is logged in, redirect to the main page, as a purchase must always be bound to a user
|
||||||
if 'authenticated_user' not in session_vars:
|
if 'authenticated_user' not in session_vars:
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
|
# Fetch the authenticated user from the database
|
||||||
uid: int = session_vars['authenticated_user']
|
uid: int = session_vars['authenticated_user']
|
||||||
user = db.get_user(uid)
|
user = db.get_user(uid)
|
||||||
|
# Read the product from the database, identified by the product ID passed as request argument
|
||||||
if 'pid' in args:
|
if 'pid' in args:
|
||||||
pid = int(str(args.pid))
|
pid = int(str(args.pid))
|
||||||
product = db.get_product(pid)
|
product = db.get_product(pid)
|
||||||
|
# Create a consumption entry for the (user, product) combination
|
||||||
db.increment_consumption(user, product)
|
db.increment_consumption(user, product)
|
||||||
|
# Redirect to the main page (where this request should have come from)
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
|
|
@ -12,12 +12,21 @@ def deposit(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[str, bytes, PageletResponse]:
|
-> Union[str, bytes, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The cash depositing mechanism. Called by the user submitting a deposit from the product list.
|
||||||
|
"""
|
||||||
|
# If no user is logged in, redirect to the main page, as a deposit must always be bound to a user
|
||||||
if 'authenticated_user' not in session_vars:
|
if 'authenticated_user' not in session_vars:
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
|
# Fetch the authenticated user from the database
|
||||||
uid: int = session_vars['authenticated_user']
|
uid: int = session_vars['authenticated_user']
|
||||||
user = db.get_user(uid)
|
user = db.get_user(uid)
|
||||||
if 'n' in args:
|
if 'n' in args:
|
||||||
|
# Read the amount of cash to deposit from the request arguments
|
||||||
n = int(str(args.n))
|
n = int(str(args.n))
|
||||||
|
# Write the deposit to the database
|
||||||
db.deposit(user, n)
|
db.deposit(user, n)
|
||||||
|
# Redirect to the main page (where this request should have come from)
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
from matemat.exceptions import AuthenticationError, HttpException
|
from matemat.exceptions import AuthenticationError, HttpException
|
||||||
|
@ -15,18 +14,32 @@ def login_page(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[bytes, str, PageletResponse]:
|
-> Union[bytes, str, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The password login mechanism. If called via GET, render the UI template; if called via POST, attempt to log in with
|
||||||
|
the provided credentials (username and passsword).
|
||||||
|
"""
|
||||||
|
# If a user is already logged in, simply redirect to the main page, showing the product list
|
||||||
if 'authenticated_user' in session_vars:
|
if 'authenticated_user' in session_vars:
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
# If requested via HTTP GET, render the login page showing the login UI
|
||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
return TemplateResponse('login.html',
|
return TemplateResponse('login.html',
|
||||||
setupname=config['InstanceName'])
|
setupname=config['InstanceName'])
|
||||||
|
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
|
||||||
elif method == 'POST':
|
elif method == 'POST':
|
||||||
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
try:
|
try:
|
||||||
|
# Read the request arguments and attempt to log in with them
|
||||||
user: User = db.login(str(args.username), str(args.password))
|
user: User = db.login(str(args.username), str(args.password))
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
|
# Reload the touchkey login page on failure
|
||||||
return RedirectResponse('/login')
|
return RedirectResponse('/login')
|
||||||
|
# Set the user ID session variable
|
||||||
session_vars['authenticated_user'] = user.id
|
session_vars['authenticated_user'] = user.id
|
||||||
|
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
|
||||||
session_vars['authentication_level'] = 2
|
session_vars['authentication_level'] = 2
|
||||||
|
# Redirect to the main page, showing the product list
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
|
||||||
raise HttpException(405)
|
raise HttpException(405)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse
|
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse
|
||||||
|
@ -12,7 +11,13 @@ def logout(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[bytes, str, PageletResponse]:
|
-> Union[bytes, str, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The logout mechanism, clearing the authentication values in the session storage.
|
||||||
|
"""
|
||||||
|
# Remove the authenticated user ID from the session storage, if any
|
||||||
if 'authenticated_user' in session_vars:
|
if 'authenticated_user' in session_vars:
|
||||||
del session_vars['authenticated_user']
|
del session_vars['authenticated_user']
|
||||||
|
# Reset the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
|
||||||
session_vars['authentication_level'] = 0
|
session_vars['authentication_level'] = 0
|
||||||
|
# Redirect to the main page, showing the user list
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
from matemat.webserver import pagelet, RequestArguments, PageletResponse, TemplateResponse
|
from matemat.webserver import pagelet, RequestArguments, PageletResponse, TemplateResponse
|
||||||
|
@ -13,16 +12,25 @@ def main_page(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[bytes, str, PageletResponse]:
|
-> Union[bytes, str, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The main page, showing either the user list (if no user is logged in) or the product list (if a user is logged in).
|
||||||
|
"""
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
|
# Check whether a user is logged in
|
||||||
if 'authenticated_user' in session_vars:
|
if 'authenticated_user' in session_vars:
|
||||||
|
# Fetch the user id and authentication level (touchkey vs password) from the session storage
|
||||||
uid: int = session_vars['authenticated_user']
|
uid: int = session_vars['authenticated_user']
|
||||||
authlevel: int = session_vars['authentication_level']
|
authlevel: int = session_vars['authentication_level']
|
||||||
|
# Fetch the user object from the database (for name display, price calculation and admin check)
|
||||||
user = db.get_user(uid)
|
user = db.get_user(uid)
|
||||||
|
# Fetch the list of products to display
|
||||||
products = db.list_products()
|
products = db.list_products()
|
||||||
|
# Prepare a response with a jinja2 template
|
||||||
return TemplateResponse('productlist.html',
|
return TemplateResponse('productlist.html',
|
||||||
authuser=user, products=products, authlevel=authlevel,
|
authuser=user, products=products, authlevel=authlevel,
|
||||||
setupname=config['InstanceName'])
|
setupname=config['InstanceName'])
|
||||||
else:
|
else:
|
||||||
|
# If no user is logged in, fetch the list of users and render the userlist template
|
||||||
users = db.list_users()
|
users = db.list_users()
|
||||||
return TemplateResponse('userlist.html',
|
return TemplateResponse('userlist.html',
|
||||||
users=users, setupname=config['InstanceName'])
|
users=users, setupname=config['InstanceName'])
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
@ -19,64 +18,102 @@ def modproduct(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[str, bytes, PageletResponse]:
|
-> Union[str, bytes, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The product modification page available from the admin panel.
|
||||||
|
"""
|
||||||
|
# If no user is logged in, redirect to the login page
|
||||||
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars:
|
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars:
|
||||||
return RedirectResponse('/login')
|
return RedirectResponse('/login')
|
||||||
authlevel: int = session_vars['authentication_level']
|
authlevel: int = session_vars['authentication_level']
|
||||||
auth_uid: int = session_vars['authenticated_user']
|
auth_uid: int = session_vars['authenticated_user']
|
||||||
|
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
|
||||||
if authlevel < 2:
|
if authlevel < 2:
|
||||||
raise HttpException(403)
|
raise HttpException(403)
|
||||||
|
|
||||||
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
|
# Fetch the authenticated user
|
||||||
authuser = db.get_user(auth_uid)
|
authuser = db.get_user(auth_uid)
|
||||||
if not authuser.is_admin:
|
if not authuser.is_admin:
|
||||||
|
# Show a 403 Forbidden error page if the user is not an admin
|
||||||
raise HttpException(403)
|
raise HttpException(403)
|
||||||
if 'productid' not in args:
|
if 'productid' not in args:
|
||||||
|
# Show a 400 Bad Request error page if no product to edit was specified
|
||||||
|
# (should never happen during normal operation)
|
||||||
raise HttpException(400, '"productid" argument missing')
|
raise HttpException(400, '"productid" argument missing')
|
||||||
|
|
||||||
|
# Fetch the product to modify from the database
|
||||||
modproduct_id = int(str(args.productid))
|
modproduct_id = int(str(args.productid))
|
||||||
product = db.get_product(modproduct_id)
|
product = db.get_product(modproduct_id)
|
||||||
|
|
||||||
|
# If the request contains a "change" parameter, delegate the change handling to the function below
|
||||||
if 'change' in args:
|
if 'change' in args:
|
||||||
handle_change(args, product, db, config)
|
handle_change(args, product, db, config)
|
||||||
|
# If the product was deleted, redirect back to the admin page, as there is nothing to edit any more
|
||||||
if str(args.change) == 'del':
|
if str(args.change) == 'del':
|
||||||
return RedirectResponse('/admin')
|
return RedirectResponse('/admin')
|
||||||
|
|
||||||
|
# Render the "Modify Product" page
|
||||||
return TemplateResponse('modproduct.html',
|
return TemplateResponse('modproduct.html',
|
||||||
authuser=authuser, product=product, authlevel=authlevel,
|
authuser=authuser, product=product, authlevel=authlevel,
|
||||||
setupname=config['InstanceName'])
|
setupname=config['InstanceName'])
|
||||||
|
|
||||||
|
|
||||||
def handle_change(args: RequestArguments, product: Product, db: MatematDatabase, config: Dict[str, str]) -> None:
|
def handle_change(args: RequestArguments, product: Product, db: MatematDatabase, config: Dict[str, str]) -> None:
|
||||||
|
"""
|
||||||
|
Write the changes requested by an admin to the database.
|
||||||
|
|
||||||
|
:param args: The RequestArguments object passed to the pagelet.
|
||||||
|
:param product: The product to edit.
|
||||||
|
:param db: The database facade where changes are written to.
|
||||||
|
:param config: The dictionary of config file entries from the [Pagelets] section.
|
||||||
|
"""
|
||||||
|
# Read the type of change requested by the admin, then switch over it
|
||||||
change = str(args.change)
|
change = str(args.change)
|
||||||
|
|
||||||
|
# Admin requested deletion of the product
|
||||||
if change == 'del':
|
if change == 'del':
|
||||||
|
# Delete the product from the database
|
||||||
db.delete_product(product)
|
db.delete_product(product)
|
||||||
|
# Delete the product image, if it exists
|
||||||
try:
|
try:
|
||||||
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
|
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
|
||||||
os.remove(os.path.join(abspath, f'{product.id}.png'))
|
os.remove(os.path.join(abspath, f'{product.id}.png'))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Admin requested update of the product details
|
||||||
elif change == 'update':
|
elif change == 'update':
|
||||||
|
# Only write a change if all properties of the product are present in the request arguments
|
||||||
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args or 'stock' not in args:
|
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args or 'stock' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the properties from the request arguments
|
||||||
name = str(args.name)
|
name = str(args.name)
|
||||||
price_member = parse_chf(str(args.pricemember))
|
price_member = parse_chf(str(args.pricemember))
|
||||||
price_non_member = parse_chf(str(args.pricenonmember))
|
price_non_member = parse_chf(str(args.pricenonmember))
|
||||||
stock = int(str(args.stock))
|
stock = int(str(args.stock))
|
||||||
|
# Attempt to write the changes to the database
|
||||||
try:
|
try:
|
||||||
db.change_product(product,
|
db.change_product(product,
|
||||||
name=name, price_member=price_member, price_non_member=price_non_member, stock=stock)
|
name=name, price_member=price_member, price_non_member=price_non_member, stock=stock)
|
||||||
except DatabaseConsistencyError:
|
except DatabaseConsistencyError:
|
||||||
pass
|
return
|
||||||
|
# If a new product image was uploaded, process it
|
||||||
if 'image' in args:
|
if 'image' in args:
|
||||||
|
# Read the raw image data from the request
|
||||||
image = bytes(args.image)
|
image = bytes(args.image)
|
||||||
|
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
|
||||||
|
if len(image) == 0:
|
||||||
|
return
|
||||||
|
# Detect the MIME type
|
||||||
filemagic: magic.FileMagic = magic.detect_from_content(image)
|
filemagic: magic.FileMagic = magic.detect_from_content(image)
|
||||||
|
# Currently, only image/png is supported, don't process any other formats
|
||||||
if filemagic.mime_type != 'image/png':
|
if filemagic.mime_type != 'image/png':
|
||||||
# TODO: Optionally convert to png
|
# TODO: Optionally convert to png
|
||||||
return
|
return
|
||||||
if len(image) > 0:
|
# Create the absolute path of the upload directory
|
||||||
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
|
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
|
||||||
os.makedirs(abspath, exist_ok=True)
|
os.makedirs(abspath, exist_ok=True)
|
||||||
|
# Write the image to the file
|
||||||
with open(os.path.join(abspath, f'{product.id}.png'), 'wb') as f:
|
with open(os.path.join(abspath, f'{product.id}.png'), 'wb') as f:
|
||||||
f.write(image)
|
f.write(image)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
@ -19,69 +18,113 @@ def moduser(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[str, bytes, PageletResponse]:
|
-> Union[str, bytes, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The user modification page available from the admin panel.
|
||||||
|
"""
|
||||||
|
# If no user is logged in, redirect to the login page
|
||||||
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars:
|
if 'authentication_level' not in session_vars or 'authenticated_user' not in session_vars:
|
||||||
return RedirectResponse('/login')
|
return RedirectResponse('/login')
|
||||||
authlevel: int = session_vars['authentication_level']
|
authlevel: int = session_vars['authentication_level']
|
||||||
auth_uid: int = session_vars['authenticated_user']
|
auth_uid: int = session_vars['authenticated_user']
|
||||||
|
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
|
||||||
if authlevel < 2:
|
if authlevel < 2:
|
||||||
raise HttpException(403)
|
raise HttpException(403)
|
||||||
|
|
||||||
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
|
# Fetch the authenticated user
|
||||||
authuser = db.get_user(auth_uid)
|
authuser = db.get_user(auth_uid)
|
||||||
if not authuser.is_admin:
|
if not authuser.is_admin:
|
||||||
|
# Show a 403 Forbidden error page if the user is not an admin
|
||||||
raise HttpException(403)
|
raise HttpException(403)
|
||||||
if 'userid' not in args:
|
if 'userid' not in args:
|
||||||
|
# Show a 400 Bad Request error page if no users to edit was specified
|
||||||
|
# (should never happen during normal operation)
|
||||||
raise HttpException(400, '"userid" argument missing')
|
raise HttpException(400, '"userid" argument missing')
|
||||||
|
|
||||||
|
# Fetch the user to modify from the database
|
||||||
moduser_id = int(str(args.userid))
|
moduser_id = int(str(args.userid))
|
||||||
user = db.get_user(moduser_id)
|
user = db.get_user(moduser_id)
|
||||||
|
|
||||||
|
# If the request contains a "change" parameter, delegate the change handling to the function below
|
||||||
if 'change' in args:
|
if 'change' in args:
|
||||||
handle_change(args, user, db, config)
|
handle_change(args, user, authuser, db, config)
|
||||||
|
# If the user was deleted, redirect back to the admin page, as there is nothing to edit any more
|
||||||
if str(args.change) == 'del':
|
if str(args.change) == 'del':
|
||||||
return RedirectResponse('/admin')
|
return RedirectResponse('/admin')
|
||||||
|
|
||||||
|
# Render the "Modify User" page
|
||||||
return TemplateResponse('moduser.html',
|
return TemplateResponse('moduser.html',
|
||||||
authuser=authuser, user=user, authlevel=authlevel,
|
authuser=authuser, user=user, authlevel=authlevel,
|
||||||
setupname=config['InstanceName'])
|
setupname=config['InstanceName'])
|
||||||
|
|
||||||
|
|
||||||
def handle_change(args: RequestArguments, user: User, db: MatematDatabase, config: Dict[str, str]) -> None:
|
def handle_change(args: RequestArguments, user: User, authuser: User, db: MatematDatabase, config: Dict[str, str]) \
|
||||||
|
-> None:
|
||||||
|
"""
|
||||||
|
Write the changes requested by an admin to the database.
|
||||||
|
|
||||||
|
:param args: The RequestArguments object passed to the pagelet.
|
||||||
|
:param user: The user to edit.
|
||||||
|
:param authuser: The user performing the modification.
|
||||||
|
:param db: The database facade where changes are written to.
|
||||||
|
:param config: The dictionary of config file entries from the [Pagelets] section.
|
||||||
|
"""
|
||||||
|
# Read the type of change requested by the admin, then switch over it
|
||||||
change = str(args.change)
|
change = str(args.change)
|
||||||
|
|
||||||
|
# Admin requested deletion of the user
|
||||||
if change == 'del':
|
if change == 'del':
|
||||||
|
# Delete the user from the database
|
||||||
db.delete_user(user)
|
db.delete_user(user)
|
||||||
|
# Delete the user's avatar, if it exists
|
||||||
try:
|
try:
|
||||||
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
|
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
|
||||||
os.remove(os.path.join(abspath, f'{user.id}.png'))
|
os.remove(os.path.join(abspath, f'{user.id}.png'))
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Admin requested update of the user's details
|
||||||
elif change == 'update':
|
elif change == 'update':
|
||||||
|
# Only write a change if all properties of the user are present in the request arguments
|
||||||
if 'username' not in args or 'email' not in args or 'password' not in args or 'balance' not in args:
|
if 'username' not in args or 'email' not in args or 'password' not in args or 'balance' not in args:
|
||||||
return
|
return
|
||||||
|
# Read the properties from the request arguments
|
||||||
username = str(args.username)
|
username = str(args.username)
|
||||||
email = str(args.email)
|
email = str(args.email)
|
||||||
password = str(args.password)
|
password = str(args.password)
|
||||||
balance = parse_chf(str(args.balance))
|
balance = parse_chf(str(args.balance))
|
||||||
is_member = 'ismember' in args
|
is_member = 'ismember' in args
|
||||||
is_admin = 'isadmin' in args
|
is_admin = 'isadmin' in args
|
||||||
|
# An empty e-mail field should be interpreted as NULL
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
email = None
|
email = None
|
||||||
|
# Attempt to write the changes to the database
|
||||||
try:
|
try:
|
||||||
db.change_user(user, name=username, email=email, is_member=is_member, is_admin=is_admin, balance=balance)
|
# If a password was entered, replace the password in the database
|
||||||
except DatabaseConsistencyError:
|
|
||||||
pass
|
|
||||||
if len(password) > 0:
|
if len(password) > 0:
|
||||||
db.change_password(user, '', password, verify_password=False)
|
db.change_password(user, '', password, verify_password=False)
|
||||||
|
# Write the user detail changes
|
||||||
|
db.change_user(user, agent=authuser, name=username, email=email, is_member=is_member, is_admin=is_admin,
|
||||||
|
balance=balance)
|
||||||
|
except DatabaseConsistencyError:
|
||||||
|
return
|
||||||
|
# If a new avatar was uploaded, process it
|
||||||
if 'avatar' in args:
|
if 'avatar' in args:
|
||||||
|
# Read the raw image data from the request
|
||||||
avatar = bytes(args.avatar)
|
avatar = bytes(args.avatar)
|
||||||
|
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
|
||||||
|
if len(avatar) == 0:
|
||||||
|
return
|
||||||
|
# Detect the MIME type
|
||||||
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
|
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
|
||||||
|
# Currently, only image/png is supported, don't process any other formats
|
||||||
if filemagic.mime_type != 'image/png':
|
if filemagic.mime_type != 'image/png':
|
||||||
# TODO: Optionally convert to png
|
# TODO: Optionally convert to png
|
||||||
return
|
return
|
||||||
if len(avatar) > 0:
|
# Create the absolute path of the upload directory
|
||||||
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
|
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
|
||||||
os.makedirs(abspath, exist_ok=True)
|
os.makedirs(abspath, exist_ok=True)
|
||||||
|
# Write the image to the file
|
||||||
with open(os.path.join(abspath, f'{user.id}.png'), 'wb') as f:
|
with open(os.path.join(abspath, f'{user.id}.png'), 'wb') as f:
|
||||||
f.write(avatar)
|
f.write(avatar)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
from typing import Any, Dict, Union
|
from typing import Any, Dict, Union
|
||||||
|
|
||||||
from matemat.exceptions import AuthenticationError, HttpException
|
from matemat.exceptions import AuthenticationError, HttpException
|
||||||
|
@ -15,19 +14,33 @@ def touchkey_page(method: str,
|
||||||
headers: Dict[str, str],
|
headers: Dict[str, str],
|
||||||
config: Dict[str, str]) \
|
config: Dict[str, str]) \
|
||||||
-> Union[bytes, str, PageletResponse]:
|
-> Union[bytes, str, PageletResponse]:
|
||||||
|
"""
|
||||||
|
The touchkey login mechanism. If called via GET, render the UI template; if called via POST, attempt to log in with
|
||||||
|
the provided credentials (username and touchkey).
|
||||||
|
"""
|
||||||
|
# If a user is already logged in, simply redirect to the main page, showing the product list
|
||||||
if 'authenticated_user' in session_vars:
|
if 'authenticated_user' in session_vars:
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
# If requested via HTTP GET, render the login page showing the touchkey UI
|
||||||
if method == 'GET':
|
if method == 'GET':
|
||||||
return TemplateResponse('touchkey.html',
|
return TemplateResponse('touchkey.html',
|
||||||
username=str(args.username), uid=int(str(args.uid)),
|
username=str(args.username), uid=int(str(args.uid)),
|
||||||
setupname=config['InstanceName'])
|
setupname=config['InstanceName'])
|
||||||
|
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
|
||||||
elif method == 'POST':
|
elif method == 'POST':
|
||||||
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
try:
|
try:
|
||||||
|
# Read the request arguments and attempt to log in with them
|
||||||
user: User = db.login(str(args.username), touchkey=str(args.touchkey))
|
user: User = db.login(str(args.username), touchkey=str(args.touchkey))
|
||||||
except AuthenticationError:
|
except AuthenticationError:
|
||||||
|
# Reload the touchkey login page on failure
|
||||||
return RedirectResponse(f'/touchkey?uid={str(args.uid)}&username={str(args.username)}')
|
return RedirectResponse(f'/touchkey?uid={str(args.uid)}&username={str(args.username)}')
|
||||||
|
# Set the user ID session variable
|
||||||
session_vars['authenticated_user'] = user.id
|
session_vars['authenticated_user'] = user.id
|
||||||
|
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
|
||||||
session_vars['authentication_level'] = 1
|
session_vars['authentication_level'] = 1
|
||||||
|
# Redirect to the main page, showing the product list
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
|
||||||
raise HttpException(405)
|
raise HttpException(405)
|
||||||
|
|
|
@ -1,9 +1,36 @@
|
||||||
|
/**
|
||||||
|
* Initialize the touchkey setup.
|
||||||
|
*
|
||||||
|
* Requires an empty SVG container, and a HTML input tag (recommended: type="hidden") to write the string representation
|
||||||
|
* to. Can additionally be provided with the ID of a HTML form which should be auto-submitted after touchkey entry.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* <form id="touchkey-form" method="post" action="/login">
|
||||||
|
* <svg id="touchkey-svg"></svg>
|
||||||
|
* <input type="hidden" name="touchkey" id="touchkey-field" />
|
||||||
|
* </form>
|
||||||
|
* <script>
|
||||||
|
* initTouchkey(false, "touchkey-svg", "touchkey-form", "touchkey-field");
|
||||||
|
* </script>
|
||||||
|
*
|
||||||
|
* @param {boolean} keepPattern: Whether to keep the pattern on screen, or to clear it after the end event.
|
||||||
|
* @param {string} svgid: HTML id of the SVG container the touchkey is drawn in.
|
||||||
|
* @param {string} formid: HTML id of the form that should be submitted after touchkey entry. null to disable
|
||||||
|
* auto-submit.
|
||||||
|
* @param {string} formfieldid: ID of the input object that should receive the entered touchkey as its value.
|
||||||
|
*/
|
||||||
|
initTouchkey = (keepPattern, svgid, formid, formfieldid) => {
|
||||||
|
|
||||||
|
// Define forEach (iteration) and slice (abused for shallow copying) on HTMLCollections (reuse the Array methods)
|
||||||
HTMLCollection.prototype.forEach = Array.prototype.forEach;
|
HTMLCollection.prototype.forEach = Array.prototype.forEach;
|
||||||
HTMLCollection.prototype.slice = Array.prototype.slice;
|
HTMLCollection.prototype.slice = Array.prototype.slice;
|
||||||
|
|
||||||
initTouchkey = (keepPattern, svgid, formid, formfieldid) => {
|
// Max. distance to a nodes center, before the path snaps to the node.
|
||||||
|
// Expressed as inverse ratio of the container width
|
||||||
|
const SNAPPING_SENSITIVITY = 12;
|
||||||
|
|
||||||
|
// Get the DOM objects for the SVG, the form and the input field
|
||||||
let svg = document.getElementById(svgid);
|
let svg = document.getElementById(svgid);
|
||||||
let form;
|
let form;
|
||||||
if (formid !== null) {
|
if (formid !== null) {
|
||||||
|
@ -11,122 +38,231 @@ initTouchkey = (keepPattern, svgid, formid, formfieldid) => {
|
||||||
}
|
}
|
||||||
let formfield = document.getElementById(formfieldid);
|
let formfield = document.getElementById(formfieldid);
|
||||||
|
|
||||||
|
// Reference to the SVG line object that's currently being drawn by the user, or null, if none
|
||||||
let currentStroke = null;
|
let currentStroke = null;
|
||||||
|
// ID generator for the SVG line objects drawn by the user
|
||||||
let strokeId = 0;
|
let strokeId = 0;
|
||||||
|
// Set of touchkey pattern nodes that have already been visited by a users pattern, and may not be reused again
|
||||||
let doneMap = {};
|
let doneMap = {};
|
||||||
|
// The string representation of the touchkey entered by the user.
|
||||||
let enteredKey = '';
|
let enteredKey = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to create a new stroke after the user completed one stroke by connecting two pattern nodes.
|
||||||
|
*
|
||||||
|
* @param {number} fromX: X coordinate of the starting point of this line.
|
||||||
|
* @param {number} fromY: Y coordinate of the starting point of this line.
|
||||||
|
* @param {number} toX: X coordinate of the ending point of this line.
|
||||||
|
* @param {number} toY: Y coordinate of the ending point of this line.
|
||||||
|
* @returns {string} The ID of the generated line object.
|
||||||
|
*/
|
||||||
let drawLine = (fromX, fromY, toX, toY) => {
|
let drawLine = (fromX, fromY, toX, toY) => {
|
||||||
|
// Create a new SVG line object
|
||||||
let line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
let line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
let id = 'l-' + (strokeId++);
|
// Generate and set an unique ID for the line object
|
||||||
let idAttr = document.createAttribute('id');
|
let id = 'touchkey-svg-stroke-' + (strokeId++);
|
||||||
let classAttr = document.createAttribute('class');
|
line.setAttribute('id', id);
|
||||||
let x1attr = document.createAttribute('x1');
|
// Create and set the HTML class attribute
|
||||||
let y1attr = document.createAttribute('y1');
|
line.setAttribute('class', 'touchkey-svg-stroke');
|
||||||
let x2attr = document.createAttribute('x2');
|
// Create and set the coordinate attributes
|
||||||
let y2attr = document.createAttribute('y2');
|
line.setAttribute('x1', fromX.toString());
|
||||||
let styleAttr = document.createAttribute('style');
|
line.setAttribute('y1', fromY.toString());
|
||||||
idAttr.value = id;
|
line.setAttribute('x2', toX.toString());
|
||||||
classAttr.value = 'l';
|
line.setAttribute('y2', toY.toString());
|
||||||
x1attr.value = fromX;
|
// Create and set the style attribute (grey, circular ends, 5% width)
|
||||||
y1attr.value = fromY;
|
line.setAttribute('style', 'stroke: grey; stroke-width: 5%; stroke-linecap: round');
|
||||||
x2attr.value = toX;
|
// Add the line to the SVG
|
||||||
y2attr.value = toY;
|
|
||||||
styleAttr.value = 'stroke: grey; stroke-width: 5%; stroke-linecap: round';
|
|
||||||
line.setAttributeNode(idAttr);
|
|
||||||
line.setAttributeNode(classAttr);
|
|
||||||
line.setAttributeNode(x1attr);
|
|
||||||
line.setAttributeNode(y1attr);
|
|
||||||
line.setAttributeNode(x2attr);
|
|
||||||
line.setAttributeNode(y2attr);
|
|
||||||
line.setAttributeNode(styleAttr);
|
|
||||||
svg.appendChild(line);
|
svg.appendChild(line);
|
||||||
|
// Return the previously generated ID to identify the line object by
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function used to remove the "trailing stroke" (i.e. after the user let go, there is a dangling stroke from
|
||||||
|
* the node that was hit last to the mouse pointer/finger).
|
||||||
|
*/
|
||||||
let endPath = () => {
|
let endPath = () => {
|
||||||
|
// Remove the current stroke ...
|
||||||
svg.removeChild(svg.getElementById(currentStroke));
|
svg.removeChild(svg.getElementById(currentStroke));
|
||||||
|
// ... and set its reference to null
|
||||||
currentStroke = null;
|
currentStroke = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function used to clear the touchkey pattern drawn by the user.
|
||||||
|
*/
|
||||||
let clearTouchkey = () => {
|
let clearTouchkey = () => {
|
||||||
|
// Reset the set of visited pattern nodes
|
||||||
doneMap = {};
|
doneMap = {};
|
||||||
|
// Reset the touchkey string representation
|
||||||
enteredKey = '';
|
enteredKey = '';
|
||||||
svg.getElementsByClassName('l').slice().reverse().forEach((line) => {
|
// Remove all line objects. Create a shallow copy of the list first to retain deletion order.
|
||||||
|
svg.getElementsByClassName('touchkey-svg-stroke').slice().forEach((line) => {
|
||||||
svg.removeChild(line);
|
svg.removeChild(line);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
svg.ontouchstart = svg.onmousedown = (ev) => {
|
/**
|
||||||
clearTouchkey();
|
* Helper function to read and convert the event coordinates of a MouseEvent or TouchEvent to coordinates relative
|
||||||
const svgrect = svg.getBoundingClientRect();
|
* to the SVG's origin.
|
||||||
|
*
|
||||||
|
* @param {(MouseEvent|TouchEvent)} ev: The event to get the X and Y coordinates from.
|
||||||
|
* @param {(ClientRect|DOMRect)} svgrect: Bounds rectangle of the SVG container.
|
||||||
|
* @returns {Array} The X and Y coordinates relative to the SVG's origin.
|
||||||
|
*/
|
||||||
|
let getEventCoordinates = (ev, svgrect) => {
|
||||||
|
// Check for existence of the "touches" property to distinguish between touch and mouse events
|
||||||
|
// For a touch event, take the page coordinates of the first touch
|
||||||
|
// For a mouse event, take the event coordinates
|
||||||
|
// Then subtract the SVG's origin from the page-relative coordinates to obtain the translated coordinates
|
||||||
const trX = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageX : ev.x) - svgrect.left;
|
const trX = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageX : ev.x) - svgrect.left;
|
||||||
const trY = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageY : ev.y) - svgrect.top;
|
const trY = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageY : ev.y) - svgrect.top;
|
||||||
|
return [trX, trY]
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the pattern node closest to a coordinate.
|
||||||
|
*
|
||||||
|
* @param {number} evX: X coordinate of the point to search the closest node for.
|
||||||
|
* @param {number} evY: Y coordinate of the point to search the closest node for.
|
||||||
|
* @param {(ClientRect|DOMRect)} svgrect: Bounds rectangle of the SVG container.
|
||||||
|
* @returns {Array} The node's ID, the distance, the X and Y coordinate of the node's center.
|
||||||
|
*/
|
||||||
|
let getNearestPatternNode = (evX, evY, svgrect) => {
|
||||||
|
// Initialize nearest neighbors search variables
|
||||||
let minId = '';
|
let minId = '';
|
||||||
let minDist = Infinity;
|
let minDist2 = Infinity; // Squared distance
|
||||||
let minx = 0;
|
let minX = 0;
|
||||||
let miny = 0;
|
let minY = 0;
|
||||||
doneMap = {};
|
// Iterate the pattern nodes for nearest neighbors search
|
||||||
document.getElementsByClassName('c').forEach((circle) => {
|
document.getElementsByClassName('touchkey-svg-node').forEach((node) => {
|
||||||
let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width;
|
// Get the position of a node's center, converted from ratio into absolute pixel count
|
||||||
let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height;
|
let x = parseFloat(node.getAttribute('cx')) / 100.0 * svgrect.width;
|
||||||
let dist = Math.pow(trX - x, 2) + Math.pow(trY - y, 2);
|
let y = parseFloat(node.getAttribute('cy')) / 100.0 * svgrect.height;
|
||||||
if (dist < minDist) {
|
// Compute the squared distance from the event coordinate to the node's center
|
||||||
minDist = dist;
|
let dist2 = Math.pow(evX - x, 2) + Math.pow(evY - y, 2);
|
||||||
minId = circle.id;
|
// Keep the properties of the closest node
|
||||||
minx = x;
|
if (dist2 < minDist2) {
|
||||||
miny = y;
|
minDist2 = dist2;
|
||||||
|
minId = node.dataset.stringRepresentation;
|
||||||
|
minX = x;
|
||||||
|
minY = y;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
currentStroke = drawLine(minx, miny, trX, trY);
|
|
||||||
|
return [minId, Math.sqrt(minDist2), minX, minY];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for "mouse down" / "touch down" events.
|
||||||
|
*
|
||||||
|
* Selects an "anchor node", i.e. the node where the pattern path started, and draws a line from there to the event
|
||||||
|
* coordinates.
|
||||||
|
*/
|
||||||
|
svg.ontouchstart = svg.onmousedown = (ev) => {
|
||||||
|
// Remove any previous strokes that may still be there if "keepPattern" was set to true in the init call
|
||||||
|
clearTouchkey();
|
||||||
|
// Get the SVG container's rectangle
|
||||||
|
const svgrect = svg.getBoundingClientRect();
|
||||||
|
// Get the event coordinates relative to the SVG container's origin
|
||||||
|
const [trX, trY] = getEventCoordinates(ev, svgrect);
|
||||||
|
// Get the closest pattern node
|
||||||
|
const [minId, _, minX, minY] = getNearestPatternNode(trX, trY, svgrect);
|
||||||
|
// Create the line from the anchor node to the event position
|
||||||
|
currentStroke = drawLine(minX, minY, trX, trY);
|
||||||
|
// Mark the anchor node as visited
|
||||||
doneMap[minId] = 1;
|
doneMap[minId] = 1;
|
||||||
|
// Add the anchor node's string representation to the touchkey string representation
|
||||||
enteredKey += minId;
|
enteredKey += minId;
|
||||||
};
|
};
|
||||||
|
|
||||||
svg.ontouchend = svg.onmouseup = (ev) => {
|
/**
|
||||||
|
* Event handler for "mouse move" / "touch move" events.
|
||||||
|
*/
|
||||||
|
svg.ontouchmove = svg.onmousemove = (ev) => {
|
||||||
|
// Only act if the user started is drawing a pattern (only relevant for mouse input)
|
||||||
|
if (currentStroke != null) {
|
||||||
|
// Get the SVG container's rectangle
|
||||||
|
const svgrect = svg.getBoundingClientRect();
|
||||||
|
// Get the event coordinates relative to the SVG container's origin
|
||||||
|
const [trX, trY] = getEventCoordinates(ev, svgrect);
|
||||||
|
// Get the closest pattern node
|
||||||
|
const [minId, minDist, minX, minY] = getNearestPatternNode(trX, trY, svgrect);
|
||||||
|
// If the closest node is not visited yet, and the event coordinate is less than from the node's
|
||||||
|
// center, snap the current stroke to the node, and create a new stroke starting from this node
|
||||||
|
if (minDist < (svgrect.width / SNAPPING_SENSITIVITY) && !(minId in doneMap)) {
|
||||||
|
// Snap the current stroke to the node
|
||||||
|
let line = svg.getElementById(currentStroke);
|
||||||
|
line.setAttribute('x2', minX.toString());
|
||||||
|
line.setAttribute('y2', minY.toString());
|
||||||
|
// Create a new line object from the closest node to the event position
|
||||||
|
currentStroke = drawLine(minX, minY, trX, trY);
|
||||||
|
// Mark the closest node as visited
|
||||||
|
doneMap[minId] = 1;
|
||||||
|
// Append its string representation to the touchkey string representation
|
||||||
|
enteredKey += minId;
|
||||||
|
} else {
|
||||||
|
// If the stroke was not snapped to the closest node, update its end position
|
||||||
|
let line = svg.getElementById(currentStroke);
|
||||||
|
line.setAttribute('x2', trX);
|
||||||
|
line.setAttribute('y2', trY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event handler for "mouse up" / "touch end" events.
|
||||||
|
*
|
||||||
|
* Sets the input object value, and optionally clears the SVG path and submits the form.
|
||||||
|
*/
|
||||||
|
svg.ontouchend = svg.onmouseup = () => {
|
||||||
|
// Remove the trailing, unfinished stroke
|
||||||
endPath();
|
endPath();
|
||||||
|
// Write the touchkey string representation to the input field
|
||||||
formfield.value = enteredKey;
|
formfield.value = enteredKey;
|
||||||
|
// Erase the touchkey pattern, if requested in the init call
|
||||||
if (keepPattern !== true) {
|
if (keepPattern !== true) {
|
||||||
clearTouchkey();
|
clearTouchkey();
|
||||||
}
|
}
|
||||||
|
// Submit the HTML form, if requested in the init call
|
||||||
if (formid !== null) {
|
if (formid !== null) {
|
||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
svg.ontouchmove = svg.onmousemove = (ev) => {
|
/*
|
||||||
const svgrect = svg.getBoundingClientRect();
|
* Create the SVG touchkey nodes
|
||||||
const trX = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageX : ev.x) - svgrect.left;
|
*/
|
||||||
const trY = (typeof ev.touches !== 'undefined' ? ev.touches[0].pageY : ev.y) - svgrect.top;
|
|
||||||
console.log(trY);
|
// Node ID provider
|
||||||
if (currentStroke != null) {
|
let touchkey_node_counter = 0;
|
||||||
let minId = '';
|
// Percentages for centers of quarters of the container's width and height
|
||||||
let minDist = Infinity;
|
['12.5%', '37.5%', '62.5%', '87.5%'].forEach((y) => {
|
||||||
let minx = 0;
|
['12.5%', '37.5%', '62.5%', '87.5%'].forEach((x) => {
|
||||||
let miny = 0;
|
// Create a new pattern node
|
||||||
document.getElementsByClassName('c').forEach((circle) => {
|
let node = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width;
|
|
||||||
let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height;
|
// Generate a new ID (and the touchkey string representation from it)
|
||||||
let dist = Math.pow(trX - x, 2) + Math.pow(trY - y, 2);
|
let id = touchkey_node_counter++;
|
||||||
if (dist < minDist) {
|
node.dataset.stringRepresentation = id.toString(16).toLowerCase();
|
||||||
minDist = dist;
|
|
||||||
minId = circle.id;
|
// Class
|
||||||
minx = x;
|
node.setAttribute('class', 'touchkey-svg-node');
|
||||||
miny = y;
|
// Center
|
||||||
}
|
node.setAttribute('cx', x);
|
||||||
|
node.setAttribute('cy', y);
|
||||||
|
// Radius
|
||||||
|
node.setAttribute('r', '10%');
|
||||||
|
// Center color
|
||||||
|
node.setAttribute('fill', 'white');
|
||||||
|
// Circle color
|
||||||
|
node.setAttribute('stroke', 'grey');
|
||||||
|
// Circle width
|
||||||
|
node.setAttribute('stroke-width', '2%');
|
||||||
|
|
||||||
|
// Add the node to the SVG container
|
||||||
|
svg.appendChild(node);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
if (minDist < 2000 && !(minId in doneMap)) {
|
|
||||||
let line = svg.getElementById(currentStroke);
|
|
||||||
line.setAttribute('x2', minx);
|
|
||||||
line.setAttribute('y2', miny);
|
|
||||||
currentStroke = drawLine(minx, miny, trX, trY);
|
|
||||||
doneMap[minId] = 1;
|
|
||||||
enteredKey += minId;
|
|
||||||
}
|
|
||||||
let line = svg.getElementById(currentStroke);
|
|
||||||
line.setAttribute('x2', trX);
|
|
||||||
line.setAttribute('y2', trY);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
|
{# If the logged in user is an administrator, call the title "Administration", otherwise "Settings" #}
|
||||||
{% if authuser.is_admin %}
|
{% if authuser.is_admin %}
|
||||||
<h1>Administration</h1>
|
<h1>Administration</h1>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -11,8 +12,10 @@
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
|
{# Always show the settings a user can edit for itself #}
|
||||||
{% include "admin_all.html" %}
|
{% include "admin_all.html" %}
|
||||||
|
|
||||||
|
{# Only show the "restricted" section if the user is an admin #}
|
||||||
{% if authuser.is_admin %}
|
{% if authuser.is_admin %}
|
||||||
{% include "admin_restricted.html" %}
|
{% include "admin_restricted.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -54,7 +54,7 @@
|
||||||
<form id="admin-touchkey-form" method="post" action="/admin?change=touchkey" accept-charset="UTF-8">
|
<form id="admin-touchkey-form" method="post" action="/admin?change=touchkey" accept-charset="UTF-8">
|
||||||
Draw a new touchkey (leave empty to disable):
|
Draw a new touchkey (leave empty to disable):
|
||||||
<br/>
|
<br/>
|
||||||
{% include "touchkey.svg" %}
|
<svg id="touchkey-svg" width="400" height="400"></svg>
|
||||||
<br/>
|
<br/>
|
||||||
<input id="admin-touchkey-touchkey" type="hidden" name="touchkey" value="" />
|
<input id="admin-touchkey-touchkey" type="hidden" name="touchkey" value="" />
|
||||||
|
|
||||||
|
|
|
@ -1,18 +1,25 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
|
{# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #}
|
||||||
<title>{{ setupname|safe }}</title>
|
<title>{{ setupname|safe }}</title>
|
||||||
<link rel="stylesheet" href="/css/matemat.css"/>
|
<link rel="stylesheet" href="/css/matemat.css"/>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
{% block header %}
|
{% block header %}
|
||||||
|
|
||||||
|
{# Always show a link to the home page, either a list of users or of products. #}
|
||||||
<a href="/">Home</a>
|
<a href="/">Home</a>
|
||||||
|
{# Show a link to the settings, if a user logged in via password (authlevel 2). #}
|
||||||
{% if authlevel|default(0) > 1 %}
|
{% if authlevel|default(0) > 1 %}
|
||||||
{% if authuser is defined %}
|
{% if authuser is defined %}
|
||||||
|
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
|
||||||
{% if authuser.is_admin %}
|
{% if authuser.is_admin %}
|
||||||
<a href="/admin">Administration</a>
|
<a href="/admin">Administration</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -22,18 +29,27 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
{% block main %}{% endblock %}
|
{% block main %}
|
||||||
|
{# Here be content. #}
|
||||||
|
{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
|
{# Show some information in the footer, e.g. the instance name, the version, and copyright info. #}
|
||||||
<ul>
|
<ul>
|
||||||
<li> {{ setupname|safe }}
|
<li> {{ setupname|safe }}
|
||||||
<li> Matemat {{ __version__ }}
|
<li> Matemat {{ __version__ }}
|
||||||
<li> © 2018 s3lph
|
<li> © 2018 s3lph
|
||||||
<li> MIT License
|
<li> MIT License
|
||||||
|
{# This used to be a link to the GitLab repo. However, users of the testing environment always clicked
|
||||||
|
that link and couldn't come back, because the UI was running in touch-only kiosk mode. #}
|
||||||
|
<li> gitlab.com/s3lph/matemat
|
||||||
</ul>
|
</ul>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<h1>Welcome</h1>
|
<h1>Welcome</h1>
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
@ -8,6 +7,7 @@
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
|
{# Show a username/password login form #}
|
||||||
<form method="post" action="/login" id="loginform" accept-charset="UTF-8">
|
<form method="post" action="/login" id="loginform" accept-charset="UTF-8">
|
||||||
<label for="login-username">Username: </label>
|
<label for="login-username">Username: </label>
|
||||||
<input id="login-username" type="text" name="username"/><br/>
|
<input id="login-username" type="text" name="username"/><br/>
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
|
{# Show the username. #}
|
||||||
<h1>Welcome, {{ authuser.name }}</h1>
|
<h1>Welcome, {{ authuser.name }}</h1>
|
||||||
|
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
|
{# Show the users current balance #}
|
||||||
Your balance: {{ authuser.balance|chf }}
|
Your balance: {{ authuser.balance|chf }}
|
||||||
<br/>
|
<br/>
|
||||||
|
{# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #}
|
||||||
<a href="/deposit?n=100">Deposit CHF 1</a><br/>
|
<a href="/deposit?n=100">Deposit CHF 1</a><br/>
|
||||||
<a href="/deposit?n=1000">Deposit CHF 10</a><br/>
|
<a href="/deposit?n=1000">Deposit CHF 10</a><br/>
|
||||||
|
|
||||||
{% for product in products %}
|
{% for product in products %}
|
||||||
|
{# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #}
|
||||||
<div class="thumblist-item">
|
<div class="thumblist-item">
|
||||||
<a href="/buy?pid={{ product.id }}">
|
<a href="/buy?pid={{ product.id }}">
|
||||||
<span class="thumblist-title">{{ product.name }}</span>
|
<span class="thumblist-title">{{ product.name }}</span>
|
||||||
|
@ -32,6 +34,7 @@ Your balance: {{ authuser.balance|chf }}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<br/>
|
<br/>
|
||||||
|
{# Logout link #}
|
||||||
<a href="/logout">Logout</a>
|
<a href="/logout">Logout</a>
|
||||||
|
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
|
@ -16,7 +16,8 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{% include "touchkey.svg" %}
|
|
||||||
|
<svg id="touchkey-svg" width="400" height="400"></svg>
|
||||||
|
|
||||||
<form method="post" action="/touchkey" id="loginform" accept-charset="UTF-8">
|
<form method="post" action="/touchkey" id="loginform" accept-charset="UTF-8">
|
||||||
<input type="hidden" name="uid" value="{{ uid }}" />
|
<input type="hidden" name="uid" value="{{ uid }}" />
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
<svg id="touchkey-svg" width="400" height="400">
|
|
||||||
<circle class="c" id="0" cx="12.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="1" cx="37.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="2" cx="62.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="3" cx="87.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
|
|
||||||
<circle class="c" id="4" cx="12.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="5" cx="37.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="6" cx="62.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="7" cx="87.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
|
|
||||||
<circle class="c" id="8" cx="12.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="9" cx="37.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="a" cx="62.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="b" cx="87.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
|
|
||||||
<circle class="c" id="c" cx="12.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="d" cx="37.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="e" cx="62.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
<circle class="c" id="f" cx="87.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white"/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.7 KiB |
|
@ -1,12 +1,15 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<h1>{{ setupname }}</h1>
|
{# Show the setup name, as set in the config file, as page title. Don't escape HTML entities. #}
|
||||||
|
<h1>{{ setupname|safe }}</h1>
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
|
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
|
||||||
<div class="thumblist-item">
|
<div class="thumblist-item">
|
||||||
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
|
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
|
||||||
<span class="thumblist-title">{{ user.name }}</span><br/>
|
<span class="thumblist-title">{{ user.name }}</span><br/>
|
||||||
|
@ -16,7 +19,11 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
<a href="/login">Password-based login</a>
|
{# Link to the password login #}
|
||||||
|
<a href="/login">Password login</a>
|
||||||
|
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Loading…
Reference in a new issue