From be09ea1ee7e937b348e5ab924f074c94c15506f4 Mon Sep 17 00:00:00 2001 From: s3lph Date: Mon, 23 Jul 2018 00:19:41 +0200 Subject: [PATCH 1/5] Wrote code documentation for the pagelets of the current implementation, and for some of the jinja2 templates --- matemat/db/facade.py | 6 +- matemat/webserver/pagelets/admin.py | 90 +++++++++++++++++++++--- matemat/webserver/pagelets/buy.py | 11 ++- matemat/webserver/pagelets/deposit.py | 11 ++- matemat/webserver/pagelets/login.py | 21 ++++-- matemat/webserver/pagelets/logout.py | 9 ++- matemat/webserver/pagelets/main.py | 12 +++- matemat/webserver/pagelets/modproduct.py | 51 ++++++++++++-- matemat/webserver/pagelets/moduser.py | 67 ++++++++++++++---- matemat/webserver/pagelets/touchkey.py | 21 ++++-- templates/admin.html | 25 ++++--- templates/base.html | 50 ++++++++----- templates/login.html | 22 +++--- templates/productlist.html | 57 ++++++++------- templates/userlist.html | 35 +++++---- 15 files changed, 365 insertions(+), 123 deletions(-) diff --git a/matemat/db/facade.py b/matemat/db/facade.py index 91365d3..30fafbf 100644 --- a/matemat/db/facade.py +++ b/matemat/db/facade.py @@ -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) diff --git a/matemat/webserver/pagelets/admin.py b/matemat/webserver/pagelets/admin.py index 4cc112c..b84db3c 100644 --- a/matemat/webserver/pagelets/admin.py +++ b/matemat/webserver/pagelets/admin.py @@ -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: diff --git a/matemat/webserver/pagelets/buy.py b/matemat/webserver/pagelets/buy.py index 3b68cee..d08bafa 100644 --- a/matemat/webserver/pagelets/buy.py +++ b/matemat/webserver/pagelets/buy.py @@ -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('/') diff --git a/matemat/webserver/pagelets/deposit.py b/matemat/webserver/pagelets/deposit.py index 60e6cf0..9e38a0b 100644 --- a/matemat/webserver/pagelets/deposit.py +++ b/matemat/webserver/pagelets/deposit.py @@ -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('/') diff --git a/matemat/webserver/pagelets/login.py b/matemat/webserver/pagelets/login.py index d8c7f0a..be4ac4b 100644 --- a/matemat/webserver/pagelets/login.py +++ b/matemat/webserver/pagelets/login.py @@ -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) diff --git a/matemat/webserver/pagelets/logout.py b/matemat/webserver/pagelets/logout.py index fb33437..5d8f289 100644 --- a/matemat/webserver/pagelets/logout.py +++ b/matemat/webserver/pagelets/logout.py @@ -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('/') diff --git a/matemat/webserver/pagelets/main.py b/matemat/webserver/pagelets/main.py index 368ff83..81f2d7d 100644 --- a/matemat/webserver/pagelets/main.py +++ b/matemat/webserver/pagelets/main.py @@ -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']) diff --git a/matemat/webserver/pagelets/modproduct.py b/matemat/webserver/pagelets/modproduct.py index e3416d9..ef6acbd 100644 --- a/matemat/webserver/pagelets/modproduct.py +++ b/matemat/webserver/pagelets/modproduct.py @@ -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) diff --git a/matemat/webserver/pagelets/moduser.py b/matemat/webserver/pagelets/moduser.py index 043b3f4..0b535f1 100644 --- a/matemat/webserver/pagelets/moduser.py +++ b/matemat/webserver/pagelets/moduser.py @@ -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) diff --git a/matemat/webserver/pagelets/touchkey.py b/matemat/webserver/pagelets/touchkey.py index 253e94b..69f5b8e 100644 --- a/matemat/webserver/pagelets/touchkey.py +++ b/matemat/webserver/pagelets/touchkey.py @@ -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) diff --git a/templates/admin.html b/templates/admin.html index 8a1e29c..7343c6d 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -1,22 +1,25 @@ {% extends "base.html" %} {% block header %} -{% if authuser.is_admin %} -

Administration

-{% else %} -

Settings

-{% endif %} -{{ super() }} + {# If the logged in user is an administrator, call the title "Administration", otherwise "Settings" #} + {% if authuser.is_admin %} +

Administration

+ {% else %} +

Settings

+ {% 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 %} diff --git a/templates/base.html b/templates/base.html index 8a48454..d41a1d0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,39 +1,55 @@ + {% block head %} - {{ setupname|safe }} - + {# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #} + {{ setupname|safe }} + {% endblock %} + +
{% block header %} - Home - {% if authlevel|default(0) > 1 %} - {% if authuser is defined %} - {% if authuser.is_admin %} - Administration - {% else %} - Settings + {# Always show a link to the home page, either a list of users or of products. #} + Home + {# 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 %} + Administration + {% else %} + Settings + {% endif %} {% endif %} {% endif %} - {% endif %} {% endblock %}
+
- {% block main %}{% endblock %} + {% block main %} + {# Here be content. #} + {% endblock %}
+ + diff --git a/templates/login.html b/templates/login.html index 5210253..09f1fd6 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,23 +1,23 @@ {% extends "base.html" %} - {% block header %} -

Welcome

-{{ super() }} +

Welcome

+ {{ super() }} {% endblock %} {% block main %} -
- -
+ {# Show a username/password login form #} + + +
- -
+ +
- -
+ + -{{ super() }} + {{ super() }} {% endblock %} diff --git a/templates/productlist.html b/templates/productlist.html index c3479af..b763bd5 100644 --- a/templates/productlist.html +++ b/templates/productlist.html @@ -1,39 +1,42 @@ {% extends "base.html" %} {% block header %} -

Welcome, {{ authuser.name }}

- -{{ super() }} - + {# Show the username. #} +

Welcome, {{ authuser.name }}

+ {{ super() }} {% endblock %} {% block main %} -Your balance: {{ authuser.balance|chf }} -
-Deposit CHF 1
-Deposit CHF 10
+ {# Show the users current balance #} + Your balance: {{ authuser.balance|chf }} +
+ {# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #} + Deposit CHF 1
+ Deposit CHF 10
-{% for product in products %} -
- - {{ product.name }} - Price: - {% if authuser.is_member %} - {{ product.price_member|chf }} - {% else %} - {{ product.price_non_member|chf }} - {% endif %} - ; Stock: {{ product.stock }}
-
-{% endfor %} -
-Logout + {% endfor %} +
+ {# Logout link #} + Logout -{{ super() }} + {{ super() }} {% endblock %} diff --git a/templates/userlist.html b/templates/userlist.html index 15bd1dd..cc42af8 100644 --- a/templates/userlist.html +++ b/templates/userlist.html @@ -1,22 +1,29 @@ {% extends "base.html" %} {% block header %} -

{{ setupname }}

-{{ super() }} + {# Show the setup name, as set in the config file, as page title. Don't escape HTML entities. #} +

{{ setupname|safe }}

+ {{ super() }} {% endblock %} {% block main %} -{% for user in users %} -
- - {{ user.name }}
-
-{% endfor %} -
-Password-based login -{{ super() }} + {% endfor %} + +
+ {# Link to the password login #} + Password login + + {{ super() }} + {% endblock %} From 61649657b088953c5100d1acb006876259c6e49b Mon Sep 17 00:00:00 2001 From: s3lph Date: Mon, 23 Jul 2018 21:07:53 +0200 Subject: [PATCH 2/5] Touchkey documentation & cleanup. --- static/js/touchkey.js | 290 ++++++++++++++++++++++++++++----------- templates/admin_all.html | 2 +- templates/touchkey.html | 3 +- templates/touchkey.svg | 21 --- 4 files changed, 214 insertions(+), 102 deletions(-) delete mode 100644 templates/touchkey.svg diff --git a/static/js/touchkey.js b/static/js/touchkey.js index d0f91ad..f799af5 100644 --- a/static/js/touchkey.js +++ b/static/js/touchkey.js @@ -1,9 +1,32 @@ - -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: + * + *
+ * + * + *
+ * + * + * @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; + + // 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 +34,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 squared 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, 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, minDist2, minX, minY] = getNearestPatternNode(trX, trY, svgrect); + // If the closest node is not visited yet, and the event coordinate is less than ~44px from the node's + // center, snap the current stroke to the node, and create a new stroke starting from this node + if (minDist2 < 2000 && !(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); + }); + }); }; diff --git a/templates/admin_all.html b/templates/admin_all.html index b977987..56dba36 100644 --- a/templates/admin_all.html +++ b/templates/admin_all.html @@ -54,7 +54,7 @@
Draw a new touchkey (leave empty to disable):
- {% include "touchkey.svg" %} +
diff --git a/templates/touchkey.html b/templates/touchkey.html index 1de4db1..9d14244 100644 --- a/templates/touchkey.html +++ b/templates/touchkey.html @@ -16,7 +16,8 @@ {% endblock %} {% block main %} -{% include "touchkey.svg" %} + + diff --git a/templates/touchkey.svg b/templates/touchkey.svg deleted file mode 100644 index 9ca53a1..0000000 --- a/templates/touchkey.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From df013ea584b5e19905be29ea430369f031193d35 Mon Sep 17 00:00:00 2001 From: s3lph Date: Tue, 24 Jul 2018 00:38:37 +0200 Subject: [PATCH 3/5] Snapping sensitivity now independent of size. --- static/js/touchkey.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/static/js/touchkey.js b/static/js/touchkey.js index f799af5..118a21e 100644 --- a/static/js/touchkey.js +++ b/static/js/touchkey.js @@ -26,6 +26,10 @@ initTouchkey = (keepPattern, svgid, formid, formfieldid) => { 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; @@ -122,7 +126,7 @@ initTouchkey = (keepPattern, svgid, formid, formfieldid) => { * @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 squared distance, the X and Y coordinate of the node's center. + * @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 @@ -146,7 +150,7 @@ initTouchkey = (keepPattern, svgid, formid, formfieldid) => { } }); - return [minId, minDist2, minX, minY]; + return [minId, Math.sqrt(minDist2), minX, minY]; }; /** @@ -183,10 +187,10 @@ initTouchkey = (keepPattern, svgid, formid, formfieldid) => { // Get the event coordinates relative to the SVG container's origin const [trX, trY] = getEventCoordinates(ev, svgrect); // Get the closest pattern node - const [minId, minDist2, minX, minY] = getNearestPatternNode(trX, trY, svgrect); - // If the closest node is not visited yet, and the event coordinate is less than ~44px from the node's + 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 (minDist2 < 2000 && !(minId in doneMap)) { + 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()); From 8aaf46e565e62487a0a9cc8657ebe42369fe8536 Mon Sep 17 00:00:00 2001 From: s3lph Date: Tue, 24 Jul 2018 13:16:01 +0200 Subject: [PATCH 4/5] Added external touchkey documentation --- doc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc b/doc index da08844..4938c51 160000 --- a/doc +++ b/doc @@ -1 +1 @@ -Subproject commit da08844f714919a287cd62a9fa90faf3a484d3c3 +Subproject commit 4938c5168324e1d67ae067b3e9b2a13205948176 From a9061f877b3736b0a3c47ce608de3ce5897e5681 Mon Sep 17 00:00:00 2001 From: s3lph Date: Fri, 27 Jul 2018 20:56:16 +0200 Subject: [PATCH 5/5] Added matemat pagelet description --- doc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc b/doc index 4938c51..ece840a 160000 --- a/doc +++ b/doc @@ -1 +1 @@ -Subproject commit 4938c5168324e1d67ae067b3e9b2a13205948176 +Subproject commit ece840af5b19d2c78b2dbfa14adac145fab79f4f