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

View file

@ -1,4 +1,3 @@
import copy
from datetime import datetime
from re import match
@ -23,7 +22,11 @@ from wtforms.validators import (
from ihatemoney.currency_convertor import CurrencyConverter
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):
@ -33,18 +36,6 @@ def strip_filter(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):
"""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":
form.original_currency.data = project.default_currency
if form.original_currency.data != CurrencyConverter.default:
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
show_no_currency = form.original_currency.data == CurrencyConverter.no_currency
form.original_currency.choices = [
(currency_name, render_localized_currency(currency_name, detailed=False))
for currency_name in form.currency_helper.get_currencies(
with_no_currency=show_no_currency
)
]
form.original_currency.label = Label(
"original_currency", "Currency (Default: %s)" % (project.default_currency)
)
active_members = [(m.id, m.name) for m in project.active_members]
form.payed_for.choices = form.payer.choices = active_members
@ -121,14 +107,14 @@ class EditProjectForm(FlaskForm):
project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter()
default_currency = SelectField(
_("Default Currency"),
choices=[
(currency_name, currency_name)
for currency_name in currency_helper.get_currencies()
],
validators=[DataRequired()],
)
default_currency = SelectField(_("Default Currency"), validators=[DataRequired()],)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.default_currency.choices = [
(currency_name, render_localized_currency(currency_name, detailed=True))
for currency_name in self.currency_helper.get_currencies()
]
@property
def logging_preference(self):
@ -242,14 +228,7 @@ class BillForm(FlaskForm):
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()])
currency_helper = CurrencyConverter()
original_currency = SelectField(
_("Currency"),
choices=[
(currency_name, currency_name)
for currency_name in currency_helper.get_currencies()
],
validators=[DataRequired()],
)
original_currency = SelectField(_("Currency"), validators=[DataRequired()],)
external_link = URLField(
_("External link"),
validators=[Optional()],
@ -281,14 +260,14 @@ class BillForm(FlaskForm):
bill.external_link = ""
bill.date = self.date
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.amount, bill.original_currency, project.default_currency
)
return bill
def fill(self, bill):
def fill(self, bill, project):
self.payer.data = bill.payer_id
self.amount.data = bill.amount
self.what.data = bill.what
@ -297,6 +276,14 @@ class BillForm(FlaskForm):
self.date.data = bill.date
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):
self.payed_for.data = self.payed_for.default

View file

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

View file

@ -3,7 +3,7 @@ import os.path
import warnings
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_migrate import Migrate, stamp, upgrade
from werkzeug.middleware.proxy_fix import ProxyFix
@ -153,6 +153,22 @@ def create_app(
# Translations
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
def get_locale():
# 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.payer, inline=True, class="form-control custom-select") }}
{{ input(form.amount, inline=True) }}
{% if not form.original_currency.render_kw %}
{% if g.project.default_currency != "XXX" %}
{{ input(form.original_currency, inline=True) }}
{% endif %}
{{ input(form.external_link, inline=True) }}

View file

@ -1,5 +1,9 @@
{% 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 js %}
{% if add_bill %} $('#new-bill > a').click(); {% endif %}
@ -123,11 +127,6 @@
</th><th>{{ _("For what?") }}
</th><th>{{ _("For whom?") }}
</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>
</thead>
<tbody>
@ -149,13 +148,11 @@
{{ bill.owers|join(', ', 'name') }}
{%- endif %}</td>
<td>
{% if bill.original_currency != "No Currency" %}
{{ "%0.2f"|format(bill.amount) }} {{bill.original_currency}} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{bill.original_currency}} {{ _(" each") }})
{%- else -%}
{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{ _(" each") }})
{% endif %}
<span data-toggle="tooltip" data-placement="top"
title="{{ bill_amount(bill, g.project.default_currency, bill.converted_amount) if bill.original_currency != g.project.default_currency else '' }}">
{{ bill_amount(bill) }}
</span>
</td>
<td>{{ "%0.2f"|format(bill.converted_amount) }}</td>
<td class="bill-actions">
<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>

View file

@ -9,8 +9,9 @@ import os
import re
from babel import Locale
from babel.numbers import get_currency_name, get_currency_symbol
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
from werkzeug.routing import HTTPException, RoutingException
@ -281,6 +282,20 @@ class FormEnum(Enum):
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):
"""Like render_template(), but selects the right template according to 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 (
AdminAuthenticationForm,
AuthenticationForm,
EditProjectForm,
InviteForm,
MemberForm,
PasswordReminder,
@ -48,7 +49,6 @@ from ihatemoney.forms import (
ResetPasswordForm,
UploadForm,
get_billform_for,
get_editprojectform_for,
)
from ihatemoney.history import get_history, get_history_queries
from ihatemoney.models import Bill, LoggingMode, Person, Project, db
@ -377,7 +377,7 @@ def reset_password():
@main.route("/<project_id>/edit", methods=["GET", "POST"])
def edit_project():
edit_form = get_editprojectform_for(g.project)
edit_form = EditProjectForm()
import_form = UploadForm()
# Import form
if import_form.validate_on_submit():
@ -393,10 +393,10 @@ def edit_project():
if edit_form.validate_on_submit():
project = edit_form.update(g.project)
# Update converted currency
if project.default_currency != CurrencyConverter.default:
if project.default_currency != CurrencyConverter.no_currency:
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.converted_amount = CurrencyConverter().exchange_currency(
@ -417,6 +417,7 @@ def edit_project():
edit_form.ip_recording.data = True
edit_form.contact_email.data = g.project.contact_email
edit_form.default_currency.data = g.project.default_currency
return render_template(
"edit_project.html",
@ -732,7 +733,7 @@ def edit_bill(bill_id):
return redirect(url_for(".list_bills"))
if not form.errors:
form.fill(bill)
form.fill(bill, g.project)
return render_template("add_bill.html", form=form, edit=True)