forked from s3lph/matemat
Compare commits
57 commits
transactio
...
main
Author | SHA1 | Date | |
---|---|---|---|
64f682fa11 | |||
b9d556ce71 | |||
f58a95a094 | |||
f517621245 | |||
924b9cee77 | |||
2fdc73c35b | |||
d163cfda05 | |||
5a0c0cf148 | |||
54086dea39 | |||
59c9bb2e71 | |||
e23ba65c71 | |||
418d7ffea9 | |||
583107ac63 | |||
a7150e123e | |||
f6f7b5abdb | |||
1669ef4c1e | |||
ddf5ed01a2 | |||
1823759433 | |||
2be7bf7683 | |||
e5870f4d60 | |||
f152a5070b | |||
e9b05fa4f4 | |||
|
470da688f3 | ||
4f69a1b447 | |||
8879add39b | |||
2883593eeb | |||
|
d3c5a8a56b | ||
43ac5d656f | |||
51adba2e25 | |||
|
6e9f60eb36 | ||
|
9a1c220813 | ||
|
05e2bed4d2 | ||
dd65b5c4d0 | |||
f8de9f5e1e | |||
|
a5907dce2d | ||
ef53367035 | |||
|
4943508233 | ||
|
8fb3bd8d9c | ||
745843e07d | |||
67e2a813d5 | |||
b3b47b6b60 | |||
4cf563ce62 | |||
418cff7348 | |||
66f23f5dda | |||
f614fe1afc | |||
4eb71415fd | |||
8287dc1947 | |||
f3af4d64a7 | |||
bfc503c5d3 | |||
d41484e69a | |||
c8243fd9d5 | |||
677c1e681b | |||
d54aa2bc57 | |||
1e561fd9cd | |||
97df130768 | |||
1b45a21210 | |||
abd70b9cc6 |
68 changed files with 2393 additions and 2183 deletions
52
.forgejo/workflows/package.yml
Normal file
52
.forgejo/workflows/package.yml
Normal 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"
|
27
.forgejo/workflows/test.yml
Normal file
27
.forgejo/workflows/test.yml
Normal 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
1
.gitignore
vendored
|
@ -9,5 +9,6 @@
|
|||
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.bak
|
||||
static/upload/
|
||||
./matemat.conf
|
||||
|
|
|
@ -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}
|
288
CHANGELOG.md
288
CHANGELOG.md
|
@ -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
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
__version__ = '0.3.7'
|
||||
__version__ = '0.4.9'
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
''')
|
||||
|
|
34
matemat/db/primitives/Barcode.py
Normal file
34
matemat/db/primitives/Barcode.py
Normal 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))
|
40
matemat/db/primitives/Token.py
Normal file
40
matemat/db/primitives/Token.py
Normal 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))
|
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
);
|
||||
''']
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
40
matemat/util/thumbnails.py
Normal file
40
matemat/util/thumbnails.py
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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('/')
|
||||
|
|
|
@ -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('/')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)}')
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
160
matemat/webserver/pagelets/settings.py
Normal file
160
matemat/webserver/pagelets/settings.py
Normal 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')
|
|
@ -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'])
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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'))
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
from .sessions import start, end, put, get, has, delete
|
||||
from .sessions import start, end, put, get, has, delete, setdefault
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
|
||||
from .notification import Notification
|
||||
from .template import init, render
|
||||
|
|
30
matemat/webserver/template/notification.py
Normal file
30
matemat/webserver/template/notification.py
Normal 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))
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -4,6 +4,6 @@ set -e
|
|||
|
||||
if [[ "$1" == "remove" ]]; then
|
||||
|
||||
userdel matemat
|
||||
deb-systemd-invoke stop matemat.service
|
||||
|
||||
fi
|
||||
|
|
|
@ -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
7
static/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/css/bootstrap.min.css.map
Normal file
1
static/css/bootstrap.min.css.map
Normal file
File diff suppressed because one or more lines are too long
|
@ -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
7
static/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
static/js/bootstrap.bundle.min.js.map
Normal file
1
static/js/bootstrap.bundle.min.js.map
Normal file
File diff suppressed because one or more lines are too long
|
@ -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]);
|
||||
|
|
|
@ -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);
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
||||
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
196
templates/settings.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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 %}
|
|
@ -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() }}
|
||||
|
|
|
@ -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 %}
|
||||
|
|
Loading…
Reference in a new issue