fix: show the purchase warning banner also on the touchkey login
All checks were successful
/ test (push) Successful in 1m22s
/ codestyle (push) Successful in 1m5s
/ build_wheel (push) Successful in 1m59s
/ build_debian (push) Successful in 2m32s

feat: replace overlay system with a generic notification banner system
feat: add a config option to automatically close tabs after ean purchase
This commit is contained in:
s3lph 2024-11-23 09:48:53 +01:00
parent 8287dc1947
commit 4eb71415fd
Signed by: s3lph
GPG key ID: 0AA29A52FB33CFB5
14 changed files with 141 additions and 128 deletions

View file

@ -1,5 +1,20 @@
# Matemat Changelog # Matemat Changelog
<!-- BEGIN RELEASE v0.3.14 -->
## Version 0.3.14
Improvement of quick-purchase via EAN codes
### Changes
<!-- BEGIN CHANGES 0.3.14 -->
- fix: show the purchase warning banner also on the touchkey login
- feat: replace overlay system with a generic notification banner system
- feat: add a config option to automatically close tabs after ean purchase
<!-- END CHANGES 0.3.14 -->
<!-- END RELEASE v0.3.14 -->
<!-- BEGIN RELEASE v0.3.13 --> <!-- BEGIN RELEASE v0.3.13 -->
## Version 0.3.13 ## Version 0.3.13

View file

@ -1,2 +1,2 @@
__version__ = '0.3.13' __version__ = '0.3.14'

View file

@ -26,6 +26,7 @@ def buy():
if 'pid' in request.params: if 'pid' in request.params:
pid = int(str(request.params.pid)) pid = int(str(request.params.pid))
product = db.get_product(pid) product = db.get_product(pid)
closetab = int(str(request.params.closetab) or 0)
if c.get_dispenser().dispense(product, 1): if c.get_dispenser().dispense(product, 1):
price = product.price_member if user.is_member else product.price_non_member price = product.price_member if user.is_member else product.price_non_member
if 'price' in request.params: if 'price' in request.params:
@ -37,7 +38,7 @@ def buy():
stock_provider.update_stock(product, -1) stock_provider.update_stock(product, -1)
# 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(f'/logout?lastaction=buy&lastproduct={pid}&lastprice={price}&closetab={closetab}')
# 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(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}&closetab={closetab}')
redirect('/') redirect('/')

View file

@ -4,7 +4,9 @@ from bottle import route, redirect, request
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import template, session from matemat.webserver import template, session
from matemat.webserver.template import Notification
from matemat.webserver.config import get_app_config, get_stock_provider from matemat.webserver.config import get_app_config, get_stock_provider
from matemat.util.currency_format import format_chf
@route('/') @route('/')
@ -18,18 +20,21 @@ 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()
if request.params.lastproduct: closetab = int(str(request.params.closetab) or 0)
lastproduct = db.get_product(request.params.lastproduct)
else:
lastproduct = None
lastprice = int(request.params.lastprice) if request.params.lastprice else None 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: if request.params.ean:
try: try:
buyproduct = db.get_product_by_ean(request.params.ean) buyproduct = db.get_product_by_ean(request.params.ean)
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
except ValueError: except ValueError:
buyproduct = None Notification.error(f'EAN code {request.params.ean} is not associated with any product.', decay=True)
else:
buyproduct = None
# Check whether a user is logged in # Check whether a user is logged in
if session.has(session_id, 'authenticated_user'): if session.has(session_id, 'authenticated_user'):
# Fetch the user id and authentication level (touchkey vs password) from the session storage # Fetch the user id and authentication level (touchkey vs password) from the session storage
@ -37,14 +42,16 @@ def main_page():
authlevel: int = session.get(session_id, 'authentication_level') authlevel: int = session.get(session_id, 'authentication_level')
# If an EAN code was scanned, directly trigger the purchase # If an EAN code was scanned, directly trigger the purchase
if buyproduct: if buyproduct:
redirect(f'/buy?pid={buyproduct.id}') url = f'/buy?pid={buyproduct.id}'
if config.get('CloseTabAfterEANPurchase', '0') == '1':
url += '&closetab=1'
redirect(url)
# Fetch the user object from the database (for name display, price calculation and admin check) # Fetch the user object from the database (for name display, price calculation and admin check)
users = db.list_users() users = db.list_users()
user = db.get_user(uid) user = db.get_user(uid)
# Prepare a response with a jinja2 template # Prepare a response with a jinja2 template
return template.render('productlist.html', return template.render('productlist.html',
authuser=user, users=users, products=products, authlevel=authlevel, authuser=user, users=users, products=products, authlevel=authlevel,
lastaction=request.params.lastaction, lastprice=lastprice, lastproduct=lastproduct,
stock=get_stock_provider(), setupname=config['InstanceName'], now=now) stock=get_stock_provider(), setupname=config['InstanceName'], now=now)
else: else:
# If there are no admin users registered, jump to the admin creation procedure # If there are no admin users registered, jump to the admin creation procedure
@ -55,5 +62,4 @@ def main_page():
return template.render('userlist.html', return template.render('userlist.html',
users=users, setupname=config['InstanceName'], now=now, users=users, setupname=config['InstanceName'], now=now,
signup=(config.get('SignupEnabled', '0') == '1'), signup=(config.get('SignupEnabled', '0') == '1'),
lastaction=request.params.lastaction, lastprice=lastprice, lastproduct=lastproduct, buyproduct=buyproduct, closetab=closetab)
buyproduct=buyproduct)

View file

@ -4,6 +4,7 @@ from matemat.db import MatematDatabase
from matemat.db.primitives import User from matemat.db.primitives import User
from matemat.exceptions import AuthenticationError from matemat.exceptions import AuthenticationError
from matemat.webserver import template, session from matemat.webserver import template, session
from matemat.webserver.template import Notification
from matemat.webserver.config import get_app_config from matemat.webserver.config import get_app_config
@ -16,36 +17,45 @@ def touchkey_page():
""" """
config = get_app_config() config = get_app_config()
session_id: str = session.start() session_id: str = session.start()
# If a user is already logged in, simply redirect to the main page, showing the product list with MatematDatabase(config['DatabaseFile']) as db:
if session.has(session_id, 'authenticated_user'): # If a user is already logged in, simply redirect to the main page, showing the product list
redirect('/') if session.has(session_id, 'authenticated_user'):
# If requested via HTTP GET, render the login page showing the touchkey UI redirect('/')
if request.method == 'GET': # If requested via HTTP GET, render the login page showing the touchkey UI
if request.params.buypid: if request.method == 'GET':
buypid = str(request.params.buypid)
else:
buypid = None buypid = None
return template.render('touchkey.html', if request.params.buypid:
username=str(request.params.username), uid=int(str(request.params.uid)), buypid = str(request.params.buypid)
setupname=config['InstanceName'], buypid=buypid) try:
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials buyproduct = db.get_product(int(buypid))
elif request.method == 'POST': Notification.success(
# Connect to the database f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
with MatematDatabase(config['DatabaseFile']) as db: except ValueError:
try: Notification.error(f'No product with id {buypid}', decay=True)
# Read the request arguments and attempt to log in with them return template.render('touchkey.html',
user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey)) username=str(request.params.username), uid=int(str(request.params.uid)),
except AuthenticationError: setupname=config['InstanceName'], buypid=buypid)
# Reload the touchkey login page on failure # If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
redirect(f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}') elif request.method == 'POST':
# Set the user ID session variable # Connect to the database
session.put(session_id, 'authenticated_user', user.id) with MatematDatabase(config['DatabaseFile']) as db:
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login) try:
session.put(session_id, 'authentication_level', 1) # Read the request arguments and attempt to log in with them
if request.params.buypid: user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey))
buypid = str(request.params.buypid) except AuthenticationError:
redirect(f'/buy?pid={buypid}') # Reload the touchkey login page on failure
# Redirect to the main page, showing the product list redirect(f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}')
redirect('/') # Set the user ID session variable
# If neither GET nor POST was used, show a 405 Method Not Allowed error page session.put(session_id, 'authenticated_user', user.id)
abort(405) # Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session.put(session_id, 'authentication_level', 1)
if request.params.buypid:
buypid = str(request.params.buypid)
url = f'/buy?pid={buypid}'
if config.get('CloseTabAfterEANPurchase', '0') == '1':
url += '&closetab=1'
redirect(url)
# Redirect to the main page, showing the product list
redirect('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
abort(405)

View file

@ -1,2 +1,3 @@
from .notification import Notification
from .template import init, render from .template import init, render

View file

@ -0,0 +1,25 @@
class Notification:
notifications = []
def __init__(self, msg: str, classes=None, decay: bool = False):
self.msg = msg
self.classes = []
self.classes.extend(classes)
if decay:
self.classes.append('decay')
@classmethod
def render(cls):
n = list(cls.notifications)
cls.notifications.clear()
return n
@classmethod
def success(cls, msg: str, decay: bool = False):
cls.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))

View file

@ -5,6 +5,7 @@ import jinja2
from matemat import __version__ from matemat import __version__
from matemat.util.currency_format import format_chf from matemat.util.currency_format import format_chf
from matemat.webserver.template import Notification
__jinja_env: jinja2.Environment = None __jinja_env: jinja2.Environment = None
@ -22,4 +23,8 @@ def init(config: Dict[str, Any]) -> None:
def render(name: str, **kwargs): def render(name: str, **kwargs):
global __jinja_env global __jinja_env
template: jinja2.Template = __jinja_env.get_template(name) template: jinja2.Template = __jinja_env.get_template(name)
return template.render(__version__=__version__, **kwargs).encode('utf-8') return template.render(
__version__=__version__,
notifications=Notification.render(),
**kwargs
).encode('utf-8')

View file

@ -37,6 +37,11 @@ InstanceName=Matemat
#SignupEnabled=1 #SignupEnabled=1
#SignupKioskMode= ::1, ::ffff:127.0.0.0/8, 127.0.0.0/8 #SignupKioskMode= ::1, ::ffff:127.0.0.0/8, 127.0.0.0/8
#
# Close tabs after completing an EAN code scan based purchase.
# This only works in Firefox with dom.allow_scripts_to_close_windows=true
#
#CloseTabAfterEANPurchase=1
# Add static HTTP headers in this section # Add static HTTP headers in this section
# [HttpHeaders] # [HttpHeaders]

View file

@ -57,8 +57,19 @@ nav div {
width: calc(100% - 36px); width: calc(100% - 36px);
margin: 10px; margin: 10px;
padding: 10px; padding: 10px;
}
.notification.success {
background-color: #c0ffc0; background-color: #c0ffc0;
} }
.notification.error {
background-color: #ffc0c0;
}
.notification.decay {
animation: notificationdecay 0s 7s forwards;
}
@keyframes notificationdecay {
to { display: none; }
}
@media print { @media print {
footer { footer {
@ -306,37 +317,3 @@ div.osk-button.osk-button-space {
flex: 5 0 1px; flex: 5 0 1px;
color: #606060; color: #606060;
} }
aside#overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: #88ff88;
text-align: center;
z-index: 1000;
padding: 5%;
font-family: sans-serif;
display: none;
transition: opacity 700ms;
opacity: 0;
}
aside#overlay.fade {
opacity: 100%;
}
aside#overlay > h2 {
font-size: 3em;
}
aside#overlay > img {
width: 30%;
height: auto;
}
aside#overlay > div.price {
padding-top: 30px;
font-size: 2em;
}

View file

@ -1,18 +0,0 @@
setTimeout(() => {
let overlay = document.getElementById('overlay');
if (overlay !== null) {
overlay.style.display = 'block';
setTimeout(() => {
overlay.classList.add('fade');
setTimeout(() => {
setTimeout(() => {
overlay.classList.remove('fade');
setTimeout(() => {
overlay.style.display = 'none';
}, 700);
}, 700);
}, 700);
}, 10);
}
}, 0);

View file

@ -12,27 +12,6 @@
<body> <body>
{% block overlay %}
{% if lastaction is defined and lastaction is not none %}
{% if lastaction == 'buy' %}
<aside id="overlay">
<h2>{{ lastproduct.name }}</h2>
<img src="/static/upload/thumbnails/products/{{ lastproduct.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ lastproduct.name }}" draggable="false"/>
{% if lastprice is not none %}
<div class="price">{{ lastprice|chf }}</div>
{% endif %}
</aside>
{% elif lastaction == 'deposit' %}
<aside id="overlay">
<h2>Deposit</h2>
{% if lastprice is not none %}
<div class="price">{{ lastprice|chf }}</div>
{% endif %}
</aside>
{% endif %}
{% endif %}
{% endblock %}
<header> <header>
{% block header %} {% block header %}
@ -55,6 +34,11 @@
</header> </header>
<main> <main>
{% block notifications %}
{% for n in notifications | default([]) %}
<div class="notification {{ n.classes | join(' ') }}">{{ n.msg|safe }}</div>
{% endfor %}
{% endblock %}
{% block main %} {% block main %}
{# Here be content. #} {# Here be content. #}
{% endblock %} {% endblock %}
@ -72,6 +56,5 @@
{% endblock %} {% endblock %}
</footer> </footer>
<script src="/static/js/overlay.js"></script>
</body> </body>
</html> </html>

View file

@ -81,4 +81,9 @@
{{ super() }} {{ super() }}
{% if closetab | default(0) %}
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
<script>setTimeout(window.close, 3000);</script>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -8,13 +8,6 @@
{% block main %} {% block main %}
{% if buyproduct %}
<div class="notification">
Login will purchase <strong>{{ buyproduct.name }}</strong>.
Click <a href="/">here</a> to abort.
</div>
{% endif %}
{% for user in users %} {% for user in users %}
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #} {# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
<div class="thumblist-item"> <div class="thumblist-item">
@ -40,4 +33,9 @@
{{ super() }} {{ super() }}
{% if closetab | default(0) %}
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
<script>setTimeout(window.close, 3000);</script>
{% endif %}
{% endblock %} {% endblock %}