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:
s3lph 2018-07-27 23:52:27 +00:00
commit 69758ed2b2
20 changed files with 584 additions and 226 deletions

2
doc

@ -1 +1 @@
Subproject commit da08844f714919a287cd62a9fa90faf3a484d3c3
Subproject commit ece840af5b19d2c78b2dbfa14adac145fab79f4f

View file

@ -232,7 +232,7 @@ class MatematDatabase(object):
'tkhash': tkhash
})
def change_user(self, user: User, agent: User, **kwargs)\
def change_user(self, user: User, agent: Optional[User], **kwargs)\
-> None:
"""
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.
: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.
: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')
oldbalance: int = row[0]
if balance != oldbalance:
if agent is None:
raise ValueError('agent must not be None for a balance change')
c.execute('''
INSERT INTO transactions (user_id, value, old_balance)
VALUES (:user_id, :value, :old_balance)

View file

@ -1,4 +1,3 @@
from typing import Any, Dict, Union
import os
@ -18,71 +17,116 @@ def admin(method: str,
headers: Dict[str, str],
config: Dict[str, str]) \
-> 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:
return RedirectResponse('/login')
authlevel: int = session_vars['authentication_level']
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:
raise HttpException(403)
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
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:
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:
handle_admin_change(args, db, config)
# Fetch all existing users and products from the database
users = db.list_users()
products = db.list_products()
# Render the "Admin/Settings" page
return TemplateResponse('admin.html',
authuser=user, authlevel=authlevel, users=users, products=products,
setupname=config['InstanceName'])
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:
# Read the type of change requested by the user, then switch over it
change = str(args.change)
# The user requested a modification of its general account information (username, email)
if change == 'account':
# Username and email must be set in the request arguments
if 'username' not in args or 'email' not in args:
return
username = str(args.username)
email = str(args.email)
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
# Attempt to update username and e-mail
try:
db.change_user(user, name=username, email=email)
db.change_user(user, agent=None, name=username, email=email)
except DatabaseConsistencyError:
pass
return
# The user requested a password change
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:
return
# Read the passwords from the request arguments
oldpass = str(args.oldpass)
newpass = str(args.newpass)
newpass2 = str(args.newpass2)
# The two instances of the new password must match
if newpass != newpass2:
raise ValueError('New passwords don\'t match')
# Write the new password to the database
db.change_password(user, oldpass, newpass)
# The user requested a touchkey change
elif change == 'touchkey':
# The touchkey must be present
if 'touchkey' not in args:
return
# Read the touchkey from the request arguments
touchkey = str(args.touchkey)
# An empty touchkey field should set the touchkey to NULL (disable touchkey login)
if len(touchkey) == 0:
touchkey = None
# Write the new touchkey to the database
db.change_touchkey(user, '', touchkey, verify_password=False)
# The user requested an avatar change
elif change == 'avatar':
# The new avatar field must be present
if 'avatar' not in args:
return
# Read the raw image data from the request
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)
# Currently, only image/png is supported, don't process any other formats
if filemagic.mime_type != 'image/png':
# TODO: Optionally convert to png
return
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
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:
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]):
"""
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:
# Read the type of change requested by the admin, then switch over it
change = str(args.adminchange)
# The user requested to create a new user
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:
return
# Read the properties from the request arguments
username = str(args.username)
email = str(args.email)
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
password = str(args.password)
is_member = 'ismember' 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)
# The user requested to create a new product
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:
return
# Read the properties from the request arguments
name = str(args.name)
price_member = int(str(args.pricemember))
price_non_member = int(str(args.pricenonmember))
# Create the user in the database
newproduct = db.create_product(name, price_member, price_non_member)
# If a new product image was uploaded, process it
if 'image' in args:
# Read the raw image data from the request
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)
# Currently, only image/png is supported, don't process any other formats
if filemagic.mime_type != 'image/png':
# TODO: Optionally convert to png
return
if len(image) > 0:
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
os.makedirs(abspath, exist_ok=True)
with open(os.path.join(abspath, f'{newproduct.id}.png'), 'wb') as f:
f.write(image)
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
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:
f.write(image)
# The user requested to restock a product
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:
return
# Read the properties from the request arguments
productid = int(str(args.productid))
amount = int(str(args.amount))
# Fetch the product to restock from the database
product = db.get_product(productid)
# Write the new stock count to the database
db.restock(product, amount)
except UnicodeDecodeError:

View file

@ -12,13 +12,22 @@ def buy(method: str,
headers: Dict[str, str],
config: Dict[str, str]) \
-> 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:
return RedirectResponse('/')
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user from the database
uid: int = session_vars['authenticated_user']
user = db.get_user(uid)
# Read the product from the database, identified by the product ID passed as request argument
if 'pid' in args:
pid = int(str(args.pid))
product = db.get_product(pid)
# Create a consumption entry for the (user, product) combination
db.increment_consumption(user, product)
return RedirectResponse('/')
# Redirect to the main page (where this request should have come from)
return RedirectResponse('/')

View file

@ -12,12 +12,21 @@ def deposit(method: str,
headers: Dict[str, str],
config: Dict[str, str]) \
-> 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:
return RedirectResponse('/')
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user from the database
uid: int = session_vars['authenticated_user']
user = db.get_user(uid)
if 'n' in args:
# Read the amount of cash to deposit from the request arguments
n = int(str(args.n))
# Write the deposit to the database
db.deposit(user, n)
return RedirectResponse('/')
# Redirect to the main page (where this request should have come from)
return RedirectResponse('/')

View file

@ -1,4 +1,3 @@
from typing import Any, Dict, Union
from matemat.exceptions import AuthenticationError, HttpException
@ -13,20 +12,34 @@ def login_page(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str])\
config: Dict[str, str]) \
-> 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:
return RedirectResponse('/')
# If requested via HTTP GET, render the login page showing the login UI
if method == 'GET':
return TemplateResponse('login.html',
setupname=config['InstanceName'])
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
elif method == 'POST':
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
try:
# Read the request arguments and attempt to log in with them
user: User = db.login(str(args.username), str(args.password))
except AuthenticationError:
# Reload the touchkey login page on failure
return RedirectResponse('/login')
session_vars['authenticated_user'] = user.id
session_vars['authentication_level'] = 2
# Set the user ID session variable
session_vars['authenticated_user'] = user.id
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session_vars['authentication_level'] = 2
# Redirect to the main page, showing the product list
return RedirectResponse('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
raise HttpException(405)

View file

@ -1,4 +1,3 @@
from typing import Any, Dict, Union
from matemat.webserver import pagelet, RequestArguments, PageletResponse, RedirectResponse
@ -10,9 +9,15 @@ def logout(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str])\
config: Dict[str, str]) \
-> 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:
del session_vars['authenticated_user']
# Reset the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session_vars['authentication_level'] = 0
# Redirect to the main page, showing the user list
return RedirectResponse('/')

View file

@ -1,4 +1,3 @@
from typing import Any, Dict, Union
from matemat.webserver import pagelet, RequestArguments, PageletResponse, TemplateResponse
@ -11,18 +10,27 @@ def main_page(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str])\
config: Dict[str, str]) \
-> 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:
# Check whether a user is logged in
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']
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)
# Fetch the list of products to display
products = db.list_products()
# Prepare a response with a jinja2 template
return TemplateResponse('productlist.html',
authuser=user, products=products, authlevel=authlevel,
setupname=config['InstanceName'])
else:
# If no user is logged in, fetch the list of users and render the userlist template
users = db.list_users()
return TemplateResponse('userlist.html',
users=users, setupname=config['InstanceName'])

View file

@ -1,4 +1,3 @@
from typing import Any, Dict, Union
import os
@ -19,64 +18,102 @@ def modproduct(method: str,
headers: Dict[str, str],
config: Dict[str, str]) \
-> 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:
return RedirectResponse('/login')
authlevel: int = session_vars['authentication_level']
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:
raise HttpException(403)
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
authuser = db.get_user(auth_uid)
if not authuser.is_admin:
# Show a 403 Forbidden error page if the user is not an admin
raise HttpException(403)
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')
# Fetch the product to modify from the database
modproduct_id = int(str(args.productid))
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:
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':
return RedirectResponse('/admin')
# Render the "Modify Product" page
return TemplateResponse('modproduct.html',
authuser=authuser, product=product, authlevel=authlevel,
setupname=config['InstanceName'])
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)
# Admin requested deletion of the product
if change == 'del':
# Delete the product from the database
db.delete_product(product)
# Delete the product image, if it exists
try:
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
os.remove(os.path.join(abspath, f'{product.id}.png'))
except FileNotFoundError:
pass
# Admin requested update of the product details
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:
return
# Read the properties from the request arguments
name = str(args.name)
price_member = parse_chf(str(args.pricemember))
price_non_member = parse_chf(str(args.pricenonmember))
stock = int(str(args.stock))
# Attempt to write the changes to the database
try:
db.change_product(product,
name=name, price_member=price_member, price_non_member=price_non_member, stock=stock)
except DatabaseConsistencyError:
pass
return
# If a new product image was uploaded, process it
if 'image' in args:
# Read the raw image data from the request
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)
# Currently, only image/png is supported, don't process any other formats
if filemagic.mime_type != 'image/png':
# TODO: Optionally convert to png
return
if len(image) > 0:
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
os.makedirs(abspath, exist_ok=True)
with open(os.path.join(abspath, f'{product.id}.png'), 'wb') as f:
f.write(image)
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
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:
f.write(image)

View file

@ -1,4 +1,3 @@
from typing import Any, Dict, Union
import os
@ -19,69 +18,113 @@ def moduser(method: str,
headers: Dict[str, str],
config: Dict[str, str]) \
-> 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:
return RedirectResponse('/login')
authlevel: int = session_vars['authentication_level']
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:
raise HttpException(403)
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
authuser = db.get_user(auth_uid)
if not authuser.is_admin:
# Show a 403 Forbidden error page if the user is not an admin
raise HttpException(403)
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')
# Fetch the user to modify from the database
moduser_id = int(str(args.userid))
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:
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':
return RedirectResponse('/admin')
# Render the "Modify User" page
return TemplateResponse('moduser.html',
authuser=authuser, user=user, authlevel=authlevel,
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)
# Admin requested deletion of the user
if change == 'del':
# Delete the user from the database
db.delete_user(user)
# Delete the user's avatar, if it exists
try:
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
os.remove(os.path.join(abspath, f'{user.id}.png'))
except FileNotFoundError:
pass
# Admin requested update of the user's details
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:
return
# Read the properties from the request arguments
username = str(args.username)
email = str(args.email)
password = str(args.password)
balance = parse_chf(str(args.balance))
is_member = 'ismember' in args
is_admin = 'isadmin' in args
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
# Attempt to write the changes to the database
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
if len(password) > 0:
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:
pass
if len(password) > 0:
db.change_password(user, '', password, verify_password=False)
return
# If a new avatar was uploaded, process it
if 'avatar' in args:
# Read the raw image data from the request
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)
# Currently, only image/png is supported, don't process any other formats
if filemagic.mime_type != 'image/png':
# TODO: Optionally convert to png
return
if len(avatar) > 0:
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
os.makedirs(abspath, exist_ok=True)
with open(os.path.join(abspath, f'{user.id}.png'), 'wb') as f:
f.write(avatar)
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
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:
f.write(avatar)

View file

@ -1,4 +1,3 @@
from typing import Any, Dict, Union
from matemat.exceptions import AuthenticationError, HttpException
@ -13,21 +12,35 @@ def touchkey_page(method: str,
args: RequestArguments,
session_vars: Dict[str, Any],
headers: Dict[str, str],
config: Dict[str, str])\
config: Dict[str, str]) \
-> 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:
return RedirectResponse('/')
# If requested via HTTP GET, render the login page showing the touchkey UI
if method == 'GET':
return TemplateResponse('touchkey.html',
username=str(args.username), uid=int(str(args.uid)),
setupname=config['InstanceName'])
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
elif method == 'POST':
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
try:
# Read the request arguments and attempt to log in with them
user: User = db.login(str(args.username), touchkey=str(args.touchkey))
except AuthenticationError:
# Reload the touchkey login page on failure
return RedirectResponse(f'/touchkey?uid={str(args.uid)}&username={str(args.username)}')
session_vars['authenticated_user'] = user.id
session_vars['authentication_level'] = 1
# Set the user ID session variable
session_vars['authenticated_user'] = user.id
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session_vars['authentication_level'] = 1
# Redirect to the main page, showing the product list
return RedirectResponse('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
raise HttpException(405)

View file

@ -1,9 +1,36 @@
HTMLCollection.prototype.forEach = Array.prototype.forEach;
HTMLCollection.prototype.slice = Array.prototype.slice;
/**
* 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.slice = Array.prototype.slice;
// 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 form;
if (formid !== null) {
@ -11,122 +38,231 @@ initTouchkey = (keepPattern, svgid, formid, 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;
// ID generator for the SVG line objects drawn by the user
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 = {};
// The string representation of the touchkey entered by the user.
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) => {
// Create a new SVG line object
let line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
let id = 'l-' + (strokeId++);
let idAttr = document.createAttribute('id');
let classAttr = document.createAttribute('class');
let x1attr = document.createAttribute('x1');
let y1attr = document.createAttribute('y1');
let x2attr = document.createAttribute('x2');
let y2attr = document.createAttribute('y2');
let styleAttr = document.createAttribute('style');
idAttr.value = id;
classAttr.value = 'l';
x1attr.value = fromX;
y1attr.value = fromY;
x2attr.value = toX;
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);
// Generate and set an unique ID for the line object
let id = 'touchkey-svg-stroke-' + (strokeId++);
line.setAttribute('id', id);
// Create and set the HTML class attribute
line.setAttribute('class', 'touchkey-svg-stroke');
// Create and set the coordinate attributes
line.setAttribute('x1', fromX.toString());
line.setAttribute('y1', fromY.toString());
line.setAttribute('x2', toX.toString());
line.setAttribute('y2', toY.toString());
// Create and set the style attribute (grey, circular ends, 5% width)
line.setAttribute('style', 'stroke: grey; stroke-width: 5%; stroke-linecap: round');
// Add the line to the SVG
svg.appendChild(line);
// Return the previously generated ID to identify the line object by
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 = () => {
// Remove the current stroke ...
svg.removeChild(svg.getElementById(currentStroke));
// ... and set its reference to null
currentStroke = null;
};
/**
* Helper function used to clear the touchkey pattern drawn by the user.
*/
let clearTouchkey = () => {
// Reset the set of visited pattern nodes
doneMap = {};
// Reset the touchkey string representation
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.ontouchstart = svg.onmousedown = (ev) => {
clearTouchkey();
const svgrect = svg.getBoundingClientRect();
/**
* Helper function to read and convert the event coordinates of a MouseEvent or TouchEvent to coordinates relative
* 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 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 minDist = Infinity;
let minx = 0;
let miny = 0;
doneMap = {};
document.getElementsByClassName('c').forEach((circle) => {
let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width;
let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height;
let dist = Math.pow(trX - x, 2) + Math.pow(trY - y, 2);
if (dist < minDist) {
minDist = dist;
minId = circle.id;
minx = x;
miny = y;
let minDist2 = Infinity; // Squared distance
let minX = 0;
let minY = 0;
// Iterate the pattern nodes for nearest neighbors search
document.getElementsByClassName('touchkey-svg-node').forEach((node) => {
// Get the position of a node's center, converted from ratio into absolute pixel count
let x = parseFloat(node.getAttribute('cx')) / 100.0 * svgrect.width;
let y = parseFloat(node.getAttribute('cy')) / 100.0 * svgrect.height;
// Compute the squared distance from the event coordinate to the node's center
let dist2 = Math.pow(evX - x, 2) + Math.pow(evY - y, 2);
// Keep the properties of the closest node
if (dist2 < minDist2) {
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;
// Add the anchor node's string representation to the touchkey string representation
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();
// Write the touchkey string representation to the input field
formfield.value = enteredKey;
// Erase the touchkey pattern, if requested in the init call
if (keepPattern !== true) {
clearTouchkey();
}
// Submit the HTML form, if requested in the init call
if (formid !== null) {
form.submit();
}
};
svg.ontouchmove = svg.onmousemove = (ev) => {
const svgrect = svg.getBoundingClientRect();
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);
if (currentStroke != null) {
let minId = '';
let minDist = Infinity;
let minx = 0;
let miny = 0;
document.getElementsByClassName('c').forEach((circle) => {
let x = parseFloat(circle.getAttribute('cx')) / 100.0 * svgrect.width;
let y = parseFloat(circle.getAttribute('cy')) / 100.0 * svgrect.height;
let dist = Math.pow(trX - x, 2) + Math.pow(trY - y, 2);
if (dist < minDist) {
minDist = dist;
minId = circle.id;
minx = x;
miny = y;
}
});
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);
}
};
/*
* Create the SVG touchkey nodes
*/
// Node ID provider
let touchkey_node_counter = 0;
// Percentages for centers of quarters of the container's width and height
['12.5%', '37.5%', '62.5%', '87.5%'].forEach((y) => {
['12.5%', '37.5%', '62.5%', '87.5%'].forEach((x) => {
// Create a new pattern node
let node = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
// Generate a new ID (and the touchkey string representation from it)
let id = touchkey_node_counter++;
node.dataset.stringRepresentation = id.toString(16).toLowerCase();
// Class
node.setAttribute('class', 'touchkey-svg-node');
// 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);
});
});
};

View file

@ -1,22 +1,25 @@
{% extends "base.html" %}
{% block header %}
{% if authuser.is_admin %}
<h1>Administration</h1>
{% else %}
<h1>Settings</h1>
{% endif %}
{{ super() }}
{# If the logged in user is an administrator, call the title "Administration", otherwise "Settings" #}
{% if authuser.is_admin %}
<h1>Administration</h1>
{% else %}
<h1>Settings</h1>
{% endif %}
{{ super() }}
{% endblock %}
{% block main %}
{% include "admin_all.html" %}
{# Always show the settings a user can edit for itself #}
{% include "admin_all.html" %}
{% if authuser.is_admin %}
{% include "admin_restricted.html" %}
{% endif %}
{# Only show the "restricted" section if the user is an admin #}
{% if authuser.is_admin %}
{% include "admin_restricted.html" %}
{% endif %}
{{ super() }}
{{ super() }}
{% endblock %}

View file

@ -54,7 +54,7 @@
<form id="admin-touchkey-form" method="post" action="/admin?change=touchkey" accept-charset="UTF-8">
Draw a new touchkey (leave empty to disable):
<br/>
{% include "touchkey.svg" %}
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="admin-touchkey-touchkey" type="hidden" name="touchkey" value="" />

View file

@ -1,39 +1,55 @@
<!DOCTYPE html>
<html>
<head>
{% block head %}
<title>{{ setupname|safe }}</title>
<link rel="stylesheet" href="/css/matemat.css" />
{# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #}
<title>{{ setupname|safe }}</title>
<link rel="stylesheet" href="/css/matemat.css"/>
{% endblock %}
</head>
<body>
<header>
{% block header %}
<a href="/">Home</a>
{% if authlevel|default(0) > 1 %}
{% if authuser is defined %}
{% if authuser.is_admin %}
<a href="/admin">Administration</a>
{% else %}
<a href="/admin">Settings</a>
{# Always show a link to the home page, either a list of users or of products. #}
<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 authuser is defined %}
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
{% if authuser.is_admin %}
<a href="/admin">Administration</a>
{% else %}
<a href="/admin">Settings</a>
{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% endblock %}
</header>
<main>
{% block main %}{% endblock %}
{% block main %}
{# Here be content. #}
{% endblock %}
</main>
<footer>
{% block footer %}
<ul>
<li> {{ setupname|safe }}
<li> Matemat {{__version__}}
<li> &copy; 2018 s3lph
<li> MIT License
</ul>
{# Show some information in the footer, e.g. the instance name, the version, and copyright info. #}
<ul>
<li> {{ setupname|safe }}
<li> Matemat {{ __version__ }}
<li> &copy; 2018 s3lph
<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>
{% endblock %}
</footer>
</body>
</html>

View file

@ -1,23 +1,23 @@
{% extends "base.html" %}
{% block header %}
<h1>Welcome</h1>
{{ super() }}
<h1>Welcome</h1>
{{ super() }}
{% endblock %}
{% block main %}
<form method="post" action="/login" id="loginform" accept-charset="UTF-8">
<label for="login-username">Username: </label>
<input id="login-username" type="text" name="username" /><br/>
{# Show a username/password login form #}
<form method="post" action="/login" id="loginform" accept-charset="UTF-8">
<label for="login-username">Username: </label>
<input id="login-username" type="text" name="username"/><br/>
<label for="login-password">Password: </label>
<input id="login-password" type="password" name="password" /><br/>
<label for="login-password">Password: </label>
<input id="login-password" type="password" name="password"/><br/>
<input type="submit" value="Login">
</form>
<input type="submit" value="Login">
</form>
{{ super() }}
{{ super() }}
{% endblock %}

View file

@ -1,39 +1,42 @@
{% extends "base.html" %}
{% block header %}
<h1>Welcome, {{ authuser.name }}</h1>
{{ super() }}
{# Show the username. #}
<h1>Welcome, {{ authuser.name }}</h1>
{{ super() }}
{% endblock %}
{% block main %}
Your balance: {{ authuser.balance|chf }}
<br/>
<a href="/deposit?n=100">Deposit CHF 1</a><br/>
<a href="/deposit?n=1000">Deposit CHF 10</a><br/>
{# Show the users current balance #}
Your balance: {{ authuser.balance|chf }}
<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=1000">Deposit CHF 10</a><br/>
{% for product in products %}
<div class="thumblist-item">
<a href="/buy?pid={{ product.id }}">
<span class="thumblist-title">{{ product.name }}</span>
<span class="thumblist-detail">Price:
{% if authuser.is_member %}
{{ product.price_member|chf }}
{% else %}
{{ product.price_non_member|chf }}
{% endif %}
; Stock: {{ product.stock }}</span><br/>
<div class="imgcontainer">
<img src="/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}" />
{% 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">
<a href="/buy?pid={{ product.id }}">
<span class="thumblist-title">{{ product.name }}</span>
<span class="thumblist-detail">Price:
{% if authuser.is_member %}
{{ product.price_member|chf }}
{% else %}
{{ product.price_non_member|chf }}
{% endif %}
; Stock: {{ product.stock }}</span><br/>
<div class="imgcontainer">
<img src="/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/>
</div>
</a>
</div>
</a>
</div>
{% endfor %}
<br/>
<a href="/logout">Logout</a>
{% endfor %}
<br/>
{# Logout link #}
<a href="/logout">Logout</a>
{{ super() }}
{{ super() }}
{% endblock %}

View file

@ -16,7 +16,8 @@
{% endblock %}
{% 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">
<input type="hidden" name="uid" value="{{ uid }}" />

View file

@ -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

View file

@ -1,22 +1,29 @@
{% extends "base.html" %}
{% block header %}
<h1>{{ setupname }}</h1>
{{ super() }}
{# Show the setup name, as set in the config file, as page title. Don't escape HTML entities. #}
<h1>{{ setupname|safe }}</h1>
{{ super() }}
{% endblock %}
{% block main %}
{% for user in users %}
<div class="thumblist-item">
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
<span class="thumblist-title">{{ user.name }}</span><br/>
<div class="imgcontainer">
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
{% 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">
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
<span class="thumblist-title">{{ user.name }}</span><br/>
<div class="imgcontainer">
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}"/>
</div>
</a>
</div>
</a>
</div>
{% endfor %}
<br/>
<a href="/login">Password-based login</a>
{{ super() }}
{% endfor %}
<br/>
{# Link to the password login #}
<a href="/login">Password login</a>
{{ super() }}
{% endblock %}