feat: add option to log out users automatically after completing a purchase
Some checks failed
/ test (push) Failing after 54s
/ codestyle (push) Failing after 1m29s

This commit is contained in:
s3lph 2024-04-09 22:35:37 +02:00
parent 1e561fd9cd
commit d54aa2bc57
Signed by: s3lph
GPG key ID: 0AA29A52FB33CFB5
12 changed files with 158 additions and 21 deletions

View file

@ -1,5 +1,18 @@
# Matemat Changelog
<!-- BEGIN RELEASE v0.3.10 -->
## Version 0.3.10
Add option to log out users automatically after completing a purchase
### Changes
<!-- BEGIN CHANGES 0.3.10 -->
- Add option to log out users automatically after completing a purchase
<!-- END CHANGES 0.3.10 -->
<!-- END RELEASE v0.3.10 -->
<!-- BEGIN RELEASE v0.3.9 -->
## Version 0.3.9

View file

@ -84,7 +84,7 @@ class MatematDatabase(object):
users: List[User] = []
with self.db.transaction(exclusive=False) as c:
for row in c.execute('''
SELECT user_id, username, email, is_admin, is_member, balance, receipt_pref
SELECT user_id, username, email, is_admin, is_member, balance, receipt_pref, logout_after_purchase
FROM users
WHERE touchkey IS NOT NULL OR NOT :must_have_touchkey
ORDER BY username COLLATE NOCASE ASC
@ -92,12 +92,13 @@ class MatematDatabase(object):
'must_have_touchkey': with_touchkey
}):
# Decompose each row and put the values into a User object
user_id, username, email, is_admin, is_member, balance, receipt_p = row
user_id, username, email, is_admin, is_member, balance, receipt_p, logout_after_purchase = row
try:
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
except ValueError:
raise DatabaseConsistencyError(f'{receipt_p} is not a valid ReceiptPreference')
users.append(User(user_id, username, balance, email, is_admin, is_member, receipt_pref))
users.append(User(user_id, username, balance, email, is_admin, is_member, receipt_pref,
logout_after_purchase))
return users
def get_user(self, uid: int) -> User:
@ -108,7 +109,7 @@ class MatematDatabase(object):
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, receipt_pref
SELECT user_id, username, email, is_admin, is_member, balance, receipt_pref, logout_after_purchase
FROM users
WHERE user_id = ?
''',
@ -117,19 +118,20 @@ class MatematDatabase(object):
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, receipt_p = row
user_id, username, email, is_admin, is_member, balance, receipt_p, logout_after_purchase = row
try:
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
except ValueError:
raise DatabaseConsistencyError(f'{receipt_p} is not a valid ReceiptPreference')
return User(user_id, username, balance, email, is_admin, is_member, receipt_pref)
return User(user_id, username, balance, email, is_admin, is_member, receipt_pref, logout_after_purchase)
def create_user(self,
username: str,
password: str,
email: Optional[str] = None,
admin: bool = False,
member: bool = True) -> User:
member: bool = True,
logout_after_purchase: bool = False) -> User:
"""
Create a new user.
:param username: The name of the new user.
@ -137,6 +139,7 @@ class MatematDatabase(object):
:param email: The user's email address, defaults to None.
:param admin: Whether the user is an administrator, defaults to False.
:param member: Whether the user is a member, defaults to True.
:param logout_after_purchase: Whether the user should be logged out after completing a purchase.
:return: A User object representing the created user.
:raises ValueError: If a user with the same name already exists.
"""
@ -150,8 +153,10 @@ class MatematDatabase(object):
raise ValueError(f'A user with the name \'{username}\' already exists.')
# Insert the user into the database.
c.execute('''
INSERT INTO users (username, email, password, balance, is_admin, is_member, lastchange, created)
VALUES (:username, :email, :pwhash, 0, :admin, :member, STRFTIME('%s', 'now'), STRFTIME('%s', 'now'))
INSERT INTO users (username, email, password, balance, is_admin, is_member, lastchange, created,
logout_after_purchase)
VALUES (:username, :email, :pwhash, 0, :admin, :member, STRFTIME('%s', 'now'), STRFTIME('%s', 'now'),
logout_after_purchase)
''', {
'username': username,
'email': email,
@ -179,14 +184,15 @@ class MatematDatabase(object):
raise ValueError('Exactly one of password and touchkey must be provided')
with self.db.transaction(exclusive=False) as c:
c.execute('''
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
FROM users
WHERE username = ?
''', [username])
row = c.fetchone()
if row is None:
raise AuthenticationError('User does not exist')
user_id, username, email, pwhash, tkhash, admin, member, balance, receipt_p = row
user_id, username, email, pwhash, tkhash, admin, member, balance, receipt_p, logout_after_purchase = row
if password is not None and not compare_digest(crypt.crypt(password, pwhash), pwhash):
raise AuthenticationError('Password mismatch')
elif touchkey is not None \
@ -199,7 +205,7 @@ class MatematDatabase(object):
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
except ValueError:
raise DatabaseConsistencyError(f'{receipt_p} is not a valid ReceiptPreference')
return User(user_id, username, balance, email, admin, member, receipt_pref)
return User(user_id, username, balance, email, admin, member, receipt_pref, logout_after_purchase)
def change_password(self, user: User, oldpass: str, newpass: str, verify_password: bool = True) -> None:
"""
@ -280,6 +286,7 @@ class MatematDatabase(object):
balance: int = kwargs['balance'] if 'balance' in kwargs else user.balance
balance_reason: Optional[str] = kwargs['balance_reason'] if 'balance_reason' in kwargs else None
receipt_pref: ReceiptPreference = kwargs['receipt_pref'] if 'receipt_pref' in kwargs else user.receipt_pref
logout_after_purchase: bool = kwargs['logout_after_purchase'] if 'logout_after_purchase' in kwargs else user.logout_after_purchase
with self.db.transaction() as c:
c.execute('SELECT balance FROM users WHERE user_id = :user_id', {'user_id': user.id})
row = c.fetchone()
@ -312,7 +319,8 @@ class MatematDatabase(object):
is_admin = :is_admin,
is_member = :is_member,
receipt_pref = :receipt_pref,
lastchange = STRFTIME('%s', 'now')
lastchange = STRFTIME('%s', 'now'),
logout_after_purchase = :logout_after_purchase
WHERE user_id = :user_id
''', {
'user_id': user.id,
@ -321,7 +329,8 @@ class MatematDatabase(object):
'balance': balance,
'is_admin': is_admin,
'is_member': is_member,
'receipt_pref': receipt_pref.value
'receipt_pref': receipt_pref.value,
'logout_after_purchase': logout_after_purchase
})
# Only update the actual user object after the changes in the database succeeded
user.name = name
@ -329,6 +338,7 @@ class MatematDatabase(object):
user.balance = balance
user.is_admin = is_admin
user.is_member = is_member
user.logout_after_purchase = user.logout_after_purchase
user.receipt_pref = receipt_pref
def delete_user(self, user: User) -> None:

View file

@ -276,3 +276,11 @@ def migrate_schema_5_to_6(c: sqlite3.Cursor):
ALTER TABLE products ADD COLUMN
custom_price INTEGER(1) DEFAULT 0;
''')
def migrate_schema_6_to_7(c: sqlite3.Cursor):
# Add custom_price column
c.execute('''
ALTER TABLE users ADD COLUMN
logout_after_purchase INTEGER(1) DEFAULT 0;
''')

View file

@ -26,7 +26,8 @@ class User:
email: Optional[str] = None,
is_admin: bool = False,
is_member: bool = False,
receipt_pref: ReceiptPreference = ReceiptPreference.NONE) -> None:
receipt_pref: ReceiptPreference = ReceiptPreference.NONE,
logout_after_purchase: bool = False) -> None:
self.id: int = _id
self.name: str = name
self.balance: int = balance
@ -34,6 +35,7 @@ class User:
self.is_admin: bool = is_admin
self.is_member: bool = is_member
self.receipt_pref: ReceiptPreference = receipt_pref
self.logout_after_purchase: bool = logout_after_purchase
def __eq__(self, other) -> bool:
if not isinstance(other, User):
@ -44,7 +46,9 @@ class User:
self.email == other.email and \
self.is_admin == other.is_admin and \
self.is_member == other.is_member and \
self.receipt_pref == other.receipt_pref
self.receipt_pref == other.receipt_pref and \
self.logout_after_purchase == other.logout_after_purchase
def __hash__(self) -> int:
return hash((self.id, self.name, self.balance, self.email, self.is_admin, self.is_member, self.receipt_pref))
return hash((self.id, self.name, self.balance, self.email, self.is_admin, self.is_member, self.receipt_pref,
self.logout_after_purchase))

View file

@ -413,3 +413,84 @@ SCHEMAS[6] = [
ON DELETE SET NULL ON UPDATE CASCADE
);
''']
SCHEMAS[7] = [
'''
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
);
''',
'''
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

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

View file

@ -75,6 +75,7 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematData
return
username = str(args.username)
email = str(args.email)
logout_after_purchase = 'logout_after_purchase' in args
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
@ -84,7 +85,8 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematData
return
# Attempt to update username, e-mail and receipt preference
try:
db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref)
db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref,
logout_after_purchase=logout_after_purchase)
except DatabaseConsistencyError:
return
@ -176,8 +178,10 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
password = str(args.password)
is_member = 'ismember' in args
is_admin = 'isadmin' in args
logout_after_purchase = 'logout_after_purchase' in args
# Create the user in the database
newuser: User = db.create_user(username, password, email, member=is_member, admin=is_admin)
newuser: User = db.create_user(username, password, email, member=is_member, admin=is_admin,
logout_after_purchase=logout_after_purchase)
# If a default avatar is set, copy it to the user's avatar path

View file

@ -16,6 +16,7 @@ def buy():
# If no user is logged in, redirect to the main page, as a purchase must always be bound to a user
if not session.has(session_id, 'authenticated_user'):
redirect('/')
authlevel: int = session.get(session_id, 'authentication_level')
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user from the database
@ -34,6 +35,9 @@ def buy():
stock_provider = c.get_stock_provider()
if stock_provider.needs_update():
stock_provider.update_stock(product, -1)
# 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:
redirect('/logout')
# Redirect to the main page (where this request should have come from)
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}')
redirect('/')

View file

@ -111,6 +111,7 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, authuser: User,
balance_reason = None
is_member = 'ismember' in args
is_admin = 'isadmin' in args
logout_after_purchase = 'logout_after_purchase' in args
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
@ -121,7 +122,8 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, authuser: User,
db.change_password(user, '', password, verify_password=False)
# Write the user detail changes
db.change_user(user, agent=authuser, name=username, email=email, is_member=is_member, is_admin=is_admin,
balance=balance, balance_reason=balance_reason, receipt_pref=receipt_pref)
balance=balance, balance_reason=balance_reason, receipt_pref=receipt_pref,
logout_after_purchase=logout_after_purchase)
except DatabaseConsistencyError:
return
# If a new avatar was uploaded, process it

View file

@ -23,6 +23,9 @@
<label for="admin-myaccount-isadmin">Admin: </label>
<input id="admin-myaccount-isadmin" type="checkbox" disabled="disabled" {% if authuser.is_admin %} checked="checked" {% endif %}/><br/>
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if authuser.logout_after_purchase %} checked="checked" {% endif %}/><br/>
<input type="submit" value="Save changes" />
</form>
</section>

View file

@ -17,6 +17,9 @@
<label for="admin-newuser-isadmin">Admin: </label>
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/>
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" /><br/>
<input type="submit" value="Create User" />
</form>
</section>

View file

@ -35,6 +35,9 @@
<label for="moduser-account-isadmin">Admin: </label>
<input id="moduser-account-isadmin" name="isadmin" type="checkbox" {% if user.is_admin %} checked="checked" {% endif %}/><br/>
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if user.logout_after_purchase %} checked="checked" {% endif %}/><br/>
<label for="moduser-account-balance">Balance: </label>
CHF <input id="moduser-account-balance" name="balance" type="number" step="0.01" value="{{ user.balance|chf(False) }}" /><br/>