Merge branch 'feature/interfaces' into 'staging'

Add pluggable interfaces for providing stock information or dispensing physical products

See merge request s3lph/matemat!73
This commit is contained in:
s3lph 2020-12-28 08:56:20 +00:00
commit 94b935d7e9
24 changed files with 358 additions and 129 deletions

View file

@ -1,5 +1,20 @@
# Matemat Changelog # Matemat Changelog
<!-- BEGIN RELEASE v0.2.5 -->
## Version 0.2.5
Feature release
### Changes
<!-- BEGIN CHANGES 0.2.5 -->
- Feature: Non-stockable products
- Feature: Pluggable stock provider and dispenser modules
- Fix: Products creation raised an error if no image was uploaded
<!-- END CHANGES 0.2.5 -->
<!-- END RELEASE v0.2.5 -->
<!-- BEGIN RELEASE v0.2.4 --> <!-- BEGIN RELEASE v0.2.4 -->
## Version 0.2.4 ## Version 0.2.4

View file

@ -1,2 +1,2 @@
__version__ = '0.2.4' __version__ = '0.2.5'

View file

@ -354,11 +354,11 @@ 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, stock SELECT product_id, name, price_member, price_non_member, stock, stockable
FROM products FROM products
'''): '''):
product_id, name, price_member, price_external, stock = row product_id, name, price_member, price_external, stock, stockable = row
products.append(Product(product_id, name, price_member, price_external, stock)) products.append(Product(product_id, name, price_member, price_external, stockable, stock))
return products return products
def get_product(self, pid: int) -> Product: def get_product(self, pid: int) -> Product:
@ -369,7 +369,7 @@ 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, stock SELECT product_id, name, price_member, price_non_member, stock, stockable
FROM products FROM products
WHERE product_id = ?''', WHERE product_id = ?''',
[pid]) [pid])
@ -377,15 +377,16 @@ class MatematDatabase(object):
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, stock = row product_id, name, price_member, price_non_member, stock, stockable = row
return Product(product_id, name, price_member, price_non_member, stock) return Product(product_id, name, price_member, price_non_member, stockable, stock)
def create_product(self, name: str, price_member: int, price_non_member: int) -> Product: def create_product(self, name: str, price_member: int, price_non_member: int, stockable: bool) -> Product:
""" """
Creates a new product. Creates a new product.
:param name: Name of the product. :param name: Name of the product.
:param price_member: Price of the product for members. :param price_member: Price of the product for members.
:param price_non_member: Price of the product for non-members. :param price_non_member: Price of the product for non-members.
:param stockable: True if the product should be stockable, false otherwise.
: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.
""" """
@ -395,16 +396,17 @@ 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, stock) INSERT INTO products (name, price_member, price_non_member, stock, stockable)
VALUES (:name, :price_member, :price_non_member, 0) VALUES (:name, :price_member, :price_non_member, 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,
'stockable': stockable
}) })
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, 0) return Product(product_id, name, price_member, price_non_member, stockable, 0)
def change_product(self, product: Product, **kwargs) -> None: def change_product(self, product: Product, **kwargs) -> None:
""" """
@ -421,6 +423,7 @@ class MatematDatabase(object):
price_member: int = kwargs['price_member'] if 'price_member' in kwargs else product.price_member price_member: int = kwargs['price_member'] if 'price_member' in kwargs else product.price_member
price_non_member: int = kwargs['price_non_member'] if 'price_non_member' in kwargs else product.price_non_member price_non_member: int = kwargs['price_non_member'] if 'price_non_member' in kwargs else product.price_non_member
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
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute(''' c.execute('''
UPDATE products UPDATE products
@ -428,14 +431,16 @@ class MatematDatabase(object):
name = :name, name = :name,
price_member = :price_member, price_member = :price_member,
price_non_member = :price_non_member, price_non_member = :price_non_member,
stock = :stock stock = :stock,
stockable = :stockable
WHERE product_id = :product_id WHERE product_id = :product_id
''', { ''', {
'product_id': product.id, 'product_id': product.id,
'name': name, 'name': name,
'price_member': price_member, 'price_member': price_member,
'price_non_member': price_non_member, 'price_non_member': price_non_member,
'stock': stock 'stock': stock,
'stockable': stockable
}) })
affected = c.execute('SELECT changes()').fetchone()[0] affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1: if affected != 1:
@ -446,6 +451,7 @@ class MatematDatabase(object):
product.price_member = price_member product.price_member = price_member
product.price_non_member = price_non_member product.price_non_member = price_non_member
product.stock = stock product.stock = stock
product.stockable = stockable
def delete_product(self, product: Product) -> None: def delete_product(self, product: Product) -> None:
""" """
@ -465,8 +471,7 @@ class MatematDatabase(object):
def increment_consumption(self, user: User, product: Product) -> None: def increment_consumption(self, user: User, product: Product) -> None:
""" """
Decrement the user's balance by the price of the product, decrement the products stock, and create an entry in Decrement the user's balance by the price of the product and create an entry in the statistics table.
the statistics table.
:param user: The user buying a product. :param user: The user buying a product.
:param product: The product the user is buying. :param product: The product the user is buying.
@ -501,44 +506,8 @@ class MatematDatabase(object):
if affected != 1: if affected != 1:
raise DatabaseConsistencyError( raise DatabaseConsistencyError(
f'increment_consumption should affect 1 users row, but affected {affected}') f'increment_consumption should affect 1 users row, but affected {affected}')
# Subtract the number of purchased units from the product's stock.
c.execute('''
UPDATE products
SET stock = stock - 1
WHERE product_id = :product_id
''', {
'product_id': product.id,
})
# Make sure exactly one product row was updated.
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(
f'increment_consumption should affect 1 products row, but affected {affected}')
# Reflect the change in the user and product objects # Reflect the change in the user and product objects
user.balance -= price user.balance -= price
product.stock -= 1
def restock(self, product: Product, count: int) -> None:
"""
Update the stock of a product.
:param product: The product to restock.
:param count: Number of units of the product to add.
:raises DatabaseConsistencyError: If the product represented by the object does not exist.
"""
with self.db.transaction() as c:
c.execute('''
UPDATE products
SET stock = stock + :count
WHERE product_id = :product_id
''', {
'product_id': product.id,
'count': count
})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(f'restock should affect 1 products row, but affected {affected}')
# Reflect the change in the product object
product.stock += count
def deposit(self, user: User, amount: int) -> None: def deposit(self, user: User, amount: int) -> None:
""" """

View file

@ -239,3 +239,32 @@ def migrate_schema_3_to_4(c: sqlite3.Cursor):
''') ''')
c.execute('INSERT INTO receipts SELECT * FROM receipts_temp') c.execute('INSERT INTO receipts SELECT * FROM receipts_temp')
c.execute('DROP TABLE receipts_temp') c.execute('DROP TABLE receipts_temp')
def migrate_schema_4_to_5(c: sqlite3.Cursor):
# Change products schema to allow null for stock and add stockable column
c.execute('''
CREATE TEMPORARY TABLE products_temp (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) NOT NULL DEFAULT 0,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT NULL
);
''')
c.execute('INSERT INTO products_temp SELECT * FROM products')
c.execute('DROP TABLE products')
c.execute('''
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) DEFAULT 0,
stockable INTEGER(1) DEFAULT 1,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT NULL
);
''')
c.execute('''
INSERT INTO products SELECT product_id, name, stock, 1, price_member, price_non_member FROM products_temp
''')
c.execute('DROP TABLE products_temp')

View file

@ -8,15 +8,19 @@ class Product:
:param name: The product's name. :param name: The product's name.
:param price_member: The price of a unit of this product for users marked as "members". :param price_member: The price of a unit of this product for users marked as "members".
:param price_non_member: The price of a unit of this product for users NOT marked as "members". :param price_non_member: The price of a unit of this product for users NOT marked as "members".
:param stock: The number of items of this product currently in stock. :param stock: The number of items of this product currently in stock, or None if not stockable.
:param stockable: Whether this product is stockable.
""" """
def __init__(self, _id: int, name: str, price_member: int, price_non_member: int, stock: int) -> None: def __init__(self, _id: int, name: str,
price_member: int, price_non_member: int,
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
self.price_non_member: int = price_non_member self.price_non_member: int = price_non_member
self.stock: int = stock self.stock: int = stock
self.stockable: bool = stockable
def __eq__(self, other) -> bool: def __eq__(self, other) -> bool:
if not isinstance(other, Product): if not isinstance(other, Product):
@ -25,7 +29,8 @@ class Product:
self.name == other.name and \ self.name == other.name and \
self.price_member == other.price_member and \ self.price_member == other.price_member and \
self.price_non_member == other.price_non_member and \ self.price_non_member == other.price_non_member and \
self.stock == other.stock self.stock == other.stock and \
self.stockable == other.stockable
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((self.id, self.name, self.price_member, self.price_non_member, self.stock)) return hash((self.id, self.name, self.price_member, self.price_non_member, self.stock, self.stockable))

View file

@ -255,3 +255,81 @@ SCHEMAS[4] = [
ON DELETE SET NULL ON UPDATE CASCADE ON DELETE SET NULL ON UPDATE CASCADE
); );
'''] ''']
SCHEMAS[5] = [
'''
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
email TEXT DEFAULT NULL,
password TEXT NOT NULL,
touchkey TEXT DEFAULT NULL,
is_admin INTEGER(1) NOT NULL DEFAULT 0,
is_member INTEGER(1) NOT NULL DEFAULT 1,
balance INTEGER(8) NOT NULL DEFAULT 0,
lastchange INTEGER(8) NOT NULL DEFAULT 0,
receipt_pref INTEGER(1) NOT NULL DEFAULT 0,
created INTEGER(8) NOT NULL DEFAULT 0
);
''',
'''
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) DEFAULT 0,
stockable INTEGER(1) DEFAULT 1,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT NULL
);
''',
'''
CREATE TABLE transactions ( -- "superclass" of the following 3 tables
ta_id INTEGER PRIMARY KEY,
user_id INTEGER DEFAULT NULL,
value INTEGER(8) NOT NULL,
old_balance INTEGER(8) NOT NULL,
date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE SET NULL ON UPDATE CASCADE
);
''',
'''
CREATE TABLE consumptions ( -- transactions involving buying a product
ta_id INTEGER PRIMARY KEY,
product TEXT NOT NULL,
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
''',
'''
CREATE TABLE deposits ( -- transactions involving depositing cash
ta_id INTEGER PRIMARY KEY,
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
''',
'''
CREATE TABLE modifications ( -- transactions involving balance modification by an admin
ta_id INTEGER NOT NULL,
agent TEXT NOT NULL,
reason TEXT DEFAULT NULL,
PRIMARY KEY (ta_id),
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
''',
'''
CREATE TABLE receipts ( -- receipts sent to the users
receipt_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
first_ta_id INTEGER DEFAULT NULL,
last_ta_id INTEGER DEFAULT NULL,
date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (first_ta_id) REFERENCES transactions(ta_id)
ON DELETE SET NULL ON UPDATE CASCADE,
FOREIGN KEY (last_ta_id) REFERENCES transactions(ta_id)
ON DELETE SET NULL ON UPDATE CASCADE
);
''']

View file

@ -220,24 +220,26 @@ class DatabaseTest(unittest.TestCase):
def test_create_product(self) -> None: def test_create_product(self) -> None:
with self.db as db: with self.db as db:
with db.transaction() as c: with db.transaction() as c:
db.create_product('Club Mate', 200, 200) db.create_product('Club Mate', 200, 200, True)
c.execute("SELECT * FROM products") c.execute("SELECT * FROM products")
row = c.fetchone() row = c.fetchone()
self.assertEqual('Club Mate', row[1]) self.assertEqual('Club Mate', row[1])
self.assertEqual(0, row[2]) self.assertEqual(0, row[2])
self.assertEqual(200, row[3]) self.assertEqual(1, row[3])
self.assertEqual(200, row[4]) self.assertEqual(200, row[4])
self.assertEqual(200, row[5])
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
db.create_product('Club Mate', 250, 250) db.create_product('Club Mate', 250, 250, False)
def test_get_product(self) -> None: def test_get_product(self) -> None:
with self.db as db: with self.db as db:
with db.transaction(exclusive=False): with db.transaction(exclusive=False):
created = db.create_product('Club Mate', 150, 250) created = db.create_product('Club Mate', 150, 250, False)
product = db.get_product(created.id) product = db.get_product(created.id)
self.assertEqual('Club Mate', product.name) self.assertEqual('Club Mate', product.name)
self.assertEqual(150, product.price_member) self.assertEqual(150, product.price_member)
self.assertEqual(250, product.price_non_member) self.assertEqual(250, product.price_non_member)
self.assertEqual(False, product.stockable)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
db.get_product(-1) db.get_product(-1)
@ -246,9 +248,9 @@ class DatabaseTest(unittest.TestCase):
# Test empty list # Test empty list
products = db.list_products() products = db.list_products()
self.assertEqual(0, len(products)) self.assertEqual(0, len(products))
db.create_product('Club Mate', 200, 200) db.create_product('Club Mate', 200, 200, True)
db.create_product('Flora Power Mate', 200, 200) db.create_product('Flora Power Mate', 200, 200, False)
db.create_product('Fritz Mate', 200, 250) db.create_product('Fritz Mate', 200, 250, True)
products = db.list_products() products = db.list_products()
self.assertEqual(3, len(products)) self.assertEqual(3, len(products))
productcheck = {} productcheck = {}
@ -256,34 +258,39 @@ class DatabaseTest(unittest.TestCase):
if product.name == 'Club Mate': if product.name == 'Club Mate':
self.assertEqual(200, product.price_member) self.assertEqual(200, product.price_member)
self.assertEqual(200, product.price_non_member) self.assertEqual(200, product.price_non_member)
self.assertTrue(product.stockable)
elif product.name == 'Flora Power Mate': elif product.name == 'Flora Power Mate':
self.assertEqual(200, product.price_member) self.assertEqual(200, product.price_member)
self.assertEqual(200, product.price_non_member) self.assertEqual(200, product.price_non_member)
self.assertFalse(product.stockable)
elif product.name == 'Fritz Mate': elif product.name == 'Fritz Mate':
self.assertEqual(200, product.price_member) self.assertEqual(200, product.price_member)
self.assertEqual(250, product.price_non_member) self.assertEqual(250, product.price_non_member)
self.assertTrue(product.stockable)
productcheck[product.id] = 1 productcheck[product.id] = 1
self.assertEqual(3, len(productcheck)) self.assertEqual(3, len(productcheck))
def test_change_product(self) -> None: def test_change_product(self) -> None:
with self.db as db: with self.db as db:
product = db.create_product('Club Mate', 200, 200) product = db.create_product('Club Mate', 200, 200, True)
db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250, stock=42) db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250,
stock=None, stockable=False)
# Changes must be reflected in the passed object # Changes must be reflected in the passed object
self.assertEqual('Flora Power Mate', product.name) self.assertEqual('Flora Power Mate', product.name)
self.assertEqual(150, product.price_member) self.assertEqual(150, product.price_member)
self.assertEqual(250, product.price_non_member) self.assertEqual(250, product.price_non_member)
self.assertEqual(42, product.stock) self.assertEqual(None, product.stock)
self.assertEqual(False, product.stockable)
# Changes must be reflected in the database # Changes must be reflected in the database
checkproduct = db.get_product(product.id) checkproduct = db.get_product(product.id)
self.assertEqual('Flora Power Mate', checkproduct.name) self.assertEqual('Flora Power Mate', checkproduct.name)
self.assertEqual(150, checkproduct.price_member) self.assertEqual(150, checkproduct.price_member)
self.assertEqual(250, checkproduct.price_non_member) self.assertEqual(250, checkproduct.price_non_member)
self.assertEqual(42, checkproduct.stock) self.assertEqual(None, checkproduct.stock)
product.id = -1 product.id = -1
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
db.change_product(product) db.change_product(product)
product2 = db.create_product('Club Mate', 200, 200) product2 = db.create_product('Club Mate', 200, 200, True)
product2.name = 'Flora Power Mate' product2.name = 'Flora Power Mate'
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
# Should fail, as a product with the same name already exists. # Should fail, as a product with the same name already exists.
@ -291,8 +298,8 @@ class DatabaseTest(unittest.TestCase):
def test_delete_product(self) -> None: def test_delete_product(self) -> None:
with self.db as db: with self.db as db:
product = db.create_product('Club Mate', 200, 200) product = db.create_product('Club Mate', 200, 200, True)
product2 = db.create_product('Flora Power Mate', 200, 200) product2 = db.create_product('Flora Power Mate', 200, 200, False)
self.assertEqual(2, len(db.list_products())) self.assertEqual(2, len(db.list_products()))
db.delete_product(product) db.delete_product(product)
@ -328,25 +335,6 @@ class DatabaseTest(unittest.TestCase):
# Should fail, user id -1 does not exist # Should fail, user id -1 does not exist
db.deposit(user, 42) db.deposit(user, 42)
def test_restock(self) -> None:
with self.db as db:
with db.transaction() as c:
product = db.create_product('Club Mate', 200, 200)
product2 = db.create_product('Flora Power Mate', 200, 200)
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product.id])
self.assertEqual(0, c.fetchone()[0])
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product2.id])
self.assertEqual(0, c.fetchone()[0])
db.restock(product, 42)
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product.id])
self.assertEqual(42, c.fetchone()[0])
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [product2.id])
self.assertEqual(0, c.fetchone()[0])
product.id = -1
with self.assertRaises(DatabaseConsistencyError):
# Should fail, product id -1 does not exist
db.restock(product, 42)
def test_consumption(self) -> None: def test_consumption(self) -> None:
with self.db as db: with self.db as db:
# Set up test case # Set up test case
@ -356,12 +344,9 @@ class DatabaseTest(unittest.TestCase):
db.deposit(user1, 1337) db.deposit(user1, 1337)
db.deposit(user2, 4242) db.deposit(user2, 4242)
db.deposit(user3, 1234) db.deposit(user3, 1234)
clubmate = db.create_product('Club Mate', 200, 200) clubmate = db.create_product('Club Mate', 200, 200, True)
florapowermate = db.create_product('Flora Power Mate', 150, 250) florapowermate = db.create_product('Flora Power Mate', 150, 250, True)
fritzmate = db.create_product('Fritz Mate', 200, 200) fritzmate = db.create_product('Fritz Mate', 200, 200, True)
db.restock(clubmate, 50)
db.restock(florapowermate, 70)
db.restock(fritzmate, 10)
# user1 is somewhat addicted to caffeine # user1 is somewhat addicted to caffeine
for _ in range(3): for _ in range(3):
@ -381,19 +366,9 @@ class DatabaseTest(unittest.TestCase):
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user3.id]) c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user3.id])
self.assertEqual(1234, c.fetchone()[0]) self.assertEqual(1234, c.fetchone()[0])
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [clubmate.id])
self.assertEqual(40, c.fetchone()[0])
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [florapowermate.id])
self.assertEqual(60, c.fetchone()[0])
c.execute('''SELECT stock FROM products WHERE product_id = ?''', [fritzmate.id])
self.assertEqual(10, c.fetchone()[0])
user1.id = -1 user1.id = -1
clubmate.id = -1
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
db.increment_consumption(user1, florapowermate) db.increment_consumption(user1, florapowermate)
with self.assertRaises(DatabaseConsistencyError):
db.increment_consumption(user2, clubmate)
def test_check_receipt_due(self): def test_check_receipt_due(self):
with self.db as db: with self.db as db:
@ -497,7 +472,7 @@ class DatabaseTest(unittest.TestCase):
admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True) admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True)
user: User = db.create_user('user', 'supersecurepassword', 'user@example.com', True, True) user: User = db.create_user('user', 'supersecurepassword', 'user@example.com', True, True)
product: Product = db.create_product('Flora Power Mate', 200, 200) product: Product = db.create_product('Flora Power Mate', 200, 200, True)
# Create some transactions # Create some transactions
db.change_user(user, agent=admin, db.change_user(user, agent=admin,
@ -586,8 +561,8 @@ class DatabaseTest(unittest.TestCase):
user2: User = db.create_user('user2', 'supersecurepassword', 'user2@example.com', True, False) user2: User = db.create_user('user2', 'supersecurepassword', 'user2@example.com', True, False)
user3: User = db.create_user('user3', 'supersecurepassword', 'user3@example.com', True, False) user3: User = db.create_user('user3', 'supersecurepassword', 'user3@example.com', True, False)
user4: User = db.create_user('user4', 'supersecurepassword', 'user4@example.com', True, False) user4: User = db.create_user('user4', 'supersecurepassword', 'user4@example.com', True, False)
flora: Product = db.create_product('Flora Power Mate', 200, 200) flora: Product = db.create_product('Flora Power Mate', 200, 200, True)
club: Product = db.create_product('Club Mate', 200, 200) club: Product = db.create_product('Club Mate', 200, 200, False)
# Create some transactions # Create some transactions
db.deposit(user1, 1337) db.deposit(user1, 1337)

View file

@ -40,7 +40,7 @@ class DatabaseTransaction(object):
class DatabaseWrapper(object): class DatabaseWrapper(object):
SCHEMA_VERSION = 4 SCHEMA_VERSION = 5
def __init__(self, filename: str) -> None: def __init__(self, filename: str) -> None:
self._filename: str = filename self._filename: str = filename
@ -85,6 +85,8 @@ class DatabaseWrapper(object):
migrate_schema_2_to_3(c) migrate_schema_2_to_3(c)
if from_version <= 3 and to_version >= 4: if from_version <= 3 and to_version >= 4:
migrate_schema_3_to_4(c) migrate_schema_3_to_4(c)
if from_version <= 4 and to_version >= 5:
migrate_schema_4_to_5(c)
def connect(self) -> None: def connect(self) -> None:
if self.is_connected(): if self.is_connected():

View file

View file

@ -0,0 +1,3 @@
from .dispenser import Dispenser
from .nulldispenser import NullDispenser

View file

@ -0,0 +1,6 @@
class Dispenser:
def dispense(self, product, amount):
pass

View file

@ -0,0 +1,8 @@
from matemat.interfacing.dispenser import Dispenser
class NullDispenser(Dispenser):
def dispense(self, product, amount):
# no-op
pass

View file

@ -0,0 +1,3 @@
from .stockprovider import StockProvider
from .databasestockprovider import DatabaseStockProvider

View file

@ -0,0 +1,32 @@
from typing import Optional
from matemat.db import MatematDatabase
from matemat.db.primitives.Product import Product
from matemat.interfacing.stock import StockProvider
from matemat.webserver.config import get_app_config
class DatabaseStockProvider(StockProvider):
@classmethod
def needs_update(cls) -> bool:
return True
def get_stock(self, product: Product) -> Optional[int]:
if not product.stockable:
return None
return product.stock
def set_stock(self, product: Product, stock: int) -> None:
if product.stock is None or not product.stockable:
return
config = get_app_config()
with MatematDatabase(config['DatabaseFile']) as db:
db.change_product(product, stock=stock)
def update_stock(self, product: Product, stock: int) -> None:
if product.stock is None or not product.stockable:
return
config = get_app_config()
with MatematDatabase(config['DatabaseFile']) as db:
db.change_product(product, stock=product.stock + stock)

View file

@ -0,0 +1,40 @@
from typing import Optional
from matemat.db.primitives.Product import Product
class StockProvider:
@classmethod
def needs_update(cls) -> bool:
"""
If this method returns True, `set_stock` and `update_stock` MUST be implemented.
If this method returns False, `set_stock` or `update_stock` will never be called.
:return: True if state needs to be updated, false otherwise.
"""
return False
def get_stock(self, product: Product) -> Optional[int]:
"""
Returns the number of items in stock, if supported.
:param product: The product to get the stock for.
:return: Number of items in stock, or None if not supported.
"""
pass
def set_stock(self, product: Product, stock: int):
"""
Sets the number of items in stock, if supported.
:param product: The product to set the stock for.
:param stock: The amount to set the stock to.
"""
pass
def update_stock(self, product: Product, stock: int) -> None:
"""
Increases or decreases the number of items in stock, if supported.
:param product: The product to update the stock for.
:param stock: The amount to add to or subtract from the stock.
"""
pass

View file

@ -4,10 +4,12 @@ from typing import Any, Dict, Iterable, List, Tuple, Union
import os import os
import sys import sys
import logging import logging
import importlib
from configparser import ConfigParser from configparser import ConfigParser
config: Dict[str, Any] = dict() config: Dict[str, Any] = dict()
stock_provider = None
dispenser = None
def parse_logging(symbolic_level: str, symbolic_target: str) -> Tuple[int, logging.Handler]: def parse_logging(symbolic_level: str, symbolic_target: str) -> Tuple[int, logging.Handler]:
@ -74,6 +76,8 @@ def parse_config_file(paths: Union[str, Iterable[str]]) -> None:
config['pagelet_variables'] = dict() config['pagelet_variables'] = dict()
# Statically configured headers # Statically configured headers
config['headers'] = dict() config['headers'] = dict()
# Pluggable interface config
config['interfaces'] = dict()
# Initialize the config parser # Initialize the config parser
parser: ConfigParser = ConfigParser() parser: ConfigParser = ConfigParser()
@ -111,6 +115,11 @@ def parse_config_file(paths: Union[str, Iterable[str]]) -> None:
for k, v in parser['HttpHeaders'].items(): for k, v in parser['HttpHeaders'].items():
config['headers'][k] = v config['headers'][k] = v
# Read all values from the [Providers] section, if present. These values are set as HTTP response headers
if 'Interfaces' in parser.sections():
for k, v in parser['Interfaces'].items():
config['interfaces'][k] = v
def get_config() -> Dict[str, Any]: def get_config() -> Dict[str, Any]:
global config global config
@ -120,3 +129,27 @@ def get_config() -> Dict[str, Any]:
def get_app_config() -> Dict[str, Any]: def get_app_config() -> Dict[str, Any]:
global config global config
return config['pagelet_variables'] return config['pagelet_variables']
def get_stock_provider() -> 'StockProvider':
global config, stock_provider
if stock_provider is None:
providers = config.get('interfaces', {})
fqcn = providers.get('StockProvider', 'matemat.interfacing.stock.DatabaseStockProvider')
modname, clsname = fqcn.rsplit('.', 1)
module = importlib.import_module(modname)
cls = getattr(module, clsname)
stock_provider = cls()
return stock_provider
def get_dispenser() -> 'Dispenser':
global config, dispenser
if dispenser is None:
providers = config.get('interfaces', {})
fqcn = providers.get('Dispenser', 'matemat.interfacing.dispenser.NullDispenser')
modname, clsname = fqcn.rsplit('.', 1)
module = importlib.import_module(modname)
cls = getattr(module, clsname)
dispenser = cls()
return dispenser

View file

@ -11,7 +11,7 @@ from matemat.db.primitives import User, ReceiptPreference
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.util.currency_format import parse_chf from matemat.util.currency_format import parse_chf
from matemat.webserver import session, template from matemat.webserver import session, template
from matemat.webserver.config import get_app_config from matemat.webserver.config import get_app_config, get_stock_provider
@get('/admin') @get('/admin')
@ -191,17 +191,19 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
# The user requested to create a new product # The user requested to create a new product
elif change == 'newproduct': elif change == 'newproduct':
# Only create a new product if all required properties of the product are present in the request arguments # Only create a new product if all required properties of the product are present in the request arguments
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args: for key in ['name', 'pricemember', 'pricenonmember']:
return if key not in args:
return
# Read the properties from the request arguments # Read the properties from the request arguments
name = str(args.name) name = str(args.name)
price_member = parse_chf(str(args.pricemember)) price_member = parse_chf(str(args.pricemember))
price_non_member = parse_chf(str(args.pricenonmember)) price_non_member = parse_chf(str(args.pricenonmember))
stockable = 'stockable' in args
# Create the user in the database # Create the user in the database
newproduct = db.create_product(name, price_member, price_non_member) newproduct = db.create_product(name, price_member, price_non_member, stockable)
# 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 None or len(image) == 0: if image is not None and len(image) > 0:
# Detect the MIME type # Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(image) filemagic: magic.FileMagic = magic.detect_from_content(image)
if not filemagic.mime_type.startswith('image/'): if not filemagic.mime_type.startswith('image/'):
@ -232,6 +234,9 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
# The user requested to restock a product # The user requested to restock a product
elif change == 'restock': elif change == 'restock':
stock_provider = get_stock_provider()
if not stock_provider.needs_update():
return
# Only restock a product if all required properties are present in the request arguments # Only restock a product if all required properties are present in the request arguments
if 'productid' not in args or 'amount' not in args: if 'productid' not in args or 'amount' not in args:
return return
@ -240,8 +245,9 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
amount = int(str(args.amount)) amount = int(str(args.amount))
# Fetch the product to restock from the database # Fetch the product to restock from the database
product = db.get_product(productid) product = db.get_product(productid)
# Write the new stock count to the database if not product.stockable:
db.restock(product, amount) return
stock_provider.update_stock(product, amount)
# The user requested to set default images # The user requested to set default images
elif change == 'defaultimg': elif change == 'defaultimg':

View file

@ -2,7 +2,7 @@ from bottle import get, post, redirect, request
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import session from matemat.webserver import session
from matemat.webserver.config import get_app_config from matemat.webserver import config as c
@get('/buy') @get('/buy')
@ -11,7 +11,7 @@ def buy():
""" """
The purchasing mechanism. Called by the user clicking an item on the product list. The purchasing mechanism. Called by the user clicking an item on the product list.
""" """
config = get_app_config() config = c.get_app_config()
session_id: str = session.start() session_id: str = session.start()
# If no user is logged in, redirect to the main page, as a purchase must always be bound to a user # If no user is logged in, redirect to the main page, as a purchase must always be bound to a user
if not session.has(session_id, 'authenticated_user'): if not session.has(session_id, 'authenticated_user'):
@ -27,5 +27,9 @@ def buy():
product = db.get_product(pid) product = db.get_product(pid)
# Create a consumption entry for the (user, product) combination # Create a consumption entry for the (user, product) combination
db.increment_consumption(user, product) db.increment_consumption(user, product)
stock_provider = c.get_stock_provider()
if stock_provider.needs_update():
stock_provider.update_stock(product, -1)
c.get_dispenser().dispense(product, 1)
# Redirect to the main page (where this request should have come from) # Redirect to the main page (where this request should have come from)
redirect('/') redirect('/')

View file

@ -2,7 +2,7 @@ from bottle import route, redirect
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import template, session from matemat.webserver import template, session
from matemat.webserver.config import get_app_config from matemat.webserver.config import get_app_config, get_stock_provider
@route('/') @route('/')
@ -24,7 +24,7 @@ def main_page():
products = db.list_products() products = db.list_products()
# Prepare a response with a jinja2 template # Prepare a response with a jinja2 template
return template.render('productlist.html', return template.render('productlist.html',
authuser=user, products=products, authlevel=authlevel, authuser=user, products=products, authlevel=authlevel, stock=get_stock_provider(),
setupname=config['InstanceName']) setupname=config['InstanceName'])
else: else:
# If there are no admin users registered, jump to the admin creation procedure # If there are no admin users registered, jump to the admin creation procedure

View file

@ -11,7 +11,7 @@ from matemat.db.primitives import Product
from matemat.exceptions import DatabaseConsistencyError from matemat.exceptions import DatabaseConsistencyError
from matemat.util.currency_format import parse_chf from matemat.util.currency_format import parse_chf
from matemat.webserver import template, session from matemat.webserver import template, session
from matemat.webserver.config import get_app_config from matemat.webserver.config import get_app_config, get_stock_provider
@get('/modproduct') @get('/modproduct')
@ -86,17 +86,23 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
# 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
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args or 'stock' not in args: for key in ['name', 'pricemember', 'pricenonmember']:
return if key not in args:
return
# Read the properties from the request arguments # Read the properties from the request arguments
name = str(args.name) name = str(args.name)
price_member = parse_chf(str(args.pricemember)) price_member = parse_chf(str(args.pricemember))
price_non_member = parse_chf(str(args.pricenonmember)) price_non_member = parse_chf(str(args.pricenonmember))
stock = int(str(args.stock)) stock = int(str(args.stock))
stockable = 'stockable' in args
# 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, stock=stock) name=name, price_member=price_member, price_non_member=price_non_member,
stock=stock, stockable=stockable)
stock_provider = get_stock_provider()
if stock_provider.needs_update() and product.stockable:
stock_provider.set_stock(product, stock)
except DatabaseConsistencyError: except DatabaseConsistencyError:
return return
# If a new product image was uploaded, process it # If a new product image was uploaded, process it

View file

@ -32,7 +32,8 @@ def parse_changelog(tag: str) -> Optional[str]:
def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str, str]: def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str, str]:
url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs' url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs'
headers: Dict[str, str] = { headers: Dict[str, str] = {
'Private-Token': api_token 'Private-Token': api_token,
'User-Agent': 'curl/7.70.0'
} }
req = urllib.request.Request(url, headers=headers) req = urllib.request.Request(url, headers=headers)
try: try:
@ -52,7 +53,10 @@ def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str
def fetch_single_shafile(url: str) -> str: def fetch_single_shafile(url: str) -> str:
req = urllib.request.Request(url) headers: Dict[str, str] = {
'User-Agent': 'curl/7.70.0'
}
req = urllib.request.Request(url, headers=headers)
try: try:
resp: http.client.HTTPResponse = urllib.request.urlopen(req) resp: http.client.HTTPResponse = urllib.request.urlopen(req)
except HTTPError as e: except HTTPError as e:
@ -138,7 +142,8 @@ def main():
f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release' f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release'
headers: Dict[str, str] = { headers: Dict[str, str] = {
'Private-Token': api_token, 'Private-Token': api_token,
'Content-Type': 'application/json; charset=utf-8' 'Content-Type': 'application/json; charset=utf-8',
'User-Agent': 'curl/7.70.0'
} }
request = urllib.request.Request( request = urllib.request.Request(

View file

@ -49,6 +49,9 @@
<label for="admin-newproduct-price-non-member">Non-member price: </label> <label for="admin-newproduct-price-non-member">Non-member price: </label>
CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0" /><br/> CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0" /><br/>
<label for="admin-newproduct-stockable">Stockable: </label>
<input id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked" /><br/>
<label for="admin-newproduct-image">Image: </label> <label for="admin-newproduct-image">Image: </label>
<input id="admin-newproduct-image" name="image" type="file" accept="image/*" /><br/> <input id="admin-newproduct-image" name="image" type="file" accept="image/*" /><br/>
@ -63,7 +66,9 @@
<label for="admin-restock-productid">Product: </label> <label for="admin-restock-productid">Product: </label>
<select id="admin-restock-productid" name="productid"> <select id="admin-restock-productid" name="productid">
{% for product in products %} {% for product in products %}
{% if product.stockable %}
<option value="{{ product.id }}">{{ product.name }} ({{ product.stock }})</option> <option value="{{ product.id }}">{{ product.name }} ({{ product.stock }})</option>
{% endif %}
{% endfor %} {% endfor %}
</select><br/> </select><br/>

View file

@ -20,6 +20,9 @@
<label for="modproduct-price-non-member">Non-member price: </label> <label for="modproduct-price-non-member">Non-member price: </label>
CHF <input id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" /><br/> CHF <input id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" /><br/>
<label for="modproduct-stockable">Stockable: </label>
<input id="modproduct-stockable" type="checkbox" name="stockable" {% if product.stockable %} checked="checked" {% endif %} /><br/>
<label for="modproduct-balance">Stock: </label> <label for="modproduct-balance">Stock: </label>
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/> <input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>

View file

@ -34,7 +34,9 @@
</span><br/> </span><br/>
<div class="imgcontainer"> <div class="imgcontainer">
<img src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/> <img src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}"/>
<span class="thumblist-stock">{{ product.stock }}</span> {% if product.stockable %}
<span class="thumblist-stock">{{ stock.get_stock(product) }}</span>
{% endif %}
</div> </div>
</a> </a>
</div> </div>