Merge branch 'dbfacade-staging-merge' into 'master'

Added balance/stock to User/Product and changed the DatabaseFacade accordingly.

See merge request s3lph/matemat!18
This commit is contained in:
s3lph 2018-07-19 20:31:53 +00:00
commit 96657c122b
4 changed files with 178 additions and 24 deletions

View file

@ -75,14 +75,30 @@ class MatematDatabase(object):
users: List[User] = [] users: List[User] = []
with self.db.transaction(exclusive=False) as c: with self.db.transaction(exclusive=False) as c:
for row in c.execute(''' for row in c.execute('''
SELECT user_id, username, email, is_admin, is_member SELECT user_id, username, email, is_admin, is_member, balance
FROM users FROM users
'''): '''):
# Decompose each row and put the values into a User object # Decompose each row and put the values into a User object
user_id, username, email, is_admin, is_member = row user_id, username, email, is_admin, is_member, balance = row
users.append(User(user_id, username, email, is_admin, is_member)) users.append(User(user_id, username, balance, email, is_admin, is_member))
return users return users
def get_user(self, uid: int) -> User:
"""
Return a user identified by its user ID.
:param uid: The user's ID.
"""
with self.db.transaction(exclusive=False) as c:
# Fetch all values to construct the user
c.execute('SELECT user_id, username, email, is_admin, is_member, balance FROM users WHERE user_id = ?',
[uid])
row = c.fetchone()
if row is None:
raise ValueError(f'No user with user ID {uid} exists.')
# Unpack the row and construct the user
user_id, username, email, is_admin, is_member, balance = row
return User(user_id, username, balance, email, is_admin, is_member)
def create_user(self, def create_user(self,
username: str, username: str,
password: str, password: str,
@ -121,7 +137,7 @@ class MatematDatabase(object):
# Fetch the new user's rowid. # Fetch the new user's rowid.
c.execute('SELECT last_insert_rowid()') c.execute('SELECT last_insert_rowid()')
user_id = int(c.fetchone()[0]) user_id = int(c.fetchone()[0])
return User(user_id, username, email, admin, member) return User(user_id, username, 0, email, admin, member)
def login(self, username: str, password: Optional[str] = None, touchkey: Optional[str] = None) -> User: def login(self, username: str, password: Optional[str] = None, touchkey: Optional[str] = None) -> User:
""" """
@ -138,14 +154,14 @@ class MatematDatabase(object):
raise ValueError('Exactly one of password and touchkey must be provided') raise ValueError('Exactly one of password and touchkey must be provided')
with self.db.transaction(exclusive=False) as c: with self.db.transaction(exclusive=False) as c:
c.execute(''' c.execute('''
SELECT user_id, username, email, password, touchkey, is_admin, is_member SELECT user_id, username, email, password, touchkey, is_admin, is_member, balance
FROM users FROM users
WHERE username = ? WHERE username = ?
''', [username]) ''', [username])
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise AuthenticationError('User does not exist') raise AuthenticationError('User does not exist')
user_id, username, email, pwhash, tkhash, admin, member = row user_id, username, email, pwhash, tkhash, admin, member, balance = row
if password is not None and not compare_digest(crypt.crypt(password, pwhash), pwhash): if password is not None and not compare_digest(crypt.crypt(password, pwhash), pwhash):
raise AuthenticationError('Password mismatch') raise AuthenticationError('Password mismatch')
elif touchkey is not None \ elif touchkey is not None \
@ -154,7 +170,7 @@ class MatematDatabase(object):
raise AuthenticationError('Touchkey mismatch') raise AuthenticationError('Touchkey mismatch')
elif touchkey is not None and tkhash is None: elif touchkey is not None and tkhash is None:
raise AuthenticationError('Touchkey not set') raise AuthenticationError('Touchkey not set')
return User(user_id, username, email, admin, member) return User(user_id, username, balance, email, admin, member)
def change_password(self, user: User, oldpass: str, newpass: str, verify_password: bool = True) -> None: def change_password(self, user: User, oldpass: str, newpass: str, verify_password: bool = True) -> None:
""" """
@ -216,30 +232,51 @@ class MatematDatabase(object):
'tkhash': tkhash 'tkhash': tkhash
}) })
def change_user(self, user: User) -> None: def change_user(self, user: User, **kwargs)\
-> None:
""" """
Write changes in the User object to the database. Commit changes to the user in the database. If writing the requested changes succeeded, the values are updated
:param user: The user object to update in the database. in the provided user object. Otherwise the user object is left untouched. The user to update is identified by
the ID field in the provided user object.
:param user: The user object to update and to identify the requested user by.
: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.
""" """
# Resolve the values to change
name: str = kwargs['name'] if 'name' in kwargs else user.name
email: str = kwargs['email'] if 'email' in kwargs else user.email
balance: int = kwargs['balance'] if 'balance' in kwargs else user.balance
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
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute(''' c.execute('''
UPDATE users SET UPDATE users SET
username = :username,
email = :email, email = :email,
balance = :balance,
is_admin = :is_admin, is_admin = :is_admin,
is_member = :is_member, is_member = :is_member,
lastchange = STRFTIME('%s', 'now') lastchange = STRFTIME('%s', 'now')
WHERE user_id = :user_id WHERE user_id = :user_id
''', { ''', {
'user_id': user.id, 'user_id': user.id,
'email': user.email, 'username': name,
'is_admin': user.is_admin, 'email': email,
'is_member': user.is_member 'balance': balance,
'is_admin': is_admin,
'is_member': is_member
}) })
affected = c.execute('SELECT changes()').fetchone()[0] affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1: if affected != 1:
raise DatabaseConsistencyError( raise DatabaseConsistencyError(
f'change_user should affect 1 users row, but affected {affected}') f'change_user should affect 1 users row, but affected {affected}')
# Only update the actual user object after the changes in the database succeeded
user.name = name
user.email = email
user.balance = balance
user.is_admin = is_admin
user.is_member = is_member
def delete_user(self, user: User) -> None: def delete_user(self, user: User) -> None:
""" """
@ -265,13 +302,32 @@ class MatematDatabase(object):
products: List[Product] = [] products: List[Product] = []
with self.db.transaction(exclusive=False) as c: with self.db.transaction(exclusive=False) as c:
for row in c.execute(''' for row in c.execute('''
SELECT product_id, name, price_member, price_non_member SELECT product_id, name, price_member, price_non_member, stock
FROM products FROM products
'''): '''):
product_id, name, price_member, price_external = row product_id, name, price_member, price_external, stock = row
products.append(Product(product_id, name, price_member, price_external)) products.append(Product(product_id, name, price_member, price_external, stock))
return products return products
def get_product(self, pid: int) -> Product:
"""
Return a product identified by its product ID.
:param pid: The products's ID.
"""
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
FROM products
WHERE product_id = ?''',
[pid])
row = c.fetchone()
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)
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) -> Product:
""" """
Creates a new product. Creates a new product.
@ -296,27 +352,48 @@ class MatematDatabase(object):
}) })
c.execute('SELECT last_insert_rowid()') c.execute('SELECT last_insert_rowid()')
product_id = int(c.fetchone()[0]) product_id = int(c.fetchone()[0])
return Product(product_id, name, price_member, price_non_member) return Product(product_id, name, price_member, price_non_member, 0)
def change_product(self, product: Product) -> None: def change_product(self, product: Product, **kwargs) -> None:
"""
Commit changes to the product in the database. If writing the requested changes succeeded, the values are
updated in the provided product object. Otherwise the product object is left untouched. The product to update
is identified by the ID field in the provided product object.
:param product: The product object to update and to identify the requested product by.
:param kwargs: The properties to change.
:raises DatabaseConsistencyError: If the product represented by the object does not exist.
"""
# Resolve the values to change
name: str = kwargs['name'] if 'name' in kwargs else product.name
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
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute(''' c.execute('''
UPDATE products UPDATE products
SET SET
name = :name, name = :name,
price_member = :price_member, price_member = :price_member,
price_non_member = :price_non_member price_non_member = :price_non_member,
stock = :stock
WHERE product_id = :product_id WHERE product_id = :product_id
''', { ''', {
'product_id': product.id, 'product_id': product.id,
'name': product.name, 'name': name,
'price_member': product.price_member, 'price_member': price_member,
'price_non_member': product.price_non_member 'price_non_member': price_non_member,
'stock': stock
}) })
affected = c.execute('SELECT changes()').fetchone()[0] affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1: if affected != 1:
raise DatabaseConsistencyError( raise DatabaseConsistencyError(
f'change_product should affect 1 products row, but affected {affected}') f'change_product should affect 1 products row, but affected {affected}')
# Only update the actual product object after the changes in the database succeeded
product.name = name
product.price_member = price_member
product.price_non_member = price_non_member
product.stock = stock
def delete_product(self, product: Product) -> None: def delete_product(self, product: Product) -> None:
""" """

View file

@ -26,6 +26,19 @@ class DatabaseTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
db.create_user('testuser', 'supersecurepassword2', 'testuser2@example.com') db.create_user('testuser', 'supersecurepassword2', 'testuser2@example.com')
def test_get_user(self) -> None:
with self.db as db:
with db.transaction(exclusive=False):
created = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com',
admin=True, member=False)
user = db.get_user(created.id)
self.assertEqual('testuser', user.name)
self.assertEqual('testuser@example.com', user.email)
self.assertEqual(False, user.is_member)
self.assertEqual(True, user.is_admin)
with self.assertRaises(ValueError):
db.get_user(-1)
def test_list_users(self) -> None: def test_list_users(self) -> None:
with self.db as db: with self.db as db:
users = db.list_users() users = db.list_users()
@ -170,6 +183,17 @@ class DatabaseTest(unittest.TestCase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
db.create_product('Club Mate', 250, 250) db.create_product('Club Mate', 250, 250)
def test_get_product(self) -> None:
with self.db as db:
with db.transaction(exclusive=False):
created = db.create_product('Club Mate', 150, 250)
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)
with self.assertRaises(ValueError):
db.get_product(-1)
def test_list_products(self) -> None: def test_list_products(self) -> None:
with self.db as db: with self.db as db:
# Test empty list # Test empty list

View file

@ -3,16 +3,31 @@ from typing import Any
class Product(object): class Product(object):
"""
Representation of a product offered by the Matemat, with a name, prices for users, and the number of items
currently in stock.
"""
def __init__(self, def __init__(self,
product_id: int, product_id: int,
name: str, name: str,
price_member: int, price_member: int,
price_non_member: int) -> None: price_non_member: int,
stock: int) -> None:
"""
Create a new product instance with the given arguments.
:param product_id: The product ID in the database.
: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.
"""
self._product_id: int = product_id self._product_id: int = product_id
self._name: str = name self._name: str = name
self._price_member: int = price_member self._price_member: int = price_member
self._price_non_member: int = price_non_member self._price_non_member: int = price_non_member
self._stock: int = stock
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
if other is None or not isinstance(other, Product): if other is None or not isinstance(other, Product):
@ -20,7 +35,8 @@ class Product(object):
return self._product_id == other._product_id \ return self._product_id == other._product_id \
and self._name == other._name \ and self._name == other._name \
and self._price_member == other._price_member \ and self._price_member == other._price_member \
and self._price_non_member == other._price_non_member and self._price_non_member == other._price_non_member \
and self._stock == other._stock
@property @property
def id(self) -> int: def id(self) -> int:
@ -49,3 +65,11 @@ class Product(object):
@price_non_member.setter @price_non_member.setter
def price_non_member(self, price: int) -> None: def price_non_member(self, price: int) -> None:
self._price_non_member = price self._price_non_member = price
@property
def stock(self) -> int:
return self._stock
@stock.setter
def stock(self, stock: int) -> None:
self._stock = stock

View file

@ -3,18 +3,35 @@ from typing import Optional, Any
class User(object): class User(object):
"""
Representation of a user registered with the Matemat, with a name, e-mail address (optional), whether the user is a
member of the organization the Matemat instance is used in, whether the user is an administrator, and the user's
account balance.
"""
def __init__(self, def __init__(self,
user_id: int, user_id: int,
username: str, username: str,
balance: int,
email: Optional[str] = None, email: Optional[str] = None,
admin: bool = False, admin: bool = False,
member: bool = True) -> None: member: bool = True) -> None:
"""
Create a new user instance with the given arguments.
:param user_id: The user ID in the database.
:param username: The user's name.
:param balance: The balance of the user's account.
:param email: The user's e-mail address (optional).
:param admin: Whether the user is an administrator.
:param member: Whether the user is a member.
"""
self._user_id: int = user_id self._user_id: int = user_id
self._username: str = username self._username: str = username
self._email: Optional[str] = email self._email: Optional[str] = email
self._admin: bool = admin self._admin: bool = admin
self._member: bool = member self._member: bool = member
self._balance: int = balance
def __eq__(self, other: Any) -> bool: def __eq__(self, other: Any) -> bool:
if other is None or not isinstance(other, User): if other is None or not isinstance(other, User):
@ -33,6 +50,10 @@ class User(object):
def name(self) -> str: def name(self) -> str:
return self._username return self._username
@name.setter
def name(self, value):
self._username = value
@property @property
def email(self) -> Optional[str]: def email(self) -> Optional[str]:
return self._email return self._email
@ -56,3 +77,11 @@ class User(object):
@is_member.setter @is_member.setter
def is_member(self, member: bool) -> None: def is_member(self, member: bool) -> None:
self._member = member self._member = member
@property
def balance(self) -> int:
return self._balance
@balance.setter
def balance(self, balance: int) -> None:
self._balance = balance