This commit is contained in:
Alexis Métaireau 2024-12-28 01:17:31 +00:00 committed by GitHub
commit d052542c92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 162 additions and 1338 deletions

View file

@ -2,8 +2,9 @@
This document describes changes between each past release. 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) - Add support for python 3.12 (#757)
- Migrate from setup.cfg to pyproject.toml (#1243) - Migrate from setup.cfg to pyproject.toml (#1243)
- Update to wtforms 3.1 (#1248) - 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) - `password`: the project password / private code (string)
- `contact_email`: the contact email, used to recover the 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: Here is the command:
$ curl -X POST https://ihatemoney.org/api/projects \ $ curl -X POST https://ihatemoney.org/api/projects \
@ -97,7 +90,6 @@ Getting information about the project:
"id": "demo", "id": "demo",
"name": "demonstration", "name": "demonstration",
"contact_email": "demo@notmyidea.org", "contact_email": "demo@notmyidea.org",
"default_currency": "XXX",
"members": [{"id": 11515, "name": "f", "weight": 1.0, "activated": true, "balance": 0}, "members": [{"id": 11515, "name": "f", "weight": 1.0, "activated": true, "balance": 0},
{"id": 11531, "name": "g", "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}, {"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", "creation_date": "2021-01-13",
"what": "Raclette du nouvel an", "what": "Raclette du nouvel an",
"external_link": "", "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 Add a bill with a `POST` query on `/api/projects/<id>/bills`. You need
the following required parameters: the following required parameters:
@ -203,9 +189,6 @@ And optional parameters:
- `date`: the date of the bill (`yyyy-mm-dd` format). Defaults to - `date`: the date of the bill (`yyyy-mm-dd` format). Defaults to
current date if not provided. 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. - `external_link`: an optional URL associated with the bill.
Returns the id of the created 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 "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 werkzeug.security import check_password_hash
from wtforms.fields import BooleanField from wtforms.fields import BooleanField
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.emails import send_creation_email from ihatemoney.emails import send_creation_email
from ihatemoney.forms import EditProjectForm, MemberForm, ProjectForm, get_billform_for from ihatemoney.forms import EditProjectForm, MemberForm, ProjectForm, get_billform_for
from ihatemoney.models import Bill, Person, Project, db from ihatemoney.models import Bill, Person, Project, db
@ -50,13 +49,6 @@ def need_auth(f):
return wrapper return wrapper
class CurrenciesHandler(Resource):
currency_helper = CurrencyConverter()
def get(self):
return self.currency_helper.get_currencies()
class ProjectsHandler(Resource): class ProjectsHandler(Resource):
def post(self): def post(self):
form = ProjectForm(meta={"csrf": False}) form = ProjectForm(meta={"csrf": False})

View file

@ -5,7 +5,6 @@ from flask_restful import Api
from ihatemoney.api.common import ( from ihatemoney.api.common import (
BillHandler, BillHandler,
BillsHandler, BillsHandler,
CurrenciesHandler,
MemberHandler, MemberHandler,
MembersHandler, MembersHandler,
ProjectHandler, ProjectHandler,
@ -18,7 +17,6 @@ api = Blueprint("api", __name__, url_prefix="/api")
CORS(api) CORS(api)
restful_api = Api(api) restful_api = Api(api)
restful_api.add_resource(CurrenciesHandler, "/currencies")
restful_api.add_resource(ProjectsHandler, "/projects") restful_api.add_resource(ProjectsHandler, "/projects")
restful_api.add_resource(ProjectHandler, "/projects/<string:project_id>") restful_api.add_resource(ProjectHandler, "/projects/<string:project_id>")
restful_api.add_resource(TokenHandler, "/projects/<string:project_id>/token") 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, BooleanField,
DateField, DateField,
DecimalField, DecimalField,
Label,
PasswordField, PasswordField,
SelectField, SelectField,
SelectMultipleField, SelectMultipleField,
@ -38,13 +37,11 @@ from wtforms.validators import (
ValidationError, ValidationError,
) )
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project from ihatemoney.models import Bill, BillType, LoggingMode, Person, Project
from ihatemoney.utils import ( from ihatemoney.utils import (
em_surround, em_surround,
eval_arithmetic_expression, eval_arithmetic_expression,
generate_password_hash, generate_password_hash,
render_localized_currency,
slugify, slugify,
) )
@ -64,20 +61,6 @@ def get_billform_for(project, set_default=True, **kwargs):
""" """
form = BillForm(**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] 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()]) contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
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()
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): def validate_current_password(self, field):
project = Project.query.get(self.id.data) project = Project.query.get(self.id.data)
@ -180,28 +139,6 @@ class EditProjectForm(FlaskForm):
else: else:
return LoggingMode.ENABLED 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): def update(self, project):
"""Update the project with the information from the form""" """Update the project with the information from the form"""
project.name = self.name.data project.name = self.name.data
@ -217,10 +154,20 @@ class EditProjectForm(FlaskForm):
project.contact_email = self.contact_email.data project.contact_email = self.contact_email.data
project.logging_preference = self.logging_preference project.logging_preference = self.logging_preference
project.switch_currency(self.default_currency.data)
return project 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): class ImportProjectForm(FlaskForm):
file = FileField( file = FileField(
@ -258,7 +205,6 @@ class ProjectForm(EditProjectForm):
password=generate_password_hash(self.password.data), password=generate_password_hash(self.password.data),
contact_email=self.contact_email.data, contact_email=self.contact_email.data,
logging_preference=self.logging_preference, logging_preference=self.logging_preference,
default_currency=self.default_currency.data,
) )
return project return project
@ -352,8 +298,6 @@ class BillForm(FlaskForm):
what = StringField(_("What?"), validators=[DataRequired()]) what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int) payer = SelectField(_("Who paid?"), validators=[DataRequired()], coerce=int)
amount = CalculatorStringField(_("How much?"), validators=[DataRequired()]) amount = CalculatorStringField(_("How much?"), validators=[DataRequired()])
currency_helper = CurrencyConverter()
original_currency = SelectField(_("Currency"), validators=[DataRequired()])
external_link = URLField( external_link = URLField(
_("External link"), _("External link"),
default="", default="",
@ -377,10 +321,8 @@ class BillForm(FlaskForm):
amount=float(self.amount.data), amount=float(self.amount.data),
date=self.date.data, date=self.date.data,
external_link=self.external_link.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), owers=Person.query.get_by_ids(self.payed_for.data, project),
payer_id=self.payer.data, payer_id=self.payer.data,
project_default_currency=project.default_currency,
what=self.what.data, what=self.what.data,
bill_type=self.bill_type.data, bill_type=self.bill_type.data,
) )
@ -393,10 +335,6 @@ class BillForm(FlaskForm):
bill.external_link = self.external_link.data bill.external_link = self.external_link.data
bill.date = self.date.data bill.date = self.date.data
bill.owers = Person.query.get_by_ids(self.payed_for.data, project) 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 return bill
def fill(self, bill, project): def fill(self, bill, project):
@ -405,18 +343,9 @@ class BillForm(FlaskForm):
self.what.data = bill.what self.what.data = bill.what
self.bill_type.data = bill.bill_type self.bill_type.data = bill.bill_type
self.external_link.data = bill.external_link self.external_link.data = bill.external_link
self.original_currency.data = bill.original_currency
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
@ -425,17 +354,6 @@ class BillForm(FlaskForm):
# See https://github.com/python-babel/babel/issues/821 # See https://github.com/python-babel/babel/issues/821
raise ValidationError(f"Result is too high: {field.data}") 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): class MemberForm(FlaskForm):
name = StringField(_("Name"), validators=[DataRequired()], filters=[strip_filter]) 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, "amount": detailed_version.amount,
"owers": [describe_version(o) for o in detailed_version.owers], "owers": [describe_version(o) for o in detailed_version.owers],
"external_link": detailed_version.external_link, "external_link": detailed_version.external_link,
"original_currency": detailed_version.original_currency,
} }
common_properties["bill_details"] = details common_properties["bill_details"] = details

View file

@ -0,0 +1,78 @@
"""remove currencies
Revision ID: 3334e1f293b4
Revises: 7a9b38559992
Create Date: 2024-12-27 00:25:06.517970
"""
# revision identifiers, used by Alembic.
revision = '3334e1f293b4'
down_revision = '7a9b38559992'
from alembic import op
import sqlalchemy as sa
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('bill', schema=None) as batch_op:
batch_op.drop_column('converted_amount')
batch_op.drop_column('original_currency')
with op.batch_alter_table('bill_version', schema=None) as batch_op:
batch_op.alter_column('bill_type',
existing_type=sa.TEXT(),
type_=sa.Enum('EXPENSE', 'REIMBURSEMENT', name='billtype'),
existing_nullable=True,
autoincrement=False)
batch_op.drop_column('converted_amount')
batch_op.drop_column('original_currency')
with op.batch_alter_table('billowers', schema=None) as batch_op:
batch_op.alter_column('bill_id',
existing_type=sa.INTEGER(),
nullable=False)
batch_op.alter_column('person_id',
existing_type=sa.INTEGER(),
nullable=False)
with op.batch_alter_table('project', schema=None) as batch_op:
batch_op.drop_column('default_currency')
with op.batch_alter_table('project_version', schema=None) as batch_op:
batch_op.drop_column('default_currency')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('project_version', schema=None) as batch_op:
batch_op.add_column(sa.Column('default_currency', sa.VARCHAR(length=3), nullable=True))
with op.batch_alter_table('project', schema=None) as batch_op:
batch_op.add_column(sa.Column('default_currency', sa.VARCHAR(length=3), server_default=sa.text("('')"), nullable=True))
with op.batch_alter_table('billowers', schema=None) as batch_op:
batch_op.alter_column('person_id',
existing_type=sa.INTEGER(),
nullable=True)
batch_op.alter_column('bill_id',
existing_type=sa.INTEGER(),
nullable=True)
with op.batch_alter_table('bill_version', schema=None) as batch_op:
batch_op.add_column(sa.Column('original_currency', sa.VARCHAR(length=3), nullable=True))
batch_op.add_column(sa.Column('converted_amount', sa.FLOAT(), nullable=True))
batch_op.alter_column('bill_type',
existing_type=sa.Enum('EXPENSE', 'REIMBURSEMENT', name='billtype'),
type_=sa.TEXT(),
existing_nullable=True,
autoincrement=False)
with op.batch_alter_table('bill', schema=None) as batch_op:
batch_op.add_column(sa.Column('original_currency', sa.VARCHAR(length=3), server_default=sa.text("('')"), nullable=True))
batch_op.add_column(sa.Column('converted_amount', sa.FLOAT(), nullable=True))
# ### end Alembic commands ###

View file

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

View file

@ -20,7 +20,6 @@ from sqlalchemy.sql import func
from sqlalchemy_continuum import make_versioned, version_class from sqlalchemy_continuum import make_versioned, version_class
from sqlalchemy_continuum.plugins import FlaskPlugin from sqlalchemy_continuum.plugins import FlaskPlugin
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.monkeypath_continuum import PatchedTransactionFactory from ihatemoney.monkeypath_continuum import PatchedTransactionFactory
from ihatemoney.utils import generate_password_hash, get_members, same_bill from ihatemoney.utils import generate_password_hash, get_members, same_bill
from ihatemoney.versioning import ( from ihatemoney.versioning import (
@ -86,7 +85,6 @@ class Project(db.Model):
members = db.relationship("Person", backref="project") members = db.relationship("Person", backref="project")
query_class = ProjectQuery query_class = ProjectQuery
default_currency = db.Column(db.String(3))
@property @property
def _to_serialize(self): def _to_serialize(self):
@ -96,7 +94,6 @@ class Project(db.Model):
"contact_email": self.contact_email, "contact_email": self.contact_email,
"logging_preference": self.logging_preference.value, "logging_preference": self.logging_preference.value,
"members": [], "members": [],
"default_currency": self.default_currency,
} }
balance = self.balance balance = self.balance
@ -130,16 +127,14 @@ class Project(db.Model):
total_weight = sum(ower.weight for ower in bill.owers) total_weight = sum(ower.weight for ower in bill.owers)
if bill.bill_type == BillType.EXPENSE: 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: for ower in bill.owers:
should_pay[ower.id] += ( should_pay[ower.id] += ower.weight * bill.amount / total_weight
ower.weight * bill.converted_amount / total_weight
)
if bill.bill_type == BillType.REIMBURSEMENT: 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: for ower in bill.owers:
should_receive[ower.id] -= bill.converted_amount should_receive[ower.id] -= bill.amount
for person in self.members: for person in self.members:
balance = should_receive[person.id] - should_pay[person.id] balance = should_receive[person.id] - should_pay[person.id]
@ -183,7 +178,7 @@ class Project(db.Model):
monthly = defaultdict(lambda: defaultdict(float)) monthly = defaultdict(lambda: defaultdict(float))
for bill in self.get_bills_unordered().all(): for bill in self.get_bills_unordered().all():
if bill.bill_type == BillType.EXPENSE: 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 return monthly
@property @property
@ -204,7 +199,6 @@ class Project(db.Model):
"ower": transaction["ower"].name, "ower": transaction["ower"].name,
"receiver": transaction["receiver"].name, "receiver": transaction["receiver"].name,
"amount": round(transaction["amount"], 2), "amount": round(transaction["amount"], 2),
"currency": transaction["currency"],
} }
) )
return pretty_transactions return pretty_transactions
@ -217,7 +211,6 @@ class Project(db.Model):
"ower": members[ower_id], "ower": members[ower_id],
"receiver": members[receiver_id], "receiver": members[receiver_id],
"amount": amount, "amount": amount,
"currency": self.default_currency,
} }
for ower_id, amount, receiver_id in settle_plan 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 if the project do have bills or not"""
return self.get_bills_unordered().count() > 0 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): def get_bills_unordered(self):
"""Base query for bill list""" """Base query for bill list"""
# The subqueryload option allows to pre-load data from the # The subqueryload option allows to pre-load data from the
@ -344,7 +327,6 @@ class Project(db.Model):
"what": bill.what, "what": bill.what,
"bill_type": bill.bill_type.value, "bill_type": bill.bill_type.value,
"amount": round(bill.amount, 2), "amount": round(bill.amount, 2),
"currency": bill.original_currency,
"date": str(bill.date), "date": str(bill.date),
"payer_name": Person.query.get(bill.payer_id).name, "payer_name": Person.query.get(bill.payer_id).name,
"payer_weight": Person.query.get(bill.payer_id).weight, "payer_weight": Person.query.get(bill.payer_id).weight,
@ -353,41 +335,6 @@ class Project(db.Model):
) )
return pretty_bills 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): def import_bills(self, bills: list):
"""Import bills from a list of dictionaries""" """Import bills from a list of dictionaries"""
# Add members not already in the project # Add members not already in the project
@ -416,10 +363,8 @@ class Project(db.Model):
date=parse(b["date"]), date=parse(b["date"]),
bill_type=b["bill_type"], bill_type=b["bill_type"],
external_link="", external_link="",
original_currency=b["currency"],
owers=Person.query.get_by_names(b["owers"], self), owers=Person.query.get_by_names(b["owers"], self),
payer_id=id_dict[b["payer_name"]], payer_id=id_dict[b["payer_name"]],
project_default_currency=self.default_currency,
what=b["what"], what=b["what"],
) )
except Exception as e: except Exception as e:
@ -527,7 +472,6 @@ class Project(db.Model):
name="demonstration", name="demonstration",
password=generate_password_hash("demo"), password=generate_password_hash("demo"),
contact_email="demo@notmyidea.org", contact_email="demo@notmyidea.org",
default_currency="XXX",
) )
db.session.add(project) db.session.add(project)
db.session.commit() db.session.commit()
@ -554,10 +498,8 @@ class Project(db.Model):
Bill( Bill(
amount=amount, amount=amount,
bill_type=bill_type, bill_type=bill_type,
original_currency=project.default_currency,
owers=[members[name] for name in owers], owers=[members[name] for name in owers],
payer_id=members[payer].id, payer_id=members[payer].id,
project_default_currency=project.default_currency,
what=what, what=what,
) )
) )
@ -689,22 +631,15 @@ class Bill(db.Model):
bill_type = db.Column(db.Enum(BillType)) bill_type = db.Column(db.Enum(BillType))
external_link = db.Column(db.UnicodeText) 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")) archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
currency_helper = CurrencyConverter()
def __init__( def __init__(
self, self,
amount: float, amount: float,
date: datetime.datetime = None, date: datetime.datetime = None,
external_link: str = "", external_link: str = "",
original_currency: str = "",
owers: list = [], owers: list = [],
payer_id: int = None, payer_id: int = None,
project_default_currency: str = "",
what: str = "", what: str = "",
bill_type: str = "Expense", bill_type: str = "Expense",
): ):
@ -712,14 +647,10 @@ class Bill(db.Model):
self.amount = amount self.amount = amount
self.date = date self.date = date
self.external_link = external_link self.external_link = external_link
self.original_currency = original_currency
self.owers = owers self.owers = owers
self.payer_id = payer_id self.payer_id = payer_id
self.what = what self.what = what
self.bill_type = BillType(bill_type) self.bill_type = BillType(bill_type)
self.converted_amount = self.currency_helper.exchange_currency(
self.amount, self.original_currency, project_default_currency
)
@property @property
def _to_serialize(self): def _to_serialize(self):
@ -733,8 +664,6 @@ class Bill(db.Model):
"what": self.what, "what": self.what,
"bill_type": self.bill_type.value, "bill_type": self.bill_type.value,
"external_link": self.external_link, "external_link": self.external_link,
"original_currency": self.original_currency,
"converted_amount": self.converted_amount,
} }
def pay_each_default(self, 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 """Warning: this is slow, if you need to compute this for many bills, do
it differently (see balance_full function) it differently (see balance_full function)
""" """
return self.pay_each_default(self.converted_amount) return self.pay_each_default(self.amount)
def __repr__(self): def __repr__(self):
return ( return (

View file

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

View file

@ -99,7 +99,6 @@
</div> </div>
</div> </div>
{{ input(form.default_currency) }}
{{ input(form.current_password) }} {{ input(form.current_password) }}
<div class="actions"> <div class="actions">
<button class="btn btn-primary">{{ _("Save changes") }}</button> <button class="btn btn-primary">{{ _("Save changes") }}</button>
@ -198,9 +197,6 @@
<details class="mb-3"> <details class="mb-3">
<summary class="mb-2">{{ _("More options") }}</summary> <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) }} {{ input(form.external_link, inline=True) }}
</details> </details>
</fieldset> </fieldset>

View file

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

View file

@ -1,9 +1,9 @@
{% extends "sidebar_table_layout.html" %} {% extends "sidebar_table_layout.html" %}
{%- macro weighted_bill_amount(bill, weights, currency=bill.original_currency, amount=bill.amount) %} {%- macro weighted_bill_amount(bill, weights, amount=bill.amount) %}
{{ amount|currency(currency) }} {{ amount }}
{%- if weights != 1.0 %} {%- if weights != 1.0 %}
({{ _("%(amount)s each", amount=(amount / weights)|currency(currency)) }}) ({{ _("%(amount)s each", amount=(amount / weights)) }})
{%- endif -%} {%- endif -%}
{% endmacro -%} {% endmacro -%}
@ -142,10 +142,7 @@
{{ bill.owers|join(', ', 'name') }} {{ bill.owers|join(', ', 'name') }}
{%- endif %}</td> {%- endif %}</td>
<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) }} {{ weighted_bill_amount(bill, weights) }}
</span>
</td> </td>
<td class="bill-actions d-flex align-items-center"> <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> <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> <link>{{ url_for(".list_bills", _external=True) }}</link>
{% for (weights, bill) in bills.items -%} {% for (weights, bill) in bills.items -%}
<item> <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> <guid isPermaLink="false">{{ bill.id }}</guid>
<dc:creator>{{ bill.payer }}</dc:creator> <dc:creator>{{ bill.payer }}</dc:creator>
{% if bill.external_link %}<link>{{ bill.external_link }}</link>{% endif -%} {% 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> <pubDate>{{ bill.creation_date.strftime("%a, %d %b %Y %T") }} +0000</pubDate>
</item> </item>
{% endfor -%} {% endfor -%}

View file

@ -15,7 +15,7 @@
<tr receiver={{bill.receiver.id}}> <tr receiver={{bill.receiver.id}}>
<td>{{ bill.ower }}</td> <td>{{ bill.ower }}</td>
<td>{{ bill.receiver }}</td> <td>{{ bill.receiver }}</td>
<td>{{ bill.amount|currency }}</td> <td>{{ bill.amount }}</td>
<td> <td>
<span id="settle-bill" class="ml-auto pb-2"> <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"> <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 %}
{%- endif %} {%- endif %}
<td class="balance-value {% if balance[member.id]|round(2) > 0 %}positive{% elif balance[member.id]|round(2) < 0 %}negative{% 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> </td>
</tr> </tr>
{%- endfor %} {%- endfor %}

View file

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

View file

@ -2,8 +2,6 @@ import base64
import datetime import datetime
import json import json
import pytest
from ihatemoney.tests.common.help_functions import em_surround from ihatemoney.tests.common.help_functions import em_surround
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
@ -11,9 +9,7 @@ from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
class TestAPI(IhatemoneyTestCase): class TestAPI(IhatemoneyTestCase):
"""Tests the API""" """Tests the API"""
def api_create( def api_create(self, name, id=None, password=None, contact=None):
self, name, id=None, password=None, contact=None, default_currency=None
):
id = id or name id = id or name
password = password or name password = password or name
contact = contact or f"{name}@notmyidea.org" contact = contact or f"{name}@notmyidea.org"
@ -24,8 +20,6 @@ class TestAPI(IhatemoneyTestCase):
"password": password, "password": password,
"contact_email": contact, "contact_email": contact,
} }
if default_currency:
data["default_currency"] = default_currency
return self.client.post( return self.client.post(
"/api/projects", "/api/projects",
@ -90,7 +84,6 @@ class TestAPI(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "raclette", "password": "raclette",
"contact_email": "not-an-email", "contact_email": "not-an-email",
"default_currency": "XXX",
}, },
) )
@ -124,7 +117,6 @@ class TestAPI(IhatemoneyTestCase):
"members": [], "members": [],
"name": "raclette", "name": "raclette",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "XXX",
"id": "raclette", "id": "raclette",
"logging_preference": 1, "logging_preference": 1,
} }
@ -136,7 +128,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"password": "raclette", "password": "raclette",
"name": "The raclette party", "name": "The raclette party",
"project_history": "y", "project_history": "y",
@ -150,7 +141,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"current_password": "fromage aux patates", "current_password": "fromage aux patates",
"password": "raclette", "password": "raclette",
"name": "The raclette party", "name": "The raclette party",
@ -165,7 +155,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"current_password": "raclette", "current_password": "raclette",
"password": "raclette", "password": "raclette",
"name": "The raclette party", "name": "The raclette party",
@ -183,7 +172,6 @@ class TestAPI(IhatemoneyTestCase):
expected = { expected = {
"name": "The raclette party", "name": "The raclette party",
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"members": [], "members": [],
"id": "raclette", "id": "raclette",
"logging_preference": 1, "logging_preference": 1,
@ -196,7 +184,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"current_password": "raclette", "current_password": "raclette",
"password": "tartiflette", "password": "tartiflette",
"name": "The raclette party", "name": "The raclette party",
@ -250,7 +237,6 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "XXX",
"password": "tartiflette", "password": "tartiflette",
"name": "The raclette party", "name": "The raclette party",
}, },
@ -435,8 +421,6 @@ class TestAPI(IhatemoneyTestCase):
"amount": 25.0, "amount": 25.0,
"date": "2011-08-10", "date": "2011-08-10",
"id": 1, "id": 1,
"converted_amount": 25.0,
"original_currency": "XXX",
"external_link": "https://raclette.fr", "external_link": "https://raclette.fr",
} }
@ -507,8 +491,6 @@ class TestAPI(IhatemoneyTestCase):
"amount": 25.0, "amount": 25.0,
"date": "2011-09-10", "date": "2011-09-10",
"external_link": "https://raclette.fr", "external_link": "https://raclette.fr",
"converted_amount": 25.0,
"original_currency": "XXX",
"id": 1, "id": 1,
} }
@ -588,8 +570,6 @@ class TestAPI(IhatemoneyTestCase):
"date": "2011-08-10", "date": "2011-08-10",
"id": id, "id": id,
"external_link": "", "external_link": "",
"original_currency": "XXX",
"converted_amount": expected_amount,
} }
got = json.loads(req.data.decode("utf-8")) got = json.loads(req.data.decode("utf-8"))
@ -624,169 +604,6 @@ class TestAPI(IhatemoneyTestCase):
) )
self.assertStatus(400, req) 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): def test_statistics(self):
# create a project # create a project
self.api_create("raclette") self.api_create("raclette")
@ -896,8 +713,6 @@ class TestAPI(IhatemoneyTestCase):
"date": "2011-08-10", "date": "2011-08-10",
"id": 1, "id": 1,
"external_link": "", "external_link": "",
"converted_amount": 25.0,
"original_currency": "XXX",
} }
got = json.loads(req.data.decode("utf-8")) got = json.loads(req.data.decode("utf-8"))
assert ( assert (
@ -940,7 +755,6 @@ class TestAPI(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"name": "raclette", "name": "raclette",
"logging_preference": 1, "logging_preference": 1,
"default_currency": "XXX",
} }
self.assertStatus(200, req) self.assertStatus(200, req)

View file

@ -4,11 +4,9 @@ import re
from urllib.parse import unquote, urlparse, urlunparse from urllib.parse import unquote, urlparse, urlunparse
from flask import session, url_for from flask import session, url_for
import pytest
from werkzeug.security import check_password_hash from werkzeug.security import check_password_hash
from ihatemoney import models from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.tests.common.help_functions import extract_link from ihatemoney.tests.common.help_functions import extract_link
from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase
from ihatemoney.utils import generate_password_hash from ihatemoney.utils import generate_password_hash
@ -182,7 +180,6 @@ class TestBudget(IhatemoneyTestCase):
"contact_email": "zorglub@notmyidea.org", "contact_email": "zorglub@notmyidea.org",
"current_password": "raclette", "current_password": "raclette",
"password": "didoudida", "password": "didoudida",
"default_currency": "XXX",
}, },
follow_redirects=True, follow_redirects=True,
) )
@ -259,7 +256,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
follow_redirects=True, follow_redirects=True,
) )
@ -287,7 +283,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette", # already used ! "id": "raclette", # already used !
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -305,7 +300,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -326,7 +320,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -345,7 +338,6 @@ class TestBudget(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "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[0].id] = 8.12
result[self.get_project("raclette").members[1].id] = 0.0 result[self.get_project("raclette").members[1].id] = 0.0
result[self.get_project("raclette").members[2].id] = -8.12 result[self.get_project("raclette").members[2].id] = -8.12
# Since we're using floating point to store currency, we can have some # Since we're using floating point to store currency, we can have some
# rounding issues that prevent test from working. # rounding issues that prevent test from working.
# However, we should obtain the same values as the theoretical ones if we # 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", "contact_email": "zorglub@notmyidea.org",
"password": "didoudida", "password": "didoudida",
"logging_preference": LoggingMode.ENABLED.value, "logging_preference": LoggingMode.ENABLED.value,
"default_currency": "USD",
} }
# It should fail if we don't provide the current password # It should fail if we don't provide the current password
@ -988,7 +980,6 @@ class TestBudget(IhatemoneyTestCase):
project = self.get_project("raclette") project = self.get_project("raclette")
assert project.name != new_data["name"] assert project.name != new_data["name"]
assert project.contact_email != new_data["contact_email"] 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"]) assert not check_password_hash(project.password, new_data["password"])
# It should fail if we provide the wrong current password # It should fail if we provide the wrong current password
@ -998,7 +989,6 @@ class TestBudget(IhatemoneyTestCase):
project = self.get_project("raclette") project = self.get_project("raclette")
assert project.name != new_data["name"] assert project.name != new_data["name"]
assert project.contact_email != new_data["contact_email"] 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"]) assert not check_password_hash(project.password, new_data["password"])
# It should work if we give the current private code # It should work if we give the current private code
@ -1008,7 +998,6 @@ class TestBudget(IhatemoneyTestCase):
project = self.get_project("raclette") project = self.get_project("raclette")
assert project.name == new_data["name"] assert project.name == new_data["name"]
assert project.contact_email == new_data["contact_email"] 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"]) assert check_password_hash(project.password, new_data["password"])
# Editing a project with a wrong email address should fail # Editing a project with a wrong email address should fail
@ -1055,7 +1044,7 @@ class TestBudget(IhatemoneyTestCase):
def test_statistics(self): def test_statistics(self):
# Output is checked with the USD sign # Output is checked with the USD sign
self.post_project("raclette", default_currency="USD") self.post_project("raclette")
# add participants # add participants
self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2}) self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2})
@ -1117,18 +1106,18 @@ class TestBudget(IhatemoneyTestCase):
response = self.client.get("/raclette/statistics") response = self.client.get("/raclette/statistics")
regex = r"<td class=\"d-md-none\">{}</td>\s*<td>{}</td>\s*<td>{}</td>" regex = r"<td class=\"d-md-none\">{}</td>\s*<td>{}</td>\s*<td>{}</td>"
assert re.search( 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"), response.data.decode("utf-8"),
) )
assert re.search( 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"), response.data.decode("utf-8"),
) )
assert re.search( 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( 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 # 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() member = models.Person.query.filter(models.Person.id == 1).one_or_none()
assert member is 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): def test_amount_is_null(self):
self.post_project("raclette") self.post_project("raclette")
@ -1791,7 +1561,6 @@ class TestBudget(IhatemoneyTestCase):
"payed_for": [1], "payed_for": [1],
"bill_type": "Expense", "bill_type": "Expense",
"amount": "0", "amount": "0",
"original_currency": "XXX",
}, },
) )
@ -1836,7 +1605,6 @@ class TestBudget(IhatemoneyTestCase):
"payed_for": [1], "payed_for": [1],
"bill_type": "Expense", "bill_type": "Expense",
"amount": "9347242149381274732472348728748723473278472843.12", "amount": "9347242149381274732472348728748723473278472843.12",
"original_currency": "EUR",
}, },
) )
assert '<p class="alert alert-danger">' in resp.data.decode("utf-8") 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. 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": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"}) self.client.post("/raclette/members/add", data={"name": "peter"})
self.client.post("/raclette/members/add", data={"name": "steven"}) self.client.post("/raclette/members/add", data={"name": "steven"})
@ -1883,7 +1651,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 1, "payer": 1,
"payed_for": [1, 2, 3], "payed_for": [1, 2, 3],
"amount": "12", "amount": "12",
"original_currency": "EUR",
"bill_type": "Expense", "bill_type": "Expense",
}, },
) )
@ -1895,7 +1662,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2, "payer": 2,
"payed_for": [1, 2], "payed_for": [1, 2],
"amount": "15", "amount": "15",
"original_currency": "EUR",
"bill_type": "Expense", "bill_type": "Expense",
}, },
) )
@ -1907,7 +1673,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2, "payer": 2,
"payed_for": [1, 2], "payed_for": [1, 2],
"amount": "10", "amount": "10",
"original_currency": "EUR",
"bill_type": "Expense", "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" /> <atom:link href="http://localhost/raclette/feed/{token}.xml" rel="self" type="application/rss+xml" />
<link>http://localhost/raclette/</link> <link>http://localhost/raclette/</link>
<item> <item>
<title>fromage à raclette - 12.00</title> <title>fromage à raclette - 12.0</title>
<guid isPermaLink="false">1</guid> <guid isPermaLink="false">1</guid>
<dc:creator>george</dc:creator> <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 in content
) )
assert """<title>charcuterie - 15.00</title>""" in content assert "<title>charcuterie - 15.0</title>" in content
assert """<title>vin blanc - 10.00</title>""" in content assert "<title>vin blanc - 10.0</title>" in content
def test_rss_feed_history_disabled(self): def test_rss_feed_history_disabled(self):
""" """
Tests that RSS feeds is correctly rendered even if the project Tests that RSS feeds is correctly rendered even if the project
history is disabled. 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": "george"})
self.client.post("/raclette/members/add", data={"name": "peter"}) self.client.post("/raclette/members/add", data={"name": "peter"})
self.client.post("/raclette/members/add", data={"name": "steven"}) self.client.post("/raclette/members/add", data={"name": "steven"})
@ -1954,7 +1719,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 1, "payer": 1,
"payed_for": [1, 2, 3], "payed_for": [1, 2, 3],
"amount": "12", "amount": "12",
"original_currency": "EUR",
"bill_type": "Expense", "bill_type": "Expense",
}, },
) )
@ -1966,7 +1730,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2, "payer": 2,
"payed_for": [1, 2], "payed_for": [1, 2],
"amount": "15", "amount": "15",
"original_currency": "EUR",
"bill_type": "Expense", "bill_type": "Expense",
}, },
) )
@ -1978,7 +1741,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 2, "payer": 2,
"payed_for": [1, 2], "payed_for": [1, 2],
"amount": "10", "amount": "10",
"original_currency": "EUR",
"bill_type": "Expense", "bill_type": "Expense",
}, },
) )
@ -1988,8 +1750,8 @@ class TestBudget(IhatemoneyTestCase):
resp = self.client.get(f"/raclette/feed/{token}.xml") resp = self.client.get(f"/raclette/feed/{token}.xml")
content = resp.data.decode() content = resp.data.decode()
assert """<title>charcuterie - 15.00</title>""" in content assert """<title>charcuterie - 15.0</title>""" in content
assert """<title>vin blanc - 10.00</title>""" in content assert """<title>vin blanc - 10.0</title>""" in content
def test_rss_if_modified_since_header(self): def test_rss_if_modified_since_header(self):
# Project creation # Project creation
@ -2036,7 +1798,6 @@ class TestBudget(IhatemoneyTestCase):
"payer": 1, "payer": 1,
"payed_for": [1], "payed_for": [1],
"amount": "12", "amount": "12",
"original_currency": "XXX",
"bill_type": "Expense", "bill_type": "Expense",
}, },
follow_redirects=True, follow_redirects=True,
@ -2094,7 +1855,6 @@ class TestBudget(IhatemoneyTestCase):
"payed_for": [1], "payed_for": [1],
"amount": "12", "amount": "12",
"bill_type": "Expense", "bill_type": "Expense",
"original_currency": "XXX",
}, },
follow_redirects=True, follow_redirects=True,
) )
@ -2175,7 +1935,6 @@ class TestBudget(IhatemoneyTestCase):
"contact_email": "zorglub@notmyidea.org", "contact_email": "zorglub@notmyidea.org",
"current_password": "raclette", "current_password": "raclette",
"password": "didoudida", "password": "didoudida",
"default_currency": "XXX",
}, },
follow_redirects=True, follow_redirects=True,
) )

View file

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

View file

@ -1,11 +1,8 @@
from unittest.mock import MagicMock
from flask import Flask from flask import Flask
from jinja2 import FileSystemBytecodeCache from jinja2 import FileSystemBytecodeCache
import pytest import pytest
from ihatemoney.babel_utils import compile_catalogs from ihatemoney.babel_utils import compile_catalogs
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.run import create_app, db from ihatemoney.run import create_app, db
@ -44,21 +41,3 @@ def client(app: Flask, request: pytest.FixtureRequest):
request.cls.client = client request.cls.client = client
yield 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", "id": "demo",
"password": "demo", "password": "demo",
"contact_email": "demo@notmyidea.org", "contact_email": "demo@notmyidea.org",
"default_currency": "XXX",
"project_history": True, "project_history": True,
}, },
) )
@ -43,7 +42,6 @@ class TestHistory(IhatemoneyTestCase):
"contact_email": "demo@notmyidea.org", "contact_email": "demo@notmyidea.org",
"current_password": current_password, "current_password": current_password,
"password": "demo", "password": "demo",
"default_currency": "XXX",
} }
if logging_preference != LoggingMode.DISABLED: if logging_preference != LoggingMode.DISABLED:
@ -93,7 +91,6 @@ class TestHistory(IhatemoneyTestCase):
"current_password": "demo", "current_password": "demo",
"password": "123456", "password": "123456",
"project_history": "y", "project_history": "y",
"default_currency": "USD", # Currency changed from default
} }
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True) 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( assert resp.data.decode("utf-8").index("Project renamed ") < resp.data.decode(
"utf-8" "utf-8"
).index("Project private code changed") ).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") assert "127.0.0.1" not in resp.data.decode("utf-8")
def test_project_privacy_edit(self): def test_project_privacy_edit(self):
@ -184,7 +181,6 @@ class TestHistory(IhatemoneyTestCase):
"contact_email": "demo2@notmyidea.org", "contact_email": "demo2@notmyidea.org",
"current_password": "demo", "current_password": "demo",
"password": "123456", "password": "123456",
"default_currency": "USD",
} }
# Keep privacy settings where they were # 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 "Nothing to list" not in resp.data.decode("utf-8")
assert "Some entries below contain IP addresses," 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 assert resp.data.decode("utf-8").count("<td> -- </td>") == 1
# Generate more operations to confirm additional IP info isn't recorded # Generate more operations to confirm additional IP info isn't recorded
@ -315,8 +311,8 @@ class TestHistory(IhatemoneyTestCase):
resp = self.client.get("/demo/history") resp = self.client.get("/demo/history")
assert resp.status_code == 200 assert resp.status_code == 200
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>") == 7 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 # Ensure we can't clear IP data with a GET or with a password-less POST
resp = self.client.get("/demo/strip_ip_addresses") resp = self.client.get("/demo/strip_ip_addresses")
@ -326,8 +322,8 @@ class TestHistory(IhatemoneyTestCase):
resp = self.client.get("/demo/history") resp = self.client.get("/demo/history")
assert resp.status_code == 200 assert resp.status_code == 200
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>") == 7 assert resp.data.decode("utf-8").count("<td> -- </td>") == 6
# Clear IP Data # Clear IP Data
resp = self.client.post( resp = self.client.post(
@ -350,7 +346,7 @@ class TestHistory(IhatemoneyTestCase):
"utf-8" "utf-8"
) )
assert resp.data.decode("utf-8").count("127.0.0.1") == 0 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): def test_logs_for_common_actions(self):
# adds a member to this project # adds a member to this project
@ -638,7 +634,6 @@ class TestHistory(IhatemoneyTestCase):
"payed_for": [1], "payed_for": [1],
"bill_type": "Expense", "bill_type": "Expense",
"amount": "10", "amount": "10",
"original_currency": "EUR",
}, },
) )

View file

@ -81,50 +81,9 @@ class CommonTestCase(object):
for d in range(len(self.data)): for d in range(len(self.data)):
self.data[d]["currency"] = currencies[d] self.data[d]["currency"] = currencies[d]
def test_import_currencies_in_empty_project_with_currency(self): def test_import_single_currency_in_empty_project(self):
# Import JSON with currencies in an empty project with a default currency # Import JSON with a single currency in an empty project
# It should work by stripping the currency from bills.
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.
self.post_project("raclette") self.post_project("raclette")
self.login("raclette") self.login("raclette")
@ -153,8 +112,6 @@ class CommonTestCase(object):
if b["what"] == d["what"]: if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"] assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"] assert b["amount"] == d["amount"]
# Currency should have been stripped
assert b["currency"] == "XXX"
assert b["payer_weight"] == d["payer_weight"] assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"] assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"] assert b["bill_type"] == d["bill_type"]
@ -182,51 +139,7 @@ class CommonTestCase(object):
# Check that there are no bills # Check that there are no bills
assert len(bills) == 0 assert len(bills) == 0
def test_import_no_currency_in_empty_project_with_currency(self): def test_import_no_currency_in_empty_project(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.
self.post_project("raclette") self.post_project("raclette")
self.login("raclette") self.login("raclette")
@ -253,7 +166,6 @@ class CommonTestCase(object):
if b["what"] == d["what"]: if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"] assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"] assert b["amount"] == d["amount"]
assert b["currency"] == "XXX"
assert b["payer_weight"] == d["payer_weight"] assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"] assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"] 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)) self.import_project("raclette", self.generate_form_data(self.data))
bills = project.get_pretty_bills() bills = project.get_pretty_bills()
@ -311,7 +221,6 @@ class CommonTestCase(object):
if b["what"] == d["what"]: if b["what"] == d["what"]:
assert b["payer_name"] == d["payer_name"] assert b["payer_name"] == d["payer_name"]
assert b["amount"] == d["amount"] assert b["amount"] == d["amount"]
assert b["currency"] == d["currency"]
assert b["payer_weight"] == d["payer_weight"] assert b["payer_weight"] == d["payer_weight"]
assert b["date"] == d["date"] assert b["date"] == d["date"]
assert b["bill_type"] == d["bill_type"] assert b["bill_type"] == d["bill_type"]
@ -406,7 +315,6 @@ class TestExport(IhatemoneyTestCase):
"bill_type": "Reimbursement", "bill_type": "Reimbursement",
"what": "refund", "what": "refund",
"amount": 13.33, "amount": 13.33,
"currency": "XXX",
"payer_name": "tata", "payer_name": "tata",
"payer_weight": 1.0, "payer_weight": 1.0,
"owers": ["jeanne"], "owers": ["jeanne"],
@ -416,7 +324,6 @@ class TestExport(IhatemoneyTestCase):
"bill_type": "Expense", "bill_type": "Expense",
"what": "red wine", "what": "red wine",
"amount": 200.0, "amount": 200.0,
"currency": "XXX",
"payer_name": "jeanne", "payer_name": "jeanne",
"payer_weight": 1.0, "payer_weight": 1.0,
"owers": ["zorglub", "tata"], "owers": ["zorglub", "tata"],
@ -426,7 +333,6 @@ class TestExport(IhatemoneyTestCase):
"bill_type": "Expense", "bill_type": "Expense",
"what": "\xe0 raclette", "what": "\xe0 raclette",
"amount": 10.0, "amount": 10.0,
"currency": "XXX",
"payer_name": "zorglub", "payer_name": "zorglub",
"payer_weight": 2.0, "payer_weight": 2.0,
"owers": ["zorglub", "jeanne", "tata", "p\xe9p\xe9"], "owers": ["zorglub", "jeanne", "tata", "p\xe9p\xe9"],
@ -437,10 +343,10 @@ class TestExport(IhatemoneyTestCase):
# generate csv export of bills # generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv") resp = self.client.get("/raclette/export/bills.csv")
expected = [ expected = [
"date,what,bill_type,amount,currency,payer_name,payer_weight,owers", "date,what,bill_type,amount,payer_name,payer_weight,owers",
"2017-01-01,refund,Reimbursement,XXX,13.33,tata,1.0,jeanne", "2017-01-01,refund,Reimbursement,13.33,tata,1.0,jeanne",
'2016-12-31,red wine,Expense,XXX,200.0,jeanne,1.0,"zorglub, tata"', '2016-12-31,red wine,Expense,200.0,jeanne,1.0,"zorglub, tata"',
'2016-12-31,à raclette,Expense,10.0,XXX,zorglub,2.0,"zorglub, jeanne, tata, pépé"', '2016-12-31,à raclette,Expense,10.0,zorglub,2.0,"zorglub, jeanne, tata, pépé"',
] ]
received_lines = resp.data.decode("utf-8").split("\n") received_lines = resp.data.decode("utf-8").split("\n")
@ -452,14 +358,12 @@ class TestExport(IhatemoneyTestCase):
expected = [ expected = [
{ {
"amount": 2.00, "amount": 2.00,
"currency": "XXX",
"receiver": "jeanne", "receiver": "jeanne",
"ower": "p\xe9p\xe9", "ower": "p\xe9p\xe9",
}, },
{"amount": 55.34, "currency": "XXX", "receiver": "jeanne", "ower": "tata"}, {"amount": 55.34, "receiver": "jeanne", "ower": "tata"},
{ {
"amount": 127.33, "amount": 127.33,
"currency": "XXX",
"receiver": "jeanne", "receiver": "jeanne",
"ower": "zorglub", "ower": "zorglub",
}, },
@ -471,10 +375,10 @@ class TestExport(IhatemoneyTestCase):
resp = self.client.get("/raclette/export/transactions.csv") resp = self.client.get("/raclette/export/transactions.csv")
expected = [ expected = [
"amount,currency,receiver,ower", "amount,receiver,ower",
"2.0,XXX,jeanne,pépé", "2.0,jeanne,pépé",
"55.34,XXX,jeanne,tata", "55.34,jeanne,tata",
"127.33,XXX,jeanne,zorglub", "127.33,jeanne,zorglub",
] ]
received_lines = resp.data.decode("utf-8").split("\n") received_lines = resp.data.decode("utf-8").split("\n")
@ -485,179 +389,8 @@ class TestExport(IhatemoneyTestCase):
resp = self.client.get("/raclette/export/transactions.wrong") resp = self.client.get("/raclette/export/transactions.wrong")
assert resp.status_code == 404 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): def test_export_escape_formulae(self):
self.post_project("raclette", default_currency="EUR") self.post_project("raclette")
# add participants # add participants
self.client.post("/raclette/members/add", data={"name": "zorglub"}) self.client.post("/raclette/members/add", data={"name": "zorglub"})
@ -672,15 +405,14 @@ class TestExport(IhatemoneyTestCase):
"payer": 1, "payer": 1,
"payed_for": [1], "payed_for": [1],
"amount": "10.0", "amount": "10.0",
"original_currency": "EUR",
}, },
) )
# generate csv export of bills # generate csv export of bills
resp = self.client.get("/raclette/export/bills.csv") resp = self.client.get("/raclette/export/bills.csv")
expected = [ expected = [
"date,what,bill_type,amount,currency,payer_name,payer_weight,owers", "date,what,bill_type,amount,payer_name,payer_weight,owers",
"2016-12-31,'=COS(36),Expense,10.0,EUR,zorglub,1.0,zorglub", "2016-12-31,'=COS(36),Expense,10.0,zorglub,1.0,zorglub",
] ]
received_lines = resp.data.decode("utf-8").split("\n") 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 werkzeug.security import check_password_hash
from ihatemoney import models from ihatemoney import models
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.manage import ( from ihatemoney.manage import (
delete_project, delete_project,
generate_config, generate_config,
@ -376,7 +375,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "éùüß", "captcha": "éùüß",
}, },
) )
@ -391,7 +389,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
assert len(models.Project.query.all()) == 0 assert len(models.Project.query.all()) == 0
@ -403,7 +400,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "nope", "captcha": "nope",
}, },
) )
@ -416,7 +412,6 @@ class TestCaptcha(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"captcha": "euro", "captcha": "euro",
}, },
) )
@ -435,37 +430,3 @@ class TestCaptcha(IhatemoneyTestCase):
) )
assert resp.status_code == 201 assert resp.status_code == 201
assert len(models.Project.query.all()) == 1 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 import socket
from babel import Locale 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 import current_app, flash, redirect, render_template
from flask_babel import get_locale, lazy_gettext as _ from flask_babel import get_locale, lazy_gettext as _
from flask_limiter import Limiter from flask_limiter import Limiter
@ -309,7 +308,6 @@ def same_bill(bill1, bill2):
"payer_name", "payer_name",
"payer_weight", "payer_weight",
"amount", "amount",
"currency",
"date", "date",
"owers", "owers",
] ]
@ -410,21 +408,6 @@ def localize_list(items, surround_with_em=True):
return output_str.format(start_object=wrapped_items.pop()) 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): def render_localized_template(template_name_prefix, **context):
"""Like render_template(), but selects the right template according to the """Like render_template(), but selects the right template according to the
current user language. Fallback to English if a template for the current user language. Fallback to English if a template for the

View file

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