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
This commit is contained in:
parent
f614fe1afc
commit
66f23f5dda
18 changed files with 197 additions and 133 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -1,5 +1,21 @@
|
|||
# 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 -->
|
||||
## Version 0.3.15
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
__version__ = '0.3.15'
|
||||
__version__ = '0.3.16'
|
||||
|
|
|
@ -3,6 +3,8 @@ from bottle import get, post, redirect, request
|
|||
from matemat.db import MatematDatabase
|
||||
from matemat.webserver import session
|
||||
from matemat.webserver import config as c
|
||||
from matemat.webserver.template import Notification
|
||||
from matemat.util.currency_format import format_chf
|
||||
|
||||
|
||||
@get('/buy')
|
||||
|
@ -35,9 +37,11 @@ def buy():
|
|||
stock_provider = c.get_stock_provider()
|
||||
if stock_provider.needs_update():
|
||||
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
|
||||
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(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}')
|
||||
redirect('/')
|
||||
|
|
|
@ -3,6 +3,8 @@ from bottle import get, post, redirect, request
|
|||
from matemat.db import MatematDatabase
|
||||
from matemat.webserver import session
|
||||
from matemat.webserver.config import get_app_config
|
||||
from matemat.webserver.template import Notification
|
||||
from matemat.util.currency_format import format_chf
|
||||
|
||||
|
||||
@get('/deposit')
|
||||
|
@ -26,6 +28,7 @@ def deposit():
|
|||
n = int(str(request.params.n))
|
||||
# Write the deposit to the database
|
||||
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(f'/?lastaction=deposit&lastprice={n}')
|
||||
redirect('/')
|
||||
|
|
|
@ -20,12 +20,6 @@ def main_page():
|
|||
with MatematDatabase(config['DatabaseFile']) as db:
|
||||
# Fetch the list of products to display
|
||||
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
|
||||
|
||||
if request.params.ean:
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
from .sessions import start, end, put, get, has, delete
|
||||
from .sessions import start, end, put, get, has, delete, setdefault
|
||||
|
|
|
@ -20,6 +20,9 @@ def start() -> str:
|
|||
|
||||
: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
|
||||
now = datetime.now(UTC)
|
||||
# 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])
|
||||
# Return the session ID and timeout
|
||||
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
|
||||
|
||||
|
||||
|
@ -61,10 +67,10 @@ def put(session_id: str, key: str, value: Any) -> None:
|
|||
__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]:
|
||||
return __session_vars[session_id][1][key]
|
||||
return None
|
||||
return default
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
|
||||
from matemat.webserver import session
|
||||
|
||||
|
||||
class Notification:
|
||||
notifications = []
|
||||
|
||||
def __init__(self, msg: str, classes=None, decay: bool = False):
|
||||
self.msg = msg
|
||||
|
@ -12,14 +13,18 @@ class Notification:
|
|||
|
||||
@classmethod
|
||||
def render(cls):
|
||||
n = list(cls.notifications)
|
||||
cls.notifications.clear()
|
||||
session_id: str = session.start()
|
||||
sn = session.get(session_id, 'notifications', [])
|
||||
n = list(sn)
|
||||
sn.clear()
|
||||
return n
|
||||
|
||||
@classmethod
|
||||
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
|
||||
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))
|
||||
|
|
|
@ -25,10 +25,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
function (e) {
|
||||
let eaninput = document.getElementById("admin-newproduct-ean");
|
||||
eaninput.value = e.data;
|
||||
eaninput.select();
|
||||
eaninput.scrollIntoView();
|
||||
}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,105 +1,85 @@
|
|||
<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">
|
||||
<label for="admin-newuser-username">Username: </label>
|
||||
<input id="admin-newuser-username" type="text" name="username" /><br/>
|
||||
|
||||
<label for="admin-newuser-email">E-Mail (optional): </label>
|
||||
<input id="admin-newuser-email" type="text" name="email" /><br/>
|
||||
|
||||
<label for="admin-newuser-password">Password: </label>
|
||||
<input id="admin-newuser-password" type="password" name="password" /><br/>
|
||||
|
||||
<label for="admin-newuser-ismember">Member: </label>
|
||||
<input id="admin-newuser-ismember" type="checkbox" name="ismember" /><br/>
|
||||
|
||||
<label for="admin-newuser-isadmin">Admin: </label>
|
||||
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/>
|
||||
|
||||
<label for="admin-newuser-logout-after-purchase">Logout after purchase: </label>
|
||||
<input id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase" /><br/>
|
||||
|
||||
<input type="submit" value="Create User" />
|
||||
</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">
|
||||
<table border="1">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>E-Mail (optional)</th>
|
||||
<th>Password</th>
|
||||
<th>Member</th>
|
||||
<th>Admin</th>
|
||||
<th>Logout after purchase</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input id="admin-newuser-username" type="text" name="username" placeholder="New username"></td>
|
||||
<td><input id="admin-newuser-email" type="text" name="email" placeholder="New e-mail"></td>
|
||||
<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>
|
||||
<td><input id="admin-newuser-isadmin" type="checkbox" name="isadmin"></td>
|
||||
<td><input id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase"></td>
|
||||
<td><input type="submit" value="Create User"></td>
|
||||
</tr>
|
||||
{% 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 %}
|
||||
</select><br/>
|
||||
|
||||
<input type="submit" value="Go" />
|
||||
</table>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<label for="admin-newproduct-name">Name: </label>
|
||||
<input id="admin-newproduct-name" type="text" name="name" /><br/>
|
||||
|
||||
<label for="admin-newproduct-ean">EAN code: </label>
|
||||
<input id="admin-newproduct-ean" type="text" name="ean" /><br/>
|
||||
|
||||
<label for="admin-newproduct-price-member">Member price: </label>
|
||||
CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0" /><br/>
|
||||
|
||||
<label for="admin-newproduct-price-non-member">Non-member price: </label>
|
||||
CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0" /><br/>
|
||||
|
||||
<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>
|
||||
<input id="admin-custom-price" type="checkbox" name="custom_price" /><br/>
|
||||
|
||||
<label for="admin-newproduct-stockable">Stockable: </label>
|
||||
<input id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked" /><br/>
|
||||
|
||||
<label for="admin-newproduct-image">Image: </label>
|
||||
<input id="admin-newproduct-image" name="image" type="file" accept="image/*" /><br/>
|
||||
|
||||
<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">
|
||||
<table border="1">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>EAN code</th>
|
||||
<th>Member price</th>
|
||||
<th>Non-member price</th>
|
||||
<th>Custom price</th>
|
||||
<th>Stockable</th>
|
||||
<th>Image</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><input id="admin-newproduct-name" type="text" name="name" placeholder="New product name"></td>
|
||||
<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>
|
||||
<td>CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0"></td>
|
||||
<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>
|
||||
<td><input id="admin-newproduct-image" name="image" type="file" accept="image/*"></td>
|
||||
<td><input type="submit" value="Create Product"></td>
|
||||
</tr>
|
||||
{% for product in products %}
|
||||
{% if product.stockable %}
|
||||
<option value="{{ product.id }}">{{ product.name }} ({{ product.stock }})</option>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>{{ product.name }}</td>
|
||||
<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 %}
|
||||
</select><br/>
|
||||
|
||||
<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">
|
||||
</table>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
@ -107,16 +87,32 @@
|
|||
<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">
|
||||
<table>
|
||||
<tr>
|
||||
<th>Default user avatar</th>
|
||||
<th>Default product image</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<label for="admin-default-images-user">
|
||||
<img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
|
||||
</label><br/>
|
||||
<input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/>
|
||||
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<label for="admin-default-images-product">
|
||||
<img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" />
|
||||
</label><br/>
|
||||
<input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/>
|
||||
|
||||
</label>
|
||||
</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">
|
||||
</form>
|
||||
</section>
|
||||
|
|
|
@ -61,7 +61,13 @@
|
|||
function connect() {
|
||||
let socket = new WebSocket("{{ eanwebsocket }}");
|
||||
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(); });
|
||||
</script>
|
||||
|
|
|
@ -18,6 +18,14 @@
|
|||
<input type="submit" value="Login">
|
||||
</form>
|
||||
|
||||
<div class="thumblist-item">
|
||||
<a href="/">Cancel</a>
|
||||
</div>
|
||||
|
||||
{{ super() }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
document.location = "/?ean=" + e.data;
|
||||
{% endblock %}
|
||||
|
|
|
@ -54,10 +54,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
function (e) {
|
||||
let eaninput = document.getElementById("modproduct-ean");
|
||||
eaninput.value = e.data;
|
||||
eaninput.select();
|
||||
eaninput.scrollIntoView();
|
||||
}
|
||||
{% endblock %}
|
||||
|
|
|
@ -84,12 +84,10 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
function (e) {
|
||||
let eaninput = document.getElementById("a-buy-ean" + e.data);
|
||||
if (eaninput === null) {
|
||||
document.location = "?ean=" + e.data;
|
||||
} else {
|
||||
eaninput.click();
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
|
|
@ -32,6 +32,11 @@
|
|||
|
||||
<input type="submit" value="Create account">
|
||||
</form>
|
||||
|
||||
<div class="thumblist-item">
|
||||
<a href="/">Cancel</a>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/touchkey.js" ></script>
|
||||
<script>
|
||||
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');
|
||||
|
@ -40,3 +45,7 @@
|
|||
{{ super() }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
document.location = "/?ean=" + e.data;
|
||||
{% endblock %}
|
||||
|
|
|
@ -27,6 +27,10 @@
|
|||
<input type="submit" value="Create account">
|
||||
</form>
|
||||
|
||||
<div class="thumblist-item">
|
||||
<a href="/">Cancel</a>
|
||||
</div>
|
||||
|
||||
<div id="osk-kbd" class="osk osk-kbd">
|
||||
{% set lower = [['1','2','3','4','5','6','7','8','9','0','-','⌫'],
|
||||
['q','w','e','r','t','y','u','i','o','p','[','⇥'],
|
||||
|
@ -115,3 +119,7 @@
|
|||
{{ super() }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
document.location = "/?ean=" + e.data;
|
||||
{% endblock %}
|
||||
|
|
|
@ -27,7 +27,10 @@
|
|||
<input type="hidden" name="buypid" value="{{ buypid }}" />
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<div class="thumblist-item">
|
||||
<a href="/">Cancel</a>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/touchkey.js"></script>
|
||||
<script>
|
||||
|
@ -37,3 +40,7 @@
|
|||
{{ super() }}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
document.location = "/?ean=" + e.data;
|
||||
{% endblock %}
|
||||
|
|
|
@ -36,7 +36,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block eanwebsocket %}
|
||||
function (e) {
|
||||
document.location = "?ean=" + e.data;
|
||||
}
|
||||
document.location = "/?ean=" + e.data;
|
||||
{% endblock %}
|
||||
|
|
Loading…
Reference in a new issue