Wrote code documentation for the pagelets of the current implementation, and for some of the jinja2 templates

This commit is contained in:
s3lph 2018-07-23 00:19:41 +02:00
parent c20029fb36
commit be09ea1ee7
15 changed files with 365 additions and 123 deletions

View file

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

View file

@ -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)
with open(os.path.join(abspath, f'{newproduct.id}.png'), 'wb') as f: # Write the image to the file
f.write(image) 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': 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:

View file

@ -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)
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], 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)
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 typing import Any, Dict, Union
from matemat.exceptions import AuthenticationError, HttpException from matemat.exceptions import AuthenticationError, HttpException
@ -13,20 +12,34 @@ def login_page(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
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')
session_vars['authenticated_user'] = user.id # Set the user ID session variable
session_vars['authentication_level'] = 2 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('/') return RedirectResponse('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
raise HttpException(405) raise HttpException(405)

View file

@ -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
@ -10,9 +9,15 @@ def logout(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
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('/')

View file

@ -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
@ -11,18 +10,27 @@ def main_page(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
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'])

View file

@ -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)
with open(os.path.join(abspath, f'{product.id}.png'), 'wb') as f: # Write the image to the file
f.write(image) 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 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
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: except DatabaseConsistencyError:
pass return
if len(password) > 0: # If a new avatar was uploaded, process it
db.change_password(user, '', password, verify_password=False)
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)
with open(os.path.join(abspath, f'{user.id}.png'), 'wb') as f: # Write the image to the file
f.write(avatar) 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 typing import Any, Dict, Union
from matemat.exceptions import AuthenticationError, HttpException from matemat.exceptions import AuthenticationError, HttpException
@ -13,21 +12,35 @@ def touchkey_page(method: str,
args: RequestArguments, args: RequestArguments,
session_vars: Dict[str, Any], session_vars: Dict[str, Any],
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)}')
session_vars['authenticated_user'] = user.id # Set the user ID session variable
session_vars['authentication_level'] = 1 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('/') return RedirectResponse('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
raise HttpException(405) raise HttpException(405)

View file

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

View file

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

View file

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

View file

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

View file

@ -1,22 +1,29 @@
{% 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. #}
{{ super() }} <h1>{{ setupname|safe }}</h1>
{{ super() }}
{% endblock %} {% endblock %}
{% block main %} {% block main %}
{% for user in users %}
<div class="thumblist-item"> {% for user in users %}
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}"> {# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
<span class="thumblist-title">{{ user.name }}</span><br/> <div class="thumblist-item">
<div class="imgcontainer"> <a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
<img src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ 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> </div>
</a> {% endfor %}
</div>
{% endfor %} <br/>
<br/> {# Link to the password login #}
<a href="/login">Password-based login</a> <a href="/login">Password login</a>
{{ super() }}
{{ super() }}
{% endblock %} {% endblock %}