feat: allow multiple barcodes to be associated with a product
chore: consistent renaming from ean to barcode
This commit is contained in:
parent
a7150e123e
commit
583107ac63
20 changed files with 294 additions and 124 deletions
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
''')
|
||||||
|
|
34
matemat/db/primitives/Barcode.py
Normal file
34
matemat/db/primitives/Barcode.py
Normal 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))
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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(); });
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block eanwebsocket %}
|
{% block barcodewebsocket %}
|
||||||
document.location = "/?ean=" + e.data;
|
document.location = "/?barcode=" + e.data;
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -2,16 +2,13 @@
|
||||||
|
|
||||||
{% 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>
|
|
||||||
<input class="form-control" id="modproduct-ean" type="text" name="ean" value="{{ product.ean or '' }}" /><br/>
|
|
||||||
|
|
||||||
<label class="form-label" for="modproduct-price-member">Member price: </label>
|
<label class="form-label" for="modproduct-price-member">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>
|
||||||
|
@ -48,6 +45,31 @@
|
||||||
<input class="btn btn-primary" type="submit" value="Save changes">
|
<input class="btn btn-primary" type="submit" value="Save changes">
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
<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">
|
||||||
|
@ -55,15 +77,15 @@
|
||||||
<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 %}
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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">
|
||||||
|
<strong>Warning: </strong>
|
||||||
Login tokens are a convenience feature that if used may weaken security.
|
Login tokens are a convenience feature that if used may weaken security.
|
||||||
Make sure you only use tokens not easily accessible to other people.
|
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();
|
||||||
|
|
|
@ -40,6 +40,6 @@
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block eanwebsocket %}
|
{% block barcodewebsocket %}
|
||||||
document.location = "/?ean=" + e.data;
|
document.location = "/?barcode=" + e.data;
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -33,6 +33,6 @@
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block eanwebsocket %}
|
{% block barcodewebsocket %}
|
||||||
document.location = "/?ean=" + e.data;
|
document.location = "/?barcode=" + e.data;
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -26,6 +26,6 @@
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block eanwebsocket %}
|
{% block barcodewebsocket %}
|
||||||
document.location = "/?ean=" + e.data;
|
document.location = "/?barcode=" + e.data;
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -22,6 +22,6 @@
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block eanwebsocket %}
|
{% block barcodewebsocket %}
|
||||||
document.location = "/?ean=" + e.data;
|
document.location = "/?barcode=" + e.data;
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Loading…
Reference in a new issue