diff --git a/doc b/doc index 92e168a..5335524 160000 --- a/doc +++ b/doc @@ -1 +1 @@ -Subproject commit 92e168a288b396a7a97d200b80affdf8690bd03d +Subproject commit 5335524d3e57c7551f31c7e21fc04c464b23429a diff --git a/matemat/util/__init__.py b/matemat/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matemat/util/currency_format.py b/matemat/util/currency_format.py new file mode 100644 index 0000000..a163238 --- /dev/null +++ b/matemat/util/currency_format.py @@ -0,0 +1,56 @@ + + +def format_chf(value: int, with_currencysign: bool = True) -> str: + """ + Formats a centime value into a commonly understood representation ("CHF -13.37"). + + :param value: The value to format, in centimes. + :param with_currencysign: Whether to include the currency prefix ("CHF ") in the output. + :return: A human-readable string representation. + """ + sign: str = '' + if value < 0: + # As // and % round towards -Inf, convert into a positive value and prepend the negative sign + sign = '-' + value = -value + # Split into full francs and fractions (centimes) + full: int = value // 100 + frac: int = value % 100 + csign: str = 'CHF ' if with_currencysign else '' + # Put it all together; centimes are always padded with 2 zeros + return f'{csign}{sign}{full}.{frac:02}' + + +def parse_chf(value: str) -> int: + """ + Parse a currency value into machine-readable format (integer centimes). The prefix "CHF", the decimal point, and + digits after the decimal point are optional. + + :param value: The value to parse. + :return: An integer representation of the value. + :raises: Value error: If more than two digits after the decimal point are present. + """ + # Remove optional leading "CHF" and strip whitespace + value = value.strip() + if value.startswith('CHF'): + value = value[3:] + value = value.strip() + if '.' not in value: + # already is an integer; parse and turn into centimes + return int(value, 10) * 100 + # Split at the decimal point + full, frac = value.split('.', 1) + if len(frac) > 2: + raise ValueError('Needs max. 2 digits after decimal point') + elif len(frac) < 2: + # Right-pad fraction with zeros ("x." -> "x.00", "x.x" -> "x.x0") + frac = frac + '0' * (2 - len(frac)) + # Parse both parts + ifrac: int = int(frac, 10) + ifull: int = int(full, 10) + if ifrac < 0: + raise ValueError('Fraction part must not be negative.') + if full.startswith('-'): + ifrac = -ifrac + # Combine into centime integer + return ifull * 100 + ifrac diff --git a/matemat/util/test/__init__.py b/matemat/util/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/matemat/util/test/test_currency_format.py b/matemat/util/test/test_currency_format.py new file mode 100644 index 0000000..882ddac --- /dev/null +++ b/matemat/util/test/test_currency_format.py @@ -0,0 +1,133 @@ + +import unittest + +from matemat.util.currency_format import format_chf, parse_chf + + +class TestCurrencyFormat(unittest.TestCase): + + def test_format_zero(self): + self.assertEqual('CHF 0.00', format_chf(0)) + self.assertEqual('0.00', format_chf(0, False)) + + def test_format_positive_full(self): + self.assertEqual('CHF 42.00', format_chf(4200)) + self.assertEqual('42.00', format_chf(4200, False)) + + def test_format_negative_full(self): + self.assertEqual('CHF -42.00', format_chf(-4200)) + self.assertEqual('-42.00', format_chf(-4200, False)) + + def test_format_positive_frac(self): + self.assertEqual('CHF 13.37', format_chf(1337)) + self.assertEqual('13.37', format_chf(1337, False)) + + def test_format_negative_frac(self): + self.assertEqual('CHF -13.37', format_chf(-1337)) + self.assertEqual('-13.37', format_chf(-1337, False)) + + def test_format_pad_left_positive(self): + self.assertEqual('CHF 0.01', format_chf(1)) + self.assertEqual('0.01', format_chf(1, False)) + + def test_format_pad_left_negative(self): + self.assertEqual('CHF -0.01', format_chf(-1)) + self.assertEqual('-0.01', format_chf(-1, False)) + + def test_format_pad_right_positive(self): + self.assertEqual('CHF 4.20', format_chf(420)) + self.assertEqual('4.20', format_chf(420, False)) + + def test_format_pad_right_negative(self): + self.assertEqual('CHF -4.20', format_chf(-420)) + self.assertEqual('-4.20', format_chf(-420, False)) + + def test_parse_empty(self): + with self.assertRaises(ValueError): + parse_chf('') + with self.assertRaises(ValueError): + parse_chf('CHF') + with self.assertRaises(ValueError): + parse_chf('CHF ') + + def test_parse_zero(self): + self.assertEqual(0, parse_chf('CHF0')) + self.assertEqual(0, parse_chf('CHF 0')) + self.assertEqual(0, parse_chf('CHF -0')) + self.assertEqual(0, parse_chf('CHF 0.')) + self.assertEqual(0, parse_chf('CHF 0.0')) + self.assertEqual(0, parse_chf('CHF 0.00')) + self.assertEqual(0, parse_chf('CHF -0.')) + self.assertEqual(0, parse_chf('CHF -0.0')) + self.assertEqual(0, parse_chf('CHF -0.00')) + self.assertEqual(0, parse_chf('0')) + self.assertEqual(0, parse_chf('0')) + self.assertEqual(0, parse_chf('-0')) + self.assertEqual(0, parse_chf('0.')) + self.assertEqual(0, parse_chf('0.0')) + self.assertEqual(0, parse_chf('0.00')) + self.assertEqual(0, parse_chf('-0.')) + self.assertEqual(0, parse_chf('-0.0')) + self.assertEqual(0, parse_chf('-0.00')) + + def test_parse_positive_full(self): + self.assertEqual(4200, parse_chf('CHF 42.00')) + self.assertEqual(4200, parse_chf('42.00')) + self.assertEqual(4200, parse_chf('CHF 42')) + self.assertEqual(4200, parse_chf('42')) + self.assertEqual(4200, parse_chf('CHF 42.')) + self.assertEqual(4200, parse_chf('42.')) + self.assertEqual(4200, parse_chf('CHF 42.0')) + self.assertEqual(4200, parse_chf('42.0')) + + def test_parse_negative_full(self): + self.assertEqual(-4200, parse_chf('CHF -42.00')) + self.assertEqual(-4200, parse_chf('-42.00')) + self.assertEqual(-4200, parse_chf('CHF -42')) + self.assertEqual(-4200, parse_chf('-42')) + self.assertEqual(-4200, parse_chf('CHF -42.')) + self.assertEqual(-4200, parse_chf('-42.')) + self.assertEqual(-4200, parse_chf('CHF -42.0')) + self.assertEqual(-4200, parse_chf('-42.0')) + + def test_parse_positive_frac(self): + self.assertEqual(1337, parse_chf('CHF 13.37')) + self.assertEqual(1337, parse_chf('13.37')) + + def test_parse_negative_frac(self): + self.assertEqual(-1337, parse_chf('CHF -13.37')) + self.assertEqual(-1337, parse_chf('-13.37')) + + def test_parse_pad_left_positive(self): + self.assertEqual(1, parse_chf('CHF 0.01')) + self.assertEqual(1, parse_chf('0.01')) + + def test_parse_pad_left_negative(self): + self.assertEqual(-1, parse_chf('CHF -0.01')) + self.assertEqual(-1, parse_chf('-0.01')) + + def test_parse_pad_right_positive(self): + self.assertEqual(420, parse_chf('CHF 4.20')) + self.assertEqual(420, parse_chf('4.20')) + self.assertEqual(420, parse_chf('CHF 4.2')) + self.assertEqual(420, parse_chf('4.2')) + + def test_parse_pad_right_negative(self): + self.assertEqual(-420, parse_chf('CHF -4.20')) + self.assertEqual(-420, parse_chf('-4.20')) + self.assertEqual(-420, parse_chf('CHF -4.2')) + self.assertEqual(-420, parse_chf('-4.2')) + + def test_parse_too_many_decimals(self): + with self.assertRaises(ValueError): + parse_chf('123.456') + with self.assertRaises(ValueError): + parse_chf('CHF 0.456') + with self.assertRaises(ValueError): + parse_chf('CHF 0.450') + + def test_parse_wrong_separator(self): + with self.assertRaises(ValueError): + parse_chf('13,37') + with self.assertRaises(ValueError): + parse_chf('CHF 13,37') diff --git a/matemat/webserver/httpd.py b/matemat/webserver/httpd.py index e2bde4f..e69c859 100644 --- a/matemat/webserver/httpd.py +++ b/matemat/webserver/httpd.py @@ -18,6 +18,7 @@ from matemat import __version__ as matemat_version from matemat.exceptions import HttpException from matemat.webserver import RequestArguments, PageletResponse, RedirectResponse, TemplateResponse from matemat.webserver.util import parse_args +from matemat.util.currency_format import format_chf # # Python internal class hacks @@ -116,8 +117,10 @@ class MatematHTTPServer(HTTPServer): self.pagelet_variables = pagelet_variables # Set up the Jinja2 environment self.jinja_env: jinja2.Environment = jinja2.Environment( - loader=jinja2.FileSystemLoader(os.path.abspath(templateroot)) + loader=jinja2.FileSystemLoader(os.path.abspath(templateroot)), + autoescape=jinja2.select_autoescape(default=True), ) + self.jinja_env.filters['chf'] = format_chf # Set up logger self.logger: logging.Logger = logging.getLogger('matemat.webserver') self.logger.setLevel(log_level) diff --git a/matemat/webserver/pagelets/modproduct.py b/matemat/webserver/pagelets/modproduct.py index 3da8ff9..51da39f 100644 --- a/matemat/webserver/pagelets/modproduct.py +++ b/matemat/webserver/pagelets/modproduct.py @@ -8,6 +8,7 @@ from matemat.webserver import pagelet, RequestArguments, PageletResponse, Redire from matemat.db import MatematDatabase from matemat.primitives import Product from matemat.exceptions import DatabaseConsistencyError, HttpException +from matemat.util.currency_format import parse_chf @pagelet('/modproduct') @@ -60,8 +61,8 @@ def handle_change(args: RequestArguments, product: Product, db: MatematDatabase, if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args or 'stock' not in args: return name = str(args.name) - price_member = int(str(args.pricemember)) - price_non_member = int(str(args.pricenonmember)) + price_member = parse_chf(str(args.pricemember)) + price_non_member = parse_chf(str(args.pricenonmember)) stock = int(str(args.stock)) try: db.change_product(product, diff --git a/matemat/webserver/pagelets/moduser.py b/matemat/webserver/pagelets/moduser.py index 301ddde..c51a05d 100644 --- a/matemat/webserver/pagelets/moduser.py +++ b/matemat/webserver/pagelets/moduser.py @@ -8,6 +8,7 @@ from matemat.webserver import pagelet, RequestArguments, PageletResponse, Redire from matemat.db import MatematDatabase from matemat.primitives import User from matemat.exceptions import DatabaseConsistencyError, HttpException +from matemat.util.currency_format import parse_chf @pagelet('/moduser') @@ -62,7 +63,7 @@ def handle_change(args: RequestArguments, user: User, db: MatematDatabase, confi username = str(args.username) email = str(args.email) password = str(args.password) - balance = int(str(args.balance)) + balance = parse_chf(str(args.balance)) is_member = 'ismember' in args is_admin = 'isadmin' in args if len(email) == 0: diff --git a/templates/base.html b/templates/base.html index ea2432a..8a48454 100644 --- a/templates/base.html +++ b/templates/base.html @@ -2,7 +2,7 @@ {% block head %} - {{ setupname }} + {{ setupname|safe }} {% endblock %} @@ -28,7 +28,7 @@