Merge branch 'staging-currency-format' into 'staging-unstable'
Currency formatting (#16) See merge request s3lph/matemat!20
This commit is contained in:
commit
07fa842b6b
12 changed files with 207 additions and 13 deletions
2
doc
2
doc
|
@ -1 +1 @@
|
||||||
Subproject commit 92e168a288b396a7a97d200b80affdf8690bd03d
|
Subproject commit 5335524d3e57c7551f31c7e21fc04c464b23429a
|
0
matemat/util/__init__.py
Normal file
0
matemat/util/__init__.py
Normal file
56
matemat/util/currency_format.py
Normal file
56
matemat/util/currency_format.py
Normal file
|
@ -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
|
0
matemat/util/test/__init__.py
Normal file
0
matemat/util/test/__init__.py
Normal file
133
matemat/util/test/test_currency_format.py
Normal file
133
matemat/util/test/test_currency_format.py
Normal file
|
@ -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')
|
|
@ -18,6 +18,7 @@ from matemat import __version__ as matemat_version
|
||||||
from matemat.exceptions import HttpException
|
from matemat.exceptions import HttpException
|
||||||
from matemat.webserver import RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
|
from matemat.webserver import RequestArguments, PageletResponse, RedirectResponse, TemplateResponse
|
||||||
from matemat.webserver.util import parse_args
|
from matemat.webserver.util import parse_args
|
||||||
|
from matemat.util.currency_format import format_chf
|
||||||
|
|
||||||
#
|
#
|
||||||
# Python internal class hacks
|
# Python internal class hacks
|
||||||
|
@ -116,8 +117,10 @@ class MatematHTTPServer(HTTPServer):
|
||||||
self.pagelet_variables = pagelet_variables
|
self.pagelet_variables = pagelet_variables
|
||||||
# Set up the Jinja2 environment
|
# Set up the Jinja2 environment
|
||||||
self.jinja_env: jinja2.Environment = 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
|
# Set up logger
|
||||||
self.logger: logging.Logger = logging.getLogger('matemat.webserver')
|
self.logger: logging.Logger = logging.getLogger('matemat.webserver')
|
||||||
self.logger.setLevel(log_level)
|
self.logger.setLevel(log_level)
|
||||||
|
|
|
@ -8,6 +8,7 @@ from matemat.webserver import pagelet, RequestArguments, PageletResponse, Redire
|
||||||
from matemat.db import MatematDatabase
|
from matemat.db import MatematDatabase
|
||||||
from matemat.primitives import Product
|
from matemat.primitives import Product
|
||||||
from matemat.exceptions import DatabaseConsistencyError, HttpException
|
from matemat.exceptions import DatabaseConsistencyError, HttpException
|
||||||
|
from matemat.util.currency_format import parse_chf
|
||||||
|
|
||||||
|
|
||||||
@pagelet('/modproduct')
|
@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:
|
if 'name' not in args or 'pricemember' not in args or 'pricenonmember' not in args or 'stock' not in args:
|
||||||
return
|
return
|
||||||
name = str(args.name)
|
name = str(args.name)
|
||||||
price_member = int(str(args.pricemember))
|
price_member = parse_chf(str(args.pricemember))
|
||||||
price_non_member = int(str(args.pricenonmember))
|
price_non_member = parse_chf(str(args.pricenonmember))
|
||||||
stock = int(str(args.stock))
|
stock = int(str(args.stock))
|
||||||
try:
|
try:
|
||||||
db.change_product(product,
|
db.change_product(product,
|
||||||
|
|
|
@ -8,6 +8,7 @@ from matemat.webserver import pagelet, RequestArguments, PageletResponse, Redire
|
||||||
from matemat.db import MatematDatabase
|
from matemat.db import MatematDatabase
|
||||||
from matemat.primitives import User
|
from matemat.primitives import User
|
||||||
from matemat.exceptions import DatabaseConsistencyError, HttpException
|
from matemat.exceptions import DatabaseConsistencyError, HttpException
|
||||||
|
from matemat.util.currency_format import parse_chf
|
||||||
|
|
||||||
|
|
||||||
@pagelet('/moduser')
|
@pagelet('/moduser')
|
||||||
|
@ -62,7 +63,7 @@ def handle_change(args: RequestArguments, user: User, db: MatematDatabase, confi
|
||||||
username = str(args.username)
|
username = str(args.username)
|
||||||
email = str(args.email)
|
email = str(args.email)
|
||||||
password = str(args.password)
|
password = str(args.password)
|
||||||
balance = int(str(args.balance))
|
balance = parse_chf(str(args.balance))
|
||||||
is_member = 'ismember' in args
|
is_member = 'ismember' in args
|
||||||
is_admin = 'isadmin' in args
|
is_admin = 'isadmin' in args
|
||||||
if len(email) == 0:
|
if len(email) == 0:
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
{% block head %}
|
{% block head %}
|
||||||
<title>{{ setupname }}</title>
|
<title>{{ setupname|safe }}</title>
|
||||||
<link rel="stylesheet" href="/css/matemat.css" />
|
<link rel="stylesheet" href="/css/matemat.css" />
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
@ -28,7 +28,7 @@
|
||||||
<footer>
|
<footer>
|
||||||
{% block footer %}
|
{% block footer %}
|
||||||
<ul>
|
<ul>
|
||||||
<li> {{ setupname }}
|
<li> {{ setupname|safe }}
|
||||||
<li> Matemat {{__version__}}
|
<li> Matemat {{__version__}}
|
||||||
<li> © 2018 s3lph
|
<li> © 2018 s3lph
|
||||||
<li> MIT License
|
<li> MIT License
|
||||||
|
|
|
@ -15,10 +15,10 @@
|
||||||
<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-price-member">Member price: </label>
|
<label for="modproduct-price-member">Member price: </label>
|
||||||
<input id="modproduct-price-member" type="text" name="pricemember" value="{{ product.price_member }}" /><br/>
|
CHF <input id="modproduct-price-member" type="number" step="0.01" name="pricemember" value="{{ product.price_member|chf(False) }}" /><br/>
|
||||||
|
|
||||||
<label for="modproduct-price-non-member">Non-member price: </label>
|
<label for="modproduct-price-non-member">Non-member price: </label>
|
||||||
<input id="modproduct-price-non-member" type="text" name="pricenonmember" value="{{ product.price_non_member }}" /><br/>
|
CHF <input id="modproduct-price-non-member" type="number" step="0.01" name="pricenonmember" value="{{ product.price_non_member|chf(False) }}" /><br/>
|
||||||
|
|
||||||
<label for="modproduct-balance">Stock: </label>
|
<label for="modproduct-balance">Stock: </label>
|
||||||
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
|
<input id="modproduct-balance" name="stock" type="text" value="{{ product.stock }}" /><br/>
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
<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="moduser-account-balance">Balance: </label>
|
<label for="moduser-account-balance">Balance: </label>
|
||||||
<input id="moduser-account-balance" name="balance" type="text" value="{{ user.balance }}" /><br/>
|
CHF <input id="moduser-account-balance" name="balance" type="number" step="0.01" value="{{ user.balance|chf(False) }}" /><br/>
|
||||||
|
|
||||||
<label for="moduser-account-avatar">
|
<label for="moduser-account-avatar">
|
||||||
<img height="150" src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
|
<img height="150" src="/upload/thumbnails/users/{{ user.id }}.png" alt="Avatar of {{ user.name }}" />
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|
||||||
Your balance: {{ authuser.balance }}
|
Your balance: {{ authuser.balance|chf }}
|
||||||
<br/>
|
<br/>
|
||||||
<a href="/deposit?n=100">Deposit CHF 1</a><br/>
|
<a href="/deposit?n=100">Deposit CHF 1</a><br/>
|
||||||
<a href="/deposit?n=1000">Deposit CHF 10</a><br/>
|
<a href="/deposit?n=1000">Deposit CHF 10</a><br/>
|
||||||
|
@ -20,9 +20,9 @@ Your balance: {{ authuser.balance }}
|
||||||
<span class="thumblist-title">{{ product.name }}</span>
|
<span class="thumblist-title">{{ product.name }}</span>
|
||||||
<span class="thumblist-detail">Price:
|
<span class="thumblist-detail">Price:
|
||||||
{% if authuser.is_member %}
|
{% if authuser.is_member %}
|
||||||
{{ product.price_member }}
|
{{ product.price_member|chf }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ product.price_non_member }}
|
{{ product.price_non_member|chf }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
; Stock: {{ product.stock }}</span><br/>
|
; Stock: {{ product.stock }}</span><br/>
|
||||||
<div class="imgcontainer">
|
<div class="imgcontainer">
|
||||||
|
|
Loading…
Reference in a new issue