1
0
Fork 0
forked from s3lph/matemat

Compare commits

...

13 commits
main ... main

Author SHA1 Message Date
f614fe1afc
breaking: remove the config option to automatically close tabs after ean purchase
fix: improve error handling on database consistency errors (e.g. non-unique ean codes) in the settings
feat: handle ean codes in the already open tab via a websocket connection
feat: populate ean code input field when a barcode is scanned while in the product settings
2024-11-25 23:29:30 +01:00
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
bfc503c5d3
feat: sort products in list 2024-05-05 00:21:04 +02:00
d41484e69a
fix: improve auto logout 2024-04-12 23:43:01 +02:00
c8243fd9d5
feat: release 0.3.10 2024-04-09 22:47:36 +02:00
677c1e681b
fix: unittests, codestyle 2024-04-09 22:40:38 +02:00
d54aa2bc57
feat: add option to log out users automatically after completing a purchase 2024-04-09 22:35:37 +02:00
1e561fd9cd
feat: release 0.3.9 2023-12-22 20:00:11 +01:00
97df130768
feat: improve link sizes for touchscreens 2023-12-22 19:57:51 +01:00
1b45a21210
feat: migrate from woodpecker to forgejo actions 2023-12-19 05:01:51 +01:00
abd70b9cc6
fix(ci): wrong filename when uploading deb package to registry 2023-09-09 18:43:25 +02:00
41 changed files with 787 additions and 346 deletions

View file

@ -0,0 +1,52 @@
---
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

@ -0,0 +1,27 @@
---
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

View file

@ -1,91 +0,0 @@
---
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,5 +1,117 @@
# Matemat Changelog
<!-- BEGIN RELEASE v0.3.15 -->
## Version 0.3.15
Websocket-based EAN code handling
### Changes
<!-- BEGIN CHANGES 0.3.15 -->
- breaking: remove the config option to automatically close tabs after ean purchase
- fix: improve error handling on database consistency errors (e.g. non-unique ean codes) in the settings
- feat: handle ean codes in the already open tab via a websocket connection
- feat: populate ean code input field when a barcode is scanned while in the product settings
<!-- END CHANGES 0.3.15 -->
<!-- END RELEASE v0.3.15 -->
<!-- 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 -->
## 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,7 +1,5 @@
# 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.7'
__version__ = '0.3.15'

View file

@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type
import crypt
from hmac import compare_digest
from datetime import datetime
from datetime import datetime, UTC
from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \
Transaction, Consumption, Deposit, Modification
@ -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,28 +109,28 @@ 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 = ?
''',
[uid])
WHERE user_id = :user_id
''', {'user_id': uid})
row = c.fetchone()
if row is None:
raise ValueError(f'No user with user ID {uid} exists.')
# Unpack the row and construct the user
user_id, username, email, is_admin, is_member, balance, 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 +138,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.
"""
@ -145,19 +147,22 @@ class MatematDatabase(object):
user_id: int = -1
with self.db.transaction() as c:
# 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:
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,
'pwhash': pwhash,
'admin': admin,
'member': member
'member': member,
'logout_after_purchase': logout_after_purchase
})
# Fetch the new user's rowid.
c.execute('SELECT last_insert_rowid()')
@ -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])
WHERE username = :username
''', {'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:
"""
@ -214,8 +220,8 @@ class MatematDatabase(object):
with self.db.transaction() as c:
# Fetch the old password.
c.execute('''
SELECT password FROM users WHERE user_id = ?
''', [user.id])
SELECT password FROM users WHERE user_id = :user_id
''', {'user_id': user.id})
row = c.fetchone()
if row is None:
raise AuthenticationError('User does not exist in database.')
@ -244,8 +250,8 @@ class MatematDatabase(object):
with self.db.transaction() as c:
# Fetch the password.
c.execute('''
SELECT password FROM users WHERE user_id = ?
''', [user.id])
SELECT password FROM users WHERE user_id = :user_id
''', {'user_id': user.id})
row = c.fetchone()
if row is None:
raise AuthenticationError('User does not exist in database.')
@ -280,6 +286,8 @@ 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 +320,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 +330,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 +339,7 @@ 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:
@ -340,8 +351,8 @@ class MatematDatabase(object):
with self.db.transaction() as c:
c.execute('''
DELETE FROM users
WHERE user_id = ?
''', [user.id])
WHERE user_id = :user_id
''', {'user_id': user.id})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(
@ -355,34 +366,52 @@ class MatematDatabase(object):
products: List[Product] = []
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
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean
FROM products ORDER BY name
'''):
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))
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, ean))
return products
def get_product(self, pid: int) -> Product:
"""
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:
# Fetch all values to construct the product
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
WHERE product_id = ?''',
[pid])
WHERE product_id = :product_id''', {'product_id': pid})
row = c.fetchone()
if row is None:
raise ValueError(f'No product with product ID {pid} exists.')
# Unpack the row and construct the product
product_id, name, price_member, price_non_member, custom_price, stock, stockable = row
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock)
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 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:
bool, stockable: bool) -> Product:
bool, stockable: bool, ean: str) -> Product:
"""
Creates a new product.
:param name: Name of the product.
@ -395,22 +424,23 @@ class MatematDatabase(object):
"""
product_id: int = -1
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:
raise ValueError(f'A product with the name \'{name}\' already exists.')
c.execute('''
INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable)
VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :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, :ean)
''', {
'name': name,
'price_member': price_member,
'price_non_member': price_non_member,
'custom_price': custom_price,
'stockable': stockable
'stockable': stockable,
'ean': ean,
})
c.execute('SELECT last_insert_rowid()')
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:
"""
@ -429,6 +459,7 @@ class MatematDatabase(object):
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
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:
c.execute('''
UPDATE products
@ -438,7 +469,8 @@ class MatematDatabase(object):
price_non_member = :price_non_member,
custom_price = :custom_price,
stock = :stock,
stockable = :stockable
stockable = :stockable,
ean = :ean
WHERE product_id = :product_id
''', {
'product_id': product.id,
@ -447,7 +479,8 @@ class MatematDatabase(object):
'price_non_member': price_non_member,
'custom_price': custom_price,
'stock': stock,
'stockable': stockable
'stockable': stockable,
'ean': ean
})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
@ -460,6 +493,7 @@ class MatematDatabase(object):
product.custom_price = custom_price
product.stock = stock
product.stockable = stockable
product.ean = ean
def delete_product(self, product: Product) -> None:
"""
@ -470,8 +504,8 @@ class MatematDatabase(object):
with self.db.transaction() as c:
c.execute('''
DELETE FROM products
WHERE product_id = ?
''', [product.id])
WHERE product_id = :product_id
''', {'product_id': product.id})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(
@ -531,8 +565,7 @@ class MatematDatabase(object):
if amount < 0:
raise ValueError('Cannot deposit a negative value')
with self.db.transaction() as c:
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
[user.id])
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': user.id})
row = c.fetchone()
if row is None:
raise DatabaseConsistencyError(f'No such user: {user.id}')
@ -576,8 +609,7 @@ class MatematDatabase(object):
raise ValueError('Cannot transfer a negative value')
with self.db.transaction() as c:
# First, remove amount from the source user's account
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
[source.id])
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': source.id})
row = c.fetchone()
if row is None:
raise DatabaseConsistencyError(f'No such user: {source.id}')
@ -609,8 +641,7 @@ class MatematDatabase(object):
if affected != 1:
raise DatabaseConsistencyError(f'transfer should affect 1 users row, but affected {affected}')
# Then, add the amount to the destination user's account
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
[dest.id])
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': dest.id})
row = c.fetchone()
if row is None:
raise DatabaseConsistencyError(f'No such user: {dest.id}')
@ -657,11 +688,11 @@ class MatematDatabase(object):
LEFT JOIN receipts AS r
ON r.user_id = u.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)
return datetime.utcnow() > next_receipt_due
return datetime.now(UTC) > next_receipt_due
def create_receipt(self, user: User, write: bool = False) -> Receipt:
transactions: List[Transaction] = []
@ -672,12 +703,12 @@ class MatematDatabase(object):
LEFT JOIN receipts AS r
ON r.user_id = u.user_id
WHERE u.user_id = :user_id
''', [user.id])
''', {'user_id': user.id})
row = cursor.fetchone()
if row is None:
raise DatabaseConsistencyError(f'No such user: {user.id}')
fromdate, min_id = row
created: datetime = datetime.fromtimestamp(fromdate)
created: datetime = datetime.fromtimestamp(fromdate, UTC)
cursor.execute('''
SELECT
t.ta_id, t.value, t.old_balance, COALESCE(t.date, 0),
@ -700,13 +731,15 @@ class MatematDatabase(object):
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)
t: Transaction = Consumption(ta_id, user, value, old_balance,
datetime.fromtimestamp(date, UTC), c_prod)
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:
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:
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)
if write:
cursor.execute('''
@ -721,7 +754,7 @@ class MatematDatabase(object):
receipt_id: int = int(cursor.fetchone()[0])
else:
receipt_id = -1
receipt = Receipt(receipt_id, transactions, user, created, datetime.utcnow())
receipt = Receipt(receipt_id, transactions, user, created, datetime.now(UTC))
return receipt
def generate_sales_statistics(self, from_date: datetime, to_date: datetime) -> Dict[str, Any]:
@ -763,7 +796,7 @@ class MatematDatabase(object):
LIMIT 1
), u.balance)
FROM users AS u
''', [to_date.timestamp()])
''', {'to_date': to_date.timestamp()})
for balance, in c.fetchall():
if balance > 0:
positive_balance += balance

View file

@ -276,3 +276,22 @@ 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;
''')
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 stock: The number of items of this product currently in stock, or None if not 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,
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.name: str = name
self.price_member: int = price_member
@ -23,6 +24,7 @@ class Product:
self.custom_price: bool = custom_price
self.stock: int = stock
self.stockable: bool = stockable
self.ean: str = ean
def __eq__(self, other) -> bool:
if not isinstance(other, Product):
@ -33,8 +35,9 @@ class Product:
self.price_non_member == other.price_non_member and \
self.custom_price == other.custom_price and \
self.stock == other.stock and \
self.stockable == other.stockable
self.stockable == other.stockable and \
self.ean == other.ean
def __hash__(self) -> int:
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
def receipt_date(self) -> str:
if self.date == datetime.fromtimestamp(0):
if self.date == datetime.fromtimestamp(0, UTC):
return '<unknown> '
date: str = self.date.strftime('%d.%m.%Y, %H:%M')
return date

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

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)
INSERT INTO users VALUES (1, 'testuser', NULL, 'supersecurepassword', NULL, 1, 1, 0, 42, 0, 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), user)
self.assertEqual((1, 'testuser', None, 'supersecurepassword', None, 1, 1, 0, 42, 0, 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)
INSERT INTO users VALUES (1, 'testuser', NULL, 'supersecurepassword', NULL, 1, 1, 0, 42, 0, 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 = 6
SCHEMA_VERSION = 8
def __init__(self, filename: str) -> None:
self._filename: str = filename
@ -89,6 +89,10 @@ 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)
if from_version <= 7 and to_version >= 8:
migrate_schema_7_to_8(c)
def connect(self) -> None:
if self.is_connected():

View file

@ -2,7 +2,7 @@
from typing import Optional
class DatabaseConsistencyError(BaseException):
class DatabaseConsistencyError(Exception):
def __init__(self, msg: Optional[str] = None) -> None:
self._msg: Optional[str] = msg

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
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
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

View file

@ -1,5 +1,5 @@
import os
from datetime import datetime
from datetime import datetime, UTC
from io import BytesIO
from shutil import copyfile
@ -13,6 +13,7 @@ from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.util.currency_format import parse_chf
from matemat.webserver import session, template
from matemat.webserver.config import get_app_config, get_stock_provider
from matemat.webserver.template import Notification
@get('/admin')
@ -48,7 +49,7 @@ def admin():
users = db.list_users()
products = db.list_products()
# Render the "Admin/Settings" page
now = str(int(datetime.utcnow().timestamp()))
now = str(int(datetime.now(UTC).timestamp()))
return template.render('admin.html',
authuser=user, authlevel=authlevel, users=users, products=products,
receipt_preference_class=ReceiptPreference, now=now,
@ -75,6 +76,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 +86,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 +179,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
@ -202,8 +207,9 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
price_non_member = parse_chf(str(args.pricenonmember))
custom_price = 'custom_price' in args
stockable = 'stockable' in args
# Create the user in the database
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable)
ean = str(args.ean) or None
# Create the product in the database
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable, ean)
# If a new product image was uploaded, process it
image = files.image.file.read() if 'image' in files else None
if image is not None and len(image) > 0:
@ -221,7 +227,8 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'{newproduct.id}.png'), 'PNG')
except OSError:
except OSError as e:
Notification.error(str(e), decay=True)
return
else:
# If no image was uploaded and a default avatar is set, copy it to the product's avatar path
@ -277,8 +284,10 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'default.png'), 'PNG')
except OSError:
except OSError as e:
Notification.error(str(e), decay=True)
return
except UnicodeDecodeError:
raise ValueError('an argument not a string')
except Exception as e:
Notification.error(str(e), decay=True)
return

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(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,4 +1,6 @@
from bottle import get, post, redirect
import urllib.parse
from bottle import get, post, redirect, request
from matemat.webserver import session
@ -16,4 +18,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('/')
redirect(f'/?{urllib.parse.urlencode(request.query)}')

View file

@ -1,10 +1,12 @@
from datetime import datetime
from datetime import datetime, UTC
from bottle import route, redirect, request
from matemat.db import MatematDatabase
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.util.currency_format import format_chf
@route('/')
@ -14,27 +16,40 @@ def main_page():
"""
config = get_app_config()
session_id: str = session.start()
now = str(int(datetime.utcnow().timestamp()))
now = str(int(datetime.now(UTC).timestamp()))
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the list of products to display
products = db.list_products()
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
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')
# If an EAN code was scanned, directly trigger the purchase
if buyproduct:
redirect(f'/buy?pid={buyproduct.id}')
# 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,
lastaction=request.params.lastaction, lastprice=lastprice, lastproduct=lastproduct,
stock=get_stock_provider(), setupname=config['InstanceName'], now=now)
else:
# If there are no admin users registered, jump to the admin creation procedure
@ -44,4 +59,5 @@ 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'))
signup=(config.get('SignupEnabled', '0') == '1'),
buyproduct=buyproduct)

View file

@ -1,6 +1,6 @@
import os
from io import BytesIO
from datetime import datetime
from datetime import datetime, UTC
from typing import Dict
import magic
@ -56,7 +56,7 @@ def modproduct():
redirect('/admin')
# Render the "Modify Product" page
now = str(int(datetime.utcnow().timestamp()))
now = str(int(datetime.now(UTC).timestamp()))
return template.render('modproduct.html',
authuser=authuser, product=product, authlevel=authlevel,
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
stock = int(str(args.stock))
stockable = 'stockable' in args
ean = str(args.ean) or None
# Attempt to write the changes to the database
try:
db.change_product(product,
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()
if stock_provider.needs_update() and product.stockable:
stock_provider.set_stock(product, stock)

View file

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

@ -1,4 +1,4 @@
from datetime import datetime, timedelta
from datetime import datetime, timedelta, UTC
from math import pi, sin, cos
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
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)
if 'fromdate' in request.params:
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.exceptions import AuthenticationError
from matemat.webserver import template, session
from matemat.webserver.template import Notification
from matemat.webserver.config import get_app_config
@ -16,14 +17,24 @@ def touchkey_page():
"""
config = get_app_config()
session_id: str = session.start()
with MatematDatabase(config['DatabaseFile']) as db:
# If a user is already logged in, simply redirect to the main page, showing the product list
if session.has(session_id, 'authenticated_user'):
redirect('/')
# If requested via HTTP GET, render the login page showing the touchkey UI
if request.method == 'GET':
buypid = None
if request.params.buypid:
buypid = str(request.params.buypid)
try:
buyproduct = db.get_product(int(buypid))
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
except ValueError:
Notification.error(f'No product with id {buypid}', decay=True)
return template.render('touchkey.html',
username=str(request.params.username), uid=int(str(request.params.uid)),
setupname=config['InstanceName'])
setupname=config['InstanceName'], buypid=buypid)
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
elif request.method == 'POST':
# Connect to the database
@ -38,6 +49,9 @@ def touchkey_page():
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)
redirect(f'/buy?pid={buypid}')
# 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

View file

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

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

@ -2,9 +2,15 @@ from typing import Any, Dict
import os.path
import jinja2
import netaddr
from bottle import request
from matemat import __version__
from matemat.util.currency_format import format_chf
from matemat.webserver.template import Notification
from matemat.webserver.config import get_app_config
__jinja_env: jinja2.Environment = None
@ -21,5 +27,16 @@ def init(config: Dict[str, Any]) -> None:
def render(name: str, **kwargs):
global __jinja_env
config = get_app_config()
template: jinja2.Template = __jinja_env.get_template(name)
return template.render(__version__=__version__, **kwargs).encode('utf-8')
wsacl = netaddr.IPSet([addr.strip() for addr in config.get('EanWebsocketAcl', '').split(',')])
if config.get('EanWebsocketUrl', '') and request.remote_addr in wsacl:
eanwebsocket = config.get('EanWebsocketUrl')
else:
eanwebsocket = None
return template.render(
__version__=__version__,
notifications=Notification.render(),
eanwebsocket=eanwebsocket,
**kwargs
).encode('utf-8')

View file

@ -37,6 +37,13 @@ InstanceName=Matemat
#SignupEnabled=1
#SignupKioskMode= ::1, ::ffff:127.0.0.0/8, 127.0.0.0/8
#
# Open a websocket connection on which to listen for scanned barcodes.
# Can be restricted so that e.g. the connection is only attempted when the client is localhost.
#
#EanWebsocketUrl=ws://localhost:47808/ws
#EanWebsocketAcl=::1,::ffff:127.0.0.0/104,127.0.0.0/8
# Add static HTTP headers in this section
# [HttpHeaders]

View file

@ -13,8 +13,8 @@ nav div {
.thumblist-item {
display: inline-block;
margin: 5px;
padding: 5px;
margin: 5px 5px 10px 10px;
padding: 15px;
background: #f0f0f0;
text-decoration: none;
}
@ -52,6 +52,25 @@ nav div {
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 {
footer {
position: fixed;
@ -298,37 +317,3 @@ div.osk-button.osk-button-space {
flex: 5 0 1px;
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

@ -23,3 +23,12 @@
{{ super() }}
{% endblock %}
{% block eanwebsocket %}
function (e) {
let eaninput = document.getElementById("admin-newproduct-ean");
eaninput.value = e.data;
eaninput.select();
eaninput.scrollIntoView();
}
{% endblock %}

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-newuser-logout-after-purchase">Logout after purchase: </label>
<input id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase" /><br/>
<input type="submit" value="Create User" />
</form>
</section>
@ -43,6 +46,9 @@
<label for="admin-newproduct-name">Name: </label>
<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>
CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0" /><br/>

View file

@ -12,18 +12,14 @@
<body>
{% block overlay %}
{% endblock %}
<header>
{% block header %}
{# 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>
@ -38,6 +34,11 @@
</header>
<main>
{% block notifications %}
{% for n in notifications | default([]) %}
<div class="notification {{ n.classes | join(' ') }}">{{ n.msg|safe }}</div>
{% endfor %}
{% endblock %}
{% block main %}
{# Here be content. #}
{% endblock %}
@ -55,6 +56,15 @@
{% endblock %}
</footer>
<script src="/static/js/overlay.js"></script>
{% if eanwebsocket %}
<script>
function connect() {
let socket = new WebSocket("{{ eanwebsocket }}");
socket.onclose = () => { setTimeout(connect, 1000); };
socket.onmessage = {% block eanwebsocket %}() => {}{% endblock %};
}
window.addEventListener("load", () => { connect(); });
</script>
{% endif %}
</body>
</html>

View file

@ -14,6 +14,9 @@
<label for="modproduct-name">Name: </label>
<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>
CHF <input id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" /><br/>
@ -49,3 +52,12 @@
{{ super() }}
{% endblock %}
{% block eanwebsocket %}
function (e) {
let eaninput = document.getElementById("modproduct-ean");
eaninput.value = e.data;
eaninput.select();
eaninput.scrollIntoView();
}
{% endblock %}

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/>

View file

@ -6,27 +6,6 @@
{{ 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 #}
@ -74,7 +53,7 @@
{% if product.custom_price %}
<a onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
{% else %}
<a href="/buy?pid={{ product.id }}">
<a {% if product.ean %}id="a-buy-ean{{ product.ean }}"{% endif %} href="/buy?pid={{ product.id }}">
{% endif %}
<span class="thumblist-title">{{ product.name }}</span>
{% if product.custom_price %}
@ -103,3 +82,14 @@
{{ super() }}
{% endblock %}
{% block eanwebsocket %}
function (e) {
let eaninput = document.getElementById("a-buy-ean" + e.data);
if (eaninput === null) {
document.location = "?ean=" + e.data;
} else {
eaninput.click();
}
}
{% endblock %}

View file

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

View file

@ -11,7 +11,7 @@
{% for user in users %}
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
<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/>
<div class="imgcontainer">
<img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" draggable="false"/>
@ -22,11 +22,21 @@
<br/>
{# Link to the password login #}
<div class="thumblist-item">
<a href="/login">Password login</a>
</div>
{% if signup %}
<div class="thumblist-item">
<a href="/signup">Create account</a>
</div>
{% endif %}
{{ super() }}
{% endblock %}
{% block eanwebsocket %}
function (e) {
document.location = "?ean=" + e.data;
}
{% endblock %}