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 @@
Picture of {{ product.name }} - {{ product.stock }} + {% if product.stockable %} + {{ stock.get_stock(product) }} + {% endif %}