1
0
Fork 0
forked from s3lph/matemat

Compare commits

..

57 commits

Author SHA1 Message Date
64f682fa11
fix: deposit js error 2024-12-17 20:17:26 +01:00
b9d556ce71
fix: height of name and scroll buttons in transfer dialog 2024-12-13 19:57:44 +01:00
f58a95a094
feat: add users balance to admin interface 2024-12-11 04:04:59 +01:00
f517621245
fix: scale up thumbnails smaller than 300x300 2024-12-11 03:08:14 +01:00
924b9cee77
v0.4.5 2024-12-11 01:39:53 +01:00
2fdc73c35b
fix: default values of config options SignupKioskMode and BarcodeWebsocketAcl did not work 2024-12-11 01:35:32 +01:00
d163cfda05
feat: proper thumbnail cache handling, removal of cachebusters 2024-12-11 01:31:29 +01:00
5a0c0cf148
feat: always pad thumbnails to a square shape 2024-12-11 01:18:56 +01:00
54086dea39
refactor: move image upload to a unified function 2024-12-11 00:51:55 +01:00
59c9bb2e71
fix: barcodes unittest 2024-12-11 00:21:00 +01:00
e23ba65c71
fix: non-http templates 2024-12-11 00:12:33 +01:00
418d7ffea9
feat: load default thumbnail on fetch rather than copying default thumbnail to user/product on creation 2024-12-11 00:04:36 +01:00
583107ac63
feat: allow multiple barcodes to be associated with a product
chore: consistent renaming from ean to barcode
2024-12-09 22:07:54 +01:00
a7150e123e
feat: make user settings available via touchkey login
feat: add an explicit home button to the navbar
2024-12-08 04:36:51 +01:00
f6f7b5abdb
release v0.4.3 2024-12-08 00:29:19 +01:00
1669ef4c1e
feat: improve "logout after purchase" ui representation 2024-12-08 00:21:11 +01:00
ddf5ed01a2
refactor(db): greatly simplify database migrations 2024-12-08 00:05:28 +01:00
1823759433
fix: default avatar missing after signup in non-kiosk mode 2024-12-07 23:28:15 +01:00
2be7bf7683
release v0.4.2 2024-12-07 22:20:50 +01:00
e5870f4d60 Merge pull request 'feat: refactor user settings' (#4) from weva/matemat:main into main
Reviewed-on: s3lph/matemat#4
2024-12-07 22:19:39 +01:00
f152a5070b Merge branch 'main' into main 2024-12-07 22:16:16 +01:00
e9b05fa4f4
fix: keep buypid when wrong touchkey is entered 2024-12-07 22:15:48 +01:00
Valentin Weber
470da688f3
feat: refactor password change section 2024-12-07 22:08:46 +01:00
4f69a1b447 Merge branch 'main' into main 2024-12-07 22:03:58 +01:00
8879add39b
feat: take a backup of the sqlite db before executing schema migrations 2024-12-07 22:02:00 +01:00
2883593eeb Merge branch 'main' into main 2024-12-07 21:28:02 +01:00
Valentin Weber
d3c5a8a56b
feat: move avatar up 2024-12-07 21:27:18 +01:00
43ac5d656f
feat: add logout after purchase setting to productlist 2024-12-07 21:15:46 +01:00
51adba2e25
fix: sqlite constraint migration 2024-12-07 21:15:08 +01:00
Valentin Weber
6e9f60eb36
Merge branch 'main' of ssh://git.kabelsalat.ch:2222/weva/matemat 2024-12-07 21:08:55 +01:00
Valentin Weber
9a1c220813 feat: refactor user settings part 1 2024-12-07 21:08:20 +01:00
Valentin Weber
05e2bed4d2
feat: refactor user settings part 1 2024-12-07 21:07:15 +01:00
dd65b5c4d0
feat: use button groups in admin tables 2024-12-07 20:38:24 +01:00
f8de9f5e1e Merge pull request 'feat(template): improve responsiveness' (#3) from weva/matemat:main into main
Reviewed-on: s3lph/matemat#3
2024-12-07 19:57:21 +01:00
Valentin Weber
a5907dce2d
feat(templates): improve breakpoints 2024-12-07 19:52:14 +01:00
ef53367035
fix: recreate users table with case-insensitive username 2024-12-07 19:51:07 +01:00
Valentin Weber
4943508233
feat(templates): userlist improve breakpoints 2024-12-07 19:49:40 +01:00
Valentin Weber
8fb3bd8d9c
feat(templates): modify login mask for responsiveness 2024-12-07 19:38:35 +01:00
745843e07d
fix: session id shared between all sessions
fix: minor css fixes
2024-12-07 16:30:28 +01:00
67e2a813d5
feat: redesign ui using bootstrap
feat: split user settings and admin settings
fix: list user tokens in admin user settings
feat!: remove osk, osk should be provided by kiosk browser
2024-12-07 15:53:19 +01:00
b3b47b6b60
fix: debian postinst/prerm scripts 2024-12-01 23:44:26 +01:00
4cf563ce62
fix: missing error message when scanning an unassociated barcode 2024-12-01 23:37:22 +01:00
418cff7348
feat: add barcode login feature 2024-12-01 21:58:16 +01:00
66f23f5dda
fix: store notifications in the session so that they won't be served to other clients
feat: list all users and products in a table in the settings
feat: add back buttons to signup, password login and touchkey login pages
feat: if the tabfocus webextension is installed, use it to focus the tab when a barcode is scanned
2024-11-27 23:45:42 +01:00
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
68 changed files with 2393 additions and 2183 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

1
.gitignore vendored
View file

@ -9,5 +9,6 @@
*.sqlite3
*.db
*.bak
static/upload/
./matemat.conf

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,293 @@
# Matemat Changelog
<!-- BEGIN RELEASE v0.4.8 -->
## Version 0.4.8
UI/UX Release
### Changes
<!-- BEGIN CHANGES 0.4.8 -->
- fix: height of name and scroll buttons in transfer dialog
<!-- END CHANGES 0.4.8 -->
<!-- END RELEASE v0.4.8 -->
<!-- BEGIN RELEASE v0.4.7 -->
## Version 0.4.7
Feature release
### Changes
<!-- BEGIN CHANGES 0.4.7 -->
- feat: add users balance to admin interface
<!-- END CHANGES 0.4.7 -->
<!-- END RELEASE v0.4.7 -->
<!-- BEGIN RELEASE v0.4.6 -->
## Version 0.4.6
Bugfix release
### Changes
<!-- BEGIN CHANGES 0.4.6 -->
- fix: scale up thumbnails smaller than 300x300
<!-- END CHANGES 0.4.6 -->
<!-- END RELEASE v0.4.6 -->
<!-- BEGIN RELEASE v0.4.5 -->
## Version 0.4.5
Feature & Bugfix release
### Changes
<!-- BEGIN CHANGES 0.4.5 -->
- fix: default values of config options SignupKioskMode and BarcodeWebsocketAcl did not work
- feat: proper thumbnail cache handling, removal of cachebusters
- feat: always pad thumbnails to a square shape
- refactor: move image upload to a unified function
- fix: barcodes unittest
- fix: non-http templates
- feat: load default thumbnail on fetch rather than copying default thumbnail to user/product on creation
- feat: allow multiple barcodes to be associated with a product
- chore: consistent renaming from ean to barcode
<!-- END CHANGES 0.4.5 -->
<!-- END RELEASE v0.4.5 -->
<!-- BEGIN RELEASE v0.4.4 -->
## Version 0.4.4
UI/UX Release
### Changes
<!-- BEGIN CHANGES 0.4.4 -->
- feat: make user settings available via touchkey login
- feat: add an explicit home button to the navbar
<!-- END CHANGES 0.4.4 -->
<!-- END RELEASE v0.4.4 -->
<!-- BEGIN RELEASE v0.4.3 -->
## Version 0.4.3
UI/UX Release
### Changes
<!-- BEGIN CHANGES 0.4.3 -->
- feat: improve "logout after purchase" ui representation
- refactor(db): greatly simplify database migrations
- fix: default avatar missing after signup in non-kiosk mode
<!-- END CHANGES 0.4.3 -->
<!-- END RELEASE v0.4.3 -->
<!-- BEGIN RELEASE v0.4.2 -->
## Version 0.4.2
UI/UX Release
### Changes
<!-- BEGIN CHANGES 0.4.2 -->
- fix: username is now case-insensitive
- fix: bootstrap responsive ui improvements
- feat: backup database before schema migration
<!-- END CHANGES 0.4.2 -->
<!-- END RELEASE v0.4.2 -->
<!-- BEGIN RELEASE v0.4.1 -->
## Version 0.4.1
Security Fix & minor UI fixes
### Changes
<!-- BEGIN CHANGES 0.4.1 -->
- fix: session id shared between all sessions
- fix: minor css fixes
<!-- END CHANGES 0.4.1 -->
<!-- END RELEASE v0.4.1 -->
<!-- BEGIN RELEASE v0.4.0 -->
## Version 0.4.0
Bootstrap UI Release
### Changes
<!-- BEGIN CHANGES 0.4.0 -->
- feat: redesign ui using bootstrap
- feat: split user settings and admin settings
- fix: list user tokens in admin user settings
- feat!: remove osk, osk should be provided by kiosk browser
<!-- END CHANGES 0.4.0 -->
<!-- END RELEASE v0.4.0 -->
<!-- BEGIN RELEASE v0.3.18 -->
## Version 0.3.18
Fix barcode error message
### Changes
<!-- BEGIN CHANGES 0.3.18 -->
- fix: missing error message when scanning an unassociated barcode
<!-- END CHANGES 0.3.18 -->
<!-- END RELEASE v0.3.18 -->
<!-- BEGIN RELEASE v0.3.17 -->
## Version 0.3.17
Add barcode login feature
### Changes
<!-- BEGIN CHANGES 0.3.17 -->
- feat: add barcode login feature
<!-- END CHANGES 0.3.17 -->
<!-- END RELEASE v0.3.17 -->
<!-- BEGIN RELEASE v0.3.16 -->
## Version 0.3.16
Settings UI rework
### Changes
<!-- BEGIN CHANGES 0.3.16 -->
- fix: store notifications in the session so that they won't be served to other clients
- feat: list all users and products in a table in the settings
- feat: add back buttons to signup, password login and touchkey login pages
- feat: if the tabfocus webextension is installed, use it to focus the tab when a barcode is scanned
<!-- END CHANGES 0.3.16 -->
<!-- END RELEASE v0.3.16 -->
<!-- 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.4.9'

View file

@ -61,6 +61,18 @@ def _init(config: Dict[str, Any]):
template_init(config)
@bottle.route('/static/upload/thumbnails/<resource>/<filename>')
def get_thumbnail(resource: str, filename: str):
config = get_config()
static: str = os.path.abspath(config['staticroot'])
rpath: str = os.path.join(static, 'upload/thumbnails', resource)
resp = bottle.static_file(filename, root=rpath)
if resp.status_code >= 400:
resp = bottle.static_file('default.png', root=rpath)
resp.headers['Cache-Control'] = 'public, max-age=0;'
return resp
@bottle.route('/static/<filename:path>')
def serve_static_files(filename: str):
config = get_config()

View file

@ -3,9 +3,9 @@ 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, \
from matemat.db.primitives import User, Token, Product, Barcode, ReceiptPreference, Receipt, \
Transaction, Consumption, Deposit, Modification
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.db import DatabaseWrapper
@ -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,29 @@ 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,
balance: int = 0) -> User:
"""
Create a new user.
:param username: The name of the new user.
@ -137,6 +139,8 @@ 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.
:param balance: Initial account balance of the user.
:return: A User object representing the created user.
:raises ValueError: If a user with the same name already exists.
"""
@ -145,24 +149,28 @@ 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, :balance, :admin, :member, STRFTIME('%s', 'now'),
STRFTIME('%s', 'now'), :logout_after_purchase)
''', {
'username': username,
'email': email,
'pwhash': pwhash,
'balance': balance,
'admin': admin,
'member': member
'member': member,
'logout_after_purchase': logout_after_purchase
})
# Fetch the new user's rowid.
c.execute('SELECT last_insert_rowid()')
user_id = int(c.fetchone()[0])
return User(user_id, username, 0, email, admin, member)
return User(user_id, username, balance, email, admin, member)
def login(self, username: str, password: Optional[str] = None, touchkey: Optional[str] = None) -> User:
"""
@ -179,14 +187,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 +208,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 +223,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 +253,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 +289,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 +323,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 +333,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 +342,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,13 +354,87 @@ 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(
f'delete_user should affect 1 users row, but affected {affected}')
def list_tokens(self, uid: int) -> List[Token]:
tokens: List[Token] = []
with self.db.transaction(exclusive=False) as c:
for row in c.execute('''
SELECT token_id, user_id, token, name, date
FROM tokens
WHERE user_id = :user_id
ORDER BY date
''', {'user_id': uid}):
token_id, user_id, token, name, date = row
tokens.append(
Token(token_id, user_id, token, name, datetime.fromtimestamp(date, UTC)))
return tokens
def get_token(self, tid: int) -> List[Token]:
with self.db.transaction(exclusive=False) as c:
c.execute('''
SELECT token_id, user_id, token, name, date
FROM tokens
WHERE token_id = :token_id
''', {'token_id': tid})
token_id, user_id, token, name, date = c.fetchone()
return Token(token_id, user_id, token, name, datetime.fromtimestamp(date, UTC))
def tokenlogin(self, token: str) -> User:
"""
Validte a user's token and return a User object on success.
:param token: The token to log in with.
:return: A tuple of (User, Token).
:raises ValueError: If none or both of password and touchkey are provided.
:raises AuthenticationError: If the user does not exist or the password or touchkey is wrong.
"""
if token is None:
raise ValueError('token must be provided')
with self.db.transaction(exclusive=False) as c:
c.execute('''
SELECT token_id, user_id, token, name, date
FROM tokens
WHERE token = :token
''', {'token': token})
row = c.fetchone()
if row is None:
raise AuthenticationError('Token does not exist')
token_id, user_id, token, name, date = row
user = self.get_user(user_id)
return user, Token(token_id, user_id, token, name, datetime.fromtimestamp(date, UTC))
def add_token(self, user: User, token: str, name: str):
with self.db.transaction() as c:
if name is None:
name = token[:3] + (len(token) - 3) * '*'
c.execute('''
INSERT INTO tokens (user_id, token, name)
VALUES (:user_id, :token, :name)
''', {
'user_id': user.id,
'token': token,
'name': name
})
c.execute('SELECT last_insert_rowid()')
token_id = int(c.fetchone()[0])
return Token(token_id, user.id, token, name)
def delete_token(self, token: Token):
with self.db.transaction() as c:
c.execute('''
DELETE FROM tokens
WHERE token_id = :token_id
''', {'token_id': token.id})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(
f'delete_token should affect 1 token row, but affected {affected}')
def list_products(self) -> List[Product]:
"""
Return a list of products in the database.
@ -356,24 +444,24 @@ 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
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))
products.append(
Product(product_id, name, price_member, price_external, custom_price, stockable, stock))
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
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.')
@ -381,8 +469,28 @@ class MatematDatabase(object):
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)
def get_product_by_barcode(self, barcode: str) -> Product:
"""
Return a product identified by its barcode.
:param barcode: The product's barcode code.
"""
with self.db.transaction(exclusive=False) as c:
# Fetch all values to construct the product
c.execute('''
SELECT p.product_id, p.name, price_member, price_non_member, custom_price, stock, stockable
FROM products AS p
JOIN barcodes AS b
ON b.product_id = p.product_id
WHERE b.barcode = :barcode''', {'barcode': barcode})
row = c.fetchone()
if row is None:
raise ValueError(f'No product with barcode {barcode} 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)
def create_product(self, name: str, price_member: int, price_non_member: int, custom_price:
bool, stockable: bool) -> Product:
bool, stockable: bool, barcode: str) -> Product:
"""
Creates a new product.
:param name: Name of the product.
@ -390,12 +498,13 @@ class MatematDatabase(object):
:param price_non_member: Price of the product for non-members.
:param custom_price: Whether the price is customizable. If yes, the price values are understood as minimum.
:param stockable: True if the product should be stockable, false otherwise.
:param barcode: If provided, a barcode this product is identified by.
:return: A Product object representing the created product.
:raises ValueError: If a product with the same name already exists.
"""
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('''
@ -406,10 +515,19 @@ class MatematDatabase(object):
'price_member': price_member,
'price_non_member': price_non_member,
'custom_price': custom_price,
'stockable': stockable
'stockable': stockable,
})
c.execute('SELECT last_insert_rowid()')
product_id = int(c.fetchone()[0])
if barcode:
c.execute('''
INSERT INTO barcodes (barcode, product_id, name)
VALUES (:barcode, :product_id, :name)
''', {
'barcode': barcode,
'product_id': product_id,
'name': name,
})
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, 0)
def change_product(self, product: Product, **kwargs) -> None:
@ -447,7 +565,7 @@ class MatematDatabase(object):
'price_non_member': price_non_member,
'custom_price': custom_price,
'stock': stock,
'stockable': stockable
'stockable': stockable,
})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
@ -470,13 +588,68 @@ 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(
f'delete_product should affect 1 products row, but affected {affected}')
def list_barcodes(self, pid: Optional[int] = None) -> List[Barcode]:
barcodes: List[Barcode] = []
with self.db.transaction(exclusive=False) as c:
if pid is not None:
rows = c.execute('''
SELECT barcode_id, barcode, product_id, name
FROM barcodes
WHERE product_id = :product_id
''', {'product_id': pid})
else:
rows = c.execute('''
SELECT barcode_id, barcode, product_id, name
FROM barcodes
''')
for row in rows:
barcode_id, barcode, product_id, name = row
barcodes.append(
Barcode(barcode_id, barcode, product_id, name))
return barcodes
def get_barcode(self, bcid: int) -> Barcode:
with self.db.transaction(exclusive=False) as c:
c.execute('''
SELECT barcode_id, barcode, product_id, name
FROM barcodes
WHERE barcode_id = :barcode_id
''', {'barcode_id': bcid})
barcode_id, barcode, product_id, name = c.fetchone()
return Barcode(barcode_id, barcode, product_id, name)
def add_barcode(self, product: Product, barcode: str, name: Optional[str]):
with self.db.transaction() as c:
c.execute('''
INSERT INTO barcodes (barcode, product_id, name)
VALUES (:barcode, :product_id, :name)
''', {
'barcode': barcode,
'product_id': product.id,
'name': name
})
c.execute('SELECT last_insert_rowid()')
bcid = int(c.fetchone()[0])
return Barcode(bcid, barcode, product.id, name)
def delete_barcode(self, barcode: Barcode):
with self.db.transaction() as c:
c.execute('''
DELETE FROM barcodes
WHERE barcode_id = :barcode_id
''', {'barcode_id': barcode.id})
affected = c.execute('SELECT changes()').fetchone()[0]
if affected != 1:
raise DatabaseConsistencyError(
f'delete_barcode should affect 1 token row, but affected {affected}')
def increment_consumption(self, user: User, product: Product, custom_price: int = None) -> None:
"""
Decrement the user's balance by the price of the product and create an entry in the statistics table.
@ -531,8 +704,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 +748,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 +780,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 +827,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 +842,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 +870,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,43 +893,9 @@ 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 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
@ -797,7 +935,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

@ -4,7 +4,44 @@ from typing import Dict
import sqlite3
def migrate_schema_1_to_2(c: sqlite3.Cursor):
def migrate_schema_1(c: sqlite3.Cursor):
c.execute('''
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
)
''')
c.execute('''
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) NOT NULL DEFAULT 0,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT NULL
)
''')
c.execute('''
CREATE TABLE consumption (
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
count INTEGER(8) NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, product_id),
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id)
ON DELETE CASCADE ON UPDATE CASCADE
)
''')
def migrate_schema_2(c: sqlite3.Cursor):
# Create missing tables
c.execute('''
CREATE TABLE transactions (
@ -115,7 +152,7 @@ def migrate_schema_1_to_2(c: sqlite3.Cursor):
c.execute('DROP TABLE consumption')
def migrate_schema_2_to_3(c: sqlite3.Cursor):
def migrate_schema_3(c: sqlite3.Cursor):
# Add missing columns to users table
c.execute('ALTER TABLE users ADD COLUMN receipt_pref INTEGER(1) NOT NULL DEFAULT 0')
c.execute('''ALTER TABLE users ADD COLUMN created INTEGER(8) NOT NULL DEFAULT 0''')
@ -203,7 +240,7 @@ def migrate_schema_2_to_3(c: sqlite3.Cursor):
''')
def migrate_schema_3_to_4(c: sqlite3.Cursor):
def migrate_schema_4(c: sqlite3.Cursor):
# Change receipts schema to allow null for transaction IDs
c.execute('''
CREATE TEMPORARY TABLE receipts_temp (
@ -241,7 +278,7 @@ def migrate_schema_3_to_4(c: sqlite3.Cursor):
c.execute('DROP TABLE receipts_temp')
def migrate_schema_4_to_5(c: sqlite3.Cursor):
def migrate_schema_5(c: sqlite3.Cursor):
# Change products schema to allow null for stock and add stockable column
c.execute('''
CREATE TEMPORARY TABLE products_temp (
@ -270,9 +307,94 @@ def migrate_schema_4_to_5(c: sqlite3.Cursor):
c.execute('DROP TABLE products_temp')
def migrate_schema_5_to_6(c: sqlite3.Cursor):
def migrate_schema_6(c: sqlite3.Cursor):
# Add custom_price column
c.execute('''
ALTER TABLE products ADD COLUMN
custom_price INTEGER(1) DEFAULT 0;
''')
def migrate_schema_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_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)
''')
def migrate_schema_9(c: sqlite3.Cursor):
c.execute('''
CREATE TABLE tokens (
token_id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
token TEXT UNIQUE NOT NULL,
name TEXT UNIQUE NOT NULL,
date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')),
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE CASCADE ON UPDATE CASCADE
)
''')
def migrate_schema_10(c: sqlite3.Cursor):
c.execute('''
ALTER TABLE users RENAME TO users_old
''')
c.execute('''
CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
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
)
''')
c.execute('''
INSERT INTO users SELECT * FROM users_old
''')
c.execute('''
DROP TABLE users_old
''')
def migrate_schema_11(c: sqlite3.Cursor):
c.execute('''
CREATE TABLE barcodes (
barcode_id INTEGER PRIMARY KEY,
barcode TEXT UNIQUE NOT NULL,
product_id INTEGER NOT NULL,
name TEXT DEFAULT NULL,
FOREIGN KEY (product_id) REFERENCES products(product_id)
ON DELETE CASCADE ON UPDATE CASCADE
)
''')
c.execute('''
INSERT INTO barcodes (barcode, product_id, name)
SELECT ean, product_id, name FROM products
WHERE ean IS NOT NULL
''')
c.execute('''
DROP INDEX IF EXISTS _matemat_products_ean_unique
''')
c.execute('''
ALTER TABLE products DROP COLUMN ean
''')

View file

@ -0,0 +1,34 @@
from typing import Optional
class Barcode:
"""
Representation of a product barcode associated with a product.
:param _id: The barcode ID in the database.
:param barcode: The barcode strig.
:param product_id: The ID of the product this barcode belongs to.
:param name: The display name of the token:
"""
def __init__(self,
_id: int,
barcode: str,
product_id: int,
name: str) -> None:
self.id: int = _id
self.barcode: str = barcode
self.product_id: str = product_id
self.name = name
def __eq__(self, other) -> bool:
if not isinstance(other, Barcode):
return False
return self.id == other.id and \
self.barcode == other.barcode and \
self.product_id == other.product_id and \
self.name == other.name
def __hash__(self) -> int:
return hash((self.id, self.barcode, self.product_id, self.name))

View file

@ -0,0 +1,40 @@
from typing import Optional
from datetime import datetime, UTC
class Token:
"""
Representation of an authentication token (such as a barcode) associated with a user.
:param _id: The token ID in the database.
:param user_id: The ID of the user this token belongs to.
:param token: The token secret.
:param name: The display name of the token:
:param date: The date the tokenw as created.
"""
def __init__(self,
_id: int,
user_id: int,
token: str,
name: Optional[str] = None,
date: Optional[datetime] = None) -> None:
self.id: int = _id
self.user_id: int = user_id
self.token: str = token
self.name = name
self.date = date
def __eq__(self, other) -> bool:
if not isinstance(other, Token):
return False
return self.id == other.id and \
self.user_id == other.user_id and \
self.token == other.token and \
self.name == other.name and \
self.date == other.date
def __hash__(self) -> int:
return hash((self.id, self.user_id, self.token, self.name, self.date))

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

@ -3,7 +3,9 @@ This package provides the 'primitive types' the Matemat software deals with - na
"""
from .User import User
from .Token import Token
from .Product import Product
from .Barcode import Barcode
from .ReceiptPreference import ReceiptPreference
from .Transaction import Transaction, Consumption, Deposit, Modification
from .Receipt import Receipt

View file

@ -1,415 +0,0 @@
from typing import Dict, List
SCHEMAS: Dict[int, List[str]] = dict()
SCHEMAS[1] = [
'''
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
);
''',
'''
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) NOT NULL DEFAULT 0,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT NULL
);
''',
'''
CREATE TABLE consumption (
user_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
count INTEGER(8) NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, product_id),
FOREIGN KEY (user_id) REFERENCES users(user_id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
''']
SCHEMAS[2] = [
'''
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
);
''',
'''
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) NOT NULL DEFAULT 0,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT NULL
);
''',
'''
CREATE TABLE transactions ( -- "superclass" of the following 3 tables
ta_id INTEGER PRIMARY KEY,
user_id INTEGER NOT 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 CASCADE ON UPDATE CASCADE
);
''',
'''
CREATE TABLE consumptions ( -- transactions involving buying a product
ta_id INTEGER PRIMARY KEY,
product_id INTEGER DEFAULT NULL,
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (product_id) REFERENCES products(product_id)
ON DELETE SET NULL 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_id INTEGER NOT NULL,
reason TEXT DEFAULT NULL,
PRIMARY KEY (ta_id),
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (agent_id) REFERENCES users(user_id)
ON DELETE CASCADE ON UPDATE CASCADE
);
''']
SCHEMAS[3] = [
'''
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
);
''',
'''
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) NOT NULL DEFAULT 0,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT 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 NOT NULL,
last_ta_id INTEGER NOT 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[4] = [
'''
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
);
''',
'''
CREATE TABLE products (
product_id INTEGER PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
stock INTEGER(8) NOT NULL DEFAULT 0,
price_member INTEGER(8) NOT NULL,
price_non_member INTEGER(8) NOT 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
);
''']
SCHEMAS[5] = [
'''
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
);
''',
'''
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
);
''',
'''
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[6] = [
'''
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
);
''',
'''
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

@ -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])
@ -230,12 +230,12 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(200, row[5])
self.assertEqual(1, row[6])
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)
@ -249,9 +249,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 = {}
@ -273,9 +273,11 @@ class DatabaseTest(unittest.TestCase):
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')
barcodes = db.list_barcodes(product.id)
db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250,
custom_price=True, stock=None, stockable=False)
db.delete_barcode(barcodes[0])
# Changes must be reflected in the passed object
self.assertEqual('Flora Power Mate', product.name)
self.assertEqual(150, product.price_member)
@ -283,6 +285,8 @@ class DatabaseTest(unittest.TestCase):
self.assertEqual(True, product.custom_price)
self.assertEqual(None, product.stock)
self.assertEqual(False, product.stockable)
self.assertEqual(1, len(barcodes))
self.assertEqual('4029764001807', barcodes[0].barcode)
# Changes must be reflected in the database
checkproduct = db.get_product(product.id)
self.assertEqual('Flora Power Mate', checkproduct.name)
@ -294,7 +298,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 +306,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 +346,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 +382,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 +434,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 +510,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 +537,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 +599,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 +614,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

@ -1,10 +1,10 @@
import unittest
from unittest.mock import PropertyMock, patch
import sqlite3
from matemat.db import DatabaseWrapper
from matemat.db.schemas import SCHEMAS
class TestMigrations(unittest.TestCase):
@ -15,204 +15,13 @@ class TestMigrations(unittest.TestCase):
def _initialize_db(self, schema_version: int):
self.db._sqlite_db = sqlite3.connect(':memory:')
cursor: sqlite3.Cursor = self.db._sqlite_db.cursor()
cursor.execute('BEGIN EXCLUSIVE')
for cmd in SCHEMAS[schema_version]:
cursor.execute(cmd)
cursor.execute('COMMIT')
self.db._setup()
def test_downgrade_fail(self):
# Test that downgrades are forbidden
self.db.SCHEMA_VERSION = 1
self.db._sqlite_db = sqlite3.connect(':memory:')
self.db._sqlite_db.execute('PRAGMA user_version = 2')
with self.assertRaises(RuntimeError):
with self.db:
pass
def test_upgrade_1_to_2(self):
# Setup test db with example entries covering - hopefully - all cases
self._initialize_db(1)
cursor: sqlite3.Cursor = self.db._sqlite_db.cursor()
cursor.execute('''
INSERT INTO users VALUES
(1, 'testadmin', 'a@b.c', '$2a$10$herebehashes', NULL, 1, 1, 1337, 0),
(2, 'testuser', NULL, '$2a$10$herebehashes', '$2a$10$herebehashes', 0, 1, 4242, 0),
(3, 'alien', NULL, '$2a$10$herebehashes', '$2a$10$herebehashes', 0, 0, 1234, 0)
''')
cursor.execute('''
INSERT INTO products VALUES
(1, 'Club Mate', 42, 200, 250),
(2, 'Flora Power Mate (1/4l)', 10, 100, 150)
''')
cursor.execute('''
INSERT INTO consumption VALUES
(1, 1, 5), (1, 2, 3), (2, 2, 10), (3, 1, 3), (3, 2, 4)
''')
cursor.execute('PRAGMA user_version = 1')
# Kick off the migration
schema_version = self.db.SCHEMA_VERSION
self.db.SCHEMA_VERSION = 2
self.db._setup()
self.db.SCHEMA_VERSION = schema_version
# Test whether the new tables were created
cursor.execute('PRAGMA table_info(transactions)')
self.assertNotEqual(0, len(cursor.fetchall()))
cursor.execute('PRAGMA table_info(consumptions)')
self.assertNotEqual(0, len(cursor.fetchall()))
cursor.execute('PRAGMA table_info(deposits)')
self.assertNotEqual(0, len(cursor.fetchall()))
cursor.execute('PRAGMA table_info(modifications)')
self.assertNotEqual(0, len(cursor.fetchall()))
# Test whether the old consumption table was dropped
cursor.execute('PRAGMA table_info(consumption)')
self.assertEqual(0, len(cursor.fetchall()))
# Test number of entries in the new tables
cursor.execute('SELECT COUNT(ta_id) FROM transactions')
self.assertEqual(25, cursor.fetchone()[0])
cursor.execute('SELECT COUNT(ta_id) FROM consumptions')
self.assertEqual(25, cursor.fetchone()[0])
cursor.execute('SELECT COUNT(ta_id) FROM deposits')
self.assertEqual(0, cursor.fetchone()[0])
cursor.execute('SELECT COUNT(ta_id) FROM modifications')
self.assertEqual(0, cursor.fetchone()[0])
# The (user_id=2 x product_id=1) combination should never appear
cursor.execute('''
SELECT COUNT(t.ta_id)
FROM transactions AS t
LEFT JOIN consumptions AS c
ON t.ta_id = c.ta_id
WHERE t.user_id = 2 AND c.product_id = 1''')
self.assertEqual(0, cursor.fetchone()[0])
# Test that one entry per consumption was created, and their values match the negative price
cursor.execute('''
SELECT COUNT(t.ta_id)
FROM transactions AS t
LEFT JOIN consumptions AS c
ON t.ta_id = c.ta_id
WHERE t.user_id = 1 AND c.product_id = 1 AND t.value = -200''')
self.assertEqual(5, cursor.fetchone()[0])
cursor.execute('''
SELECT COUNT(t.ta_id)
FROM transactions AS t
LEFT JOIN consumptions AS c
ON t.ta_id = c.ta_id
WHERE t.user_id = 1 AND c.product_id = 2 AND t.value = -100''')
self.assertEqual(3, cursor.fetchone()[0])
cursor.execute('''
SELECT COUNT(t.ta_id)
FROM transactions AS t
LEFT JOIN consumptions AS c
ON t.ta_id = c.ta_id
WHERE t.user_id = 2 AND c.product_id = 2 AND t.value = -100''')
self.assertEqual(10, cursor.fetchone()[0])
cursor.execute('''
SELECT COUNT(t.ta_id)
FROM transactions AS t
LEFT JOIN consumptions AS c
ON t.ta_id = c.ta_id
WHERE t.user_id = 3 AND c.product_id = 1 AND t.value = -250''')
self.assertEqual(3, cursor.fetchone()[0])
cursor.execute('''
SELECT COUNT(t.ta_id)
FROM transactions AS t
LEFT JOIN consumptions AS c
ON t.ta_id = c.ta_id
WHERE t.user_id = 3 AND c.product_id = 2 AND t.value = -150''')
self.assertEqual(4, cursor.fetchone()[0])
def test_upgrade_2_to_3(self):
# Setup test db with example entries covering - hopefully - all cases
self._initialize_db(2)
cursor: sqlite3.Cursor = self.db._sqlite_db.cursor()
cursor.execute('''
INSERT INTO users VALUES
(1, 'testadmin', 'a@b.c', '$2a$10$herebehashes', NULL, 1, 1, 1337, 0),
(2, 'testuser', NULL, '$2a$10$herebehashes', '$2a$10$herebehashes', 0, 1, 4242, 0),
(3, 'alien', NULL, '$2a$10$herebehashes', '$2a$10$herebehashes', 0, 0, 1234, 0),
(4, 'neverused', NULL, '$2a$10$herebehashes', '$2a$10$herebehashes', 0, 0, 1234, 1234)
''')
cursor.execute('''
INSERT INTO products VALUES
(1, 'Club Mate', 42, 200, 250),
(2, 'Flora Power Mate', 10, 100, 150)
''')
cursor.execute('''
INSERT INTO transactions VALUES
(1, 1, 4200, 0, 1000), -- deposit
(2, 2, 1337, 0, 1001), -- modification
(3, 3, 1337, 0, 1002), -- modification with deleted agent
(4, 2, -200, 1337, 1003), -- consumption
(5, 3, -200, 1337, 1004) -- consumption with deleted product
''')
cursor.execute('''INSERT INTO deposits VALUES (1)''')
cursor.execute('''
INSERT INTO modifications VALUES
(2, 1, 'Account migration'),
(3, 42, 'You can''t find out who i am... MUAHAHAHA!!!')''')
cursor.execute('''INSERT INTO consumptions VALUES (4, 2), (5, 42)''')
cursor.execute('''PRAGMA user_version = 2''')
# Kick off the migration
schema_version = self.db.SCHEMA_VERSION
self.db.SCHEMA_VERSION = 3
self.db._setup()
self.db.SCHEMA_VERSION = schema_version
# Make sure the receipts table was created
cursor.execute('''SELECT COUNT(receipt_id) FROM receipts''')
self.assertEqual(0, cursor.fetchone()[0])
# Make sure users.created was populated with the expected values
cursor.execute('''SELECT u.created FROM users AS u ORDER BY u.user_id ASC''')
self.assertEqual([(940,), (941,), (942,), (1174,)], cursor.fetchall())
# Make sure the modifications table was changed to contain the username, or a fallback
cursor.execute('''SELECT agent FROM modifications WHERE ta_id = 2''')
self.assertEqual('testadmin', cursor.fetchone()[0])
cursor.execute('''SELECT agent FROM modifications WHERE ta_id = 3''')
self.assertEqual('<unknown>', cursor.fetchone()[0])
# Make sure the consumptions table was changed to contain the product name, or a fallback
cursor.execute('''SELECT product FROM consumptions WHERE ta_id = 4''')
self.assertEqual('Flora Power Mate', cursor.fetchone()[0])
cursor.execute('''SELECT product FROM consumptions WHERE ta_id = 5''')
self.assertEqual('<unknown>', cursor.fetchone()[0])
def test_upgrade_3_to_4(self):
# Setup test db with example entries to test schema change
self._initialize_db(3)
cursor: sqlite3.Cursor = self.db._sqlite_db.cursor()
cursor.execute('''
INSERT INTO users VALUES
(1, 'testadmin', 'a@b.c', '$2a$10$herebehashes', NULL, 1, 1, 1337, 0, 0, 0)
''')
cursor.execute('''
INSERT INTO products VALUES
(1, 'Club Mate', 42, 200, 250)
''')
cursor.execute('''
INSERT INTO transactions VALUES (1, 1, 4200, 0, 1000)
''')
cursor.execute('''
INSERT INTO receipts VALUES (1, 1, 1, 1, 1337)
''')
cursor.execute('PRAGMA user_version = 3')
# Kick off the migration
schema_version = self.db.SCHEMA_VERSION
self.db.SCHEMA_VERSION = 4
self.db._setup()
self.db.SCHEMA_VERSION = schema_version
# Make sure entries from the receipts table are preserved
cursor.execute('''SELECT COUNT(receipt_id) FROM receipts''')
self.assertEqual(1, cursor.fetchone()[0])
# Make sure transaction IDs can be set to NULL
cursor.execute('UPDATE receipts SET first_ta_id = NULL, last_ta_id = NULL')
with patch('matemat.db.DatabaseWrapper.schema_version', new_callable=PropertyMock(return_value=1)):
with self.assertRaises(RuntimeError):
with self.db:
pass

View file

@ -17,7 +17,7 @@ class DatabaseTest(unittest.TestCase):
Test creation of database schema in an empty database
"""
with self.db as db:
self.assertEqual(DatabaseWrapper.SCHEMA_VERSION, db._user_version)
self.assertEqual(db.schema_version, db._user_version)
def test_in_transaction(self) -> None:
"""
@ -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

@ -1,9 +1,10 @@
from typing import Any, Optional
import sqlite3
from matemat.exceptions import DatabaseConsistencyError
from matemat.db.schemas import SCHEMAS
from matemat.db.migrations import *
import matemat.db.migrations
class DatabaseTransaction(object):
@ -40,8 +41,6 @@ class DatabaseTransaction(object):
class DatabaseWrapper(object):
SCHEMA_VERSION = 6
def __init__(self, filename: str) -> None:
self._filename: str = filename
self._sqlite_db: Optional[sqlite3.Connection] = None
@ -60,35 +59,36 @@ class DatabaseWrapper(object):
def _setup(self) -> None:
# Create or update schemas if necessary
with self.transaction() as c:
version: int = self._user_version
if version < 1:
# Don't use executescript, as it issues a COMMIT first
for command in SCHEMAS[self.SCHEMA_VERSION]:
c.execute(command)
elif version < self.SCHEMA_VERSION:
self._upgrade(from_version=version, to_version=self.SCHEMA_VERSION)
elif version > self.SCHEMA_VERSION:
raise RuntimeError('Database schema is newer than supported by this version of Matemat.')
self._user_version = self.SCHEMA_VERSION
version: int = self._user_version
if version < self.schema_version:
self._upgrade(from_version=version, to_version=self.schema_version)
self._user_version = self.schema_version
elif version > self.schema_version:
raise RuntimeError('Database schema is newer than supported by this version of Matemat.')
# Enable foreign key enforcement
cursor = self._sqlite_db.cursor()
cursor.execute('PRAGMA foreign_keys = 1')
cursor.execute('PRAGMA foreign_keys=ON')
def _upgrade(self, from_version: int, to_version: int) -> None:
if from_version >= to_version:
return
# Create backup before migration
if self._filename != ':memory:':
bakfile = f'{self._filename}_{from_version}_{to_version}.bak'
bak = sqlite3.connect(bakfile)
with bak:
self._sqlite_db.backup(bak, pages=1)
bak.close()
# Iterate through migrations, executing them one by one
with self.transaction() as c:
# Note to future s3lph: If there are further migrations, also consider upgrades like 1 -> 3
if from_version == 1 and to_version >= 2:
migrate_schema_1_to_2(c)
if from_version <= 2 and to_version >= 3:
migrate_schema_2_to_3(c)
if from_version <= 3 and to_version >= 4:
migrate_schema_3_to_4(c)
if from_version <= 4 and to_version >= 5:
migrate_schema_4_to_5(c)
if from_version <= 5 and to_version >= 6:
migrate_schema_5_to_6(c)
c.execute('PRAGMA foreign_keys=OFF')
c.execute('PRAGMA legacy_alter_table=ON')
for i in range(from_version+1, to_version+1):
migration = getattr(matemat.db.migrations, f'migrate_schema_{i}')
migration(c)
c.execute('PRAGMA foreign_key_check')
c.execute('PRAGMA foreign_keys=ON')
def connect(self) -> None:
if self.is_connected():
@ -125,3 +125,13 @@ class DatabaseWrapper(object):
raise RuntimeError(f'Database connection to {self._filename} is not established.')
cursor = self._sqlite_db.cursor()
cursor.execute(f'PRAGMA user_version = {version}')
@property
def schema_version(self) -> int:
max_migration = 0
for name in dir(matemat.db.migrations):
if not name.startswith('migrate_schema_'):
continue
migration = int(name.split('_')[2])
max_migration = max(max_migration, migration)
return max_migration

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

@ -0,0 +1,40 @@
import os
from io import BytesIO
from shutil import copyfile
import magic
from PIL import Image
def upload_thumbnail(image_data: bytes, filename: str, size: int = 300) -> bool:
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if image_data is None or len(image_data) == 0:
return False
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(image_data)
if not filemagic.mime_type.startswith('image/'):
raise ValueError(f'Unsupported file type: {filemagic.mime_type}')
# Create the absolute path of the upload directory
dirname: str = os.path.abspath(os.path.dirname(filename))
os.makedirs(dirname, exist_ok=True)
# Parse the image data
try:
image: Image = Image.open(BytesIO(image_data))
except IOError:
raise ValueError(f'Unsupported file type: {filemagic.mime_type}')
# Resize the image preserving the aspect ratio
if image.width > image.height:
resized = image.resize((size, size*image.height//image.width), Image.LANCZOS)
else:
resized = image.resize((size*image.width//image.height, size), Image.LANCZOS)
# Create a new square transparent image and paste the thumbnail in the center
thumb: Image = Image.new('RGBA', (size, size), (0, 0, 0, 0))
x = (thumb.width - resized.width) // 2
y = (thumb.height - resized.height) // 2
thumb.paste(resized, (x, y))
# Write the image to the file
thumb.save(filename, 'PNG')
return True

View file

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

View file

@ -1,26 +1,23 @@
import os
from datetime import datetime
from io import BytesIO
from shutil import copyfile
from datetime import datetime, UTC
import magic
from PIL import Image
from bottle import get, post, abort, redirect, request, FormsDict
from matemat.db import MatematDatabase
from matemat.db.primitives import User, ReceiptPreference
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.util.currency_format import parse_chf
from matemat.util.thumbnails import upload_thumbnail
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')
@post('/admin')
def admin():
"""
The admin panel, shows a user's own settings. Additionally, for administrators, settings to modify other users and
products are shown.
The admin panel, shows settings to modify other users and products.
"""
config = get_app_config()
session_id: str = session.start()
@ -29,128 +26,31 @@ def admin():
redirect('/login')
authlevel: int = session.get(session_id, 'authentication_level')
uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2:
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (2) or token (1)
if authlevel < 3:
abort(403)
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
user = db.get_user(uid)
# If the POST request contains a "change" parameter, delegate the change handling to the function below
if request.method == 'POST' and 'change' in request.params:
handle_change(request.params, request.files, user, db)
# If the POST request contains an "adminchange" parameter, delegate the change handling to the function below
elif request.method == 'POST' and 'adminchange' in request.params and user.is_admin:
handle_admin_change(request.params, request.files, db)
if 'adminchange' in request.params and user.is_admin:
handle_change(request.params, request.files, db)
# Fetch all existing users and products from the database
users = db.list_users()
products = db.list_products()
# Render the "Admin/Settings" page
now = str(int(datetime.utcnow().timestamp()))
barcodes = db.list_barcodes()
# Render the "Admin" page
now = str(int(datetime.now(UTC).timestamp()))
return template.render('admin.html',
authuser=user, authlevel=authlevel, users=users, products=products,
authuser=user, authlevel=authlevel, users=users, products=products, barcodes=barcodes,
receipt_preference_class=ReceiptPreference, now=now,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematDatabase) -> None:
"""
Write the changes requested by a user for its own account to the database.
:param args: The FormsDict object passed to the pagelet.
:param user: The user to edit.
:param db: The database facade where changes are written to.
"""
config = get_app_config()
try:
# Read the type of change requested by the user, then switch over it
change = str(args.change)
# The user requested a modification of its general account information (username, email)
if change == 'account':
# Username and email must be set in the request arguments
if 'username' not in args or 'email' not in args:
return
username = str(args.username)
email = str(args.email)
# An empty e-mail field should be interpreted as NULL
if len(email) == 0:
email = None
try:
receipt_pref = ReceiptPreference(int(str(args.receipt_pref)))
except ValueError:
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)
except DatabaseConsistencyError:
return
# The user requested a password change
elif change == 'password':
# The old password and 2x the new password must be present
if 'oldpass' not in args or 'newpass' not in args or 'newpass2' not in args:
return
# Read the passwords from the request arguments
oldpass = str(args.oldpass)
newpass = str(args.newpass)
newpass2 = str(args.newpass2)
# The two instances of the new password must match
if newpass != newpass2:
raise ValueError('New passwords don\'t match')
# Write the new password to the database
try:
db.change_password(user, oldpass, newpass)
except AuthenticationError:
raise ValueError('Old password doesn\'t match')
# The user requested a touchkey change
elif change == 'touchkey':
# The touchkey must be present
if 'touchkey' not in args:
return
# Read the touchkey from the request arguments
touchkey = str(args.touchkey)
# An empty touchkey field should set the touchkey to NULL (disable touchkey login)
if len(touchkey) == 0:
touchkey = None
# Write the new touchkey to the database
db.change_touchkey(user, '', touchkey, verify_password=False)
# The user requested an avatar change
elif change == 'avatar':
# The new avatar field must be present
if 'avatar' not in files:
return
# Read the raw image data from the request
avatar = files.avatar.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0:
return
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
if not filemagic.mime_type.startswith('image/'):
return
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
os.makedirs(abspath, exist_ok=True)
try:
# Parse the image data
image: Image = Image.open(BytesIO(avatar))
# Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'{user.id}.png'), 'PNG')
except OSError:
return
except UnicodeDecodeError:
raise ValueError('an argument not a string')
def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
def handle_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
"""
Write the changes requested by an admin for users of products.
@ -176,19 +76,11 @@ 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
balance = parse_chf(str(args.balance))
# Create the user in the database
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
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
# Derive the individual paths
default: str = os.path.join(abspath, 'default.png')
userimg: str = os.path.join(abspath, f'{newuser.id}.png')
# Copy the default image, if it exists
if os.path.exists(default):
copyfile(default, userimg, follow_symlinks=True)
newuser: User = db.create_user(username, password, email, member=is_member, admin=is_admin,
logout_after_purchase=logout_after_purchase, balance=balance)
# The user requested to create a new product
elif change == 'newproduct':
@ -202,38 +94,18 @@ 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)
barcode = str(args.barcode) or None
# Create the product in the database
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable, barcode)
# If a new product image was uploaded, process it
image = files.image.file.read() if 'image' in files else None
filename = os.path.join(os.path.abspath(config['UploadDir']), f'thumbnails/products/{newproduct.id}.png')
if image is not None and len(image) > 0:
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(image)
if not filemagic.mime_type.startswith('image/'):
return
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
os.makedirs(abspath, exist_ok=True)
try:
# Parse the image data
image: Image = Image.open(BytesIO(image))
# Resize the image to 150x150
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:
upload_thumbnail(image, filename)
except Exception 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
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
# Derive the individual paths
default: str = os.path.join(abspath, 'default.png')
userimg: str = os.path.join(abspath, f'{newproduct.id}.png')
# Copy the default image, if it exists
if os.path.exists(default):
copyfile(default, userimg, follow_symlinks=True)
# The user requested to restock a product
elif change == 'restock':
@ -260,25 +132,14 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
continue
# Read the raw image data from the request
default: bytes = files[category].file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(default) == 0:
continue
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(default)
if not filemagic.mime_type.startswith('image/'):
continue
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), f'thumbnails/{category}/')
os.makedirs(abspath, exist_ok=True)
filename: str = os.path.join(os.path.abspath(config['UploadDir']), f'thumbnails/{category}/default.png')
try:
# Parse the image data
image: Image = Image.open(BytesIO(default))
# Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'default.png'), 'PNG')
except OSError:
if upload_thumbnail(default, filename):
Notification.success(f'{category} default image updated successfully.', decay=True)
except Exception 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

@ -3,6 +3,8 @@ from bottle import get, post, redirect, request
from matemat.db import MatematDatabase
from matemat.webserver import session
from matemat.webserver import config as c
from matemat.webserver.template import Notification
from matemat.util.currency_format import format_chf
@get('/buy')
@ -16,6 +18,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 +37,11 @@ def buy():
stock_provider = c.get_stock_provider()
if stock_provider.needs_update():
stock_provider.update_stock(product, -1)
# Redirect to the main page (where this request should have come from)
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}')
# Show notification on next page load
Notification.success(
f'Purchased <strong>{product.name}</strong> for <strong>{format_chf(price)}</strong>', decay=True)
# Logout user if configured, logged in via touchkey and no price entry input was shown
if user.logout_after_purchase and authlevel < 3 and not product.custom_price:
redirect('/logout')
# Redirect to the main page (where this request should have come from)
redirect('/')

View file

@ -3,6 +3,8 @@ from bottle import get, post, redirect, request
from matemat.db import MatematDatabase
from matemat.webserver import session
from matemat.webserver.config import get_app_config
from matemat.webserver.template import Notification
from matemat.util.currency_format import format_chf
@get('/deposit')
@ -26,6 +28,7 @@ def deposit():
n = int(str(request.params.n))
# Write the deposit to the database
db.deposit(user, n)
# Show notification on next page load
Notification.success(f'Deposited <strong>{format_chf(n)}</strong>', decay=True)
# Redirect to the main page (where this request should have come from)
redirect(f'/?lastaction=deposit&lastprice={n}')
redirect('/')

View file

@ -21,7 +21,7 @@ def login_page():
redirect('/')
# If requested via HTTP GET, render the login page showing the login UI
if request.method == 'GET':
return template.render('login.html',
return template.render('login.html', signup=(config.get('SignupEnabled', '0') == '1'),
setupname=config['InstanceName'])
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
elif request.method == 'POST':
@ -35,8 +35,8 @@ def login_page():
redirect('/login')
# Set the user ID session variable
session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session.put(session_id, 'authentication_level', 2)
# Set the authlevel session variable (0 = none, 1 = token, 2 = touchkey, 3 = password)
session.put(session_id, 'authentication_level', 3)
# 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

@ -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,13 @@
from datetime import datetime
from datetime import datetime, UTC
from bottle import route, redirect, request
from matemat.db import MatematDatabase
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, get_stock_provider
from matemat.util.currency_format import format_chf
@route('/')
@ -14,34 +17,58 @@ 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()
buyproduct = None
if request.params.barcode:
try:
buyproduct = db.get_product_by_barcode(request.params.barcode)
except ValueError:
if not session.has(session_id, 'authenticated_user'):
try:
user, token = db.tokenlogin(str(request.params.barcode))
# Set the user ID session variable
session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = token, 2 = touchkey, 3 = password)
session.put(session_id, 'authentication_level', 1)
redirect('/')
except AuthenticationError:
# Redirect to main page on token login error
pass
Notification.error(f'Barcode {request.params.barcode} is not associated with any product.', decay=True)
redirect('/')
# 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 barcode 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
if 'logout_after_purchase' in request.params:
db.change_user(user, agent=user, logout_after_purchase=request.params.logout_after_purchase != '0')
# 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
if not db.has_admin_users():
redirect('/userbootstrap')
if buyproduct:
Notification.success(
f'Login will purchase <strong>{buyproduct.name}</strong>. ' +
'Click <a class="alert-link" href="/">here</a> to abort.')
# 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'))
signup=(config.get('SignupEnabled', '0') == '1'),
buyproduct=buyproduct)

View file

@ -1,18 +1,17 @@
import os
from io import BytesIO
from datetime import datetime
from datetime import datetime, UTC
from typing import Dict
import magic
from PIL import Image
from bottle import get, post, redirect, abort, request, FormsDict
from matemat.db import MatematDatabase
from matemat.db.primitives import Product
from matemat.exceptions import DatabaseConsistencyError
from matemat.util.currency_format import parse_chf
from matemat.util.thumbnails import upload_thumbnail
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config, get_stock_provider
from matemat.webserver.template import Notification
@get('/modproduct')
@ -28,8 +27,8 @@ def modproduct():
redirect('/login')
authlevel: int = session.get(session_id, 'authentication_level')
auth_uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2:
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via token (1) / touchkey (2)
if authlevel < 3:
abort(403)
# Connect to the database
@ -56,9 +55,10 @@ def modproduct():
redirect('/admin')
# Render the "Modify Product" page
now = str(int(datetime.utcnow().timestamp()))
barcodes = db.list_barcodes(modproduct_id)
now = str(int(datetime.now(UTC).timestamp()))
return template.render('modproduct.html',
authuser=authuser, product=product, authlevel=authlevel,
authuser=authuser, product=product, authlevel=authlevel, barcodes=barcodes,
setupname=config['InstanceName'], now=now)
@ -85,6 +85,30 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
except FileNotFoundError:
pass
elif change == 'addbarcode':
if 'barcode' not in args:
return
barcode = str(args.barcode)
name = None if 'name' not in args or len(args.name) == 0 else str(args.name)
try:
bcobj = db.add_barcode(product, barcode, name)
Notification.success(f'Barcode {name} added successfully', decay=True)
except DatabaseConsistencyError:
Notification.error(f'Barcode {barcode} already exists', decay=True)
elif change == 'delbarcode':
try:
bcid = id(str(request.params.barcode))
barcode = db.get_barcode(bcid)
except Exception as e:
Notification.error('Barcode not found', decay=True)
return
try:
db.delete_barcode(token)
except DatabaseConsistencyError:
Notification.error(f'Failed to delete barcode {barcode.name}', decay=True)
Notification.success(f'Barcode {barcode.name} removed', decay=True)
# Admin requested update of the product details
elif change == 'update':
# Only write a change if all properties of the product are present in the request arguments
@ -111,23 +135,9 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
# If a new product image was uploaded, process it
if 'image' in files:
# Read the raw image data from the request
avatar = files.image.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0:
return
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
if not filemagic.mime_type.startswith('image/'):
return
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/products/')
os.makedirs(abspath, exist_ok=True)
image = files.image.file.read()
filename: str = os.path.join(os.path.abspath(config['UploadDir']), f'thumbnails/products/{product.id}.png')
try:
# Parse the image data
image: Image = Image.open(BytesIO(avatar))
# Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'{product.id}.png'), 'PNG')
except OSError:
return
upload_thumbnail(image, filename)
except Exception as e:
Notification.error(str(e), decay=True)

View file

@ -1,18 +1,17 @@
import os
from datetime import datetime
from io import BytesIO
from datetime import datetime, UTC
from typing import Dict, Optional
import magic
from PIL import Image
from bottle import get, post, redirect, abort, request, FormsDict
from matemat.db import MatematDatabase
from matemat.db.primitives import User, ReceiptPreference
from matemat.exceptions import DatabaseConsistencyError
from matemat.util.currency_format import parse_chf
from matemat.util.thumbnails import upload_thumbnail
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
from matemat.webserver.template import Notification
@get('/moduser')
@ -28,8 +27,8 @@ def moduser():
redirect('/login')
authlevel: int = session.get(session_id, 'authentication_level')
auth_uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2:
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via token (1) / touchkey (2)
if authlevel < 3:
abort(403)
# Connect to the database
@ -56,10 +55,11 @@ def moduser():
redirect('/admin')
# Render the "Modify User" page
now = str(int(datetime.utcnow().timestamp()))
tokens = db.list_tokens(moduser_id)
now = str(int(datetime.now(UTC).timestamp()))
return template.render('moduser.html',
authuser=authuser, user=user, authlevel=authlevel, now=now,
receipt_preference_class=ReceiptPreference,
receipt_preference_class=ReceiptPreference, tokens=tokens,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
@ -88,6 +88,20 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, authuser: User,
except FileNotFoundError:
pass
elif change == 'deltoken':
try:
tokid = id(str(request.params.token))
token = db.get_token(tokid)
except Exception as e:
Notification.error('Token not found', decay=True)
return
try:
db.delete_token(token)
except DatabaseConsistencyError:
Notification.error(f'Failed to delete token {token.name}', decay=True)
Notification.success(f'Token {token.name} removed', decay=True)
return
# Admin requested update of the user's details
elif change == 'update':
# Only write a change if all properties of the user are present in the request arguments
@ -111,6 +125,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,29 +136,16 @@ 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
if 'avatar' in files:
# Read the raw image data from the request
avatar = files.avatar.file.read()
# Only process the image, if its size is more than zero. Zero size means no new image was uploaded
if len(avatar) == 0:
return
# Detect the MIME type
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
if not filemagic.mime_type.startswith('image/'):
return
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
os.makedirs(abspath, exist_ok=True)
image = files.avatar.file.read()
filename: str = os.path.join(os.path.abspath(config['UploadDir']), f'thumbnails/users/{user.id}.png')
try:
# Parse the image data
image: Image = Image.open(BytesIO(avatar))
# Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'{user.id}.png'), 'PNG')
except OSError:
return
upload_thumbnail(image, filename)
except Exception as e:
Notification.error(str(e), decay=True)

View file

@ -0,0 +1,160 @@
import os
from datetime import datetime, UTC
from bottle import get, post, abort, redirect, request, FormsDict
from matemat.db import MatematDatabase
from matemat.db.primitives import User, ReceiptPreference
from matemat.exceptions import AuthenticationError, DatabaseConsistencyError
from matemat.util.currency_format import parse_chf
from matemat.util.thumbnails import upload_thumbnail
from matemat.webserver import session, template
from matemat.webserver.config import get_app_config, get_stock_provider
from matemat.webserver.template import Notification
@get('/settings')
@post('/settings')
def settings():
"""
The settings panel, shows a user's own settings.
"""
config = get_app_config()
session_id: str = session.start()
# If no user is logged in, redirect to the login page
if not session.has(session_id, 'authentication_level') or not session.has(session_id, 'authenticated_user'):
redirect('/login')
authlevel: int = session.get(session_id, 'authentication_level')
uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via token (1)
if authlevel < 2:
abort(403)
# Connect to the database
with MatematDatabase(config['DatabaseFile']) as db:
# Fetch the authenticated user
user = db.get_user(uid)
# If the POST request contains a "change" parameter, delegate the change handling to the function below
if 'change' in request.params:
handle_change(request.params, request.files, user, db)
user = db.get_user(uid)
tokens = db.list_tokens(uid)
# Render the "Settings" page
now = str(int(datetime.now(UTC).timestamp()))
return template.render('settings.html',
authuser=user, authlevel=authlevel, tokens=tokens,
receipt_preference_class=ReceiptPreference, now=now,
setupname=config['InstanceName'], config_smtp_enabled=config['SmtpSendReceipts'])
def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematDatabase) -> None:
"""
Write the changes requested by a user for its own account to the database.
:param args: The FormsDict object passed to the pagelet.
:param user: The user to edit.
:param db: The database facade where changes are written to.
"""
config = get_app_config()
try:
# Read the type of change requested by the user, then switch over it
change = str(args.change)
# The user requested a modification of its general account information (username, email)
if change == 'account':
# Username and email must be set in the request arguments
if 'username' not in args or 'email' not in args:
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
try:
receipt_pref = ReceiptPreference(int(str(args.receipt_pref)))
except ValueError:
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)
except DatabaseConsistencyError:
return
# The user requested a password change
elif change == 'password':
# The old password and 2x the new password must be present
if 'oldpass' not in args or 'newpass' not in args or 'newpass2' not in args:
return
# Read the passwords from the request arguments
oldpass = str(args.oldpass)
newpass = str(args.newpass)
newpass2 = str(args.newpass2)
# The two instances of the new password must match
if newpass != newpass2:
raise ValueError('New passwords don\'t match')
# Write the new password to the database
try:
db.change_password(user, oldpass, newpass)
except AuthenticationError:
raise ValueError('Old password doesn\'t match')
# The user requested a touchkey change
elif change == 'touchkey':
# The touchkey must be present
if 'touchkey' not in args:
return
# Read the touchkey from the request arguments
touchkey = str(args.touchkey)
# An empty touchkey field should set the touchkey to NULL (disable touchkey login)
if len(touchkey) == 0:
touchkey = None
# Write the new touchkey to the database
db.change_touchkey(user, '', touchkey, verify_password=False)
# The user added a new token
elif change == 'addtoken':
if 'token' not in args:
return
token = str(args.token)
if len(token) < 6:
Notification.error(f'Token must at least be 6 characters long', decay=True)
return
name = None if 'name' not in args or len(args.name) == 0 else str(args.name)
try:
tokobj = db.add_token(user, token, name)
Notification.success(f'Token {tokobj.name} created successfully', decay=True)
except DatabaseConsistencyError:
Notification.error('Token already exists', decay=True)
elif change == 'deltoken':
try:
tokid = int(str(request.params.token))
token = db.get_token(tokid)
except Exception as e:
Notification.error('Token not found', decay=True)
return
if token.user_id != user.id:
Notification.error('Token not found', decay=True)
return
try:
db.delete_token(token)
except DatabaseConsistencyError:
Notification.error(f'Failed to delete token {token.name}', decay=True)
Notification.success(f'Token {token.name} removed', decay=True)
# The user requested an avatar change
elif change == 'avatar' and 'avatar' in files:
# Read the raw image data from the request
image = files.avatar.file.read()
filename: str = os.path.join(os.path.abspath(config['UploadDir']), f'thumbnails/users/{user.id}.png')
try:
if upload_thumbnail(image, filename):
Notification.success('Avatar changed')
except Exception as e:
Notification.error(str(e), decay=True)
except UnicodeDecodeError:
raise ValueError('an argument not a string')

View file

@ -1,15 +1,12 @@
import os
from shutil import copyfile
from io import BytesIO
import magic
from bottle import get, post, redirect, abort, request, FormsDict
from PIL import Image
import netaddr
from matemat.db import MatematDatabase
from matemat.db.primitives import User
from matemat.util.thumbnails import upload_thumbnail
from matemat.webserver import template, session
from matemat.webserver.config import get_app_config
@ -33,29 +30,10 @@ def signup_user(args: FormsDict, files: FormsDict, db: MatematDatabase) -> User:
touchkey = str(args.touchkey)
db.change_touchkey(new_user, password, touchkey, verify_password=False)
# Finally, set the avatar, if provided
if 'avatar' in files:
avatar = files.avatar.file.read()
if len(avatar) > 0:
filemagic: magic.FileMagic = magic.detect_from_content(avatar)
if filemagic.mime_type.startswith('image/'):
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
os.makedirs(abspath, exist_ok=True)
# Parse the image data
image: Image = Image.open(BytesIO(avatar))
# Resize the image to 150x150
image.thumbnail((150, 150), Image.LANCZOS)
# Write the image to the file
image.save(os.path.join(abspath, f'{new_user.id}.png'), 'PNG')
else:
# If a default avatar is set, copy it to the user's avatar path
# Create the absolute path of the upload directory
abspath: str = os.path.join(os.path.abspath(config['UploadDir']), 'thumbnails/users/')
# Derive the individual paths
default: str = os.path.join(abspath, 'default.png')
userimg: str = os.path.join(abspath, f'{new_user.id}.png')
# Copy the default image, if it exists
if os.path.exists(default):
copyfile(default, userimg, follow_symlinks=True)
image = files.avatar.file.read() if 'avatar' in files else None
filename: str = os.path.join(os.path.abspath(config['UploadDir']), f'thumbnails/users/{new_user.id}.png')
upload_thumbnail(image, filename)
return new_user
@ -80,21 +58,25 @@ def signup():
with MatematDatabase(config['DatabaseFile']) as db:
try:
user = signup_user(request.params, request.files, db)
except ValueError as e:
except Exception as e:
Notification.error(str(e), decay=True)
redirect('/signup')
# Set the user ID session variable
session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session.put(session_id, 'authentication_level', 2)
# Set the authlevel session variable (0 = none, 1 = token, 2 = touchkey, 3 = password)
session.put(session_id, 'authentication_level', 3)
# Redirect to the main page, showing the product list
redirect('/')
elif request.method != 'GET':
abort(405, 'Method not allowed')
acl = netaddr.IPSet([addr.strip() for addr in config.get('SignupKioskMode', '').split(',')])
acl_addrs = [s.strip() for s in config.get('SignupKioskMode', '').split(',') if s.strip()]
acl = netaddr.IPSet(acl_addrs)
if request.remote_addr in acl:
return template.render('signup_kiosk.html',
signup=(config.get('SignupEnabled', '0') == '1'),
zip=zip,
setupname=config['InstanceName'])
return template.render('signup.html',
signup=(config.get('SignupEnabled', '0') == '1'),
setupname=config['InstanceName'])

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
@ -23,7 +23,7 @@ def statistics():
authlevel: int = session.get(session_id, 'authentication_level')
auth_uid: int = session.get(session_id, 'authenticated_user')
# Show a 403 Forbidden error page if no user is logged in (0) or a user logged in via touchkey (1)
if authlevel < 2:
if authlevel < 3:
abort(403)
# Connect to the database
@ -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,29 +17,46 @@ def touchkey_page():
"""
config = get_app_config()
session_id: str = session.start()
# 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':
return template.render('touchkey.html',
username=str(request.params.username), uid=int(str(request.params.uid)),
setupname=config['InstanceName'])
# 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
with MatematDatabase(config['DatabaseFile']) as db:
try:
# Read the request arguments and attempt to log in with them
user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey))
except AuthenticationError:
# Reload the touchkey login page on failure
redirect(f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}')
# Set the user ID session variable
session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
session.put(session_id, 'authentication_level', 1)
# Redirect to the main page, showing the product list
redirect('/')
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
abort(405)
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 class="alert-link" href="/">here</a> to abort.')
except ValueError:
Notification.error(f'No product with id {buypid}', decay=True)
return template.render('touchkey.html', signup=(config.get('SignupEnabled', '0') == '1'),
username=str(request.params.username), uid=int(str(request.params.uid)),
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
with MatematDatabase(config['DatabaseFile']) as db:
try:
# Read the request arguments and attempt to log in with them
user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey))
except AuthenticationError:
# Reload the touchkey login page on failure
url = f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}'
if request.params.buypid:
url += f'&buypid={request.params.buypid}'
redirect(url)
# Set the user ID session variable
session.put(session_id, 'authenticated_user', user.id)
# Set the authlevel session variable (0 = none, 1 = token, 2 = touchkey, 3 = password)
session.put(session_id, 'authentication_level', 2)
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
abort(405)

View file

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

@ -1,2 +1,2 @@
from .sessions import start, end, put, get, has, delete
from .sessions import start, end, put, get, has, delete, setdefault

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)
@ -20,8 +20,11 @@ def start() -> str:
:return: The session ID.
"""
if 'session_id' in request.environ:
# A session has already been created while handling the same request
return request.environ['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
@ -43,6 +46,9 @@ def start() -> str:
(now + timedelta(seconds=_SESSION_TIMEOUT), __session_vars[session_id][1])
# Return the session ID and timeout
response.set_cookie(_COOKIE_NAME, session_id, secret=__key)
# Piggy-back the session id onto the request object so that we don't create another session
# in subsequent calls to start() while handling the same request.
request.environ['session_id'] = session_id
return session_id
@ -61,10 +67,10 @@ def put(session_id: str, key: str, value: Any) -> None:
__session_vars[session_id][1][key] = value
def get(session_id: str, key: str) -> Any:
def get(session_id: str, key: str, default: Any = None) -> Any:
if session_id in __session_vars and key in __session_vars[session_id][1]:
return __session_vars[session_id][1][key]
return None
return default
def delete(session_id: str, key: str) -> None:
@ -74,3 +80,13 @@ def delete(session_id: str, key: str) -> None:
def has(session_id: str, key: str) -> bool:
return session_id in __session_vars and key in __session_vars[session_id][1]
def setdefault(session_id: str, key: str, value: Any) -> Any:
if session_id in __session_vars:
if has(session_id, key):
return get(session_id, key)
else:
put(session_id, key, value)
return value
return None

View file

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

View file

@ -0,0 +1,30 @@
from matemat.webserver import session
class Notification:
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):
session_id: str = session.start()
sn = session.get(session_id, 'notifications', [])
n = list(sn)
sn.clear()
return n
@classmethod
def success(cls, msg: str, decay: bool = False):
session_id: str = session.start()
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['alert-success'], decay=decay))
@classmethod
def error(cls, msg: str, decay: bool = False):
session_id: str = session.start()
session.setdefault(session_id, 'notifications', []).append(cls(msg, classes=['alert-danger'], 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,17 @@ 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_addrs = [s.strip() for s in config.get('BarcodeWebsocketAcl', '').split(',') if s.strip()]
wsacl = netaddr.IPSet(wsacl_addrs)
if config.get('BarcodeWebsocketUrl', '') and hasattr(request, 'remote_addr') and request.remote_addr in wsacl:
bcwebsocket = config.get('BarcodeWebsocketUrl')
else:
bcwebsocket = None
return template.render(
__version__=__version__,
notifications=Notification.render(),
barcodewebsocket=bcwebsocket,
**kwargs
).encode('utf-8')

View file

@ -17,5 +17,7 @@ if [[ "$1" == "configure" ]]; then
ln -sf /var/lib/matemat/upload /usr/lib/matemat/static/upload
systemctl daemon-reload || true
deb-systemd-helper enable matemat.service
deb-systemd-invoke restart matemat.service
fi

View file

@ -4,6 +4,6 @@ set -e
if [[ "$1" == "remove" ]]; then
userdel matemat
deb-systemd-invoke stop matemat.service
fi

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.
#
#BarcodeWebsocketUrl=ws://localhost:47808/ws
#BarcodeWebsocketAcl=::1,::ffff:127.0.0.0/104,127.0.0.0/8
# Add static HTTP headers in this section
# [HttpHeaders]

7
static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,55 +1,9 @@
* {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
nav div {
display: inline-block;
.alert.decay {
animation: notificationdecay 0s 7s forwards;
}
.thumblist-item {
display: inline-block;
margin: 5px;
padding: 5px;
background: #f0f0f0;
text-decoration: none;
}
.thumblist-item a {
text-decoration: none;
}
.thumblist-item .imgcontainer {
width: 100px;
height: 100px;
position: relative;
}
.thumblist-item .imgcontainer img {
max-width: 100px;
max-height: 100px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.thumblist-title {
display: block;
font-weight: bolder;
}
.thumblist-stock {
position: absolute;
z-index: 10;
bottom: 0;
right: 0;
background: #f0f0f0;
padding: 10px;
@keyframes notificationdecay {
to { display: none; }
}
@media print {
@ -64,10 +18,6 @@ nav div {
}
}
#depositlist {
display: inline;
}
#deposit-wrapper {
display: none;
position: absolute;
@ -130,9 +80,14 @@ nav div {
font-family: monospace;
}
#touchkey-svg {
width: 400px;
height: auto;
}
.numpad {
background: #f0f0f0;
text-decoration: none;
text-decoration: none;
font-size: 50px;
font-family: sans-serif;
line-height: 100px;
@ -236,13 +191,11 @@ nav div {
font-family: sans-serif;
line-height: 20px;
font-size: 20px;
height: 20px;
}
#transfer-userlist > #scroll-up, #transfer-userlist > #scroll-down {
left: 0;
right: 0;
height: 25px;
text-align: center;
background: #f0f0f0;
padding: 20px;
@ -255,80 +208,32 @@ nav div {
background: #60f060;
}
div.osk-kbd {
display: none;
font-family: sans-serif;
position: fixed;
left: 0;
bottom: 0;
right: 0;
flex-direction: column;
z-index: 100;
background: white;
font-size: 5vh;
.itemlist a {
text-decoration: none;
}
.itemlist a:visited {
text-decoration: none;
}
div.osk-kbd.visible {
display: flex;
}
div.osk-kbd-row {
width: 100%;
height: 10vh;
flex-grow: 1;
display: flex;
flex-direction: row;
}
div.osk-button {
flex: 1 0 1px;
background: #f0f0f0;
padding: 5px;
margin: 2px;
text-align: center;
line-height: calc(10vh - 10px);
}
div.osk-button:active, div.osk-button.osk-locked {
background: #606060;
}
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%;
.itemlist img {
max-width: 100%;
max-height: 128px;
height: auto;
object-fit: cover;
}
aside#overlay > div.price {
padding-top: 30px;
font-size: 2em;
}
.card-img-overlay {
padding: 0;
left: auto;
opacity: .7;
}
.card-img-overlay span {
padding: .5em;
}
.card-header {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

7
static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,13 +1,13 @@
Number.prototype.pad = function(size) {
var s = String(this);
while (s.length < (size || 2)) {s = "0" + s;}
return s;
var s = String(this);
while (s.length < (size || 2)) {s = "0" + s;}
return s;
}
const Mode = {
Deposit: 0,
Buy: 1,
Transfer: 2,
Deposit: 0,
Buy: 1,
Transfer: 2,
}
let mode = Mode.Deposit;
@ -15,117 +15,122 @@ let product_id = null;
let target_user = null;
let target_user_li = null;
let deposit = '0';
let button = document.createElement('div');
let button_transfer = document.createElement('div');
let button = document.createElement('a');
let button_transfer = document.createElement('a');
let input = document.getElementById('deposit-wrapper');
let amount = document.getElementById('deposit-amount');
let title = document.getElementById('deposit-title');
let userlist = document.getElementById('transfer-userlist');
let userlist_list = document.getElementById('transfer-userlist-list');
let ok_button = document.getElementById('numpad-ok');
button.classList.add('thumblist-item');
button.classList.add('fakelink');
button.classList.add('btn');
button.classList.add('btn-primary');
button.classList.add('me-2');
button.innerText = 'Deposit';
button.onclick = (ev) => {
mode = Mode.Deposit;
mode = Mode.Deposit;
product_id = null;
target_user = null;
target_user = null;
deposit = '0';
title.innerText = 'Deposit';
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
input.classList.add('show');
userlist.classList.remove('show');
ok_button.classList.remove('disabled');
userlist.classList.remove('show');
ok_button.classList.remove('disabled');
};
button_transfer.classList.add('thumblist-item');
button_transfer.classList.add('fakelink');
button_transfer.classList.add('btn');
button_transfer.classList.add('btn-primary');
button_transfer.classList.add('me-2');
button_transfer.innerText = 'Transfer';
button_transfer.onclick = (ev) => {
mode = Mode.Transfer;
mode = Mode.Transfer;
product_id = null;
target_user = null;
target_user = null;
deposit = '0';
title.innerText = 'Transfer';
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
input.classList.add('show');
userlist.classList.add('show');
ok_button.classList.add('disabled');
userlist.classList.add('show');
ok_button.classList.add('disabled');
};
setup_custom_price = (pid, pname) => {
mode = Mode.Buy;
mode = Mode.Buy;
product_id = pid;
target_user = null;
target_user = null;
title.innerText = pname;
deposit = '0';
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
input.classList.add('show');
userlist.classList.remove('show');
ok_button.classList.remove('disabled');
userlist.classList.remove('show');
ok_button.classList.remove('disabled');
};
set_transfer_user = (li, uid) => {
if (target_user_li != null) {
target_user_li.classList.remove('active');
}
target_user = uid;
target_user_li = li;
ok_button.classList.remove('disabled');
target_user_li.classList.add('active');
if (target_user_li != null) {
target_user_li.classList.remove('active');
}
target_user = uid;
target_user_li = li;
ok_button.classList.remove('disabled');
target_user_li.classList.add('active');
}
scrollUserlist = (delta) => {
userlist_list.scrollBy(0, delta);
userlist_list.scrollBy(0, delta);
}
deposit_key = (k) => {
if (k == 'ok') {
switch (mode) {
case Mode.Deposit:
window.location.href = '/deposit?n=' + parseInt(deposit);
break;
case Mode.Buy:
window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit);
break;
case Mode.Transfer:
if (target_user == null) {
return;
}
window.location.href = '/transfer?target=' + target_user + '&n=' + parseInt(deposit);
break;
if (k == 'ok') {
switch (mode) {
case Mode.Deposit:
window.location.href = '/deposit?n=' + parseInt(deposit);
break;
case Mode.Buy:
window.location.href = '/buy?pid=' + product_id + '&price=' + parseInt(deposit);
break;
case Mode.Transfer:
if (target_user == null) {
return;
}
mode = Mode.Deposit;
deposit = '0';
product_id = null;
target_user = null;
if (target_user_li != null) {
target_user_li.classList.remove('active');
}
input.classList.remove('show');
userlist.classList.remove('show');
window.location.href = '/transfer?target=' + target_user + '&n=' + parseInt(deposit);
break;
}
mode = Mode.Deposit;
deposit = '0';
product_id = null;
target_user = null;
if (target_user_li != null) {
target_user_li.classList.remove('active');
}
input.classList.remove('show');
userlist.classList.remove('show');
} else if (k == 'del') {
if (deposit == '0') {
product_id = null;
target_user = null;
if (target_user_li != null) {
target_user_li.classList.remove('active');
}
userlist.classList.remove('show');
input.classList.remove('show');
}
deposit = deposit.substr(0, deposit.length - 1);
if (deposit.length == 0) {
deposit = '0';
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
if (deposit == '0') {
product_id = null;
target_user = null;
if (target_user_li != null) {
target_user_li.classList.remove('active');
}
userlist.classList.remove('show');
input.classList.remove('show');
}
deposit = deposit.substr(0, deposit.length - 1);
if (deposit.length == 0) {
deposit = '0';
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
} else {
if (deposit == '0') {
deposit = k;
} else {
if (deposit == '0') {
deposit = k;
} else {
deposit += k;
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
deposit += k;
}
amount.innerText = (Math.floor(parseInt(deposit) / 100)) + '.' + (parseInt(deposit) % 100).pad();
}
};
let list = document.getElementById('depositlist');
list.innerHTML = '';
list.appendChild(button);
list.appendChild(button_transfer);
let oldbuttons = document.getElementsByClassName("deposit-to-remove");
while (oldbuttons.length > 0) {
oldbuttons[0].remove();
}
let list = document.getElementById("depositlist");
list.insertBefore(button_transfer, list.children[0]);
list.insertBefore(button, list.children[0]);

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

@ -1,25 +1,184 @@
{% extends "base.html" %}
{% block header %}
{# If the logged in user is an administrator, call the title "Administration", otherwise "Settings" #}
{% if authuser.is_admin %}
<h1>Administration</h1>
{% else %}
<h1>Settings</h1>
{% endif %}
{{ super() }}
{% endblock %}
{% block main %}
{# Always show the settings a user can edit for itself #}
{% include "admin_all.html" %}
<h1>Administration</h1>
{# Only show the "restricted" section if the user is an admin #}
{% if authuser.is_admin %}
{% include "admin_restricted.html" %}
{% endif %}
<ul class="nav nav-tabs" id="adminTab" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-users-tab" data-bs-toggle="tab" data-bs-target="#admin-users-tab-pane" type="button" role="tab" aria-controls="admin-users-tab-pane" aria-selected="true">Users</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-products-tab" data-bs-toggle="tab" data-bs-target="#admin-products-tab-pane" type="button" role="tab" aria-controls="admin-products-tab-pane" aria-selected="false">Products</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-default-images-tab" data-bs-toggle="tab" data-bs-target="#admin-default-images-tab-pane" type="button" role="tab" aria-controls="admin-default-images-tab-pane" aria-selected="false">Default Images</button>
</li>
</ul>
<div class="tab-content" id="adminTabContent">
<section class="tab-pane fade pt-3 show active" id="admin-users-tab-pane" role="tabpanel">
<h2>Users</h2>
<form id="admin-newuser-form" method="post" action="/admin?adminchange=newuser" accept-charset="UTF-8">
<table class="table table-striped">
<tr>
<th>Username</th>
<th>E-Mail (optional)</th>
<th>Password</th>
<th>Member</th>
<th>Admin</th>
<th>Logout after purchase</th>
<th>Balance</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="admin-newuser-username" type="text" name="username" placeholder="New username"></td>
<td><input class="form-control" id="admin-newuser-email" type="text" name="email" placeholder="New e-mail"></td>
<td><input class="form-control" id="admin-newuser-password" type="password" name="password" placeholder="New password"></td>
<td><input class="form-check-input" id="admin-newuser-ismember" type="checkbox" name="ismember"></td>
<td><input class="form-check-input" id="admin-newuser-isadmin" type="checkbox" name="isadmin"></td>
<td><input class="form-check-input" id="admin-newuser-logout-after-purchase" type="checkbox" name="logout_after_purchase"></td>
<td>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="admin-newuser-balance" type="number" step="0.01" name="balance" value="0.00">
</div>
</td>
<td><input class="btn btn-success" type="submit" value="Create User"></td>
</tr>
{% for user in users %}
<tr>
<td>{{ user.name }}</td>
<td>{{ '✓' if user.email else '✗' }}</td>
<td>••••••••</td>
<td>{{ '✓' if user.is_member else '✗' }}</td>
<td>{{ '✓' if user.is_admin else '✗' }}</td>
<td>{{ '✓' if user.logout_after_purchase else '✗' }}</td>
<td>{{ user.balance | chf }}</td>
<td>
<div class="btn-group" role="group">
<a class="btn btn-primary" href="/moduser?userid={{ user.id }}">Edit</a>
<a class="btn btn-danger" href="/moduser?userid={{ user.id }}&change=del">Delete</a>
</div>
</td>
</tr>
{% endfor %}
</table>
</form>
</section>
<section class="tab-pane fade pt-3" id="admin-products-tab-pane" role="tabpanel">
<h2>Products</h2>
<form id="admin-newproduct-form" method="post" action="/admin?adminchange=newproduct" enctype="multipart/form-data" accept-charset="UTF-8">
<table class="table table-striped">
<tr>
<th>Name</th>
<th>Barcodes</th>
<th>Member price</th>
<th>Non-member price</th>
<th>Custom price</th>
<th>Stockable</th>
<th>Image</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="admin-newproduct-name" type="text" name="name" placeholder="New product name"></td>
<td><input class="form-control" id="admin-newproduct-barcode" type="text" name="barcode" placeholder="Scan barcode to insert here"></td>
<td>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0.00">
</div>
</td>
<td>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0.00">
</div>
</td>
<td><input class="form-check-input" id="admin-custom-price" type="checkbox" name="custom_price"></td>
<td><input class="form-check-input" id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked"></td>
<td><input class="form-control" id="admin-newproduct-image" name="image" type="file" accept="image/*"></td>
<td><input class="btn btn-success" type="submit" value="Create Product"></td>
</tr>
{% for product in products %}
<tr>
<td>{{ product.name }}</td>
<td>
{% set bcs = barcodes | selectattr('product_id', 'eq', product.id) | list %}
{% if bcs | length > 0 %}
{{ bcs[0].barcode }}
{% if bcs | length > 1 %}
<span class="badge bg-secondary">+{{ bcs | length - 1 }}</span>
{% endif %}
{% endif %}
</td>
<td>{{ product.price_member | chf }}</td>
<td>{{ product.price_non_member | chf }}</td>
<td>{{ '✓' if product.custom_price else '✗' }}</td>
<td>{{ '✓' if product.stockable else '✗' }}</td>
<td><img style="height: 2em;" src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}" draggable="false"></td>
<td>
<div class="btn-group" role="group">
<a class="btn btn-primary" href="/modproduct?productid={{ product.id }}">Edit</a>
<a class="btn btn-danger" href="/modproduct?productid={{ product.id }}&change=del">Delete</a>
</div>
</td>
</tr>
{% endfor %}
</table>
</form>
</section>
<section class="tab-pane fade pt-3" id="admin-default-images-tab-pane" role="tabpanel">
<h2>Default Images</h2>
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
<div class="row">
<div class="col-sm-2 g-4">
<div class="card h-100">
<img class="card-img-top" src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
<div class="card-body">
<label class="card-title" for="admin-default-images-user">Default user avatar</label>
<div class="card-text">
<input class="form-control" id="admin-default-images-user" type="file" name="users" accept="image/*" />
</div>
</div>
</div>
</div>
<div class="col-sm-2 g-4">
<div class="card h-100">
<img class="card-img-top" src="/static/upload/thumbnails/products/default.png" alt="Default product image" />
<div class="card-body">
<label class="card-title" for="admin-default-images-product">Default product image</label>
<div class="card-text">
<input class="form-control" id="admin-default-images-product" type="file" name="products" accept="image/*" />
</div>
</div>
</div>
</div>
</div>
<p>
<input class="btn btn-primary" type="submit" value="Save changes">
</p>
</form>
</section>
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
let bcinput = document.getElementById("admin-newproduct-barcode");
bcinput.value = e.data;
bcinput.select();
bcinput.scrollIntoView();
{% endblock %}

View file

@ -1,77 +0,0 @@
<section id="admin-myaccount">
<h2>My Account</h2>
<form id="admin-myaccount-form" method="post" action="/admin?change=account" accept-charset="UTF-8">
<label for="admin-myaccount-username">Username: </label>
<input id="admin-myaccount-username" type="text" name="username" value="{{ authuser.name }}" /><br/>
<label for="admin-myaccount-email">E-Mail: </label>
<input id="admin-myaccount-email" type="text" name="email" value="{% if authuser.email is not none %}{{ authuser.email }}{% endif %}" /><br/>
<label for="admin-myaccount-receipt-pref">Receipts: </label>
<select id="admin-myaccount-receipt-pref" name="receipt_pref">
{% for pref in receipt_preference_class %}
<option value="{{ pref.value }}" {% if authuser.receipt_pref == pref %} selected="selected" {% endif %}>{{ pref.human_readable }}</option>
{% endfor %}
</select>
{% if config_smtp_enabled != '1' %}Sending receipts is disabled by your administrator.{% endif %}
<br/>
<label for="admin-myaccount-ismember">Member: </label>
<input id="admin-myaccount-ismember" type="checkbox" disabled="disabled" {% if authuser.is_member %} checked="checked" {% endif %}/><br/>
<label for="admin-myaccount-isadmin">Admin: </label>
<input id="admin-myaccount-isadmin" type="checkbox" disabled="disabled" {% if authuser.is_admin %} checked="checked" {% endif %}/><br/>
<input type="submit" value="Save changes" />
</form>
</section>
<section id="admin-avatar">
<h2>Avatar</h2>
<form id="admin-avatar-form" method="post" action="/admin?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8">
<img src="/static/upload/thumbnails/users/{{ authuser.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ authuser.name }}" /><br/>
<label for="admin-avatar-avatar">Upload new file: </label>
<input id="admin-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input type="submit" value="Save changes" />
</form>
</section>
<section id="admin-password">
<h2>Password</h2>
<form id="admin-password-form" method="post" action="/admin?change=password" accept-charset="UTF-8">
<label for="admin-password-oldpass">Current password: </label>
<input id="admin-password-oldpass" type="password" name="oldpass" /><br/>
<label for="admin-password-newpass">New password: </label>
<input id="admin-password-newpass" type="password" name="newpass" /><br/>
<label for="admin-password-newpass2">Repeat password: </label>
<input id="admin-password-newpass2" type="password" name="newpass2" /><br/>
<input type="submit" value="Save changes" />
</form>
</section>
<section id="admin-touchkey">
<h2>Touchkey</h2>
<form id="admin-touchkey-form" method="post" action="/admin?change=touchkey" accept-charset="UTF-8">
Draw a new touchkey (leave empty to disable):
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="admin-touchkey-touchkey" type="hidden" name="touchkey" value="" />
<input type="submit" value="Save changes" />
</form>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'admin-touchkey-touchkey');
</script>
</section>

View file

@ -1,116 +0,0 @@
<section id="admin-restricted-newuser">
<h2>Create New User</h2>
<form id="admin-newuser-form" method="post" action="/admin?adminchange=newuser" accept-charset="UTF-8">
<label for="admin-newuser-username">Username: </label>
<input id="admin-newuser-username" type="text" name="username" /><br/>
<label for="admin-newuser-email">E-Mail (optional): </label>
<input id="admin-newuser-email" type="text" name="email" /><br/>
<label for="admin-newuser-password">Password: </label>
<input id="admin-newuser-password" type="password" name="password" /><br/>
<label for="admin-newuser-ismember">Member: </label>
<input id="admin-newuser-ismember" type="checkbox" name="ismember" /><br/>
<label for="admin-newuser-isadmin">Admin: </label>
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/>
<input type="submit" value="Create User" />
</form>
</section>
<section id="admin-restricted-moduser">
<h2>Modify User</h2>
<form id="admin-moduser-form" method="get" action="/moduser" accept-charset="UTF-8">
<label for="admin-moduser-userid">Username: </label>
<select id="admin-moduser-userid" name="userid">
{% for user in users %}
<option value="{{ user.id }}">{{ user.name }}</option>
{% endfor %}
</select><br/>
<input type="submit" value="Go" />
</form>
</section>
<section id="admin-restricted-newproduct">
<h2>Create New Product</h2>
<form id="admin-newproduct-form" method="post" action="/admin?adminchange=newproduct" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="admin-newproduct-name">Name: </label>
<input id="admin-newproduct-name" type="text" name="name" /><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/>
<label for="admin-newproduct-price-non-member">Non-member price: </label>
CHF <input id="admin-newproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="0" /><br/>
<label for="admin-custom-price"><abbr title="When 'Custom Price' is enabled, users choose the price to pay, but at least the prices given above">Custom Price</abbr>: </label>
<input id="admin-custom-price" type="checkbox" name="custom_price" /><br/>
<label for="admin-newproduct-stockable">Stockable: </label>
<input id="admin-newproduct-stockable" type="checkbox" name="stockable" checked="checked" /><br/>
<label for="admin-newproduct-image">Image: </label>
<input id="admin-newproduct-image" name="image" type="file" accept="image/*" /><br/>
<input type="submit" value="Create Product" />
</form>
</section>
<section id="admin-restricted-restock">
<h2>Restock Product</h2>
<form id="admin-restock-form" method="post" action="/admin?adminchange=restock" accept-charset="UTF-8">
<label for="admin-restock-productid">Product: </label>
<select id="admin-restock-productid" name="productid">
{% for product in products %}
{% if product.stockable %}
<option value="{{ product.id }}">{{ product.name }} ({{ product.stock }})</option>
{% endif %}
{% endfor %}
</select><br/>
<label for="admin-restock-amount">Amount: </label>
<input id="admin-restock-amount" type="number" min="0" name="amount" /><br/>
<input type="submit" value="Restock" />
</form>
</section>
<section id="admin-restricted-modproduct">
<h2>Modify Product</h2>
<form id="admin-modproduct-form" method="get" action="/modproduct" accept-charset="UTF-8">
<label for="admin-modproduct-productid">Product: </label>
<select id="admin-modproduct-productid" name="productid">
{% for product in products %}
<option value="{{ product.id }}">{{ product.name }}</option>
{% endfor %}
</select><br/>
<input type="submit" value="Go">
</form>
</section>
<section id="admin-restricted-default-images">
<h2>Set default images</h2>
<form id="admin-default-images-form" method="post" action="/admin?adminchange=defaultimg" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="admin-default-images-user">
<img src="/static/upload/thumbnails/users/default.png" alt="Default user avatar" />
</label><br/>
<input id="admin-default-images-user" type="file" name="users" accept="image/*" /><br/>
<label for="admin-default-images-product">
<img src="/static/upload/thumbnails/products/default.png" alt="Default product avatar" />
</label><br/>
<input id="admin-default-images-product" type="file" name="products" accept="image/*" /><br/>
<input type="submit" value="Save changes">
</form>
</section>

View file

@ -1,65 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
{% block head %}
{# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #}
<title>{{ setupname|safe }}</title>
<link rel="stylesheet" href="/static/css/matemat.css"/>
<link rel="stylesheet" href="/static/css/theme.css"/>
{% endblock %}
</head>
<meta http-equiv="encoding" charset="utf-8" />
{# Show the setup name, as set in the config file, as tab title. Don't escape HTML entities. #}
<title>{{ setupname|safe }}</title>
<link rel="stylesheet" href="/static/css/bootstrap.min.css" />
<link rel="stylesheet" href="/static/css/matemat.css"/>
<link rel="stylesheet" href="/static/css/theme.css"/>
<script src="/static/js/bootstrap.bundle.min.js"></script>
</head>
<body>
<body>
{% block overlay %}
{% endblock %}
<header class="navbar navbar-expand-lg navbar-dark bg-dark">
{% block header %}
<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 %}
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
{% if authuser.is_admin %}
<a href="/admin">Administration</a>
<a href="/statistics">Sales Statistics</a>
{% else %}
<a href="/admin">Settings</a>
{% endif %}
{% endif %}
{% endif %}
{% if authlevel|default(0) > 0 %}
{% if authuser is defined %}
<a href="/transactions">Transactions</a>
<nav class="container-fluid">
<a class="navbar-brand" href="/">{{ setupname|safe }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-collapse" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a href="/" class="nav-link"></i>Home</a></li>
{# Show a link to the settings, if a user logged in via password (authlevel 2). #}
{% if authuser is defined and authlevel|default(0) > 1 %}
<li class="nav-item"><a href="/settings" class="nav-link">Settings</a></li>
{% if authuser.is_admin and authlevel|default(0) > 2 %}
<li class="nav-item"><a href="/admin" class="nav-link">Administration</a></li>
<li class="nav-item"><a href="/statistics" class="nav-link">Sales Statistics</a></li>
{% endif %}
{% endif %}
{% endif %}
{# Login/Logout buttons #}
{% if authuser is defined %}
<li class="nav-item justify-content-end"><a href="/logout" class="nav-link">Logout</a></li>
{% else %}
<li class="nav-item justify-content-end"><a href="/login" class="nav-link">Login</a></li>
{% if signup|default(false) %}
<li class="nav-item justify-content-end"><a href="/signup" class="nav-link">Signup</a></li>
{% endif %}
{% endif %}
</ul>
</div>
</nav>
{% endblock %}
</header>
{% endblock %}
</header>
<main>
{% block main %}
<main class="container-fluid pb-5 pt-3">
{% block notifications %}
{% for n in notifications | default([]) %}
<div class="alert {{ n.classes | join(' ') }}" role="alert">
{{ n.msg|safe }}
</div>
{% endfor %}
{% endblock %}
{% block main %}
{# Here be content. #}
{% endblock %}
</main>
{% endblock %}
</main>
<footer>
{% block footer %}
{# Show some information in the footer, e.g. the instance name, the version, and copyright info. #}
<ul>
<li> {{ setupname|safe }}
<li> Matemat {{ __version__ }}
<li> MIT License
<li> git.kabelsalat.ch/s3lph/matemat
</ul>
{% endblock %}
</footer>
<footer class="fixed-bottom p-3 bg-light">
{% block footer %}
<div class="text-muted">
{{ setupname|safe }} | Matemat {{ __version__ }}
</div>
{% endblock %}
</footer>
<script src="/static/js/overlay.js"></script>
</body>
{% if barcodewebsocket %}
<script>
function connect() {
let socket = new WebSocket("{{ barcodewebsocket }}");
socket.onclose = () => { setTimeout(connect, 1000); };
socket.onmessage = function (e) {
// Focus this tab - requires https://git.kabelsalat.ch/ccc-basel/barcode-utils
if (typeof window.extension_tabfocus === "function") {
window.extension_tabfocus();
}
{% block barcodewebsocket %}{% endblock %}
};
}
window.addEventListener("load", () => { connect(); });
</script>
{% endif %}
</body>
</html>

View file

@ -1,23 +1,30 @@
{% extends "base.html" %}
{% block header %}
<h1>Welcome</h1>
{{ super() }}
{% endblock %}
{% block main %}
{# Show a username/password login form #}
<form method="post" action="/login" id="loginform" accept-charset="UTF-8">
<label for="login-username">Username: </label>
<input id="login-username" type="text" name="username"/><br/>
<h1>Welcome</h1>
<label for="login-password">Password: </label>
<input id="login-password" type="password" name="password"/><br/>
{# Show a username/password login form #}
<form method="post" action="/login" accept-charset="UTF-8" id="loginform" class="row gy-2 gx-3 align-items-center">
<div class="col-auto">
<label class="visually-hidden" for="login-username">Username</label>
<input class="form-control" id="login-username" type="text" name="username" placeholder="Username" />
</div>
<div class="col-auto">
<label class="visually-hidden" for="login-password">Password</label>
<input class="form-control" id="login-password" type="password" name="password" placeholder="Password" />
</div>
<div class="col-auto">
<input class="btn btn-primary" type="submit" value="Login">
<a class="btn btn-secondary" href="/">Cancel</a>
</div>
</form>
<input type="submit" value="Login">
</form>
{{ super() }}
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
document.location = "/?barcode=" + e.data;
{% endblock %}

View file

@ -1,51 +1,91 @@
{% extends "base.html" %}
{% block header %}
<h1>Administration</h1>
{{ super() }}
{% endblock %}
{% block main %}
<section id="modproduct">
<h2>Modify {{ product.name }}</h2>
<section id="modproduct">
<h1>Modify {{ product.name }}</h1>
<form id="modproduct-form" method="post" action="/modproduct?change=update" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="modproduct-name">Name: </label>
<input id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/>
<label class="form-label" for="modproduct-name">Name: </label>
<input class="form-control" id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><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/>
<label class="form-label" for="modproduct-price-member">Member price: </label>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" />
</div>
<label for="modproduct-price-non-member">Non-member price: </label>
CHF <input id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" /><br/>
<label class="form-label" for="modproduct-price-non-member">Non-member price: </label>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" />
</div>
<label for="modproduct-custom-price"><abbr title="When 'Custom Price' is enabled, users choose the price to pay, but at least the prices given above">Custom Price</abbr>: </label>
<input id="modproduct-custom-price" type="checkbox" name="custom_price" {% if product.custom_price %} checked="checked" {% endif %} /><br/>
<label for="modproduct-stockable">Stockable: </label>
<input id="modproduct-stockable" type="checkbox" name="stockable" {% if product.stockable %} checked="checked" {% endif %} /><br/>
<div class="form-check">
<input class="form-check-input" id="modproduct-custom-price" type="checkbox" name="custom_price" {% if product.custom_price %} checked="checked" {% endif %} />
<label class="form-check-label" for="modproduct-custom-price"><abbr title="When 'Custom Price' is enabled, users choose the price to pay, but at least the prices given above">Custom Price</abbr></label>
</div>
<label for="modproduct-balance">Stock: </label>
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
<div class="form-check">
<input class="form-check-input" id="modproduct-stockable" type="checkbox" name="stockable" {% if product.stockable %} checked="checked" {% endif %} />
<label class="form-check-label" for="modproduct-stockable">Stockable</label>
</div>
<label for="modproduct-image">
<img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Image of {{ product.name }}" />
</label><br/>
<input id="modproduct-image" type="file" name="image" accept="image/*" /><br/>
<label class="form-label" for="modproduct-balance">Stock: </label>
<input class="form-control" id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
<input id="modproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<label class="form-label" for="modproduct-image">
<img height="150" src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Image of {{ product.name }}" />
</label><br/>
<input class="form-control" id="modproduct-image" type="file" name="image" accept="image/*" /><br/>
<input type="submit" value="Save changes">
<input id="modproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<input class="btn btn-primary" type="submit" value="Save changes">
</form>
<h2>Barcodes</h2>
<form id="modproduct-barcode-form" method="post" action="/modproduct?change=addbarcode" accept-charset="UTF-8">
<input id="modproduct-barcode-productid" type="hidden" name="productid" value="{{ product.id }}" />
<table class="table table-striped">
<tr>
<th>Barcode</th>
<th>Name</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="modproduct-barcode-barcode" type="text" name="barcode" value="" placeholder="Scan barcode to insert here"></td>
<td><input class="form-control" id="modproduct-barcode-name" type="text" name="name" value="" placeholder="Name for this barcode"></td>
<td><input class="btn btn-success" type="submit" value="Add barcode"></td>
</tr>
{% for barcode in barcodes %}
<tr>
<td>{{ barcode.barcode }}</td>
<td>{{ barcode.name }}</td>
<td><a class="btn btn-danger" href="/modproduct?change=delbarcode&barcode={{ barcode.id }}">Delete</a></td>
</tr>
{% endfor %}
</table>
</form>
<h2>Delete Product</h2>
<form id="modproduct-delproduct-form" method="post" action="/modproduct?change=del" accept-charset="UTF-8">
<input id="modproduct-delproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<input type="submit" value="Delete product" />
<input id="modproduct-delproduct-productid" type="hidden" name="productid" value="{{ product.id }}" /><br/>
<input class="btn btn-danger" type="submit" value="Delete product {{ product.name }}" />
</form>
</section>
</section>
{{ super() }}
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
let bcinput = document.getElementById("modproduct-barcode-barcode");
bcinput.value = e.data;
bcinput.select();
bcinput.scrollIntoView();
{% endblock %}

View file

@ -1,27 +1,22 @@
{% extends "base.html" %}
{% block header %}
<h1>Administration</h1>
{{ super() }}
{% endblock %}
{% block main %}
<section id="moduser-account">
<h2>Modify {{ user.name }}</h2>
<h1>Modify {{ user.name }}</h1>
<form id="moduser-account-form" method="post" action="/moduser?change=update" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="moduser-account-username">Username: </label>
<input id="moduser-account-username" type="text" name="username" value="{{ user.name }}" /><br/>
<label class="form-label" for="moduser-account-username">Username: </label>
<input class="form-control" id="moduser-account-username" type="text" name="username" value="{{ user.name }}" /><br/>
<label for="moduser-account-email">E-Mail: </label>
<input id="moduser-account-email" type="text" name="email" value="{% if user.email is not none %}{{ user.email }}{% endif %}" /><br/>
<label class="form-label" for="moduser-account-email">E-Mail: </label>
<input class="form-control" id="moduser-account-email" type="text" name="email" value="{% if user.email is not none %}{{ user.email }}{% endif %}" /><br/>
<label for="moduser-account-password">Password: </label>
<input id="moduser-account-password" type="password" name="password" /><br/>
<label class="form-label" for="moduser-account-password">Password: </label>
<input class="form-control" id="moduser-account-password" type="password" name="password" /><br/>
<label for="moduser-account-receipt-pref">Receipts: </label>
<select id="moduser-account-receipt-pref" name="receipt_pref">
<label class="form-label" for="moduser-account-receipt-pref">Receipts: </label>
<select class="form-select" id="moduser-account-receipt-pref" name="receipt_pref">
{% for pref in receipt_preference_class %}
<option value="{{ pref.value }}" {% if user.receipt_pref == pref %} selected="selected" {% endif %}>{{ pref.human_readable }}</option>
{% endfor %}
@ -29,31 +24,64 @@
{% if config_smtp_enabled != '1' %}Sending receipts is disabled by your administrator.{% endif %}
<br/>
<label for="moduser-account-ismember">Member: </label>
<input id="moduser-account-ismember" name="ismember" type="checkbox" {% if user.is_member %} checked="checked" {% endif %}/><br/>
<div class="form-check">
<input class="form-check-input" id="moduser-account-ismember" name="ismember" type="checkbox" {% if user.is_member %} checked="checked" {% endif %}>
<label class="form-check-label" for="moduser-account-ismember">Member</label>
</div>
<label for="moduser-account-isadmin">Admin: </label>
<input id="moduser-account-isadmin" name="isadmin" type="checkbox" {% if user.is_admin %} checked="checked" {% endif %}/><br/>
<div class="form-check">
<input class="form-check-input" id="moduser-account-isadmin" name="isadmin" type="checkbox" {% if user.is_admin %} checked="checked" {% endif %}/>
<label class="form-check-label" for="moduser-account-isadmin">Admin</label>
</div>
<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/>
<div class="form-check">
<input class="form-check-input" id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if user.logout_after_purchase %} checked="checked" {% endif %}/>
<label class="form-check-label" for="admin-myaccount-logout-after-purchase">Logout after purchase</label>
</div>
<label for="moduser-account-balance-reason">Reason for balance modification: </label>
<input id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/>
<label class="form-label" for="moduser-account-balance">Balance: </label>
<div class="input-group mb-3">
<span class="input-group-text">CHF</span>
<input class="form-control" id="moduser-account-balance" name="balance" type="number" step="0.01" value="{{ user.balance|chf(False) }}" />
</div>
<label for="moduser-account-avatar">
<img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" />
<label class="form-label" for="moduser-account-balance-reason">Reason for balance modification: </label>
<input class="form-control" id="moduser-account-balance-reason" type="text" name="reason" placeholder="Shows up on receipt" /><br/>
<label class="form-label" for="moduser-account-avatar">
<img height="150" src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
</label><br/>
<input id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input class="form-control" id="moduser-account-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input id="moduser-account-userid" type="hidden" name="userid" value="{{ user.id }}" /><br/>
<input type="submit" value="Save changes">
<input class="btn btn-primary" type="submit" value="Save changes">
</form>
<h2>Tokens</h2>
<table class="table table-striped">
<tr>
<th>Token</th>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
{% for token in tokens %}
<tr>
<td>••••••••</td>
<td>{{ token.name }}</td>
<td>{{ token.date }}</td>
<td><a class="btn btn-danger" href="/moduser?change=deltoken&token={{ token.id }}">Delete</a></td>
</tr>
{% endfor %}
</table>
<h2>Delete Account</h2>
<form id="moduser-deluser-form" method="post" action="/moduser?change=del" accept-charset="UTF-8">
<input id="moduser-deluser-userid" type="hidden" name="userid" value="{{ user.id }}" /><br/>
<input type="submit" value="Delete user" />
<input class="btn btn-danger" type="submit" value="Delete user account {{ user.name }}" />
</form>
</section>

View file

@ -1,105 +1,87 @@
{% extends "base.html" %}
{% block header %}
{# Show the username. #}
<h1>Welcome, {{ authuser.name }}</h1>
{{ 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 #}
Your balance: {{ authuser.balance|chf }}
<br/>
{# Logout link #}
<div class="thumblist-item">
<a href="/logout">Logout</a>
</div>
{# Links to deposit two common amounts of cash. TODO: Will be replaced by a nicer UI later (#20) #}
<div id="depositlist">
<div class="thumblist-item">
<a href="/deposit?n=100">Deposit CHF 1</a>
</div>
<div class="thumblist-item">
<a href="/deposit?n=1000">Deposit CHF 10</a>
</div>
</div>
<div id="deposit-wrapper">
<div id="deposit-input">
<div id="deposit-output">
<span id="deposit-title"></span>
<span id="deposit-amount">0.00</span>
</div>
{% for i in [('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('del', '✗'), ('0', '0'), ('ok', '✓')] %}
<div class="numpad" id="numpad-{{ i.0 }}" onclick="deposit_key('{{ i.0 }}');">{{ i.1 }}</div>
{% endfor %}
<div id="transfer-userlist">
<div id="scroll-up" onclick="scrollUserlist(-130);"></div>
<ul id="transfer-userlist-list">
{% for user in (users if user != authuser) %}
<li onclick="set_transfer_user(this, {{ user.id }})">{{ user.name }}</li>
{% endfor %}
</ul>
<div id="scroll-down" onclick="scrollUserlist(+130);"></div>
</div>
</div>
</div>
<script src="/static/js/depositlist.js"></script>
<br/>
<h1>Welcome, {{ authuser.name }}</h1>
{% for product in products %}
{# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #}
<div class="thumblist-item">
{% if product.custom_price %}
<a onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
{% else %}
<a href="/buy?pid={{ product.id }}">
{% endif %}
<span class="thumblist-title">{{ product.name }}</span>
{% if product.custom_price %}
<span class="thumblist-detail">Custom Price</span><br/>
{% else %}
<span class="thumblist-detail">Price:
{% if authuser.is_member %}
{{ product.price_member|chf }}
{% else %}
{{ product.price_non_member|chf }}
{% endif %}
</span><br/>
{% endif %}
<div class="imgcontainer">
<img src="/static/upload/thumbnails/products/{{ product.id }}.png?cacheBuster={{ now }}" alt="Picture of {{ product.name }}" draggable="false"/>
{% set pstock = stock.get_stock(product) %}
{% if pstock is not none %}
<span class="thumblist-stock">{{ pstock }}</span>
{% endif %}
</div>
</a>
</div>
{# Show the users current balance #}
<p>
Your balance: <strong>{{ authuser.balance|chf }}</strong>
</p>
<div id="depositlist">
<a class="deposit-to-remove btn btn-primary me-2" href="/deposit?n=100">Deposit CHF 1</a>
<a class="deposit-to-remove btn btn-primary me-2" href="/deposit?n=1000">Deposit CHF 10</a>
{% if authuser.logout_after_purchase %}
<a class="btn btn-primary me-2" href="/?logout_after_purchase=0">Logout after purchase</a>
{% else %}
<a class="btn btn-outline-primary me-2" href="/?logout_after_purchase=1">Logout after purchase</a>
{% endif %}
</div>
</div>
<div id="deposit-wrapper">
<div id="deposit-input">
<div id="deposit-output">
<span id="deposit-title"></span>
<span id="deposit-amount">0.00</span>
</div>
{% for i in [('1', '1'), ('2', '2'), ('3', '3'), ('4', '4'), ('5', '5'), ('6', '6'), ('7', '7'), ('8', '8'), ('9', '9'), ('del', '✗'), ('0', '0'), ('ok', '✓')] %}
<div class="numpad" id="numpad-{{ i.0 }}" onclick="deposit_key('{{ i.0 }}');">{{ i.1 }}</div>
{% endfor %}
<br/>
<div id="transfer-userlist">
<div id="scroll-up" onclick="scrollUserlist(-130);"></div>
<ul id="transfer-userlist-list">
{% for user in (users if user != authuser) %}
<li onclick="set_transfer_user(this, {{ user.id }})">{{ user.name }}</li>
{% endfor %}
</ul>
<div id="scroll-down" onclick="scrollUserlist(+130);"></div>
</div>
</div>
</div>
<script src="/static/js/depositlist.js"></script>
<br/>
{{ super() }}
<div class="row itemlist">
{% for product in products %}
{# Show an item per product, consisting of the name, image, price and stock, triggering a purchase on click #}
<div class="col-xl-1 col-md-2 col-sm-3 col-4 g-4">
{% if product.custom_price %}
<a class="card h-100 text-bg-light" onclick="setup_custom_price({{ product.id }}, '{{ product.name}}');">
{% else %}
<a class="card h-100 text-bg-light" href="/buy?pid={{ product.id }}">
{% endif %}
<div class="card-header">
{{ product.name }}
</div>
<div class="card-body">
{% if product.custom_price %}
<span class="card-text">Custom Price</span>
{% else %}
<span class="card-text">
{% if authuser.is_member %}
{{ product.price_member|chf }}
{% else %}
{{ product.price_non_member|chf }}
{% endif %}
</span>
{% endif %}
</div>
<img class="card-img-bottom" src="/static/upload/thumbnails/products/{{ product.id }}.png" alt="Picture of {{ product.name }}" draggable="false"/>
{% set pstock = stock.get_stock(product) %}
{% if pstock is not none %}
<div class="card-img-overlay d-flex flex-column justify-content-end">
<span class="card-text text-bg-light">{{ pstock }}</span>
</div>
{% endif %}
</a>
</div>
{% endfor %}
</div>
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
document.location = "?barcode=" + e.data;
{% endblock %}

196
templates/settings.html Normal file
View file

@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block main %}
<h1>Settings</h1>
<ul class="nav nav-tabs" id="settingsTabContent" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="settings-account-tab" data-bs-toggle="tab" data-bs-target="#settings-account-tab-pane" type="button" role="tab" aria-controls="settings-account-tab-pane" aria-selected="true">My Account</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-password-tab" data-bs-toggle="tab" data-bs-target="#settings-password-tab-pane" type="button" role="tab" aria-controls="settings-password-tab-pane" aria-selected="false">Password</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-touchkey-tab" data-bs-toggle="tab" data-bs-target="#settings-touchkey-tab-pane" type="button" role="tab" aria-controls="settings-touchkey-tab-pane" aria-selected="false">Touchkey</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="settings-tokens-tab" data-bs-toggle="tab" data-bs-target="#settings-tokens-tab-pane" type="button" role="tab" aria-controls="settings-tokens-tab-pane" aria-selected="false">Tokens</button>
</li>
</ul>
<div class="tab-content" id="settingsTabContent">
<section class="tab-pane fade pt-3 show active" id="settings-account-tab-pane" role="tabpanel">
<h2>My Account</h2>
<div class="row itemlist">
<div class="col-xl-1 col-md-2 col-sm-3 col-4 mb-3">
<div class="card text-bg-light">
<div class="card-header">{{ authuser.name }}</div>
<img class="card-img-bottom" draggable="false" src="/static/upload/thumbnails/users/{{ authuser.id }}.png" alt="Avatar of {{ authuser.name }}">
</div>
</div>
</div>
<form id="settings-myaccount-form" method="post" action="/settings?change=account" accept-charset="UTF-8">
<div class="row g-2">
<div class="col-md">
<div class="form-floating">
<input class="form-control" id="settings-myaccount-username" type="text" name="username" value="{{ authuser.name }}">
<label for="settings-myaccount-username">Username</label>
</div>
</div>
<div class="col-md">
<div class="form-floating">
<input class="form-control" id="settings-myaccount-email" type="email" name="email" value="{% if authuser.email is not none %}{{ authuser.email }}{% endif %}">
<label for="settings-myaccount-email">E-Mail</label>
</div>
</div>
{% if config_smtp_enabled == '1' %}
<div class="col-md">
<div class="form-floating">
<select class="form-select" id="settings-myaccount-receipt-pref" name="receipt_pref">
{% for pref in receipt_preference_class %}
<option value="{{ pref.value }}" {% if authuser.receipt_pref == pref %} selected {% endif %}>{{ pref.human_readable }}</option>
{% endfor %}
</select>
<label for="settings-myaccount-receipt-pref">Receipts</label>
</div>
</div>
{% endif %}
</div>
<div class="row g-2">
<div class="col">
<div class="form-check">
<input class="form-check-input" id="settings-myaccount-ismember" type="checkbox" disabled="disabled" {% if authuser.is_member %} checked="checked" {% endif %}>
<label class="form-check-label" for="settings-myaccount-ismember">Member</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" id="settings-myaccount-isadmin" type="checkbox" disabled="disabled" {% if authuser.is_admin %} checked="checked" {% endif %}>
<label class="form-check-label" for="settings-myaccount-isadmin">Admin</label>
</div>
</div>
<div class="col">
<div class="form-check">
<input class="form-check-input" id="settings-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if authuser.logout_after_purchase %} checked="checked" {% endif %}>
<label class="form-check-label" for="settings-myaccount-logout-after-purchase">Logout after purchase</label>
</div>
</div>
</div>
<div class="row g-2">
<div class="col">
<input class="btn btn-primary" type="submit" value="Save changes">
</div>
</div>
</form>
<h2>Avatar</h2>
<form id="settings-avatar-form" method="post" action="/settings?change=avatar" enctype="multipart/form-data" accept-charset="UTF-8">
<label class="form-label" for="settings-avatar-avatar">Upload new file: </label>
<input class="form-control" id="settings-avatar-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input class="btn btn-primary" type="submit" value="Save changes" />
</form>
</section>
<section class="tab-pane fade pt-3" id="settings-password-tab-pane" role="tabpanel">
<h2>Password</h2>
<form id="settings-password-form" method="post" action="/settings?change=password" accept-charset="UTF-8">
<div class="row g-2 mb-2">
<div class="col-md">
<div class="form-floating">
<input class="form-control" id="settings-password-oldpass" type="password" name="oldpass">
<label for="settings-password-oldpass">Current password </label>
</div>
</div>
<div class="col-md">
<div class="form-floating">
<input class="form-control" id="settings-password-newpass" type="password" name="newpass">
<label for="settings-password-newpass">New password</label>
</div>
</div>
<div class="col-md">
<div class="form-floating">
<input class="form-control" id="settings-password-newpass2" type="password" name="newpass2">
<label for="settings-password-newpass2">Repeat password</label>
</div>
</div>
</div>
<div class="row g-2">
<div class="col">
<input class="btn btn-primary" type="submit" value="Save changes">
</div>
</div>
</form>
</section>
<section class="tab-pane fade pt-3" id="settings-touchkey-tab-pane" role="tabpanel">
<h2>Touchkey</h2>
<form id="settings-touchkey-form" method="post" action="/settings?change=touchkey" accept-charset="UTF-8">
Draw a new touchkey (leave empty to disable):
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="settings-touchkey-touchkey" type="hidden" name="touchkey" value="" />
<input class="btn btn-primary" type="submit" value="Save changes" />
</form>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'settings-touchkey-touchkey');
</script>
</section>
<section class="tab-pane fade pt-3" id="settings-tokens-tab-pane" role="tabpanel">
<h2>Tokens</h2>
<div class="alert alert-warning">
<strong>Warning: </strong>
Login tokens are a convenience feature that if used may weaken security.
Make sure you only use tokens not easily accessible to other people.
</div>
<form id="settings-newtoken-form" method="post" action="/settings?change=addtoken" accept-charset="UTF-8">
<table class="table table-striped">
<tr>
<th>Token</th>
<th>Name</th>
<th>Created</th>
<th>Actions</th>
</tr>
<tr>
<td><input class="form-control" id="settings-newtoken-token" type="password" name="token" value="" placeholder="Scan barcode to insert here"></td>
<td><input class="form-control" id="settings-newtoken-name" type="text" name="name" value="" placeholder="New token name"></td>
<td></td>
<td><input class="btn btn-success" type="submit" value="Create Token"></td>
</tr>
{% for token in tokens %}
<tr>
<td>••••••••</td>
<td>{{ token.name }}</td>
<td>{{ token.date }}</td>
<td><a class="btn btn-danger" href="/settings?change=deltoken&token={{ token.id }}">Delete</a></td>
</tr>
{% endfor %}
</table>
</form>
</section>
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
let tokeninput = document.getElementById("settings-newtoken-token");
tokeninput.value = e.data;
tokeninput.select();
tokeninput.scrollIntoView();
{% endblock %}

View file

@ -1,42 +1,45 @@
{% extends "base.html" %}
{% block header %}
<h1>Signup</h1>
{{ super() }}
{% endblock %}
{% block main %}
{# Show a username/password signup form #}
<form method="post" action="/signup" id="signupform" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="signup-username"><b>Username</b>: </label>
<input id="signup-username" type="text" name="username" required="required"/><br/>
<h1>Signup</h1>
<label for="signup-password"><b>Choose a password</b>: </label>
<input id="signup-password" type="password" name="password" required="required"/><br/>
{# Show a username/password signup form #}
<form method="post" action="/signup" id="signupform" enctype="multipart/form-data" accept-charset="UTF-8">
<label class="form-label" for="signup-username"><b>Username</b>: </label>
<input class="form-control" id="signup-username" type="text" name="username" required="required"/><br/>
<label for="signup-password2"><b>Repeat password</b>: </label>
<input id="signup-password2" type="password" name="password2" required="required"/><br/>
<label class="form-label" for="signup-password"><b>Choose a password</b>: </label>
<input class="form-control" id="signup-password" type="password" name="password" required="required"/><br/>
<label for="signup-email">E-Mail: </label>
<input id="signup-email" type="text" name="email"/><br/>
<label class="form-label" for="signup-password2"><b>Repeat password</b>: </label>
<input class="form-control" id="signup-password2" type="password" name="password2" required="required"/><br/>
<label for="signup-avatar">Upload a profile picture: </label>
<input id="signup-avatar" type="file" name="avatar" accept="image/*" /><br/>
<label class="form-label" for="signup-email">E-Mail: </label>
<input class="form-control" id="signup-email" type="text" name="email"/><br/>
<label for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="signup-touchkey" type="hidden" name="touchkey" value="" />
<label class="form-label" for="signup-avatar">Upload a profile picture: </label>
<input class="form-control" id="signup-avatar" type="file" name="avatar" accept="image/*" /><br/>
<input type="submit" value="Create account">
</form>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');
</script>
<label class="form-label" for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="signup-touchkey" type="hidden" name="touchkey" value="" />
{{ super() }}
<input class="btn btn-primary" type="submit" value="Create account">
<a class="btn btn-secondary" href="/">Cancel</a>
</form>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');
</script>
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
document.location = "/?barcode=" + e.data;
{% endblock %}

View file

@ -1,117 +1,38 @@
{% extends "base.html" %}
{% block header %}
<h1>Signup</h1>
{{ super() }}
{% endblock %}
{% block main %}
{# Show a username/password signup form #}
<form method="post" action="/signup" id="signupform" enctype="multipart/form-data" accept-charset="UTF-8">
<label for="signup-username"><b>Username</b>: </label>
<input id="signup-username" type="text" name="username" required="required" class="osk-target"/><br/>
<h1>Signup</h1>
<label for="signup-password"><b>Choose a password</b>: </label>
<input id="signup-password" type="password" name="password" required="required" class="osk-target"/><br/>
{# Show a username/password signup form #}
<form method="post" action="/signup" id="signupform" enctype="multipart/form-data" accept-charset="UTF-8">
<label class="form-label" for="signup-username">Username:</label>
<input class="form-control" id="signup-username" type="text" name="username" required="required" class="osk-target"/><br/>
<label for="signup-password2"><b>Repeat password</b>: </label>
<input id="signup-password2" type="password" name="password2" required="required" class="osk-target"/><br/>
<label class="form-label" for="signup-password">Choose a password:</label>
<input class="form-control" id="signup-password" type="password" name="password" required="required" class="osk-target"/><br/>
<label for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="signup-touchkey" type="hidden" name="touchkey" value="" />
<label class="form-label" for="signup-password2">Repeat password:</label>
<input class="form-control" id="signup-password2" type="password" name="password2" required="required" class="osk-target"/><br/>
<input type="submit" value="Create account">
</form>
<label class="form-label" for="signup-touchkey">Draw a touchkey (touchscreen login pattern)</label>
<br/>
<svg id="touchkey-svg" width="400" height="400"></svg>
<br/>
<input id="signup-touchkey" type="hidden" name="touchkey" value="" />
<div id="osk-kbd" class="osk osk-kbd">
{% set lower = [['1','2','3','4','5','6','7','8','9','0','-','⌫'],
['q','w','e','r','t','y','u','i','o','p','[','⇥'],
['a','s','d','f','g','h','j','k','l',';','\'','⇤'],
['z','x','c','v','b','n','m',',','.','/','{','⇧'],
['SPACE']] %}
{% set upper = [['!','@','#','$','%','^','&','*','(',')','_','⌫'],
['Q','W','E','R','T','Y','U','I','O','P',']','⇥'],
['A','S','D','F','G','H','J','K','L',':','"','⇤'],
['Z','X','C','V','B','N','M','<','>','?','}','⇧'],
['SPACE']] %}
{% for lrow, urow in zip(lower, upper) %}
<div class="osk osk-kbd-row">
{% for lc, uc in zip(lrow, urow) %}
<div tabindex="1000" class="osk osk-button{% if lc == 'SPACE' %} osk-button-space{% endif %}" data-lowercase="{{ lc }}" data-uppercase="{{ uc }}">{{ lc }}</div>
{% endfor %}
</div>
{% endfor %}
</div>
<input class="btn btn-primary" type="submit" value="Create account">
<a class="btn btn-secondary" href="/">Cancel</a>
</form>
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');
</script>
<script>
let lastfocus = null;
let shift = 0;
let osk = document.getElementById('osk-kbd');
let inputs = [].slice.call(document.getElementsByClassName('osk-target'));
let oskButtons = document.getElementsByClassName('osk-button');
for (let i = 0; i < inputs.length; ++i) {
inputs[i].onfocus = () => {
osk.classList.add('visible');
}
inputs[i].onblur = (blur) => {
if (blur.relatedTarget !== undefined && blur.relatedTarget !== null && blur.relatedTarget.classList.contains('osk')) {
lastfocus = blur.target;
} else {
lastfocus = null;
if (blur.relatedTarget === undefined || blur.relatedTarget === null || !blur.relatedTarget.classList.contains('osk-target')) {
osk.classList.remove('visible');
}
}
}
}
for (let i = 0; i < oskButtons.length; ++i) {
oskButtons[i].onclick = (click) => {
if (lastfocus === null || lastfocus === undefined) {
return;
}
let btn = click.target.innerText;
let idx = inputs.indexOf(lastfocus);
switch (btn) {
case '⇥':
lastfocus = inputs[(idx + 1) % inputs.length];
break;
case '⇤':
lastfocus = inputs[(((idx - 1) % inputs.length) + inputs.length) % inputs.length];
break;
case '⌫':
if (lastfocus.value.length > 0) {
lastfocus.value = lastfocus.value.substring(0, lastfocus.value.length-1);
}
break;
case 'SPACE':
lastfocus.value += ' ';
break;
case '⇧':
shift = !shift;
click.target.classList.toggle('osk-locked');
for (let j = 0; j < oskButtons.length; ++j) {
oskButtons[j].innerText = oskButtons[j].getAttribute(shift ? 'data-uppercase' : 'data-lowercase');
}
break;
default: {
lastfocus.value += btn;
break;
}
}
lastfocus.focus();
}
}
</script>
{{ super() }}
<script src="/static/js/touchkey.js" ></script>
<script>
initTouchkey(true, 'touchkey-svg', null, 'signup-touchkey');
</script>
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
document.location = "/?barcode=" + e.data;
{% endblock %}

View file

@ -1,107 +1,113 @@
{% 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 }} Sales Statistics</h1>
<style>
@media all {
svg g text {
display: none;
}
svg g:hover text {
display: block;
}
span.input-replacement {
display: none;
}
}
@media print {
svg g text {
display: block;
}
input {
display: none;
}
span.input-replacement {
display: inline;
}
}
</style>
{{ super() }}
<style>
@media all {
svg g text {
display: none;
}
svg g:hover text {
display: block;
}
span.input-replacement {
display: none;
}
}
@media print {
svg g text {
display: block;
}
input {
display: none;
}
span.input-replacement {
display: inline;
}
}
</style>
{{ super() }}
{% endblock %}
{% block main %}
<section id="statistics-range">
<h1>Sales Statistics</h1>
<h2>Time Range</h2>
<section id="statistics-range">
<form action="/statistics" method="get" accept-charset="utf-8">
<label for="statistics-range-from">From:</label>
<input type="date" id="statistics-range-from" name="fromdate" value="{{fromdate}}" />
<span class="input-replacement">{{fromdate}}</span>
<h2>Time Range</h2>
<label for="statistics-range-to">To:</label>
<input type="date" id="statistics-range-to" name="todate" value="{{todate}}" />
<span class="input-replacement">{{todate}}</span>
<form action="/statistics" method="get" accept-charset="utf-8">
<label class="form-label" for="statistics-range-from">From:</label>
<input class="form-control" type="date" id="statistics-range-from" name="fromdate" value="{{fromdate}}" />
<span class="input-replacement">{{fromdate}}</span>
<input type="submit" value="Update">
</form>
<label class="form-label" for="statistics-range-to">To:</label>
<input class="form-control" type="date" id="statistics-range-to" name="todate" value="{{todate}}" />
<span class="input-replacement">{{todate}}</span>
</section>
<input class="btn btn-primary" type="submit" value="Update">
</form>
<section id="statistics-total">
</section>
<h2>Totals</h2>
<section id="statistics-total">
<ul>
<li>Total products purchased: {{ total_consumption }}</li>
<li>Total income: {{ total_income|chf }}</li>
<li>Total deposited: {{ total_deposits|chf }}</li>
<li>Total accounts balance: {{ total_balance|chf }}</li>
</ul>
<h2>Totals</h2>
</section>
<ul>
<li>Total products purchased: {{ total_consumption }}</li>
<li>Total income: {{ total_income|chf }}</li>
<li>Total deposited: {{ total_deposits|chf }}</li>
<li>Total accounts balance: {{ total_balance|chf }}</li>
</ul>
<section>
</section>
<h2>Purchases</h2>
<section>
<table>
<tr class="head"><td>Product</td><td>Income</td><td>Units</td></tr>
{% for prod, data in consumptions.items() %}
<tr class="{{ loop.cycle('odd', '') }}"><td><b>{{ prod }}</b></td><td>{{ -data[0]|chf }}</td><td>{{ data[1] }}</td></tr>
{% endfor %}
<tr class="foot"><td>Total</td><td>{{ total_income|chf }}</td><td>{{ total_consumption }}</td></tr>
</table>
<h2>Purchases</h2>
{# Really hacky pie chart implementation. #}
<svg width="400" height="400">
{% for s in product_slices %}
<g>
<path d="M 200 200 L {{ s[1]+200 }} {{ s[2]+200 }} A 100 100 0 {{ s[5] }} 1 {{ s[3]+200 }} {{ s[4]+200 }} L 200 200"
fill="{{ loop.cycle('green', 'red', 'blue', 'yellow', 'purple', 'orange') }}"></path>
<text text-anchor="middle"
x="{{ 200 + s[6] }}"
y="{{ 200 + s[7] }}">{{ s[0] }} ({{ s[8] }})</text>
</g>
{% endfor %}
</svg>
<table class="table table-striped">
<thead>
<tr><th>Product</th><th>Income</th><th>Units</th></tr>
</thead>
<tbody>
{% for prod, data in consumptions.items() %}
<tr><td>{{ prod }}</td><td>{{ -data[0]|chf }}</td><td>{{ data[1] }}</td></tr>
{% endfor %}
</tbody>
<tfoot>
<tr><td>Total</td><td>{{ total_income|chf }}</td><td>{{ total_consumption }}</td></tr>
</tfoot>
</table>
</section>
{# Really hacky pie chart implementation. #}
<svg width="400" height="400">
{% for s in product_slices %}
<g>
<path d="M 200 200 L {{ s[1]+200 }} {{ s[2]+200 }} A 100 100 0 {{ s[5] }} 1 {{ s[3]+200 }} {{ s[4]+200 }} L 200 200"
fill="{{ loop.cycle('green', 'red', 'blue', 'yellow', 'purple', 'orange') }}"></path>
<text text-anchor="middle"
x="{{ 200 + s[6] }}"
y="{{ 200 + s[7] }}">{{ s[0] }} ({{ s[8] }})</text>
</g>
{% endfor %}
</svg>
<section id="statistics-balances">
</section>
<h2>Account Balances</h2>
<section id="statistics-balances">
<ul>
<li>Total account balances: {{ total_balance|chf }}</li>
<li>Total positive account balances: {{ positive_balance|chf }}</li>
<li>Total negative account balances: {{ negative_balance|chf }}</li>
</ul>
<h2>Account Balances</h2>
</section>
<ul>
<li>Total account balances: {{ total_balance|chf }}</li>
<li>Total positive account balances: {{ positive_balance|chf }}</li>
<li>Total negative account balances: {{ negative_balance|chf }}</li>
</ul>
{{ super() }}
</section>
{{ super() }}
{% endblock %}

View file

@ -1,36 +1,31 @@
{% extends "base.html" %}
{% block head %}
{{ super() }}
<style>
svg {
width: 400px;
height: auto;
}
</style>
{% endblock %}
{% block header %}
<h1>Welcome, {{ username }}</h1>
{{ super() }}
{% endblock %}
{% block main %}
<svg id="touchkey-svg" width="400" height="400"></svg>
<h1>Welcome, {{ username }}</h1>
<form method="post" action="/touchkey" id="loginform" accept-charset="UTF-8">
<svg id="touchkey-svg" width="400" height="400"></svg>
<form method="post" action="/touchkey" id="loginform" accept-charset="UTF-8">
<input type="hidden" name="uid" value="{{ uid }}" />
<input type="hidden" name="username" value="{{ username }}" />
<input type="hidden" name="touchkey" value="" id="loginform-touchkey-value" />
</form>
<a href="/">Cancel</a>
{% if buypid %}
<input type="hidden" name="buypid" value="{{ buypid }}" />
{% endif %}
</form>
<script src="/static/js/touchkey.js"></script>
<script>
<a class="btn btn-secondary" href="/">Cancel</a>
<script src="/static/js/touchkey.js"></script>
<script>
initTouchkey(false, 'touchkey-svg', 'loginform', 'loginform-touchkey-value');
</script>
</script>
{{ super() }}
{{ super() }}
{% endblock %}
{% endblock %}
{% block barcodewebsocket %}
document.location = "/?barcode=" + e.data;
{% endblock %}

View file

@ -1,39 +0,0 @@
{% 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

@ -1,26 +1,22 @@
{% extends "base.html" %}
{% block header %}
{# Show the setup name, as set in the config file, as page title followed by "Setup". Don't escape HTML entities. #}
<h1>{{ setupname|safe }} Setup</h1>
{{ super() }}
{% endblock %}
{% block main %}
<h1>Welcome</h1>
{# Show a user creation form #}
Please create an admin user account
<form method="post" action="/userbootstrap" accept-charset="UTF-8">
<label for="username">Username: </label>
<input id="username" type="text" name="username"/><br/>
<label class="form-label" for="username">Username: </label>
<input class="form-control" id="username" type="text" name="username"/><br/>
<label for="password">Password: </label>
<input id="password" type="password" name="password"/><br/>
<label class="form-label" for="password">Password: </label>
<input class="form-control" id="password" type="password" name="password"/><br/>
<label for="password2">Repeat: </label>
<input id="password2" type="password" name="password2"/><br/>
<label class="form-label" for="password2">Repeat: </label>
<input class="form-control" id="password2" type="password" name="password2"/><br/>
<input type="submit" value="Create user">
<input class="btn btn-success" type="submit" value="Create user">
</form>
{{ super() }}

View file

@ -1,32 +1,27 @@
{% 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() }}
{% endblock %}
{% block main %}
<div class="row itemlist">
{% 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 }}">
<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"/>
</div>
</a>
</div>
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
<div class="col-xl-1 col-md-2 col-sm-3 col-4 g-4 g-4">
<a class="card h-100 text-bg-light" href="/touchkey?uid={{ user.id }}&username={{ user.name }}{% if buyproduct %}&buypid={{ buyproduct.id }}{% endif %}">
<div class="card-header">
{{ user.name }}
</div>
<img class="card-img-bottom" src="/static/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" draggable="false"/>
</a>
</div>
{% endfor %}
</div>
<br/>
{# Link to the password login #}
<a href="/login">Password login</a>
{% if signup %}
<a href="/signup">Create account</a>
{% endif %}
<br/>
{{ super() }}
{{ super() }}
{% endblock %}
{% block barcodewebsocket %}
document.location = "/?barcode=" + e.data;
{% endblock %}