From 583107ac63596e7e7a15dba83994f20e879bce17 Mon Sep 17 00:00:00 2001 From: s3lph Date: Mon, 9 Dec 2024 22:07:54 +0100 Subject: [PATCH] feat: allow multiple barcodes to be associated with a product chore: consistent renaming from ean to barcode --- matemat/db/facade.py | 112 ++++++++++++++++++----- matemat/db/migrations.py | 24 +++++ matemat/db/primitives/Barcode.py | 34 +++++++ matemat/db/primitives/Product.py | 9 +- matemat/db/primitives/__init__.py | 1 + matemat/webserver/pagelets/admin.py | 8 +- matemat/webserver/pagelets/main.py | 17 ++-- matemat/webserver/pagelets/modproduct.py | 30 +++++- matemat/webserver/template/template.py | 10 +- package/debian/matemat/etc/matemat.conf | 4 +- templates/admin.html | 24 +++-- templates/base.html | 6 +- templates/login.html | 4 +- templates/modproduct.html | 96 +++++++++++-------- templates/productlist.html | 11 +-- templates/settings.html | 12 ++- templates/signup.html | 4 +- templates/signup_kiosk.html | 4 +- templates/touchkey.html | 4 +- templates/userlist.html | 4 +- 20 files changed, 294 insertions(+), 124 deletions(-) create mode 100644 matemat/db/primitives/Barcode.py diff --git a/matemat/db/facade.py b/matemat/db/facade.py index c8fda1e..882051e 100644 --- a/matemat/db/facade.py +++ b/matemat/db/facade.py @@ -5,7 +5,7 @@ import crypt from hmac import compare_digest 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 from matemat.exceptions import AuthenticationError, DatabaseConsistencyError from matemat.db import DatabaseWrapper @@ -440,12 +440,12 @@ class MatematDatabase(object): products: List[Product] = [] with self.db.transaction(exclusive=False) as c: 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 '''): - 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( - 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 def get_product(self, pid: int) -> Product: @@ -456,36 +456,38 @@ class MatematDatabase(object): with self.db.transaction(exclusive=False) as c: # Fetch all values to construct the product 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 WHERE product_id = :product_id''', {'product_id': pid}) row = c.fetchone() if row is None: raise ValueError(f'No product with product ID {pid} exists.') # Unpack the row and construct the product - product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row - return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean) + 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) - 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. - :param ean: The product's EAN code. + Return a product identified by its barcode. + :param barcode: The product's barcode code. """ with self.db.transaction(exclusive=False) as c: # Fetch all values to construct the product c.execute(''' - SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean - FROM products - WHERE ean = :ean''', {'ean': ean}) + SELECT p.product_id, p.name, price_member, price_non_member, custom_price, stock, stockable + FROM products AS p + JOIN barcodes AS b + ON b.product_id = p.product_id + WHERE b.barcode = :barcode''', {'barcode': barcode}) row = c.fetchone() 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 - product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row - return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean) + 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) 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. :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 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 barcode: If provided, a barcode this product is identified by. :return: A Product object representing the created product. :raises ValueError: If a product with the same name already exists. """ @@ -502,19 +505,27 @@ class MatematDatabase(object): if c.fetchone() is not None: raise ValueError(f'A product with the name \'{name}\' already exists.') c.execute(''' - INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable, ean) - VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :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) ''', { 'name': name, 'price_member': price_member, 'price_non_member': price_non_member, 'custom_price': custom_price, 'stockable': stockable, - 'ean': ean, }) c.execute('SELECT last_insert_rowid()') 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: """ @@ -533,7 +544,6 @@ class MatematDatabase(object): 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 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: c.execute(''' UPDATE products @@ -544,7 +554,6 @@ class MatematDatabase(object): custom_price = :custom_price, stock = :stock, stockable = :stockable, - ean = :ean WHERE product_id = :product_id ''', { 'product_id': product.id, @@ -554,7 +563,6 @@ class MatematDatabase(object): 'custom_price': custom_price, 'stock': stock, 'stockable': stockable, - 'ean': ean }) affected = c.execute('SELECT changes()').fetchone()[0] if affected != 1: @@ -567,7 +575,6 @@ class MatematDatabase(object): product.custom_price = custom_price product.stock = stock product.stockable = stockable - product.ean = ean def delete_product(self, product: Product) -> None: """ @@ -585,6 +592,61 @@ class MatematDatabase(object): raise DatabaseConsistencyError( 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: """ Decrement the user's balance by the price of the product and create an entry in the statistics table. diff --git a/matemat/db/migrations.py b/matemat/db/migrations.py index fd9868e..11643bd 100644 --- a/matemat/db/migrations.py +++ b/matemat/db/migrations.py @@ -374,3 +374,27 @@ def migrate_schema_10(c: sqlite3.Cursor): c.execute(''' 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 + ''') diff --git a/matemat/db/primitives/Barcode.py b/matemat/db/primitives/Barcode.py new file mode 100644 index 0000000..c12f70b --- /dev/null +++ b/matemat/db/primitives/Barcode.py @@ -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)) diff --git a/matemat/db/primitives/Product.py b/matemat/db/primitives/Product.py index 71fb903..5ce119c 100644 --- a/matemat/db/primitives/Product.py +++ b/matemat/db/primitives/Product.py @@ -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 stock: The number of items of this product currently in stock, or None if not 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, 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.name: str = name self.price_member: int = price_member @@ -24,7 +23,6 @@ class Product: self.custom_price: bool = custom_price self.stock: int = stock self.stockable: bool = stockable - self.ean: str = ean def __eq__(self, other) -> bool: if not isinstance(other, Product): @@ -35,9 +33,8 @@ class Product: self.price_non_member == other.price_non_member and \ self.custom_price == other.custom_price and \ self.stock == other.stock and \ - self.stockable == other.stockable and \ - self.ean == other.ean + self.stockable == other.stockable def __hash__(self) -> int: 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)) diff --git a/matemat/db/primitives/__init__.py b/matemat/db/primitives/__init__.py index 3bc677a..b5fe1e4 100644 --- a/matemat/db/primitives/__init__.py +++ b/matemat/db/primitives/__init__.py @@ -5,6 +5,7 @@ This package provides the 'primitive types' the Matemat software deals with - na from .User import User from .Token import Token from .Product import Product +from .Barcode import Barcode from .ReceiptPreference import ReceiptPreference from .Transaction import Transaction, Consumption, Deposit, Modification from .Receipt import Receipt diff --git a/matemat/webserver/pagelets/admin.py b/matemat/webserver/pagelets/admin.py index 10c18fb..cc440ac 100644 --- a/matemat/webserver/pagelets/admin.py +++ b/matemat/webserver/pagelets/admin.py @@ -43,12 +43,12 @@ def admin(): # Fetch all existing users and products from the database users = db.list_users() - tokens = db.list_tokens(uid) products = db.list_products() + barcodes = db.list_barcodes() # Render the "Admin" page now = str(int(datetime.now(UTC).timestamp())) 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, 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)) custom_price = 'custom_price' in args stockable = 'stockable' in args - ean = str(args.ean) or None + barcode = str(args.barcode) or None # 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 image = files.image.file.read() if 'image' in files else None if image is not None and len(image) > 0: diff --git a/matemat/webserver/pagelets/main.py b/matemat/webserver/pagelets/main.py index 897b6a5..8bd4426 100644 --- a/matemat/webserver/pagelets/main.py +++ b/matemat/webserver/pagelets/main.py @@ -23,16 +23,13 @@ def main_page(): products = db.list_products() buyproduct = None - if request.params.ean: + if request.params.barcode: try: - buyproduct = db.get_product_by_ean(request.params.ean) - Notification.success( - f'Login will purchase {buyproduct.name}. ' + - 'Click here to abort.') + buyproduct = db.get_product_by_barcode(request.params.barcode) except ValueError: if not session.has(session_id, 'authenticated_user'): try: - user, token = db.tokenlogin(str(request.params.ean)) + user, token = db.tokenlogin(str(request.params.barcode)) # Set the user ID session variable session.put(session_id, 'authenticated_user', user.id) # Set the authlevel session variable (0 = none, 1 = token, 2 = touchkey, 3 = password) @@ -41,7 +38,7 @@ def main_page(): except AuthenticationError: # Redirect to main page on token login error 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('/') # 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 uid: int = session.get(session_id, 'authenticated_user') 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: redirect(f'/buy?pid={buyproduct.id}') # 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 not db.has_admin_users(): redirect('/userbootstrap') + if buyproduct: + Notification.success( + f'Login will purchase {buyproduct.name}. ' + + 'Click here to abort.') # If no user is logged in, fetch the list of users and render the userlist template users = db.list_users(with_touchkey=True) return template.render('userlist.html', diff --git a/matemat/webserver/pagelets/modproduct.py b/matemat/webserver/pagelets/modproduct.py index c11a6ba..71a261c 100644 --- a/matemat/webserver/pagelets/modproduct.py +++ b/matemat/webserver/pagelets/modproduct.py @@ -56,9 +56,10 @@ def modproduct(): redirect('/admin') # Render the "Modify Product" page + barcodes = db.list_barcodes(modproduct_id) now = str(int(datetime.now(UTC).timestamp())) return template.render('modproduct.html', - authuser=authuser, product=product, authlevel=authlevel, + authuser=authuser, product=product, authlevel=authlevel, barcodes=barcodes, setupname=config['InstanceName'], now=now) @@ -85,6 +86,30 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem except FileNotFoundError: 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 elif change == 'update': # 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 stock = int(str(args.stock)) stockable = 'stockable' in args - ean = str(args.ean) or None # Attempt to write the changes to the database try: db.change_product(product, 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() if stock_provider.needs_update() and product.stockable: stock_provider.set_stock(product, stock) diff --git a/matemat/webserver/template/template.py b/matemat/webserver/template/template.py index f048af6..e42555f 100644 --- a/matemat/webserver/template/template.py +++ b/matemat/webserver/template/template.py @@ -29,14 +29,14 @@ def render(name: str, **kwargs): global __jinja_env config = get_app_config() 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') + wsacl = netaddr.IPSet([addr.strip() for addr in config.get('BarcodeWebsocketAcl', '').split(',')]) + if config.get('BarcodeWebsocketUrl', '') and request.remote_addr in wsacl: + bcwebsocket = config.get('BarcodeWebsocketUrl') else: - eanwebsocket = None + bcwebsocket = None return template.render( __version__=__version__, notifications=Notification.render(), - eanwebsocket=eanwebsocket, + barcodewebsocket=bcwebsocket, **kwargs ).encode('utf-8') diff --git a/package/debian/matemat/etc/matemat.conf b/package/debian/matemat/etc/matemat.conf index 09ceab5..2c20d8f 100644 --- a/package/debian/matemat/etc/matemat.conf +++ b/package/debian/matemat/etc/matemat.conf @@ -41,8 +41,8 @@ InstanceName=Matemat # 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. # -#EanWebsocketUrl=ws://localhost:47808/ws -#EanWebsocketAcl=::1,::ffff:127.0.0.0/104,127.0.0.0/8 +#BarcodeWebsocketUrl=ws://localhost:47808/ws +#BarcodeWebsocketAcl=::1,::ffff:127.0.0.0/104,127.0.0.0/8 # Add static HTTP headers in this section diff --git a/templates/admin.html b/templates/admin.html index 3cf55c1..71aa309 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -68,7 +68,7 @@ - + @@ -78,7 +78,7 @@ - + - + @@ -160,9 +168,9 @@ {% endblock %} -{% block eanwebsocket %} - let eaninput = document.getElementById("admin-newproduct-ean"); - eaninput.value = e.data; - eaninput.select(); - eaninput.scrollIntoView(); +{% block barcodewebsocket %} + let bcinput = document.getElementById("admin-newproduct-barcode"); + bcinput.value = e.data; + bcinput.select(); + bcinput.scrollIntoView(); {% endblock %} diff --git a/templates/base.html b/templates/base.html index b3d2520..2e67519 100644 --- a/templates/base.html +++ b/templates/base.html @@ -68,17 +68,17 @@ {% endblock %} - {% if eanwebsocket %} + {% if barcodewebsocket %}
NameEAN codeBarcodes Member price Non-member price Custom price
CHF @@ -99,7 +99,15 @@ {% for product in products %}
{{ product.name }}{{ product.ean or '' }} + {% set bcs = barcodes | selectattr('product_id', 'eq', product.id) | list %} + {% if bcs | length > 0 %} + {{ bcs[0].barcode }} + {% if bcs | length > 1 %} + +{{ bcs | length - 1 }} + {% endif %} + {% endif %} + {{ product.price_member | chf }} {{ product.price_non_member | chf }} {{ '✓' if product.custom_price else '✗' }}