forked from s3lph/matemat
Compare commits
12 commits
transactio
...
main
Author | SHA1 | Date | |
---|---|---|---|
4eb71415fd | |||
8287dc1947 | |||
f3af4d64a7 | |||
bfc503c5d3 | |||
d41484e69a | |||
c8243fd9d5 | |||
677c1e681b | |||
d54aa2bc57 | |||
1e561fd9cd | |||
97df130768 | |||
1b45a21210 | |||
abd70b9cc6 |
39 changed files with 718 additions and 340 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,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}
|
|
96
CHANGELOG.md
96
CHANGELOG.md
|
@ -1,5 +1,101 @@
|
||||||
# Matemat Changelog
|
# Matemat Changelog
|
||||||
|
|
||||||
|
<!-- 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 -->
|
<!-- BEGIN RELEASE v0.3.7 -->
|
||||||
## Version 0.3.7
|
## Version 0.3.7
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
# Matemat
|
# 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.
|
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
|
It provides a touch-input-friendly user interface (as most input happens through the
|
||||||
soda machine's touch screen).
|
soda machine's touch screen).
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
|
|
||||||
__version__ = '0.3.7'
|
__version__ = '0.3.14'
|
||||||
|
|
|
@ -3,7 +3,7 @@ from typing import Any, Dict, List, Optional, Tuple, Type
|
||||||
|
|
||||||
import crypt
|
import crypt
|
||||||
from hmac import compare_digest
|
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, Product, ReceiptPreference, Receipt, \
|
||||||
Transaction, Consumption, Deposit, Modification
|
Transaction, Consumption, Deposit, Modification
|
||||||
|
@ -84,7 +84,7 @@ class MatematDatabase(object):
|
||||||
users: List[User] = []
|
users: List[User] = []
|
||||||
with self.db.transaction(exclusive=False) as c:
|
with self.db.transaction(exclusive=False) as c:
|
||||||
for row in c.execute('''
|
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
|
FROM users
|
||||||
WHERE touchkey IS NOT NULL OR NOT :must_have_touchkey
|
WHERE touchkey IS NOT NULL OR NOT :must_have_touchkey
|
||||||
ORDER BY username COLLATE NOCASE ASC
|
ORDER BY username COLLATE NOCASE ASC
|
||||||
|
@ -92,12 +92,13 @@ class MatematDatabase(object):
|
||||||
'must_have_touchkey': with_touchkey
|
'must_have_touchkey': with_touchkey
|
||||||
}):
|
}):
|
||||||
# Decompose each row and put the values into a User object
|
# 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:
|
try:
|
||||||
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
|
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise DatabaseConsistencyError(f'{receipt_p} is not a valid ReceiptPreference')
|
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
|
return users
|
||||||
|
|
||||||
def get_user(self, uid: int) -> User:
|
def get_user(self, uid: int) -> User:
|
||||||
|
@ -108,28 +109,28 @@ class MatematDatabase(object):
|
||||||
with self.db.transaction(exclusive=False) as c:
|
with self.db.transaction(exclusive=False) as c:
|
||||||
# Fetch all values to construct the user
|
# Fetch all values to construct the user
|
||||||
c.execute('''
|
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
|
FROM users
|
||||||
WHERE user_id = ?
|
WHERE user_id = :user_id
|
||||||
''',
|
''', {'user_id': uid})
|
||||||
[uid])
|
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError(f'No user with user ID {uid} exists.')
|
raise ValueError(f'No user with user ID {uid} exists.')
|
||||||
# Unpack the row and construct the user
|
# 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:
|
try:
|
||||||
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
|
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise DatabaseConsistencyError(f'{receipt_p} is not a valid ReceiptPreference')
|
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,
|
def create_user(self,
|
||||||
username: str,
|
username: str,
|
||||||
password: str,
|
password: str,
|
||||||
email: Optional[str] = None,
|
email: Optional[str] = None,
|
||||||
admin: bool = False,
|
admin: bool = False,
|
||||||
member: bool = True) -> User:
|
member: bool = True,
|
||||||
|
logout_after_purchase: bool = False) -> User:
|
||||||
"""
|
"""
|
||||||
Create a new user.
|
Create a new user.
|
||||||
:param username: The name of the new user.
|
:param username: The name of the new user.
|
||||||
|
@ -137,6 +138,7 @@ class MatematDatabase(object):
|
||||||
:param email: The user's email address, defaults to None.
|
:param email: The user's email address, defaults to None.
|
||||||
:param admin: Whether the user is an administrator, defaults to False.
|
:param admin: Whether the user is an administrator, defaults to False.
|
||||||
:param member: Whether the user is a member, defaults to True.
|
:param member: Whether the user is a member, defaults to True.
|
||||||
|
:param logout_after_purchase: Whether the user should be logged out after completing a purchase.
|
||||||
:return: A User object representing the created user.
|
:return: A User object representing the created user.
|
||||||
:raises ValueError: If a user with the same name already exists.
|
:raises ValueError: If a user with the same name already exists.
|
||||||
"""
|
"""
|
||||||
|
@ -145,19 +147,22 @@ class MatematDatabase(object):
|
||||||
user_id: int = -1
|
user_id: int = -1
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
# Look up whether a user with the same name already exists.
|
# 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:
|
if c.fetchone() is not None:
|
||||||
raise ValueError(f'A user with the name \'{username}\' already exists.')
|
raise ValueError(f'A user with the name \'{username}\' already exists.')
|
||||||
# Insert the user into the database.
|
# Insert the user into the database.
|
||||||
c.execute('''
|
c.execute('''
|
||||||
INSERT INTO users (username, email, password, balance, is_admin, is_member, lastchange, created)
|
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'))
|
logout_after_purchase)
|
||||||
|
VALUES (:username, :email, :pwhash, 0, :admin, :member, STRFTIME('%s', 'now'), STRFTIME('%s', 'now'),
|
||||||
|
:logout_after_purchase)
|
||||||
''', {
|
''', {
|
||||||
'username': username,
|
'username': username,
|
||||||
'email': email,
|
'email': email,
|
||||||
'pwhash': pwhash,
|
'pwhash': pwhash,
|
||||||
'admin': admin,
|
'admin': admin,
|
||||||
'member': member
|
'member': member,
|
||||||
|
'logout_after_purchase': logout_after_purchase
|
||||||
})
|
})
|
||||||
# Fetch the new user's rowid.
|
# Fetch the new user's rowid.
|
||||||
c.execute('SELECT last_insert_rowid()')
|
c.execute('SELECT last_insert_rowid()')
|
||||||
|
@ -179,14 +184,15 @@ class MatematDatabase(object):
|
||||||
raise ValueError('Exactly one of password and touchkey must be provided')
|
raise ValueError('Exactly one of password and touchkey must be provided')
|
||||||
with self.db.transaction(exclusive=False) as c:
|
with self.db.transaction(exclusive=False) as c:
|
||||||
c.execute('''
|
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
|
FROM users
|
||||||
WHERE username = ?
|
WHERE username = :username
|
||||||
''', [username])
|
''', {'username': username})
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise AuthenticationError('User does not exist')
|
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):
|
if password is not None and not compare_digest(crypt.crypt(password, pwhash), pwhash):
|
||||||
raise AuthenticationError('Password mismatch')
|
raise AuthenticationError('Password mismatch')
|
||||||
elif touchkey is not None \
|
elif touchkey is not None \
|
||||||
|
@ -199,7 +205,7 @@ class MatematDatabase(object):
|
||||||
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
|
receipt_pref: ReceiptPreference = ReceiptPreference(receipt_p)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise DatabaseConsistencyError(f'{receipt_p} is not a valid ReceiptPreference')
|
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:
|
def change_password(self, user: User, oldpass: str, newpass: str, verify_password: bool = True) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -214,8 +220,8 @@ class MatematDatabase(object):
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
# Fetch the old password.
|
# Fetch the old password.
|
||||||
c.execute('''
|
c.execute('''
|
||||||
SELECT password FROM users WHERE user_id = ?
|
SELECT password FROM users WHERE user_id = :user_id
|
||||||
''', [user.id])
|
''', {'user_id': user.id})
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise AuthenticationError('User does not exist in database.')
|
raise AuthenticationError('User does not exist in database.')
|
||||||
|
@ -244,8 +250,8 @@ class MatematDatabase(object):
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
# Fetch the password.
|
# Fetch the password.
|
||||||
c.execute('''
|
c.execute('''
|
||||||
SELECT password FROM users WHERE user_id = ?
|
SELECT password FROM users WHERE user_id = :user_id
|
||||||
''', [user.id])
|
''', {'user_id': user.id})
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise AuthenticationError('User does not exist in database.')
|
raise AuthenticationError('User does not exist in database.')
|
||||||
|
@ -280,6 +286,8 @@ class MatematDatabase(object):
|
||||||
balance: int = kwargs['balance'] if 'balance' in kwargs else user.balance
|
balance: int = kwargs['balance'] if 'balance' in kwargs else user.balance
|
||||||
balance_reason: Optional[str] = kwargs['balance_reason'] if 'balance_reason' in kwargs else None
|
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
|
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:
|
with self.db.transaction() as c:
|
||||||
c.execute('SELECT balance FROM users WHERE user_id = :user_id', {'user_id': user.id})
|
c.execute('SELECT balance FROM users WHERE user_id = :user_id', {'user_id': user.id})
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
|
@ -312,7 +320,8 @@ class MatematDatabase(object):
|
||||||
is_admin = :is_admin,
|
is_admin = :is_admin,
|
||||||
is_member = :is_member,
|
is_member = :is_member,
|
||||||
receipt_pref = :receipt_pref,
|
receipt_pref = :receipt_pref,
|
||||||
lastchange = STRFTIME('%s', 'now')
|
lastchange = STRFTIME('%s', 'now'),
|
||||||
|
logout_after_purchase = :logout_after_purchase
|
||||||
WHERE user_id = :user_id
|
WHERE user_id = :user_id
|
||||||
''', {
|
''', {
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
|
@ -321,7 +330,8 @@ class MatematDatabase(object):
|
||||||
'balance': balance,
|
'balance': balance,
|
||||||
'is_admin': is_admin,
|
'is_admin': is_admin,
|
||||||
'is_member': is_member,
|
'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
|
# Only update the actual user object after the changes in the database succeeded
|
||||||
user.name = name
|
user.name = name
|
||||||
|
@ -329,6 +339,7 @@ class MatematDatabase(object):
|
||||||
user.balance = balance
|
user.balance = balance
|
||||||
user.is_admin = is_admin
|
user.is_admin = is_admin
|
||||||
user.is_member = is_member
|
user.is_member = is_member
|
||||||
|
user.logout_after_purchase = logout_after_purchase
|
||||||
user.receipt_pref = receipt_pref
|
user.receipt_pref = receipt_pref
|
||||||
|
|
||||||
def delete_user(self, user: User) -> None:
|
def delete_user(self, user: User) -> None:
|
||||||
|
@ -340,8 +351,8 @@ class MatematDatabase(object):
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
c.execute('''
|
c.execute('''
|
||||||
DELETE FROM users
|
DELETE FROM users
|
||||||
WHERE user_id = ?
|
WHERE user_id = :user_id
|
||||||
''', [user.id])
|
''', {'user_id': user.id})
|
||||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||||
if affected != 1:
|
if affected != 1:
|
||||||
raise DatabaseConsistencyError(
|
raise DatabaseConsistencyError(
|
||||||
|
@ -355,34 +366,52 @@ class MatematDatabase(object):
|
||||||
products: List[Product] = []
|
products: List[Product] = []
|
||||||
with self.db.transaction(exclusive=False) as c:
|
with self.db.transaction(exclusive=False) as c:
|
||||||
for row in c.execute('''
|
for row in c.execute('''
|
||||||
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable
|
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean
|
||||||
FROM products
|
FROM products ORDER BY name
|
||||||
'''):
|
'''):
|
||||||
product_id, name, price_member, price_external, custom_price, stock, stockable = row
|
product_id, name, price_member, price_external, custom_price, stock, stockable, ean = row
|
||||||
products.append(Product(product_id, name, price_member, price_external, custom_price, stockable, stock))
|
products.append(
|
||||||
|
Product(product_id, name, price_member, price_external, custom_price, stockable, stock, ean))
|
||||||
return products
|
return products
|
||||||
|
|
||||||
def get_product(self, pid: int) -> Product:
|
def get_product(self, pid: int) -> Product:
|
||||||
"""
|
"""
|
||||||
Return a product identified by its product ID.
|
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:
|
with self.db.transaction(exclusive=False) as c:
|
||||||
# Fetch all values to construct the product
|
# Fetch all values to construct the product
|
||||||
c.execute('''
|
c.execute('''
|
||||||
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable
|
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean
|
||||||
FROM products
|
FROM products
|
||||||
WHERE product_id = ?''',
|
WHERE product_id = :product_id''', {'product_id': pid})
|
||||||
[pid])
|
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError(f'No product with product ID {pid} exists.')
|
raise ValueError(f'No product with product ID {pid} exists.')
|
||||||
# Unpack the row and construct the product
|
# Unpack the row and construct the product
|
||||||
product_id, name, price_member, price_non_member, custom_price, stock, stockable = row
|
product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row
|
||||||
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock)
|
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean)
|
||||||
|
|
||||||
|
def get_product_by_ean(self, ean: str) -> Product:
|
||||||
|
"""
|
||||||
|
Return a product identified by its EAN code.
|
||||||
|
:param ean: The product's EAN code.
|
||||||
|
"""
|
||||||
|
with self.db.transaction(exclusive=False) as c:
|
||||||
|
# Fetch all values to construct the product
|
||||||
|
c.execute('''
|
||||||
|
SELECT product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean
|
||||||
|
FROM products
|
||||||
|
WHERE ean = :ean''', {'ean': ean})
|
||||||
|
row = c.fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f'No product with EAN code {ean} exists.')
|
||||||
|
# Unpack the row and construct the product
|
||||||
|
product_id, name, price_member, price_non_member, custom_price, stock, stockable, ean = row
|
||||||
|
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, stock, ean)
|
||||||
|
|
||||||
def create_product(self, name: str, price_member: int, price_non_member: int, custom_price:
|
def create_product(self, name: str, price_member: int, price_non_member: int, custom_price:
|
||||||
bool, stockable: bool) -> Product:
|
bool, stockable: bool, ean: str) -> Product:
|
||||||
"""
|
"""
|
||||||
Creates a new product.
|
Creates a new product.
|
||||||
:param name: Name of the product.
|
:param name: Name of the product.
|
||||||
|
@ -395,22 +424,23 @@ class MatematDatabase(object):
|
||||||
"""
|
"""
|
||||||
product_id: int = -1
|
product_id: int = -1
|
||||||
with self.db.transaction() as c:
|
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:
|
if c.fetchone() is not None:
|
||||||
raise ValueError(f'A product with the name \'{name}\' already exists.')
|
raise ValueError(f'A product with the name \'{name}\' already exists.')
|
||||||
c.execute('''
|
c.execute('''
|
||||||
INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable)
|
INSERT INTO products (name, price_member, price_non_member, custom_price, stock, stockable, ean)
|
||||||
VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :stockable)
|
VALUES (:name, :price_member, :price_non_member, :custom_price, 0, :stockable, :ean)
|
||||||
''', {
|
''', {
|
||||||
'name': name,
|
'name': name,
|
||||||
'price_member': price_member,
|
'price_member': price_member,
|
||||||
'price_non_member': price_non_member,
|
'price_non_member': price_non_member,
|
||||||
'custom_price': custom_price,
|
'custom_price': custom_price,
|
||||||
'stockable': stockable
|
'stockable': stockable,
|
||||||
|
'ean': ean,
|
||||||
})
|
})
|
||||||
c.execute('SELECT last_insert_rowid()')
|
c.execute('SELECT last_insert_rowid()')
|
||||||
product_id = int(c.fetchone()[0])
|
product_id = int(c.fetchone()[0])
|
||||||
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, 0)
|
return Product(product_id, name, price_member, price_non_member, custom_price, stockable, 0, ean)
|
||||||
|
|
||||||
def change_product(self, product: Product, **kwargs) -> None:
|
def change_product(self, product: Product, **kwargs) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -429,6 +459,7 @@ class MatematDatabase(object):
|
||||||
custom_price: int = kwargs['custom_price'] if 'custom_price' in kwargs else product.custom_price
|
custom_price: int = kwargs['custom_price'] if 'custom_price' in kwargs else product.custom_price
|
||||||
stock: int = kwargs['stock'] if 'stock' in kwargs else product.stock
|
stock: int = kwargs['stock'] if 'stock' in kwargs else product.stock
|
||||||
stockable: bool = kwargs['stockable'] if 'stockable' in kwargs else product.stockable
|
stockable: bool = kwargs['stockable'] if 'stockable' in kwargs else product.stockable
|
||||||
|
ean: str = kwargs['ean'] if 'ean' in kwargs else product.ean
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
c.execute('''
|
c.execute('''
|
||||||
UPDATE products
|
UPDATE products
|
||||||
|
@ -438,7 +469,8 @@ class MatematDatabase(object):
|
||||||
price_non_member = :price_non_member,
|
price_non_member = :price_non_member,
|
||||||
custom_price = :custom_price,
|
custom_price = :custom_price,
|
||||||
stock = :stock,
|
stock = :stock,
|
||||||
stockable = :stockable
|
stockable = :stockable,
|
||||||
|
ean = :ean
|
||||||
WHERE product_id = :product_id
|
WHERE product_id = :product_id
|
||||||
''', {
|
''', {
|
||||||
'product_id': product.id,
|
'product_id': product.id,
|
||||||
|
@ -447,7 +479,8 @@ class MatematDatabase(object):
|
||||||
'price_non_member': price_non_member,
|
'price_non_member': price_non_member,
|
||||||
'custom_price': custom_price,
|
'custom_price': custom_price,
|
||||||
'stock': stock,
|
'stock': stock,
|
||||||
'stockable': stockable
|
'stockable': stockable,
|
||||||
|
'ean': ean
|
||||||
})
|
})
|
||||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||||
if affected != 1:
|
if affected != 1:
|
||||||
|
@ -460,6 +493,7 @@ class MatematDatabase(object):
|
||||||
product.custom_price = custom_price
|
product.custom_price = custom_price
|
||||||
product.stock = stock
|
product.stock = stock
|
||||||
product.stockable = stockable
|
product.stockable = stockable
|
||||||
|
product.ean = ean
|
||||||
|
|
||||||
def delete_product(self, product: Product) -> None:
|
def delete_product(self, product: Product) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -470,8 +504,8 @@ class MatematDatabase(object):
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
c.execute('''
|
c.execute('''
|
||||||
DELETE FROM products
|
DELETE FROM products
|
||||||
WHERE product_id = ?
|
WHERE product_id = :product_id
|
||||||
''', [product.id])
|
''', {'product_id': product.id})
|
||||||
affected = c.execute('SELECT changes()').fetchone()[0]
|
affected = c.execute('SELECT changes()').fetchone()[0]
|
||||||
if affected != 1:
|
if affected != 1:
|
||||||
raise DatabaseConsistencyError(
|
raise DatabaseConsistencyError(
|
||||||
|
@ -531,8 +565,7 @@ class MatematDatabase(object):
|
||||||
if amount < 0:
|
if amount < 0:
|
||||||
raise ValueError('Cannot deposit a negative value')
|
raise ValueError('Cannot deposit a negative value')
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
|
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': user.id})
|
||||||
[user.id])
|
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise DatabaseConsistencyError(f'No such user: {user.id}')
|
raise DatabaseConsistencyError(f'No such user: {user.id}')
|
||||||
|
@ -576,8 +609,7 @@ class MatematDatabase(object):
|
||||||
raise ValueError('Cannot transfer a negative value')
|
raise ValueError('Cannot transfer a negative value')
|
||||||
with self.db.transaction() as c:
|
with self.db.transaction() as c:
|
||||||
# First, remove amount from the source user's account
|
# First, remove amount from the source user's account
|
||||||
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
|
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': source.id})
|
||||||
[source.id])
|
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise DatabaseConsistencyError(f'No such user: {source.id}')
|
raise DatabaseConsistencyError(f'No such user: {source.id}')
|
||||||
|
@ -609,8 +641,7 @@ class MatematDatabase(object):
|
||||||
if affected != 1:
|
if affected != 1:
|
||||||
raise DatabaseConsistencyError(f'transfer should affect 1 users row, but affected {affected}')
|
raise DatabaseConsistencyError(f'transfer should affect 1 users row, but affected {affected}')
|
||||||
# Then, add the amount to the destination user's account
|
# Then, add the amount to the destination user's account
|
||||||
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''',
|
c.execute('''SELECT balance FROM users WHERE user_id = :user_id''', {'user_id': dest.id})
|
||||||
[dest.id])
|
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise DatabaseConsistencyError(f'No such user: {dest.id}')
|
raise DatabaseConsistencyError(f'No such user: {dest.id}')
|
||||||
|
@ -657,11 +688,11 @@ class MatematDatabase(object):
|
||||||
LEFT JOIN receipts AS r
|
LEFT JOIN receipts AS r
|
||||||
ON r.user_id = u.user_id
|
ON r.user_id = u.user_id
|
||||||
WHERE u.user_id = :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)
|
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:
|
def create_receipt(self, user: User, write: bool = False) -> Receipt:
|
||||||
transactions: List[Transaction] = []
|
transactions: List[Transaction] = []
|
||||||
|
@ -672,12 +703,12 @@ class MatematDatabase(object):
|
||||||
LEFT JOIN receipts AS r
|
LEFT JOIN receipts AS r
|
||||||
ON r.user_id = u.user_id
|
ON r.user_id = u.user_id
|
||||||
WHERE u.user_id = :user_id
|
WHERE u.user_id = :user_id
|
||||||
''', [user.id])
|
''', {'user_id': user.id})
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise DatabaseConsistencyError(f'No such user: {user.id}')
|
raise DatabaseConsistencyError(f'No such user: {user.id}')
|
||||||
fromdate, min_id = row
|
fromdate, min_id = row
|
||||||
created: datetime = datetime.fromtimestamp(fromdate)
|
created: datetime = datetime.fromtimestamp(fromdate, UTC)
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
SELECT
|
SELECT
|
||||||
t.ta_id, t.value, t.old_balance, COALESCE(t.date, 0),
|
t.ta_id, t.value, t.old_balance, COALESCE(t.date, 0),
|
||||||
|
@ -700,13 +731,15 @@ class MatematDatabase(object):
|
||||||
for row in rows:
|
for row in rows:
|
||||||
ta_id, value, old_balance, date, c, d, m, c_prod, m_agent, m_reason = row
|
ta_id, value, old_balance, date, c, d, m, c_prod, m_agent, m_reason = row
|
||||||
if c == ta_id:
|
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:
|
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:
|
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:
|
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)
|
transactions.append(t)
|
||||||
if write:
|
if write:
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
|
@ -721,7 +754,7 @@ class MatematDatabase(object):
|
||||||
receipt_id: int = int(cursor.fetchone()[0])
|
receipt_id: int = int(cursor.fetchone()[0])
|
||||||
else:
|
else:
|
||||||
receipt_id = -1
|
receipt_id = -1
|
||||||
receipt = Receipt(receipt_id, transactions, user, created, datetime.utcnow())
|
receipt = Receipt(receipt_id, transactions, user, created, datetime.now(UTC))
|
||||||
return receipt
|
return receipt
|
||||||
|
|
||||||
def generate_sales_statistics(self, from_date: datetime, to_date: datetime) -> Dict[str, Any]:
|
def generate_sales_statistics(self, from_date: datetime, to_date: datetime) -> Dict[str, Any]:
|
||||||
|
@ -763,7 +796,7 @@ class MatematDatabase(object):
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
), u.balance)
|
), u.balance)
|
||||||
FROM users AS u
|
FROM users AS u
|
||||||
''', [to_date.timestamp()])
|
''', {'to_date': to_date.timestamp()})
|
||||||
for balance, in c.fetchall():
|
for balance, in c.fetchall():
|
||||||
if balance > 0:
|
if balance > 0:
|
||||||
positive_balance += balance
|
positive_balance += balance
|
||||||
|
|
|
@ -276,3 +276,22 @@ def migrate_schema_5_to_6(c: sqlite3.Cursor):
|
||||||
ALTER TABLE products ADD COLUMN
|
ALTER TABLE products ADD COLUMN
|
||||||
custom_price INTEGER(1) DEFAULT 0;
|
custom_price INTEGER(1) DEFAULT 0;
|
||||||
''')
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_schema_6_to_7(c: sqlite3.Cursor):
|
||||||
|
# Add custom_price column
|
||||||
|
c.execute('''
|
||||||
|
ALTER TABLE users ADD COLUMN
|
||||||
|
logout_after_purchase INTEGER(1) DEFAULT 0;
|
||||||
|
''')
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_schema_7_to_8(c: sqlite3.Cursor):
|
||||||
|
# Add ean column
|
||||||
|
c.execute('''
|
||||||
|
ALTER TABLE products ADD COLUMN ean TEXT DEFAULT NULL
|
||||||
|
''')
|
||||||
|
# Make ean column unique
|
||||||
|
c.execute('''
|
||||||
|
CREATE UNIQUE INDEX _matemat_products_ean_unique ON products(ean)
|
||||||
|
''')
|
||||||
|
|
|
@ -11,11 +11,12 @@ class Product:
|
||||||
:param custom_price: If true, the user can choose the price to pay, but at least the regular price.
|
:param custom_price: If true, the user can choose the price to pay, but at least the regular price.
|
||||||
:param stock: The number of items of this product currently in stock, or None if not stockable.
|
:param stock: The number of items of this product currently in stock, or None if not stockable.
|
||||||
:param stockable: Whether this product is stockable.
|
:param stockable: Whether this product is stockable.
|
||||||
|
:param ean: The product's EAN code. May be None.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, _id: int, name: str,
|
def __init__(self, _id: int, name: str,
|
||||||
price_member: int, price_non_member: int, custom_price: bool,
|
price_member: int, price_non_member: int, custom_price: bool,
|
||||||
stockable: bool, stock: int) -> None:
|
stockable: bool, stock: int, ean: str) -> None:
|
||||||
self.id: int = _id
|
self.id: int = _id
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
self.price_member: int = price_member
|
self.price_member: int = price_member
|
||||||
|
@ -23,6 +24,7 @@ class Product:
|
||||||
self.custom_price: bool = custom_price
|
self.custom_price: bool = custom_price
|
||||||
self.stock: int = stock
|
self.stock: int = stock
|
||||||
self.stockable: bool = stockable
|
self.stockable: bool = stockable
|
||||||
|
self.ean: str = ean
|
||||||
|
|
||||||
def __eq__(self, other) -> bool:
|
def __eq__(self, other) -> bool:
|
||||||
if not isinstance(other, Product):
|
if not isinstance(other, Product):
|
||||||
|
@ -33,8 +35,9 @@ class Product:
|
||||||
self.price_non_member == other.price_non_member and \
|
self.price_non_member == other.price_non_member and \
|
||||||
self.custom_price == other.custom_price and \
|
self.custom_price == other.custom_price and \
|
||||||
self.stock == other.stock and \
|
self.stock == other.stock and \
|
||||||
self.stockable == other.stockable
|
self.stockable == other.stockable and \
|
||||||
|
self.ean == other.ean
|
||||||
|
|
||||||
def __hash__(self) -> int:
|
def __hash__(self) -> int:
|
||||||
return hash((self.id, self.name, self.price_member, self.price_non_member, self.custom_price,
|
return hash((self.id, self.name, self.price_member, self.price_non_member, self.custom_price,
|
||||||
self.stock, self.stockable))
|
self.stock, self.stockable, self.ean))
|
||||||
|
|
|
@ -27,7 +27,7 @@ class Transaction:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def receipt_date(self) -> str:
|
def receipt_date(self) -> str:
|
||||||
if self.date == datetime.fromtimestamp(0):
|
if self.date == datetime.fromtimestamp(0, UTC):
|
||||||
return '<unknown> '
|
return '<unknown> '
|
||||||
date: str = self.date.strftime('%d.%m.%Y, %H:%M')
|
date: str = self.date.strftime('%d.%m.%Y, %H:%M')
|
||||||
return date
|
return date
|
||||||
|
|
|
@ -26,7 +26,8 @@ class User:
|
||||||
email: Optional[str] = None,
|
email: Optional[str] = None,
|
||||||
is_admin: bool = False,
|
is_admin: bool = False,
|
||||||
is_member: 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.id: int = _id
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
self.balance: int = balance
|
self.balance: int = balance
|
||||||
|
@ -34,6 +35,7 @@ class User:
|
||||||
self.is_admin: bool = is_admin
|
self.is_admin: bool = is_admin
|
||||||
self.is_member: bool = is_member
|
self.is_member: bool = is_member
|
||||||
self.receipt_pref: ReceiptPreference = receipt_pref
|
self.receipt_pref: ReceiptPreference = receipt_pref
|
||||||
|
self.logout_after_purchase: bool = logout_after_purchase
|
||||||
|
|
||||||
def __eq__(self, other) -> bool:
|
def __eq__(self, other) -> bool:
|
||||||
if not isinstance(other, User):
|
if not isinstance(other, User):
|
||||||
|
@ -44,7 +46,9 @@ class User:
|
||||||
self.email == other.email and \
|
self.email == other.email and \
|
||||||
self.is_admin == other.is_admin and \
|
self.is_admin == other.is_admin and \
|
||||||
self.is_member == other.is_member 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:
|
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))
|
||||||
|
|
|
@ -413,3 +413,166 @@ SCHEMAS[6] = [
|
||||||
ON DELETE SET NULL ON UPDATE CASCADE
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
);
|
);
|
||||||
''']
|
''']
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMAS[7] = [
|
||||||
|
'''
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT DEFAULT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
touchkey TEXT DEFAULT NULL,
|
||||||
|
is_admin INTEGER(1) NOT NULL DEFAULT 0,
|
||||||
|
is_member INTEGER(1) NOT NULL DEFAULT 1,
|
||||||
|
balance INTEGER(8) NOT NULL DEFAULT 0,
|
||||||
|
lastchange INTEGER(8) NOT NULL DEFAULT 0,
|
||||||
|
receipt_pref INTEGER(1) NOT NULL DEFAULT 0,
|
||||||
|
created INTEGER(8) NOT NULL DEFAULT 0,
|
||||||
|
logout_after_purchase INTEGER(1) DEFAULT 0
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE products (
|
||||||
|
product_id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
stock INTEGER(8) DEFAULT 0,
|
||||||
|
stockable INTEGER(1) DEFAULT 1,
|
||||||
|
price_member INTEGER(8) NOT NULL,
|
||||||
|
price_non_member INTEGER(8) NOT NULL,
|
||||||
|
custom_price INTEGER(1) DEFAULT 0
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE transactions ( -- "superclass" of the following 3 tables
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER DEFAULT NULL,
|
||||||
|
value INTEGER(8) NOT NULL,
|
||||||
|
old_balance INTEGER(8) NOT NULL,
|
||||||
|
date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE consumptions ( -- transactions involving buying a product
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE deposits ( -- transactions involving depositing cash
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE modifications ( -- transactions involving balance modification by an admin
|
||||||
|
ta_id INTEGER NOT NULL,
|
||||||
|
agent TEXT NOT NULL,
|
||||||
|
reason TEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (ta_id),
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE receipts ( -- receipts sent to the users
|
||||||
|
receipt_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
first_ta_id INTEGER DEFAULT NULL,
|
||||||
|
last_ta_id INTEGER DEFAULT NULL,
|
||||||
|
date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (first_ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (last_ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''']
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMAS[8] = [
|
||||||
|
'''
|
||||||
|
CREATE TABLE users (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT DEFAULT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
touchkey TEXT DEFAULT NULL,
|
||||||
|
is_admin INTEGER(1) NOT NULL DEFAULT 0,
|
||||||
|
is_member INTEGER(1) NOT NULL DEFAULT 1,
|
||||||
|
balance INTEGER(8) NOT NULL DEFAULT 0,
|
||||||
|
lastchange INTEGER(8) NOT NULL DEFAULT 0,
|
||||||
|
receipt_pref INTEGER(1) NOT NULL DEFAULT 0,
|
||||||
|
created INTEGER(8) NOT NULL DEFAULT 0,
|
||||||
|
logout_after_purchase INTEGER(1) DEFAULT 0
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE products (
|
||||||
|
product_id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT UNIQUE NOT NULL,
|
||||||
|
stock INTEGER(8) DEFAULT 0,
|
||||||
|
stockable INTEGER(1) DEFAULT 1,
|
||||||
|
price_member INTEGER(8) NOT NULL,
|
||||||
|
price_non_member INTEGER(8) NOT NULL,
|
||||||
|
custom_price INTEGER(1) DEFAULT 0,
|
||||||
|
ean TEXT UNIQUE DEFAULT NULL
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE transactions ( -- "superclass" of the following 3 tables
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER DEFAULT NULL,
|
||||||
|
value INTEGER(8) NOT NULL,
|
||||||
|
old_balance INTEGER(8) NOT NULL,
|
||||||
|
date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE consumptions ( -- transactions involving buying a product
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
product TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE deposits ( -- transactions involving depositing cash
|
||||||
|
ta_id INTEGER PRIMARY KEY,
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE modifications ( -- transactions involving balance modification by an admin
|
||||||
|
ta_id INTEGER NOT NULL,
|
||||||
|
agent TEXT NOT NULL,
|
||||||
|
reason TEXT DEFAULT NULL,
|
||||||
|
PRIMARY KEY (ta_id),
|
||||||
|
FOREIGN KEY (ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
'''
|
||||||
|
CREATE TABLE receipts ( -- receipts sent to the users
|
||||||
|
receipt_id INTEGER PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
first_ta_id INTEGER DEFAULT NULL,
|
||||||
|
last_ta_id INTEGER DEFAULT NULL,
|
||||||
|
date INTEGER(8) DEFAULT (STRFTIME('%s', 'now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(user_id)
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (first_ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (last_ta_id) REFERENCES transactions(ta_id)
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
''']
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
import crypt
|
import crypt
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, UTC
|
||||||
|
|
||||||
from matemat.db import MatematDatabase
|
from matemat.db import MatematDatabase
|
||||||
from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \
|
from matemat.db.primitives import User, Product, ReceiptPreference, Receipt, \
|
||||||
|
@ -220,7 +220,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
def test_create_product(self) -> None:
|
def test_create_product(self) -> None:
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
with db.transaction() as c:
|
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")
|
c.execute("SELECT * FROM products")
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
self.assertEqual('Club Mate', row[1])
|
self.assertEqual('Club Mate', row[1])
|
||||||
|
@ -229,18 +229,20 @@ class DatabaseTest(unittest.TestCase):
|
||||||
self.assertEqual(200, row[4])
|
self.assertEqual(200, row[4])
|
||||||
self.assertEqual(200, row[5])
|
self.assertEqual(200, row[5])
|
||||||
self.assertEqual(1, row[6])
|
self.assertEqual(1, row[6])
|
||||||
|
self.assertEqual('4029764001807', row[7])
|
||||||
with self.assertRaises(ValueError):
|
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:
|
def test_get_product(self) -> None:
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
with db.transaction(exclusive=False):
|
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)
|
product = db.get_product(created.id)
|
||||||
self.assertEqual('Club Mate', product.name)
|
self.assertEqual('Club Mate', product.name)
|
||||||
self.assertEqual(150, product.price_member)
|
self.assertEqual(150, product.price_member)
|
||||||
self.assertEqual(250, product.price_non_member)
|
self.assertEqual(250, product.price_non_member)
|
||||||
self.assertEqual(False, product.stockable)
|
self.assertEqual(False, product.stockable)
|
||||||
|
self.assertEqual('4029764001807', product.ean)
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
db.get_product(-1)
|
db.get_product(-1)
|
||||||
|
|
||||||
|
@ -249,9 +251,9 @@ class DatabaseTest(unittest.TestCase):
|
||||||
# Test empty list
|
# Test empty list
|
||||||
products = db.list_products()
|
products = db.list_products()
|
||||||
self.assertEqual(0, len(products))
|
self.assertEqual(0, len(products))
|
||||||
db.create_product('Club Mate', 200, 200, False, True)
|
db.create_product('Club Mate', 200, 200, False, True, '4029764001807')
|
||||||
db.create_product('Flora Power Mate', 200, 200, False, False)
|
db.create_product('Flora Power Mate', 200, 200, False, False, None)
|
||||||
db.create_product('Fritz Mate', 200, 250, False, True)
|
db.create_product('Fritz Mate', 200, 250, False, True, '4260107223177')
|
||||||
products = db.list_products()
|
products = db.list_products()
|
||||||
self.assertEqual(3, len(products))
|
self.assertEqual(3, len(products))
|
||||||
productcheck = {}
|
productcheck = {}
|
||||||
|
@ -260,22 +262,25 @@ class DatabaseTest(unittest.TestCase):
|
||||||
self.assertEqual(200, product.price_member)
|
self.assertEqual(200, product.price_member)
|
||||||
self.assertEqual(200, product.price_non_member)
|
self.assertEqual(200, product.price_non_member)
|
||||||
self.assertTrue(product.stockable)
|
self.assertTrue(product.stockable)
|
||||||
|
self.assertEqual('4029764001807', product.ean)
|
||||||
elif product.name == 'Flora Power Mate':
|
elif product.name == 'Flora Power Mate':
|
||||||
self.assertEqual(200, product.price_member)
|
self.assertEqual(200, product.price_member)
|
||||||
self.assertEqual(200, product.price_non_member)
|
self.assertEqual(200, product.price_non_member)
|
||||||
self.assertFalse(product.stockable)
|
self.assertFalse(product.stockable)
|
||||||
|
self.assertEqual(None, product.ean)
|
||||||
elif product.name == 'Fritz Mate':
|
elif product.name == 'Fritz Mate':
|
||||||
self.assertEqual(200, product.price_member)
|
self.assertEqual(200, product.price_member)
|
||||||
self.assertEqual(250, product.price_non_member)
|
self.assertEqual(250, product.price_non_member)
|
||||||
self.assertTrue(product.stockable)
|
self.assertTrue(product.stockable)
|
||||||
|
self.assertEqual('4260107223177', product.ean)
|
||||||
productcheck[product.id] = 1
|
productcheck[product.id] = 1
|
||||||
self.assertEqual(3, len(productcheck))
|
self.assertEqual(3, len(productcheck))
|
||||||
|
|
||||||
def test_change_product(self) -> None:
|
def test_change_product(self) -> None:
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
product = db.create_product('Club Mate', 200, 200, False, True)
|
product = db.create_product('Club Mate', 200, 200, False, True, '4029764001807')
|
||||||
db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250,
|
db.change_product(product, name='Flora Power Mate', price_member=150, price_non_member=250,
|
||||||
custom_price=True, stock=None, stockable=False)
|
custom_price=True, stock=None, stockable=False, ean=None)
|
||||||
# Changes must be reflected in the passed object
|
# Changes must be reflected in the passed object
|
||||||
self.assertEqual('Flora Power Mate', product.name)
|
self.assertEqual('Flora Power Mate', product.name)
|
||||||
self.assertEqual(150, product.price_member)
|
self.assertEqual(150, product.price_member)
|
||||||
|
@ -283,6 +288,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
self.assertEqual(True, product.custom_price)
|
self.assertEqual(True, product.custom_price)
|
||||||
self.assertEqual(None, product.stock)
|
self.assertEqual(None, product.stock)
|
||||||
self.assertEqual(False, product.stockable)
|
self.assertEqual(False, product.stockable)
|
||||||
|
self.assertEqual(None, product.ean)
|
||||||
# Changes must be reflected in the database
|
# Changes must be reflected in the database
|
||||||
checkproduct = db.get_product(product.id)
|
checkproduct = db.get_product(product.id)
|
||||||
self.assertEqual('Flora Power Mate', checkproduct.name)
|
self.assertEqual('Flora Power Mate', checkproduct.name)
|
||||||
|
@ -294,7 +300,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
product.id = -1
|
product.id = -1
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
db.change_product(product)
|
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'
|
product2.name = 'Flora Power Mate'
|
||||||
with self.assertRaises(DatabaseConsistencyError):
|
with self.assertRaises(DatabaseConsistencyError):
|
||||||
# Should fail, as a product with the same name already exists.
|
# Should fail, as a product with the same name already exists.
|
||||||
|
@ -302,8 +308,8 @@ class DatabaseTest(unittest.TestCase):
|
||||||
|
|
||||||
def test_delete_product(self) -> None:
|
def test_delete_product(self) -> None:
|
||||||
with self.db as db:
|
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')
|
||||||
product2 = db.create_product('Flora Power Mate', 200, 200, False, False)
|
product2 = db.create_product('Flora Power Mate', 200, 200, False, False, None)
|
||||||
|
|
||||||
self.assertEqual(2, len(db.list_products()))
|
self.assertEqual(2, len(db.list_products()))
|
||||||
db.delete_product(product)
|
db.delete_product(product)
|
||||||
|
@ -342,9 +348,9 @@ class DatabaseTest(unittest.TestCase):
|
||||||
def test_transfer(self) -> None:
|
def test_transfer(self) -> None:
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
with db.transaction() as c:
|
with db.transaction() as c:
|
||||||
user = db.create_user('testuser', '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)
|
user2 = db.create_user('testuser2', 'supersecurepassword', 'testuser@example.com', True, True, False)
|
||||||
user3 = db.create_user('testuser3', 'supersecurepassword', 'testuser@example.com', True, True)
|
user3 = db.create_user('testuser3', 'supersecurepassword', 'testuser@example.com', True, True, True)
|
||||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user.id])
|
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user.id])
|
||||||
self.assertEqual(0, c.fetchone()[0])
|
self.assertEqual(0, c.fetchone()[0])
|
||||||
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user2.id])
|
c.execute('''SELECT balance FROM users WHERE user_id = ?''', [user2.id])
|
||||||
|
@ -378,9 +384,9 @@ class DatabaseTest(unittest.TestCase):
|
||||||
db.deposit(user1, 1337)
|
db.deposit(user1, 1337)
|
||||||
db.deposit(user2, 4242)
|
db.deposit(user2, 4242)
|
||||||
db.deposit(user3, 1234)
|
db.deposit(user3, 1234)
|
||||||
clubmate = db.create_product('Club 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)
|
florapowermate = db.create_product('Flora Power Mate', 150, 250, False, True, None)
|
||||||
fritzmate = db.create_product('Fritz Mate', 200, 200, False, True)
|
fritzmate = db.create_product('Fritz Mate', 200, 200, False, True, '4260107223177')
|
||||||
|
|
||||||
# user1 is somewhat addicted to caffeine
|
# user1 is somewhat addicted to caffeine
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
|
@ -430,10 +436,10 @@ class DatabaseTest(unittest.TestCase):
|
||||||
user7 = db.create_user('user7', 'supersecurepassword', 'user7@example.com', True, True)
|
user7 = db.create_user('user7', 'supersecurepassword', 'user7@example.com', True, True)
|
||||||
user7.receipt_pref = 42
|
user7.receipt_pref = 42
|
||||||
|
|
||||||
twoyears: int = int((datetime.utcnow() - timedelta(days=730)).timestamp())
|
twoyears: int = int((datetime.now(UTC) - timedelta(days=730)).timestamp())
|
||||||
halfyear: int = int((datetime.utcnow() - timedelta(days=183)).timestamp())
|
halfyear: int = int((datetime.now(UTC) - timedelta(days=183)).timestamp())
|
||||||
twomonths: int = int((datetime.utcnow() - timedelta(days=61)).timestamp())
|
twomonths: int = int((datetime.now(UTC) - timedelta(days=61)).timestamp())
|
||||||
halfmonth: int = int((datetime.utcnow() - timedelta(days=15)).timestamp())
|
halfmonth: int = int((datetime.now(UTC) - timedelta(days=15)).timestamp())
|
||||||
|
|
||||||
with db.transaction() as c:
|
with db.transaction() as c:
|
||||||
# Fix creation date for user2
|
# Fix creation date for user2
|
||||||
|
@ -506,7 +512,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
|
|
||||||
admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True)
|
admin: User = db.create_user('admin', 'supersecurepassword', 'admin@example.com', True, True)
|
||||||
user: User = db.create_user('user', 'supersecurepassword', 'user@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
|
# Create some transactions
|
||||||
db.change_user(user, agent=admin,
|
db.change_user(user, agent=admin,
|
||||||
|
@ -533,7 +539,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
SELECT user_id, 500, balance
|
SELECT user_id, 500, balance
|
||||||
FROM users
|
FROM users
|
||||||
WHERE user_id = :id
|
WHERE user_id = :id
|
||||||
''', [user.id])
|
''', {'id': user.id})
|
||||||
receipt3: Receipt = db.create_receipt(user, write=False)
|
receipt3: Receipt = db.create_receipt(user, write=False)
|
||||||
|
|
||||||
with db.transaction() as c:
|
with db.transaction() as c:
|
||||||
|
@ -595,8 +601,8 @@ class DatabaseTest(unittest.TestCase):
|
||||||
user2: User = db.create_user('user2', 'supersecurepassword', 'user2@example.com', True, False)
|
user2: User = db.create_user('user2', 'supersecurepassword', 'user2@example.com', True, False)
|
||||||
user3: User = db.create_user('user3', 'supersecurepassword', 'user3@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)
|
user4: User = db.create_user('user4', 'supersecurepassword', 'user4@example.com', True, False)
|
||||||
flora: Product = db.create_product('Flora Power Mate', 200, 200, False, True)
|
flora: Product = db.create_product('Flora Power Mate', 200, 200, False, True, None)
|
||||||
club: Product = db.create_product('Club Mate', 200, 200, False, False)
|
club: Product = db.create_product('Club Mate', 200, 200, False, False, '4029764001807')
|
||||||
|
|
||||||
# Create some transactions
|
# Create some transactions
|
||||||
db.deposit(user1, 1337)
|
db.deposit(user1, 1337)
|
||||||
|
@ -610,7 +616,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
db.increment_consumption(user4, club)
|
db.increment_consumption(user4, club)
|
||||||
|
|
||||||
# Generate statistics
|
# Generate statistics
|
||||||
now = datetime.utcnow()
|
now = datetime.now(UTC)
|
||||||
stats = db.generate_sales_statistics(now - timedelta(days=1), now + timedelta(days=1))
|
stats = db.generate_sales_statistics(now - timedelta(days=1), now + timedelta(days=1))
|
||||||
|
|
||||||
self.assertEqual(7, len(stats))
|
self.assertEqual(7, len(stats))
|
||||||
|
|
|
@ -53,12 +53,12 @@ class DatabaseTest(unittest.TestCase):
|
||||||
with self.db as db:
|
with self.db as db:
|
||||||
with db.transaction() as c:
|
with db.transaction() as c:
|
||||||
c.execute('''
|
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 = db._sqlite_db.cursor()
|
||||||
c.execute("SELECT * FROM users")
|
c.execute("SELECT * FROM users")
|
||||||
user = c.fetchone()
|
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:
|
def test_transaction_rollback(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
@ -68,7 +68,7 @@ class DatabaseTest(unittest.TestCase):
|
||||||
try:
|
try:
|
||||||
with db.transaction() as c:
|
with db.transaction() as c:
|
||||||
c.execute('''
|
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')
|
raise ValueError('This should trigger a rollback')
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|
|
@ -40,7 +40,7 @@ class DatabaseTransaction(object):
|
||||||
|
|
||||||
class DatabaseWrapper(object):
|
class DatabaseWrapper(object):
|
||||||
|
|
||||||
SCHEMA_VERSION = 6
|
SCHEMA_VERSION = 8
|
||||||
|
|
||||||
def __init__(self, filename: str) -> None:
|
def __init__(self, filename: str) -> None:
|
||||||
self._filename: str = filename
|
self._filename: str = filename
|
||||||
|
@ -89,6 +89,10 @@ class DatabaseWrapper(object):
|
||||||
migrate_schema_4_to_5(c)
|
migrate_schema_4_to_5(c)
|
||||||
if from_version <= 5 and to_version >= 6:
|
if from_version <= 5 and to_version >= 6:
|
||||||
migrate_schema_5_to_6(c)
|
migrate_schema_5_to_6(c)
|
||||||
|
if from_version <= 6 and to_version >= 7:
|
||||||
|
migrate_schema_6_to_7(c)
|
||||||
|
if from_version <= 7 and to_version >= 8:
|
||||||
|
migrate_schema_7_to_8(c)
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
if self.is_connected():
|
if self.is_connected():
|
||||||
|
|
|
@ -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
|
# 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)
|
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
|
# 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
|
return newdate
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, UTC
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from shutil import copyfile
|
from shutil import copyfile
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ def admin():
|
||||||
users = db.list_users()
|
users = db.list_users()
|
||||||
products = db.list_products()
|
products = db.list_products()
|
||||||
# Render the "Admin/Settings" page
|
# Render the "Admin/Settings" page
|
||||||
now = str(int(datetime.utcnow().timestamp()))
|
now = str(int(datetime.now(UTC).timestamp()))
|
||||||
return template.render('admin.html',
|
return template.render('admin.html',
|
||||||
authuser=user, authlevel=authlevel, users=users, products=products,
|
authuser=user, authlevel=authlevel, users=users, products=products,
|
||||||
receipt_preference_class=ReceiptPreference, now=now,
|
receipt_preference_class=ReceiptPreference, now=now,
|
||||||
|
@ -75,6 +75,7 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematData
|
||||||
return
|
return
|
||||||
username = str(args.username)
|
username = str(args.username)
|
||||||
email = str(args.email)
|
email = str(args.email)
|
||||||
|
logout_after_purchase = 'logout_after_purchase' in args
|
||||||
# An empty e-mail field should be interpreted as NULL
|
# An empty e-mail field should be interpreted as NULL
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
email = None
|
email = None
|
||||||
|
@ -84,7 +85,8 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, db: MatematData
|
||||||
return
|
return
|
||||||
# Attempt to update username, e-mail and receipt preference
|
# Attempt to update username, e-mail and receipt preference
|
||||||
try:
|
try:
|
||||||
db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref)
|
db.change_user(user, agent=None, name=username, email=email, receipt_pref=receipt_pref,
|
||||||
|
logout_after_purchase=logout_after_purchase)
|
||||||
except DatabaseConsistencyError:
|
except DatabaseConsistencyError:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -176,8 +178,10 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
|
||||||
password = str(args.password)
|
password = str(args.password)
|
||||||
is_member = 'ismember' in args
|
is_member = 'ismember' in args
|
||||||
is_admin = 'isadmin' in args
|
is_admin = 'isadmin' in args
|
||||||
|
logout_after_purchase = 'logout_after_purchase' in args
|
||||||
# Create the user in the database
|
# Create the user in the database
|
||||||
newuser: User = db.create_user(username, password, email, member=is_member, admin=is_admin)
|
newuser: User = db.create_user(username, password, email, member=is_member, admin=is_admin,
|
||||||
|
logout_after_purchase=logout_after_purchase)
|
||||||
|
|
||||||
# If a default avatar is set, copy it to the user's avatar path
|
# If a default avatar is set, copy it to the user's avatar path
|
||||||
|
|
||||||
|
@ -202,8 +206,9 @@ def handle_admin_change(args: FormsDict, files: FormsDict, db: MatematDatabase):
|
||||||
price_non_member = parse_chf(str(args.pricenonmember))
|
price_non_member = parse_chf(str(args.pricenonmember))
|
||||||
custom_price = 'custom_price' in args
|
custom_price = 'custom_price' in args
|
||||||
stockable = 'stockable' in args
|
stockable = 'stockable' in args
|
||||||
|
ean = str(args.ean) or None
|
||||||
# Create the user in the database
|
# Create the user in the database
|
||||||
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable)
|
newproduct = db.create_product(name, price_member, price_non_member, custom_price, stockable, ean)
|
||||||
# If a new product image was uploaded, process it
|
# If a new product image was uploaded, process it
|
||||||
image = files.image.file.read() if 'image' in files else None
|
image = files.image.file.read() if 'image' in files else None
|
||||||
if image is not None and len(image) > 0:
|
if image is not None and len(image) > 0:
|
||||||
|
|
|
@ -16,6 +16,7 @@ def buy():
|
||||||
# If no user is logged in, redirect to the main page, as a purchase must always be bound to a user
|
# If 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'):
|
if not session.has(session_id, 'authenticated_user'):
|
||||||
redirect('/')
|
redirect('/')
|
||||||
|
authlevel: int = session.get(session_id, 'authentication_level')
|
||||||
# Connect to the database
|
# Connect to the database
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
# Fetch the authenticated user from the database
|
# Fetch the authenticated user from the database
|
||||||
|
@ -25,6 +26,7 @@ def buy():
|
||||||
if 'pid' in request.params:
|
if 'pid' in request.params:
|
||||||
pid = int(str(request.params.pid))
|
pid = int(str(request.params.pid))
|
||||||
product = db.get_product(pid)
|
product = db.get_product(pid)
|
||||||
|
closetab = int(str(request.params.closetab) or 0)
|
||||||
if c.get_dispenser().dispense(product, 1):
|
if c.get_dispenser().dispense(product, 1):
|
||||||
price = product.price_member if user.is_member else product.price_non_member
|
price = product.price_member if user.is_member else product.price_non_member
|
||||||
if 'price' in request.params:
|
if 'price' in request.params:
|
||||||
|
@ -34,6 +36,9 @@ def buy():
|
||||||
stock_provider = c.get_stock_provider()
|
stock_provider = c.get_stock_provider()
|
||||||
if stock_provider.needs_update():
|
if stock_provider.needs_update():
|
||||||
stock_provider.update_stock(product, -1)
|
stock_provider.update_stock(product, -1)
|
||||||
|
# Logout user if configured, logged in via touchkey and no price entry input was shown
|
||||||
|
if user.logout_after_purchase and authlevel < 2 and not product.custom_price:
|
||||||
|
redirect(f'/logout?lastaction=buy&lastproduct={pid}&lastprice={price}&closetab={closetab}')
|
||||||
# Redirect to the main page (where this request should have come from)
|
# Redirect to the main page (where this request should have come from)
|
||||||
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}')
|
redirect(f'/?lastaction=buy&lastproduct={pid}&lastprice={price}&closetab={closetab}')
|
||||||
redirect('/')
|
redirect('/')
|
||||||
|
|
|
@ -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
|
from matemat.webserver import session
|
||||||
|
|
||||||
|
@ -16,4 +18,4 @@ def logout():
|
||||||
# Reset the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
|
# Reset the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
|
||||||
session.put(session_id, 'authentication_level', 0)
|
session.put(session_id, 'authentication_level', 0)
|
||||||
# Redirect to the main page, showing the user list
|
# Redirect to the main page, showing the user list
|
||||||
redirect('/')
|
redirect(f'/?{urllib.parse.urlencode(request.query)}')
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
from datetime import datetime
|
from datetime import datetime, UTC
|
||||||
|
|
||||||
from bottle import route, redirect, request
|
from bottle import route, redirect, request
|
||||||
|
|
||||||
from matemat.db import MatematDatabase
|
from matemat.db import MatematDatabase
|
||||||
from matemat.webserver import template, session
|
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.webserver.config import get_app_config, get_stock_provider
|
||||||
|
from matemat.util.currency_format import format_chf
|
||||||
|
|
||||||
|
|
||||||
@route('/')
|
@route('/')
|
||||||
|
@ -14,27 +16,42 @@ def main_page():
|
||||||
"""
|
"""
|
||||||
config = get_app_config()
|
config = get_app_config()
|
||||||
session_id: str = session.start()
|
session_id: str = session.start()
|
||||||
now = str(int(datetime.utcnow().timestamp()))
|
now = str(int(datetime.now(UTC).timestamp()))
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
|
# Fetch the list of products to display
|
||||||
|
products = db.list_products()
|
||||||
|
closetab = int(str(request.params.closetab) or 0)
|
||||||
|
lastprice = int(request.params.lastprice) if request.params.lastprice else None
|
||||||
|
if request.params.lastaction == 'deposit' and lastprice:
|
||||||
|
Notification.success(f'Deposited {format_chf(lastprice)}', decay=True)
|
||||||
|
elif request.params.lastaction == 'buy' and lastprice and request.params.lastproduct:
|
||||||
|
lastproduct = db.get_product(request.params.lastproduct)
|
||||||
|
Notification.success(f'Purchased {lastproduct.name} for {format_chf(lastprice)}', decay=True)
|
||||||
|
buyproduct = None
|
||||||
|
if request.params.ean:
|
||||||
|
try:
|
||||||
|
buyproduct = db.get_product_by_ean(request.params.ean)
|
||||||
|
Notification.success(
|
||||||
|
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
|
||||||
|
except ValueError:
|
||||||
|
Notification.error(f'EAN code {request.params.ean} is not associated with any product.', decay=True)
|
||||||
# Check whether a user is logged in
|
# Check whether a user is logged in
|
||||||
if session.has(session_id, 'authenticated_user'):
|
if session.has(session_id, 'authenticated_user'):
|
||||||
# Fetch the user id and authentication level (touchkey vs password) from the session storage
|
# Fetch the user id and authentication level (touchkey vs password) from the session storage
|
||||||
uid: int = session.get(session_id, 'authenticated_user')
|
uid: int = session.get(session_id, 'authenticated_user')
|
||||||
authlevel: int = session.get(session_id, 'authentication_level')
|
authlevel: int = session.get(session_id, 'authentication_level')
|
||||||
|
# If an EAN code was scanned, directly trigger the purchase
|
||||||
|
if buyproduct:
|
||||||
|
url = f'/buy?pid={buyproduct.id}'
|
||||||
|
if config.get('CloseTabAfterEANPurchase', '0') == '1':
|
||||||
|
url += '&closetab=1'
|
||||||
|
redirect(url)
|
||||||
# Fetch the user object from the database (for name display, price calculation and admin check)
|
# Fetch the user object from the database (for name display, price calculation and admin check)
|
||||||
users = db.list_users()
|
users = db.list_users()
|
||||||
user = db.get_user(uid)
|
user = db.get_user(uid)
|
||||||
# Fetch the list of products to display
|
|
||||||
products = db.list_products()
|
|
||||||
if request.params.lastproduct:
|
|
||||||
lastproduct = db.get_product(request.params.lastproduct)
|
|
||||||
else:
|
|
||||||
lastproduct = None
|
|
||||||
lastprice = int(request.params.lastprice) if request.params.lastprice else None
|
|
||||||
# Prepare a response with a jinja2 template
|
# Prepare a response with a jinja2 template
|
||||||
return template.render('productlist.html',
|
return template.render('productlist.html',
|
||||||
authuser=user, users=users, products=products, authlevel=authlevel,
|
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)
|
stock=get_stock_provider(), setupname=config['InstanceName'], now=now)
|
||||||
else:
|
else:
|
||||||
# If there are no admin users registered, jump to the admin creation procedure
|
# If there are no admin users registered, jump to the admin creation procedure
|
||||||
|
@ -44,4 +61,5 @@ def main_page():
|
||||||
users = db.list_users(with_touchkey=True)
|
users = db.list_users(with_touchkey=True)
|
||||||
return template.render('userlist.html',
|
return template.render('userlist.html',
|
||||||
users=users, setupname=config['InstanceName'], now=now,
|
users=users, setupname=config['InstanceName'], now=now,
|
||||||
signup=(config.get('SignupEnabled', '0') == '1'))
|
signup=(config.get('SignupEnabled', '0') == '1'),
|
||||||
|
buyproduct=buyproduct, closetab=closetab)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import os
|
import os
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from datetime import datetime
|
from datetime import datetime, UTC
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
|
@ -56,7 +56,7 @@ def modproduct():
|
||||||
redirect('/admin')
|
redirect('/admin')
|
||||||
|
|
||||||
# Render the "Modify Product" page
|
# Render the "Modify Product" page
|
||||||
now = str(int(datetime.utcnow().timestamp()))
|
now = str(int(datetime.now(UTC).timestamp()))
|
||||||
return template.render('modproduct.html',
|
return template.render('modproduct.html',
|
||||||
authuser=authuser, product=product, authlevel=authlevel,
|
authuser=authuser, product=product, authlevel=authlevel,
|
||||||
setupname=config['InstanceName'], now=now)
|
setupname=config['InstanceName'], now=now)
|
||||||
|
@ -98,11 +98,12 @@ def handle_change(args: FormsDict, files: FormsDict, product: Product, db: Matem
|
||||||
custom_price = 'custom_price' in args
|
custom_price = 'custom_price' in args
|
||||||
stock = int(str(args.stock))
|
stock = int(str(args.stock))
|
||||||
stockable = 'stockable' in args
|
stockable = 'stockable' in args
|
||||||
|
ean = str(args.ean) or None
|
||||||
# Attempt to write the changes to the database
|
# Attempt to write the changes to the database
|
||||||
try:
|
try:
|
||||||
db.change_product(product,
|
db.change_product(product,
|
||||||
name=name, price_member=price_member, price_non_member=price_non_member,
|
name=name, price_member=price_member, price_non_member=price_non_member,
|
||||||
custom_price=custom_price, stock=stock, stockable=stockable)
|
custom_price=custom_price, stock=stock, stockable=stockable, ean=ean)
|
||||||
stock_provider = get_stock_provider()
|
stock_provider = get_stock_provider()
|
||||||
if stock_provider.needs_update() and product.stockable:
|
if stock_provider.needs_update() and product.stockable:
|
||||||
stock_provider.set_stock(product, stock)
|
stock_provider.set_stock(product, stock)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime, UTC
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ def moduser():
|
||||||
redirect('/admin')
|
redirect('/admin')
|
||||||
|
|
||||||
# Render the "Modify User" page
|
# Render the "Modify User" page
|
||||||
now = str(int(datetime.utcnow().timestamp()))
|
now = str(int(datetime.now(UTC).timestamp()))
|
||||||
return template.render('moduser.html',
|
return template.render('moduser.html',
|
||||||
authuser=authuser, user=user, authlevel=authlevel, now=now,
|
authuser=authuser, user=user, authlevel=authlevel, now=now,
|
||||||
receipt_preference_class=ReceiptPreference,
|
receipt_preference_class=ReceiptPreference,
|
||||||
|
@ -111,6 +111,7 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, authuser: User,
|
||||||
balance_reason = None
|
balance_reason = None
|
||||||
is_member = 'ismember' in args
|
is_member = 'ismember' in args
|
||||||
is_admin = 'isadmin' 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
|
# An empty e-mail field should be interpreted as NULL
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
email = None
|
email = None
|
||||||
|
@ -121,7 +122,8 @@ def handle_change(args: FormsDict, files: FormsDict, user: User, authuser: User,
|
||||||
db.change_password(user, '', password, verify_password=False)
|
db.change_password(user, '', password, verify_password=False)
|
||||||
# Write the user detail changes
|
# Write the user detail changes
|
||||||
db.change_user(user, agent=authuser, name=username, email=email, is_member=is_member, is_admin=is_admin,
|
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:
|
except DatabaseConsistencyError:
|
||||||
return
|
return
|
||||||
# If a new avatar was uploaded, process it
|
# If a new avatar was uploaded, process it
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, UTC
|
||||||
from math import pi, sin, cos
|
from math import pi, sin, cos
|
||||||
from typing import Any, Dict, List, Tuple
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ def statistics():
|
||||||
# Show a 403 Forbidden error page if the user is not an admin
|
# Show a 403 Forbidden error page if the user is not an admin
|
||||||
abort(403)
|
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)
|
fromdate: datetime = (todate - timedelta(days=365)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
if 'fromdate' in request.params:
|
if 'fromdate' in request.params:
|
||||||
fdarg: str = str(request.params.fromdate)
|
fdarg: str = str(request.params.fromdate)
|
||||||
|
|
|
@ -4,6 +4,7 @@ from matemat.db import MatematDatabase
|
||||||
from matemat.db.primitives import User
|
from matemat.db.primitives import User
|
||||||
from matemat.exceptions import AuthenticationError
|
from matemat.exceptions import AuthenticationError
|
||||||
from matemat.webserver import template, session
|
from matemat.webserver import template, session
|
||||||
|
from matemat.webserver.template import Notification
|
||||||
from matemat.webserver.config import get_app_config
|
from matemat.webserver.config import get_app_config
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,29 +17,45 @@ def touchkey_page():
|
||||||
"""
|
"""
|
||||||
config = get_app_config()
|
config = get_app_config()
|
||||||
session_id: str = session.start()
|
session_id: str = session.start()
|
||||||
# If a user is already logged in, simply redirect to the main page, showing the product list
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
if session.has(session_id, 'authenticated_user'):
|
# If a user is already logged in, simply redirect to the main page, showing the product list
|
||||||
redirect('/')
|
if session.has(session_id, 'authenticated_user'):
|
||||||
# If requested via HTTP GET, render the login page showing the touchkey UI
|
redirect('/')
|
||||||
if request.method == 'GET':
|
# If requested via HTTP GET, render the login page showing the touchkey UI
|
||||||
return template.render('touchkey.html',
|
if request.method == 'GET':
|
||||||
username=str(request.params.username), uid=int(str(request.params.uid)),
|
buypid = None
|
||||||
setupname=config['InstanceName'])
|
if request.params.buypid:
|
||||||
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
|
buypid = str(request.params.buypid)
|
||||||
elif request.method == 'POST':
|
try:
|
||||||
# Connect to the database
|
buyproduct = db.get_product(int(buypid))
|
||||||
with MatematDatabase(config['DatabaseFile']) as db:
|
Notification.success(
|
||||||
try:
|
f'Login will purchase <strong>{buyproduct.name}</strong>. Click <a href="/">here</a> to abort.')
|
||||||
# Read the request arguments and attempt to log in with them
|
except ValueError:
|
||||||
user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey))
|
Notification.error(f'No product with id {buypid}', decay=True)
|
||||||
except AuthenticationError:
|
return template.render('touchkey.html',
|
||||||
# Reload the touchkey login page on failure
|
username=str(request.params.username), uid=int(str(request.params.uid)),
|
||||||
redirect(f'/touchkey?uid={str(request.params.uid)}&username={str(request.params.username)}')
|
setupname=config['InstanceName'], buypid=buypid)
|
||||||
# Set the user ID session variable
|
# If requested via HTTP POST, read the request arguments and attempt to log in with the provided credentials
|
||||||
session.put(session_id, 'authenticated_user', user.id)
|
elif request.method == 'POST':
|
||||||
# Set the authlevel session variable (0 = none, 1 = touchkey, 2 = password login)
|
# Connect to the database
|
||||||
session.put(session_id, 'authentication_level', 1)
|
with MatematDatabase(config['DatabaseFile']) as db:
|
||||||
# Redirect to the main page, showing the product list
|
try:
|
||||||
redirect('/')
|
# Read the request arguments and attempt to log in with them
|
||||||
# If neither GET nor POST was used, show a 405 Method Not Allowed error page
|
user: User = db.login(str(request.params.username), touchkey=str(request.params.touchkey))
|
||||||
abort(405)
|
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)
|
||||||
|
if request.params.buypid:
|
||||||
|
buypid = str(request.params.buypid)
|
||||||
|
url = f'/buy?pid={buypid}'
|
||||||
|
if config.get('CloseTabAfterEANPurchase', '0') == '1':
|
||||||
|
url += '&closetab=1'
|
||||||
|
redirect(url)
|
||||||
|
# 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)
|
||||||
|
|
|
@ -3,7 +3,7 @@ from typing import Any, Dict, Tuple, Optional
|
||||||
from bottle import request, response
|
from bottle import request, response
|
||||||
from secrets import token_bytes
|
from secrets import token_bytes
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta, UTC
|
||||||
|
|
||||||
__key: Optional[str] = token_bytes(32)
|
__key: Optional[str] = token_bytes(32)
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ def start() -> str:
|
||||||
:return: The session ID.
|
:return: The session ID.
|
||||||
"""
|
"""
|
||||||
# Reference date for session timeout
|
# Reference date for session timeout
|
||||||
now = datetime.utcnow()
|
now = datetime.now(UTC)
|
||||||
# Read the client's session ID, if any
|
# Read the client's session ID, if any
|
||||||
session_id = request.get_cookie(_COOKIE_NAME, secret=__key)
|
session_id = request.get_cookie(_COOKIE_NAME, secret=__key)
|
||||||
# If there is no active session, create a new session ID
|
# If there is no active session, create a new session ID
|
||||||
|
|
|
@ -1,2 +1,3 @@
|
||||||
|
|
||||||
|
from .notification import Notification
|
||||||
from .template import init, render
|
from .template import init, render
|
||||||
|
|
25
matemat/webserver/template/notification.py
Normal file
25
matemat/webserver/template/notification.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
|
||||||
|
|
||||||
|
class Notification:
|
||||||
|
notifications = []
|
||||||
|
|
||||||
|
def __init__(self, msg: str, classes=None, decay: bool = False):
|
||||||
|
self.msg = msg
|
||||||
|
self.classes = []
|
||||||
|
self.classes.extend(classes)
|
||||||
|
if decay:
|
||||||
|
self.classes.append('decay')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def render(cls):
|
||||||
|
n = list(cls.notifications)
|
||||||
|
cls.notifications.clear()
|
||||||
|
return n
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def success(cls, msg: str, decay: bool = False):
|
||||||
|
cls.notifications.append(cls(msg, classes=['success'], decay=decay))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def error(cls, msg: str, decay: bool = False):
|
||||||
|
cls.notifications.append(cls(msg, classes=['error'], decay=decay))
|
|
@ -5,6 +5,7 @@ import jinja2
|
||||||
|
|
||||||
from matemat import __version__
|
from matemat import __version__
|
||||||
from matemat.util.currency_format import format_chf
|
from matemat.util.currency_format import format_chf
|
||||||
|
from matemat.webserver.template import Notification
|
||||||
|
|
||||||
__jinja_env: jinja2.Environment = None
|
__jinja_env: jinja2.Environment = None
|
||||||
|
|
||||||
|
@ -22,4 +23,8 @@ def init(config: Dict[str, Any]) -> None:
|
||||||
def render(name: str, **kwargs):
|
def render(name: str, **kwargs):
|
||||||
global __jinja_env
|
global __jinja_env
|
||||||
template: jinja2.Template = __jinja_env.get_template(name)
|
template: jinja2.Template = __jinja_env.get_template(name)
|
||||||
return template.render(__version__=__version__, **kwargs).encode('utf-8')
|
return template.render(
|
||||||
|
__version__=__version__,
|
||||||
|
notifications=Notification.render(),
|
||||||
|
**kwargs
|
||||||
|
).encode('utf-8')
|
||||||
|
|
|
@ -37,6 +37,11 @@ InstanceName=Matemat
|
||||||
#SignupEnabled=1
|
#SignupEnabled=1
|
||||||
#SignupKioskMode= ::1, ::ffff:127.0.0.0/8, 127.0.0.0/8
|
#SignupKioskMode= ::1, ::ffff:127.0.0.0/8, 127.0.0.0/8
|
||||||
|
|
||||||
|
#
|
||||||
|
# Close tabs after completing an EAN code scan based purchase.
|
||||||
|
# This only works in Firefox with dom.allow_scripts_to_close_windows=true
|
||||||
|
#
|
||||||
|
#CloseTabAfterEANPurchase=1
|
||||||
|
|
||||||
# Add static HTTP headers in this section
|
# Add static HTTP headers in this section
|
||||||
# [HttpHeaders]
|
# [HttpHeaders]
|
||||||
|
|
|
@ -13,8 +13,8 @@ nav div {
|
||||||
|
|
||||||
.thumblist-item {
|
.thumblist-item {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 5px;
|
margin: 5px 5px 10px 10px;
|
||||||
padding: 5px;
|
padding: 15px;
|
||||||
background: #f0f0f0;
|
background: #f0f0f0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@ -52,6 +52,25 @@ nav div {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
display: block;
|
||||||
|
width: calc(100% - 36px);
|
||||||
|
margin: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.notification.success {
|
||||||
|
background-color: #c0ffc0;
|
||||||
|
}
|
||||||
|
.notification.error {
|
||||||
|
background-color: #ffc0c0;
|
||||||
|
}
|
||||||
|
.notification.decay {
|
||||||
|
animation: notificationdecay 0s 7s forwards;
|
||||||
|
}
|
||||||
|
@keyframes notificationdecay {
|
||||||
|
to { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
@media print {
|
@media print {
|
||||||
footer {
|
footer {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
@ -298,37 +317,3 @@ div.osk-button.osk-button-space {
|
||||||
flex: 5 0 1px;
|
flex: 5 0 1px;
|
||||||
color: #606060;
|
color: #606060;
|
||||||
}
|
}
|
||||||
|
|
||||||
aside#overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
background: #88ff88;
|
|
||||||
text-align: center;
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 5%;
|
|
||||||
font-family: sans-serif;
|
|
||||||
display: none;
|
|
||||||
transition: opacity 700ms;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside#overlay.fade {
|
|
||||||
opacity: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside#overlay > h2 {
|
|
||||||
font-size: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside#overlay > img {
|
|
||||||
width: 30%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside#overlay > div.price {
|
|
||||||
padding-top: 30px;
|
|
||||||
font-size: 2em;
|
|
||||||
}
|
|
|
@ -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);
|
|
|
@ -23,6 +23,9 @@
|
||||||
<label for="admin-myaccount-isadmin">Admin: </label>
|
<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 id="admin-myaccount-isadmin" type="checkbox" disabled="disabled" {% if authuser.is_admin %} checked="checked" {% endif %}/><br/>
|
||||||
|
|
||||||
|
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
|
||||||
|
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if authuser.logout_after_purchase %} checked="checked" {% endif %}/><br/>
|
||||||
|
|
||||||
<input type="submit" value="Save changes" />
|
<input type="submit" value="Save changes" />
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -17,6 +17,9 @@
|
||||||
<label for="admin-newuser-isadmin">Admin: </label>
|
<label for="admin-newuser-isadmin">Admin: </label>
|
||||||
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/>
|
<input id="admin-newuser-isadmin" type="checkbox" name="isadmin" /><br/>
|
||||||
|
|
||||||
|
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
|
||||||
|
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" /><br/>
|
||||||
|
|
||||||
<input type="submit" value="Create User" />
|
<input type="submit" value="Create User" />
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
@ -43,6 +46,9 @@
|
||||||
<label for="admin-newproduct-name">Name: </label>
|
<label for="admin-newproduct-name">Name: </label>
|
||||||
<input id="admin-newproduct-name" type="text" name="name" /><br/>
|
<input id="admin-newproduct-name" type="text" name="name" /><br/>
|
||||||
|
|
||||||
|
<label for="admin-newproduct-ean">EAN code: </label>
|
||||||
|
<input id="admin-newproduct-ean" type="text" name="ean" /><br/>
|
||||||
|
|
||||||
<label for="admin-newproduct-price-member">Member price: </label>
|
<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/>
|
CHF <input id="admin-newproduct-price-member" type="number" step="0.01" name="pricemember" value="0" /><br/>
|
||||||
|
|
||||||
|
|
|
@ -12,18 +12,14 @@
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
{% block overlay %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
{% block header %}
|
{% block header %}
|
||||||
|
|
||||||
{# Always show a link to the home page, either a list of users or of products. #}
|
|
||||||
<nav class="navbarbutton">
|
<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). #}
|
{# Show a link to the settings, if a user logged in via password (authlevel 2). #}
|
||||||
{% if authlevel|default(0) > 1 %}
|
{% if authlevel|default(0) > 1 %}
|
||||||
{% if authuser is defined %}
|
{% if authuser is defined %}
|
||||||
|
<a href="/">Home</a>
|
||||||
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
|
{# If a user is an admin, call the link "Administration". Otherwise, call it "Settings". #}
|
||||||
{% if authuser.is_admin %}
|
{% if authuser.is_admin %}
|
||||||
<a href="/admin">Administration</a>
|
<a href="/admin">Administration</a>
|
||||||
|
@ -38,6 +34,11 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
{% block notifications %}
|
||||||
|
{% for n in notifications | default([]) %}
|
||||||
|
<div class="notification {{ n.classes | join(' ') }}">{{ n.msg|safe }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
||||||
{% block main %}
|
{% block main %}
|
||||||
{# Here be content. #}
|
{# Here be content. #}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -55,6 +56,5 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="/static/js/overlay.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
@ -14,6 +14,9 @@
|
||||||
<label for="modproduct-name">Name: </label>
|
<label for="modproduct-name">Name: </label>
|
||||||
<input id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/>
|
<input id="modproduct-name" type="text" name="name" value="{{ product.name }}" /><br/>
|
||||||
|
|
||||||
|
<label for="modproduct-ean">EAN code: </label>
|
||||||
|
<input id="modproduct-ean" type="text" name="ean" value="{{ product.ean or '' }}" /><br/>
|
||||||
|
|
||||||
<label for="modproduct-price-member">Member price: </label>
|
<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/>
|
CHF <input id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" /><br/>
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,9 @@
|
||||||
<label for="moduser-account-isadmin">Admin: </label>
|
<label for="moduser-account-isadmin">Admin: </label>
|
||||||
<input id="moduser-account-isadmin" name="isadmin" type="checkbox" {% if user.is_admin %} checked="checked" {% endif %}/><br/>
|
<input id="moduser-account-isadmin" name="isadmin" type="checkbox" {% if user.is_admin %} checked="checked" {% endif %}/><br/>
|
||||||
|
|
||||||
|
<label for="admin-myaccount-logout-after-purchase">Logout after purchase: </label>
|
||||||
|
<input id="admin-myaccount-logout-after-purchase" type="checkbox" name="logout_after_purchase" {% if user.logout_after_purchase %} checked="checked" {% endif %}/><br/>
|
||||||
|
|
||||||
<label for="moduser-account-balance">Balance: </label>
|
<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/>
|
CHF <input id="moduser-account-balance" name="balance" type="number" step="0.01" value="{{ user.balance|chf(False) }}" /><br/>
|
||||||
|
|
||||||
|
|
|
@ -6,27 +6,6 @@
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
{% endblock %}
|
{% 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 %}
|
{% block main %}
|
||||||
|
|
||||||
{# Show the users current balance #}
|
{# Show the users current balance #}
|
||||||
|
@ -102,4 +81,9 @@
|
||||||
|
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
|
{% if closetab | default(0) %}
|
||||||
|
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
|
||||||
|
<script>setTimeout(window.close, 3000);</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -23,6 +23,9 @@
|
||||||
<input type="hidden" name="uid" value="{{ uid }}" />
|
<input type="hidden" name="uid" value="{{ uid }}" />
|
||||||
<input type="hidden" name="username" value="{{ username }}" />
|
<input type="hidden" name="username" value="{{ username }}" />
|
||||||
<input type="hidden" name="touchkey" value="" id="loginform-touchkey-value" />
|
<input type="hidden" name="touchkey" value="" id="loginform-touchkey-value" />
|
||||||
|
{% if buypid %}
|
||||||
|
<input type="hidden" name="buypid" value="{{ buypid }}" />
|
||||||
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
<a href="/">Cancel</a>
|
<a href="/">Cancel</a>
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
{% for user in users %}
|
{% for user in users %}
|
||||||
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
|
{# Show an item per user, consisting of the username, and the avatar, linking to the touchkey login #}
|
||||||
<div class="thumblist-item">
|
<div class="thumblist-item">
|
||||||
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}">
|
<a href="/touchkey?uid={{ user.id }}&username={{ user.name }}{% if buyproduct %}&buypid={{ buyproduct.id }}{% endif %}">
|
||||||
<span class="thumblist-title">{{ user.name }}</span><br/>
|
<span class="thumblist-title">{{ user.name }}</span><br/>
|
||||||
<div class="imgcontainer">
|
<div class="imgcontainer">
|
||||||
<img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" draggable="false"/>
|
<img src="/static/upload/thumbnails/users/{{ user.id }}.png?cacheBuster={{ now }}" alt="Avatar of {{ user.name }}" draggable="false"/>
|
||||||
|
@ -22,11 +22,20 @@
|
||||||
|
|
||||||
<br/>
|
<br/>
|
||||||
{# Link to the password login #}
|
{# Link to the password login #}
|
||||||
<a href="/login">Password login</a>
|
<div class="thumblist-item">
|
||||||
|
<a href="/login">Password login</a>
|
||||||
|
</div>
|
||||||
{% if signup %}
|
{% if signup %}
|
||||||
<a href="/signup">Create account</a>
|
<div class="thumblist-item">
|
||||||
|
<a href="/signup">Create account</a>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
|
|
||||||
|
{% if closetab | default(0) %}
|
||||||
|
{# This only works in Firefox with dom.allow_scripts_to_close_windows=true #}
|
||||||
|
<script>setTimeout(window.close, 3000);</script>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
Loading…
Reference in a new issue