fix: store notifications in the session so that they won't be served to other clients
All checks were successful
/ test (push) Successful in 1m12s
/ codestyle (push) Successful in 1m26s
/ build_wheel (push) Successful in 1m42s
/ build_debian (push) Successful in 2m3s

feat: list all users and products in a table in the settings
feat: add back buttons to signup, password login and touchkey login pages
feat: if the tabfocus webextension is installed, use it to focus the tab when a barcode is scanned
This commit is contained in:
s3lph 2024-11-27 23:43:23 +01:00
parent f614fe1afc
commit 66f23f5dda
Signed by: s3lph
GPG key ID: 0AA29A52FB33CFB5
18 changed files with 197 additions and 133 deletions

View file

@ -1,5 +1,21 @@
# Matemat Changelog # Matemat Changelog
<!-- BEGIN RELEASE v0.3.16 -->
## Version 0.3.16
Settings UI rework
### Changes
<!-- BEGIN CHANGES 0.3.16 -->
- fix: store notifications in the session so that they won't be served to other clients
- feat: list all users and products in a table in the settings
- feat: add back buttons to signup, password login and touchkey login pages
- feat: if the tabfocus webextension is installed, use it to focus the tab when a barcode is scanned
<!-- END CHANGES 0.3.16 -->
<!-- END RELEASE v0.3.16 -->
<!-- BEGIN RELEASE v0.3.15 --> <!-- BEGIN RELEASE v0.3.15 -->
## Version 0.3.15 ## Version 0.3.15

View file

@ -1,2 +1,2 @@
__version__ = '0.3.15' __version__ = '0.3.16'

View file

@ -3,6 +3,8 @@ from bottle import get, post, redirect, request
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import session from matemat.webserver import session
from matemat.webserver import config as c from matemat.webserver import config as c
from matemat.webserver.template import Notification
from matemat.util.currency_format import format_chf
@get('/buy') @get('/buy')
@ -35,9 +37,11 @@ def buy():
stock_provider = c.get_stock_provider() stock_provider = c.get_stock_provider()
if stock_provider.needs_update(): if stock_provider.needs_update():
stock_provider.update_stock(product, -1) stock_provider.update_stock(product, -1)
# Show notification on next page load
Notification.success(
f'Purchased <strong>{product.name}</strong> for <strong>{format_chf(price)}</strong>', decay=True)
# Logout user if configured, logged in via touchkey and no price entry input was shown # Logout user if configured, logged in via touchkey and no price entry input was shown
if user.logout_after_purchase and authlevel < 2 and not product.custom_price: if user.logout_after_purchase and authlevel < 2 and not product.custom_price:
redirect(f'/logout?lastaction=buy&lastproduct={pid}&lastprice={price}') redirect('/logout')
# Redirect to the main page (where this request should have come from) # Redirect to the main page (where this request should have come from)
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}')
redirect('/') redirect('/')

View file

@ -3,6 +3,8 @@ from bottle import get, post, redirect, request
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import session from matemat.webserver import session
from matemat.webserver.config import get_app_config from matemat.webserver.config import get_app_config
from matemat.webserver.template import Notification
from matemat.util.currency_format import format_chf
@get('/deposit') @get('/deposit')
@ -26,6 +28,7 @@ def deposit():
n = int(str(request.params.n)) n = int(str(request.params.n))
# Write the deposit to the database # Write the deposit to the database
db.deposit(user, n) db.deposit(user, n)
# Show notification on next page load
Notification.success(f'Deposited <strong>{format_chf(n)}</strong>', decay=True)
# Redirect to the main page (where this request should have come from) # Redirect to the main page (where this request should have come from)
redirect(f'/?lastaction=deposit&lastprice={n}')
redirect('/') redirect('/')

View file

@ -20,12 +20,6 @@ def main_page():
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the list of products to display # Fetch the list of products to display
products = db.list_products() products = db.list_products()
lastprice = int(request.params.lastprice) if request.params.lastprice else None
if request.params.lastaction == 'deposit' and lastprice:
Notification.success(f'Deposited {format_chf(lastprice)}', decay=True)
elif request.params.lastaction == 'buy' and lastprice and request.params.lastproduct:
lastproduct = db.get_product(request.params.lastproduct)
Notification.success(f'Purchased {lastproduct.name} for {format_chf(lastprice)}', decay=True)
buyproduct = None buyproduct = None
if request.params.ean: if request.params.ean:

View file

@ -1,2 +1,2 @@
from .sessions import start, end, put, get, has, delete from .sessions import start, end, put, get, has, delete, setdefault

View file

@ -20,6 +20,9 @@ def start() -> str:
:return: The session ID. :return: The session ID.
""" """
if hasattr(response, 'session_id'):
# A session has already been created while handling the same request
return response.session_id
# Reference date for session timeout # Reference date for session timeout
now = datetime.now(UTC) now = datetime.now(UTC)
# Read the client's session ID, if any # Read the client's session ID, if any
@ -43,6 +46,9 @@ def start() -> str:
(now + timedelta(seconds=_SESSION_TIMEOUT), __session_vars[session_id][1]) (now + timedelta(seconds=_SESSION_TIMEOUT), __session_vars[session_id][1])
# Return the session ID and timeout # Return the session ID and timeout
response.set_cookie(_COOKIE_NAME, session_id, secret=__key) response.set_cookie(_COOKIE_NAME, session_id, secret=__key)
# Piggy-back the session id onto the response object so that we don't create another session
# in subsequent calls to start() while handling the same request.
response.session_id = session_id
return session_id return session_id
@ -61,10 +67,10 @@ def put(session_id: str, key: str, value: Any) -> None:
__session_vars[session_id][1][key] = value __session_vars[session_id][1][key] = value
def get(session_id: str, key: str) -> Any: def get(session_id: str, key: str, default: Any = None) -> Any:
if session_id in __session_vars and key in __session_vars[session_id][1]: if session_id in __session_vars and key in __session_vars[session_id][1]:
return __session_vars[session_id][1][key] return __session_vars[session_id][1][key]
return None return default
def delete(session_id: str, key: str) -> None: def delete(session_id: str, key: str) -> None:
@ -74,3 +80,13 @@ def delete(session_id: str, key: str) -> None:
def has(session_id: str, key: str) -> bool: def has(session_id: str, key: str) -> bool:
return session_id in __session_vars and key in __session_vars[session_id][1] return session_id in __session_vars and key in __session_vars[session_id][1]
def setdefault(session_id: str, key: str, value: Any) -> Any:
if session_id in __session_vars:
if has(session_id, key):
return get(session_id, key)
else:
put(session_id, key, value)
return value
return None

View file

@ -1,7 +1,8 @@
from matemat.webserver import session
class Notification: class Notification:
notifications = []
def __init__(self, msg: str, classes=None, decay: bool = False): def __init__(self, msg: str, classes=None, decay: bool = False):
self.msg = msg self.msg = msg
@ -12,14 +13,18 @@ class Notification:
@classmethod @classmethod
def render(cls): def render(cls):
n = list(cls.notifications) session_id: str = session.start()
cls.notifications.clear() sn = session.get(session_id, 'notifications', [])
n = list(sn)
sn.clear()
return n return n
@classmethod @classmethod
def success(cls, msg: str, decay: bool = False): def success(cls, msg: str, decay: bool = False):
cls.notifications.append(cls(msg, classes=['success'], decay=decay)) session_id: str = session.start()
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['success'], decay=decay))
@classmethod @classmethod
def error(cls, msg: str, decay: bool = False): def error(cls, msg: str, decay: bool = False):
cls.notifications.append(cls(msg, classes=['error'], decay=decay)) session_id: str = session.start()
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['error'], decay=decay))

View file

@ -25,10 +25,8 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block eanwebsocket %}
function (e) {
let eaninput = document.getElementById("admin-newproduct-ean"); let eaninput = document.getElementById("admin-newproduct-ean");
eaninput.value = e.data; eaninput.value = e.data;
eaninput.select(); eaninput.select();
eaninput.scrollIntoView(); eaninput.scrollIntoView();
}
{% endblock %} {% endblock %}

View file

@ -1,105 +1,85 @@
<section id="admin-restricted-newuser"> <section id="admin-restricted-newuser">
<h2>Create New User</h2> <h2>Users</h2>
<form id="admin-newuser-form" method="post" action="/admin?adminchange=newuser" accept-charset="UTF-8"> <form id="admin-newuser-form" method="post" action="/admin?adminchange=newuser" accept-charset="UTF-8">
<label for="admin-newuser-username">Username: </label> <table border="1">
<input id="admin-newuser-username" type="text" name="username" /><br/> <tr>
<th>Username</th>
<label for="admin-newuser-email">E-Mail (optional): </label> <th>E-Mail (optional)</th>
<input id="admin-newuser-email" type="text" name="email" /><br/> <th>Password</th>
<th>Member</th>
<label for="admin-newuser-password">Password: </label> <th>Admin</th>
<input id="admin-newuser-password" type="password" name="password" /><br/> <th>Logout after purchase</th>
<th>Actions</th>
<label for="admin-newuser-ismember">Member: </label> </tr>
<input id="admin-newuser-ismember" type="checkbox" name="ismember" /><br/> <tr>
<td><input id="admin-newuser-username" type="text" name="username" placeholder="New username"></td>
<label for="admin-newuser-isadmin">Admin: </label> <td><input id="admin-newuser-email" type="text" name="email" placeholder="New e-mail"></td>
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/> <td><input id="admin-newuser-password" type="password" name="password" placeholder="New password"></td>
<td><input id="admin-newuser-ismember" type="checkbox" name="ismember"></td>
<label for="admin-newuser-logout-after-purchase">Logout after purchase: </label> <td><input id="admin-newuser-isadmin" type="checkbox" name="isadmin"></td>
<input id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase" /><br/> <td><input id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase"></td>
<td><input type="submit" value="Create User"></td>
<input type="submit" value="Create User" /> </tr>
</form>
</section>
<section id="admin-restricted-moduser">
<h2>Modify User</h2>
<form id="admin-moduser-form" method="get" action="/moduser" accept-charset="UTF-8">
<label for="admin-moduser-userid">Username: </label>
<select id="admin-moduser-userid" name="userid">
{% for user in users %} {% for user in users %}
<option value="{{ user.id }}">{{ user.name }}</option> <tr>
<td>{{ user.name }}</td>
<td>{{ '✓' if user.email else '✗' }}</td>
<td>••••••••</td>
<td>{{ '✓' if user.is_member else '✗' }}</td>
<td>{{ '✓' if user.is_admin else '✗' }}</td>
<td>{{ '✓' if user.logout_after_purchase else '✗' }}</td>
<td>
<a style="text-decoration: none; color: #0000ff;" href="/moduser?userid={{ user.id }}">🖊</a>
<a style="text-decoration: none; color: #ff0000;" href="/moduser?userid={{ user.id }}&change=del">🗑</a>
</td>
</tr>
{% endfor %} {% endfor %}
</select><br/> </table>
<input type="submit" value="Go" />
</form> </form>
</section> </section>
<section id="admin-restricted-newproduct"> <section id="admin-restricted-newproduct">
<h2>Create New Product</h2> <h2>Products</h2>
<form id="admin-newproduct-form" method="post" action="/admin?adminchange=newproduct" enctype="multipart/form-data" accept-charset="UTF-8"> <form id="admin-newproduct-form" method="post" action="/admin?adminchange=newproduct" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="admin-newproduct-name">Name: </label> <table border="1">
<input id="admin-newproduct-name" type="text" name="name" /><br/> <tr>
<th>Name</th>
<label for="admin-newproduct-ean">EAN code: </label> <th>EAN code</th>
<input id="admin-newproduct-ean" type="text" name="ean" /><br/> <th>Member price</th>
<th>Non-member price</th>
<label for="admin-newproduct-price-member">Member price: </label> <th>Custom price</th>
CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0" /><br/> <th>Stockable</th>
<th>Image</th>
<label for="admin-newproduct-price-non-member">Non-member price: </label> <th>Actions</th>
CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0" /><br/> </tr>
<tr>
<label for="admin-custom-price"><abbr title="When 'Custom Price' is enabled, users choose the price to pay, but at least the prices given above">Custom Price</abbr>: </label> <td><input id="admin-newproduct-name" type="text" name="name" placeholder="New product name"></td>
<input id="admin-custom-price" type="checkbox" name="custom_price" /><br/> <td><input id="admin-newproduct-ean" type="text" name="ean" placeholder="Scan to insert EAN"></td>
<td>CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0"></td>
<label for="admin-newproduct-stockable">Stockable: </label> <td>CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0"></td>
<input id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked" /><br/> <td><input id="admin-custom-price" type="checkbox" name="custom_price"></td>
<td><input id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked"></td>
<label for="admin-newproduct-image">Image: </label> <td><input id="admin-newproduct-image" name="image" type="file" accept="image/*"></td>
<input id="admin-newproduct-image" name="image" type="file" accept="image/*" /><br/> <td><input type="submit" value="Create Product"></td>
</tr>
<input type="submit" value="Create Product" />
</form>
</section>
<section id="admin-restricted-restock">
<h2>Restock Product</h2>
<form id="admin-restock-form" method="post" action="/admin?adminchange=restock" accept-charset="UTF-8">
<label for="admin-restock-productid">Product: </label>
<select id="admin-restock-productid" name="productid">
{% for product in products %} {% for product in products %}
{% if product.stockable %} <tr>
<option value="{{ product.id }}">{{ product.name }} ({{ product.stock }})</option> <td>{{ product.name }}</td>
{% endif %} <td>{{ product.ean or '' }}</td>
<td>{{ product.price_member | chf }}</td>
<td>{{ product.price_non_member | chf }}</td>
<td>{{ '✓' if product.custom_price else '✗' }}</td>
<td>{{ '✓' if product.stockable else '✗' }}</td>
<td><img style="height: 2em;" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ product.name }}" draggable="false"></td>
<td>
<a style="text-decoration: none; color: #0000ff;" href="/modproduct?productid={{ product.id }}">🖊</a>
<a style="text-decoration: none; color: #ff0000;" href="/modproduct?productid={{ product.id }}&change=del">🗑</a>
</td>
</tr>
{% endfor %} {% endfor %}
</select><br/> </table>
<label for="admin-restock-amount">Amount: </label>
<input id="admin-restock-amount" type="number" min="0" name="amount" /><br/>
<input type="submit" value="Restock" />
</form>
</section>
<section id="admin-restricted-modproduct">
<h2>Modify Product</h2>
<form id="admin-modproduct-form" method="get" action="/modproduct" accept-charset="UTF-8">
<label for="admin-modproduct-productid">Product: </label>
<select id="admin-modproduct-productid" name="productid">
{% for product in products %}
<option value="{{ product.id }}">{{ product.name }}</option>
{% endfor %}
</select><br/>
<input type="submit" value="Go">
</form> </form>
</section> </section>
@ -107,16 +87,32 @@
<h2>Set default images</h2> <h2>Set default images</h2>
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8"> <form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
<table>
<tr>
<th>Default user avatar</th>
<th>Default product image</th>
</tr>
<tr>
<td>
<label for="admin-default-images-user"> <label for="admin-default-images-user">
<img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" /> <img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
</label><br/> </label>
<input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/> </td>
<td>
<label for="admin-default-images-product"> <label for="admin-default-images-product">
<img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" /> <img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" />
</label><br/> </label>
<input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/> </td>
</tr>
<tr>
<td>
<input id="admin-default-images-user" type="file" name="users" accept="image/*" />
</td>
<td>
<input id="admin-default-images-product" type="file" name="products" accept="image/*" />
</td>
</tr>
</table>
<input type="submit" value="Save changes"> <input type="submit" value="Save changes">
</form> </form>
</section> </section>

View file

@ -61,7 +61,13 @@
function connect() { function connect() {
let socket = new WebSocket("{{ eanwebsocket }}"); let socket = new WebSocket("{{ eanwebsocket }}");
socket.onclose = () => { setTimeout(connect, 1000); }; socket.onclose = () => { setTimeout(connect, 1000); };
socket.onmessage = {% block eanwebsocket %}() => {}{% endblock %}; socket.onmessage = function (e) {
// Focus this tab - requires https://git.kabelsalat.ch/ccc-basel/barcode-utils
if (typeof window.extension_tabfocus === "function") {
window.extension_tabfocus();
}
{% block eanwebsocket %}{% endblock %}
};
} }
window.addEventListener("load", () => { connect(); }); window.addEventListener("load", () => { connect(); });
</script> </script>

View file

@ -18,6 +18,14 @@
<input type="submit" value="Login"> <input type="submit" value="Login">
</form> </form>
<div class="thumblist-item">
<a href="/">Cancel</a>
</div>
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block eanwebsocket %}
document.location = "/?ean=" + e.data;
{% endblock %}

View file

@ -54,10 +54,8 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block eanwebsocket %}
function (e) {
let eaninput = document.getElementById("modproduct-ean"); let eaninput = document.getElementById("modproduct-ean");
eaninput.value = e.data; eaninput.value = e.data;
eaninput.select(); eaninput.select();
eaninput.scrollIntoView(); eaninput.scrollIntoView();
}
{% endblock %} {% endblock %}

View file

@ -84,12 +84,10 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block eanwebsocket %}
function (e) {
let eaninput = document.getElementById("a-buy-ean" + e.data); let eaninput = document.getElementById("a-buy-ean" + e.data);
if (eaninput === null) { if (eaninput === null) {
document.location = "?ean=" + e.data; document.location = "?ean=" + e.data;
} else { } else {
eaninput.click(); eaninput.click();
} }
}
{% endblock %} {% endblock %}

View file

@ -32,6 +32,11 @@
<input type="submit" value="Create account"> <input type="submit" value="Create account">
</form> </form>
<div class="thumblist-item">
<a href="/">Cancel</a>
</div>
<script src="/static/js/touchkey.js" ></script> <script src="/static/js/touchkey.js" ></script>
<script> <script>
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey'); initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');
@ -40,3 +45,7 @@
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block eanwebsocket %}
document.location = "/?ean=" + e.data;
{% endblock %}

View file

@ -27,6 +27,10 @@
<input type="submit" value="Create account"> <input type="submit" value="Create account">
</form> </form>
<div class="thumblist-item">
<a href="/">Cancel</a>
</div>
<div id="osk-kbd" class="osk osk-kbd"> <div id="osk-kbd" class="osk osk-kbd">
{% set lower = [['1','2','3','4','5','6','7','8','9','0','-','⌫'], {% set lower = [['1','2','3','4','5','6','7','8','9','0','-','⌫'],
['q','w','e','r','t','y','u','i','o','p','[','⇥'], ['q','w','e','r','t','y','u','i','o','p','[','⇥'],
@ -115,3 +119,7 @@
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block eanwebsocket %}
document.location = "/?ean=" + e.data;
{% endblock %}

View file

@ -27,7 +27,10 @@
<input type="hidden" name="buypid" value="{{ buypid }}" /> <input type="hidden" name="buypid" value="{{ buypid }}" />
{% endif %} {% endif %}
</form> </form>
<div class="thumblist-item">
<a href="/">Cancel</a> <a href="/">Cancel</a>
</div>
<script src="/static/js/touchkey.js"></script> <script src="/static/js/touchkey.js"></script>
<script> <script>
@ -37,3 +40,7 @@
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block eanwebsocket %}
document.location = "/?ean=" + e.data;
{% endblock %}

View file

@ -36,7 +36,5 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block eanwebsocket %}
function (e) { document.location = "/?ean=" + e.data;
document.location = "?ean=" + e.data;
}
{% endblock %} {% endblock %}