breaking: remove the config option to automatically close tabs after ean purchase
fix: improve error handling on database consistency errors (e.g. non-unique ean codes) in the settings feat: handle ean codes in the already open tab via a websocket connection feat: populate ean code input field when a barcode is scanned while in the product settings
This commit is contained in:
parent
4eb71415fd
commit
f614fe1afc
15 changed files with 99 additions and 36 deletions
16
CHANGELOG.md
16
CHANGELOG.md
|
@ -1,5 +1,21 @@
|
||||||
# Matemat Changelog
|
# Matemat Changelog
|
||||||
|
|
||||||
|
<!-- BEGIN RELEASE v0.3.15 -->
|
||||||
|
## Version 0.3.15
|
||||||
|
|
||||||
|
Websocket-based EAN code handling
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
<!-- BEGIN CHANGES 0.3.15 -->
|
||||||
|
- breaking: remove the config option to automatically close tabs after ean purchase
|
||||||
|
- fix: improve error handling on database consistency errors (e.g. non-unique ean codes) in the settings
|
||||||
|
- feat: handle ean codes in the already open tab via a websocket connection
|
||||||
|
- feat: populate ean code input field when a barcode is scanned while in the product settings
|
||||||
|
<!-- END CHANGES 0.3.15 -->
|
||||||
|
|
||||||
|
<!-- END RELEASE v0.3.15 -->
|
||||||
|
|
||||||
<!-- BEGIN RELEASE v0.3.14 -->
|
<!-- BEGIN RELEASE v0.3.14 -->
|
||||||
## Version 0.3.14
|
## Version 0.3.14
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
__version__ = '0.3.14'
|
__version__ = '0.3.15'
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class DatabaseConsistencyError(BaseException):
|
class DatabaseConsistencyError(Exception):
|
||||||
|
|
||||||
def __init__(self, msg: Optional[str] = None) -> None:
|
def __init__(self, msg: Optional[str] = None) -> None:
|
||||||
self._msg: Optional[str] = msg
|
self._msg: Optional[str] = msg
|
||||||
|
|
|
@ -13,6 +13,7 @@ from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
|
||||||
from matemat.util.currency_format import parse_chf
|
from matemat.util.currency_format import parse_chf
|
||||||
from matemat.webserver import session, template
|
from matemat.webserver import session, template
|
||||||
from matemat.webserver.config import get_app_config, get_stock_provider
|
from matemat.webserver.config import get_app_config, get_stock_provider
|
||||||
|
from matemat.webserver.template import Notification
|
||||||
|
|
||||||
|
|
||||||
@get('/admin')
|
@get('/admin')
|
||||||
|
@ -207,7 +208,7 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
|
||||||
custom_price = 'custom_price' in args
|
custom_price = 'custom_price' in args
|
||||||
stockable = 'stockable' in args
|
stockable = 'stockable' in args
|
||||||
ean = str(args.ean) or None
|
ean = str(args.ean) or None
|
||||||
# Create the user in the database
|
# Create the product in the database
|
||||||
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable, ean)
|
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable, ean)
|
||||||
# If a new product image was uploaded, process it
|
# If a new product image was uploaded, process it
|
||||||
image = files.image.file.read() if 'image' in files else None
|
image = files.image.file.read() if 'image' in files else None
|
||||||
|
@ -226,7 +227,8 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
|
||||||
image.thumbnail((150, 150), Image.LANCZOS)
|
image.thumbnail((150, 150), Image.LANCZOS)
|
||||||
# Write the image to the file
|
# Write the image to the file
|
||||||
image.save(os.path.join(abspath, f'{newproduct.id}.png'), 'PNG')
|
image.save(os.path.join(abspath, f'{newproduct.id}.png'), 'PNG')
|
||||||
except OSError:
|
except OSError as e:
|
||||||
|
Notification.error(str(e), decay=True)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
# If no image was uploaded and a default avatar is set, copy it to the product's avatar path
|
# If no image was uploaded and a default avatar is set, copy it to the product's avatar path
|
||||||
|
@ -282,8 +284,10 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
|
||||||
image.thumbnail((150, 150), Image.LANCZOS)
|
image.thumbnail((150, 150), Image.LANCZOS)
|
||||||
# Write the image to the file
|
# Write the image to the file
|
||||||
image.save(os.path.join(abspath, f'default.png'), 'PNG')
|
image.save(os.path.join(abspath, f'default.png'), 'PNG')
|
||||||
except OSError:
|
except OSError as e:
|
||||||
|
Notification.error(str(e), decay=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
except UnicodeDecodeError:
|
except Exception as e:
|
||||||
raise ValueError('an argument not a string')
|
Notification.error(str(e), decay=True)
|
||||||
|
return
|
||||||
|
|
|
@ -26,7 +26,6 @@ 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:
|
||||||
|
@ -38,7 +37,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}&closetab={closetab}')
|
redirect(f'/logout?lastaction=buy&lastproduct={pid}&lastprice={price}')
|
||||||
# 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}&closetab={closetab}')
|
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}')
|
||||||
redirect('/')
|
redirect('/')
|
||||||
|
|
|
@ -20,7 +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()
|
||||||
closetab = int(str(request.params.closetab) or 0)
|
|
||||||
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:
|
if request.params.lastaction == 'deposit' and lastprice:
|
||||||
Notification.success(f'Deposited {format_chf(lastprice)}', decay=True)
|
Notification.success(f'Deposited {format_chf(lastprice)}', decay=True)
|
||||||
|
@ -28,6 +27,7 @@ def main_page():
|
||||||
lastproduct = db.get_product(request.params.lastproduct)
|
lastproduct = db.get_product(request.params.lastproduct)
|
||||||
Notification.success(f'Purchased {lastproduct.name} for {format_chf(lastprice)}', decay=True)
|
Notification.success(f'Purchased {lastproduct.name} for {format_chf(lastprice)}', decay=True)
|
||||||
buyproduct = None
|
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)
|
||||||
|
@ -35,6 +35,7 @@ def main_page():
|
||||||
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
|
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
Notification.error(f'EAN code {request.params.ean} is not associated with any product.', decay=True)
|
Notification.error(f'EAN code {request.params.ean} is not associated with any product.', decay=True)
|
||||||
|
|
||||||
# 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
|
||||||
|
@ -42,10 +43,7 @@ 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:
|
||||||
url = f'/buy?pid={buyproduct.id}'
|
redirect(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)
|
||||||
|
@ -62,4 +60,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'),
|
||||||
buyproduct=buyproduct, closetab=closetab)
|
buyproduct=buyproduct)
|
||||||
|
|
|
@ -51,10 +51,7 @@ def touchkey_page():
|
||||||
session.put(session_id, 'authentication_level', 1)
|
session.put(session_id, 'authentication_level', 1)
|
||||||
if request.params.buypid:
|
if request.params.buypid:
|
||||||
buypid = str(request.params.buypid)
|
buypid = str(request.params.buypid)
|
||||||
url = f'/buy?pid={buypid}'
|
redirect(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 to the main page, showing the product list
|
||||||
redirect('/')
|
redirect('/')
|
||||||
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
|
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
|
||||||
|
|
|
@ -2,10 +2,15 @@ from typing import Any, Dict
|
||||||
|
|
||||||
import os.path
|
import os.path
|
||||||
import jinja2
|
import jinja2
|
||||||
|
import netaddr
|
||||||
|
|
||||||
|
from bottle import request
|
||||||
|
|
||||||
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
|
from matemat.webserver.template import Notification
|
||||||
|
from matemat.webserver.config import get_app_config
|
||||||
|
|
||||||
|
|
||||||
__jinja_env: jinja2.Environment = None
|
__jinja_env: jinja2.Environment = None
|
||||||
|
|
||||||
|
@ -22,9 +27,16 @@ def init(config: Dict[str, Any]) -> None:
|
||||||
|
|
||||||
def render(name: str, **kwargs):
|
def render(name: str, **kwargs):
|
||||||
global __jinja_env
|
global __jinja_env
|
||||||
|
config = get_app_config()
|
||||||
template: jinja2.Template = __jinja_env.get_template(name)
|
template: jinja2.Template = __jinja_env.get_template(name)
|
||||||
|
wsacl = netaddr.IPSet([addr.strip() for addr in config.get('EanWebsocketAcl', '').split(',')])
|
||||||
|
if config.get('EanWebsocketUrl', '') and request.remote_addr in wsacl:
|
||||||
|
eanwebsocket = config.get('EanWebsocketUrl')
|
||||||
|
else:
|
||||||
|
eanwebsocket = None
|
||||||
return template.render(
|
return template.render(
|
||||||
__version__=__version__,
|
__version__=__version__,
|
||||||
notifications=Notification.render(),
|
notifications=Notification.render(),
|
||||||
|
eanwebsocket=eanwebsocket,
|
||||||
**kwargs
|
**kwargs
|
||||||
).encode('utf-8')
|
).encode('utf-8')
|
||||||
|
|
|
@ -38,10 +38,12 @@ InstanceName=Matemat
|
||||||
#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.
|
# Open a websocket connection on which to listen for scanned barcodes.
|
||||||
# This only works in Firefox with dom.allow_scripts_to_close_windows=true
|
# Can be restricted so that e.g. the connection is only attempted when the client is localhost.
|
||||||
#
|
#
|
||||||
#CloseTabAfterEANPurchase=1
|
#EanWebsocketUrl=ws://localhost:47808/ws
|
||||||
|
#EanWebsocketAcl=::1,::ffff:127.0.0.0/104,127.0.0.0/8
|
||||||
|
|
||||||
|
|
||||||
# Add static HTTP headers in this section
|
# Add static HTTP headers in this section
|
||||||
# [HttpHeaders]
|
# [HttpHeaders]
|
||||||
|
|
|
@ -23,3 +23,12 @@
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block eanwebsocket %}
|
||||||
|
function (e) {
|
||||||
|
let eaninput = document.getElementById("admin-newproduct-ean");
|
||||||
|
eaninput.value = e.data;
|
||||||
|
eaninput.select();
|
||||||
|
eaninput.scrollIntoView();
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -17,8 +17,8 @@
|
||||||
<label for="admin-newuser-isadmin">Admin: </label>
|
<label for="admin-newuser-isadmin">Admin: </label>
|
||||||
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/>
|
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/>
|
||||||
|
|
||||||
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
|
<label for="admin-newuser-logout-after-purchase">Logout after purchase: </label>
|
||||||
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" /><br/>
|
<input id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase" /><br/>
|
||||||
|
|
||||||
<input type="submit" value="Create User" />
|
<input type="submit" value="Create User" />
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -56,5 +56,15 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
{% if eanwebsocket %}
|
||||||
|
<script>
|
||||||
|
function connect() {
|
||||||
|
let socket = new WebSocket("{{ eanwebsocket }}");
|
||||||
|
socket.onclose = () => { setTimeout(connect, 1000); };
|
||||||
|
socket.onmessage = {% block eanwebsocket %}() => {}{% endblock %};
|
||||||
|
}
|
||||||
|
window.addEventListener("load", () => { connect(); });
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -52,3 +52,12 @@
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block eanwebsocket %}
|
||||||
|
function (e) {
|
||||||
|
let eaninput = document.getElementById("modproduct-ean");
|
||||||
|
eaninput.value = e.data;
|
||||||
|
eaninput.select();
|
||||||
|
eaninput.scrollIntoView();
|
||||||
|
}
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -53,7 +53,7 @@
|
||||||
{% if product.custom_price %}
|
{% if product.custom_price %}
|
||||||
<a onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
|
<a onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/buy?pid={{ product.id }}">
|
<a {% if product.ean %}id="a-buy-ean{{ product.ean }}"{% endif %} href="/buy?pid={{ product.id }}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="thumblist-title">{{ product.name }}</span>
|
<span class="thumblist-title">{{ product.name }}</span>
|
||||||
{% if product.custom_price %}
|
{% if product.custom_price %}
|
||||||
|
@ -81,9 +81,15 @@
|
||||||
|
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
{% if closetab | default(0) %}
|
{% endblock %}
|
||||||
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
|
|
||||||
<script>setTimeout(window.close, 3000);</script>
|
{% block eanwebsocket %}
|
||||||
{% endif %}
|
function (e) {
|
||||||
|
let eaninput = document.getElementById("a-buy-ean" + e.data);
|
||||||
|
if (eaninput === null) {
|
||||||
|
document.location = "?ean=" + e.data;
|
||||||
|
} else {
|
||||||
|
eaninput.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -33,9 +33,10 @@
|
||||||
|
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
{% if closetab | default(0) %}
|
{% endblock %}
|
||||||
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
|
|
||||||
<script>setTimeout(window.close, 3000);</script>
|
{% block eanwebsocket %}
|
||||||
{% endif %}
|
function (e) {
|
||||||
|
document.location = "?ean=" + e.data;
|
||||||
|
}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Loading…
Reference in a new issue