diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 06a3c1c..5a5691a 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -118,21 +118,6 @@ build_archlinux:
- 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:
stage: deploy
script:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ecf0cf5..1b2b0a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,20 @@
# Matemat Changelog
+
+## Version 0.2.5
+
+Feature release
+
+### Changes
+
+
+- Feature: Non-stockable products
+- Feature: Pluggable stock provider and dispenser modules
+- Fix: Products creation raised an error if no image was uploaded
+
+
+
+
## Version 0.2.4
diff --git a/matemat/__init__.py b/matemat/__init__.py
index 5e42c43..6c0a1ad 100644
--- a/matemat/__init__.py
+++ b/matemat/__init__.py
@@ -1,2 +1,2 @@
-__version__ = '0.2.4'
+__version__ = '0.2.5'
diff --git a/matemat/db/facade.py b/matemat/db/facade.py
index 78c51d0..43a150f 100644
--- a/matemat/db/facade.py
+++ b/matemat/db/facade.py
@@ -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:
"""
diff --git a/matemat/db/migrations.py b/matemat/db/migrations.py
index 584f4ed..6775424 100644
--- a/matemat/db/migrations.py
+++ b/matemat/db/migrations.py
@@ -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')
diff --git a/matemat/db/primitives/Product.py b/matemat/db/primitives/Product.py
index ed196d4..323d4f7 100644
--- a/matemat/db/primitives/Product.py
+++ b/matemat/db/primitives/Product.py
@@ -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))
diff --git a/matemat/db/schemas.py b/matemat/db/schemas.py
index d47abbd..cccb638 100644
--- a/matemat/db/schemas.py
+++ b/matemat/db/schemas.py
@@ -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
+ );
+ ''']
diff --git a/matemat/db/test/test_facade.py b/matemat/db/test/test_facade.py
index 29c38e2..d3e34fb 100644
--- a/matemat/db/test/test_facade.py
+++ b/matemat/db/test/test_facade.py
@@ -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)
diff --git a/matemat/db/wrapper.py b/matemat/db/wrapper.py
index 94c6693..6f09e07 100644
--- a/matemat/db/wrapper.py
+++ b/matemat/db/wrapper.py
@@ -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():
diff --git a/matemat/interfacing/__init__.py b/matemat/interfacing/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/matemat/interfacing/dispenser/__init__.py b/matemat/interfacing/dispenser/__init__.py
new file mode 100644
index 0000000..226eaee
--- /dev/null
+++ b/matemat/interfacing/dispenser/__init__.py
@@ -0,0 +1,3 @@
+
+from .dispenser import Dispenser
+from .nulldispenser import NullDispenser
diff --git a/matemat/interfacing/dispenser/dispenser.py b/matemat/interfacing/dispenser/dispenser.py
new file mode 100644
index 0000000..0ca8f9e
--- /dev/null
+++ b/matemat/interfacing/dispenser/dispenser.py
@@ -0,0 +1,6 @@
+
+
+class Dispenser:
+
+ def dispense(self, product, amount):
+ pass
diff --git a/matemat/interfacing/dispenser/nulldispenser.py b/matemat/interfacing/dispenser/nulldispenser.py
new file mode 100644
index 0000000..3b5fdfb
--- /dev/null
+++ b/matemat/interfacing/dispenser/nulldispenser.py
@@ -0,0 +1,8 @@
+from matemat.interfacing.dispenser import Dispenser
+
+
+class NullDispenser(Dispenser):
+
+ def dispense(self, product, amount):
+ # no-op
+ pass
diff --git a/matemat/interfacing/stock/__init__.py b/matemat/interfacing/stock/__init__.py
new file mode 100644
index 0000000..88afdb8
--- /dev/null
+++ b/matemat/interfacing/stock/__init__.py
@@ -0,0 +1,3 @@
+
+from .stockprovider import StockProvider
+from .databasestockprovider import DatabaseStockProvider
diff --git a/matemat/interfacing/stock/databasestockprovider.py b/matemat/interfacing/stock/databasestockprovider.py
new file mode 100644
index 0000000..25b6f2d
--- /dev/null
+++ b/matemat/interfacing/stock/databasestockprovider.py
@@ -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)
diff --git a/matemat/interfacing/stock/stockprovider.py b/matemat/interfacing/stock/stockprovider.py
new file mode 100644
index 0000000..d8041f0
--- /dev/null
+++ b/matemat/interfacing/stock/stockprovider.py
@@ -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
diff --git a/matemat/webserver/config.py b/matemat/webserver/config.py
index fe48322..d42cf8a 100644
--- a/matemat/webserver/config.py
+++ b/matemat/webserver/config.py
@@ -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
diff --git a/matemat/webserver/pagelets/admin.py b/matemat/webserver/pagelets/admin.py
index 95c130f..9b598f5 100644
--- a/matemat/webserver/pagelets/admin.py
+++ b/matemat/webserver/pagelets/admin.py
@@ -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':
diff --git a/matemat/webserver/pagelets/buy.py b/matemat/webserver/pagelets/buy.py
index db47ca0..bd64b2e 100644
--- a/matemat/webserver/pagelets/buy.py
+++ b/matemat/webserver/pagelets/buy.py
@@ -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('/')
diff --git a/matemat/webserver/pagelets/main.py b/matemat/webserver/pagelets/main.py
index 49ddd80..3207e48 100644
--- a/matemat/webserver/pagelets/main.py
+++ b/matemat/webserver/pagelets/main.py
@@ -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
diff --git a/matemat/webserver/pagelets/modproduct.py b/matemat/webserver/pagelets/modproduct.py
index 6139eaf..57afc0f 100644
--- a/matemat/webserver/pagelets/modproduct.py
+++ b/matemat/webserver/pagelets/modproduct.py
@@ -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
diff --git a/package/release.py b/package/release.py
index 8461268..6f8f46f 100755
--- a/package/release.py
+++ b/package/release.py
@@ -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(
diff --git a/templates/admin_restricted.html b/templates/admin_restricted.html
index 31f1240..adf06ef 100644
--- a/templates/admin_restricted.html
+++ b/templates/admin_restricted.html
@@ -49,6 +49,9 @@
CHF
+
+
+
@@ -63,7 +66,9 @@
diff --git a/templates/modproduct.html b/templates/modproduct.html
index ab49953..7f80c67 100644
--- a/templates/modproduct.html
+++ b/templates/modproduct.html
@@ -20,6 +20,9 @@
CHF
+
+
+
diff --git a/templates/productlist.html b/templates/productlist.html
index 552856d..51ae56f 100644
--- a/templates/productlist.html
+++ b/templates/productlist.html
@@ -34,7 +34,9 @@