Compare commits

..

1 commit

Author SHA1 Message Date
jonnygiger
8cc4175f61 Adds the option for a user to see their 10 most recent transactions. 2023-12-03 13:18:06 +01:00
28 changed files with 280 additions and 343 deletions

View file

@ -1,52 +0,0 @@
---
on:
push:
tags:
- "v*"
jobs:
build_wheel:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Build Python wheel
run: |
apt update; apt install -y python3-pip
pip3 install --break-system-packages -e .[test]
python3 setup.py egg_info bdist_wheel
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-wheel-package-upload@v3
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
build_debian:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Prepare package
run: |
# The Python package name provided by the python3-magic Debian package is "python-magic" rather than "file-magic".
sed -re 's/file-magic/python-magic/' -i setup.py
cp -r static/ package/debian/matemat/usr/lib/matemat/static/
cp -r templates/ package/debian/matemat/usr/lib/matemat/templates/
mkdir -p package/debian/matemat/var/lib/matemat/themes/
cp -r themes/ package/debian/matemat/usr/lib/matemat/themes/
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-python-debian-package@v5
with:
python_module: matemat
package_name: matemat
package_root: package/debian/matemat
package_output_path: package/debian
pre_package_hook: |
mv matemat/usr/bin/matemat matemat/usr/lib/matemat/matemat
rm -rf matemat/usr/bin
chmod +x matemat/usr/lib/matemat/matemat
- uses: https://git.kabelsalat.ch/s3lph/forgejo-action-debian-package-upload@v2
with:
username: ${{ secrets.API_USERNAME }}
password: ${{ secrets.API_PASSWORD }}
deb: "package/debian/*.deb"

View file

@ -1,27 +0,0 @@
---
on: push
jobs:
test:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: test
run: |
apt update; apt install --yes python3-pip
pip3 install --break-system-packages -e .[test]
python3 -m coverage run --rcfile=setup.cfg -m unittest discover matemat
python3 -m coverage combine
python3 -m coverage report --rcfile=setup.cfg
codestyle:
runs-on: docker
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: codestyle
run: |
apt update; apt install --yes python3-pip
pip3 install --break-system-packages -e .[test]
pycodestyle matemat

91
.woodpecker.yml Normal file
View file

@ -0,0 +1,91 @@
---
steps:
test:
image: python:3.11-bookworm
group: test
commands:
- pip3 install -e .[test]
- python3 -m coverage run --rcfile=setup.cfg -m unittest discover matemat
- python3 -m coverage combine
- python3 -m coverage report --rcfile=setup.cfg
codestyle:
image: python:3.11-bookworm
group: test
commands:
- pip3 install -e .[test]
- pycodestyle matemat
build_wheel:
image: python:3.11-bookworm
group: package
when:
- event: tag
secrets:
- GITEA_API_REPOSITORY_PYPI
- GITEA_API_USERNAME
- GITEA_API_PASSWORD
commands:
- pip3 install -e .[test]
- python3 setup.py egg_info bdist_wheel
- |
cat > ~/.pypirc <<EOF
[distutils]
index-servers = gitea
[gitea]
repository = $${GITEA_API_REPOSITORY_PYPI}
username = $${GITEA_API_USERNAME}
password = $${GITEA_API_PASSWORD}
EOF
- python3 -m twine upload --repository gitea dist/*.whl
build_debian:
image: python:3.11-bookworm
group: package
when:
- event: tag
secrets:
- GITEA_API_REPOSITORY_DEB
- GITEA_API_USERNAME
- GITEA_API_PASSWORD
commands:
- apt update; apt install -y lintian rsync sudo curl
- export MATEMAT_VERSION=$(python -c 'import matemat; print(matemat.__version__)')
# The Python package name provided by the python3-magic Debian package is "python-magic" rather than "file-magic".
- sed -re 's/file-magic/python-magic/' -i setup.py
- |
(for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
echo "matemat ($${version}-1) stable; urgency=medium\n"
cat CHANGELOG.md | grep -A 1000 "<"'!'"-- BEGIN CHANGES $${version} -->" | grep -B 1000 "<"'!'"-- END CHANGES $${version} -->" | tail -n +2 | head -n -1 | sed -re 's/^-/ */g'
echo "\n -- s3lph@kabelsalat.ch $(date -R)\n"
done) > package/debian/matemat/usr/share/doc/matemat/changelog
- gzip -9n package/debian/matemat/usr/share/doc/matemat/changelog
- cp -r static/ package/debian/matemat/usr/lib/matemat/static/
- cp -r templates/ package/debian/matemat/usr/lib/matemat/templates/
- mkdir -p package/debian/matemat/var/lib/matemat/themes/
- cp -r themes/ package/debian/matemat/usr/lib/matemat/themes/
- python3 setup.py egg_info install --root=package/debian/matemat/ --prefix=/usr --optimize=1
- cd package/debian
- mkdir -p matemat/usr/lib/python3/dist-packages/
- rsync -a matemat/usr/lib/python3.11/site-packages/ matemat/usr/lib/python3/dist-packages/
- rm -rf matemat/usr/lib/python3.11/
- find matemat/usr/lib/python3/dist-packages -name __pycache__ -exec rm -r {} \; 2>/dev/null || true
- find matemat/usr/lib/python3/dist-packages -name '*.pyc' -exec rm {} \;
- mv matemat/usr/bin/matemat matemat/usr/lib/matemat/matemat
- rm -rf matemat/usr/bin
- sed -re 's$#!/usr/local/bin/python3$#!/usr/bin/python3$' -i matemat/usr/lib/matemat/matemat
- find matemat -type f -exec chmod 0644 {} \;
- find matemat -type d -exec chmod 755 {} \;
- chmod +x matemat/usr/lib/matemat/matemat matemat/DEBIAN/postinst matemat/DEBIAN/prerm matemat/DEBIAN/postrm
- sed -re "s/__VERSION__/$${MATEMAT_VERSION}-1/g" -i matemat/DEBIAN/control
- dpkg-deb --build matemat
- mv matemat.deb "matemat_$${MATEMAT_VERSION}-1_all.deb"
- sudo -u nobody lintian "matemat_$${MATEMAT_VERSION}-1_all.deb" || true
- >-
curl
--user "$${GITEA_API_USERNAME}:$${GITEA_API_PASSWORD}"
--upload-file "matemat_$${EXPORTER_VERSION}-1_all.deb"
$${GITEA_API_REPOSITORY_DEB}

View file

@ -1,71 +1,5 @@
# Matemat Changelog
<!-- BEGIN RELEASE v0.3.12 -->
## Version 0.3.12
Sort products
### Changes
<!-- BEGIN CHANGES 0.3.12 -->
- Sort products in the list
<!-- END CHANGES 0.3.12 -->
<!-- END RELEASE v0.3.12 -->
<!-- BEGIN RELEASE v0.3.11 -->
## Version 0.3.11
Improve auto-logout
### Changes
<!-- BEGIN CHANGES 0.3.11 -->
- Show purchase overlay after logout
- Fix state of auto-logout checkbox after changing user settings
<!-- END CHANGES 0.3.11 -->
<!-- END RELEASE v0.3.11 -->
<!-- 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
Improve UX on small touchscreens
### Changes
<!-- BEGIN CHANGES 0.3.9 -->
- Improve link sizes for touchscreens
<!-- END CHANGES 0.3.9 -->
<!-- END RELEASE v0.3.9 -->
<!-- BEGIN RELEASE v0.3.8 -->
## Version 0.3.8
Migrate from Woodpecker CI to Forgejo Actions
### Changes
<!-- BEGIN CHANGES 0.3.8 -->
- Migrate from Woodpecker CI to Forgejo Actions
<!-- END CHANGES 0.3.8 -->
<!-- END RELEASE v0.3.8 -->
<!-- BEGIN RELEASE v0.3.7 -->
## Version 0.3.7

View file

@ -1,5 +1,7 @@
# Matemat
[![status-badge](https://woodpecker.kabelsalat.ch/api/badges/80/status.svg)](https://woodpecker.kabelsalat.ch/repos/80)
A web service for automated stock-keeping of a soda machine written in Python.
It provides a touch-input-friendly user interface (as most input happens through the
soda machine's touch screen).

View file

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

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, logout_after_purchase
SELECT user_id, username, email, is_admin, is_member, balance, receipt_pref
FROM users
WHERE touchkey IS NOT NULL OR NOT :must_have_touchkey
ORDER BY username COLLATE NOCASE ASC
@ -92,13 +92,12 @@ 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, logout_after_purchase = row
user_id, username, email, is_admin, is_member, balance, receipt_p = 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,
logout_after_purchase))
users.append(User(user_id, username, balance, email, is_admin, is_member, receipt_pref))
return users
def get_user(self, uid: int) -> User:
@ -109,7 +108,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, logout_after_purchase
SELECT user_id, username, email, is_admin, is_member, balance, receipt_pref
FROM users
WHERE user_id = ?
''',
@ -118,20 +117,19 @@ 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, logout_after_purchase = row
user_id, username, email, is_admin, is_member, balance, receipt_p = 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, logout_after_purchase)
return User(user_id, username, balance, email, is_admin, is_member, receipt_pref)
def create_user(self,
username: str,
password: str,
email: Optional[str] = None,
admin: bool = False,
member: bool = True,
logout_after_purchase: bool = False) -> User:
member: bool = True) -> User:
"""
Create a new user.
:param username: The name of the new user.
@ -139,7 +137,6 @@ 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.
"""
@ -153,17 +150,14 @@ 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,
logout_after_purchase)
VALUES (:username, :email, :pwhash, 0, :admin, :member, STRFTIME('%s', 'now'), STRFTIME('%s', 'now'),
:logout_after_purchase)
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'))
''', {
'username': username,
'email': email,
'pwhash': pwhash,
'admin': admin,
'member': member,
'logout_after_purchase': logout_after_purchase
'member': member
})
# Fetch the new user's rowid.
c.execute('SELECT last_insert_rowid()')
@ -185,15 +179,14 @@ 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,
logout_after_purchase
SELECT user_id, username, email, password, touchkey, is_admin, is_member, balance, receipt_pref
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, logout_after_purchase = row
user_id, username, email, pwhash, tkhash, admin, member, balance, receipt_p = row
if password is not None and not compare_digest(crypt.crypt(password, pwhash), pwhash):
raise AuthenticationError('Password mismatch')
elif touchkey is not None \
@ -206,7 +199,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, logout_after_purchase)
return User(user_id, username, balance, email, admin, member, receipt_pref)
def change_password(self, user: User, oldpass: str, newpass: str, verify_password: bool = True) -> None:
"""
@ -287,8 +280,6 @@ 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()
@ -321,8 +312,7 @@ class MatematDatabase(object):
is_admin = :is_admin,
is_member = :is_member,
receipt_pref = :receipt_pref,
lastchange = STRFTIME('%s', 'now'),
logout_after_purchase = :logout_after_purchase
lastchange = STRFTIME('%s', 'now')
WHERE user_id = :user_id
''', {
'user_id': user.id,
@ -331,8 +321,7 @@ class MatematDatabase(object):
'balance': balance,
'is_admin': is_admin,
'is_member': is_member,
'receipt_pref': receipt_pref.value,
'logout_after_purchase': logout_after_purchase
'receipt_pref': receipt_pref.value
})
# Only update the actual user object after the changes in the database succeeded
user.name = name
@ -340,7 +329,6 @@ class MatematDatabase(object):
user.balance = balance
user.is_admin = is_admin
user.is_member = is_member
user.logout_after_purchase = logout_after_purchase
user.receipt_pref = receipt_pref
def delete_user(self, user: User) -> None:
@ -368,7 +356,7 @@ class MatematDatabase(object):
with self.db.transaction(exclusive=False) as c:
for row in c.execute('''
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable
FROM products ORDER BY name
FROM products
'''):
product_id, name, price_member, price_external, custom_price, stock, stockable = row
products.append(Product(product_id, name, price_member, price_external, custom_price, stockable, stock))
@ -736,6 +724,40 @@ class MatematDatabase(object):
receipt = Receipt(receipt_id, transactions, user, created, datetime.utcnow())
return receipt
def get_transactions(self, user: User) -> List[Transaction]:
transactions: List[Transaction] = []
with self.db.transaction() as cursor:
cursor.execute('''
SELECT
t.ta_id, t.value, t.old_balance, COALESCE(t.date, 0),
c.ta_id, d.ta_id, m.ta_id, c.product, m.agent, m.reason
FROM transactions AS t
LEFT JOIN consumptions AS c
ON t.ta_id = c.ta_id
LEFT JOIN deposits AS d
ON t.ta_id = d.ta_id
LEFT JOIN modifications AS m
ON t.ta_id = m.ta_id
WHERE t.user_id = :user_id
ORDER BY t.date DESC
LIMIT 10
''', {
'user_id': user.id
})
rows = cursor.fetchall()
for row in rows:
ta_id, value, old_balance, date, c, d, m, c_prod, m_agent, m_reason = row
if c == ta_id:
t: Transaction = Consumption(ta_id, user, value, old_balance, datetime.fromtimestamp(date), c_prod)
elif d == ta_id:
t = Deposit(ta_id, user, value, old_balance, datetime.fromtimestamp(date))
elif m == ta_id:
t = Modification(ta_id, user, value, old_balance, datetime.fromtimestamp(date), m_agent, m_reason)
else:
t = Transaction(ta_id, user, value, old_balance, datetime.fromtimestamp(date))
transactions.append(t)
return transactions
def generate_sales_statistics(self, from_date: datetime, to_date: datetime) -> Dict[str, Any]:
consumptions: Dict[str, Tuple[int, int]] = dict()
total_income: int = 0

View file

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

View file

@ -413,84 +413,3 @@ 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

@ -342,9 +342,9 @@ class DatabaseTest(unittest.TestCase):
def test_transfer(self) -> None:
with self.db as db:
with db.transaction() as c:
user = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com', True, True, False)
user2 = db.create_user('testuser2', 'supersecurepassword', 'testuser@example.com', True, True, False)
user3 = db.create_user('testuser3', 'supersecurepassword', 'testuser@example.com', True, True, True)
user = db.create_user('testuser', 'supersecurepassword', 'testuser@example.com', True, True)
user2 = db.create_user('testuser2', 'supersecurepassword', 'testuser@example.com', True, True)
user3 = db.create_user('testuser3', 'supersecurepassword', 'testuser@example.com', True, True)
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user.id])
self.assertEqual(0, c.fetchone()[0])
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user2.id])

View file

@ -53,12 +53,12 @@ class DatabaseTest(unittest.TestCase):
with self.db as db:
with db.transaction() as c:
c.execute('''
INSERT INTO users VALUES (1, 'testuser', NULL, 'supersecurepassword', NULL, 1, 1, 0, 42, 0, 0, 0)
INSERT INTO users VALUES (1, 'testuser', NULL, 'supersecurepassword', NULL, 1, 1, 0, 42, 0, 0)
''')
c = db._sqlite_db.cursor()
c.execute("SELECT * FROM users")
user = c.fetchone()
self.assertEqual((1, 'testuser', None, 'supersecurepassword', None, 1, 1, 0, 42, 0, 0, 0), user)
self.assertEqual((1, 'testuser', None, 'supersecurepassword', None, 1, 1, 0, 42, 0, 0), user)
def test_transaction_rollback(self) -> None:
"""
@ -68,7 +68,7 @@ class DatabaseTest(unittest.TestCase):
try:
with db.transaction() as c:
c.execute('''
INSERT INTO users VALUES (1, 'testuser', NULL, 'supersecurepassword', NULL, 1, 1, 0, 42, 0, 0, 0)
INSERT INTO users VALUES (1, 'testuser', NULL, 'supersecurepassword', NULL, 1, 1, 0, 42, 0, 0)
''')
raise ValueError('This should trigger a rollback')
except ValueError as e:

View file

@ -40,7 +40,7 @@ class DatabaseTransaction(object):
class DatabaseWrapper(object):
SCHEMA_VERSION = 7
SCHEMA_VERSION = 6
def __init__(self, filename: str) -> None:
self._filename: str = filename
@ -89,8 +89,6 @@ 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

@ -18,3 +18,4 @@ from .moduser import moduser
from .modproduct import modproduct
from .userbootstrap import userbootstrap
from .statistics import statistics
from .transactions import transactions_page

View file

@ -75,7 +75,6 @@ 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
@ -85,8 +84,7 @@ 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,
logout_after_purchase=logout_after_purchase)
db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref)
except DatabaseConsistencyError:
return
@ -178,10 +176,8 @@ 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,
logout_after_purchase=logout_after_purchase)
newuser: User = db.create_user(username, password, email, member=is_member, admin=is_admin)
# If a default avatar is set, copy it to the user's avatar path

View file

@ -16,7 +16,6 @@ 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
@ -35,9 +34,6 @@ 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(f'/logout?lastaction=buy&lastproduct={pid}&lastprice={price}')
# Redirect to the main page (where this request should have come from)
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}')
redirect('/')

View file

@ -1,6 +1,4 @@
import urllib.parse
from bottle import get, post, redirect, request
from bottle import get, post, redirect
from matemat.webserver import session
@ -18,4 +16,4 @@ def logout():
# Reset the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session.put(session_id, 'authentication_level', 0)
# Redirect to the main page, showing the user list
redirect(f'/?{urllib.parse.urlencode(request.query)}')
redirect('/')

View file

@ -16,13 +16,6 @@ def main_page():
session_id: str = session.start()
now = str(int(datetime.utcnow().timestamp()))
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the list of products to display
products = db.list_products()
if request.params.lastproduct:
lastproduct = db.get_product(request.params.lastproduct)
else:
lastproduct = None
lastprice = int(request.params.lastprice) if request.params.lastprice else None
# Check whether a user is logged in
if session.has(session_id, 'authenticated_user'):
# Fetch the user id and authentication level (touchkey vs password) from the session storage
@ -31,6 +24,13 @@ def main_page():
# Fetch the user object from the database (for name display, price calculation and admin check)
users = db.list_users()
user = db.get_user(uid)
# Fetch the list of products to display
products = db.list_products()
if request.params.lastproduct:
lastproduct = db.get_product(request.params.lastproduct)
else:
lastproduct = None
lastprice = int(request.params.lastprice) if request.params.lastprice else None
# Prepare a response with a jinja2 template
return template.render('productlist.html',
authuser=user, users=users, products=products, authlevel=authlevel,
@ -44,5 +44,4 @@ def main_page():
users = db.list_users(with_touchkey=True)
return template.render('userlist.html',
users=users, setupname=config['InstanceName'], now=now,
signup=(config.get('SignupEnabled', '0') == '1'),
lastaction=request.params.lastaction, lastprice=lastprice, lastproduct=lastproduct)
signup=(config.get('SignupEnabled', '0') == '1'))

View file

@ -111,7 +111,6 @@ 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
@ -122,8 +121,7 @@ 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,
logout_after_purchase=logout_after_purchase)
balance=balance, balance_reason=balance_reason, receipt_pref=receipt_pref)
except DatabaseConsistencyError:
return
# If a new avatar was uploaded, process it

View file

@ -0,0 +1,39 @@
from datetime import datetime
from bottle import route, redirect, request
from matemat.db import MatematDatabase
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config, get_stock_provider
@route('/transactions')
def transactions_page():
"""
The transaction history page, showing a list of recent transactions.
"""
config = get_app_config()
session_id: str = session.start()
now = str(int(datetime.utcnow().timestamp()))
with MatematDatabase(config['DatabaseFile']) as db:
# Check whether a user is logged in
if session.has(session_id, 'authenticated_user'):
# Fetch the user id and authentication level (touchkey vs password) from the session storage
uid: int = session.get(session_id, 'authenticated_user')
authlevel: int = session.get(session_id, 'authentication_level')
# Fetch the user object from the database (for name display, price calculation and admin check)
user = db.get_user(uid)
transactions = db.get_transactions(user)
# Prepare a response with a jinja2 template
return template.render('transactions.html',
authuser=user, authlevel=authlevel,
setupname=config['InstanceName'], transactions=transactions)
else:
# If there are no admin users registered, jump to the admin creation procedure
if not db.has_admin_users():
redirect('/userbootstrap')
# If no user is logged in, fetch the list of users and render the userlist template
users = db.list_users(with_touchkey=True)
return template.render('userlist.html',
users=users, setupname=config['InstanceName'], now=now,
signup=(config.get('SignupEnabled', '0') == '1'))

View file

@ -13,8 +13,8 @@ nav div {
.thumblist-item {
display: inline-block;
margin: 5px 5px 10px 10px;
padding: 15px;
margin: 5px;
padding: 5px;
background: #f0f0f0;
text-decoration: none;
}
@ -331,4 +331,4 @@ aside#overlay > img {
aside#overlay > div.price {
padding-top: 30px;
font-size: 2em;
}
}

View file

@ -23,9 +23,6 @@
<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,9 +17,6 @@
<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

@ -13,34 +13,17 @@
<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>
{% block header %}
<nav class="navbarbutton">
{# Always show a link to the home page, either a list of users or of products. #}
<nav class="navbarbutton">
<div class="selected"><a href="/">Home</a></div>
{# Show a link to the settings, if a user logged in via password (authlevel 2). #}
{% if authlevel|default(0) > 1 %}
{% if authuser is defined %}
<a href="/">Home</a>
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
{% if authuser.is_admin %}
<a href="/admin">Administration</a>
@ -50,6 +33,11 @@
{% endif %}
{% endif %}
{% endif %}
{% if authlevel|default(0) > 0 %}
{% if authuser is defined %}
<a href="/transactions">Transactions</a>
{% endif %}
{% endif %}
</nav>
{% endblock %}
</header>

View file

@ -35,9 +35,6 @@
<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/>

View file

@ -6,6 +6,27 @@
{{ super() }}
{% endblock %}
{% block overlay %}
{% if 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 %}
{% block main %}
{# Show the users current balance #}

View file

@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block header %}
{# Show the setup name, as set in the config file, as page title. Don't escape HTML entities. #}
<h1>{{ setupname|safe }}</h1>
{{ super() }}
<style>
table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
</style>
{% endblock %}
{% block main %}
<div class="transactions-table">
List of 10 most recent transactions.
<table>
<tr>
<th>Date</th>
<th>Description</th>
<th>Value</th>
<th>Message</th>
</tr>
{% for t in transactions %}
<tr>
<td>{{ t.receipt_date }}</td>
<td>{{ t.receipt_description }}</td>
<td>{{ t.receipt_value }}</td>
<td>{{ t.receipt_message }}</td>
</tr>
{% endfor %}
</table>
</div>
{{ super() }}
{% endblock %}

View file

@ -22,13 +22,9 @@
<br/>
{# Link to the password login #}
<div class="thumblist-item">
<a href="/login">Password login</a>
</div>
<a href="/login">Password login</a>
{% if signup %}
<div class="thumblist-item">
<a href="/signup">Create account</a>
</div>
<a href="/signup">Create account</a>
{% endif %}
{{ super() }}