Feature/currencies (#541)

Now each project can have a currency, default to None.
Each bill can use a different currency, and a conversion to project default currency is done on settle.

Fix #512
This commit is contained in:
dark0dave 2020-04-29 21:57:08 +01:00 committed by GitHub
parent 162193c787
commit f389c56259
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 313 additions and 11 deletions

6
.gitignore vendored
View file

@ -1,4 +1,3 @@
ihatemoney.cfg
*.pyc *.pyc
*.egg-info *.egg-info
dist dist
@ -13,3 +12,8 @@ build
.pytest_cache .pytest_cache
ihatemoney/budget.db ihatemoney/budget.db
.idea/ .idea/
.envrc
.DS_Store
.idea
.python-version

View file

@ -0,0 +1,46 @@
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
default = "No Currency"
api_url = "https://api.exchangeratesapi.io/latest?base=USD"
def __init__(self):
pass
@cached(cache=TTLCache(maxsize=1, ttl=86400))
def get_rates(self):
rates = requests.get(self.api_url).json()["rates"]
rates[self.default] = 1.0
return rates
def get_currencies(self):
rates = [rate for rate in self.get_rates()]
rates.sort(key=lambda rate: "" if rate == self.default else rate)
return rates
def exchange_currency(self, amount, source_currency, dest_currency):
if (
source_currency == dest_currency
or source_currency == self.default
or dest_currency == self.default
):
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

@ -1,3 +1,4 @@
import copy
from datetime import datetime from datetime import datetime
from re import match from re import match
@ -8,7 +9,7 @@ from flask_wtf.file import FileAllowed, FileField, FileRequired
from flask_wtf.form import FlaskForm from flask_wtf.form import FlaskForm
from jinja2 import Markup from jinja2 import Markup
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from wtforms.fields.core import SelectField, SelectMultipleField from wtforms.fields.core import Label, SelectField, SelectMultipleField
from wtforms.fields.html5 import DateField, DecimalField, URLField from wtforms.fields.html5 import DateField, DecimalField, URLField
from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField from wtforms.fields.simple import BooleanField, PasswordField, StringField, SubmitField
from wtforms.validators import ( from wtforms.validators import (
@ -20,6 +21,7 @@ from wtforms.validators import (
ValidationError, ValidationError,
) )
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import LoggingMode, Person, Project from ihatemoney.models import LoggingMode, Person, Project
from ihatemoney.utils import eval_arithmetic_expression, slugify from ihatemoney.utils import eval_arithmetic_expression, slugify
@ -31,6 +33,18 @@ def strip_filter(string):
return string return string
def get_editprojectform_for(project, **kwargs):
"""Return an instance of EditProjectForm configured for a particular project.
"""
form = EditProjectForm(**kwargs)
choices = copy.copy(form.default_currency.choices)
choices.sort(
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
)
form.default_currency.choices = choices
return form
def get_billform_for(project, set_default=True, **kwargs): def get_billform_for(project, set_default=True, **kwargs):
"""Return an instance of BillForm configured for a particular project. """Return an instance of BillForm configured for a particular project.
@ -39,6 +53,23 @@ def get_billform_for(project, set_default=True, **kwargs):
""" """
form = BillForm(**kwargs) form = BillForm(**kwargs)
if form.original_currency.data == "None":
form.original_currency.data = project.default_currency
if form.original_currency.data != CurrencyConverter.default:
choices = copy.copy(form.original_currency.choices)
choices.remove((CurrencyConverter.default, CurrencyConverter.default))
choices.sort(
key=lambda rates: "" if rates[0] == project.default_currency else rates[0]
)
form.original_currency.choices = choices
else:
form.original_currency.render_kw = {"default": True}
form.original_currency.data = CurrencyConverter.default
form.original_currency.label = Label(
"original_currency", "Currency (Default: %s)" % (project.default_currency)
)
active_members = [(m.id, m.name) for m in project.active_members] active_members = [(m.id, m.name) for m in project.active_members]
form.payed_for.choices = form.payer.choices = active_members form.payed_for.choices = form.payer.choices = active_members
@ -89,6 +120,15 @@ 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"),
choices=[
(currency_name, currency_name)
for currency_name in currency_helper.get_currencies()
],
validators=[DataRequired()],
)
@property @property
def logging_preference(self): def logging_preference(self):
@ -112,6 +152,7 @@ class EditProjectForm(FlaskForm):
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
@ -125,6 +166,7 @@ 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.default_currency = self.default_currency.data
return project return project
@ -199,6 +241,15 @@ class BillForm(FlaskForm):
what = StringField(_("What?"), validators=[DataRequired()]) what = StringField(_("What?"), validators=[DataRequired()])
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int) payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()]) amount = CalculatorStringField(_("Amount paid"), validators=[DataRequired()])
currency_helper = CurrencyConverter()
original_currency = SelectField(
_("Currency"),
choices=[
(currency_name, currency_name)
for currency_name in currency_helper.get_currencies()
],
validators=[DataRequired()],
)
external_link = URLField( external_link = URLField(
_("External link"), _("External link"),
validators=[Optional()], validators=[Optional()],
@ -217,6 +268,10 @@ 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(ower, project) for ower in self.payed_for.data] bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data]
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 fake_form(self, bill, project): def fake_form(self, bill, project):
@ -226,6 +281,10 @@ class BillForm(FlaskForm):
bill.external_link = "" bill.external_link = ""
bill.date = self.date bill.date = self.date
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for] bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
bill.original_currency = CurrencyConverter.default
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
return bill return bill
@ -234,6 +293,7 @@ class BillForm(FlaskForm):
self.amount.data = bill.amount self.amount.data = bill.amount
self.what.data = bill.what self.what.data = bill.what
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]

View file

@ -105,6 +105,14 @@ def get_history(project, human_readable_names=True):
if removed: if removed:
changeset["owers_removed"] = (None, removed) changeset["owers_removed"] = (None, removed)
# Remove converted_amount if amount changed in the same way.
if (
"amount" in changeset
and "converted_amount" in changeset
and changeset["amount"] == changeset["converted_amount"]
):
del changeset["converted_amount"]
for (prop, (val_before, val_after),) in changeset.items(): for (prop, (val_before, val_after),) in changeset.items():
if human_readable_names: if human_readable_names:
if prop == "payer_id": if prop == "payer_id":

View file

@ -0,0 +1,73 @@
"""Add currencies
Revision ID: 927ed575acbd
Revises: cb038f79982e
Create Date: 2020-04-25 14:49:41.136602
"""
# revision identifiers, used by Alembic.
revision = "927ed575acbd"
down_revision = "cb038f79982e"
from alembic import op
import sqlalchemy as sa
from ihatemoney.currency_convertor import CurrencyConverter
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("bill", sa.Column("converted_amount", sa.Float(), nullable=True))
op.add_column(
"bill",
sa.Column(
"original_currency",
sa.String(length=3),
server_default=CurrencyConverter.default,
nullable=True,
),
)
op.add_column(
"bill_version",
sa.Column("converted_amount", sa.Float(), autoincrement=False, nullable=True),
)
op.add_column(
"bill_version",
sa.Column(
"original_currency", sa.String(length=3), autoincrement=False, nullable=True
),
)
op.add_column(
"project",
sa.Column(
"default_currency",
sa.String(length=3),
server_default=CurrencyConverter.default,
nullable=True,
),
)
op.add_column(
"project_version",
sa.Column(
"default_currency", sa.String(length=3), autoincrement=False, nullable=True
),
)
# ### end Alembic commands ###
op.execute(
"""
UPDATE bill
SET converted_amount = amount
WHERE converted_amount IS NULL
"""
)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("project_version", "default_currency")
op.drop_column("project", "default_currency")
op.drop_column("bill_version", "original_currency")
op.drop_column("bill_version", "converted_amount")
op.drop_column("bill", "original_currency")
op.drop_column("bill", "converted_amount")
# ### end Alembic commands ###

View file

@ -71,6 +71,7 @@ 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):
@ -80,6 +81,7 @@ 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
@ -128,7 +130,10 @@ class Project(db.Model):
{ {
"member": member, "member": member,
"paid": sum( "paid": sum(
[bill.amount for bill in self.get_member_bills(member.id).all()] [
bill.converted_amount
for bill in self.get_member_bills(member.id).all()
]
), ),
"spent": sum( "spent": sum(
[ [
@ -151,7 +156,7 @@ class Project(db.Model):
""" """
monthly = defaultdict(lambda: defaultdict(float)) monthly = defaultdict(lambda: defaultdict(float))
for bill in self.get_bills().all(): for bill in self.get_bills().all():
monthly[bill.date.year][bill.date.month] += bill.amount monthly[bill.date.year][bill.date.month] += bill.converted_amount
return monthly return monthly
@property @property
@ -432,6 +437,9 @@ class Bill(db.Model):
what = db.Column(db.UnicodeText) what = db.Column(db.UnicodeText)
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"))
@property @property
@ -445,9 +453,11 @@ class Bill(db.Model):
"creation_date": self.creation_date, "creation_date": self.creation_date,
"what": self.what, "what": self.what,
"external_link": self.external_link, "external_link": self.external_link,
"original_currency": self.original_currency,
"converted_amount": self.converted_amount,
} }
def pay_each(self): def pay_each_default(self, amount):
"""Compute what each share has to pay""" """Compute what each share has to pay"""
if self.owers: if self.owers:
weights = ( weights = (
@ -455,13 +465,16 @@ class Bill(db.Model):
.join(billowers, Bill) .join(billowers, Bill)
.filter(Bill.id == self.id) .filter(Bill.id == self.id)
).scalar() ).scalar()
return self.amount / weights return amount / weights
else: else:
return 0 return 0
def __str__(self): def __str__(self):
return self.what return self.what
def pay_each(self):
return self.pay_each_default(self.converted_amount)
def __repr__(self): def __repr__(self):
return ( return (
f"<Bill of {self.amount} from {self.payer} for " f"<Bill of {self.amount} from {self.payer} for "

View file

@ -10,6 +10,7 @@ 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,
@ -137,6 +138,9 @@ 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

View file

@ -75,6 +75,7 @@
{{ input(form.name) }} {{ input(form.name) }}
{{ input(form.password) }} {{ input(form.password) }}
{{ input(form.contact_email) }} {{ input(form.contact_email) }}
{{ input(form.default_currency) }}
{% if not home %} {% if not home %}
{{ submit(form.submit, home=True) }} {{ submit(form.submit, home=True) }}
{% endif %} {% endif %}
@ -96,6 +97,7 @@
</div> </div>
</div> </div>
{{ input(form.default_currency) }}
<div class="actions"> <div class="actions">
<button class="btn btn-primary">{{ _("Edit the project") }}</button> <button class="btn btn-primary">{{ _("Edit the project") }}</button>
<a id="delete-project" style="color:red; margin-left:10px; cursor:pointer; ">{{ _("delete") }}</a> <a id="delete-project" style="color:red; margin-left:10px; cursor:pointer; ">{{ _("delete") }}</a>
@ -122,6 +124,9 @@
{{ input(form.what, inline=True) }} {{ input(form.what, inline=True) }}
{{ input(form.payer, inline=True, class="form-control custom-select") }} {{ input(form.payer, inline=True, class="form-control custom-select") }}
{{ input(form.amount, inline=True) }} {{ input(form.amount, inline=True) }}
{% if not form.original_currency.render_kw %}
{{ input(form.original_currency, inline=True) }}
{% endif %}
{{ input(form.external_link, inline=True) }} {{ input(form.external_link, inline=True) }}
<div class="form-group row"> <div class="form-group row">

View file

@ -225,6 +225,10 @@
{{ simple_property_change(event, _("Amount")) }} {{ simple_property_change(event, _("Amount")) }}
{% elif event.prop_changed == "date" %} {% elif event.prop_changed == "date" %}
{{ simple_property_change(event, _("Date")) }} {{ simple_property_change(event, _("Date")) }}
{% elif event.prop_changed == "original_currency" %}
{{ simple_property_change(event, _("Currency")) }}
{% elif event.prop_changed == "converted_amount" %}
{{ simple_property_change(event, _("Amount in %(currency)s", currency=g.project.default_currency)) }}
{% else %} {% else %}
{{ describe_object(event) }} {{ _("modified") }} {{ describe_object(event) }} {{ _("modified") }}
{% endif %} {% endif %}

View file

@ -111,7 +111,19 @@
<div class="clearfix"></div> <div class="clearfix"></div>
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm"> <table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
<thead><tr><th>{{ _("When?") }}</th><th>{{ _("Who paid?") }}</<th><th>{{ _("For what?") }}</th><th>{{ _("For whom?") }}</th><th>{{ _("How much?") }}</th><th>{{ _("Actions") }}</th></tr></thead> <thead>
<tr><th>{{ _("When?") }}
</th><th>{{ _("Who paid?") }}
</th><th>{{ _("For what?") }}
</th><th>{{ _("For whom?") }}
</th><th>{{ _("How much?") }}
{% if g.project.default_currency != "No Currency" %}
</th><th>{{ _("Amount in %(currency)s", currency=g.project.default_currency) }}
{%- else -%}
</th><th>{{ _("Amount") }}
{% endif %}
</th><th>{{ _("Actions") }}</th></tr>
</thead>
<tbody> <tbody>
{% for bill in bills.items %} {% for bill in bills.items %}
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}"> <tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
@ -130,7 +142,14 @@
{%- else -%} {%- else -%}
{{ bill.owers|join(', ', 'name') }} {{ bill.owers|join(', ', 'name') }}
{%- endif %}</td> {%- endif %}</td>
<td>{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }})</td> <td>
{% if bill.original_currency != "No Currency" %}
{{ "%0.2f"|format(bill.amount) }} {{bill.original_currency}} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{bill.original_currency}} {{ _(" each") }})
{%- else -%}
{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each_default(bill.amount)) }} {{ _(" each") }})
{% endif %}
</td>
<td>{{ "%0.2f"|format(bill.converted_amount) }}</td>
<td class="bill-actions"> <td class="bill-actions">
<a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a> <a class="edit" href="{{ url_for(".edit_bill", bill_id=bill.id) }}" title="{{ _("edit") }}">{{ _('edit') }}</a>
<a class="delete" href="{{ url_for(".delete_bill", bill_id=bill.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a> <a class="delete" href="{{ url_for(".delete_bill", bill_id=bill.id) }}" title="{{ _("delete") }}">{{ _('delete') }}</a>

View file

@ -6,7 +6,7 @@ import json
import os import os
from time import sleep from time import sleep
import unittest import unittest
from unittest.mock import patch from unittest.mock import MagicMock, patch
from flask import session from flask import session
from flask_testing import TestCase from flask_testing import TestCase
@ -14,6 +14,7 @@ from sqlalchemy import orm
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import history, models, utils from ihatemoney import history, models, utils
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash
from ihatemoney.run import create_app, db, load_configuration from ihatemoney.run import create_app, db, load_configuration
from ihatemoney.versioning import LoggingMode from ihatemoney.versioning import LoggingMode
@ -59,6 +60,7 @@ class BaseTestCase(TestCase):
"id": name, "id": name,
"password": name, "password": name,
"contact_email": f"{name}@notmyidea.org", "contact_email": f"{name}@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -68,6 +70,7 @@ class BaseTestCase(TestCase):
name=str(name), name=str(name),
password=generate_password_hash(name), password=generate_password_hash(name),
contact_email=f"{name}@notmyidea.org", contact_email=f"{name}@notmyidea.org",
default_currency="USD",
) )
models.db.session.add(project) models.db.session.add(project)
models.db.session.commit() models.db.session.commit()
@ -254,6 +257,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -273,6 +277,7 @@ class BudgetTestCase(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",
}, },
) )
@ -290,6 +295,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -310,6 +316,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -329,6 +336,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "party", "password": "party",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
}, },
) )
@ -841,6 +849,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"contact_email": "alexis@notmyidea.org", "contact_email": "alexis@notmyidea.org",
"password": "didoudida", "password": "didoudida",
"logging_preference": LoggingMode.ENABLED.value, "logging_preference": LoggingMode.ENABLED.value,
"default_currency": "USD",
} }
resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True) resp = self.client.post("/raclette/edit", data=new_data, follow_redirects=True)
@ -849,6 +858,7 @@ class BudgetTestCase(IhatemoneyTestCase):
self.assertEqual(project.name, new_data["name"]) self.assertEqual(project.name, new_data["name"])
self.assertEqual(project.contact_email, new_data["contact_email"]) self.assertEqual(project.contact_email, new_data["contact_email"])
self.assertEqual(project.default_currency, new_data["default_currency"])
self.assertTrue(check_password_hash(project.password, new_data["password"])) self.assertTrue(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
@ -1099,6 +1109,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 1, "payer": 1,
"payed_for": [1, 2, 3, 4], "payed_for": [1, 2, 3, 4],
"amount": "10.0", "amount": "10.0",
"original_currency": "USD",
}, },
) )
@ -1110,6 +1121,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 2, "payer": 2,
"payed_for": [1, 3], "payed_for": [1, 3],
"amount": "200", "amount": "200",
"original_currency": "USD",
}, },
) )
@ -1121,6 +1133,7 @@ class BudgetTestCase(IhatemoneyTestCase):
"payer": 3, "payer": 3,
"payed_for": [2], "payed_for": [2],
"amount": "13.33", "amount": "13.33",
"original_currency": "USD",
}, },
) )
@ -1425,6 +1438,7 @@ class APITestCase(IhatemoneyTestCase):
"id": id, "id": id,
"password": password, "password": password,
"contact_email": contact, "contact_email": contact,
"default_currency": "USD",
}, },
) )
@ -1486,6 +1500,7 @@ class APITestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"password": "raclette", "password": "raclette",
"contact_email": "not-an-email", "contact_email": "not-an-email",
"default_currency": "USD",
}, },
) )
@ -1514,6 +1529,7 @@ class APITestCase(IhatemoneyTestCase):
"members": [], "members": [],
"name": "raclette", "name": "raclette",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"default_currency": "USD",
"id": "raclette", "id": "raclette",
"logging_preference": 1, "logging_preference": 1,
} }
@ -1525,6 +1541,7 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "USD",
"password": "raclette", "password": "raclette",
"name": "The raclette party", "name": "The raclette party",
"project_history": "y", "project_history": "y",
@ -1542,6 +1559,7 @@ class APITestCase(IhatemoneyTestCase):
expected = { expected = {
"name": "The raclette party", "name": "The raclette party",
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "USD",
"members": [], "members": [],
"id": "raclette", "id": "raclette",
"logging_preference": 1, "logging_preference": 1,
@ -1554,6 +1572,7 @@ class APITestCase(IhatemoneyTestCase):
"/api/projects/raclette", "/api/projects/raclette",
data={ data={
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"default_currency": "USD",
"password": "tartiflette", "password": "tartiflette",
"name": "The raclette party", "name": "The raclette party",
}, },
@ -1776,6 +1795,8 @@ class APITestCase(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": "USD",
"external_link": "https://raclette.fr", "external_link": "https://raclette.fr",
} }
@ -1845,6 +1866,8 @@ class APITestCase(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": "USD",
"id": 1, "id": 1,
} }
@ -1922,6 +1945,8 @@ class APITestCase(IhatemoneyTestCase):
"date": "2011-08-10", "date": "2011-08-10",
"id": id, "id": id,
"external_link": "", "external_link": "",
"original_currency": "USD",
"converted_amount": expected_amount,
} }
got = json.loads(req.data.decode("utf-8")) got = json.loads(req.data.decode("utf-8"))
@ -2064,6 +2089,8 @@ class APITestCase(IhatemoneyTestCase):
"date": "2011-08-10", "date": "2011-08-10",
"id": 1, "id": 1,
"external_link": "", "external_link": "",
"converted_amount": 25.0,
"original_currency": "USD",
} }
got = json.loads(req.data.decode("utf-8")) got = json.loads(req.data.decode("utf-8"))
self.assertEqual( self.assertEqual(
@ -2106,6 +2133,7 @@ class APITestCase(IhatemoneyTestCase):
"id": "raclette", "id": "raclette",
"name": "raclette", "name": "raclette",
"logging_preference": 1, "logging_preference": 1,
"default_currency": "USD",
} }
self.assertStatus(200, req) self.assertStatus(200, req)
@ -2273,6 +2301,7 @@ class HistoryTestCase(IhatemoneyTestCase):
"name": "demo", "name": "demo",
"contact_email": "demo@notmyidea.org", "contact_email": "demo@notmyidea.org",
"password": "demo", "password": "demo",
"default_currency": "USD",
} }
if logging_preference != LoggingMode.DISABLED: if logging_preference != LoggingMode.DISABLED:
@ -2327,6 +2356,7 @@ class HistoryTestCase(IhatemoneyTestCase):
"contact_email": "demo2@notmyidea.org", "contact_email": "demo2@notmyidea.org",
"password": "123456", "password": "123456",
"project_history": "y", "project_history": "y",
"default_currency": "USD",
} }
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True) resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
@ -2422,6 +2452,7 @@ class HistoryTestCase(IhatemoneyTestCase):
"name": "demo2", "name": "demo2",
"contact_email": "demo2@notmyidea.org", "contact_email": "demo2@notmyidea.org",
"password": "123456", "password": "123456",
"default_currency": "USD",
} }
# Keep privacy settings where they were # Keep privacy settings where they were
@ -2850,5 +2881,23 @@ class HistoryTestCase(IhatemoneyTestCase):
self.assertEqual(len(history_list), 6) self.assertEqual(len(history_list), 6)
class TestCurrencyConverter(unittest.TestCase):
converter = CurrencyConverter()
mock_data = {"USD": 1, "EUR": 0.8115}
converter.get_rates = MagicMock(return_value=mock_data)
def test_only_one_instance(self):
one = id(CurrencyConverter())
two = id(CurrencyConverter())
self.assertEqual(one, two)
def test_get_currencies(self):
self.assertCountEqual(self.converter.get_currencies(), ["USD", "EUR"])
def test_exchange_currency(self):
result = self.converter.exchange_currency(100, "USD", "EUR")
self.assertEqual(result, 81.15)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -37,10 +37,10 @@ from sqlalchemy_continuum import Operation
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.forms import ( from ihatemoney.forms import (
AdminAuthenticationForm, AdminAuthenticationForm,
AuthenticationForm, AuthenticationForm,
EditProjectForm,
InviteForm, InviteForm,
MemberForm, MemberForm,
PasswordReminder, PasswordReminder,
@ -48,6 +48,7 @@ from ihatemoney.forms import (
ResetPasswordForm, ResetPasswordForm,
UploadForm, UploadForm,
get_billform_for, get_billform_for,
get_editprojectform_for,
) )
from ihatemoney.history import get_history, get_history_queries from ihatemoney.history import get_history, get_history_queries
from ihatemoney.models import Bill, LoggingMode, Person, Project, db from ihatemoney.models import Bill, LoggingMode, Person, Project, db
@ -376,7 +377,7 @@ def reset_password():
@main.route("/<project_id>/edit", methods=["GET", "POST"]) @main.route("/<project_id>/edit", methods=["GET", "POST"])
def edit_project(): def edit_project():
edit_form = EditProjectForm() edit_form = get_editprojectform_for(g.project)
import_form = UploadForm() import_form = UploadForm()
# Import form # Import form
if import_form.validate_on_submit(): if import_form.validate_on_submit():
@ -391,6 +392,18 @@ def edit_project():
# Edit form # Edit form
if edit_form.validate_on_submit(): if edit_form.validate_on_submit():
project = edit_form.update(g.project) project = edit_form.update(g.project)
# Update converted currency
if project.default_currency != CurrencyConverter.default:
for bill in project.get_bills():
if bill.original_currency == CurrencyConverter.default:
bill.original_currency = project.default_currency
bill.converted_amount = CurrencyConverter().exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
db.session.add(bill)
db.session.add(project) db.session.add(project)
db.session.commit() db.session.commit()
@ -478,6 +491,7 @@ def import_project(file, project):
form.date = parse(b["date"]) form.date = parse(b["date"])
form.payer = id_dict[b["payer_name"]] form.payer = id_dict[b["payer_name"]]
form.payed_for = owers_id form.payed_for = owers_id
form.original_currency = b.get("original_currency")
db.session.add(form.fake_form(bill, project)) db.session.add(form.fake_form(bill, project))
@ -543,6 +557,7 @@ def demo():
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="EUR",
) )
db.session.add(project) db.session.add(project)
db.session.commit() db.session.commit()

View file

@ -23,6 +23,7 @@ include_package_data = True
zip_safe = False zip_safe = False
install_requires = install_requires =
blinker==1.4 blinker==1.4
cachetools==4.1.0
debts==0.5 debts==0.5
email_validator==1.0.5 email_validator==1.0.5
Flask-Babel==1.0.0 Flask-Babel==1.0.0
@ -37,6 +38,7 @@ install_requires =
Flask==1.1.2 Flask==1.1.2
itsdangerous==1.1.0 itsdangerous==1.1.0
Jinja2==2.11.2 Jinja2==2.11.2
requests==2.22.0
SQLAlchemy-Continuum==1.3.9 SQLAlchemy-Continuum==1.3.9
[options.extras_require] [options.extras_require]