mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-01 02:32:23 +02:00
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:
parent
76911983af
commit
981edd413a
8 changed files with 93 additions and 73 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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))
|
form.original_currency.choices = [
|
||||||
choices.sort(
|
(currency_name, render_localized_currency(currency_name, detailed=False))
|
||||||
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
|
for currency_name in form.currency_helper.get_currencies(
|
||||||
)
|
with_no_currency=show_no_currency
|
||||||
form.original_currency.choices = choices
|
)
|
||||||
else:
|
]
|
||||||
form.original_currency.render_kw = {"default": True}
|
|
||||||
form.original_currency.data = CurrencyConverter.default
|
|
||||||
|
|
||||||
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]
|
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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) }}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue