This work is from the previous pr, I have merged it all together so its

possible to continue their fine work.

Add original currency and amount fields to Bill model

Add default currency to Project model

Add minor fix to String length

Reorganize the new migration file

Crete a migration file for Project.default_currency

Change models.py to have Project.default_currency

Added new fields to to_serialize()

Update bill view to display original currencies and original amounts

Minor change to new labels

Add new fields to tests

Include default_currency field in tests.py

Datafield entry in forms

adding a stray file

added requests to the requirements file

Added another exception for catching errors related to mail

Testing to find the issue

revert change

Adding a removed change to list_bills.html template

added currency conversion to the CurrencyConversion class

fixed typo in function name

Edit BillForm to have currency dropdown and convert functionality

Show currency dropdown in BillForm

Fix minor issues with BillForm task

Edit tests.py to include missing original_currency and amount fields

Fix failing tests

Fix other failing tests

Final fix to tests

Ran black on the project directory

Make small ammendments to tests

Temporarily set maxDiff as None

Fix test failure in test_export()

formatted util file so it could pass formatting tests

changed the structure of the table in the bill view to be more reader friendly, by adding the different curriencies in their respective columns

Cleaning up pr with comments from previous prs

Signed-off-by: dark0dave <dark0dave@mykolab.com>

Now caching respone for one day to ensure it is not call often

Signed-off-by: dark0dave <dark0dave@mykolab.com>

Updated migration and currency converter to help existing bills

Signed-off-by: dark0dave <dark0dave@mykolab.com>
This commit is contained in:
Sungho Cho 2019-12-06 15:30:48 -05:00 committed by dark0dave
parent 68f6df6abc
commit c6ace4f710
11 changed files with 299 additions and 10 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 @@
import requests
from cachetools import cached, TTLCache
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

@ -5,12 +5,15 @@ import email_validator
from flask import request from flask import request
from flask_babel import lazy_gettext as _ from flask_babel import lazy_gettext as _
from flask_wtf.file import FileAllowed, FileField, FileRequired from flask_wtf.file import FileAllowed, FileField, FileRequired
import copy
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 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.fields.core import Label
from wtforms.validators import ( from wtforms.validators import (
DataRequired, DataRequired,
Email, Email,
@ -22,6 +25,7 @@ from wtforms.validators import (
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
from ihatemoney.currency_convertor import CurrencyConverter
def strip_filter(string): def strip_filter(string):
@ -31,6 +35,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 +55,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 +122,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 +154,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 +168,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 +243,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 +270,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 +283,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 +295,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

@ -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

@ -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 ") }}{{ 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 patch, MagicMock
from flask import session from flask import session
from flask_testing import TestCase from flask_testing import TestCase
@ -15,6 +15,7 @@ from werkzeug.security import check_password_hash, generate_password_hash
from ihatemoney import history, models, utils from ihatemoney import history, models, utils
from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash
from ihatemoney.currency_convertor import CurrencyConverter
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)
@ -2850,5 +2878,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]