Merge branch '15-record-each-sale-individually' into 'staging-unstable'
Resolve "Record each sale individually" See merge request s3lph/matemat!21
This commit is contained in:
commit
ddc3e17846
10 changed files with 441 additions and 101 deletions
2
doc
2
doc
|
@ -1 +1 @@
|
||||||
Subproject commit 5335524d3e57c7551f31c7e21fc04c464b23429a
|
Subproject commit 2ce0e26b101192b92061c299ffc0e5524104f215
|
|
@ -232,7 +232,7 @@ class MatematDatabase(object):
|
||||||
'tkhash': tkhash
|
'tkhash': tkhash
|
||||||
})
|
})
|
||||||
|
|
||||||
def change_user(self, user: User, **kwargs)\
|
def change_user(self, user: User, agent: User, **kwargs)\
|
||||||
-> None:
|
-> None:
|
||||||
"""
|
"""
|
||||||
Commit changes to the user in the database. If writing the requested changes succeeded, the values are updated
|
Commit changes to the user in the database. If writing the requested changes succeeded, the values are updated
|
||||||
|
@ -240,6 +240,7 @@ class MatematDatabase(object):
|
||||||
the ID field in the provided user object.
|
the ID field in the provided user object.
|
||||||
|
|
||||||
:param user: The user object to update and to identify the requested user by.
|
:param user: The user object to update and to identify the requested user by.
|
||||||
|
:param agent: The user that is performing the change.
|
||||||
:param kwargs: The properties to change.
|
:param kwargs: The properties to change.
|
||||||
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
|
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
|
||||||
"""
|
"""
|
||||||
|
@ -250,6 +251,25 @@ class MatematDatabase(object):
|
||||||
is_admin: bool = kwargs['is_admin'] if 'is_admin' in kwargs else user.is_admin
|
is_admin: bool = kwargs['is_admin'] if 'is_admin' in kwargs else user.is_admin
|
||||||
is_member: bool = kwargs['is_member'] if 'is_member' in kwargs else user.is_member
|
is_member: bool = kwargs['is_member'] if 'is_member' in kwargs else user.is_member
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
|
c.execute('SELECT balance FROM users WHERE user_id = :user_id', {'user_id': user.id})
|
||||||
|
row = c.fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise DatabaseConsistencyError(f'User with ID {user.id} does not exist')
|
||||||
|
oldbalance: int = row[0]
|
||||||
|
if balance != oldbalance:
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO transactions (user_id, value, old_balance)
|
||||||
|
VALUES (:user_id, :value, :old_balance)
|
||||||
|
''', {
|
||||||
|
'user_id': user.id,
|
||||||
|
'value': balance - oldbalance,
|
||||||
|
'old_balance': oldbalance
|
||||||
|
})
|
||||||
|
# TODO: Implement reason field
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO modifications (ta_id, agent_id, reason)
|
||||||
|
VALUES (last_insert_rowid(), :agent_id, NULL)
|
||||||
|
''', {'agent_id': agent.id})
|
||||||
c.execute('''
|
c.execute('''
|
||||||
UPDATE users SET
|
UPDATE users SET
|
||||||
username = :username,
|
username = :username,
|
||||||
|
@ -411,60 +431,38 @@ class MatematDatabase(object):
|
||||||
raise DatabaseConsistencyError(
|
raise DatabaseConsistencyError(
|
||||||
f'delete_product should affect 1 products row, but affected {affected}')
|
f'delete_product should affect 1 products row, but affected {affected}')
|
||||||
|
|
||||||
def increment_consumption(self, user: User, product: Product, count: int = 1) -> 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, decrement the products stock, 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.
|
||||||
:param count: How many units of the product the user is buying, defaults to 1.
|
|
||||||
:raises DatabaseConsistencyError: If the user or the product does not exist in the database.
|
:raises DatabaseConsistencyError: If the user or the product does not exist in the database.
|
||||||
"""
|
"""
|
||||||
|
price: int = product.price_member if user.is_member else product.price_non_member
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
# Retrieve the consumption entry for the (user, product) pair, if any.
|
|
||||||
c.execute('''
|
c.execute('''
|
||||||
SELECT count
|
INSERT INTO transactions (user_id, value, old_balance)
|
||||||
FROM consumption
|
VALUES (:user_id, :value, :old_balance)
|
||||||
WHERE user_id = :user_id
|
|
||||||
AND product_id = :product_id
|
|
||||||
''', {
|
''', {
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
|
'value': -price,
|
||||||
|
'old_balance': user.balance
|
||||||
|
})
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO consumptions (ta_id, product_id)
|
||||||
|
VALUES (last_insert_rowid(), :product_id)
|
||||||
|
''', {
|
||||||
'product_id': product.id
|
'product_id': product.id
|
||||||
})
|
})
|
||||||
row = c.fetchone()
|
# Subtract the price from the user's account balance.
|
||||||
if row is None:
|
|
||||||
# If the entry does not exist, create a new one.
|
|
||||||
c.execute('''
|
|
||||||
INSERT INTO consumption (user_id, product_id, count)
|
|
||||||
VALUES (:user_id, :product_id, :count)
|
|
||||||
''', {
|
|
||||||
'user_id': user.id,
|
|
||||||
'product_id': product.id,
|
|
||||||
'count': count
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
# If the entry exists, update the consumption count.
|
|
||||||
c.execute('''
|
|
||||||
UPDATE consumption
|
|
||||||
SET count = count + :count
|
|
||||||
WHERE user_id = :user_id AND product_id = :product_id
|
|
||||||
''', {
|
|
||||||
'user_id': user.id,
|
|
||||||
'product_id': product.id,
|
|
||||||
'count': count
|
|
||||||
})
|
|
||||||
# Make sure exactly one consumption row was updated/inserted.
|
|
||||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
|
||||||
if affected != 1:
|
|
||||||
raise DatabaseConsistencyError(
|
|
||||||
f'increment_consumption should affect 1 consumption row, but affected {affected}')
|
|
||||||
# Compute the cost of the transaction and subtract it from the user's account balance.
|
|
||||||
c.execute('''
|
c.execute('''
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET balance = balance - :cost
|
SET balance = balance - :cost
|
||||||
WHERE user_id = :user_id''', {
|
WHERE user_id = :user_id''', {
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
'cost': count * product.price_member if user.is_member else count * product.price_non_member
|
'cost': price
|
||||||
})
|
})
|
||||||
# Make sure exactly one user row was updated.
|
# Make sure exactly one user row was updated.
|
||||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||||
|
@ -474,11 +472,10 @@ class MatematDatabase(object):
|
||||||
# Subtract the number of purchased units from the product's stock.
|
# Subtract the number of purchased units from the product's stock.
|
||||||
c.execute('''
|
c.execute('''
|
||||||
UPDATE products
|
UPDATE products
|
||||||
SET stock = stock - :count
|
SET stock = stock - 1
|
||||||
WHERE product_id = :product_id
|
WHERE product_id = :product_id
|
||||||
''', {
|
''', {
|
||||||
'product_id': product.id,
|
'product_id': product.id,
|
||||||
'count': count
|
|
||||||
})
|
})
|
||||||
# Make sure exactly one product row was updated.
|
# Make sure exactly one product row was updated.
|
||||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||||
|
@ -509,6 +506,7 @@ class MatematDatabase(object):
|
||||||
def deposit(self, user: User, amount: int) -> None:
|
def deposit(self, user: User, amount: int) -> None:
|
||||||
"""
|
"""
|
||||||
Update the account balance of a user.
|
Update the account balance of a user.
|
||||||
|
|
||||||
:param user: The user to update the account balance for.
|
:param user: The user to update the account balance for.
|
||||||
:param amount: The amount to add to the account balance.
|
:param amount: The amount to add to the account balance.
|
||||||
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
|
:raises DatabaseConsistencyError: If the user represented by the object does not exist.
|
||||||
|
@ -516,6 +514,18 @@ class MatematDatabase(object):
|
||||||
if amount < 0:
|
if amount < 0:
|
||||||
raise ValueError('Cannot deposit a negative value')
|
raise ValueError('Cannot deposit a negative value')
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO transactions (user_id, value, old_balance)
|
||||||
|
VALUES (:user_id, :value, :old_balance)
|
||||||
|
''', {
|
||||||
|
'user_id': user.id,
|
||||||
|
'value': amount,
|
||||||
|
'old_balance': user.balance
|
||||||
|
})
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO deposits (ta_id)
|
||||||
|
VALUES (last_insert_rowid())
|
||||||
|
''')
|
||||||
c.execute('''
|
c.execute('''
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET balance = balance + :amount
|
SET balance = balance + :amount
|
||||||
|
|
115
matemat/db/migrations.py
Normal file
115
matemat/db/migrations.py
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_schema_1_to_2(c: sqlite3.Cursor):
|
||||||
|
# Create missing tables
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE transactions (
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT 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 CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''')
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE consumptions (
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
product_id INTEGER DEFAULT NULL,
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(product_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''')
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE deposits (
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''')
|
||||||
|
c.execute('''
|
||||||
|
CREATE TABLE modifications (
|
||||||
|
ta_id INTEGER NOT NULL,
|
||||||
|
agent_id INTEGER NOT NULL,
|
||||||
|
reason TEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (ta_id),
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES users(user_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''')
|
||||||
|
|
||||||
|
#
|
||||||
|
# Convert entries from the old consumption table into entries for the new consumptions table
|
||||||
|
#
|
||||||
|
|
||||||
|
# Fetch current users, their balance and membership status
|
||||||
|
c.execute('SELECT user_id, balance, is_member FROM users')
|
||||||
|
balances: Dict[int, int] = dict()
|
||||||
|
memberships: Dict[int, bool] = dict()
|
||||||
|
for user_id, balance, member in c:
|
||||||
|
balances[user_id] = balance
|
||||||
|
memberships[user_id] = bool(member)
|
||||||
|
|
||||||
|
# Fetch current products and their prices
|
||||||
|
c.execute('SELECT product_id, price_member, price_non_member FROM products')
|
||||||
|
prices_member: Dict[int, int] = dict()
|
||||||
|
prices_non_member: Dict[int, int] = dict()
|
||||||
|
for product_id, price_member, price_non_member in c:
|
||||||
|
prices_member[product_id] = price_member
|
||||||
|
prices_non_member[product_id] = price_non_member
|
||||||
|
|
||||||
|
# As the following migration does reverse insertions, compute the max. primary key that can occur, and
|
||||||
|
# further down count downward from there
|
||||||
|
c.execute('SELECT SUM(count) FROM consumption')
|
||||||
|
ta_id: int = c.fetchone()[0]
|
||||||
|
|
||||||
|
# Iterate (users x products)
|
||||||
|
for user_id in balances.keys():
|
||||||
|
member: bool = memberships[user_id]
|
||||||
|
for product_id in prices_member:
|
||||||
|
price: int = prices_member[product_id] if member else prices_non_member[product_id]
|
||||||
|
|
||||||
|
# Select the number of items the user has bought from this product
|
||||||
|
c.execute('''
|
||||||
|
SELECT consumption.count FROM consumption
|
||||||
|
WHERE user_id = :user_id AND product_id = :product_id
|
||||||
|
''', {
|
||||||
|
'user_id': user_id,
|
||||||
|
'product_id': product_id
|
||||||
|
})
|
||||||
|
row = c.fetchone()
|
||||||
|
if row is not None:
|
||||||
|
count: int = row[0]
|
||||||
|
# Insert one row per bought item, setting the date to NULL, as it is not known
|
||||||
|
for _ in range(count):
|
||||||
|
# This migration "goes back in time", so after processing a purchase entry, "locally
|
||||||
|
# refund" the payment to reconstruct the "older" entries
|
||||||
|
balances[user_id] += price
|
||||||
|
# Insert into base table
|
||||||
|
c.execute('''
|
||||||
|
INSERT INTO transactions (ta_id, user_id, value, old_balance, date)
|
||||||
|
VALUES (:ta_id, :user_id, :value, :old_balance, NULL)
|
||||||
|
''', {
|
||||||
|
'ta_id': ta_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'value': -price,
|
||||||
|
'old_balance': balances[user_id]
|
||||||
|
})
|
||||||
|
# Insert into specialization table
|
||||||
|
c.execute('INSERT INTO consumptions (ta_id, product_id) VALUES (:ta_id, :product_id)', {
|
||||||
|
'ta_id': ta_id,
|
||||||
|
'product_id': product_id
|
||||||
|
})
|
||||||
|
# Decrement the transaction table insertion primary key
|
||||||
|
ta_id -= 1
|
||||||
|
# Drop the old consumption table
|
||||||
|
c.execute('DROP TABLE consumption')
|
103
matemat/db/schemas.py
Normal file
103
matemat/db/schemas.py
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
SCHEMAS: Dict[int, List[str]] = dict()
|
||||||
|
|
||||||
|
SCHEMAS[1] = [
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE products (
|
||||||
|
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
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE consumption (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
product_id INTEGER NOT NULL,
|
||||||
|
count INTEGER(8) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, product_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(product_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''']
|
||||||
|
|
||||||
|
SCHEMAS[2] = [
|
||||||
|
'''
|
||||||
|
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
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE products (
|
||||||
|
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
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE transactions ( -- "superclass" of the following 3 tables
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT 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 CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE consumptions ( -- transactions involving buying a product
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
product_id INTEGER DEFAULT NULL,
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(product_id)
|
||||||
|
ON DELETE SET NULL 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_id INTEGER NOT NULL,
|
||||||
|
reason TEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (ta_id),
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (agent_id) REFERENCES users(user_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''']
|
|
@ -143,18 +143,23 @@ class DatabaseTest(unittest.TestCase):
|
||||||
|
|
||||||
def test_change_user(self) -> None:
|
def test_change_user(self) -> None:
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
|
agent = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True)
|
||||||
user = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com', True, True)
|
user = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com', True, True)
|
||||||
user.email = 'newaddress@example.com'
|
db.change_user(user, agent, email='newaddress@example.com', is_admin=False, is_member=False, balance=4200)
|
||||||
user.is_admin = False
|
# Changes must be reflected in the passed user object
|
||||||
user.is_member = False
|
self.assertEqual('newaddress@example.com', user.email)
|
||||||
db.change_user(user)
|
self.assertFalse(user.is_admin)
|
||||||
checkuser = db.login('testuser', 'supersecurepassword')
|
self.assertFalse(user.is_member)
|
||||||
self.assertEqual('newaddress@example.com', checkuser.email)
|
self.assertEqual(4200, user.balance)
|
||||||
|
# Changes must be reflected in the database
|
||||||
|
checkuser = db.get_user(user.id)
|
||||||
|
self.assertEqual('newaddress@example.com', user.email)
|
||||||
self.assertFalse(checkuser.is_admin)
|
self.assertFalse(checkuser.is_admin)
|
||||||
self.assertFalse(checkuser.is_member)
|
self.assertFalse(checkuser.is_member)
|
||||||
|
self.assertEqual(4200, checkuser.balance)
|
||||||
user._user_id = -1
|
user._user_id = -1
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
db.change_user(user)
|
db.change_user(user, agent, is_member='True')
|
||||||
|
|
||||||
def test_delete_user(self) -> None:
|
def test_delete_user(self) -> None:
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
|
@ -221,14 +226,18 @@ class DatabaseTest(unittest.TestCase):
|
||||||
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)
|
||||||
product.name = 'Flora Power Mate'
|
db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250, stock=42)
|
||||||
product.price_member = 150
|
# Changes must be reflected in the passed object
|
||||||
product.price_non_member = 250
|
self.assertEqual('Flora Power Mate', product.name)
|
||||||
db.change_product(product)
|
self.assertEqual(150, product.price_member)
|
||||||
checkproduct = db.list_products()[0]
|
self.assertEqual(250, product.price_non_member)
|
||||||
|
self.assertEqual(42, product.stock)
|
||||||
|
# Changes must be reflected in the database
|
||||||
|
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)
|
||||||
product._product_id = -1
|
product._product_id = -1
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
db.change_product(product)
|
db.change_product(product)
|
||||||
|
@ -313,14 +322,14 @@ class DatabaseTest(unittest.TestCase):
|
||||||
db.restock(fritzmate, 10)
|
db.restock(fritzmate, 10)
|
||||||
|
|
||||||
# user1 is somewhat addicted to caffeine
|
# user1 is somewhat addicted to caffeine
|
||||||
db.increment_consumption(user1, clubmate, 1)
|
for _ in range(3):
|
||||||
db.increment_consumption(user1, clubmate, 2)
|
db.increment_consumption(user1, clubmate)
|
||||||
db.increment_consumption(user1, florapowermate, 3)
|
db.increment_consumption(user1, florapowermate)
|
||||||
|
|
||||||
# user2 is reeeally addicted
|
# user2 is reeeally addicted
|
||||||
db.increment_consumption(user2, clubmate, 7)
|
for _ in range(7):
|
||||||
db.increment_consumption(user2, florapowermate, 3)
|
db.increment_consumption(user2, clubmate)
|
||||||
db.increment_consumption(user2, florapowermate, 4)
|
db.increment_consumption(user2, florapowermate)
|
||||||
|
|
||||||
with db.transaction(exclusive=False) as c:
|
with db.transaction(exclusive=False) as c:
|
||||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user1.id])
|
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user1.id])
|
||||||
|
|
124
matemat/db/test/test_migrations.py
Normal file
124
matemat/db/test/test_migrations.py
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from matemat.db import DatabaseWrapper
|
||||||
|
from matemat.db.schemas import SCHEMAS
|
||||||
|
|
||||||
|
|
||||||
|
class TestMigrations(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Create an in-memory database for testing
|
||||||
|
self.db = DatabaseWrapper(':memory:')
|
||||||
|
|
||||||
|
def _initialize_db(self, schema_version: int):
|
||||||
|
self.db._sqlite_db = sqlite3.connect(':memory:')
|
||||||
|
cursor: sqlite3.Cursor = self.db._sqlite_db.cursor()
|
||||||
|
cursor.execute('BEGIN EXCLUSIVE')
|
||||||
|
for cmd in SCHEMAS[schema_version]:
|
||||||
|
cursor.execute(cmd)
|
||||||
|
cursor.execute('COMMIT')
|
||||||
|
|
||||||
|
def test_downgrade_fail(self):
|
||||||
|
# Test that downgrades are forbidden
|
||||||
|
self.db.SCHEMA_VERSION = 1
|
||||||
|
self.db._sqlite_db = sqlite3.connect(':memory:')
|
||||||
|
self.db._sqlite_db.execute('PRAGMA user_version = 2')
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
with self.db:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_upgrade_1_to_2(self):
|
||||||
|
# Setup test db with example entries covering - hopefully - all cases
|
||||||
|
self._initialize_db(1)
|
||||||
|
cursor: sqlite3.Cursor = self.db._sqlite_db.cursor()
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO users VALUES
|
||||||
|
(1, 'testadmin', 'a@b.c', '$2a$10$herebehashes', NULL, 1, 1, 1337, 0),
|
||||||
|
(2, 'testuser', NULL, '$2a$10$herebehashes', '$2a$10$herebehashes', 0, 1, 4242, 0),
|
||||||
|
(3, 'alien', NULL, '$2a$10$herebehashes', '$2a$10$herebehashes', 0, 0, 1234, 0)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO products VALUES
|
||||||
|
(1, 'Club Mate', 42, 200, 250),
|
||||||
|
(2, 'Flora Power Mate (1/4l)', 10, 100, 150)
|
||||||
|
''')
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO consumption VALUES
|
||||||
|
(1, 1, 5), (1, 2, 3), (2, 2, 10), (3, 1, 3), (3, 2, 4)
|
||||||
|
''')
|
||||||
|
cursor.execute('PRAGMA user_version = 1')
|
||||||
|
|
||||||
|
# Kick off the migration
|
||||||
|
self.db._setup()
|
||||||
|
|
||||||
|
# Test whether the new tables were created
|
||||||
|
cursor.execute('PRAGMA table_info(transactions)')
|
||||||
|
self.assertNotEqual(0, len(cursor.fetchall()))
|
||||||
|
cursor.execute('PRAGMA table_info(consumptions)')
|
||||||
|
self.assertNotEqual(0, len(cursor.fetchall()))
|
||||||
|
cursor.execute('PRAGMA table_info(deposits)')
|
||||||
|
self.assertNotEqual(0, len(cursor.fetchall()))
|
||||||
|
cursor.execute('PRAGMA table_info(modifications)')
|
||||||
|
self.assertNotEqual(0, len(cursor.fetchall()))
|
||||||
|
# Test whether the old consumption table was dropped
|
||||||
|
cursor.execute('PRAGMA table_info(consumption)')
|
||||||
|
self.assertEqual(0, len(cursor.fetchall()))
|
||||||
|
|
||||||
|
# Test number of entries in the new tables
|
||||||
|
cursor.execute('SELECT COUNT(ta_id) FROM transactions')
|
||||||
|
self.assertEqual(25, cursor.fetchone()[0])
|
||||||
|
cursor.execute('SELECT COUNT(ta_id) FROM consumptions')
|
||||||
|
self.assertEqual(25, cursor.fetchone()[0])
|
||||||
|
cursor.execute('SELECT COUNT(ta_id) FROM deposits')
|
||||||
|
self.assertEqual(0, cursor.fetchone()[0])
|
||||||
|
cursor.execute('SELECT COUNT(ta_id) FROM modifications')
|
||||||
|
self.assertEqual(0, cursor.fetchone()[0])
|
||||||
|
|
||||||
|
# The (user_id=2 x product_id=1) combination should never appear
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COUNT(t.ta_id)
|
||||||
|
FROM transactions AS t
|
||||||
|
LEFT JOIN consumptions AS c
|
||||||
|
ON t.ta_id = c.ta_id
|
||||||
|
WHERE t.user_id = 2 AND c.product_id = 1''')
|
||||||
|
self.assertEqual(0, cursor.fetchone()[0])
|
||||||
|
|
||||||
|
# Test that one entry per consumption was created, and their values match the negative price
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COUNT(t.ta_id)
|
||||||
|
FROM transactions AS t
|
||||||
|
LEFT JOIN consumptions AS c
|
||||||
|
ON t.ta_id = c.ta_id
|
||||||
|
WHERE t.user_id = 1 AND c.product_id = 1 AND t.value = -200''')
|
||||||
|
self.assertEqual(5, cursor.fetchone()[0])
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COUNT(t.ta_id)
|
||||||
|
FROM transactions AS t
|
||||||
|
LEFT JOIN consumptions AS c
|
||||||
|
ON t.ta_id = c.ta_id
|
||||||
|
WHERE t.user_id = 1 AND c.product_id = 2 AND t.value = -100''')
|
||||||
|
self.assertEqual(3, cursor.fetchone()[0])
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COUNT(t.ta_id)
|
||||||
|
FROM transactions AS t
|
||||||
|
LEFT JOIN consumptions AS c
|
||||||
|
ON t.ta_id = c.ta_id
|
||||||
|
WHERE t.user_id = 2 AND c.product_id = 2 AND t.value = -100''')
|
||||||
|
self.assertEqual(10, cursor.fetchone()[0])
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COUNT(t.ta_id)
|
||||||
|
FROM transactions AS t
|
||||||
|
LEFT JOIN consumptions AS c
|
||||||
|
ON t.ta_id = c.ta_id
|
||||||
|
WHERE t.user_id = 3 AND c.product_id = 1 AND t.value = -250''')
|
||||||
|
self.assertEqual(3, cursor.fetchone()[0])
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT COUNT(t.ta_id)
|
||||||
|
FROM transactions AS t
|
||||||
|
LEFT JOIN consumptions AS c
|
||||||
|
ON t.ta_id = c.ta_id
|
||||||
|
WHERE t.user_id = 3 AND c.product_id = 2 AND t.value = -150''')
|
||||||
|
self.assertEqual(4, cursor.fetchone()[0])
|
|
@ -1,6 +1,8 @@
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
from matemat.db import DatabaseWrapper
|
from matemat.db import DatabaseWrapper
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,8 @@ from typing import Any, Optional
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from matemat.exceptions import DatabaseConsistencyError
|
from matemat.exceptions import DatabaseConsistencyError
|
||||||
|
from matemat.db.schemas import SCHEMAS
|
||||||
|
from matemat.db.migrations import migrate_schema_1_to_2
|
||||||
|
|
||||||
|
|
||||||
class Transaction(object):
|
class Transaction(object):
|
||||||
|
@ -39,38 +41,8 @@ class Transaction(object):
|
||||||
|
|
||||||
|
|
||||||
class DatabaseWrapper(object):
|
class DatabaseWrapper(object):
|
||||||
SCHEMA_VERSION = 1
|
|
||||||
|
|
||||||
SCHEMA = '''
|
SCHEMA_VERSION = 2
|
||||||
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
|
|
||||||
);
|
|
||||||
CREATE TABLE products (
|
|
||||||
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
|
|
||||||
);
|
|
||||||
CREATE TABLE consumption (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
product_id INTEGER NOT NULL,
|
|
||||||
count INTEGER(8) NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (user_id, product_id),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (product_id) REFERENCES products(product_id)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
);
|
|
||||||
'''
|
|
||||||
|
|
||||||
def __init__(self, filename: str) -> None:
|
def __init__(self, filename: str) -> None:
|
||||||
self._filename: str = filename
|
self._filename: str = filename
|
||||||
|
@ -92,13 +64,20 @@ class DatabaseWrapper(object):
|
||||||
with self.transaction() as c:
|
with self.transaction() as c:
|
||||||
version: int = self._user_version
|
version: int = self._user_version
|
||||||
if version < 1:
|
if version < 1:
|
||||||
c.executescript(self.SCHEMA)
|
# Don't use executescript, as it issues a COMMIT first
|
||||||
|
for command in SCHEMAS[self.SCHEMA_VERSION]:
|
||||||
|
c.execute(command)
|
||||||
elif version < self.SCHEMA_VERSION:
|
elif version < self.SCHEMA_VERSION:
|
||||||
self._upgrade(old=version, new=self.SCHEMA_VERSION)
|
self._upgrade(from_version=version, to_version=self.SCHEMA_VERSION)
|
||||||
|
elif version > self.SCHEMA_VERSION:
|
||||||
|
raise RuntimeError('Database schema is newer than supported by this version of Matemat.')
|
||||||
self._user_version = self.SCHEMA_VERSION
|
self._user_version = self.SCHEMA_VERSION
|
||||||
|
|
||||||
def _upgrade(self, old: int, new: int) -> None:
|
def _upgrade(self, from_version: int, to_version: int) -> None:
|
||||||
pass
|
with self.transaction() as c:
|
||||||
|
# Note to future s3lph: If there are further migrations, also consider upgrades like 1 -> 3
|
||||||
|
if from_version == 1 and to_version == 2:
|
||||||
|
migrate_schema_1_to_2(c)
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
if self.is_connected():
|
if self.is_connected():
|
||||||
|
|
|
@ -17,12 +17,8 @@ def buy(method: str,
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
uid: int = session_vars['authenticated_user']
|
uid: int = session_vars['authenticated_user']
|
||||||
user = db.get_user(uid)
|
user = db.get_user(uid)
|
||||||
if 'n' in args:
|
|
||||||
n = int(str(args.n))
|
|
||||||
else:
|
|
||||||
n = 1
|
|
||||||
if 'pid' in args:
|
if 'pid' in args:
|
||||||
pid = int(str(args.pid))
|
pid = int(str(args.pid))
|
||||||
product = db.get_product(pid)
|
product = db.get_product(pid)
|
||||||
db.increment_consumption(user, product, n)
|
db.increment_consumption(user, product)
|
||||||
return RedirectResponse('/')
|
return RedirectResponse('/')
|
||||||
|
|
|
@ -119,10 +119,12 @@ class MockServer:
|
||||||
# Set up logger
|
# Set up logger
|
||||||
self.logger: logging.Logger = logging.getLogger('matemat unit test')
|
self.logger: logging.Logger = logging.getLogger('matemat unit test')
|
||||||
self.logger.setLevel(logging.DEBUG)
|
self.logger.setLevel(logging.DEBUG)
|
||||||
|
# Disable logging
|
||||||
|
self.logger.addHandler(logging.NullHandler())
|
||||||
# Initalize a log handler to stderr and set the log format
|
# Initalize a log handler to stderr and set the log format
|
||||||
sh: logging.StreamHandler = logging.StreamHandler()
|
# sh: logging.StreamHandler = logging.StreamHandler()
|
||||||
sh.setFormatter(logging.Formatter('%(asctime)s %(name)s [%(levelname)s]: %(message)s'))
|
# sh.setFormatter(logging.Formatter('%(asctime)s %(name)s [%(levelname)s]: %(message)s'))
|
||||||
self.logger.addHandler(sh)
|
# self.logger.addHandler(sh)
|
||||||
|
|
||||||
|
|
||||||
class MockSocket(bytes):
|
class MockSocket(bytes):
|
||||||
|
|
Loading…
Reference in a new issue