Merge branch 'staging-currency-format' into 'staging-unstable'

Currency formatting (#16)

See merge request s3lph/matemat!20
This commit is contained in:
s3lph 2018-07-20 13:39:41 +00:00
commit 07fa842b6b
12 changed files with 207 additions and 13 deletions

2
doc

@ -1 +1 @@
Subproject commit 92e168a288b396a7a97d200b80affdf8690bd03d Subproject commit 5335524d3e57c7551f31c7e21fc04c464b23429a

0
matemat/util/__init__.py Normal file
View file

View 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

View file

View 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')

View file

@ -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)

View file

@ -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,

View file

@ -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:

View file

@ -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> &copy; 2018 s3lph <li> &copy; 2018 s3lph
<li> MIT License <li> MIT License

View file

@ -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/>

View file

@ -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 }}" />

View file

@ -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">