feat: allow multiple barcodes to be associated with a product

chore: consistent renaming from ean to barcode
This commit is contained in:
s3lph 2024-12-09 22:07:54 +01:00
parent a7150e123e
commit 583107ac63
Signed by: s3lph
GPG key ID: 0AA29A52FB33CFB5
20 changed files with 294 additions and 124 deletions

View file

@ -5,7 +5,7 @@ import crypt
from hmac import compare_digest from hmac import compare_digest
from datetime import datetime, UTC from datetime import datetime, UTC
from matemat.db.primitives import User, Token, Product, ReceiptPreference, Receipt, \ from matemat.db.primitives import User, Token, Product, Barcode, ReceiptPreference, Receipt, \
Transaction, Consumption, Deposit, Modification Transaction, Consumption, Deposit, Modification
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.db import DatabaseWrapper from matemat.db import DatabaseWrapper
@ -440,12 +440,12 @@ class MatematDatabase(object):
products: List[Product] = [] products: List[Product] = []
with self.db.transaction(exclusive=False) as c: with self.db.transaction(exclusive=False) as c:
for row in c.execute(''' for row in c.execute('''
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable
FROM products ORDER BY name FROM products ORDER BY name
'''): '''):
product_id, name, price_member, price_external, custom_price, stock, stockable, ean = row product_id, name, price_member, price_external, custom_price, stock, stockable = row
products.append( products.append(
Product(product_id, name, price_member, price_external, custom_price, stockable, stock, ean)) Product(product_id, name, price_member, price_external, custom_price, stockable, stock))
return products return products
def get_product(self, pid: int) -> Product: def get_product(self, pid: int) -> Product:
@ -456,36 +456,38 @@ class MatematDatabase(object):
with self.db.transaction(exclusive=False) as c: with self.db.transaction(exclusive=False) as c:
# Fetch all values to construct the product # Fetch all values to construct the product
c.execute(''' c.execute('''
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable
FROM products FROM products
WHERE product_id = :product_id''', {'product_id': pid}) WHERE product_id = :product_id''', {'product_id': pid})
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise ValueError(f'No product with product ID {pid} exists.') raise ValueError(f'No product with product ID {pid} exists.')
# Unpack the row and construct the product # Unpack the row and construct the product
product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row product_id, name, price_member, price_non_member, custom_price, stock, stockable = row
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean) return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock)
def get_product_by_ean(self, ean: str) -> Product: def get_product_by_barcode(self, barcode: str) -> Product:
""" """
Return a product identified by its EAN code. Return a product identified by its barcode.
:param ean: The product's EAN code. :param barcode: The product's barcode code.
""" """
with self.db.transaction(exclusive=False) as c: with self.db.transaction(exclusive=False) as c:
# Fetch all values to construct the product # Fetch all values to construct the product
c.execute(''' c.execute('''
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean SELECT p.product_id, p.name, price_member, price_non_member, custom_price, stock, stockable
FROM products FROM products AS p
WHERE ean = :ean''', {'ean': ean}) JOIN barcodes AS b
ON b.product_id = p.product_id
WHERE b.barcode = :barcode''', {'barcode': barcode})
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise ValueError(f'No product with EAN code {ean} exists.') raise ValueError(f'No product with barcode {barcode} exists.')
# Unpack the row and construct the product # Unpack the row and construct the product
product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row product_id, name, price_member, price_non_member, custom_price, stock, stockable = row
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean) return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock)
def create_product(self, name: str, price_member: int, price_non_member: int, custom_price: def create_product(self, name: str, price_member: int, price_non_member: int, custom_price:
bool, stockable: bool, ean: str) -> Product: bool, stockable: bool, barcode: str) -> Product:
""" """
Creates a new product. Creates a new product.
:param name: Name of the product. :param name: Name of the product.
@ -493,6 +495,7 @@ class MatematDatabase(object):
:param price_non_member: Price of the product for non-members. :param price_non_member: Price of the product for non-members.
:param custom_price: Whether the price is customizable. If yes, the price values are understood as minimum. :param custom_price: Whether the price is customizable. If yes, the price values are understood as minimum.
:param stockable: True if the product should be stockable, false otherwise. :param stockable: True if the product should be stockable, false otherwise.
:param barcode: If provided, a barcode this product is identified by.
:return: A Product object representing the created product. :return: A Product object representing the created product.
:raises ValueError: If a product with the same name already exists. :raises ValueError: If a product with the same name already exists.
""" """
@ -502,19 +505,27 @@ class MatematDatabase(object):
if c.fetchone() is not None: if c.fetchone() is not None:
raise ValueError(f'A product with the name \'{name}\' already exists.') raise ValueError(f'A product with the name \'{name}\' already exists.')
c.execute(''' c.execute('''
INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable, ean) INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable)
VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :stockable, :ean) VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :stockable)
''', { ''', {
'name': name, 'name': name,
'price_member': price_member, 'price_member': price_member,
'price_non_member': price_non_member, 'price_non_member': price_non_member,
'custom_price': custom_price, 'custom_price': custom_price,
'stockable': stockable, 'stockable': stockable,
'ean': ean,
}) })
c.execute('SELECT last_insert_rowid()') c.execute('SELECT last_insert_rowid()')
product_id = int(c.fetchone()[0]) product_id = int(c.fetchone()[0])
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, 0, ean) if barcode:
c.execute('''
INSERT INTO barcodes (barcode, product_id, name)
VALUES (:barcode, :product_id, :name)
''', {
'barcode': barcode,
'product_id': product_id,
'name': name,
})
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, 0)
def change_product(self, product: Product, **kwargs) -> None: def change_product(self, product: Product, **kwargs) -> None:
""" """
@ -533,7 +544,6 @@ class MatematDatabase(object):
custom_price: int = kwargs['custom_price'] if 'custom_price' in kwargs else product.custom_price custom_price: int = kwargs['custom_price'] if 'custom_price' in kwargs else product.custom_price
stock: int = kwargs['stock'] if 'stock' in kwargs else product.stock stock: int = kwargs['stock'] if 'stock' in kwargs else product.stock
stockable: bool = kwargs['stockable'] if 'stockable' in kwargs else product.stockable stockable: bool = kwargs['stockable'] if 'stockable' in kwargs else product.stockable
ean: str = kwargs['ean'] if 'ean' in kwargs else product.ean
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute(''' c.execute('''
UPDATE products UPDATE products
@ -544,7 +554,6 @@ class MatematDatabase(object):
custom_price = :custom_price, custom_price = :custom_price,
stock = :stock, stock = :stock,
stockable = :stockable, stockable = :stockable,
ean = :ean
WHERE product_id = :product_id WHERE product_id = :product_id
''', { ''', {
'product_id': product.id, 'product_id': product.id,
@ -554,7 +563,6 @@ class MatematDatabase(object):
'custom_price': custom_price, 'custom_price': custom_price,
'stock': stock, 'stock': stock,
'stockable': stockable, 'stockable': stockable,
'ean': ean
}) })
affected = c.execute('SELECT changes()').fetchone()[0] affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1: if affected != 1:
@ -567,7 +575,6 @@ class MatematDatabase(object):
product.custom_price = custom_price product.custom_price = custom_price
product.stock = stock product.stock = stock
product.stockable = stockable product.stockable = stockable
product.ean = ean
def delete_product(self, product: Product) -> None: def delete_product(self, product: Product) -> None:
""" """
@ -585,6 +592,61 @@ class MatematDatabase(object):
raise DatabaseConsistencyError( raise DatabaseConsistencyError(
f'delete_product should affect 1 products row, but affected {affected}') f'delete_product should affect 1 products row, but affected {affected}')
def list_barcodes(self, pid: Optional[int] = None) -> List[Barcode]:
barcodes: List[Barcode] = []
with self.db.transaction(exclusive=False) as c:
if pid is not None:
rows = c.execute('''
SELECT barcode_id, barcode, product_id, name
FROM barcodes
WHERE product_id = :product_id
''', {'product_id': pid})
else:
rows = c.execute('''
SELECT barcode_id, barcode, product_id, name
FROM barcodes
''')
for row in rows:
barcode_id, barcode, product_id, name = row
barcodes.append(
Barcode(barcode_id, barcode, product_id, name))
return barcodes
def get_barcode(self, bcid: int) -> Barcode:
with self.db.transaction(exclusive=False) as c:
c.execute('''
SELECT barcode_id, barcode, product_id, name
FROM barcodes
WHERE barcode_id = :barcode_id
''', {'barcode_id': bcid})
barcode_id, barcode, product_id, name = c.fetchone()
return Barcode(barcode_id, barcode, product_id, name)
def add_barcode(self, product: Product, barcode: str, name: Optional[str]):
with self.db.transaction() as c:
c.execute('''
INSERT INTO barcodes (barcode, product_id, name)
VALUES (:barcode, :product_id, :name)
''', {
'barcode': barcode,
'product_id': product.id,
'name': name
})
c.execute('SELECT last_insert_rowid()')
bcid = int(c.fetchone()[0])
return Barcode(bcid, barcode, product.id, name)
def delete_barcode(self, barcode: Barcode):
with self.db.transaction() as c:
c.execute('''
DELETE FROM barcodes
WHERE barcode_id = :barcode_id
''', {'barcode_id': barcode.id})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(
f'delete_barcode should affect 1 token row, but affected {affected}')
def increment_consumption(self, user: User, product: Product, custom_price: int = None) -> None: def increment_consumption(self, user: User, product: Product, custom_price: int = None) -> None:
""" """
Decrement the user's balance by the price of the product and create an entry in the statistics table. Decrement the user's balance by the price of the product and create an entry in the statistics table.

View file

@ -374,3 +374,27 @@ def migrate_schema_10(c: sqlite3.Cursor):
c.execute(''' c.execute('''
DROP TABLE users_old DROP TABLE users_old
''') ''')
def migrate_schema_11(c: sqlite3.Cursor):
c.execute('''
CREATE TABLE barcodes (
barcode_id INTEGER PRIMARY KEY,
barcode TEXT UNIQUE NOT NULL,
product_id INTEGER NOT NULL,
name TEXT DEFAULT NULL,
FOREIGN KEY (product_id) REFERENCES products(product_id)
ON DELETE CASCADE ON UPDATE CASCADE
)
''')
c.execute('''
INSERT INTO barcodes (barcode, product_id, name)
SELECT ean, product_id, name FROM products
WHERE ean IS NOT NULL
''')
c.execute('''
DROP INDEX IF EXISTS _matemat_products_ean_unique
''')
c.execute('''
ALTER TABLE products DROP COLUMN ean
''')

View file

@ -0,0 +1,34 @@
from typing import Optional
class Barcode:
"""
Representation of a product barcode associated with a product.
:param _id: The barcode ID in the database.
:param barcode: The barcode strig.
:param product_id: The ID of the product this barcode belongs to.
:param name: The display name of the token:
"""
def __init__(self,
_id: int,
barcode: str,
product_id: int,
name: str) -> None:
self.id: int = _id
self.barcode: str = barcode
self.product_id: str = product_id
self.name = name
def __eq__(self, other) -> bool:
if not isinstance(other, Barcode):
return False
return self.id == other.id and \
self.barcode == other.barcode and \
self.product_id == other.product_id and \
self.name == other.name
def __hash__(self) -> int:
return hash((self.id, self.barcode, self.product_id, self.name))

View file

@ -11,12 +11,11 @@ class Product:
:param custom_price: If true, the user can choose the price to pay, but at least the regular price. :param custom_price: If true, the user can choose the price to pay, but at least the regular price.
:param stock: The number of items of this product currently in stock, or None if not stockable. :param stock: The number of items of this product currently in stock, or None if not stockable.
:param stockable: Whether this product is stockable. :param stockable: Whether this product is stockable.
:param ean: The product's EAN code. May be None.
""" """
def __init__(self, _id: int, name: str, def __init__(self, _id: int, name: str,
price_member: int, price_non_member: int, custom_price: bool, price_member: int, price_non_member: int, custom_price: bool,
stockable: bool, stock: int, ean: str) -> None: stockable: bool, stock: int) -> None:
self.id: int = _id self.id: int = _id
self.name: str = name self.name: str = name
self.price_member: int = price_member self.price_member: int = price_member
@ -24,7 +23,6 @@ class Product:
self.custom_price: bool = custom_price self.custom_price: bool = custom_price
self.stock: int = stock self.stock: int = stock
self.stockable: bool = stockable self.stockable: bool = stockable
self.ean: str = ean
def __eq__(self, other) -> bool: def __eq__(self, other) -> bool:
if not isinstance(other, Product): if not isinstance(other, Product):
@ -35,9 +33,8 @@ class Product:
self.price_non_member == other.price_non_member and \ self.price_non_member == other.price_non_member and \
self.custom_price == other.custom_price and \ self.custom_price == other.custom_price and \
self.stock == other.stock and \ self.stock == other.stock and \
self.stockable == other.stockable and \ self.stockable == other.stockable
self.ean == other.ean
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((self.id, self.name, self.price_member, self.price_non_member, self.custom_price, return hash((self.id, self.name, self.price_member, self.price_non_member, self.custom_price,
self.stock, self.stockable, self.ean)) self.stock, self.stockable))

View file

@ -5,6 +5,7 @@ This package provides the 'primitive types' the Matemat software deals with - na
from .User import User from .User import User
from .Token import Token from .Token import Token
from .Product import Product from .Product import Product
from .Barcode import Barcode
from .ReceiptPreference import ReceiptPreference from .ReceiptPreference import ReceiptPreference
from .Transaction import Transaction, Consumption, Deposit, Modification from .Transaction import Transaction, Consumption, Deposit, Modification
from .Receipt import Receipt from .Receipt import Receipt

View file

@ -43,12 +43,12 @@ def admin():
# Fetch all existing users and products from the database # Fetch all existing users and products from the database
users = db.list_users() users = db.list_users()
tokens = db.list_tokens(uid)
products = db.list_products() products = db.list_products()
barcodes = db.list_barcodes()
# Render the "Admin" page # Render the "Admin" page
now = str(int(datetime.now(UTC).timestamp())) now = str(int(datetime.now(UTC).timestamp()))
return template.render('admin.html', return template.render('admin.html',
authuser=user, authlevel=authlevel, tokens=tokens, users=users, products=products, authuser=user, authlevel=authlevel, users=users, products=products, barcodes=barcodes,
receipt_preference_class=ReceiptPreference, now=now, receipt_preference_class=ReceiptPreference, now=now,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts']) setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
@ -107,9 +107,9 @@ def handle_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
price_non_member = parse_chf(str(args.pricenonmember)) price_non_member = parse_chf(str(args.pricenonmember))
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 barcode = str(args.barcode) or None
# Create the product 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, barcode)
# 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
if image is not None and len(image) > 0: if image is not None and len(image) > 0:

View file

@ -23,16 +23,13 @@ def main_page():
products = db.list_products() products = db.list_products()
buyproduct = None buyproduct = None
if request.params.ean: if request.params.barcode:
try: try:
buyproduct = db.get_product_by_ean(request.params.ean) buyproduct = db.get_product_by_barcode(request.params.barcode)
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. ' +
'Click <a class="alert-link" href="/">here</a> to abort.')
except ValueError: except ValueError:
if not session.has(session_id, 'authenticated_user'): if not session.has(session_id, 'authenticated_user'):
try: try:
user, token = db.tokenlogin(str(request.params.ean)) user, token = db.tokenlogin(str(request.params.barcode))
# Set the user ID session variable # Set the user ID session variable
session.put(session_id, 'authenticated_user', user.id) session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = token, 2 = touchkey, 3 = password) # Set the authlevel session variable (0 = none, 1 = token, 2 = touchkey, 3 = password)
@ -41,7 +38,7 @@ def main_page():
except AuthenticationError: except AuthenticationError:
# Redirect to main page on token login error # Redirect to main page on token login error
pass pass
Notification.error(f'EAN code {request.params.ean} is not associated with any product.', decay=True) Notification.error(f'Barcode {request.params.barcode} is not associated with any product.', decay=True)
redirect('/') redirect('/')
# Check whether a user is logged in # Check whether a user is logged in
@ -49,7 +46,7 @@ def main_page():
# 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
uid: int = session.get(session_id, 'authenticated_user') uid: int = session.get(session_id, 'authenticated_user')
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 barcode was scanned, directly trigger the purchase
if buyproduct: if buyproduct:
redirect(f'/buy?pid={buyproduct.id}') redirect(f'/buy?pid={buyproduct.id}')
# 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)
@ -65,6 +62,10 @@ def main_page():
# 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
if not db.has_admin_users(): if not db.has_admin_users():
redirect('/userbootstrap') redirect('/userbootstrap')
if buyproduct:
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. ' +
'Click <a class="alert-link" href="/">here</a> to abort.')
# If no user is logged in, fetch the list of users and render the userlist template # If no user is logged in, fetch the list of users and render the userlist template
users = db.list_users(with_touchkey=True) users = db.list_users(with_touchkey=True)
return template.render('userlist.html', return template.render('userlist.html',

View file

@ -56,9 +56,10 @@ def modproduct():
redirect('/admin') redirect('/admin')
# Render the "Modify Product" page # Render the "Modify Product" page
barcodes = db.list_barcodes(modproduct_id)
now = str(int(datetime.now(UTC).timestamp())) now = str(int(datetime.now(UTC).timestamp()))
return template.render('modproduct.html', return template.render('modproduct.html',
authuser=authuser, product=product, authlevel=authlevel, authuser=authuser, product=product, authlevel=authlevel, barcodes=barcodes,
setupname=config['InstanceName'], now=now) setupname=config['InstanceName'], now=now)
@ -85,6 +86,30 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
except FileNotFoundError: except FileNotFoundError:
pass pass
elif change == 'addbarcode':
if 'barcode' not in args:
return
barcode = str(args.barcode)
name = None if 'name' not in args or len(args.name) == 0 else str(args.name)
try:
bcobj = db.add_barcode(product, barcode, name)
Notification.success(f'Barcode {name} added successfully', decay=True)
except DatabaseConsistencyError:
Notification.error(f'Barcode {barcode} already exists', decay=True)
elif change == 'delbarcode':
try:
bcid = id(str(request.params.barcode))
barcode = db.get_barcode(bcid)
except Exception as e:
Notification.error('Barcode not found', decay=True)
return
try:
db.delete_barcode(token)
except DatabaseConsistencyError:
Notification.error(f'Failed to delete barcode {barcode.name}', decay=True)
Notification.success(f'Barcode {barcode.name} removed', decay=True)
# Admin requested update of the product details # Admin requested update of the product details
elif change == 'update': elif change == 'update':
# Only write a change if all properties of the product are present in the request arguments # Only write a change if all properties of the product are present in the request arguments
@ -98,12 +123,11 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
custom_price = 'custom_price' in args custom_price = 'custom_price' in args
stock = int(str(args.stock)) stock = int(str(args.stock))
stockable = 'stockable' in args stockable = 'stockable' in args
ean = str(args.ean) or None
# Attempt to write the changes to the database # Attempt to write the changes to the database
try: try:
db.change_product(product, db.change_product(product,
name=name, price_member=price_member, price_non_member=price_non_member, name=name, price_member=price_member, price_non_member=price_non_member,
custom_price=custom_price, stock=stock, stockable=stockable, ean=ean) custom_price=custom_price, stock=stock, stockable=stockable)
stock_provider = get_stock_provider() stock_provider = get_stock_provider()
if stock_provider.needs_update() and product.stockable: if stock_provider.needs_update() and product.stockable:
stock_provider.set_stock(product, stock) stock_provider.set_stock(product, stock)

View file

@ -29,14 +29,14 @@ def render(name: str, **kwargs):
global __jinja_env global __jinja_env
config = get_app_config() 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(',')]) wsacl = netaddr.IPSet([addr.strip() for addr in config.get('BarcodeWebsocketAcl', '').split(',')])
if config.get('EanWebsocketUrl', '') and request.remote_addr in wsacl: if config.get('BarcodeWebsocketUrl', '') and request.remote_addr in wsacl:
eanwebsocket = config.get('EanWebsocketUrl') bcwebsocket = config.get('BarcodeWebsocketUrl')
else: else:
eanwebsocket = None bcwebsocket = None
return template.render( return template.render(
__version__=__version__, __version__=__version__,
notifications=Notification.render(), notifications=Notification.render(),
eanwebsocket=eanwebsocket, barcodewebsocket=bcwebsocket,
**kwargs **kwargs
).encode('utf-8') ).encode('utf-8')

View file

@ -41,8 +41,8 @@ InstanceName=Matemat
# Open a websocket connection on which to listen for scanned barcodes. # Open a websocket connection on which to listen for scanned barcodes.
# Can be restricted so that e.g. the connection is only attempted when the client is localhost. # Can be restricted so that e.g. the connection is only attempted when the client is localhost.
# #
#EanWebsocketUrl=ws://localhost:47808/ws #BarcodeWebsocketUrl=ws://localhost:47808/ws
#EanWebsocketAcl=::1,::ffff:127.0.0.0/104,127.0.0.0/8 #BarcodeWebsocketAcl=::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

View file

@ -68,7 +68,7 @@
<table class="table table-striped"> <table class="table table-striped">
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>EAN code</th> <th>Barcodes</th>
<th>Member price</th> <th>Member price</th>
<th>Non-member price</th> <th>Non-member price</th>
<th>Custom price</th> <th>Custom price</th>
@ -78,7 +78,7 @@
</tr> </tr>
<tr> <tr>
<td><input class="form-control" id="admin-newproduct-name" type="text" name="name" placeholder="New product name"></td> <td><input class="form-control" id="admin-newproduct-name" type="text" name="name" placeholder="New product name"></td>
<td><input class="form-control" id="admin-newproduct-ean" type="text" name="ean" placeholder="Scan to insert EAN"></td> <td><input class="form-control" id="admin-newproduct-barcode" type="text" name="barcode" placeholder="Scan barcode to insert here"></td>
<td> <td>
<div class="input-group mb-3"> <div class="input-group mb-3">
<span class="input-group-text">CHF</span> <span class="input-group-text">CHF</span>
@ -99,7 +99,15 @@
{% for product in products %} {% for product in products %}
<tr> <tr>
<td>{{ product.name }}</td> <td>{{ product.name }}</td>
<td>{{ product.ean or '' }}</td> <td>
{% set bcs = barcodes | selectattr('product_id', 'eq', product.id) | list %}
{% if bcs | length > 0 %}
{{ bcs[0].barcode }}
{% if bcs | length > 1 %}
<span class="badge bg-secondary">+{{ bcs | length - 1 }}</span>
{% endif %}
{% endif %}
</td>
<td>{{ product.price_member | chf }}</td> <td>{{ product.price_member | chf }}</td>
<td>{{ product.price_non_member | chf }}</td> <td>{{ product.price_non_member | chf }}</td>
<td>{{ '✓' if product.custom_price else '✗' }}</td> <td>{{ '✓' if product.custom_price else '✗' }}</td>
@ -160,9 +168,9 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
let eaninput = document.getElementById("admin-newproduct-ean"); let bcinput = document.getElementById("admin-newproduct-barcode");
eaninput.value = e.data; bcinput.value = e.data;
eaninput.select(); bcinput.select();
eaninput.scrollIntoView(); bcinput.scrollIntoView();
{% endblock %} {% endblock %}

View file

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

View file

@ -24,7 +24,7 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
document.location = "/?ean=" + e.data; document.location = "/?barcode=" + e.data;
{% endblock %} {% endblock %}

View file

@ -2,68 +2,90 @@
{% block main %} {% block main %}
<section id="modproduct"> <section id="modproduct">
<h1>Modify {{ product.name }}</h1> <h1>Modify {{ product.name }}</h1>
<form id="modproduct-form" method="post" action="/modproduct?change=update" enctype="multipart/form-data" accept-charset="UTF-8"> <form id="modproduct-form" method="post" action="/modproduct?change=update" enctype="multipart/form-data" accept-charset="UTF-8">
<label class="form-label" for="modproduct-name">Name: </label> <label class="form-label" for="modproduct-name">Name: </label>
<input class="form-control" id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/> <input class="form-control" id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/>
<label class="form-label" for="modproduct-ean">EAN code: </label> <label class="form-label" for="modproduct-price-member">Member price: </label>
<input class="form-control" id="modproduct-ean" type="text" name="ean" value="{{ product.ean or '' }}" /><br/> <div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" />
</div>
<label class="form-label" for="modproduct-price-member">Member price: </label> <label class="form-label" for="modproduct-price-non-member">Non-member price: </label>
<div class="input-group mb-3"> <div class="input-group mb-3">
<span class="input-group-text">CHF</span> <span class="input-group-text">CHF</span>
<input class="form-control" id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" /> <input class="form-control" id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" />
</div> </div>
<label class="form-label" for="modproduct-price-non-member">Non-member price: </label>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" />
</div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" id="modproduct-custom-price" type="checkbox" name="custom_price" {% if product.custom_price %} checked="checked" {% endif %} /> <input class="form-check-input" id="modproduct-custom-price" type="checkbox" name="custom_price" {% if product.custom_price %} checked="checked" {% endif %} />
<label class="form-check-label" for="modproduct-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> <label class="form-check-label" for="modproduct-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>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" id="modproduct-stockable" type="checkbox" name="stockable" {% if product.stockable %} checked="checked" {% endif %} /> <input class="form-check-input" id="modproduct-stockable" type="checkbox" name="stockable" {% if product.stockable %} checked="checked" {% endif %} />
<label class="form-check-label" for="modproduct-stockable">Stockable</label> <label class="form-check-label" for="modproduct-stockable">Stockable</label>
</div> </div>
<label class="form-label" for="modproduct-balance">Stock: </label> <label class="form-label" for="modproduct-balance">Stock: </label>
<input class="form-control" id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/> <input class="form-control" id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
<label class="form-label" for="modproduct-image"> <label class="form-label" for="modproduct-image">
<img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Image of {{ product.name }}" /> <img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Image of {{ product.name }}" />
</label><br/> </label><br/>
<input class="form-control" id="modproduct-image" type="file" name="image" accept="image/*" /><br/> <input class="form-control" id="modproduct-image" type="file" name="image" accept="image/*" /><br/>
<input id="modproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/> <input id="modproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<input class="btn btn-primary" type="submit" value="Save changes"> <input class="btn btn-primary" type="submit" value="Save changes">
</form>
<h2>Barcodes</h2>
<form id="modproduct-barcode-form" method="post" action="/modproduct?change=addbarcode" accept-charset="UTF-8">
<input id="modproduct-barcode-productid" type="hidden" name="productid" value="{{ product.id }}" />
<table class="table table-striped">
<tr>
<th>Barcode</th>
<th>Name</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="modproduct-barcode-barcode" type="text" name="barcode" value="" placeholder="Scan barcode to insert here"></td>
<td><input class="form-control" id="modproduct-barcode-name" type="text" name="name" value="" placeholder="Name for this barcode"></td>
<td><input class="btn btn-success" type="submit" value="Add barcode"></td>
</tr>
{% for barcode in barcodes %}
<tr>
<td>{{ barcode.barcode }}</td>
<td>{{ barcode.name }}</td>
<td><a class="btn btn-danger" href="/modproduct?change=delbarcode&barcode={{ barcode.id }}">Delete</a></td>
</tr>
{% endfor %}
</table>
</form> </form>
<h2>Delete Product</h2> <h2>Delete Product</h2>
<form id="modproduct-delproduct-form" method="post" action="/modproduct?change=del" accept-charset="UTF-8"> <form id="modproduct-delproduct-form" method="post" action="/modproduct?change=del" accept-charset="UTF-8">
<input id="modproduct-delproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/> <input id="modproduct-delproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<input class="btn btn-danger" type="submit" value="Delete product {{ product.name }}" /> <input class="btn btn-danger" type="submit" value="Delete product {{ product.name }}" />
</form> </form>
</section> </section>
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
let eaninput = document.getElementById("modproduct-ean"); let bcinput = document.getElementById("modproduct-barcode-barcode");
eaninput.value = e.data; bcinput.value = e.data;
eaninput.select(); bcinput.select();
eaninput.scrollIntoView(); bcinput.scrollIntoView();
{% endblock %} {% endblock %}

View file

@ -48,7 +48,7 @@
{% if product.custom_price %} {% if product.custom_price %}
<a class="card h-100 text-bg-light" onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');"> <a class="card h-100 text-bg-light" onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
{% else %} {% else %}
<a class="card h-100 text-bg-light" {% if product.ean %}id="a-buy-ean{{ product.ean }}"{% endif %} href="/buy?pid={{ product.id }}"> <a class="card h-100 text-bg-light" href="/buy?pid={{ product.id }}">
{% endif %} {% endif %}
<div class="card-header"> <div class="card-header">
{{ product.name }} {{ product.name }}
@ -82,11 +82,6 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
let eaninput = document.getElementById("a-buy-ean" + e.data); document.location = "?barcode=" + e.data;
if (eaninput === null) {
document.location = "?ean=" + e.data;
} else {
eaninput.click();
}
{% endblock %} {% endblock %}

View file

@ -151,9 +151,11 @@
<section class="tab-pane fade pt-3" id="settings-tokens-tab-pane" role="tabpanel"> <section class="tab-pane fade pt-3" id="settings-tokens-tab-pane" role="tabpanel">
<h2>Tokens</h2> <h2>Tokens</h2>
<strong>Warning:</strong> <div class="alert alert-warning">
Login tokens are a convenience feature that if used may weaken security. <strong>Warning: </strong>
Make sure you only use tokens not easily accessible to other people. Login tokens are a convenience feature that if used may weaken security.
Make sure you only use tokens not easily accessible to other people.
</div>
<form id="settings-newtoken-form" method="post" action="/settings?change=addtoken" accept-charset="UTF-8"> <form id="settings-newtoken-form" method="post" action="/settings?change=addtoken" accept-charset="UTF-8">
<table class="table table-striped"> <table class="table table-striped">
@ -164,7 +166,7 @@
<th>Actions</th> <th>Actions</th>
</tr> </tr>
<tr> <tr>
<td><input class="form-control" id="settings-newtoken-token" type="password" name="token" value="" placeholder="Scan to insert EAN"></td> <td><input class="form-control" id="settings-newtoken-token" type="password" name="token" value="" placeholder="Scan barcode to insert here"></td>
<td><input class="form-control" id="settings-newtoken-name" type="text" name="name" value="" placeholder="New token name"></td> <td><input class="form-control" id="settings-newtoken-name" type="text" name="name" value="" placeholder="New token name"></td>
<td></td> <td></td>
<td><input class="btn btn-success" type="submit" value="Create Token"></td> <td><input class="btn btn-success" type="submit" value="Create Token"></td>
@ -186,7 +188,7 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
let tokeninput = document.getElementById("settings-newtoken-token"); let tokeninput = document.getElementById("settings-newtoken-token");
tokeninput.value = e.data; tokeninput.value = e.data;
tokeninput.select(); tokeninput.select();

View file

@ -40,6 +40,6 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
document.location = "/?ean=" + e.data; document.location = "/?barcode=" + e.data;
{% endblock %} {% endblock %}

View file

@ -33,6 +33,6 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
document.location = "/?ean=" + e.data; document.location = "/?barcode=" + e.data;
{% endblock %} {% endblock %}

View file

@ -26,6 +26,6 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
document.location = "/?ean=" + e.data; document.location = "/?barcode=" + e.data;
{% endblock %} {% endblock %}

View file

@ -22,6 +22,6 @@
{% endblock %} {% endblock %}
{% block eanwebsocket %} {% block barcodewebsocket %}
document.location = "/?ean=" + e.data; document.location = "/?barcode=" + e.data;
{% endblock %} {% endblock %}