1
0
Fork 0
forked from s3lph/matemat

Compare commits

..

3 commits

Author SHA1 Message Date
4eb71415fd
fix: show the purchase warning banner also on the touchkey login
feat: replace overlay system with a generic notification banner system
feat: add a config option to automatically close tabs after ean purchase
2024-11-23 09:48:53 +01:00
8287dc1947
fix: codestyle 2024-11-23 04:37:47 +01:00
f3af4d64a7
feat: Immediately purchase a product by calling /?ean=...
chore: Replace datetime.utcnow with datetime.now(UTC)
chore: Replace sqlite3 qmark-bindings with named bindings
2024-11-23 04:35:05 +01:00
31 changed files with 404 additions and 208 deletions

View file

@ -1,5 +1,35 @@
# Matemat Changelog # Matemat Changelog
<!-- BEGIN RELEASE v0.3.14 -->
## Version 0.3.14
Improvement of quick-purchase via EAN codes
### Changes
<!-- BEGIN CHANGES 0.3.14 -->
- fix: show the purchase warning banner also on the touchkey login
- feat: replace overlay system with a generic notification banner system
- feat: add a config option to automatically close tabs after ean purchase
<!-- END CHANGES 0.3.14 -->
<!-- END RELEASE v0.3.14 -->
<!-- BEGIN RELEASE v0.3.13 -->
## Version 0.3.13
Quick-purchase via EAN codes
### Changes
<!-- BEGIN CHANGES 0.3.13 -->
- feat: Immediately purchase a product by calling `/?ean=...`
- chore: Replace datetime.utcnow with datetime.now(UTC)
- chore: Replace sqlite3 qmark-bindings with named bindings
<!-- END CHANGES 0.3.13 -->
<!-- END RELEASE v0.3.13 -->
<!-- BEGIN RELEASE v0.3.12 --> <!-- BEGIN RELEASE v0.3.12 -->
## Version 0.3.12 ## Version 0.3.12

View file

@ -1,2 +1,2 @@
__version__ = '0.3.12' __version__ = '0.3.14'

View file

@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type
import crypt import crypt
from hmac import compare_digest from hmac import compare_digest
from datetime import datetime from datetime import datetime, UTC
from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \ from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \
Transaction, Consumption, Deposit, Modification Transaction, Consumption, Deposit, Modification
@ -111,9 +111,8 @@ class MatematDatabase(object):
c.execute(''' c.execute('''
SELECT user_id, username, email, is_admin, is_member, balance, receipt_pref, logout_after_purchase SELECT user_id, username, email, is_admin, is_member, balance, receipt_pref, logout_after_purchase
FROM users FROM users
WHERE user_id = ? WHERE user_id = :user_id
''', ''', {'user_id': uid})
[uid])
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise ValueError(f'No user with user ID {uid} exists.') raise ValueError(f'No user with user ID {uid} exists.')
@ -148,7 +147,7 @@ class MatematDatabase(object):
user_id: int = -1 user_id: int = -1
with self.db.transaction() as c: with self.db.transaction() as c:
# Look up whether a user with the same name already exists. # Look up whether a user with the same name already exists.
c.execute('SELECT user_id FROM users WHERE username = ?', [username]) c.execute('SELECT user_id FROM users WHERE username = :username', {'username': username})
if c.fetchone() is not None: if c.fetchone() is not None:
raise ValueError(f'A user with the name \'{username}\' already exists.') raise ValueError(f'A user with the name \'{username}\' already exists.')
# Insert the user into the database. # Insert the user into the database.
@ -188,8 +187,8 @@ class MatematDatabase(object):
SELECT user_id, username, email, password, touchkey, is_admin, is_member, balance, receipt_pref, SELECT user_id, username, email, password, touchkey, is_admin, is_member, balance, receipt_pref,
logout_after_purchase logout_after_purchase
FROM users FROM users
WHERE username = ? WHERE username = :username
''', [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')
@ -221,8 +220,8 @@ class MatematDatabase(object):
with self.db.transaction() as c: with self.db.transaction() as c:
# Fetch the old password. # Fetch the old password.
c.execute(''' c.execute('''
SELECT password FROM users WHERE user_id = ? SELECT password FROM users WHERE user_id = :user_id
''', [user.id]) ''', {'user_id': user.id})
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise AuthenticationError('User does not exist in database.') raise AuthenticationError('User does not exist in database.')
@ -251,8 +250,8 @@ class MatematDatabase(object):
with self.db.transaction() as c: with self.db.transaction() as c:
# Fetch the password. # Fetch the password.
c.execute(''' c.execute('''
SELECT password FROM users WHERE user_id = ? SELECT password FROM users WHERE user_id = :user_id
''', [user.id]) ''', {'user_id': user.id})
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise AuthenticationError('User does not exist in database.') raise AuthenticationError('User does not exist in database.')
@ -352,8 +351,8 @@ class MatematDatabase(object):
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute(''' c.execute('''
DELETE FROM users DELETE FROM users
WHERE user_id = ? WHERE user_id = :user_id
''', [user.id]) ''', {'user_id': user.id})
affected = c.execute('SELECT changes()').fetchone()[0] affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1: if affected != 1:
raise DatabaseConsistencyError( raise DatabaseConsistencyError(
@ -367,34 +366,52 @@ 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, custom_price, stock, stockable SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean
FROM products ORDER BY name FROM products ORDER BY name
'''): '''):
product_id, name, price_member, price_external, custom_price, stock, stockable = row product_id, name, price_member, price_external, custom_price, stock, stockable, ean = row
products.append(Product(product_id, name, price_member, price_external, custom_price, stockable, stock)) products.append(
Product(product_id, name, price_member, price_external, custom_price, stockable, stock, ean))
return products return products
def get_product(self, pid: int) -> Product: def get_product(self, pid: int) -> Product:
""" """
Return a product identified by its product ID. Return a product identified by its product ID.
:param pid: The products's ID. :param pid: The product's ID.
""" """
with self.db.transaction(exclusive=False) as c: with self.db.transaction(exclusive=False) as c:
# Fetch all values to construct the product # Fetch all values to construct the product
c.execute(''' c.execute('''
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean
FROM products FROM products
WHERE product_id = ?''', WHERE product_id = :product_id''', {'product_id': pid})
[pid])
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise ValueError(f'No product with product ID {pid} exists.') raise ValueError(f'No product with product ID {pid} exists.')
# Unpack the row and construct the product # Unpack the row and construct the product
product_id, name, price_member, price_non_member, custom_price, stock, stockable = row product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock) return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean)
def get_product_by_ean(self, ean: str) -> Product:
"""
Return a product identified by its EAN code.
:param ean: The product's EAN code.
"""
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, custom_price, stock, stockable, ean
FROM products
WHERE ean = :ean''', {'ean': ean})
row = c.fetchone()
if row is None:
raise ValueError(f'No product with EAN code {ean} exists.')
# Unpack the row and construct the product
product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean)
def create_product(self, name: str, price_member: int, price_non_member: int, custom_price: def create_product(self, name: str, price_member: int, price_non_member: int, custom_price:
bool, stockable: bool) -> Product: bool, stockable: bool, ean: str) -> Product:
""" """
Creates a new product. Creates a new product.
:param name: Name of the product. :param name: Name of the product.
@ -407,22 +424,23 @@ class MatematDatabase(object):
""" """
product_id: int = -1 product_id: int = -1
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute('SELECT product_id FROM products WHERE name = ?', [name]) c.execute('SELECT product_id FROM products WHERE name = :name', {'name': name})
if c.fetchone() is not None: if c.fetchone() is not None:
raise ValueError(f'A product with the name \'{name}\' already exists.') raise ValueError(f'A product with the name \'{name}\' already exists.')
c.execute(''' c.execute('''
INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable) INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable, ean)
VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :stockable) VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :stockable, :ean)
''', { ''', {
'name': name, 'name': name,
'price_member': price_member, 'price_member': price_member,
'price_non_member': price_non_member, 'price_non_member': price_non_member,
'custom_price': custom_price, 'custom_price': custom_price,
'stockable': stockable 'stockable': stockable,
'ean': ean,
}) })
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, custom_price, stockable, 0) return Product(product_id, name, price_member, price_non_member, custom_price, stockable, 0, ean)
def change_product(self, product: Product, **kwargs) -> None: def change_product(self, product: Product, **kwargs) -> None:
""" """
@ -441,6 +459,7 @@ class MatematDatabase(object):
custom_price: int = kwargs['custom_price'] if 'custom_price' in kwargs else product.custom_price custom_price: int = kwargs['custom_price'] if 'custom_price' in kwargs else product.custom_price
stock: int = kwargs['stock'] if 'stock' in kwargs else product.stock stock: int = kwargs['stock'] if 'stock' in kwargs else product.stock
stockable: bool = kwargs['stockable'] if 'stockable' in kwargs else product.stockable stockable: bool = kwargs['stockable'] if 'stockable' in kwargs else product.stockable
ean: str = kwargs['ean'] if 'ean' in kwargs else product.ean
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute(''' c.execute('''
UPDATE products UPDATE products
@ -450,7 +469,8 @@ class MatematDatabase(object):
price_non_member = :price_non_member, price_non_member = :price_non_member,
custom_price = :custom_price, custom_price = :custom_price,
stock = :stock, stock = :stock,
stockable = :stockable stockable = :stockable,
ean = :ean
WHERE product_id = :product_id WHERE product_id = :product_id
''', { ''', {
'product_id': product.id, 'product_id': product.id,
@ -459,7 +479,8 @@ class MatematDatabase(object):
'price_non_member': price_non_member, 'price_non_member': price_non_member,
'custom_price': custom_price, 'custom_price': custom_price,
'stock': stock, 'stock': stock,
'stockable': stockable 'stockable': stockable,
'ean': ean
}) })
affected = c.execute('SELECT changes()').fetchone()[0] affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1: if affected != 1:
@ -472,6 +493,7 @@ class MatematDatabase(object):
product.custom_price = custom_price product.custom_price = custom_price
product.stock = stock product.stock = stock
product.stockable = stockable product.stockable = stockable
product.ean = ean
def delete_product(self, product: Product) -> None: def delete_product(self, product: Product) -> None:
""" """
@ -482,8 +504,8 @@ class MatematDatabase(object):
with self.db.transaction() as c: with self.db.transaction() as c:
c.execute(''' c.execute('''
DELETE FROM products DELETE FROM products
WHERE product_id = ? WHERE product_id = :product_id
''', [product.id]) ''', {'product_id': product.id})
affected = c.execute('SELECT changes()').fetchone()[0] affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1: if affected != 1:
raise DatabaseConsistencyError( raise DatabaseConsistencyError(
@ -543,8 +565,7 @@ 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('''SELECT balance FROM users WHERE user_id = :user_id''', c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': user.id})
[user.id])
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise DatabaseConsistencyError(f'No such user: {user.id}') raise DatabaseConsistencyError(f'No such user: {user.id}')
@ -588,8 +609,7 @@ class MatematDatabase(object):
raise ValueError('Cannot transfer a negative value') raise ValueError('Cannot transfer a negative value')
with self.db.transaction() as c: with self.db.transaction() as c:
# First, remove amount from the source user's account # First, remove amount from the source user's account
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': source.id})
[source.id])
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise DatabaseConsistencyError(f'No such user: {source.id}') raise DatabaseConsistencyError(f'No such user: {source.id}')
@ -621,8 +641,7 @@ class MatematDatabase(object):
if affected != 1: if affected != 1:
raise DatabaseConsistencyError(f'transfer should affect 1 users row, but affected {affected}') raise DatabaseConsistencyError(f'transfer should affect 1 users row, but affected {affected}')
# Then, add the amount to the destination user's account # Then, add the amount to the destination user's account
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': dest.id})
[dest.id])
row = c.fetchone() row = c.fetchone()
if row is None: if row is None:
raise DatabaseConsistencyError(f'No such user: {dest.id}') raise DatabaseConsistencyError(f'No such user: {dest.id}')
@ -669,11 +688,11 @@ class MatematDatabase(object):
LEFT JOIN receipts AS r LEFT JOIN receipts AS r
ON r.user_id = u.user_id ON r.user_id = u.user_id
WHERE u.user_id = :user_id WHERE u.user_id = :user_id
''', [user.id]) ''', {'user_id': user.id})
last_receipt: datetime = datetime.fromtimestamp(c.fetchone()[0]) last_receipt: datetime = datetime.fromtimestamp(c.fetchone()[0], UTC)
next_receipt_due: datetime = user.receipt_pref.next_receipt_due(last_receipt) next_receipt_due: datetime = user.receipt_pref.next_receipt_due(last_receipt)
return datetime.utcnow() > next_receipt_due return datetime.now(UTC) > next_receipt_due
def create_receipt(self, user: User, write: bool = False) -> Receipt: def create_receipt(self, user: User, write: bool = False) -> Receipt:
transactions: List[Transaction] = [] transactions: List[Transaction] = []
@ -684,12 +703,12 @@ class MatematDatabase(object):
LEFT JOIN receipts AS r LEFT JOIN receipts AS r
ON r.user_id = u.user_id ON r.user_id = u.user_id
WHERE u.user_id = :user_id WHERE u.user_id = :user_id
''', [user.id]) ''', {'user_id': user.id})
row = cursor.fetchone() row = cursor.fetchone()
if row is None: if row is None:
raise DatabaseConsistencyError(f'No such user: {user.id}') raise DatabaseConsistencyError(f'No such user: {user.id}')
fromdate, min_id = row fromdate, min_id = row
created: datetime = datetime.fromtimestamp(fromdate) created: datetime = datetime.fromtimestamp(fromdate, UTC)
cursor.execute(''' cursor.execute('''
SELECT SELECT
t.ta_id, t.value, t.old_balance, COALESCE(t.date, 0), t.ta_id, t.value, t.old_balance, COALESCE(t.date, 0),
@ -712,13 +731,15 @@ class MatematDatabase(object):
for row in rows: for row in rows:
ta_id, value, old_balance, date, c, d, m, c_prod, m_agent, m_reason = row ta_id, value, old_balance, date, c, d, m, c_prod, m_agent, m_reason = row
if c == ta_id: if c == ta_id:
t: Transaction = Consumption(ta_id, user, value, old_balance, datetime.fromtimestamp(date), c_prod) t: Transaction = Consumption(ta_id, user, value, old_balance,
datetime.fromtimestamp(date, UTC), c_prod)
elif d == ta_id: elif d == ta_id:
t = Deposit(ta_id, user, value, old_balance, datetime.fromtimestamp(date)) t = Deposit(ta_id, user, value, old_balance, datetime.fromtimestamp(date, UTC))
elif m == ta_id: elif m == ta_id:
t = Modification(ta_id, user, value, old_balance, datetime.fromtimestamp(date), m_agent, m_reason) t = Modification(ta_id, user, value, old_balance,
datetime.fromtimestamp(date, UTC), m_agent, m_reason)
else: else:
t = Transaction(ta_id, user, value, old_balance, datetime.fromtimestamp(date)) t = Transaction(ta_id, user, value, old_balance, datetime.fromtimestamp(date, UTC))
transactions.append(t) transactions.append(t)
if write: if write:
cursor.execute(''' cursor.execute('''
@ -733,7 +754,7 @@ class MatematDatabase(object):
receipt_id: int = int(cursor.fetchone()[0]) receipt_id: int = int(cursor.fetchone()[0])
else: else:
receipt_id = -1 receipt_id = -1
receipt = Receipt(receipt_id, transactions, user, created, datetime.utcnow()) receipt = Receipt(receipt_id, transactions, user, created, datetime.now(UTC))
return receipt return receipt
def generate_sales_statistics(self, from_date: datetime, to_date: datetime) -> Dict[str, Any]: def generate_sales_statistics(self, from_date: datetime, to_date: datetime) -> Dict[str, Any]:
@ -775,7 +796,7 @@ class MatematDatabase(object):
LIMIT 1 LIMIT 1
), u.balance) ), u.balance)
FROM users AS u FROM users AS u
''', [to_date.timestamp()]) ''', {'to_date': to_date.timestamp()})
for balance, in c.fetchall(): for balance, in c.fetchall():
if balance > 0: if balance > 0:
positive_balance += balance positive_balance += balance

View file

@ -284,3 +284,14 @@ def migrate_schema_6_to_7(c: sqlite3.Cursor):
ALTER TABLE users ADD COLUMN ALTER TABLE users ADD COLUMN
logout_after_purchase INTEGER(1) DEFAULT 0; logout_after_purchase INTEGER(1) DEFAULT 0;
''') ''')
def migrate_schema_7_to_8(c: sqlite3.Cursor):
# Add ean column
c.execute('''
ALTER TABLE products ADD COLUMN ean TEXT DEFAULT NULL
''')
# Make ean column unique
c.execute('''
CREATE UNIQUE INDEX _matemat_products_ean_unique ON products(ean)
''')

View file

@ -11,11 +11,12 @@ class Product:
:param custom_price: If true, the user can choose the price to pay, but at least the regular price. :param custom_price: If true, the user can choose the price to pay, but at least the regular price.
:param stock: The number of items of this product currently in stock, or None if not stockable. :param stock: The number of items of this product currently in stock, or None if not stockable.
:param stockable: Whether this product is stockable. :param stockable: Whether this product is stockable.
:param ean: The product's EAN code. May be None.
""" """
def __init__(self, _id: int, name: str, def __init__(self, _id: int, name: str,
price_member: int, price_non_member: int, custom_price: bool, price_member: int, price_non_member: int, custom_price: bool,
stockable: bool, stock: int) -> None: stockable: bool, stock: int, ean: str) -> None:
self.id: int = _id self.id: int = _id
self.name: str = name self.name: str = name
self.price_member: int = price_member self.price_member: int = price_member
@ -23,6 +24,7 @@ class Product:
self.custom_price: bool = custom_price self.custom_price: bool = custom_price
self.stock: int = stock self.stock: int = stock
self.stockable: bool = stockable self.stockable: bool = stockable
self.ean: str = ean
def __eq__(self, other) -> bool: def __eq__(self, other) -> bool:
if not isinstance(other, Product): if not isinstance(other, Product):
@ -33,8 +35,9 @@ class Product:
self.price_non_member == other.price_non_member and \ self.price_non_member == other.price_non_member and \
self.custom_price == other.custom_price and \ self.custom_price == other.custom_price and \
self.stock == other.stock and \ self.stock == other.stock and \
self.stockable == other.stockable self.stockable == other.stockable and \
self.ean == other.ean
def __hash__(self) -> int: def __hash__(self) -> int:
return hash((self.id, self.name, self.price_member, self.price_non_member, self.custom_price, return hash((self.id, self.name, self.price_member, self.price_non_member, self.custom_price,
self.stock, self.stockable)) self.stock, self.stockable, self.ean))

View file

@ -27,7 +27,7 @@ class Transaction:
@property @property
def receipt_date(self) -> str: def receipt_date(self) -> str:
if self.date == datetime.fromtimestamp(0): if self.date == datetime.fromtimestamp(0, UTC):
return '<unknown> ' return '<unknown> '
date: str = self.date.strftime('%d.%m.%Y, %H:%M') date: str = self.date.strftime('%d.%m.%Y, %H:%M')
return date return date

View file

@ -494,3 +494,85 @@ SCHEMAS[7] = [
ON DELETE SET NULL ON UPDATE CASCADE ON DELETE SET NULL ON UPDATE CASCADE
); );
'''] ''']
SCHEMAS[8] = [
'''
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,
logout_after_purchase INTEGER(1) 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,
custom_price INTEGER(1) DEFAULT 0,
ean TEXT UNIQUE DEFAULT 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
);
''']

View file

@ -2,7 +2,7 @@
import unittest import unittest
import crypt import crypt
from datetime import datetime, timedelta from datetime import datetime, timedelta, UTC
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \ from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \
@ -220,7 +220,7 @@ class DatabaseTest(unittest.TestCase):
def test_create_product(self) -> None: def test_create_product(self) -> None:
with self.db as db: with self.db as db:
with db.transaction() as c: with db.transaction() as c:
db.create_product('Club Mate', 200, 200, True, True) db.create_product('Club Mate', 200, 200, True, True, '4029764001807')
c.execute("SELECT * FROM products") c.execute("SELECT * FROM products")
row = c.fetchone() row = c.fetchone()
self.assertEqual('Club Mate', row[1]) self.assertEqual('Club Mate', row[1])
@ -229,18 +229,20 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(200, row[4]) self.assertEqual(200, row[4])
self.assertEqual(200, row[5]) self.assertEqual(200, row[5])
self.assertEqual(1, row[6]) self.assertEqual(1, row[6])
self.assertEqual('4029764001807', row[7])
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
db.create_product('Club Mate', 250, 250, False, False) db.create_product('Club Mate', 250, 250, False, False, '4029764001807')
def test_get_product(self) -> None: def test_get_product(self) -> None:
with self.db as db: with self.db as db:
with db.transaction(exclusive=False): with db.transaction(exclusive=False):
created = db.create_product('Club Mate', 150, 250, False, False) created = db.create_product('Club Mate', 150, 250, False, False, '4029764001807')
product = db.get_product(created.id) product = db.get_product(created.id)
self.assertEqual('Club Mate', product.name) self.assertEqual('Club Mate', product.name)
self.assertEqual(150, product.price_member) self.assertEqual(150, product.price_member)
self.assertEqual(250, product.price_non_member) self.assertEqual(250, product.price_non_member)
self.assertEqual(False, product.stockable) self.assertEqual(False, product.stockable)
self.assertEqual('4029764001807', product.ean)
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
db.get_product(-1) db.get_product(-1)
@ -249,9 +251,9 @@ class DatabaseTest(unittest.TestCase):
# Test empty list # Test empty list
products = db.list_products() products = db.list_products()
self.assertEqual(0, len(products)) self.assertEqual(0, len(products))
db.create_product('Club Mate', 200, 200, False, True) db.create_product('Club Mate', 200, 200, False, True, '4029764001807')
db.create_product('Flora Power Mate', 200, 200, False, False) db.create_product('Flora Power Mate', 200, 200, False, False, None)
db.create_product('Fritz Mate', 200, 250, False, True) db.create_product('Fritz Mate', 200, 250, False, True, '4260107223177')
products = db.list_products() products = db.list_products()
self.assertEqual(3, len(products)) self.assertEqual(3, len(products))
productcheck = {} productcheck = {}
@ -260,22 +262,25 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(200, product.price_member) self.assertEqual(200, product.price_member)
self.assertEqual(200, product.price_non_member) self.assertEqual(200, product.price_non_member)
self.assertTrue(product.stockable) self.assertTrue(product.stockable)
self.assertEqual('4029764001807', product.ean)
elif product.name == 'Flora Power Mate': elif product.name == 'Flora Power Mate':
self.assertEqual(200, product.price_member) self.assertEqual(200, product.price_member)
self.assertEqual(200, product.price_non_member) self.assertEqual(200, product.price_non_member)
self.assertFalse(product.stockable) self.assertFalse(product.stockable)
self.assertEqual(None, product.ean)
elif product.name == 'Fritz Mate': elif product.name == 'Fritz Mate':
self.assertEqual(200, product.price_member) self.assertEqual(200, product.price_member)
self.assertEqual(250, product.price_non_member) self.assertEqual(250, product.price_non_member)
self.assertTrue(product.stockable) self.assertTrue(product.stockable)
self.assertEqual('4260107223177', product.ean)
productcheck[product.id] = 1 productcheck[product.id] = 1
self.assertEqual(3, len(productcheck)) self.assertEqual(3, len(productcheck))
def test_change_product(self) -> None: def test_change_product(self) -> None:
with self.db as db: with self.db as db:
product = db.create_product('Club Mate', 200, 200, False, True) product = db.create_product('Club Mate', 200, 200, False, True, '4029764001807')
db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250, db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250,
custom_price=True, stock=None, stockable=False) custom_price=True, stock=None, stockable=False, ean=None)
# Changes must be reflected in the passed object # Changes must be reflected in the passed object
self.assertEqual('Flora Power Mate', product.name) self.assertEqual('Flora Power Mate', product.name)
self.assertEqual(150, product.price_member) self.assertEqual(150, product.price_member)
@ -283,6 +288,7 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(True, product.custom_price) self.assertEqual(True, product.custom_price)
self.assertEqual(None, product.stock) self.assertEqual(None, product.stock)
self.assertEqual(False, product.stockable) self.assertEqual(False, product.stockable)
self.assertEqual(None, product.ean)
# Changes must be reflected in the database # Changes must be reflected in the database
checkproduct = db.get_product(product.id) checkproduct = db.get_product(product.id)
self.assertEqual('Flora Power Mate', checkproduct.name) self.assertEqual('Flora Power Mate', checkproduct.name)
@ -294,7 +300,7 @@ class DatabaseTest(unittest.TestCase):
product.id = -1 product.id = -1
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
db.change_product(product) db.change_product(product)
product2 = db.create_product('Club Mate', 200, 200, False, True) product2 = db.create_product('Club Mate', 200, 200, False, True, '4029764001807')
product2.name = 'Flora Power Mate' product2.name = 'Flora Power Mate'
with self.assertRaises(DatabaseConsistencyError): with self.assertRaises(DatabaseConsistencyError):
# Should fail, as a product with the same name already exists. # Should fail, as a product with the same name already exists.
@ -302,8 +308,8 @@ class DatabaseTest(unittest.TestCase):
def test_delete_product(self) -> None: def test_delete_product(self) -> None:
with self.db as db: with self.db as db:
product = db.create_product('Club Mate', 200, 200, False, True) product = db.create_product('Club Mate', 200, 200, False, True, '4029764001807')
product2 = db.create_product('Flora Power Mate', 200, 200, False, False) product2 = db.create_product('Flora Power Mate', 200, 200, False, False, None)
self.assertEqual(2, len(db.list_products())) self.assertEqual(2, len(db.list_products()))
db.delete_product(product) db.delete_product(product)
@ -378,9 +384,9 @@ class DatabaseTest(unittest.TestCase):
db.deposit(user1, 1337) db.deposit(user1, 1337)
db.deposit(user2, 4242) db.deposit(user2, 4242)
db.deposit(user3, 1234) db.deposit(user3, 1234)
clubmate = db.create_product('Club Mate', 200, 200, False, True) clubmate = db.create_product('Club Mate', 200, 200, False, True, '4029764001807')
florapowermate = db.create_product('Flora Power Mate', 150, 250, False, True) florapowermate = db.create_product('Flora Power Mate', 150, 250, False, True, None)
fritzmate = db.create_product('Fritz Mate', 200, 200, False, True) fritzmate = db.create_product('Fritz Mate', 200, 200, False, True, '4260107223177')
# user1 is somewhat addicted to caffeine # user1 is somewhat addicted to caffeine
for _ in range(3): for _ in range(3):
@ -430,10 +436,10 @@ class DatabaseTest(unittest.TestCase):
user7 = db.create_user('user7', 'supersecurepassword', 'user7@example.com', True, True) user7 = db.create_user('user7', 'supersecurepassword', 'user7@example.com', True, True)
user7.receipt_pref = 42 user7.receipt_pref = 42
twoyears: int = int((datetime.utcnow() - timedelta(days=730)).timestamp()) twoyears: int = int((datetime.now(UTC) - timedelta(days=730)).timestamp())
halfyear: int = int((datetime.utcnow() - timedelta(days=183)).timestamp()) halfyear: int = int((datetime.now(UTC) - timedelta(days=183)).timestamp())
twomonths: int = int((datetime.utcnow() - timedelta(days=61)).timestamp()) twomonths: int = int((datetime.now(UTC) - timedelta(days=61)).timestamp())
halfmonth: int = int((datetime.utcnow() - timedelta(days=15)).timestamp()) halfmonth: int = int((datetime.now(UTC) - timedelta(days=15)).timestamp())
with db.transaction() as c: with db.transaction() as c:
# Fix creation date for user2 # Fix creation date for user2
@ -506,7 +512,7 @@ class DatabaseTest(unittest.TestCase):
admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True) admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True)
user: User = db.create_user('user', 'supersecurepassword', 'user@example.com', True, True) user: User = db.create_user('user', 'supersecurepassword', 'user@example.com', True, True)
product: Product = db.create_product('Flora Power Mate', 200, 200, False, True) product: Product = db.create_product('Flora Power Mate', 200, 200, False, True, None)
# Create some transactions # Create some transactions
db.change_user(user, agent=admin, db.change_user(user, agent=admin,
@ -533,7 +539,7 @@ class DatabaseTest(unittest.TestCase):
SELECT user_id, 500, balance SELECT user_id, 500, balance
FROM users FROM users
WHERE user_id = :id WHERE user_id = :id
''', [user.id]) ''', {'id': user.id})
receipt3: Receipt = db.create_receipt(user, write=False) receipt3: Receipt = db.create_receipt(user, write=False)
with db.transaction() as c: with db.transaction() as c:
@ -595,8 +601,8 @@ class DatabaseTest(unittest.TestCase):
user2: User = db.create_user('user2', 'supersecurepassword', 'user2@example.com', True, False) user2: User = db.create_user('user2', 'supersecurepassword', 'user2@example.com', True, False)
user3: User = db.create_user('user3', 'supersecurepassword', 'user3@example.com', True, False) user3: User = db.create_user('user3', 'supersecurepassword', 'user3@example.com', True, False)
user4: User = db.create_user('user4', 'supersecurepassword', 'user4@example.com', True, False) user4: User = db.create_user('user4', 'supersecurepassword', 'user4@example.com', True, False)
flora: Product = db.create_product('Flora Power Mate', 200, 200, False, True) flora: Product = db.create_product('Flora Power Mate', 200, 200, False, True, None)
club: Product = db.create_product('Club Mate', 200, 200, False, False) club: Product = db.create_product('Club Mate', 200, 200, False, False, '4029764001807')
# Create some transactions # Create some transactions
db.deposit(user1, 1337) db.deposit(user1, 1337)
@ -610,7 +616,7 @@ class DatabaseTest(unittest.TestCase):
db.increment_consumption(user4, club) db.increment_consumption(user4, club)
# Generate statistics # Generate statistics
now = datetime.utcnow() now = datetime.now(UTC)
stats = db.generate_sales_statistics(now - timedelta(days=1), now + timedelta(days=1)) stats = db.generate_sales_statistics(now - timedelta(days=1), now + timedelta(days=1))
self.assertEqual(7, len(stats)) self.assertEqual(7, len(stats))

View file

@ -40,7 +40,7 @@ class DatabaseTransaction(object):
class DatabaseWrapper(object): class DatabaseWrapper(object):
SCHEMA_VERSION = 7 SCHEMA_VERSION = 8
def __init__(self, filename: str) -> None: def __init__(self, filename: str) -> None:
self._filename: str = filename self._filename: str = filename
@ -91,6 +91,8 @@ class DatabaseWrapper(object):
migrate_schema_5_to_6(c) migrate_schema_5_to_6(c)
if from_version <= 6 and to_version >= 7: if from_version <= 6 and to_version >= 7:
migrate_schema_6_to_7(c) migrate_schema_6_to_7(c)
if from_version <= 7 and to_version >= 8:
migrate_schema_7_to_8(c)
def connect(self) -> None: def connect(self) -> None:
if self.is_connected(): if self.is_connected():

View file

@ -28,5 +28,5 @@ def add_months(d: datetime, months: int) -> datetime:
# Set the day of month temporarily to 1, then add the day offset to reach the 1st of the target month # Set the day of month temporarily to 1, then add the day offset to reach the 1st of the target month
newdate: datetime = d.replace(day=1) + timedelta(days=days) newdate: datetime = d.replace(day=1) + timedelta(days=days)
# Re-set the day of month to the intended value, but capped by the max. day in the target month # Re-set the day of month to the intended value, but capped by the max. day in the target month
newdate = newdate.replace(day=min(d.day, calendar.monthrange(newdate.year, newdate.month)[1])) newdate = newdate.replace(day=min(d.day, calendar.monthrange(newdate.year, newdate.month)[1]), tzinfo=d.tzinfo)
return newdate return newdate

View file

@ -1,5 +1,5 @@
import os import os
from datetime import datetime from datetime import datetime, UTC
from io import BytesIO from io import BytesIO
from shutil import copyfile from shutil import copyfile
@ -48,7 +48,7 @@ def admin():
users = db.list_users() users = db.list_users()
products = db.list_products() products = db.list_products()
# Render the "Admin/Settings" page # Render the "Admin/Settings" page
now = str(int(datetime.utcnow().timestamp())) now = str(int(datetime.now(UTC).timestamp()))
return template.render('admin.html', return template.render('admin.html',
authuser=user, authlevel=authlevel, users=users, products=products, authuser=user, authlevel=authlevel, users=users, products=products,
receipt_preference_class=ReceiptPreference, now=now, receipt_preference_class=ReceiptPreference, now=now,
@ -206,8 +206,9 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
price_non_member = parse_chf(str(args.pricenonmember)) price_non_member = parse_chf(str(args.pricenonmember))
custom_price = 'custom_price' in args custom_price = 'custom_price' in args
stockable = 'stockable' in args stockable = 'stockable' in args
ean = str(args.ean) or None
# Create the user in the database # Create the user in the database
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable) newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable, ean)
# If a new product image was uploaded, process it # If a new product image was uploaded, process it
image = files.image.file.read() if 'image' in files else None image = files.image.file.read() if 'image' in files else None
if image is not None and len(image) > 0: if image is not None and len(image) > 0:

View file

@ -26,6 +26,7 @@ def buy():
if 'pid' in request.params: if 'pid' in request.params:
pid = int(str(request.params.pid)) pid = int(str(request.params.pid))
product = db.get_product(pid) product = db.get_product(pid)
closetab = int(str(request.params.closetab) or 0)
if c.get_dispenser().dispense(product, 1): if c.get_dispenser().dispense(product, 1):
price = product.price_member if user.is_member else product.price_non_member price = product.price_member if user.is_member else product.price_non_member
if 'price' in request.params: if 'price' in request.params:
@ -37,7 +38,7 @@ def buy():
stock_provider.update_stock(product, -1) stock_provider.update_stock(product, -1)
# Logout user if configured, logged in via touchkey and no price entry input was shown # Logout user if configured, logged in via touchkey and no price entry input was shown
if user.logout_after_purchase and authlevel < 2 and not product.custom_price: if user.logout_after_purchase and authlevel < 2 and not product.custom_price:
redirect(f'/logout?lastaction=buy&lastproduct={pid}&lastprice={price}') redirect(f'/logout?lastaction=buy&lastproduct={pid}&lastprice={price}&closetab={closetab}')
# Redirect to the main page (where this request should have come from) # Redirect to the main page (where this request should have come from)
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}') redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}&closetab={closetab}')
redirect('/') redirect('/')

View file

@ -1,10 +1,12 @@
from datetime import datetime from datetime import datetime, UTC
from bottle import route, redirect, request from bottle import route, redirect, request
from matemat.db import MatematDatabase from matemat.db import MatematDatabase
from matemat.webserver import template, session from matemat.webserver import template, session
from matemat.webserver.template import Notification
from matemat.webserver.config import get_app_config, get_stock_provider from matemat.webserver.config import get_app_config, get_stock_provider
from matemat.util.currency_format import format_chf
@route('/') @route('/')
@ -14,27 +16,42 @@ def main_page():
""" """
config = get_app_config() config = get_app_config()
session_id: str = session.start() session_id: str = session.start()
now = str(int(datetime.utcnow().timestamp())) now = str(int(datetime.now(UTC).timestamp()))
with MatematDatabase(config['DatabaseFile']) as db: with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the list of products to display # Fetch the list of products to display
products = db.list_products() products = db.list_products()
if request.params.lastproduct: closetab = int(str(request.params.closetab) or 0)
lastproduct = db.get_product(request.params.lastproduct)
else:
lastproduct = None
lastprice = int(request.params.lastprice) if request.params.lastprice else None lastprice = int(request.params.lastprice) if request.params.lastprice else None
if request.params.lastaction == 'deposit' and lastprice:
Notification.success(f'Deposited {format_chf(lastprice)}', decay=True)
elif request.params.lastaction == 'buy' and lastprice and request.params.lastproduct:
lastproduct = db.get_product(request.params.lastproduct)
Notification.success(f'Purchased {lastproduct.name} for {format_chf(lastprice)}', decay=True)
buyproduct = None
if request.params.ean:
try:
buyproduct = db.get_product_by_ean(request.params.ean)
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
except ValueError:
Notification.error(f'EAN code {request.params.ean} is not associated with any product.', decay=True)
# Check whether a user is logged in # Check whether a user is logged in
if session.has(session_id, 'authenticated_user'): if session.has(session_id, 'authenticated_user'):
# Fetch the user id and authentication level (touchkey vs password) from the session storage # Fetch the user id and authentication level (touchkey vs password) from the session storage
uid: int = session.get(session_id, 'authenticated_user') uid: int = session.get(session_id, 'authenticated_user')
authlevel: int = session.get(session_id, 'authentication_level') authlevel: int = session.get(session_id, 'authentication_level')
# If an EAN code was scanned, directly trigger the purchase
if buyproduct:
url = f'/buy?pid={buyproduct.id}'
if config.get('CloseTabAfterEANPurchase', '0') == '1':
url += '&closetab=1'
redirect(url)
# Fetch the user object from the database (for name display, price calculation and admin check) # Fetch the user object from the database (for name display, price calculation and admin check)
users = db.list_users() users = db.list_users()
user = db.get_user(uid) user = db.get_user(uid)
# Prepare a response with a jinja2 template # Prepare a response with a jinja2 template
return template.render('productlist.html', return template.render('productlist.html',
authuser=user, users=users, products=products, authlevel=authlevel, authuser=user, users=users, products=products, authlevel=authlevel,
lastaction=request.params.lastaction, lastprice=lastprice, lastproduct=lastproduct,
stock=get_stock_provider(), setupname=config['InstanceName'], now=now) stock=get_stock_provider(), setupname=config['InstanceName'], now=now)
else: else:
# If there are no admin users registered, jump to the admin creation procedure # If there are no admin users registered, jump to the admin creation procedure
@ -45,4 +62,4 @@ def main_page():
return template.render('userlist.html', return template.render('userlist.html',
users=users, setupname=config['InstanceName'], now=now, users=users, setupname=config['InstanceName'], now=now,
signup=(config.get('SignupEnabled', '0') == '1'), signup=(config.get('SignupEnabled', '0') == '1'),
lastaction=request.params.lastaction, lastprice=lastprice, lastproduct=lastproduct) buyproduct=buyproduct, closetab=closetab)

View file

@ -1,6 +1,6 @@
import os import os
from io import BytesIO from io import BytesIO
from datetime import datetime from datetime import datetime, UTC
from typing import Dict from typing import Dict
import magic import magic
@ -56,7 +56,7 @@ def modproduct():
redirect('/admin') redirect('/admin')
# Render the "Modify Product" page # Render the "Modify Product" page
now = str(int(datetime.utcnow().timestamp())) now = str(int(datetime.now(UTC).timestamp()))
return template.render('modproduct.html', return template.render('modproduct.html',
authuser=authuser, product=product, authlevel=authlevel, authuser=authuser, product=product, authlevel=authlevel,
setupname=config['InstanceName'], now=now) setupname=config['InstanceName'], now=now)
@ -98,11 +98,12 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
custom_price = 'custom_price' in args custom_price = 'custom_price' in args
stock = int(str(args.stock)) stock = int(str(args.stock))
stockable = 'stockable' in args stockable = 'stockable' in args
ean = str(args.ean) or None
# Attempt to write the changes to the database # Attempt to write the changes to the database
try: try:
db.change_product(product, db.change_product(product,
name=name, price_member=price_member, price_non_member=price_non_member, name=name, price_member=price_member, price_non_member=price_non_member,
custom_price=custom_price, stock=stock, stockable=stockable) custom_price=custom_price, stock=stock, stockable=stockable, ean=ean)
stock_provider = get_stock_provider() stock_provider = get_stock_provider()
if stock_provider.needs_update() and product.stockable: if stock_provider.needs_update() and product.stockable:
stock_provider.set_stock(product, stock) stock_provider.set_stock(product, stock)

View file

@ -1,5 +1,5 @@
import os import os
from datetime import datetime from datetime import datetime, UTC
from io import BytesIO from io import BytesIO
from typing import Dict, Optional from typing import Dict, Optional
@ -56,7 +56,7 @@ def moduser():
redirect('/admin') redirect('/admin')
# Render the "Modify User" page # Render the "Modify User" page
now = str(int(datetime.utcnow().timestamp())) now = str(int(datetime.now(UTC).timestamp()))
return template.render('moduser.html', return template.render('moduser.html',
authuser=authuser, user=user, authlevel=authlevel, now=now, authuser=authuser, user=user, authlevel=authlevel, now=now,
receipt_preference_class=ReceiptPreference, receipt_preference_class=ReceiptPreference,

View file

@ -1,4 +1,4 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta, UTC
from math import pi, sin, cos from math import pi, sin, cos
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Tuple
@ -34,7 +34,7 @@ def statistics():
# Show a 403 Forbidden error page if the user is not an admin # Show a 403 Forbidden error page if the user is not an admin
abort(403) abort(403)
todate: datetime = datetime.utcnow() todate: datetime = datetime.now(UTC)
fromdate: datetime = (todate - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0) fromdate: datetime = (todate - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0)
if 'fromdate' in request.params: if 'fromdate' in request.params:
fdarg: str = str(request.params.fromdate) fdarg: str = str(request.params.fromdate)

View file

@ -4,6 +4,7 @@ from matemat.db import MatematDatabase
from matemat.db.primitives import User from matemat.db.primitives import User
from matemat.exceptions import AuthenticationError from matemat.exceptions import AuthenticationError
from matemat.webserver import template, session from matemat.webserver import template, session
from matemat.webserver.template import Notification
from matemat.webserver.config import get_app_config from matemat.webserver.config import get_app_config
@ -16,29 +17,45 @@ def touchkey_page():
""" """
config = get_app_config() config = get_app_config()
session_id: str = session.start() session_id: str = session.start()
# If a user is already logged in, simply redirect to the main page, showing the product list with MatematDatabase(config['DatabaseFile']) as db:
if session.has(session_id, 'authenticated_user'): # If a user is already logged in, simply redirect to the main page, showing the product list
redirect('/') if session.has(session_id, 'authenticated_user'):
# If requested via HTTP GET, render the login page showing the touchkey UI redirect('/')
if request.method == 'GET': # If requested via HTTP GET, render the login page showing the touchkey UI
return template.render('touchkey.html', if request.method == 'GET':
username=str(request.params.username), uid=int(str(request.params.uid)), buypid = None
setupname=config['InstanceName']) if request.params.buypid:
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials buypid = str(request.params.buypid)
elif request.method == 'POST': try:
# Connect to the database buyproduct = db.get_product(int(buypid))
with MatematDatabase(config['DatabaseFile']) as db: Notification.success(
try: f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
# Read the request arguments and attempt to log in with them except ValueError:
user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey)) Notification.error(f'No product with id {buypid}', decay=True)
except AuthenticationError: return template.render('touchkey.html',
# Reload the touchkey login page on failure username=str(request.params.username), uid=int(str(request.params.uid)),
redirect(f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}') setupname=config['InstanceName'], buypid=buypid)
# Set the user ID session variable # If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
session.put(session_id, 'authenticated_user', user.id) elif request.method == 'POST':
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login) # Connect to the database
session.put(session_id, 'authentication_level', 1) with MatematDatabase(config['DatabaseFile']) as db:
# Redirect to the main page, showing the product list try:
redirect('/') # Read the request arguments and attempt to log in with them
# If neither GET nor POST was used, show a 405 Method Not Allowed error page user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey))
abort(405) except AuthenticationError:
# Reload the touchkey login page on failure
redirect(f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}')
# Set the user ID session variable
session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session.put(session_id, 'authentication_level', 1)
if request.params.buypid:
buypid = str(request.params.buypid)
url = f'/buy?pid={buypid}'
if config.get('CloseTabAfterEANPurchase', '0') == '1':
url += '&closetab=1'
redirect(url)
# Redirect to the main page, showing the product list
redirect('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
abort(405)

View file

@ -3,7 +3,7 @@ from typing import Any, Dict, Tuple, Optional
from bottle import request, response from bottle import request, response
from secrets import token_bytes from secrets import token_bytes
from uuid import uuid4 from uuid import uuid4
from datetime import datetime, timedelta from datetime import datetime, timedelta, UTC
__key: Optional[str] = token_bytes(32) __key: Optional[str] = token_bytes(32)
@ -21,7 +21,7 @@ def start() -> str:
:return: The session ID. :return: The session ID.
""" """
# Reference date for session timeout # Reference date for session timeout
now = datetime.utcnow() now = datetime.now(UTC)
# Read the client's session ID, if any # Read the client's session ID, if any
session_id = request.get_cookie(_COOKIE_NAME, secret=__key) session_id = request.get_cookie(_COOKIE_NAME, secret=__key)
# If there is no active session, create a new session ID # If there is no active session, create a new session ID

View file

@ -1,2 +1,3 @@
from .notification import Notification
from .template import init, render from .template import init, render

View file

@ -0,0 +1,25 @@
class Notification:
notifications = []
def __init__(self, msg: str, classes=None, decay: bool = False):
self.msg = msg
self.classes = []
self.classes.extend(classes)
if decay:
self.classes.append('decay')
@classmethod
def render(cls):
n = list(cls.notifications)
cls.notifications.clear()
return n
@classmethod
def success(cls, msg: str, decay: bool = False):
cls.notifications.append(cls(msg, classes=['success'], decay=decay))
@classmethod
def error(cls, msg: str, decay: bool = False):
cls.notifications.append(cls(msg, classes=['error'], decay=decay))

View file

@ -5,6 +5,7 @@ import jinja2
from matemat import __version__ from matemat import __version__
from matemat.util.currency_format import format_chf from matemat.util.currency_format import format_chf
from matemat.webserver.template import Notification
__jinja_env: jinja2.Environment = None __jinja_env: jinja2.Environment = None
@ -22,4 +23,8 @@ def init(config: Dict[str, Any]) -> None:
def render(name: str, **kwargs): def render(name: str, **kwargs):
global __jinja_env global __jinja_env
template: jinja2.Template = __jinja_env.get_template(name) template: jinja2.Template = __jinja_env.get_template(name)
return template.render(__version__=__version__, **kwargs).encode('utf-8') return template.render(
__version__=__version__,
notifications=Notification.render(),
**kwargs
).encode('utf-8')

View file

@ -37,6 +37,11 @@ InstanceName=Matemat
#SignupEnabled=1 #SignupEnabled=1
#SignupKioskMode= ::1, ::ffff:127.0.0.0/8, 127.0.0.0/8 #SignupKioskMode= ::1, ::ffff:127.0.0.0/8, 127.0.0.0/8
#
# Close tabs after completing an EAN code scan based purchase.
# This only works in Firefox with dom.allow_scripts_to_close_windows=true
#
#CloseTabAfterEANPurchase=1
# Add static HTTP headers in this section # Add static HTTP headers in this section
# [HttpHeaders] # [HttpHeaders]

View file

@ -52,6 +52,25 @@ nav div {
padding: 10px; padding: 10px;
} }
.notification {
display: block;
width: calc(100% - 36px);
margin: 10px;
padding: 10px;
}
.notification.success {
background-color: #c0ffc0;
}
.notification.error {
background-color: #ffc0c0;
}
.notification.decay {
animation: notificationdecay 0s 7s forwards;
}
@keyframes notificationdecay {
to { display: none; }
}
@media print { @media print {
footer { footer {
position: fixed; position: fixed;
@ -132,7 +151,7 @@ nav div {
.numpad { .numpad {
background: #f0f0f0; background: #f0f0f0;
text-decoration: none; text-decoration: none;
font-size: 50px; font-size: 50px;
font-family: sans-serif; font-family: sans-serif;
line-height: 100px; line-height: 100px;
@ -298,37 +317,3 @@ div.osk-button.osk-button-space {
flex: 5 0 1px; flex: 5 0 1px;
color: #606060; color: #606060;
} }
aside#overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
background: #88ff88;
text-align: center;
z-index: 1000;
padding: 5%;
font-family: sans-serif;
display: none;
transition: opacity 700ms;
opacity: 0;
}
aside#overlay.fade {
opacity: 100%;
}
aside#overlay > h2 {
font-size: 3em;
}
aside#overlay > img {
width: 30%;
height: auto;
}
aside#overlay > div.price {
padding-top: 30px;
font-size: 2em;
}

View file

@ -1,18 +0,0 @@
setTimeout(() => {
let overlay = document.getElementById('overlay');
if (overlay !== null) {
overlay.style.display = 'block';
setTimeout(() => {
overlay.classList.add('fade');
setTimeout(() => {
setTimeout(() => {
overlay.classList.remove('fade');
setTimeout(() => {
overlay.style.display = 'none';
}, 700);
}, 700);
}, 700);
}, 10);
}
}, 0);

View file

@ -46,6 +46,9 @@
<label for="admin-newproduct-name">Name: </label> <label for="admin-newproduct-name">Name: </label>
<input id="admin-newproduct-name" type="text" name="name" /><br/> <input id="admin-newproduct-name" type="text" name="name" /><br/>
<label for="admin-newproduct-ean">EAN code: </label>
<input id="admin-newproduct-ean" type="text" name="ean" /><br/>
<label for="admin-newproduct-price-member">Member price: </label> <label for="admin-newproduct-price-member">Member price: </label>
CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0" /><br/> CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0" /><br/>

View file

@ -12,31 +12,10 @@
<body> <body>
{% block overlay %}
{% if lastaction is defined and lastaction is not none %}
{% if lastaction == 'buy' %}
<aside id="overlay">
<h2>{{ lastproduct.name }}</h2>
<img src="/static/upload/thumbnails/products/{{ lastproduct.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ lastproduct.name }}" draggable="false"/>
{% if lastprice is not none %}
<div class="price">{{ lastprice|chf }}</div>
{% endif %}
</aside>
{% elif lastaction == 'deposit' %}
<aside id="overlay">
<h2>Deposit</h2>
{% if lastprice is not none %}
<div class="price">{{ lastprice|chf }}</div>
{% endif %}
</aside>
{% endif %}
{% endif %}
{% endblock %}
<header> <header>
{% block header %} {% block header %}
<nav class="navbarbutton"> <nav class="navbarbutton">
{# Show a link to the settings, if a user logged in via password (authlevel 2). #} {# Show a link to the settings, if a user logged in via password (authlevel 2). #}
{% if authlevel|default(0) > 1 %} {% if authlevel|default(0) > 1 %}
{% if authuser is defined %} {% if authuser is defined %}
@ -55,6 +34,11 @@
</header> </header>
<main> <main>
{% block notifications %}
{% for n in notifications | default([]) %}
<div class="notification {{ n.classes | join(' ') }}">{{ n.msg|safe }}</div>
{% endfor %}
{% endblock %}
{% block main %} {% block main %}
{# Here be content. #} {# Here be content. #}
{% endblock %} {% endblock %}
@ -72,6 +56,5 @@
{% endblock %} {% endblock %}
</footer> </footer>
<script src="/static/js/overlay.js"></script>
</body> </body>
</html> </html>

View file

@ -14,6 +14,9 @@
<label for="modproduct-name">Name: </label> <label for="modproduct-name">Name: </label>
<input id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/> <input id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/>
<label for="modproduct-ean">EAN code: </label>
<input id="modproduct-ean" type="text" name="ean" value="{{ product.ean or '' }}" /><br/>
<label for="modproduct-price-member">Member price: </label> <label for="modproduct-price-member">Member price: </label>
CHF <input id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" /><br/> CHF <input id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" /><br/>

View file

@ -81,4 +81,9 @@
{{ super() }} {{ super() }}
{% if closetab | default(0) %}
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
<script>setTimeout(window.close, 3000);</script>
{% endif %}
{% endblock %} {% endblock %}

View file

@ -25,7 +25,6 @@
<input id="signup-touchkey" type="hidden" name="touchkey" value="" /> <input id="signup-touchkey" type="hidden" name="touchkey" value="" />
<input type="submit" value="Create account"> <input type="submit" value="Create account">
<input type="button" value="Back" onclick="history.back()">
</form> </form>
<div id="osk-kbd" class="osk osk-kbd"> <div id="osk-kbd" class="osk osk-kbd">

View file

@ -23,6 +23,9 @@
<input type="hidden" name="uid" value="{{ uid }}" /> <input type="hidden" name="uid" value="{{ uid }}" />
<input type="hidden" name="username" value="{{ username }}" /> <input type="hidden" name="username" value="{{ username }}" />
<input type="hidden" name="touchkey" value="" id="loginform-touchkey-value" /> <input type="hidden" name="touchkey" value="" id="loginform-touchkey-value" />
{% if buypid %}
<input type="hidden" name="buypid" value="{{ buypid }}" />
{% endif %}
</form> </form>
<a href="/">Cancel</a> <a href="/">Cancel</a>
@ -33,4 +36,4 @@
{{ super() }} {{ super() }}
{% endblock %} {% endblock %}

View file

@ -11,7 +11,7 @@
{% for user in users %} {% for user in users %}
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #} {# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
<div class="thumblist-item"> <div class="thumblist-item">
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}"> <a href="/touchkey?uid={{ user.id }}&username={{ user.name }}{% if buyproduct %}&buypid={{ buyproduct.id }}{% endif %}">
<span class="thumblist-title">{{ user.name }}</span><br/> <span class="thumblist-title">{{ user.name }}</span><br/>
<div class="imgcontainer"> <div class="imgcontainer">
<img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" draggable="false"/> <img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" draggable="false"/>
@ -33,4 +33,9 @@
{{ super() }} {{ super() }}
{% if closetab | default(0) %}
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
<script>setTimeout(window.close, 3000);</script>
{% endif %}
{% endblock %} {% endblock %}