Remove multi currencies support

For more information about the rationale, this was [discussed](https://github.com/spiral-project/ihatemoney/issues/1232#issuecomment-2081517453) in our bugtracker.
This commit is contained in:
Alexis Métaireau 2024-12-26 17:21:33 +01:00
parent 4f9cad88bd
commit 04b18a8d3d
No known key found for this signature in database
GPG key ID: 1C21B876828E5FF2
26 changed files with 84 additions and 1338 deletions

View file

@ -2,8 +2,9 @@
This document describes changes between each past release.
## 6.2.0 (unreleased)
## 7.0.0 (unreleased)
- Remove the support for multiple currencies, [as discussed](https://github.com/spiral-project/ihatemoney/issues/1232#issuecomment-2081517453) in our bugtracker.
- Add support for python 3.12 (#757)
- Migrate from setup.cfg to pyproject.toml (#1243)
- Update to wtforms 3.1 (#1248)

View file

@ -71,13 +71,6 @@ A project needs the following arguments:
- `password`: the project password / private code (string)
- `contact_email`: the contact email, used to recover the private code (string)
Optional arguments:
- `default_currency`: the default currency to use for a multi-currency
project, in ISO 4217 format. Bills are converted to this currency
for operations like balance or statistics. Default value: `XXX` (no
currency).
Here is the command:
$ curl -X POST https://ihatemoney.org/api/projects \
@ -97,7 +90,6 @@ Getting information about the project:
"id": "demo",
"name": "demonstration",
"contact_email": "demo@notmyidea.org",
"default_currency": "XXX",
"members": [{"id": 11515, "name": "f", "weight": 1.0, "activated": true, "balance": 0},
{"id": 11531, "name": "g", "weight": 1.0, "activated": true, "balance": 0},
{"id": 11532, "name": "peter", "weight": 1.0, "activated": true, "balance": 5.0},
@ -181,14 +173,8 @@ Or get a specific bill by ID:
"creation_date": "2021-01-13",
"what": "Raclette du nouvel an",
"external_link": "",
"original_currency": "XXX",
"converted_amount": 100
}
`amount` is expressed in the `original_currency` of the bill, while
`converted_amount` is expressed in the project `default_currency`. Here,
they are the same.
Add a bill with a `POST` query on `/api/projects/<id>/bills`. You need
the following required parameters:
@ -203,9 +189,6 @@ And optional parameters:
- `date`: the date of the bill (`yyyy-mm-dd` format). Defaults to
current date if not provided.
- `original_currency`: the currency in which `amount` has been paid
(ISO 4217 code). Only makes sense for a project with currencies.
Defaults to the project `default_currency`.
- `external_link`: an optional URL associated with the bill.
Returns the id of the created bill :
@ -250,23 +233,3 @@ You can get some project stats with a `GET` on
"balance": -10.5
}
]
### Currencies
You can get a list of supported currencies with a `GET` on
`/api/currencies`:
$ curl --basic https://ihatemoney.org/api/currencies
[
"XXX",
"AED",
"AFN",
.
.
.
"ZAR",
"ZMW",
"ZWL"
]

View file

@ -5,7 +5,6 @@ from flask_restful import Resource, abort
from werkzeug.security import check_password_hash
from wtforms.fields import BooleanField
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.emails import send_creation_email
from ihatemoney.forms import EditProjectForm, MemberForm, ProjectForm, get_billform_for
from ihatemoney.models import Bill, Person, Project, db
@ -50,13 +49,6 @@ def need_auth(f):
return wrapper
class CurrenciesHandler(Resource):
currency_helper = CurrencyConverter()
def get(self):
return self.currency_helper.get_currencies()
class ProjectsHandler(Resource):
def post(self):
form = ProjectForm(meta={"csrf": False})

View file

@ -5,7 +5,6 @@ from flask_restful import Api
from ihatemoney.api.common import (
BillHandler,
BillsHandler,
CurrenciesHandler,
MemberHandler,
MembersHandler,
ProjectHandler,
@ -18,7 +17,6 @@ api = Blueprint("api", __name__, url_prefix="/api")
CORS(api)
restful_api = Api(api)
restful_api.add_resource(CurrenciesHandler, "/currencies")
restful_api.add_resource(ProjectsHandler, "/projects")
restful_api.add_resource(ProjectHandler, "/projects/<string:project_id>")
restful_api.add_resource(TokenHandler, "/projects/<string:project_id>/token")

View file

@ -1,228 +0,0 @@
import traceback
import warnings
from cachetools import TTLCache, cached
import requests
class Singleton(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs)
return cls._instances[cls]
class CurrencyConverter(object, metaclass=Singleton):
# Get exchange rates
no_currency = "XXX"
api_url = "https://api.exchangerate.host/latest?base=USD"
def __init__(self):
pass
@cached(cache=TTLCache(maxsize=1, ttl=86400))
def get_rates(self):
try:
rates = requests.get(self.api_url).json()["rates"]
except Exception:
warnings.warn(
f"Call to {self.api_url} failed: {traceback.format_exc(limit=0).strip()}"
)
# In case of any exception, let's have an empty value
rates = {}
rates[self.no_currency] = 1.0
return rates
def get_currencies(self, with_no_currency=True):
currencies = [
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BGN",
"BHD",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BTC",
"BTN",
"BWP",
"BYN",
"BZD",
"CAD",
"CDF",
"CHF",
"CLF",
"CLP",
"CNH",
"CNY",
"COP",
"CRC",
"CUC",
"CUP",
"CVE",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ERN",
"ETB",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GGP",
"GHS",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HRK",
"HTG",
"HUF",
"IDR",
"ILS",
"IMP",
"INR",
"IQD",
"IRR",
"ISK",
"JEP",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KPW",
"KRW",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"LYD",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRU",
"MUR",
"MVR",
"MWK",
"MXN",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"OMR",
"PAB",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEK",
"SGD",
"SHP",
"SLL",
"SOS",
"SRD",
"SSP",
"STD",
"STN",
"SVC",
"SYP",
"SZL",
"THB",
"TJS",
"TMT",
"TND",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"UYU",
"UZS",
"VEF",
"VES",
"VND",
"VUV",
"WST",
"XAF",
"XAG",
"XAU",
"XCD",
"XDR",
"XOF",
"XPD",
"XPF",
"XPT",
"YER",
"ZAR",
"ZMW",
"ZWL",
]
if with_no_currency:
currencies.append(self.no_currency)
return currencies
def exchange_currency(self, amount, source_currency, dest_currency):
if (
source_currency == dest_currency
or source_currency == self.no_currency
or dest_currency == self.no_currency
):
return amount
rates = self.get_rates()
source_rate = rates[source_currency]
dest_rate = rates[dest_currency]
new_amount = (float(amount) / source_rate) * dest_rate
# round to two digits because we are dealing with money
return round(new_amount, 2)

View file

@ -14,7 +14,6 @@ from wtforms.fields import (
BooleanField,
DateField,
DecimalField,
Label,
PasswordField,
SelectField,
SelectMultipleField,
@ -38,13 +37,11 @@ from wtforms.validators import (
ValidationError,
)
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project
from ihatemoney.utils import (
em_surround,
eval_arithmetic_expression,
generate_password_hash,
render_localized_currency,
slugify,
)
@ -64,20 +61,6 @@ def get_billform_for(project, set_default=True, **kwargs):
"""
form = BillForm(**kwargs)
if form.original_currency.data is None:
form.original_currency.data = project.default_currency
# Used in validate_original_currency
form.project_currency = project.default_currency
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
)
]
active_members = [(m.id, m.name) for m in project.active_members]
@ -137,30 +120,6 @@ class EditProjectForm(FlaskForm):
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
project_history = BooleanField(_("Enable project history"))
ip_recording = BooleanField(_("Use IP tracking for project history"))
currency_helper = CurrencyConverter()
default_currency = SelectField(
_("Default Currency"),
validators=[DataRequired()],
default=CurrencyConverter.no_currency,
description=_(
"Setting a default currency enables currency conversion between bills"
),
)
def __init__(self, *args, **kwargs):
if not hasattr(self, "id"):
# We must access the project to validate the default currency, using its id.
# In ProjectForm, 'id' is provided, but not in this base class, so it *must*
# be provided by callers.
# Since id can be defined as a WTForms.StringField, we mimics it,
# using an object that can have a 'data' attribute.
# It defaults to empty string to ensure that query run smoothly.
self.id = SimpleNamespace(data=kwargs.pop("id", ""))
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()
]
def validate_current_password(self, field):
project = Project.query.get(self.id.data)
@ -180,28 +139,6 @@ class EditProjectForm(FlaskForm):
else:
return LoggingMode.ENABLED
def validate_default_currency(self, field):
project = Project.query.get(self.id.data)
if (
project is not None
and field.data == CurrencyConverter.no_currency
and project.has_multiple_currencies()
):
msg = _(
"This project cannot be set to 'no currency'"
" because it contains bills in multiple currencies."
)
raise ValidationError(msg)
if (
project is not None
and field.data != project.default_currency
and project.has_bills()
):
msg = _(
"Cannot change project currency because currency conversion is broken"
)
raise ValidationError(msg)
def update(self, project):
"""Update the project with the information from the form"""
project.name = self.name.data
@ -217,10 +154,20 @@ class EditProjectForm(FlaskForm):
project.contact_email = self.contact_email.data
project.logging_preference = self.logging_preference
project.switch_currency(self.default_currency.data)
return project
def __init__(self, *args, **kwargs):
if not hasattr(self, "id"):
# We must access the project to validate the default currency, using its id.
# In ProjectForm, 'id' is provided, but not in this base class, so it *must*
# be provided by callers.
# Since id can be defined as a WTForms.StringField, we mimic it,
# using an object that can have a 'data' attribute.
# It defaults to empty string to ensure that query run smoothly.
self.id = SimpleNamespace(data=kwargs.pop("id", ""))
super().__init__(*args, **kwargs)
class ImportProjectForm(FlaskForm):
file = FileField(
@ -258,7 +205,6 @@ class ProjectForm(EditProjectForm):
password=generate_password_hash(self.password.data),
contact_email=self.contact_email.data,
logging_preference=self.logging_preference,
default_currency=self.default_currency.data,
)
return project
@ -352,8 +298,6 @@ class BillForm(FlaskForm):
what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int)
amount = CalculatorStringField(_("How much?"), validators=[DataRequired()])
currency_helper = CurrencyConverter()
original_currency = SelectField(_("Currency"), validators=[DataRequired()])
external_link = URLField(
_("External link"),
default="",
@ -377,10 +321,8 @@ class BillForm(FlaskForm):
amount=float(self.amount.data),
date=self.date.data,
external_link=self.external_link.data,
original_currency=str(self.original_currency.data),
owers=Person.query.get_by_ids(self.payed_for.data, project),
payer_id=self.payer.data,
project_default_currency=project.default_currency,
what=self.what.data,
bill_type=self.bill_type.data,
)
@ -393,10 +335,6 @@ class BillForm(FlaskForm):
bill.external_link = self.external_link.data
bill.date = self.date.data
bill.owers = Person.query.get_by_ids(self.payed_for.data, project)
bill.original_currency = self.original_currency.data
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
return bill
def fill(self, bill, project):
@ -405,18 +343,9 @@ class BillForm(FlaskForm):
self.what.data = bill.what
self.bill_type.data = bill.bill_type
self.external_link.data = bill.external_link
self.original_currency.data = bill.original_currency
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
@ -425,17 +354,6 @@ class BillForm(FlaskForm):
# See https://github.com/python-babel/babel/issues/821
raise ValidationError(f"Result is too high: {field.data}")
def validate_original_currency(self, field):
# Workaround for currency API breakage
# See #1232
if field.data not in [CurrencyConverter.no_currency, self.project_currency]:
msg = _(
"Failed to convert from %(bill_currency)s currency to %(project_currency)s",
bill_currency=field.data,
project_currency=self.project_currency,
)
raise ValidationError(msg)
class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter])

View file

@ -102,7 +102,6 @@ def get_history(project, human_readable_names=True):
"amount": detailed_version.amount,
"owers": [describe_version(o) for o in detailed_version.owers],
"external_link": detailed_version.external_link,
"original_currency": detailed_version.original_currency,
}
common_properties["bill_details"] = details

View file

@ -12,7 +12,6 @@ down_revision = "cb038f79982e"
from alembic import op
import sqlalchemy as sa
from ihatemoney.currency_convertor import CurrencyConverter
def upgrade():
@ -23,7 +22,7 @@ def upgrade():
sa.Column(
"original_currency",
sa.String(length=3),
server_default=CurrencyConverter.no_currency,
server_default="",
nullable=True,
),
)
@ -42,7 +41,7 @@ def upgrade():
sa.Column(
"default_currency",
sa.String(length=3),
server_default=CurrencyConverter.no_currency,
server_default="",
nullable=True,
),
)

View file

@ -20,7 +20,6 @@ from sqlalchemy.sql import func
from sqlalchemy_continuum import make_versioned, version_class
from sqlalchemy_continuum.plugins import FlaskPlugin
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.monkeypath_continuum import PatchedTransactionFactory
from ihatemoney.utils import generate_password_hash, get_members, same_bill
from ihatemoney.versioning import (
@ -86,7 +85,6 @@ class Project(db.Model):
members = db.relationship("Person", backref="project")
query_class = ProjectQuery
default_currency = db.Column(db.String(3))
@property
def _to_serialize(self):
@ -96,7 +94,6 @@ class Project(db.Model):
"contact_email": self.contact_email,
"logging_preference": self.logging_preference.value,
"members": [],
"default_currency": self.default_currency,
}
balance = self.balance
@ -130,16 +127,14 @@ class Project(db.Model):
total_weight = sum(ower.weight for ower in bill.owers)
if bill.bill_type == BillType.EXPENSE:
should_receive[bill.payer.id] += bill.converted_amount
should_receive[bill.payer.id] += bill.amount
for ower in bill.owers:
should_pay[ower.id] += (
ower.weight * bill.converted_amount / total_weight
)
should_pay[ower.id] += ower.weight * bill.amount / total_weight
if bill.bill_type == BillType.REIMBURSEMENT:
should_receive[bill.payer.id] += bill.converted_amount
should_receive[bill.payer.id] += bill.amount
for ower in bill.owers:
should_receive[ower.id] -= bill.converted_amount
should_receive[ower.id] -= bill.amount
for person in self.members:
balance = should_receive[person.id] - should_pay[person.id]
@ -183,7 +178,7 @@ class Project(db.Model):
monthly = defaultdict(lambda: defaultdict(float))
for bill in self.get_bills_unordered().all():
if bill.bill_type == BillType.EXPENSE:
monthly[bill.date.year][bill.date.month] += bill.converted_amount
monthly[bill.date.year][bill.date.month] += bill.amount
return monthly
@property
@ -204,7 +199,6 @@ class Project(db.Model):
"ower": transaction["ower"].name,
"receiver": transaction["receiver"].name,
"amount": round(transaction["amount"], 2),
"currency": transaction["currency"],
}
)
return pretty_transactions
@ -217,7 +211,6 @@ class Project(db.Model):
"ower": members[ower_id],
"receiver": members[receiver_id],
"amount": amount,
"currency": self.default_currency,
}
for ower_id, amount, receiver_id in settle_plan
]
@ -228,16 +221,6 @@ class Project(db.Model):
"""return if the project do have bills or not"""
return self.get_bills_unordered().count() > 0
def has_multiple_currencies(self):
"""Returns True if multiple currencies are used"""
# It would be more efficient to do the counting in the database,
# but this is called very rarely so we can tolerate if it's a bit
# slow. And doing this in Python is much more readable, see #784.
nb_currencies = len(
set(bill.original_currency for bill in self.get_bills_unordered())
)
return nb_currencies > 1
def get_bills_unordered(self):
"""Base query for bill list"""
# The subqueryload option allows to pre-load data from the
@ -344,7 +327,6 @@ class Project(db.Model):
"what": bill.what,
"bill_type": bill.bill_type.value,
"amount": round(bill.amount, 2),
"currency": bill.original_currency,
"date": str(bill.date),
"payer_name": Person.query.get(bill.payer_id).name,
"payer_weight": Person.query.get(bill.payer_id).weight,
@ -353,41 +335,6 @@ class Project(db.Model):
)
return pretty_bills
def switch_currency(self, new_currency):
if new_currency == self.default_currency:
return
# Update converted currency
if new_currency == CurrencyConverter.no_currency:
if self.has_multiple_currencies():
raise ValueError(f"Can't unset currency of project {self.id}")
for bill in self.get_bills_unordered():
# We are removing the currency, and we already checked that all bills
# had the same currency: it means that we can simply strip the currency
# without converting the amounts. We basically ignore the current default_currency
# Reset converted amount in case it was different from the original amount
bill.converted_amount = bill.amount
# Strip currency
bill.original_currency = CurrencyConverter.no_currency
db.session.add(bill)
else:
for bill in self.get_bills_unordered():
if bill.original_currency == CurrencyConverter.no_currency:
# Bills that were created without currency will be set to the new currency
bill.original_currency = new_currency
bill.converted_amount = bill.amount
else:
# Convert amount for others, without touching original_currency
bill.converted_amount = CurrencyConverter().exchange_currency(
bill.amount, bill.original_currency, new_currency
)
db.session.add(bill)
self.default_currency = new_currency
db.session.add(self)
db.session.commit()
def import_bills(self, bills: list):
"""Import bills from a list of dictionaries"""
# Add members not already in the project
@ -416,10 +363,8 @@ class Project(db.Model):
date=parse(b["date"]),
bill_type=b["bill_type"],
external_link="",
original_currency=b["currency"],
owers=Person.query.get_by_names(b["owers"], self),
payer_id=id_dict[b["payer_name"]],
project_default_currency=self.default_currency,
what=b["what"],
)
except Exception as e:
@ -527,7 +472,6 @@ class Project(db.Model):
name="demonstration",
password=generate_password_hash("demo"),
contact_email="demo@notmyidea.org",
default_currency="XXX",
)
db.session.add(project)
db.session.commit()
@ -554,10 +498,8 @@ class Project(db.Model):
Bill(
amount=amount,
bill_type=bill_type,
original_currency=project.default_currency,
owers=[members[name] for name in owers],
payer_id=members[payer].id,
project_default_currency=project.default_currency,
what=what,
)
)
@ -689,22 +631,15 @@ class Bill(db.Model):
bill_type = db.Column(db.Enum(BillType))
external_link = db.Column(db.UnicodeText)
original_currency = db.Column(db.String(3))
converted_amount = db.Column(db.Float)
archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
currency_helper = CurrencyConverter()
def __init__(
self,
amount: float,
date: datetime.datetime = None,
external_link: str = "",
original_currency: str = "",
owers: list = [],
payer_id: int = None,
project_default_currency: str = "",
what: str = "",
bill_type: str = "Expense",
):
@ -712,14 +647,10 @@ class Bill(db.Model):
self.amount = amount
self.date = date
self.external_link = external_link
self.original_currency = original_currency
self.owers = owers
self.payer_id = payer_id
self.what = what
self.bill_type = BillType(bill_type)
self.converted_amount = self.currency_helper.exchange_currency(
self.amount, self.original_currency, project_default_currency
)
@property
def _to_serialize(self):
@ -733,8 +664,6 @@ class Bill(db.Model):
"what": self.what,
"bill_type": self.bill_type.value,
"external_link": self.external_link,
"original_currency": self.original_currency,
"converted_amount": self.converted_amount,
}
def pay_each_default(self, amount):
@ -759,7 +688,7 @@ class Bill(db.Model):
"""Warning: this is slow, if you need to compute this for many bills, do
it differently (see balance_full function)
"""
return self.pay_each_default(self.converted_amount)
return self.pay_each_default(self.amount)
def __repr__(self):
return (

View file

@ -5,18 +5,16 @@ import warnings
from babel.dates import LOCALTZ
from flask import Flask, g, render_template, request, session
from flask_babel import Babel, format_currency
from flask_babel import Babel
from flask_mail import Mail
from flask_migrate import Migrate, stamp, upgrade
from flask_talisman import Talisman
from jinja2 import pass_context
from markupsafe import Markup
import pytz
from werkzeug.middleware.proxy_fix import ProxyFix
from ihatemoney import default_settings
from ihatemoney.api.v1 import api as apiv1
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import db
from ihatemoney.utils import (
IhmJSONEncoder,
@ -176,9 +174,6 @@ def create_app(
# Configure the a, root="main"pplication
setup_database(app)
# Setup Currency Cache
CurrencyConverter()
mail = Mail()
mail.init_app(app)
app.mail = mail
@ -220,25 +215,6 @@ def create_app(
else:
Babel(app, default_timezone=default_timezone, locale_selector=get_locale)
# 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
@pass_context
def currency(context, number, currency=None, *args, **kwargs):
if currency is None:
currency = context.get("g").project.default_currency
"""
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["currency"] = currency
return app

View file

@ -99,7 +99,6 @@
</div>
</div>
{{ input(form.default_currency) }}
{{ input(form.current_password) }}
<div class="actions">
<button class="btn btn-primary">{{ _("Save changes") }}</button>
@ -198,9 +197,6 @@
<details class="mb-3">
<summary class="mb-2">{{ _("More options") }}</summary>
{% if g.project.default_currency != "XXX" %}
{{ input(form.original_currency, inline=True, class="form-control custom-select") }}
{% endif %}
{{ input(form.external_link, inline=True) }}
</details>
</fieldset>

View file

@ -97,7 +97,7 @@
<summary>{% if before %} {{ _("Details of the bill (before the change)") }} {% else %} {{ _("Details of the bill") }} {% endif %}</summary>
{{ _("Date:") }} {{ details.date|em_surround }}.
{{ _("Payer:") }} {{ details.payer|em_surround }}.
{{ _("Amount:") }} {{ details.amount|currency(details.original_currency)|em_surround }}.
{{ _("Amount:") }} {{ details.amount|em_surround }}.
{{ _("Owers:") }} {{ owers_list_str }}.
{% if details.external_link %}
{{ _("External link:") }}
@ -229,10 +229,6 @@
{{ bill_property_change(event, _("Amount")) }}
{% elif event.prop_changed == "date" %}
{{ bill_property_change(event, _("Date")) }}
{% elif event.prop_changed == "original_currency" %}
{{ bill_property_change(event, _("Currency")) }}
{% elif event.prop_changed == "converted_amount" %}
{{ bill_property_change(event, _("Amount in %(currency)s", currency=g.project.default_currency)) }}
{% else %}
{% trans %}Bill {{ name }} modified{% endtrans %}
{% endif %}

View file

@ -1,9 +1,9 @@
{% extends "sidebar_table_layout.html" %}
{%- macro weighted_bill_amount(bill, weights, currency=bill.original_currency, amount=bill.amount) %}
{{ amount|currency(currency) }}
{%- macro weighted_bill_amount(bill, weights, amount=bill.amount) %}
{{ amount }}
{%- if weights != 1.0 %}
({{ _("%(amount)s each", amount=(amount / weights)|currency(currency)) }})
({{ _("%(amount)s each", amount=(amount / weights)) }})
{%- endif -%}
{% endmacro -%}
@ -142,10 +142,7 @@
{{ bill.owers|join(', ', 'name') }}
{%- endif %}</td>
<td>
<span data-toggle="tooltip" data-placement="top"
title="{{ weighted_bill_amount(bill, weights, g.project.default_currency, bill.converted_amount) if bill.original_currency != g.project.default_currency else '' }}">
{{ weighted_bill_amount(bill, weights) }}
</span>
</td>
<td class="bill-actions d-flex align-items-center">
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>

View file

@ -10,11 +10,11 @@
<link>{{ url_for(".list_bills", _external=True) }}</link>
{% for (weights, bill) in bills.items -%}
<item>
<title>{{ bill.what }} - {{ bill.amount|currency(bill.original_currency) }}</title>
<title>{{ bill.what }} - {{ bill.amount | round(2) }}</title>
<guid isPermaLink="false">{{ bill.id }}</guid>
<dc:creator>{{ bill.payer }}</dc:creator>
{% if bill.external_link %}<link>{{ bill.external_link }}</link>{% endif -%}
<description>{{ bill.date|dateformat("long") }} - {{ bill.owers|join(', ', 'name') }} : {{ (bill.amount/weights)|currency(bill.original_currency) }}</description>
<description>{{ bill.date|dateformat("long") }} - {{ bill.owers|join(', ', 'name') }} : {{ bill.amount/weights | round(2)}}</description>
<pubDate>{{ bill.creation_date.strftime("%a, %d %b %Y %T") }} +0000</pubDate>
</item>
{% endfor -%}

View file

@ -15,7 +15,7 @@
<tr receiver={{bill.receiver.id}}>
<td>{{ bill.ower }}</td>
<td>{{ bill.receiver }}</td>
<td>{{ bill.amount|currency }}</td>
<td>{{ bill.amount }}</td>
<td>
<span id="settle-bill" class="ml-auto pb-2">
<a href="{{ url_for('.settle', amount = bill.amount, ower_id = bill.ower.id, payer_id = bill.receiver.id) }}" class="btn btn-primary">

View file

@ -39,7 +39,7 @@
{%- endif %}
{%- endif %}
<td class="balance-value {% if balance[member.id]|round(2) > 0 %}positive{% elif balance[member.id]|round(2) < 0 %}negative{% endif %}">
{% if balance[member.id] | round(2) > 0 %}+{% endif %}{{ balance[member.id]|currency }}
{% if balance[member.id] | round(2) > 0 %}+{% endif %}{{ balance[member.id] }}
</td>
</tr>
{%- endfor %}

View file

@ -15,8 +15,8 @@
{% for stat in members_stats|sort(attribute='member.name') %}
<tr>
<td class="d-md-none">{{ stat.member.name }}</td>
<td>{{ stat.paid|currency }}</td>
<td>{{ stat.spent|currency }}</td>
<td>{{ stat.paid | round(2) }}</td>
<td>{{ stat.spent | round(2) }}</td>
</tr>
{% endfor %}
</tbody>
@ -28,7 +28,7 @@
{% for month in months %}
<tr>
<td>{{ month|dateformat("MMMM yyyy") }}</td>
<td>{{ monthly_stats[month.year][month.month]|currency }}</td>
<td>{{ monthly_stats[month.year][month.month] | round(2) }}</td>
</tr>
{% endfor %}
</tbody>

View file

@ -2,8 +2,6 @@ import base64
import datetime
import json
import pytest
from ihatemoney.tests.common.help_functions import em_surround
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
@ -11,9 +9,7 @@ from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
class TestAPI(IhatemoneyTestCase):
"""Tests the API"""
def api_create(
self, name, id=None, password=None, contact=None, default_currency=None
):
def api_create(self, name, id=None, password=None, contact=None):
id = id or name
password = password or name
contact = contact or f"{name}@notmyidea.org"
@ -24,8 +20,6 @@ class TestAPI(IhatemoneyTestCase):
"password": password,
"contact_email": contact,
}
if default_currency:
data["default_currency"] = default_currency
return self.client.post(
"/api/projects",
@ -90,7 +84,6 @@ class TestAPI(IhatemoneyTestCase):
"id": "raclette",
"password": "raclette",
"contact_email": "not-an-email",
"default_currency": "XXX",
},
)
@ -124,7 +117,6 @@ class TestAPI(IhatemoneyTestCase):
"members": [],
"name": "raclette",
"contact_email": "raclette@notmyidea.org",
"default_currency": "XXX",
"id": "raclette",
"logging_preference": 1,
}
@ -136,7 +128,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"password": "raclette",
"name": "The raclette party",
"project_history": "y",
@ -150,7 +141,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"current_password": "fromage aux patates",
"password": "raclette",
"name": "The raclette party",
@ -165,7 +155,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"current_password": "raclette",
"password": "raclette",
"name": "The raclette party",
@ -183,7 +172,6 @@ class TestAPI(IhatemoneyTestCase):
expected = {
"name": "The raclette party",
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"members": [],
"id": "raclette",
"logging_preference": 1,
@ -196,7 +184,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"current_password": "raclette",
"password": "tartiflette",
"name": "The raclette party",
@ -250,7 +237,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"password": "tartiflette",
"name": "The raclette party",
},
@ -435,8 +421,6 @@ class TestAPI(IhatemoneyTestCase):
"amount": 25.0,
"date": "2011-08-10",
"id": 1,
"converted_amount": 25.0,
"original_currency": "XXX",
"external_link": "https://raclette.fr",
}
@ -507,8 +491,6 @@ class TestAPI(IhatemoneyTestCase):
"amount": 25.0,
"date": "2011-09-10",
"external_link": "https://raclette.fr",
"converted_amount": 25.0,
"original_currency": "XXX",
"id": 1,
}
@ -588,8 +570,6 @@ class TestAPI(IhatemoneyTestCase):
"date": "2011-08-10",
"id": id,
"external_link": "",
"original_currency": "XXX",
"converted_amount": expected_amount,
}
got = json.loads(req.data.decode("utf-8"))
@ -624,169 +604,6 @@ class TestAPI(IhatemoneyTestCase):
)
self.assertStatus(400, req)
@pytest.mark.skip(reason="Currency conversion is broken")
def test_currencies(self):
# check /currencies for list of supported currencies
resp = self.client.get("/api/currencies")
assert 200 == resp.status_code
assert "XXX" in json.loads(resp.data.decode("utf-8"))
# create project with a default currency
resp = self.api_create("raclette", default_currency="EUR")
assert 201 == resp.status_code
# get information about it
resp = self.client.get(
"/api/projects/raclette", headers=self.get_auth("raclette")
)
assert 200 == resp.status_code
expected = {
"members": [],
"name": "raclette",
"contact_email": "raclette@notmyidea.org",
"default_currency": "EUR",
"id": "raclette",
"logging_preference": 1,
}
decoded_resp = json.loads(resp.data.decode("utf-8"))
assert decoded_resp == expected
# Add participants
self.api_add_member("raclette", "zorglub")
self.api_add_member("raclette", "jeanne")
self.api_add_member("raclette", "quentin")
# Add a bill without explicit currency
req = self.client.post(
"/api/projects/raclette/bills",
data={
"date": "2011-08-10",
"what": "fromage",
"payer": "1",
"payed_for": ["1", "2"],
"bill_type": "Expense",
"amount": "25",
"external_link": "https://raclette.fr",
},
headers=self.get_auth("raclette"),
)
# should return the id
self.assertStatus(201, req)
assert req.data.decode("utf-8") == "1\n"
# get this bill details
req = self.client.get(
"/api/projects/raclette/bills/1", headers=self.get_auth("raclette")
)
# compare with the added info
self.assertStatus(200, req)
expected = {
"what": "fromage",
"payer_id": 1,
"owers": [
{"activated": True, "id": 1, "name": "zorglub", "weight": 1},
{"activated": True, "id": 2, "name": "jeanne", "weight": 1},
],
"bill_type": "Expense",
"amount": 25.0,
"date": "2011-08-10",
"id": 1,
"converted_amount": 25.0,
"original_currency": "EUR",
"external_link": "https://raclette.fr",
}
got = json.loads(req.data.decode("utf-8"))
assert (
datetime.date.today()
== datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date()
)
del got["creation_date"]
assert expected == got
# Change bill amount and currency
req = self.client.put(
"/api/projects/raclette/bills/1",
data={
"date": "2011-08-10",
"what": "fromage",
"payer": "1",
"payed_for": ["1", "2"],
"bill_type": "Expense",
"amount": "30",
"external_link": "https://raclette.fr",
"original_currency": "CAD",
},
headers=self.get_auth("raclette"),
)
self.assertStatus(200, req)
# Check result
req = self.client.get(
"/api/projects/raclette/bills/1", headers=self.get_auth("raclette")
)
self.assertStatus(200, req)
expected_amount = self.converter.exchange_currency(30.0, "CAD", "EUR")
expected = {
"what": "fromage",
"payer_id": 1,
"owers": [
{"activated": True, "id": 1, "name": "zorglub", "weight": 1.0},
{"activated": True, "id": 2, "name": "jeanne", "weight": 1.0},
],
"bill_type": "Expense",
"amount": 30.0,
"date": "2011-08-10",
"id": 1,
"converted_amount": expected_amount,
"original_currency": "CAD",
"external_link": "https://raclette.fr",
}
got = json.loads(req.data.decode("utf-8"))
del got["creation_date"]
assert expected == got
# Add a bill with yet another currency
req = self.client.post(
"/api/projects/raclette/bills",
data={
"date": "2011-09-10",
"what": "Pierogi",
"payer": "1",
"payed_for": ["2", "3"],
"bill_type": "Expense",
"amount": "80",
"original_currency": "PLN",
},
headers=self.get_auth("raclette"),
)
# should return the id
self.assertStatus(201, req)
assert req.data.decode("utf-8") == "2\n"
# Try to remove default project currency, it should fail
req = self.client.put(
"/api/projects/raclette",
data={
"contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"current_password": "raclette",
"password": "raclette",
"name": "The raclette party",
},
headers=self.get_auth("raclette"),
)
self.assertStatus(400, req)
assert "This project cannot be set" in req.data.decode("utf-8")
assert "because it contains bills in multiple currencies" in req.data.decode(
"utf-8"
)
def test_statistics(self):
# create a project
self.api_create("raclette")
@ -896,8 +713,6 @@ class TestAPI(IhatemoneyTestCase):
"date": "2011-08-10",
"id": 1,
"external_link": "",
"converted_amount": 25.0,
"original_currency": "XXX",
}
got = json.loads(req.data.decode("utf-8"))
assert (
@ -940,7 +755,6 @@ class TestAPI(IhatemoneyTestCase):
"id": "raclette",
"name": "raclette",
"logging_preference": 1,
"default_currency": "XXX",
}
self.assertStatus(200, req)

View file

@ -4,11 +4,9 @@ import re
from urllib.parse import unquote, urlparse, urlunparse
from flask import session, url_for
import pytest
from werkzeug.security import check_password_hash
from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.utils import generate_password_hash
@ -182,7 +180,6 @@ class TestBudget(IhatemoneyTestCase):
"contact_email": "zorglub@notmyidea.org",
"current_password": "raclette",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)
@ -259,7 +256,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
follow_redirects=True,
)
@ -287,7 +283,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette", # already used !
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -305,7 +300,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -326,7 +320,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -345,7 +338,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
@ -963,6 +955,7 @@ class TestBudget(IhatemoneyTestCase):
result[self.get_project("raclette").members[0].id] = 8.12
result[self.get_project("raclette").members[1].id] = 0.0
result[self.get_project("raclette").members[2].id] = -8.12
# Since we're using floating point to store currency, we can have some
# rounding issues that prevent test from working.
# However, we should obtain the same values as the theoretical ones if we
@ -979,7 +972,6 @@ class TestBudget(IhatemoneyTestCase):
"contact_email": "zorglub@notmyidea.org",
"password": "didoudida",
"logging_preference": LoggingMode.ENABLED.value,
"default_currency": "USD",
}
# It should fail if we don't provide the current password
@ -988,7 +980,6 @@ class TestBudget(IhatemoneyTestCase):
project = self.get_project("raclette")
assert project.name != new_data["name"]
assert project.contact_email != new_data["contact_email"]
assert project.default_currency != new_data["default_currency"]
assert not check_password_hash(project.password, new_data["password"])
# It should fail if we provide the wrong current password
@ -998,7 +989,6 @@ class TestBudget(IhatemoneyTestCase):
project = self.get_project("raclette")
assert project.name != new_data["name"]
assert project.contact_email != new_data["contact_email"]
assert project.default_currency != new_data["default_currency"]
assert not check_password_hash(project.password, new_data["password"])
# It should work if we give the current private code
@ -1008,7 +998,6 @@ class TestBudget(IhatemoneyTestCase):
project = self.get_project("raclette")
assert project.name == new_data["name"]
assert project.contact_email == new_data["contact_email"]
assert project.default_currency == new_data["default_currency"]
assert check_password_hash(project.password, new_data["password"])
# Editing a project with a wrong email address should fail
@ -1055,7 +1044,7 @@ class TestBudget(IhatemoneyTestCase):
def test_statistics(self):
# Output is checked with the USD sign
self.post_project("raclette", default_currency="USD")
self.post_project("raclette")
# add participants
self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2})
@ -1117,18 +1106,18 @@ class TestBudget(IhatemoneyTestCase):
response = self.client.get("/raclette/statistics")
regex = r"<td class=\"d-md-none\">{}</td>\s*<td>{}</td>\s*<td>{}</td>"
assert re.search(
regex.format("zorglub", r"\$20\.00", r"\$31\.67"),
regex.format("zorglub", r"20\.0", r"31\.67"),
response.data.decode("utf-8"),
)
assert re.search(
regex.format("jeanne", r"\$20\.00", r"\$5\.83"),
regex.format("jeanne", r"20\.0", r"5\.83"),
response.data.decode("utf-8"),
)
assert re.search(
regex.format("tata", r"\$0\.00", r"\$2\.50"), response.data.decode("utf-8")
regex.format("tata", r"0", r"2\.5"), response.data.decode("utf-8")
)
assert re.search(
regex.format("pépé", r"\$0\.00", r"\$0\.00"), response.data.decode("utf-8")
regex.format("pépé", r"0", r"0"), response.data.decode("utf-8")
)
# Check that the order of participants in the sidebar table is the
@ -1556,225 +1545,6 @@ class TestBudget(IhatemoneyTestCase):
member = models.Person.query.filter(models.Person.id == 1).one_or_none()
assert member is None
@pytest.mark.skip(reason="Currency conversion is broken")
def test_currency_switch(self):
# A project should be editable
self.post_project("raclette")
# add participants
self.client.post("/raclette/members/add", data={"name": "zorglub"})
self.client.post("/raclette/members/add", data={"name": "jeanne"})
self.client.post("/raclette/members/add", data={"name": "tata"})
# create bills
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2, 3],
"bill_type": "Expense",
"amount": "10.0",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "red wine",
"payer": 2,
"payed_for": [1, 3],
"bill_type": "Expense",
"amount": "20",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "refund",
"payer": 3,
"payed_for": [2],
"bill_type": "Expense",
"amount": "13.33",
},
)
project = self.get_project("raclette")
# First all converted_amount should be the same as amount, with no currency
for bill in project.get_bills():
assert bill.original_currency == CurrencyConverter.no_currency
assert bill.amount == bill.converted_amount
# Then, switch to EUR, all bills must have been changed to this currency
project.switch_currency("EUR")
for bill in project.get_bills():
assert bill.original_currency == "EUR"
assert bill.amount == bill.converted_amount
# Add a bill in EUR, the current default currency
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "refund from EUR",
"payer": 3,
"payed_for": [2],
"bill_type": "Expense",
"amount": "20",
"original_currency": "EUR",
},
)
last_bill = project.get_bills().first()
assert last_bill.converted_amount == last_bill.amount
# Erase all currencies
project.switch_currency(CurrencyConverter.no_currency)
for bill in project.get_bills():
assert bill.original_currency == CurrencyConverter.no_currency
assert bill.amount == bill.converted_amount
# Let's go back to EUR to test conversion
project.switch_currency("EUR")
# This is a bill in CAD
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "Poutine",
"payer": 3,
"payed_for": [2],
"bill_type": "Expense",
"amount": "18",
"original_currency": "CAD",
},
)
last_bill = project.get_bills().first()
expected_amount = self.converter.exchange_currency(
last_bill.amount, "CAD", "EUR"
)
assert last_bill.converted_amount == expected_amount
# Switch to USD. Now, NO bill should be in USD, since they already had a currency
project.switch_currency("USD")
for bill in project.get_bills():
assert bill.original_currency != "USD"
expected_amount = self.converter.exchange_currency(
bill.amount, bill.original_currency, "USD"
)
assert bill.converted_amount == expected_amount
# Switching back to no currency must fail
with pytest.raises(ValueError):
project.switch_currency(CurrencyConverter.no_currency)
# It also must fails with a nice error using the form
resp = self.client.post(
"/raclette/edit",
data={
"name": "demonstration",
"password": "demo",
"contact_email": "demo@notmyidea.org",
"project_history": "y",
"default_currency": CurrencyConverter.no_currency,
},
)
# A user displayed error should be generated, and its currency should be the same.
self.assertStatus(200, resp)
assert '<p class="alert alert-danger">' in resp.data.decode("utf-8")
assert self.get_project("raclette").default_currency == "USD"
@pytest.mark.skip(reason="Currency conversion is broken")
def test_currency_switch_to_bill_currency(self):
# Default currency is 'XXX', but we should start from a project with a currency
self.post_project("raclette", default_currency="USD")
# add participants
self.client.post("/raclette/members/add", data={"name": "zorglub"})
self.client.post("/raclette/members/add", data={"name": "jeanne"})
# Bill with a different currency than project's default
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2],
"bill_type": "Expense",
"amount": "10.0",
"original_currency": "EUR",
},
)
project = self.get_project("raclette")
bill = project.get_bills().first()
assert (
self.converter.exchange_currency(bill.amount, "EUR", "USD")
== bill.converted_amount
)
# And switch project to the currency from the bill we created
project.switch_currency("EUR")
bill = project.get_bills().first()
assert bill.converted_amount == bill.amount
@pytest.mark.skip(reason="Currency conversion is broken")
def test_currency_switch_to_no_currency(self):
# Default currency is 'XXX', but we should start from a project with a currency
self.post_project("raclette", default_currency="USD")
# add participants
self.client.post("/raclette/members/add", data={"name": "zorglub"})
self.client.post("/raclette/members/add", data={"name": "jeanne"})
# Bills with a different currency than project's default
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "fromage à raclette",
"payer": 1,
"payed_for": [1, 2],
"bill_type": "Expense",
"amount": "10.0",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "aspirine",
"payer": 2,
"payed_for": [1, 2],
"bill_type": "Expense",
"amount": "5.0",
"original_currency": "EUR",
},
)
project = self.get_project("raclette")
for bill in project.get_bills_unordered():
assert (
self.converter.exchange_currency(bill.amount, "EUR", "USD")
== bill.converted_amount
)
# And switch project to no currency: amount should be equal to what was submitted
project.switch_currency(CurrencyConverter.no_currency)
no_currency_bills = [
(bill.amount, bill.converted_amount) for bill in project.get_bills()
]
assert no_currency_bills == [(5.0, 5.0), (10.0, 10.0)]
def test_amount_is_null(self):
self.post_project("raclette")
@ -1791,7 +1561,6 @@ class TestBudget(IhatemoneyTestCase):
"payed_for": [1],
"bill_type": "Expense",
"amount": "0",
"original_currency": "XXX",
},
)
@ -1836,7 +1605,6 @@ class TestBudget(IhatemoneyTestCase):
"payed_for": [1],
"bill_type": "Expense",
"amount": "9347242149381274732472348728748723473278472843.12",
"original_currency": "EUR",
},
)
assert '<p class="alert alert-danger">' in resp.data.decode("utf-8")
@ -1870,7 +1638,7 @@ class TestBudget(IhatemoneyTestCase):
"""
Tests that the RSS feed output content is expected.
"""
self.post_project("raclette", default_currency="EUR")
self.post_project("raclette")
self.client.post("/raclette/members/add", data={"name": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"})
self.client.post("/raclette/members/add", data={"name": "steven"})
@ -1883,7 +1651,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 1,
"payed_for": [1, 2, 3],
"amount": "12",
"original_currency": "EUR",
"bill_type": "Expense",
},
)
@ -1895,7 +1662,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2,
"payed_for": [1, 2],
"amount": "15",
"original_currency": "EUR",
"bill_type": "Expense",
},
)
@ -1907,7 +1673,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2,
"payed_for": [1, 2],
"amount": "10",
"original_currency": "EUR",
"bill_type": "Expense",
},
)
@ -1925,23 +1690,23 @@ class TestBudget(IhatemoneyTestCase):
<atom:link href="http://localhost/raclette/feed/{token}.xml" rel="self" type="application/rss+xml" />
<link>http://localhost/raclette/</link>
<item>
<title>fromage à raclette - 12.00</title>
<title>fromage à raclette - 12.0</title>
<guid isPermaLink="false">1</guid>
<dc:creator>george</dc:creator>
<description>December 31, 2016 - george, peter, steven : 4.00</description>
<description>December 31, 2016 - george, peter, steven : 4.0</description>
"""
in content
)
assert """<title>charcuterie - 15.00</title>""" in content
assert """<title>vin blanc - 10.00</title>""" in content
assert "<title>charcuterie - 15.0</title>" in content
assert "<title>vin blanc - 10.0</title>" in content
def test_rss_feed_history_disabled(self):
"""
Tests that RSS feeds is correctly rendered even if the project
history is disabled.
"""
self.post_project("raclette", default_currency="EUR", project_history=False)
self.post_project("raclette", project_history=False)
self.client.post("/raclette/members/add", data={"name": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"})
self.client.post("/raclette/members/add", data={"name": "steven"})
@ -1954,7 +1719,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 1,
"payed_for": [1, 2, 3],
"amount": "12",
"original_currency": "EUR",
"bill_type": "Expense",
},
)
@ -1966,7 +1730,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2,
"payed_for": [1, 2],
"amount": "15",
"original_currency": "EUR",
"bill_type": "Expense",
},
)
@ -1978,7 +1741,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2,
"payed_for": [1, 2],
"amount": "10",
"original_currency": "EUR",
"bill_type": "Expense",
},
)
@ -1988,8 +1750,8 @@ class TestBudget(IhatemoneyTestCase):
resp = self.client.get(f"/raclette/feed/{token}.xml")
content = resp.data.decode()
assert """<title>charcuterie - 15.00</title>""" in content
assert """<title>vin blanc - 10.00</title>""" in content
assert """<title>charcuterie - 15.0</title>""" in content
assert """<title>vin blanc - 10.0</title>""" in content
def test_rss_if_modified_since_header(self):
# Project creation
@ -2036,7 +1798,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 1,
"payed_for": [1],
"amount": "12",
"original_currency": "XXX",
"bill_type": "Expense",
},
follow_redirects=True,
@ -2094,7 +1855,6 @@ class TestBudget(IhatemoneyTestCase):
"payed_for": [1],
"amount": "12",
"bill_type": "Expense",
"original_currency": "XXX",
},
follow_redirects=True,
)
@ -2175,7 +1935,6 @@ class TestBudget(IhatemoneyTestCase):
"contact_email": "zorglub@notmyidea.org",
"current_password": "raclette",
"password": "didoudida",
"default_currency": "XXX",
},
follow_redirects=True,
)

View file

@ -6,7 +6,7 @@ from ihatemoney import models
from ihatemoney.utils import generate_password_hash
@pytest.mark.usefixtures("client", "converter")
@pytest.mark.usefixtures("client")
class BaseTestCase:
SECRET_KEY = "TEST SESSION"
SQLALCHEMY_DATABASE_URI = os.environ.get(
@ -29,7 +29,6 @@ class BaseTestCase:
self,
id,
follow_redirects=True,
default_currency="XXX",
name=None,
password=None,
project_history=True,
@ -45,7 +44,6 @@ class BaseTestCase:
"id": id,
"password": password,
"contact_email": f"{id}@notmyidea.org",
"default_currency": default_currency,
"project_history": project_history,
},
follow_redirects=follow_redirects,
@ -59,7 +57,7 @@ class BaseTestCase:
)
assert ("/{id}/edit" in str(resp.response)) == (not success)
def create_project(self, id, default_currency="XXX", name=None, password=None):
def create_project(self, id, name=None, password=None):
name = name or str(id)
password = password or id
project = models.Project(
@ -67,7 +65,6 @@ class BaseTestCase:
name=name,
password=generate_password_hash(password),
contact_email=f"{id}@notmyidea.org",
default_currency=default_currency,
)
models.db.session.add(project)
models.db.session.commit()

View file

@ -1,11 +1,8 @@
from unittest.mock import MagicMock
from flask import Flask
from jinja2 import FileSystemBytecodeCache
import pytest
from ihatemoney.babel_utils import compile_catalogs
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.run import create_app, db
@ -44,21 +41,3 @@ def client(app: Flask, request: pytest.FixtureRequest):
request.cls.client = client
yield client
@pytest.fixture
def converter(request: pytest.FixtureRequest):
# Add dummy data to CurrencyConverter for all tests (since it's a singleton)
mock_data = {
"USD": 1,
"EUR": 0.8,
"CAD": 1.2,
"PLN": 4,
CurrencyConverter.no_currency: 1,
}
converter = CurrencyConverter()
converter.get_rates = MagicMock(return_value=mock_data)
# Also add it to an attribute to make tests clearer
request.cls.converter = converter
yield converter

View file

@ -17,7 +17,6 @@ def demo(client):
"id": "demo",
"password": "demo",
"contact_email": "demo@notmyidea.org",
"default_currency": "XXX",
"project_history": True,
},
)
@ -43,7 +42,6 @@ class TestHistory(IhatemoneyTestCase):
"contact_email": "demo@notmyidea.org",
"current_password": current_password,
"password": "demo",
"default_currency": "XXX",
}
if logging_preference != LoggingMode.DISABLED:
@ -93,7 +91,6 @@ class TestHistory(IhatemoneyTestCase):
"current_password": "demo",
"password": "123456",
"project_history": "y",
"default_currency": "USD", # Currency changed from default
}
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
@ -114,7 +111,7 @@ class TestHistory(IhatemoneyTestCase):
assert resp.data.decode("utf-8").index("Project renamed ") < resp.data.decode(
"utf-8"
).index("Project private code changed")
assert resp.data.decode("utf-8").count("<td> -- </td>") == 5
assert resp.data.decode("utf-8").count("<td> -- </td>") == 4
assert "127.0.0.1" not in resp.data.decode("utf-8")
def test_project_privacy_edit(self):
@ -184,7 +181,6 @@ class TestHistory(IhatemoneyTestCase):
"contact_email": "demo2@notmyidea.org",
"current_password": "demo",
"password": "123456",
"default_currency": "USD",
}
# Keep privacy settings where they were
@ -307,7 +303,7 @@ class TestHistory(IhatemoneyTestCase):
)
assert "Nothing to list" not in resp.data.decode("utf-8")
assert "Some entries below contain IP addresses," in resp.data.decode("utf-8")
assert resp.data.decode("utf-8").count("127.0.0.1") == 12
assert resp.data.decode("utf-8").count("127.0.0.1") == 10
assert resp.data.decode("utf-8").count("<td> -- </td>") == 1
# Generate more operations to confirm additional IP info isn't recorded
@ -315,8 +311,8 @@ class TestHistory(IhatemoneyTestCase):
resp = self.client.get("/demo/history")
assert resp.status_code == 200
assert resp.data.decode("utf-8").count("127.0.0.1") == 12
assert resp.data.decode("utf-8").count("<td> -- </td>") == 7
assert resp.data.decode("utf-8").count("127.0.0.1") == 10
assert resp.data.decode("utf-8").count("<td> -- </td>") == 6
# Ensure we can't clear IP data with a GET or with a password-less POST
resp = self.client.get("/demo/strip_ip_addresses")
@ -326,8 +322,8 @@ class TestHistory(IhatemoneyTestCase):
resp = self.client.get("/demo/history")
assert resp.status_code == 200
assert resp.data.decode("utf-8").count("127.0.0.1") == 12
assert resp.data.decode("utf-8").count("<td> -- </td>") == 7
assert resp.data.decode("utf-8").count("127.0.0.1") == 10
assert resp.data.decode("utf-8").count("<td> -- </td>") == 6
# Clear IP Data
resp = self.client.post(
@ -350,7 +346,7 @@ class TestHistory(IhatemoneyTestCase):
"utf-8"
)
assert resp.data.decode("utf-8").count("127.0.0.1") == 0
assert resp.data.decode("utf-8").count("<td> -- </td>") == 19
assert resp.data.decode("utf-8").count("<td> -- </td>") == 16
def test_logs_for_common_actions(self):
# adds a member to this project
@ -638,7 +634,6 @@ class TestHistory(IhatemoneyTestCase):
"payed_for": [1],
"bill_type": "Expense",
"amount": "10",
"original_currency": "EUR",
},
)

View file

@ -81,50 +81,9 @@ class CommonTestCase(object):
for d in range(len(self.data)):
self.data[d]["currency"] = currencies[d]
def test_import_currencies_in_empty_project_with_currency(self):
# Import JSON with currencies in an empty project with a default currency
self.post_project("raclette", default_currency="EUR")
self.login("raclette")
project = self.get_project("raclette")
self.populate_data_with_currencies(["EUR", "CAD", "EUR"])
self.import_project("raclette", self.generate_form_data(self.data))
bills = project.get_pretty_bills()
# Check if all bills have been added
assert len(bills) == len(self.data)
# Check if name of bills are ok
b = [e["what"] for e in bills]
b.sort()
ref = [e["what"] for e in self.data]
ref.sort()
assert b == ref
# Check if other informations in bill are ok
for d in self.data:
for b in bills:
if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"]
assert b["currency"] == d["currency"]
assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"]
list_project = [ower for ower in b["owers"]]
list_project.sort()
list_json = [ower for ower in d["owers"]]
list_json.sort()
assert list_project == list_json
def test_import_single_currency_in_empty_project_without_currency(self):
# Import JSON with a single currency in an empty project with no
# default currency. It should work by stripping the currency from
# bills.
def test_import_single_currency_in_empty_project(self):
# Import JSON with a single currency in an empty project
# It should work by stripping the currency from bills.
self.post_project("raclette")
self.login("raclette")
@ -153,8 +112,6 @@ class CommonTestCase(object):
if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"]
# Currency should have been stripped
assert b["currency"] == "XXX"
assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"]
@ -182,51 +139,7 @@ class CommonTestCase(object):
# Check that there are no bills
assert len(bills) == 0
def test_import_no_currency_in_empty_project_with_currency(self):
# Import JSON without currencies (from ihatemoney < 5) in an empty
# project with a default currency.
self.post_project("raclette", default_currency="EUR")
self.login("raclette")
project = self.get_project("raclette")
self.import_project("raclette", self.generate_form_data(self.data))
bills = project.get_pretty_bills()
# Check if all bills have been added
assert len(bills) == len(self.data)
# Check if name of bills are ok
b = [e["what"] for e in bills]
b.sort()
ref = [e["what"] for e in self.data]
ref.sort()
assert b == ref
# Check if other informations in bill are ok
for d in self.data:
for b in bills:
if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"]
# All bills are converted to default project currency
assert b["currency"] == "EUR"
assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"]
list_project = [ower for ower in b["owers"]]
list_project.sort()
list_json = [ower for ower in d["owers"]]
list_json.sort()
assert list_project == list_json
def test_import_no_currency_in_empty_project_without_currency(self):
# Import JSON without currencies (from ihatemoney < 5) in an empty
# project with no default currency.
def test_import_no_currency_in_empty_project(self):
self.post_project("raclette")
self.login("raclette")
@ -253,7 +166,6 @@ class CommonTestCase(object):
if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"]
assert b["currency"] == "XXX"
assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"]
@ -288,8 +200,6 @@ class CommonTestCase(object):
},
)
self.populate_data_with_currencies(["XXX", "XXX", "XXX"])
self.import_project("raclette", self.generate_form_data(self.data))
bills = project.get_pretty_bills()
@ -311,7 +221,6 @@ class CommonTestCase(object):
if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"]
assert b["currency"] == d["currency"]
assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"]
@ -406,7 +315,6 @@ class TestExport(IhatemoneyTestCase):
"bill_type": "Reimbursement",
"what": "refund",
"amount": 13.33,
"currency": "XXX",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["jeanne"],
@ -416,7 +324,6 @@ class TestExport(IhatemoneyTestCase):
"bill_type": "Expense",
"what": "red wine",
"amount": 200.0,
"currency": "XXX",
"payer_name": "jeanne",
"payer_weight": 1.0,
"owers": ["zorglub", "tata"],
@ -426,7 +333,6 @@ class TestExport(IhatemoneyTestCase):
"bill_type": "Expense",
"what": "\xe0 raclette",
"amount": 10.0,
"currency": "XXX",
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "jeanne", "tata", "p\xe9p\xe9"],
@ -437,10 +343,10 @@ class TestExport(IhatemoneyTestCase):
# generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv")
expected = [
"date,what,bill_type,amount,currency,payer_name,payer_weight,owers",
"2017-01-01,refund,Reimbursement,XXX,13.33,tata,1.0,jeanne",
'2016-12-31,red wine,Expense,XXX,200.0,jeanne,1.0,"zorglub, tata"',
'2016-12-31,à raclette,Expense,10.0,XXX,zorglub,2.0,"zorglub, jeanne, tata, pépé"',
"date,what,bill_type,amount,payer_name,payer_weight,owers",
"2017-01-01,refund,Reimbursement,13.33,tata,1.0,jeanne",
'2016-12-31,red wine,Expense,200.0,jeanne,1.0,"zorglub, tata"',
'2016-12-31,à raclette,Expense,10.0,zorglub,2.0,"zorglub, jeanne, tata, pépé"',
]
received_lines = resp.data.decode("utf-8").split("\n")
@ -452,14 +358,12 @@ class TestExport(IhatemoneyTestCase):
expected = [
{
"amount": 2.00,
"currency": "XXX",
"receiver": "jeanne",
"ower": "p\xe9p\xe9",
},
{"amount": 55.34, "currency": "XXX", "receiver": "jeanne", "ower": "tata"},
{"amount": 55.34, "receiver": "jeanne", "ower": "tata"},
{
"amount": 127.33,
"currency": "XXX",
"receiver": "jeanne",
"ower": "zorglub",
},
@ -471,10 +375,10 @@ class TestExport(IhatemoneyTestCase):
resp = self.client.get("/raclette/export/transactions.csv")
expected = [
"amount,currency,receiver,ower",
"2.0,XXX,jeanne,pépé",
"55.34,XXX,jeanne,tata",
"127.33,XXX,jeanne,zorglub",
"amount,receiver,ower",
"2.0,jeanne,pépé",
"55.34,jeanne,tata",
"127.33,jeanne,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")
@ -485,179 +389,8 @@ class TestExport(IhatemoneyTestCase):
resp = self.client.get("/raclette/export/transactions.wrong")
assert resp.status_code == 404
@pytest.mark.skip(reason="Currency conversion is broken")
def test_export_with_currencies(self):
self.post_project("raclette", default_currency="EUR")
# add participants
self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2})
self.client.post("/raclette/members/add", data={"name": "jeanne"})
self.client.post("/raclette/members/add", data={"name": "tata"})
self.client.post("/raclette/members/add", data={"name": "pépé"})
# create bills
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "à raclette",
"bill_type": "Expense",
"payer": 1,
"payed_for": [1, 2, 3, 4],
"amount": "10.0",
"original_currency": "EUR",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2016-12-31",
"what": "poutine from Québec",
"bill_type": "Expense",
"payer": 2,
"payed_for": [1, 3],
"amount": "100",
"original_currency": "CAD",
},
)
self.client.post(
"/raclette/add",
data={
"date": "2017-01-01",
"what": "refund",
"bill_type": "Reimbursement",
"payer": 3,
"payed_for": [2],
"amount": "13.33",
"original_currency": "EUR",
},
)
# generate json export of bills
resp = self.client.get("/raclette/export/bills.json")
expected = [
{
"date": "2017-01-01",
"what": "refund",
"bill_type": "Reimbursement",
"amount": 13.33,
"currency": "EUR",
"payer_name": "tata",
"payer_weight": 1.0,
"owers": ["jeanne"],
},
{
"date": "2016-12-31",
"what": "poutine from Qu\xe9bec",
"bill_type": "Expense",
"amount": 100.0,
"currency": "CAD",
"payer_name": "jeanne",
"payer_weight": 1.0,
"owers": ["zorglub", "tata"],
},
{
"date": "2016-12-31",
"what": "fromage \xe0 raclette",
"bill_type": "Expense",
"amount": 10.0,
"currency": "EUR",
"payer_name": "zorglub",
"payer_weight": 2.0,
"owers": ["zorglub", "jeanne", "tata", "p\xe9p\xe9"],
},
]
assert json.loads(resp.data.decode("utf-8")) == expected
# generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv")
expected = [
"date,what,bill_type,amount,currency,payer_name,payer_weight,owers",
"2017-01-01,refund,Reimbursement,13.33,EUR,tata,1.0,jeanne",
'2016-12-31,poutine from Québec,Expense,100.0,CAD,jeanne,1.0,"zorglub, tata"',
'2016-12-31,à raclette,Expense,10.0,EUR,zorglub,2.0,"zorglub, jeanne, tata, pépé"',
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
# generate json export of transactions (in EUR!)
resp = self.client.get("/raclette/export/transactions.json")
expected = [
{
"amount": 2.00,
"currency": "EUR",
"receiver": "jeanne",
"ower": "p\xe9p\xe9",
},
{"amount": 10.89, "currency": "EUR", "receiver": "jeanne", "ower": "tata"},
{
"amount": 38.45,
"currency": "EUR",
"receiver": "jeanne",
"ower": "zorglub",
},
]
assert json.loads(resp.data.decode("utf-8")) == expected
# generate csv export of transactions
resp = self.client.get("/raclette/export/transactions.csv")
expected = [
"amount,currency,receiver,ower",
"2.0,EUR,jeanne,pépé",
"10.89,EUR,jeanne,tata",
"38.45,EUR,jeanne,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
# Change project currency to CAD
project = self.get_project("raclette")
project.switch_currency("CAD")
# generate json export of transactions (now in CAD!)
resp = self.client.get("/raclette/export/transactions.json")
expected = [
{
"amount": 3.00,
"currency": "CAD",
"receiver": "jeanne",
"ower": "p\xe9p\xe9",
},
{"amount": 16.34, "currency": "CAD", "receiver": "jeanne", "ower": "tata"},
{
"amount": 57.67,
"currency": "CAD",
"receiver": "jeanne",
"ower": "zorglub",
},
]
assert json.loads(resp.data.decode("utf-8")) == expected
# generate csv export of transactions
resp = self.client.get("/raclette/export/transactions.csv")
expected = [
"amount,currency,receiver,ower",
"3.0,CAD,jeanne,pépé",
"16.34,CAD,jeanne,tata",
"57.67,CAD,jeanne,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")
for i, line in enumerate(expected):
assert set(line.split(",")) == set(received_lines[i].strip("\r").split(","))
def test_export_escape_formulae(self):
self.post_project("raclette", default_currency="EUR")
self.post_project("raclette")
# add participants
self.client.post("/raclette/members/add", data={"name": "zorglub"})
@ -672,15 +405,14 @@ class TestExport(IhatemoneyTestCase):
"payer": 1,
"payed_for": [1],
"amount": "10.0",
"original_currency": "EUR",
},
)
# generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv")
expected = [
"date,what,bill_type,amount,currency,payer_name,payer_weight,owers",
"2016-12-31,'=COS(36),Expense,10.0,EUR,zorglub,1.0,zorglub",
"date,what,bill_type,amount,payer_name,payer_weight,owers",
"2016-12-31,'=COS(36),Expense,10.0,zorglub,1.0,zorglub",
]
received_lines = resp.data.decode("utf-8").split("\n")

View file

@ -7,7 +7,6 @@ from sqlalchemy import orm
from werkzeug.security import check_password_hash
from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.manage import (
delete_project,
generate_config,
@ -376,7 +375,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "éùüß",
},
)
@ -391,7 +389,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
},
)
assert len(models.Project.query.all()) == 0
@ -403,7 +400,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "nope",
},
)
@ -416,7 +412,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette",
"password": "party",
"contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "euro",
},
)
@ -435,37 +430,3 @@ class TestCaptcha(IhatemoneyTestCase):
)
assert resp.status_code == 201
assert len(models.Project.query.all()) == 1
class TestCurrencyConverter:
converter = CurrencyConverter()
mock_data = {
"USD": 1,
"EUR": 0.8,
"CAD": 1.2,
"PLN": 4,
CurrencyConverter.no_currency: 1,
}
converter.get_rates = MagicMock(return_value=mock_data)
def test_only_one_instance(self):
one = id(CurrencyConverter())
two = id(CurrencyConverter())
assert one == two
def test_get_currencies(self):
currencies = self.converter.get_currencies()
for currency in ["USD", "EUR", "CAD", "PLN", CurrencyConverter.no_currency]:
assert currency in currencies
def test_exchange_currency(self):
result = self.converter.exchange_currency(100, "USD", "EUR")
assert result == 80.0
def test_failing_remote(self):
rates = {}
with patch("requests.Response.json", new=lambda _: {}):
# we need a non-patched converter, but it seems that MagickMock
# is mocking EVERY instance of the class method. Too bad.
rates = CurrencyConverter.get_rates(self.converter)
assert rates == {CurrencyConverter.no_currency: 1}

View file

@ -11,7 +11,6 @@ import smtplib
import socket
from babel import Locale
from babel.numbers import get_currency_name, get_currency_symbol
from flask import current_app, flash, redirect, render_template
from flask_babel import get_locale, lazy_gettext as _
from flask_limiter import Limiter
@ -309,7 +308,6 @@ def same_bill(bill1, bill2):
"payer_name",
"payer_weight",
"amount",
"currency",
"date",
"owers",
]
@ -410,21 +408,6 @@ def localize_list(items, surround_with_em=True):
return output_str.format(start_object=wrapped_items.pop())
def render_localized_currency(code, detailed=True):
# We cannot use CurrencyConvertor.no_currency here because of circular dependencies
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

@ -40,7 +40,6 @@ from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.emails import send_creation_email
from ihatemoney.forms import (
AdminAuthenticationForm,
@ -448,7 +447,6 @@ 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",
@ -479,7 +477,6 @@ def import_project():
attr = [
"amount",
"bill_type",
"currency",
"date",
"owers",
"payer_name",
@ -488,28 +485,22 @@ def import_project():
]
currencies = set()
for b in bills:
if b.get("currency", "") in ["", "XXX"]:
b["currency"] = g.project.default_currency
if "currency" in b.keys():
currencies.add(b["currency"])
del b["currency"]
for a in attr:
if a not in b:
raise ValueError(
_("Missing attribute: %(attribute)s", attribute=a)
)
currencies.add(b["currency"])
# Additional checks if project has no default currency
if g.project.default_currency == CurrencyConverter.no_currency:
# If bills have currencies, they must be consistent
if len(currencies - {CurrencyConverter.no_currency}) >= 2:
if len(currencies) > 1:
raise ValueError(
_(
"Cannot add bills in multiple currencies to a project without default "
"currency"
)
)
# Strip currency from bills (since it's the same for every bill)
for b in bills:
b["currency"] = CurrencyConverter.no_currency
g.project.import_bills(bills)
@ -863,7 +854,6 @@ def settle(amount, ower_id, payer_id):
date=datetime.datetime.today(),
owers=[Person.query.get(payer_id)],
payer_id=ower_id,
project_default_currency=g.project.default_currency,
bill_type=BillType.REIMBURSEMENT,
what=_("Settlement"),
)