Merge branch 'staging' into 'master'

Automated Deployment & Release Management

See merge request s3lph/matemat!55
This commit is contained in:
s3lph 2019-02-08 03:37:38 +00:00
commit 7cb1bbe24a
36 changed files with 760 additions and 238 deletions

2
.gitignore vendored
View file

@ -10,4 +10,4 @@
*.sqlite3 *.sqlite3
*.db *.db
static/upload/ static/upload/
**/matemat.conf ./matemat.conf

View file

@ -1,39 +1,126 @@
--- ---
image: s3lph/matemat-ci:20180802-02 image: s3lph/matemat-ci:20181107-02
stages: stages:
- test - test
- build - build
- staging - deploy
before_script:
- export MATEMAT_VERSION=$(python -c 'import matemat; print(matemat.__version__)')
test: test:
stage: test stage: test
script: script:
- pip3 install -r requirements.txt - pip3 install -e .
- sudo -u matemat python3 -m coverage run --rcfile=setup.cfg -m unittest discover matemat - sudo -u matemat python3.6 -m coverage run --rcfile=setup.cfg -m unittest discover matemat
- sudo -u matemat python3 -m coverage combine - sudo -u matemat python3.6 -m coverage combine
- sudo -u matemat python3 -m coverage report --rcfile=setup.cfg - sudo -u matemat python3.6 -m coverage report --rcfile=setup.cfg
codestyle: codestyle:
stage: test stage: test
script: script:
- pip3 install -r requirements.txt - pip3 install -e .
- sudo -u matemat pycodestyle matemat - sudo -u matemat pycodestyle matemat
build:
build_docker:
stage: build stage: build
script: script:
- docker build -t "registry.gitlab.com/s3lph/matemat:$(git rev-parse HEAD)" . - docker build -t "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_SHA" -f package/docker/Dockerfile .
- docker tag "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/matemat:latest-$([[ $CI_COMMIT_REF_NAME == "master" ]] && echo stable || echo staging)" - docker tag "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_REF_NAME"
- if [[ -n "$CI_COMMIT_TAG" ]]; then docker tag "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_SHA" "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_TAG"; fi
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_TOKEN registry.gitlab.com - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_TOKEN registry.gitlab.com
- docker push "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_SHA" - docker push "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_SHA"
- docker push registry.gitlab.com/s3lph/matemat:latest-$([[ $CI_COMMIT_REF_NAME == "master" ]] && echo stable || echo staging) - docker push "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_REF_NAME"
- if [[ -n "$CI_COMMIT_TAG" ]]; then docker push "registry.gitlab.com/s3lph/matemat:$CI_COMMIT_TAG"; fi
only: only:
- staging - staging
- master - tags
build_wheel:
stage: build
script:
- python3.6 setup.py egg_info bdist_wheel
- cd dist
- sha256sum *.whl > SHA256SUMS
artifacts:
paths:
- "dist/*.whl"
- dist/SHA256SUMS
only:
- tags
build_debian:
stage: build
script:
# 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
- echo -n > package/debian/matemat/usr/share/doc/matemat/changelog
- |
for version in "$(cat CHANGELOG.md | grep '<!-- BEGIN CHANGES' | cut -d ' ' -f 4)"; do
echo "matemat (${version}-1); urgency=medium\n" >> package/debian/matemat/usr/share/doc/matemat/changelog
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' >> package/debian/matemat/usr/share/doc/matemat/changelog
echo "\n -- ${PACKAGE_AUTHOR} $(date -R)\n" >> package/debian/matemat/usr/share/doc/matemat/changelog
done
- 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/
- python3.6 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.6/site-packages/ matemat/usr/lib/python3/dist-packages/
- rm -rf matemat/usr/lib/python3.6/
- 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.6$#!/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
- dpkg-deb --build matemat
- mv matemat.deb "matemat_${MATEMAT_VERSION}-1_all.deb"
- sudo -u nobody lintian "matemat_${MATEMAT_VERSION}-1_all.deb"
- sha256sum *.deb > SHA256SUMS
artifacts:
paths:
- "package/debian/*.deb"
- package/debian/SHA256SUMS
only:
- tags
build_archlinux:
stage: build
image: archlinux/base:latest # Use an archlinux image instead of the customized debian image.
script:
- pacman -Sy --noconfirm python python-setuptools python-pip python-wheel python-jinja python-pillow python-magic base-devel
- export MATEMAT_VERSION=$(python -c 'import matemat; print(matemat.__version__)')
- cp -r static/ package/archlinux/matemat/usr/lib/matemat/static/
- cp -r templates/ package/archlinux/matemat/usr/lib/matemat/templates/
- python3 setup.py egg_info -d -b +master install --root=package/archlinux/matemat/ --prefix=/usr --optimize=1
- cd package/archlinux
- mv matemat/usr/bin/matemat matemat/usr/lib/matemat/matemat
- rm -rf matemat/usr/bin
- sed -re "s/__VERSION__/${MATEMAT_VERSION}/g" -i PKGBUILD
- sudo -u nobody makepkg -c
- sha256sum *.pkg.tar.xz > SHA256SUMS
artifacts:
paths:
- "package/archlinux/*.pkg.tar.xz"
- package/archlinux/SHA256SUMS
only:
- tags
staging: staging:
stage: staging stage: deploy
script: script:
- eval $(ssh-agent -s) - eval $(ssh-agent -s)
- ssh-add - <<<"$STAGING_SSH_PRIVATE_KEY" - ssh-add - <<<"$STAGING_SSH_PRIVATE_KEY"
@ -43,3 +130,12 @@ staging:
url: https://matemat.kernelpanic.lol/ url: https://matemat.kernelpanic.lol/
only: only:
- staging - staging
release:
stage: deploy
script:
- python package/release.py
only:
- tags

1
CHANGELOG.md Normal file
View file

@ -0,0 +1 @@
# Matemat Changelog

View file

@ -16,7 +16,7 @@ This project intends to provide a well-tested and maintainable alternative to
## Dependencies ## Dependencies
- Python 3 (>=3.7) - Python 3 (>=3.6)
- Python dependencies: - Python dependencies:
- file-magic - file-magic
- jinja2 - jinja2

2
doc

@ -1 +1 @@
Subproject commit 411880ae72b3a2204fed4b945bdb3a15d3ece364 Subproject commit 0fcf4244f90d93cbc7be8e670aeaa30288d24ae3

View file

@ -1,2 +1,2 @@
__version__ = '2.0' __version__ = '0.1'

View file

@ -3,14 +3,15 @@ from typing import Any, Dict, Iterable, Union
import sys import sys
from matemat.webserver import MatematWebserver
from matemat.webserver import parse_config_file from matemat.webserver import parse_config_file
if __name__ == '__main__':
# Those imports are actually needed, as they implicitly register pagelets. # Those imports are actually needed, as they implicitly register pagelets.
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from matemat.webserver.pagelets import * from matemat.webserver.pagelets import *
from matemat.webserver import MatematWebserver
def main():
# Use config file name from command line, if present # Use config file name from command line, if present
configfile: Union[str, Iterable[str]] = '/etc/matemat.conf' configfile: Union[str, Iterable[str]] = '/etc/matemat.conf'
if len(sys.argv) > 1: if len(sys.argv) > 1:
@ -21,3 +22,7 @@ if __name__ == '__main__':
# Start the web server # Start the web server
MatematWebserver(**config).start() MatematWebserver(**config).start()
if __name__ == '__main__':
main()

View file

@ -1,5 +1,4 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple, Type from typing import Any, Dict, List, Optional, Tuple, Type
import crypt import crypt
@ -39,7 +38,7 @@ class MatematDatabase(object):
""" """
self.db: DatabaseWrapper = DatabaseWrapper(filename) self.db: DatabaseWrapper = DatabaseWrapper(filename)
def __enter__(self) -> MatematDatabase: def __enter__(self) -> 'MatematDatabase':
# Pass context manager stuff through to the database wrapper # Pass context manager stuff through to the database wrapper
self.db.__enter__() self.db.__enter__()
return self return self

View file

@ -1,22 +1,31 @@
from dataclasses import dataclass
@dataclass
class Product: class Product:
""" """
Representation of a product offered by the Matemat, with a name, prices for users, and the number of items Representation of a product offered by the Matemat, with a name, prices for users, and the number of items
currently in stock. currently in stock.
:param id: The product ID in the database. :param _id: The product ID in the database.
:param name: The product's name. :param name: The product's name.
:param price_member: The price of a unit of this product for users marked as "members". :param price_member: The price of a unit of this product for users marked as "members".
:param price_non_member: The price of a unit of this product for users NOT marked as "members". :param price_non_member: The price of a unit of this product for users NOT marked as "members".
:param stock: The number of items of this product currently in stock. :param stock: The number of items of this product currently in stock.
""" """
id: int def __init__(self, _id: int, name: str, price_member: int, price_non_member: int, stock: int) -> None:
name: str self.id: int = _id
price_member: int self.name: str = name
price_non_member: int self.price_member: int = price_member
stock: int self.price_non_member: int = price_non_member
self.stock: int = stock
def __eq__(self, other) -> bool:
if not isinstance(other, Product):
return False
return self.id == other.id and \
self.name == other.name and \
self.price_member == other.price_member and \
self.price_non_member == other.price_non_member and \
self.stock == other.stock
def __hash__(self) -> int:
return hash((self.id, self.name, self.price_member, self.price_non_member, self.stock))

View file

@ -1,17 +1,42 @@
from typing import List from typing import List
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from matemat.db.primitives import User, Transaction from matemat.db.primitives import User, Transaction
@dataclass
class Receipt: class Receipt:
"""
Representation of a receipt for a user and a given timespan.
id: int :param _id: The receipt ID in the database.
transactions: List[Transaction] :param transactions: The list of transactions on this receipt.
user: User :param user: The user for whom this receipt was issued.
from_date: datetime :param from_date: The beginning of the time span this receipt covers.
to_date: datetime :param to_date: The end of the time span this receipt covers.
"""
def __init__(self,
_id: int,
transactions: List[Transaction],
user: User,
from_date: datetime,
to_date: datetime) -> None:
self.id: int = _id
self.transactions: List[Transaction] = transactions
self.user: User = user
self.from_date: datetime = from_date
self.to_date: datetime = to_date
def __eq__(self, other) -> bool:
if not isinstance(other, Receipt):
return False
return self.id == other.id and \
self.transactions == other.transactions and \
self.user == other.user and \
self.from_date == other.from_date and \
self.to_date == other.to_date
def __hash__(self) -> int:
return hash((self.id, self.transactions, self.user, self.from_date, self.to_date))

View file

@ -11,7 +11,7 @@ class ReceiptPreference(Enum):
A user's preference for the frequency of receiving receipts. A user's preference for the frequency of receiving receipts.
""" """
def __new__(cls, *args, **kwargs): def __new__(cls, *args, **kwargs) -> 'ReceiptPreference':
e = object.__new__(cls) e = object.__new__(cls)
# The enum's internal value # The enum's internal value
e._value_: int = args[0] e._value_: int = args[0]

View file

@ -1,6 +1,5 @@
from typing import Optional from typing import Optional
from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@ -8,14 +7,23 @@ from matemat.db.primitives import User
from matemat.util.currency_format import format_chf from matemat.util.currency_format import format_chf
@dataclass(frozen=True)
class Transaction: class Transaction:
"""
Representation of a generic transaction involving an user and an amount of money.
id: int :param _id: The transaction ID in the database.
user: User :param user: The user affected by this transaction.
value: int :param value: The monetary value of this transaction.
old_balance: int :param old_balance: The balance on the user's account before this transaction.
date: datetime :param date: The date of this transaction.
"""
def __init__(self, _id: int, user: User, value: int, old_balance: int, date: datetime) -> None:
self.id: int = _id
self.user: User = user
self.value: int = value
self.old_balance: int = old_balance
self.date: datetime = date
@property @property
def receipt_date(self) -> str: def receipt_date(self) -> str:
@ -37,30 +45,101 @@ class Transaction:
def receipt_message(self) -> Optional[str]: def receipt_message(self) -> Optional[str]:
return None return None
def __eq__(self, other) -> bool:
if not isinstance(other, Transaction):
return False
return self.id == other.id and \
self.user == other.user and \
self.value == other.value and \
self.old_balance == other.old_balance and \
self.date == other.date
def __hash__(self) -> int:
return hash((self.id, self.user, self.value, self.old_balance, self.date))
@dataclass(frozen=True)
class Consumption(Transaction): class Consumption(Transaction):
"""
Representation of a consumption involving an user, a product and an amount of money.
product: str :param _id: The transaction ID in the database.
:param user: The user affected by this transaction.
:param value: The (negative) price of the product that was bought.
:param old_balance: The balance on the user's account before this transaction.
:param date: The date of this transaction.
:param product: The name of the product that was bought.
"""
def __init__(self, _id: int, user: User, value: int, old_balance: int, date: datetime, product: str) -> None:
super().__init__(_id, user, value, old_balance, date)
self.product: str = product
@property @property
def receipt_description(self) -> str: def receipt_description(self) -> str:
return self.product return self.product
def __eq__(self, other) -> bool:
if not isinstance(other, Consumption):
return False
return super().__eq__(other) and \
self.product == other.product
def __hash__(self) -> int:
return hash((super().__hash__(), self.product))
@dataclass(frozen=True)
class Deposit(Transaction): class Deposit(Transaction):
"""
Representation of a deposit involving an user and an amount of money.
:param _id: The transaction ID in the database.
:param user: The user affected by this transaction.
:param value: The amount of money that was deposited on the account.
:param old_balance: The balance on the user's account before this transaction.
:param date: The date of this transaction.
"""
def __init__(self, _id: int, user: User, value: int, old_balance: int, date: datetime) -> None:
super().__init__(_id, user, value, old_balance, date)
@property @property
def receipt_description(self) -> str: def receipt_description(self) -> str:
return 'Deposit' return 'Deposit'
def __eq__(self, other) -> bool:
if not isinstance(other, Deposit):
return False
return super().__eq__(other)
def __hash__(self) -> int:
return super().__hash__()
@dataclass(frozen=True)
class Modification(Transaction): class Modification(Transaction):
"""
Representation of a administrative account balance modification. Involves the affected user, the agent that
performed the modification and optionally a message for the reason of the modification.
agent: str :param _id: The transaction ID in the database.
reason: Optional[str] :param user: The user affected by this transaction.
:param value: The amount of money that was deposited on the account.
:param old_balance: The balance on the user's account before this transaction.
:param date: The date of this transaction.
:param agent: The username of the agent performing the modification.
:param reason: The reason for this modification, as provided by the agent.
"""
def __init__(self,
_id: int,
user: User,
value: int,
old_balance: int,
date: datetime,
agent: str,
reason: Optional[str]) -> None:
super().__init__(_id, user, value, old_balance, date)
self.agent: str = agent
self.reason: Optional[str] = reason
@property @property
def receipt_description(self) -> str: def receipt_description(self) -> str:
@ -72,3 +151,13 @@ class Modification(Transaction):
return None return None
else: else:
return f'Reason: «{self.reason}»' return f'Reason: «{self.reason}»'
def __eq__(self, other) -> bool:
if not isinstance(other, Modification):
return False
return super().__eq__(other) and \
self.agent == other.agent and \
self.reason == other.reason
def __hash__(self) -> int:
return hash((super().__hash__(), self.agent, self.reason))

View file

@ -1,30 +1,50 @@
from typing import Optional from typing import Optional
from dataclasses import dataclass
from matemat.db.primitives.ReceiptPreference import ReceiptPreference from matemat.db.primitives.ReceiptPreference import ReceiptPreference
@dataclass
class User: class User:
""" """
Representation of a user registered with the Matemat, with a name, e-mail address (optional), whether the user is a Representation of a user registered with the Matemat, with a name, e-mail address (optional), whether the user is a
member of the organization the Matemat instance is used in, whether the user is an administrator, and the user's member of the organization the Matemat instance is used in, whether the user is an administrator, and the user's
account balance. account balance.
:param id: The user ID in the database. :param _id: The user ID in the database.
:param username: The user's name. :param name: The user's name.
:param balance: The balance of the user's account. :param balance: The balance of the user's account.
:param email: The user's e-mail address (optional). :param email: The user's e-mail address (optional).
:param admin: Whether the user is an administrator. :param is_admin: Whether the user is an administrator.
:param member: Whether the user is a member. :param is_member: Whether the user is a member.
:param receipt_pref: The user's preference on how often to receive transaction receipts. :param receipt_pref: The user's preference on how often to receive transaction receipts.
""" """
id: int def __init__(self,
name: str _id: int,
balance: int name: str,
email: Optional[str] = None balance: int,
is_admin: bool = False email: Optional[str] = None,
is_member: bool = False is_admin: bool = False,
receipt_pref: ReceiptPreference = ReceiptPreference.NONE is_member: bool = False,
receipt_pref: ReceiptPreference = ReceiptPreference.NONE) -> None:
self.id: int = _id
self.name: str = name
self.balance: int = balance
self.email: Optional[str] = email
self.is_admin: bool = is_admin
self.is_member: bool = is_member
self.receipt_pref: ReceiptPreference = receipt_pref
def __eq__(self, other) -> bool:
if not isinstance(other, User):
return False
return self.id == other.id and \
self.name == other.name and \
self.balance == other.balance and \
self.email == other.email and \
self.is_admin == other.is_admin and \
self.is_member == other.is_member and \
self.receipt_pref == other.receipt_pref
def __hash__(self) -> int:
return hash((self.id, self.name, self.balance, self.email, self.is_admin, self.is_member, self.receipt_pref))

View file

@ -1,5 +1,4 @@
from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from matemat.exceptions import DatabaseConsistencyError from matemat.exceptions import DatabaseConsistencyError
@ -47,7 +46,7 @@ class DatabaseWrapper(object):
self._filename: str = filename self._filename: str = filename
self._sqlite_db: Optional[sqlite3.Connection] = None self._sqlite_db: Optional[sqlite3.Connection] = None
def __enter__(self) -> DatabaseWrapper: def __enter__(self) -> 'DatabaseWrapper':
self.connect() self.connect()
return self return self

View file

@ -20,10 +20,13 @@ def add_months(d: datetime, months: int) -> datetime:
days: int = 0 days: int = 0
# Iterate the months between the passed date and the target month # Iterate the months between the passed date and the target month
for i in range(months): for i in range(months):
days += calendar.monthlen(*nextmonth) days += calendar.monthrange(*nextmonth)[1]
nextmonth = calendar.nextmonth(*nextmonth) if nextmonth[1] == 12:
nextmonth = nextmonth[0] + 1, 1
else:
nextmonth = nextmonth[0], nextmonth[1] + 1
# 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.monthlen(newdate.year, newdate.month))) newdate = newdate.replace(day=min(d.day, calendar.monthrange(newdate.year, newdate.month)[1]))
return newdate return newdate

View file

@ -1,5 +1,4 @@
from __future__ import annotations
from typing import Dict, Iterator, List, Tuple, Union from typing import Dict, Iterator, List, Tuple, Union
@ -25,7 +24,7 @@ class RequestArguments(object):
""" """
self.__container: Dict[str, RequestArgument] = dict() self.__container: Dict[str, RequestArgument] = dict()
def __getitem__(self, key: str) -> RequestArgument: def __getitem__(self, key: str) -> 'RequestArgument':
""" """
Retrieve the argument for the given name, creating it on the fly, if it doesn't exist. Retrieve the argument for the given name, creating it on the fly, if it doesn't exist.
@ -41,7 +40,7 @@ class RequestArguments(object):
# Return the argument for the name # Return the argument for the name
return self.__container[key] return self.__container[key]
def __getattr__(self, key: str) -> RequestArgument: def __getattr__(self, key: str) -> 'RequestArgument':
""" """
Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be Syntactic sugar for accessing values with a name that can be used in Python attributes. The value will be
returned as an immutable view. returned as an immutable view.
@ -279,7 +278,7 @@ class RequestArgument(object):
# Yield an immutable scalar view for each (ctype, value) element in the array # Yield an immutable scalar view for each (ctype, value) element in the array
yield _View(self.__name, v) yield _View(self.__name, v)
def __getitem__(self, index: Union[int, slice]) -> RequestArgument: def __getitem__(self, index: Union[int, slice]) -> 'RequestArgument':
""" """
Index the argument with either an int or a slice. The returned values are represented as immutable Index the argument with either an int or a slice. The returned values are represented as immutable
RequestArgument views. RequestArgument views.

View file

@ -0,0 +1,29 @@
# Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
pkgname=matemat
pkgver=0.1
pkgrel=1
arch=('any')
pkgdesc='A soda machine stock-keeping webservice'
url='https://gitlab.com/s3lph/matemat'
licence=('MIT')
depends=(
'python'
'python-jinja'
'python-pillow'
'python-magic'
'file'
)
backup=(
'etc/matemat.conf'
)
install=$pkgname.install
package() {
cp -r ../matemat/* ../pkg/matemat/
}

View file

@ -0,0 +1,31 @@
post_install() {
if ! getent group matemat >/dev/null; then
groupadd --system matemat
fi
if ! getent passwd matemat >/dev/null; then
useradd --system --create-home --gid matemat --home-dir /var/lib/matemat --shell /usr/bin/nologin matemat
fi
chown matemat:matemat /var/lib/matemat
chmod 0750 /var/lib/matemat
ln -sf /var/lib/matemat/upload /usr/lib/matemat/static/upload
systemctl daemon-reload
}
pre_remove() {
systemctl stop matemat.service
userdel matemat
}
post_remove() {
systemctl daemon-reload
}

View file

@ -0,0 +1,32 @@
[Matemat]
# The IP address to listen on
Address=::
# The TCP port to listen on
Port=80
# The log level, one of NONE, DEBUG, INFO, WARNING, ERROR, CRITICAL
LogLevel=DEBUG
[Pagelets]
# Name of the Matemat instance, shown in the web app
InstanceName=Matemat
#
# Configure SMTP credentials
#
# SmtpFrom=matemat@example.com
# SmtpSubj=Matemat Receipt
# SmtpHost=exmaple.com
# SmtpPort=587
# SmtpUser=matemat@example.com
# SmtpPass=supersecurepassword
#
# Enable to allow users to receive receipts via email
#
# SmtpSendReceipts=1
# Add static HTTP headers in this section
# [HttpHeaders]

View file

@ -0,0 +1,11 @@
[Matemat]
StaticPath=/usr/lib/matemat/static
TemplatePath=/usr/lib/matemat/templates
LogTarget=stdout
[Pagelets]
UploadDir=/var/lib/matemat/upload
DatabaseFile=/var/lib/matemat/matemat.db

View file

@ -0,0 +1,12 @@
[Unit]
Description=matemat
After=networking.target
[Service]
ExecStart=/usr/bin/python -m matemat /etc/matemat.conf /usr/lib/matemat/matemat.conf
User=matemat
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1 @@
/etc/matemat.conf

View file

@ -0,0 +1,11 @@
Package: matemat
Version: 0.1
Maintainer: s3lph <account-gitlab-ideynizv@kernelpanic.lol>
Section: web
Priority: optional
Architecture: all
Depends: python3 (>= 3.6), python3-jinja2, python3-magic, python3-pil
Description: Soda machine stock-keeping webservice
A web service for automated stock-keeping of a soda machine written in Python.
It provides a touch-input-friendly user interface (as most input happens
through the soda machine's touch screen).

View file

@ -0,0 +1,21 @@
#!/bin/bash
set -e
if [[ "$1" == "configure" ]]; then
if ! getent group matemat >/dev/null; then
groupadd --system matemat
fi
if ! getent passwd matemat >/dev/null; then
useradd --system --create-home --gid matemat --home-dir /var/lib/matemat --shell /usr/sbin/nologin matemat
fi
chown matemat:matemat /var/lib/matemat
chmod 0750 /var/lib/matemat
ln -sf /var/lib/matemat/upload /usr/lib/matemat/static/upload
systemctl daemon-reload || true
fi

View file

@ -0,0 +1,9 @@
#!/bin/bash
set -e
if [[ "$1" == "remove" ]]; then
systemctl daemon-reload || true
fi

View file

@ -0,0 +1,9 @@
#!/bin/bash
set -e
if [[ "$1" == "remove" ]]; then
userdel matemat
fi

View file

@ -0,0 +1,32 @@
[Matemat]
# The IP address to listen on
Address=::
# The TCP port to listen on
Port=80
# The log level, one of NONE, DEBUG, INFO, WARNING, ERROR, CRITICAL
LogLevel=DEBUG
[Pagelets]
# Name of the Matemat instance, shown in the web app
InstanceName=Matemat
#
# Configure SMTP credentials
#
# SmtpFrom=matemat@example.com
# SmtpSubj=Matemat Receipt
# SmtpHost=exmaple.com
# SmtpPort=587
# SmtpUser=matemat@example.com
# SmtpPass=supersecurepassword
#
# Enable to allow users to receive receipts via email
#
# SmtpSendReceipts=1
# Add static HTTP headers in this section
# [HttpHeaders]

View file

@ -0,0 +1,12 @@
[Unit]
Description=matemat
After=networking.target
[Service]
ExecStart=/usr/bin/python3 -m matemat /etc/matemat.conf /usr/lib/matemat/matemat.conf
User=matemat
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,11 @@
[Matemat]
StaticPath=/usr/lib/matemat/static
TemplatePath=/usr/lib/matemat/templates
LogTarget=stdout
[Pagelets]
UploadDir=/var/lib/matemat/upload
DatabaseFile=/var/lib/matemat/matemat.db

View file

@ -0,0 +1,16 @@
Copyright 2018 s3lph
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View file

@ -4,9 +4,10 @@ FROM python:3.7-alpine
ADD . / ADD . /
RUN mkdir -p /var/matemat/db /var/matemat/upload \ RUN mkdir -p /var/matemat/db /var/matemat/upload \
&& apk --update add libmagic zlib jpeg zlib-dev jpeg-dev build-base \ && apk --update add libmagic zlib jpeg zlib-dev jpeg-dev build-base \
&& pip3 install -r /requirements.txt \ && pip3 install -e . \
&& apk del zlib-dev jpeg-dev build-base \ && apk del zlib-dev jpeg-dev build-base \
&& rm -rf /var/cache/apk /root/.cache/pip && rm -rf /var/cache/apk /root/.cache/pip \
&& rm -rf /package
EXPOSE 80/tcp EXPOSE 80/tcp
CMD [ "/run.sh" ] CMD [ "/run.sh" ]

168
package/release.py Executable file
View file

@ -0,0 +1,168 @@
#!/usr/bin/env python3
from typing import Any, Dict, List, Optional, Tuple
import os
import sys
import json
import urllib.request
import http.client
from urllib.error import HTTPError
def parse_changelog(tag: str) -> Optional[str]:
release_changelog: str = ''
with open('CHANGELOG.md', 'r') as f:
in_target: bool = False
done: bool = False
for line in f.readlines():
if in_target:
if f'<!-- END RELEASE {tag} -->' in line:
done = True
break
release_changelog += line
elif f'<!-- BEGIN RELEASE {tag} -->' in line:
in_target = True
continue
if not done:
return None
return release_changelog
def fetch_job_ids(project_id: int, pipeline_id: int, api_token: str) -> Dict[str, str]:
url: str = f'https://gitlab.com/api/v4/projects/{project_id}/pipelines/{pipeline_id}/jobs'
headers: Dict[str, str] = {
'Private-Token': api_token
}
req = urllib.request.Request(url, headers=headers)
try:
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
except HTTPError as e:
print(e.read().decode())
sys.exit(1)
resp_data: bytes = resp.read()
joblist: List[Dict[str, Any]] = json.loads(resp_data.decode())
jobidmap: Dict[str, str] = {}
for job in joblist:
name: str = job['name']
job_id: str = job['id']
jobidmap[name] = job_id
return jobidmap
def fetch_single_shafile(url: str) -> str:
req = urllib.request.Request(url)
try:
resp: http.client.HTTPResponse = urllib.request.urlopen(req)
except HTTPError as e:
print(e.read().decode())
sys.exit(1)
resp_data: bytes = resp.readline()
shafile: str = resp_data.decode()
filename: str = shafile.strip().split(' ')[-1].strip()
return filename
def fetch_wheel_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
mybase: str = f'{base_url}/jobs/{job_ids["build_wheel"]}/artifacts/raw'
wheel_sha_url: str = f'{mybase}/dist/SHA256SUMS'
wheel_filename: str = fetch_single_shafile(wheel_sha_url)
wheel_url: str = f'{mybase}/dist/{wheel_filename}'
return wheel_url, wheel_sha_url
def fetch_debian_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
mybase: str = f'{base_url}/jobs/{job_ids["build_debian"]}/artifacts/raw'
debian_sha_url: str = f'{mybase}/package/debian/SHA256SUMS'
debian_filename: str = fetch_single_shafile(debian_sha_url)
debian_url: str = f'{mybase}/package/debian/{debian_filename}'
return debian_url, debian_sha_url
def fetch_arch_url(base_url: str, job_ids: Dict[str, str]) -> Optional[Tuple[str, str]]:
mybase: str = f'{base_url}/jobs/{job_ids["build_archlinux"]}/artifacts/raw'
arch_sha_url: str = f'{mybase}/package/archlinux/SHA256SUMS'
arch_filename: str = fetch_single_shafile(arch_sha_url)
arch_url: str = f'{mybase}/package/archlinux/{arch_filename}'
return arch_url, arch_sha_url
def main():
api_token: Optional[str] = os.getenv('GITLAB_API_TOKEN')
release_tag: Optional[str] = os.getenv('CI_COMMIT_TAG')
project_name: Optional[str] = os.getenv('CI_PROJECT_PATH')
project_id: Optional[str] = os.getenv('CI_PROJECT_ID')
pipeline_id: Optional[str] = os.getenv('CI_PIPELINE_ID')
if api_token is None:
print('GITLAB_API_TOKEN is not set.', file=sys.stderr)
sys.exit(1)
if release_tag is None:
print('CI_COMMIT_TAG is not set.', file=sys.stderr)
sys.exit(1)
if project_name is None:
print('CI_PROJECT_PATH is not set.', file=sys.stderr)
sys.exit(1)
if project_id is None:
print('CI_PROJECT_ID is not set.', file=sys.stderr)
sys.exit(1)
if pipeline_id is None:
print('CI_PIPELINE_ID is not set.', file=sys.stderr)
sys.exit(1)
changelog: Optional[str] = parse_changelog(release_tag)
if changelog is None:
print('Changelog could not be parsed.', file=sys.stderr)
sys.exit(1)
job_ids: Dict[str, str] = fetch_job_ids(project_id, pipeline_id, api_token)
base_url: str = f'https://gitlab.com/{project_name}/-'
wheel_url, wheel_sha_url = fetch_wheel_url(base_url, job_ids)
debian_url, debian_sha_url = fetch_debian_url(base_url, job_ids)
arch_url, arch_sha_url = fetch_arch_url(base_url, job_ids)
augmented_changelog = f'''{changelog.strip()}
### Download
- [Python Wheel]({wheel_url}) ([sha256]({wheel_sha_url}))
- [Debian Package]({debian_url}) ([sha256]({debian_sha_url}))
- [Arch Linux Package]({arch_url}) ([sha256]({arch_sha_url}))
- Docker image: registry.gitlab.com/{project_name}:{release_tag}'''
post_body: str = json.dumps({'description': augmented_changelog})
gitlab_release_api_url: str = \
f'https://gitlab.com/api/v4/projects/{project_id}/repository/tags/{release_tag}/release'
headers: Dict[str, str] = {
'Private-Token': api_token,
'Content-Type': 'application/json; charset=utf-8'
}
request = urllib.request.Request(
gitlab_release_api_url,
post_body.encode('utf-8'),
headers=headers,
method='POST'
)
try:
response: http.client.HTTPResponse = urllib.request.urlopen(request)
except HTTPError as e:
print(e.read().decode())
sys.exit(1)
response_bytes: bytes = response.read()
response_str: str = response_bytes.decode()
response_data: Dict[str, Any] = json.loads(response_str)
if response_data['tag_name'] != release_tag:
print('Something went wrong...', file=sys.stderr)
print(response_str, file=sys.stderr)
sys.exit(1)
print(response_data['description'])
if __name__ == '__main__':
main()

View file

@ -1,3 +1 @@
file-magic .
jinja2
Pillow

View file

@ -1,12 +1,32 @@
from setuptools import setup from setuptools import setup, find_packages
from matemat import __version__
setup( setup(
name='matemat', name='matemat',
version='2.0', version=__version__,
packages=['matemat'], url='https://gitlab.com/s3lph/matemat',
url='', license='MIT',
license='',
author='s3lph', author='s3lph',
author_email='', author_email='',
description='Replacement for the original Ruby matemat software.' description='Soda machine stock-keeping webservice',
long_description='''
A web service for automated stock-keeping of a soda machine written in Python.
It provides a touch-input-friendly user interface (as most input happens through the
soda machine's touch screen).
''',
packages=find_packages(exclude=['*.test']),
python_requires='>=3.6',
install_requires=[
'file-magic',
'jinja2',
'Pillow'
],
test_loader='unittest:TestLoader',
entry_points={
'console_scripts': [
'matemat = matemat.__main__:main'
]
}
) )

View file

@ -1,6 +1,6 @@
# There is no buster image yet and stretch doesn't have a docker package. So let's just "upgrade" the image to buster. # There is no buster image yet and stretch doesn't have a docker package. So let's just "upgrade" the image to buster.
FROM python:3.7-stretch FROM python:3.6-stretch
RUN sed -re 's/stretch/buster/g' -i /etc/apt/sources.list \ RUN sed -re 's/stretch/buster/g' -i /etc/apt/sources.list \
&& useradd -d /home/matemat -m matemat \ && useradd -d /home/matemat -m matemat \
@ -8,8 +8,8 @@ RUN sed -re 's/stretch/buster/g' -i /etc/apt/sources.list \
&& chown matemat:matemat -R /var/matemat/db \ && chown matemat:matemat -R /var/matemat/db \
&& chown matemat:matemat -R /var/matemat/upload \ && chown matemat:matemat -R /var/matemat/upload \
&& apt-get update -qy \ && apt-get update -qy \
&& apt-get install -y --no-install-recommends file sudo openssh-client git docker.io build-essential \ && apt-get install -y --no-install-recommends file sudo openssh-client git docker.io build-essential lintian rsync \
&& python3.7 -m pip install coverage wheel pycodestyle mypy \ && python3.6 -m pip install coverage wheel pycodestyle mypy \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /home/matemat WORKDIR /home/matemat

View file

@ -1,147 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style>
svg {
width: 600px;
height: auto;
}
</style>
</head>
<body>
<h1>Welcome, {{username}}</h1>
<svg id="svg" width="400" height="400">
<circle class="c" id="0" cx="12.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="1" cx="37.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="2" cx="62.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="3" cx="87.5%" cy="12.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="4" cx="12.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="5" cx="37.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="6" cx="62.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="7" cx="87.5%" cy="37.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="8" cx="12.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="9" cx="37.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="a" cx="62.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="b" cx="87.5%" cy="62.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="c" cx="12.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="d" cx="37.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="e" cx="62.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
<circle class="c" id="f" cx="87.5%" cy="87.5%" r="10%" stroke="grey" stroke-width="2%" fill="white" />
</svg>
<script>
HTMLCollection.prototype.forEach = Array.prototype.forEach;
HTMLCollection.prototype.slice = Array.prototype.slice;
mouseDown = false;
currentStroke = null;
strokeId = 0;
doneMap = {};
enteredKey = '';
svg = document.getElementById('svg');
drawLine = (fromX, fromY, toX, toY) => {
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
var id = 'l-' + (strokeId++);
var idAttr = document.createAttribute('id');
var classAttr = document.createAttribute('class');
var x1attr = document.createAttribute('x1');
var y1attr = document.createAttribute('y1');
var x2attr = document.createAttribute('x2');
var y2attr = document.createAttribute('y2');
var styleAttr = document.createAttribute('style');
idAttr.value = id;
classAttr.value = 'l';
x1attr.value = fromX;
y1attr.value = fromY;
x2attr.value = toX;
y2attr.value = toY;
styleAttr.value = 'stroke: grey; stroke-width: 5%; stroke-linecap: round';
line.setAttributeNode(idAttr);
line.setAttributeNode(classAttr);
line.setAttributeNode(x1attr);
line.setAttributeNode(y1attr);
line.setAttributeNode(x2attr);
line.setAttributeNode(y2attr);
line.setAttributeNode(styleAttr);
svg.appendChild(line);
return id;
};
svg.onmousedown = (ev) => {
var svgrect = svg.getBoundingClientRect();
var minId = '';
var minDist = Infinity;
var minx = 0;
var miny = 0;
doneMap = {};
document.getElementsByClassName('c').forEach((circle) => {
var x = parseFloat(circle.getAttribute('cx'))/100.0 * svgrect.width;
var y = parseFloat(circle.getAttribute('cy'))/100.0 * svgrect.height;
var dist = Math.pow(ev.offsetX - x, 2) + Math.pow(ev.offsetY - y, 2);
if (dist < minDist) {
minDist = dist;
minId = circle.id;
minx = x;
miny = y;
}
});
var minNode = svg.getElementById(minId);
currentStroke = drawLine(minx, miny, ev.offsetX, ev.offsetY);
doneMap[minId] = 1;
enteredKey += minId;
};
svg.onmouseup = (ev) => {
currentStroke = null;
doneMap = {};
console.log(enteredKey);
enteredKey = '';
svg.getElementsByClassName('l').slice().reverse().forEach((line) => {
svg.removeChild(line);
});
};
svg.onmousemove = (ev) => {
if (currentStroke != null) {
var svgrect = svg.getBoundingClientRect();
var minId = '';
var minDist = Infinity;
var minx = 0;
var miny = 0;
document.getElementsByClassName('c').forEach((circle) => {
var x = parseFloat(circle.getAttribute('cx'))/100.0 * svgrect.width;
var y = parseFloat(circle.getAttribute('cy'))/100.0 * svgrect.height;
var dist = Math.pow(ev.offsetX - x, 2) + Math.pow(ev.offsetY - y, 2);
if (dist < minDist) {
minDist = dist;
minId = circle.id;
minx = x;
miny = y;
}
});
if (minDist < 2000 && !(minId in doneMap)) {
var line = svg.getElementById(currentStroke);
line.setAttribute('x2', minx);
line.setAttribute('y2', miny);
currentStroke = drawLine(minx, miny, ev.offsetX, ev.offsetY);
doneMap[minId] = 1;
enteredKey += minId;
}
var line = svg.getElementById(currentStroke);
line.setAttribute('x2', ev.offsetX);
line.setAttribute('y2', ev.offsetY);
}
};
</script>
</body>
</html>