Merge branch 'staging' into 'master'
Release 0.2.5 See merge request s3lph/matemat!74
This commit is contained in:
commit
7cb83c9136
25 changed files with 358 additions and 144 deletions
|
@ -118,21 +118,6 @@ build_archlinux:
|
||||||
- tags
|
- tags
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
staging:
|
|
||||||
stage: deploy
|
|
||||||
script:
|
|
||||||
- eval $(ssh-agent -s)
|
|
||||||
- ssh-add - <<<"$STAGING_SSH_PRIVATE_KEY"
|
|
||||||
- echo "$CI_COMMIT_SHA" | ssh -p 20022 -oStrictHostKeyChecking=no matemat@kernelpanic.lol
|
|
||||||
environment:
|
|
||||||
name: staging
|
|
||||||
url: https://matemat.kernelpanic.lol/
|
|
||||||
only:
|
|
||||||
- staging
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
script:
|
script:
|
||||||
|
|
15
CHANGELOG.md
15
CHANGELOG.md
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
__version__ = '0.2.4'
|
__version__ = '0.2.5'
|
||||||
|
|
|
@ -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:
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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))
|
||||||
|
|
|
@ -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
|
||||||
|
);
|
||||||
|
''']
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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():
|
||||||
|
|
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 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
|
||||||
|
|
|
@ -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']:
|
||||||
|
if key not in args:
|
||||||
return
|
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':
|
||||||
|
|
|
@ -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('/')
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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']:
|
||||||
|
if key not in args:
|
||||||
return
|
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
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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/>
|
||||||
|
|
||||||
|
|
|
@ -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/>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue