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:
commit
94b935d7e9
24 changed files with 358 additions and 129 deletions
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -1,5 +1,20 @@
|
|||
# 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 -->
|
||||
## Version 0.2.4
|
||||
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
__version__ = '0.2.4'
|
||||
__version__ = '0.2.5'
|
||||
|
|
|
@ -354,11 +354,11 @@ 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, stock
|
||||
SELECT product_id, name, price_member, price_non_member, stock, stockable
|
||||
FROM products
|
||||
'''):
|
||||
product_id, name, price_member, price_external, stock = row
|
||||
products.append(Product(product_id, name, price_member, price_external, stock))
|
||||
product_id, name, price_member, price_external, stock, stockable = row
|
||||
products.append(Product(product_id, name, price_member, price_external, stockable, stock))
|
||||
return products
|
||||
|
||||
def get_product(self, pid: int) -> Product:
|
||||
|
@ -369,7 +369,7 @@ 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, stock
|
||||
SELECT product_id, name, price_member, price_non_member, stock, stockable
|
||||
FROM products
|
||||
WHERE product_id = ?''',
|
||||
[pid])
|
||||
|
@ -377,15 +377,16 @@ class MatematDatabase(object):
|
|||
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, stock = row
|
||||
return Product(product_id, name, price_member, price_non_member, stock)
|
||||
product_id, name, price_member, price_non_member, stock, stockable = row
|
||||
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.
|
||||
:param name: Name of the product.
|
||||
:param price_member: Price of the product for 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.
|
||||
:raises ValueError: If a product with the same name already exists.
|
||||
"""
|
||||
|
@ -395,16 +396,17 @@ 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, stock)
|
||||
VALUES (:name, :price_member, :price_non_member, 0)
|
||||
INSERT INTO products (name, price_member, price_non_member, stock, stockable)
|
||||
VALUES (:name, :price_member, :price_non_member, 0, :stockable)
|
||||
''', {
|
||||
'name': name,
|
||||
'price_member': price_member,
|
||||
'price_non_member': price_non_member
|
||||
'price_non_member': price_non_member,
|
||||
'stockable': stockable
|
||||
})
|
||||
c.execute('SELECT last_insert_rowid()')
|
||||
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:
|
||||
"""
|
||||
|
@ -421,6 +423,7 @@ class MatematDatabase(object):
|
|||
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
|
||||
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:
|
||||
c.execute('''
|
||||
UPDATE products
|
||||
|
@ -428,14 +431,16 @@ class MatematDatabase(object):
|
|||
name = :name,
|
||||
price_member = :price_member,
|
||||
price_non_member = :price_non_member,
|
||||
stock = :stock
|
||||
stock = :stock,
|
||||
stockable = :stockable
|
||||
WHERE product_id = :product_id
|
||||
''', {
|
||||
'product_id': product.id,
|
||||
'name': name,
|
||||
'price_member': price_member,
|
||||
'price_non_member': price_non_member,
|
||||
'stock': stock
|
||||
'stock': stock,
|
||||
'stockable': stockable
|
||||
})
|
||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||
if affected != 1:
|
||||
|
@ -446,6 +451,7 @@ class MatematDatabase(object):
|
|||
product.price_member = price_member
|
||||
product.price_non_member = price_non_member
|
||||
product.stock = stock
|
||||
product.stockable = stockable
|
||||
|
||||
def delete_product(self, product: Product) -> None:
|
||||
"""
|
||||
|
@ -465,8 +471,7 @@ class MatematDatabase(object):
|
|||
|
||||
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
|
||||
the statistics table.
|
||||
Decrement the user's balance by the price of the product and create an entry in the statistics table.
|
||||
|
||||
:param user: The user buying a product.
|
||||
:param product: The product the user is buying.
|
||||
|
@ -501,44 +506,8 @@ class MatematDatabase(object):
|
|||
if affected != 1:
|
||||
raise DatabaseConsistencyError(
|
||||
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
|
||||
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:
|
||||
"""
|
||||
|
|
|
@ -239,3 +239,32 @@ def migrate_schema_3_to_4(c: sqlite3.Cursor):
|
|||
''')
|
||||
c.execute('INSERT INTO receipts SELECT * FROM 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')
|
||||
|
|
|
@ -8,15 +8,19 @@ class Product:
|
|||
:param name: The product's name.
|
||||
: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 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.name: str = name
|
||||
self.price_member: int = price_member
|
||||
self.price_non_member: int = price_non_member
|
||||
self.stock: int = stock
|
||||
self.stockable: bool = stockable
|
||||
|
||||
def __eq__(self, other) -> bool:
|
||||
if not isinstance(other, Product):
|
||||
|
@ -25,7 +29,8 @@ class Product:
|
|||
self.name == other.name and \
|
||||
self.price_member == other.price_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:
|
||||
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))
|
||||
|
|
|
@ -255,3 +255,81 @@ SCHEMAS[4] = [
|
|||
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
|
||||
);
|
||||
''']
|
||||
|
|
|
@ -220,24 +220,26 @@ class DatabaseTest(unittest.TestCase):
|
|||
def test_create_product(self) -> None:
|
||||
with self.db as db:
|
||||
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")
|
||||
row = c.fetchone()
|
||||
self.assertEqual('Club Mate', row[1])
|
||||
self.assertEqual(0, row[2])
|
||||
self.assertEqual(200, row[3])
|
||||
self.assertEqual(1, row[3])
|
||||
self.assertEqual(200, row[4])
|
||||
self.assertEqual(200, row[5])
|
||||
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:
|
||||
with self.db as db:
|
||||
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)
|
||||
self.assertEqual('Club Mate', product.name)
|
||||
self.assertEqual(150, product.price_member)
|
||||
self.assertEqual(250, product.price_non_member)
|
||||
self.assertEqual(False, product.stockable)
|
||||
with self.assertRaises(ValueError):
|
||||
db.get_product(-1)
|
||||
|
||||
|
@ -246,9 +248,9 @@ class DatabaseTest(unittest.TestCase):
|
|||
# Test empty list
|
||||
products = db.list_products()
|
||||
self.assertEqual(0, len(products))
|
||||
db.create_product('Club Mate', 200, 200)
|
||||
db.create_product('Flora Power Mate', 200, 200)
|
||||
db.create_product('Fritz Mate', 200, 250)
|
||||
db.create_product('Club Mate', 200, 200, True)
|
||||
db.create_product('Flora Power Mate', 200, 200, False)
|
||||
db.create_product('Fritz Mate', 200, 250, True)
|
||||
products = db.list_products()
|
||||
self.assertEqual(3, len(products))
|
||||
productcheck = {}
|
||||
|
@ -256,34 +258,39 @@ class DatabaseTest(unittest.TestCase):
|
|||
if product.name == 'Club Mate':
|
||||
self.assertEqual(200, product.price_member)
|
||||
self.assertEqual(200, product.price_non_member)
|
||||
self.assertTrue(product.stockable)
|
||||
elif product.name == 'Flora Power Mate':
|
||||
self.assertEqual(200, product.price_member)
|
||||
self.assertEqual(200, product.price_non_member)
|
||||
self.assertFalse(product.stockable)
|
||||
elif product.name == 'Fritz Mate':
|
||||
self.assertEqual(200, product.price_member)
|
||||
self.assertEqual(250, product.price_non_member)
|
||||
self.assertTrue(product.stockable)
|
||||
productcheck[product.id] = 1
|
||||
self.assertEqual(3, len(productcheck))
|
||||
|
||||
def test_change_product(self) -> None:
|
||||
with self.db as db:
|
||||
product = db.create_product('Club Mate', 200, 200)
|
||||
db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250, stock=42)
|
||||
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=None, stockable=False)
|
||||
# Changes must be reflected in the passed object
|
||||
self.assertEqual('Flora Power Mate', product.name)
|
||||
self.assertEqual(150, product.price_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
|
||||
checkproduct = db.get_product(product.id)
|
||||
self.assertEqual('Flora Power Mate', checkproduct.name)
|
||||
self.assertEqual(150, checkproduct.price_member)
|
||||
self.assertEqual(250, checkproduct.price_non_member)
|
||||
self.assertEqual(42, checkproduct.stock)
|
||||
self.assertEqual(None, checkproduct.stock)
|
||||
product.id = -1
|
||||
with self.assertRaises(DatabaseConsistencyError):
|
||||
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'
|
||||
with self.assertRaises(DatabaseConsistencyError):
|
||||
# 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:
|
||||
with self.db as db:
|
||||
product = db.create_product('Club Mate', 200, 200)
|
||||
product2 = db.create_product('Flora Power Mate', 200, 200)
|
||||
product = db.create_product('Club Mate', 200, 200, True)
|
||||
product2 = db.create_product('Flora Power Mate', 200, 200, False)
|
||||
|
||||
self.assertEqual(2, len(db.list_products()))
|
||||
db.delete_product(product)
|
||||
|
@ -328,25 +335,6 @@ class DatabaseTest(unittest.TestCase):
|
|||
# Should fail, user id -1 does not exist
|
||||
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:
|
||||
with self.db as db:
|
||||
# Set up test case
|
||||
|
@ -356,12 +344,9 @@ class DatabaseTest(unittest.TestCase):
|
|||
db.deposit(user1, 1337)
|
||||
db.deposit(user2, 4242)
|
||||
db.deposit(user3, 1234)
|
||||
clubmate = db.create_product('Club Mate', 200, 200)
|
||||
florapowermate = db.create_product('Flora Power Mate', 150, 250)
|
||||
fritzmate = db.create_product('Fritz Mate', 200, 200)
|
||||
db.restock(clubmate, 50)
|
||||
db.restock(florapowermate, 70)
|
||||
db.restock(fritzmate, 10)
|
||||
clubmate = db.create_product('Club Mate', 200, 200, True)
|
||||
florapowermate = db.create_product('Flora Power Mate', 150, 250, True)
|
||||
fritzmate = db.create_product('Fritz Mate', 200, 200, True)
|
||||
|
||||
# user1 is somewhat addicted to caffeine
|
||||
for _ in range(3):
|
||||
|
@ -381,19 +366,9 @@ class DatabaseTest(unittest.TestCase):
|
|||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user3.id])
|
||||
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
|
||||
clubmate.id = -1
|
||||
with self.assertRaises(DatabaseConsistencyError):
|
||||
db.increment_consumption(user1, florapowermate)
|
||||
with self.assertRaises(DatabaseConsistencyError):
|
||||
db.increment_consumption(user2, clubmate)
|
||||
|
||||
def test_check_receipt_due(self):
|
||||
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)
|
||||
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
|
||||
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)
|
||||
user3: User = db.create_user('user3', 'supersecurepassword', 'user3@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)
|
||||
club: Product = db.create_product('Club Mate', 200, 200)
|
||||
flora: Product = db.create_product('Flora Power Mate', 200, 200, True)
|
||||
club: Product = db.create_product('Club Mate', 200, 200, False)
|
||||
|
||||
# Create some transactions
|
||||
db.deposit(user1, 1337)
|
||||
|
|
|
@ -40,7 +40,7 @@ class DatabaseTransaction(object):
|
|||
|
||||
class DatabaseWrapper(object):
|
||||
|
||||
SCHEMA_VERSION = 4
|
||||
SCHEMA_VERSION = 5
|
||||
|
||||
def __init__(self, filename: str) -> None:
|
||||
self._filename: str = filename
|
||||
|
@ -85,6 +85,8 @@ class DatabaseWrapper(object):
|
|||
migrate_schema_2_to_3(c)
|
||||
if from_version <= 3 and to_version >= 4:
|
||||
migrate_schema_3_to_4(c)
|
||||
if from_version <= 4 and to_version >= 5:
|
||||
migrate_schema_4_to_5(c)
|
||||
|
||||
def connect(self) -> None:
|
||||
if self.is_connected():
|
||||
|
|
0
matemat/interfacing/__init__.py
Normal file
0
matemat/interfacing/__init__.py
Normal file
3
matemat/interfacing/dispenser/__init__.py
Normal file
3
matemat/interfacing/dispenser/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
|
||||
from .dispenser import Dispenser
|
||||
from .nulldispenser import NullDispenser
|
6
matemat/interfacing/dispenser/dispenser.py
Normal file
6
matemat/interfacing/dispenser/dispenser.py
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
|
||||
class Dispenser:
|
||||
|
||||
def dispense(self, product, amount):
|
||||
pass
|
8
matemat/interfacing/dispenser/nulldispenser.py
Normal file
8
matemat/interfacing/dispenser/nulldispenser.py
Normal file
|
@ -0,0 +1,8 @@
|
|||
from matemat.interfacing.dispenser import Dispenser
|
||||
|
||||
|
||||
class NullDispenser(Dispenser):
|
||||
|
||||
def dispense(self, product, amount):
|
||||
# no-op
|
||||
pass
|
3
matemat/interfacing/stock/__init__.py
Normal file
3
matemat/interfacing/stock/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
|
||||
from .stockprovider import StockProvider
|
||||
from .databasestockprovider import DatabaseStockProvider
|
32
matemat/interfacing/stock/databasestockprovider.py
Normal file
32
matemat/interfacing/stock/databasestockprovider.py
Normal 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)
|
40
matemat/interfacing/stock/stockprovider.py
Normal file
40
matemat/interfacing/stock/stockprovider.py
Normal 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
|
|
@ -4,10 +4,12 @@ from typing import Any, Dict, Iterable, List, Tuple, Union
|
|||
import os
|
||||
import sys
|
||||
import logging
|
||||
import importlib
|
||||
from configparser import ConfigParser
|
||||
|
||||
|
||||
config: Dict[str, Any] = dict()
|
||||
stock_provider = None
|
||||
dispenser = None
|
||||
|
||||
|
||||
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()
|
||||
# Statically configured headers
|
||||
config['headers'] = dict()
|
||||
# Pluggable interface config
|
||||
config['interfaces'] = dict()
|
||||
|
||||
# Initialize the config parser
|
||||
parser: ConfigParser = ConfigParser()
|
||||
|
@ -111,6 +115,11 @@ def parse_config_file(paths: Union[str, Iterable[str]]) -> None:
|
|||
for k, v in parser['HttpHeaders'].items():
|
||||
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]:
|
||||
global config
|
||||
|
@ -120,3 +129,27 @@ def get_config() -> Dict[str, Any]:
|
|||
def get_app_config() -> Dict[str, Any]:
|
||||
global config
|
||||
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
|
||||
|
|
|
@ -11,7 +11,7 @@ from matemat.db.primitives import User, ReceiptPreference
|
|||
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
|
||||
from matemat.util.currency_format import parse_chf
|
||||
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')
|
||||
|
@ -191,17 +191,19 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
|
|||
# The user requested to create a new product
|
||||
elif change == 'newproduct':
|
||||
# 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:
|
||||
return
|
||||
for key in ['name', 'pricemember', 'pricenonmember']:
|
||||
if key not in args:
|
||||
return
|
||||
# Read the properties from the request arguments
|
||||
name = str(args.name)
|
||||
price_member = parse_chf(str(args.pricemember))
|
||||
price_non_member = parse_chf(str(args.pricenonmember))
|
||||
stockable = 'stockable' in args
|
||||
# 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
|
||||
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
|
||||
filemagic: magic.FileMagic = magic.detect_from_content(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
|
||||
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
|
||||
if 'productid' not in args or 'amount' not in args:
|
||||
return
|
||||
|
@ -240,8 +245,9 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
|
|||
amount = int(str(args.amount))
|
||||
# Fetch the product to restock from the database
|
||||
product = db.get_product(productid)
|
||||
# Write the new stock count to the database
|
||||
db.restock(product, amount)
|
||||
if not product.stockable:
|
||||
return
|
||||
stock_provider.update_stock(product, amount)
|
||||
|
||||
# The user requested to set default images
|
||||
elif change == 'defaultimg':
|
||||
|
|
|
@ -2,7 +2,7 @@ from bottle import get, post, redirect, request
|
|||
|
||||
from matemat.db import MatematDatabase
|
||||
from matemat.webserver import session
|
||||
from matemat.webserver.config import get_app_config
|
||||
from matemat.webserver import config as c
|
||||
|
||||
|
||||
@get('/buy')
|
||||
|
@ -11,7 +11,7 @@ def buy():
|
|||
"""
|
||||
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()
|
||||
# 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'):
|
||||
|
@ -27,5 +27,9 @@ def buy():
|
|||
product = db.get_product(pid)
|
||||
# Create a consumption entry for the (user, product) combination
|
||||
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('/')
|
||||
|
|
|
@ -2,7 +2,7 @@ from bottle import route, redirect
|
|||
|
||||
from matemat.db import MatematDatabase
|
||||
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('/')
|
||||
|
@ -24,7 +24,7 @@ def main_page():
|
|||
products = db.list_products()
|
||||
# Prepare a response with a jinja2 template
|
||||
return template.render('productlist.html',
|
||||
authuser=user, products=products, authlevel=authlevel,
|
||||
authuser=user, products=products, authlevel=authlevel, stock=get_stock_provider(),
|
||||
setupname=config['InstanceName'])
|
||||
else:
|
||||
# If there are no admin users registered, jump to the admin creation procedure
|
||||
|
|
|
@ -11,7 +11,7 @@ from matemat.db.primitives import Product
|
|||
from matemat.exceptions import DatabaseConsistencyError
|
||||
from matemat.util.currency_format import parse_chf
|
||||
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')
|
||||
|
@ -86,17 +86,23 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
|
|||
# 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
|
||||
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args or 'stock' not in args:
|
||||
return
|
||||
for key in ['name', 'pricemember', 'pricenonmember']:
|
||||
if key not in args:
|
||||
return
|
||||
# Read the properties from the request arguments
|
||||
name = str(args.name)
|
||||
price_member = parse_chf(str(args.pricemember))
|
||||
price_non_member = parse_chf(str(args.pricenonmember))
|
||||
stock = int(str(args.stock))
|
||||
stockable = 'stockable' in args
|
||||
# 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, 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:
|
||||
return
|
||||
# If a new product image was uploaded, process it
|
||||
|
|
|
@ -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]:
|
||||
url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs'
|
||||
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)
|
||||
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:
|
||||
req = urllib.request.Request(url)
|
||||
headers: Dict[str, str] = {
|
||||
'User-Agent': 'curl/7.70.0'
|
||||
}
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
try:
|
||||
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
|
||||
except HTTPError as e:
|
||||
|
@ -138,7 +142,8 @@ def main():
|
|||
f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release'
|
||||
headers: Dict[str, str] = {
|
||||
'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(
|
||||
|
|
|
@ -49,6 +49,9 @@
|
|||
<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/>
|
||||
|
||||
<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>
|
||||
<input id="admin-newproduct-image" name="image" type="file" accept="image/*" /><br/>
|
||||
|
||||
|
@ -63,7 +66,9 @@
|
|||
<label for="admin-restock-productid">Product: </label>
|
||||
<select id="admin-restock-productid" name="productid">
|
||||
{% for product in products %}
|
||||
{% if product.stockable %}
|
||||
<option value="{{ product.id }}">{{ product.name }} ({{ product.stock }})</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select><br/>
|
||||
|
||||
|
|
|
@ -20,6 +20,9 @@
|
|||
<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/>
|
||||
|
||||
<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>
|
||||
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
|
||||
|
||||
|
|
|
@ -34,7 +34,9 @@
|
|||
</span><br/>
|
||||
<div class="imgcontainer">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue