Improve currencies (#604)

- Rename "No Currency" to ISO4217 "XXX"
- Use Babel to render currency symbols and names in currency lists
- Improve i18n in bill lists

Fix #601
Fix #600
This commit is contained in:
Glandos 2020-05-07 22:56:17 +02:00 committed by GitHub
parent 76911983af
commit 981edd413a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 93 additions and 73 deletions

View file

@ -13,7 +13,7 @@ class Singleton(type):
class CurrencyConverter(object, metaclass=Singleton): class CurrencyConverter(object, metaclass=Singleton):
# Get exchange rates # Get exchange rates
default = "No Currency" no_currency = "XXX"
api_url = "https://api.exchangeratesapi.io/latest?base=USD" api_url = "https://api.exchangeratesapi.io/latest?base=USD"
def __init__(self): def __init__(self):
@ -22,19 +22,23 @@ class CurrencyConverter(object, metaclass=Singleton):
@cached(cache=TTLCache(maxsize=1, ttl=86400)) @cached(cache=TTLCache(maxsize=1, ttl=86400))
def get_rates(self): def get_rates(self):
rates = requests.get(self.api_url).json()["rates"] rates = requests.get(self.api_url).json()["rates"]
rates[self.default] = 1.0 rates[self.no_currency] = 1.0
return rates return rates
def get_currencies(self): def get_currencies(self, with_no_currency=True):
rates = [rate for rate in self.get_rates()] rates = [
rates.sort(key=lambda rate: "" if rate == self.default else rate) rate
for rate in self.get_rates()
if with_no_currency or rate != self.no_currency
]
rates.sort(key=lambda rate: "" if rate == self.no_currency else rate)
return rates return rates
def exchange_currency(self, amount, source_currency, dest_currency): def exchange_currency(self, amount, source_currency, dest_currency):
if ( if (
source_currency == dest_currency source_currency == dest_currency
or source_currency == self.default or source_currency == self.no_currency
or dest_currency == self.default or dest_currency == self.no_currency
): ):
return amount return amount

View file

@ -1,4 +1,3 @@
import copy
from datetime import datetime from datetime import datetime
from re import match from re import match
@ -23,7 +22,11 @@ from wtforms.validators import (
from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import LoggingMode, Person, Project from ihatemoney.models import LoggingMode, Person, Project
from ihatemoney.utils import eval_arithmetic_expression, slugify from ihatemoney.utils import (
eval_arithmetic_expression,
render_localized_currency,
slugify,
)
def strip_filter(string): def strip_filter(string):
@ -33,18 +36,6 @@ def strip_filter(string):
return string return string
def get_editprojectform_for(project, **kwargs):
"""Return an instance of EditProjectForm configured for a particular project.
"""
form = EditProjectForm(**kwargs)
choices = copy.copy(form.default_currency.choices)
choices.sort(
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
)
form.default_currency.choices = choices
return form
def get_billform_for(project, set_default=True, **kwargs): def get_billform_for(project, set_default=True, **kwargs):
"""Return an instance of BillForm configured for a particular project. """Return an instance of BillForm configured for a particular project.
@ -56,20 +47,15 @@ def get_billform_for(project, set_default=True, **kwargs):
if form.original_currency.data == "None": if form.original_currency.data == "None":
form.original_currency.data = project.default_currency form.original_currency.data = project.default_currency
if form.original_currency.data != CurrencyConverter.default: show_no_currency = form.original_currency.data == CurrencyConverter.no_currency
choices = copy.copy(form.original_currency.choices)
choices.remove((CurrencyConverter.default, CurrencyConverter.default))
choices.sort(
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
)
form.original_currency.choices = choices
else:
form.original_currency.render_kw = {"default": True}
form.original_currency.data = CurrencyConverter.default
form.original_currency.label = Label( form.original_currency.choices = [
"original_currency", "Currency (Default: %s)" % (project.default_currency) (currency_name, render_localized_currency(currency_name, detailed=False))
for currency_name in form.currency_helper.get_currencies(
with_no_currency=show_no_currency
) )
]
active_members = [(m.id, m.name) for m in project.active_members] active_members = [(m.id, m.name) for m in project.active_members]
form.payed_for.choices = form.payer.choices = active_members form.payed_for.choices = form.payer.choices = active_members
@ -121,14 +107,14 @@ class EditProjectForm(FlaskForm):
project_history = BooleanField(_("Enable project history")) project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history")) ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter() currency_helper = CurrencyConverter()
default_currency = SelectField( default_currency = SelectField(_("Default Currency"), validators=[DataRequired()],)
_("Default Currency"),
choices=[ def __init__(self, *args, **kwargs):
(currency_name, currency_name) super().__init__(*args, **kwargs)
for currency_name in currency_helper.get_currencies() self.default_currency.choices = [
], (currency_name, render_localized_currency(currency_name, detailed=True))
validators=[DataRequired()], for currency_name in self.currency_helper.get_currencies()
) ]
@property @property
def logging_preference(self): def logging_preference(self):
@ -242,14 +228,7 @@ class BillForm(FlaskForm):
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int) payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()]) amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()])
currency_helper = CurrencyConverter() currency_helper = CurrencyConverter()
original_currency = SelectField( original_currency = SelectField(_("Currency"), validators=[DataRequired()],)
_("Currency"),
choices=[
(currency_name, currency_name)
for currency_name in currency_helper.get_currencies()
],
validators=[DataRequired()],
)
external_link = URLField( external_link = URLField(
_("External link"), _("External link"),
validators=[Optional()], validators=[Optional()],
@ -281,14 +260,14 @@ class BillForm(FlaskForm):
bill.external_link = "" bill.external_link = ""
bill.date = self.date bill.date = self.date
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for] bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
bill.original_currency = CurrencyConverter.default bill.original_currency = CurrencyConverter.no_currency
bill.converted_amount = self.currency_helper.exchange_currency( bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency bill.amount, bill.original_currency, project.default_currency
) )
return bill return bill
def fill(self, bill): def fill(self, bill, project):
self.payer.data = bill.payer_id self.payer.data = bill.payer_id
self.amount.data = bill.amount self.amount.data = bill.amount
self.what.data = bill.what self.what.data = bill.what
@ -297,6 +276,14 @@ class BillForm(FlaskForm):
self.date.data = bill.date self.date.data = bill.date
self.payed_for.data = [int(ower.id) for ower in bill.owers] self.payed_for.data = [int(ower.id) for ower in bill.owers]
self.original_currency.label = Label("original_currency", _("Currency"))
self.original_currency.description = _(
"Project default: %(currency)s",
currency=render_localized_currency(
project.default_currency, detailed=False
),
)
def set_default(self): def set_default(self):
self.payed_for.data = self.payed_for.default self.payed_for.data = self.payed_for.default

View file

@ -23,7 +23,7 @@ def upgrade():
sa.Column( sa.Column(
"original_currency", "original_currency",
sa.String(length=3), sa.String(length=3),
server_default=CurrencyConverter.default, server_default=CurrencyConverter.no_currency,
nullable=True, nullable=True,
), ),
) )
@ -42,7 +42,7 @@ def upgrade():
sa.Column( sa.Column(
"default_currency", "default_currency",
sa.String(length=3), sa.String(length=3),
server_default=CurrencyConverter.default, server_default=CurrencyConverter.no_currency,
nullable=True, nullable=True,
), ),
) )

View file

@ -3,7 +3,7 @@ import os.path
import warnings import warnings
from flask import Flask, g, render_template, request, session from flask import Flask, g, render_template, request, session
from flask_babel import Babel from flask_babel import Babel, format_currency
from flask_mail import Mail from flask_mail import Mail
from flask_migrate import Migrate, stamp, upgrade from flask_migrate import Migrate, stamp, upgrade
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
@ -153,6 +153,22 @@ def create_app(
# Translations # Translations
babel = Babel(app) babel = Babel(app)
# Undocumented currencyformat filter from flask_babel is forwarding to Babel format_currency
# We overwrite it to remove the currency sign ¤ when there is no currency
def currencyformat_nc(number, currency, *args, **kwargs):
"""
Same as flask_babel.Babel.currencyformat, but without the "no currency ¤" sign
when there is no currency.
"""
return format_currency(
number,
currency if currency != CurrencyConverter.no_currency else "",
*args,
**kwargs
).strip()
app.jinja_env.filters["currencyformat_nc"] = currencyformat_nc
@babel.localeselector @babel.localeselector
def get_locale(): def get_locale():
# get the lang from the session if defined, fallback on the browser "accept # get the lang from the session if defined, fallback on the browser "accept

View file

@ -124,7 +124,7 @@
{{ input(form.what, inline=True) }} {{ input(form.what, inline=True) }}
{{ input(form.payer, inline=True, class="form-control custom-select") }} {{ input(form.payer, inline=True, class="form-control custom-select") }}
{{ input(form.amount, inline=True) }} {{ input(form.amount, inline=True) }}
{% if not form.original_currency.render_kw %} {% if g.project.default_currency != "XXX" %}
{{ input(form.original_currency, inline=True) }} {{ input(form.original_currency, inline=True) }}
{% endif %} {% endif %}
{{ input(form.external_link, inline=True) }} {{ input(form.external_link, inline=True) }}

View file

@ -1,5 +1,9 @@
{% extends "sidebar_table_layout.html" %} {% extends "sidebar_table_layout.html" %}
{%- macro bill_amount(bill, currency=bill.original_currency, amount=bill.amount) %}
{{ amount|currencyformat_nc(currency) }} ({{ _("%(amount)s each", amount=bill.pay_each_default(amount)|currencyformat_nc(currency)) }})
{% endmacro -%}
{% block title %} - {{ g.project.name }}{% endblock %} {% block title %} - {{ g.project.name }}{% endblock %}
{% block js %} {% block js %}
{% if add_bill %} $('#new-bill > a').click(); {% endif %} {% if add_bill %} $('#new-bill > a').click(); {% endif %}
@ -123,11 +127,6 @@
</th><th>{{ _("For what?") }} </th><th>{{ _("For what?") }}
</th><th>{{ _("For whom?") }} </th><th>{{ _("For whom?") }}
</th><th>{{ _("How much?") }} </th><th>{{ _("How much?") }}
{% if g.project.default_currency != "No Currency" %}
</th><th>{{ _("Amount in %(currency)s", currency=g.project.default_currency) }}
{%- else -%}
</th><th>{{ _("Amount") }}
{% endif %}
</th><th>{{ _("Actions") }}</th></tr> </th><th>{{ _("Actions") }}</th></tr>
</thead> </thead>
<tbody> <tbody>
@ -149,13 +148,11 @@
{{ bill.owers|join(', ', 'name') }} {{ bill.owers|join(', ', 'name') }}
{%- endif %}</td> {%- endif %}</td>
<td> <td>
{% if bill.original_currency != "No Currency" %} <span data-toggle="tooltip" data-placement="top"
{{ "%0.2f"|format(bill.amount) }} {{bill.original_currency}} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{bill.original_currency}} {{ _(" each") }}) title="{{ bill_amount(bill, g.project.default_currency, bill.converted_amount) if bill.original_currency != g.project.default_currency else '' }}">
{%- else -%} {{ bill_amount(bill) }}
{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{ _(" each") }}) </span>
{% endif %}
</td> </td>
<td>{{ "%0.2f"|format(bill.converted_amount) }}</td>
<td class="bill-actions"> <td class="bill-actions">
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a> <a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
<a class="delete" href="{{ url_for(".delete_bill", bill_id=bill.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a> <a class="delete" href="{{ url_for(".delete_bill", bill_id=bill.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a>

View file

@ -9,8 +9,9 @@ import os
import re import re
from babel import Locale from babel import Locale
from babel.numbers import get_currency_name, get_currency_symbol
from flask import current_app, redirect, render_template from flask import current_app, redirect, render_template
from flask_babel import get_locale from flask_babel import get_locale, lazy_gettext as _
import jinja2 import jinja2
from werkzeug.routing import HTTPException, RoutingException from werkzeug.routing import HTTPException, RoutingException
@ -281,6 +282,20 @@ class FormEnum(Enum):
return str(self.value) return str(self.value)
def render_localized_currency(code, detailed=True):
if code == "XXX":
return _("No Currency")
locale = get_locale() or "en_US"
symbol = get_currency_symbol(code, locale=locale)
details = ""
if detailed:
details = f" {get_currency_name(code, locale=locale)}"
if symbol == code:
return f"{code}{details}"
else:
return f"{code} {symbol}{details}"
def render_localized_template(template_name_prefix, **context): def render_localized_template(template_name_prefix, **context):
"""Like render_template(), but selects the right template according to the """Like render_template(), but selects the right template according to the
current user language. Fallback to English if a template for the current user language. Fallback to English if a template for the

View file

@ -41,6 +41,7 @@ from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.forms import ( from ihatemoney.forms import (
AdminAuthenticationForm, AdminAuthenticationForm,
AuthenticationForm, AuthenticationForm,
EditProjectForm,
InviteForm, InviteForm,
MemberForm, MemberForm,
PasswordReminder, PasswordReminder,
@ -48,7 +49,6 @@ from ihatemoney.forms import (
ResetPasswordForm, ResetPasswordForm,
UploadForm, UploadForm,
get_billform_for, get_billform_for,
get_editprojectform_for,
) )
from ihatemoney.history import get_history, get_history_queries from ihatemoney.history import get_history, get_history_queries
from ihatemoney.models import Bill, LoggingMode, Person, Project, db from ihatemoney.models import Bill, LoggingMode, Person, Project, db
@ -377,7 +377,7 @@ def reset_password():
@main.route("/<project_id>/edit", methods=["GET", "POST"]) @main.route("/<project_id>/edit", methods=["GET", "POST"])
def edit_project(): def edit_project():
edit_form = get_editprojectform_for(g.project) edit_form = EditProjectForm()
import_form = UploadForm() import_form = UploadForm()
# Import form # Import form
if import_form.validate_on_submit(): if import_form.validate_on_submit():
@ -393,10 +393,10 @@ def edit_project():
if edit_form.validate_on_submit(): if edit_form.validate_on_submit():
project = edit_form.update(g.project) project = edit_form.update(g.project)
# Update converted currency # Update converted currency
if project.default_currency != CurrencyConverter.default: if project.default_currency != CurrencyConverter.no_currency:
for bill in project.get_bills(): for bill in project.get_bills():
if bill.original_currency == CurrencyConverter.default: if bill.original_currency == CurrencyConverter.no_currency:
bill.original_currency = project.default_currency bill.original_currency = project.default_currency
bill.converted_amount = CurrencyConverter().exchange_currency( bill.converted_amount = CurrencyConverter().exchange_currency(
@ -417,6 +417,7 @@ def edit_project():
edit_form.ip_recording.data = True edit_form.ip_recording.data = True
edit_form.contact_email.data = g.project.contact_email edit_form.contact_email.data = g.project.contact_email
edit_form.default_currency.data = g.project.default_currency
return render_template( return render_template(
"edit_project.html", "edit_project.html",
@ -732,7 +733,7 @@ def edit_bill(bill_id):
return redirect(url_for(".list_bills")) return redirect(url_for(".list_bills"))
if not form.errors: if not form.errors:
form.fill(bill) form.fill(bill, g.project)
return render_template("add_bill.html", form=form, edit=True) return render_template("add_bill.html", form=form, edit=True)