mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
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:
parent
162193c787
commit
f389c56259
13 changed files with 313 additions and 11 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,4 +1,3 @@
|
|||
ihatemoney.cfg
|
||||
*.pyc
|
||||
*.egg-info
|
||||
dist
|
||||
|
@ -13,3 +12,8 @@ build
|
|||
.pytest_cache
|
||||
ihatemoney/budget.db
|
||||
.idea/
|
||||
.envrc
|
||||
.DS_Store
|
||||
.idea
|
||||
.python-version
|
||||
|
||||
|
|
46
ihatemoney/currency_convertor.py
Normal file
46
ihatemoney/currency_convertor.py
Normal 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)
|
|
@ -1,3 +1,4 @@
|
|||
import copy
|
||||
from datetime import datetime
|
||||
from re import match
|
||||
|
||||
|
@ -8,7 +9,7 @@ from flask_wtf.file import FileAllowed, FileField, FileRequired
|
|||
from flask_wtf.form import FlaskForm
|
||||
from jinja2 import Markup
|
||||
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.simple import BooleanField, PasswordField, StringField, SubmitField
|
||||
from wtforms.validators import (
|
||||
|
@ -20,6 +21,7 @@ from wtforms.validators import (
|
|||
ValidationError,
|
||||
)
|
||||
|
||||
from ihatemoney.currency_convertor import CurrencyConverter
|
||||
from ihatemoney.models import LoggingMode, Person, Project
|
||||
from ihatemoney.utils import eval_arithmetic_expression, slugify
|
||||
|
||||
|
@ -31,6 +33,18 @@ def strip_filter(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):
|
||||
"""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)
|
||||
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]
|
||||
|
||||
form.payed_for.choices = form.payer.choices = active_members
|
||||
|
@ -89,6 +120,15 @@ class EditProjectForm(FlaskForm):
|
|||
contact_email = StringField(_("Email"), validators=[DataRequired(), Email()])
|
||||
project_history = BooleanField(_("Enable project history"))
|
||||
ip_recording = BooleanField(_("Use IP tracking for project history"))
|
||||
currency_helper = CurrencyConverter()
|
||||
default_currency = SelectField(
|
||||
_("Default Currency"),
|
||||
choices=[
|
||||
(currency_name, currency_name)
|
||||
for currency_name in currency_helper.get_currencies()
|
||||
],
|
||||
validators=[DataRequired()],
|
||||
)
|
||||
|
||||
@property
|
||||
def logging_preference(self):
|
||||
|
@ -112,6 +152,7 @@ class EditProjectForm(FlaskForm):
|
|||
password=generate_password_hash(self.password.data),
|
||||
contact_email=self.contact_email.data,
|
||||
logging_preference=self.logging_preference,
|
||||
default_currency=self.default_currency.data,
|
||||
)
|
||||
return project
|
||||
|
||||
|
@ -125,6 +166,7 @@ class EditProjectForm(FlaskForm):
|
|||
|
||||
project.contact_email = self.contact_email.data
|
||||
project.logging_preference = self.logging_preference
|
||||
project.default_currency = self.default_currency.data
|
||||
|
||||
return project
|
||||
|
||||
|
@ -199,6 +241,15 @@ class BillForm(FlaskForm):
|
|||
what = StringField(_("What?"), validators=[DataRequired()])
|
||||
payer = SelectField(_("Payer"), validators=[DataRequired()], coerce=int)
|
||||
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"),
|
||||
validators=[Optional()],
|
||||
|
@ -217,6 +268,10 @@ class BillForm(FlaskForm):
|
|||
bill.external_link = self.external_link.data
|
||||
bill.date = self.date.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
|
||||
|
||||
def fake_form(self, bill, project):
|
||||
|
@ -226,6 +281,10 @@ class BillForm(FlaskForm):
|
|||
bill.external_link = ""
|
||||
bill.date = self.date
|
||||
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
|
||||
|
||||
|
@ -234,6 +293,7 @@ class BillForm(FlaskForm):
|
|||
self.amount.data = bill.amount
|
||||
self.what.data = bill.what
|
||||
self.external_link.data = bill.external_link
|
||||
self.original_currency.data = bill.original_currency
|
||||
self.date.data = bill.date
|
||||
self.payed_for.data = [int(ower.id) for ower in bill.owers]
|
||||
|
||||
|
|
|
@ -105,6 +105,14 @@ def get_history(project, human_readable_names=True):
|
|||
if 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():
|
||||
if human_readable_names:
|
||||
if prop == "payer_id":
|
||||
|
|
|
@ -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 ###
|
|
@ -71,6 +71,7 @@ class Project(db.Model):
|
|||
members = db.relationship("Person", backref="project")
|
||||
|
||||
query_class = ProjectQuery
|
||||
default_currency = db.Column(db.String(3))
|
||||
|
||||
@property
|
||||
def _to_serialize(self):
|
||||
|
@ -80,6 +81,7 @@ class Project(db.Model):
|
|||
"contact_email": self.contact_email,
|
||||
"logging_preference": self.logging_preference.value,
|
||||
"members": [],
|
||||
"default_currency": self.default_currency,
|
||||
}
|
||||
|
||||
balance = self.balance
|
||||
|
@ -128,7 +130,10 @@ class Project(db.Model):
|
|||
{
|
||||
"member": member,
|
||||
"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(
|
||||
[
|
||||
|
@ -151,7 +156,7 @@ class Project(db.Model):
|
|||
"""
|
||||
monthly = defaultdict(lambda: defaultdict(float))
|
||||
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
|
||||
|
||||
@property
|
||||
|
@ -432,6 +437,9 @@ class Bill(db.Model):
|
|||
what = 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"))
|
||||
|
||||
@property
|
||||
|
@ -445,9 +453,11 @@ class Bill(db.Model):
|
|||
"creation_date": self.creation_date,
|
||||
"what": self.what,
|
||||
"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"""
|
||||
if self.owers:
|
||||
weights = (
|
||||
|
@ -455,13 +465,16 @@ class Bill(db.Model):
|
|||
.join(billowers, Bill)
|
||||
.filter(Bill.id == self.id)
|
||||
).scalar()
|
||||
return self.amount / weights
|
||||
return amount / weights
|
||||
else:
|
||||
return 0
|
||||
|
||||
def __str__(self):
|
||||
return self.what
|
||||
|
||||
def pay_each(self):
|
||||
return self.pay_each_default(self.converted_amount)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<Bill of {self.amount} from {self.payer} for "
|
||||
|
|
|
@ -10,6 +10,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix
|
|||
|
||||
from ihatemoney import default_settings
|
||||
from ihatemoney.api.v1 import api as apiv1
|
||||
from ihatemoney.currency_convertor import CurrencyConverter
|
||||
from ihatemoney.models import db
|
||||
from ihatemoney.utils import (
|
||||
IhmJSONEncoder,
|
||||
|
@ -137,6 +138,9 @@ def create_app(
|
|||
# Configure the a, root="main"pplication
|
||||
setup_database(app)
|
||||
|
||||
# Setup Currency Cache
|
||||
CurrencyConverter()
|
||||
|
||||
mail = Mail()
|
||||
mail.init_app(app)
|
||||
app.mail = mail
|
||||
|
|
|
@ -75,6 +75,7 @@
|
|||
{{ input(form.name) }}
|
||||
{{ input(form.password) }}
|
||||
{{ input(form.contact_email) }}
|
||||
{{ input(form.default_currency) }}
|
||||
{% if not home %}
|
||||
{{ submit(form.submit, home=True) }}
|
||||
{% endif %}
|
||||
|
@ -96,6 +97,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{ input(form.default_currency) }}
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">{{ _("Edit the project") }}</button>
|
||||
<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.payer, inline=True, class="form-control custom-select") }}
|
||||
{{ 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) }}
|
||||
|
||||
<div class="form-group row">
|
||||
|
|
|
@ -225,6 +225,10 @@
|
|||
{{ simple_property_change(event, _("Amount")) }}
|
||||
{% elif event.prop_changed == "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 %}
|
||||
{{ describe_object(event) }} {{ _("modified") }}
|
||||
{% endif %}
|
||||
|
|
|
@ -111,7 +111,19 @@
|
|||
<div class="clearfix"></div>
|
||||
|
||||
<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>
|
||||
{% for bill in bills.items %}
|
||||
<tr owers="{{bill.owers|join(',','id')}}" payer="{{bill.payer.id}}">
|
||||
|
@ -130,7 +142,14 @@
|
|||
{%- else -%}
|
||||
{{ bill.owers|join(', ', 'name') }}
|
||||
{%- 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">
|
||||
<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>
|
||||
|
|
|
@ -6,7 +6,7 @@ import json
|
|||
import os
|
||||
from time import sleep
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from flask import session
|
||||
from flask_testing import TestCase
|
||||
|
@ -14,6 +14,7 @@ from sqlalchemy import orm
|
|||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from ihatemoney import history, models, utils
|
||||
from ihatemoney.currency_convertor import CurrencyConverter
|
||||
from ihatemoney.manage import DeleteProject, GenerateConfig, GeneratePasswordHash
|
||||
from ihatemoney.run import create_app, db, load_configuration
|
||||
from ihatemoney.versioning import LoggingMode
|
||||
|
@ -59,6 +60,7 @@ class BaseTestCase(TestCase):
|
|||
"id": name,
|
||||
"password": name,
|
||||
"contact_email": f"{name}@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -68,6 +70,7 @@ class BaseTestCase(TestCase):
|
|||
name=str(name),
|
||||
password=generate_password_hash(name),
|
||||
contact_email=f"{name}@notmyidea.org",
|
||||
default_currency="USD",
|
||||
)
|
||||
models.db.session.add(project)
|
||||
models.db.session.commit()
|
||||
|
@ -254,6 +257,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"password": "party",
|
||||
"contact_email": "raclette@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -273,6 +277,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"id": "raclette", # already used !
|
||||
"password": "party",
|
||||
"contact_email": "raclette@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -290,6 +295,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"password": "party",
|
||||
"contact_email": "raclette@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -310,6 +316,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"password": "party",
|
||||
"contact_email": "raclette@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -329,6 +336,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"password": "party",
|
||||
"contact_email": "raclette@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -841,6 +849,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"contact_email": "alexis@notmyidea.org",
|
||||
"password": "didoudida",
|
||||
"logging_preference": LoggingMode.ENABLED.value,
|
||||
"default_currency": "USD",
|
||||
}
|
||||
|
||||
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.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"]))
|
||||
|
||||
# Editing a project with a wrong email address should fail
|
||||
|
@ -1099,6 +1109,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"payer": 1,
|
||||
"payed_for": [1, 2, 3, 4],
|
||||
"amount": "10.0",
|
||||
"original_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -1110,6 +1121,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"payer": 2,
|
||||
"payed_for": [1, 3],
|
||||
"amount": "200",
|
||||
"original_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -1121,6 +1133,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
|||
"payer": 3,
|
||||
"payed_for": [2],
|
||||
"amount": "13.33",
|
||||
"original_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -1425,6 +1438,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"id": id,
|
||||
"password": password,
|
||||
"contact_email": contact,
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -1486,6 +1500,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"password": "raclette",
|
||||
"contact_email": "not-an-email",
|
||||
"default_currency": "USD",
|
||||
},
|
||||
)
|
||||
|
||||
|
@ -1514,6 +1529,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"members": [],
|
||||
"name": "raclette",
|
||||
"contact_email": "raclette@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"id": "raclette",
|
||||
"logging_preference": 1,
|
||||
}
|
||||
|
@ -1525,6 +1541,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"/api/projects/raclette",
|
||||
data={
|
||||
"contact_email": "yeah@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"password": "raclette",
|
||||
"name": "The raclette party",
|
||||
"project_history": "y",
|
||||
|
@ -1542,6 +1559,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
expected = {
|
||||
"name": "The raclette party",
|
||||
"contact_email": "yeah@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"members": [],
|
||||
"id": "raclette",
|
||||
"logging_preference": 1,
|
||||
|
@ -1554,6 +1572,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"/api/projects/raclette",
|
||||
data={
|
||||
"contact_email": "yeah@notmyidea.org",
|
||||
"default_currency": "USD",
|
||||
"password": "tartiflette",
|
||||
"name": "The raclette party",
|
||||
},
|
||||
|
@ -1776,6 +1795,8 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"amount": 25.0,
|
||||
"date": "2011-08-10",
|
||||
"id": 1,
|
||||
"converted_amount": 25.0,
|
||||
"original_currency": "USD",
|
||||
"external_link": "https://raclette.fr",
|
||||
}
|
||||
|
||||
|
@ -1845,6 +1866,8 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"amount": 25.0,
|
||||
"date": "2011-09-10",
|
||||
"external_link": "https://raclette.fr",
|
||||
"converted_amount": 25.0,
|
||||
"original_currency": "USD",
|
||||
"id": 1,
|
||||
}
|
||||
|
||||
|
@ -1922,6 +1945,8 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"date": "2011-08-10",
|
||||
"id": id,
|
||||
"external_link": "",
|
||||
"original_currency": "USD",
|
||||
"converted_amount": expected_amount,
|
||||
}
|
||||
|
||||
got = json.loads(req.data.decode("utf-8"))
|
||||
|
@ -2064,6 +2089,8 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"date": "2011-08-10",
|
||||
"id": 1,
|
||||
"external_link": "",
|
||||
"converted_amount": 25.0,
|
||||
"original_currency": "USD",
|
||||
}
|
||||
got = json.loads(req.data.decode("utf-8"))
|
||||
self.assertEqual(
|
||||
|
@ -2106,6 +2133,7 @@ class APITestCase(IhatemoneyTestCase):
|
|||
"id": "raclette",
|
||||
"name": "raclette",
|
||||
"logging_preference": 1,
|
||||
"default_currency": "USD",
|
||||
}
|
||||
|
||||
self.assertStatus(200, req)
|
||||
|
@ -2273,6 +2301,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
|||
"name": "demo",
|
||||
"contact_email": "demo@notmyidea.org",
|
||||
"password": "demo",
|
||||
"default_currency": "USD",
|
||||
}
|
||||
|
||||
if logging_preference != LoggingMode.DISABLED:
|
||||
|
@ -2327,6 +2356,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
|||
"contact_email": "demo2@notmyidea.org",
|
||||
"password": "123456",
|
||||
"project_history": "y",
|
||||
"default_currency": "USD",
|
||||
}
|
||||
|
||||
resp = self.client.post("/demo/edit", data=new_data, follow_redirects=True)
|
||||
|
@ -2422,6 +2452,7 @@ class HistoryTestCase(IhatemoneyTestCase):
|
|||
"name": "demo2",
|
||||
"contact_email": "demo2@notmyidea.org",
|
||||
"password": "123456",
|
||||
"default_currency": "USD",
|
||||
}
|
||||
|
||||
# Keep privacy settings where they were
|
||||
|
@ -2850,5 +2881,23 @@ class HistoryTestCase(IhatemoneyTestCase):
|
|||
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__":
|
||||
unittest.main()
|
||||
|
|
|
@ -37,10 +37,10 @@ from sqlalchemy_continuum import Operation
|
|||
from werkzeug.exceptions import NotFound
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from ihatemoney.currency_convertor import CurrencyConverter
|
||||
from ihatemoney.forms import (
|
||||
AdminAuthenticationForm,
|
||||
AuthenticationForm,
|
||||
EditProjectForm,
|
||||
InviteForm,
|
||||
MemberForm,
|
||||
PasswordReminder,
|
||||
|
@ -48,6 +48,7 @@ from ihatemoney.forms import (
|
|||
ResetPasswordForm,
|
||||
UploadForm,
|
||||
get_billform_for,
|
||||
get_editprojectform_for,
|
||||
)
|
||||
from ihatemoney.history import get_history, get_history_queries
|
||||
from ihatemoney.models import Bill, LoggingMode, Person, Project, db
|
||||
|
@ -376,7 +377,7 @@ def reset_password():
|
|||
|
||||
@main.route("/<project_id>/edit", methods=["GET", "POST"])
|
||||
def edit_project():
|
||||
edit_form = EditProjectForm()
|
||||
edit_form = get_editprojectform_for(g.project)
|
||||
import_form = UploadForm()
|
||||
# Import form
|
||||
if import_form.validate_on_submit():
|
||||
|
@ -391,6 +392,18 @@ def edit_project():
|
|||
# Edit form
|
||||
if edit_form.validate_on_submit():
|
||||
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.commit()
|
||||
|
||||
|
@ -478,6 +491,7 @@ def import_project(file, project):
|
|||
form.date = parse(b["date"])
|
||||
form.payer = id_dict[b["payer_name"]]
|
||||
form.payed_for = owers_id
|
||||
form.original_currency = b.get("original_currency")
|
||||
|
||||
db.session.add(form.fake_form(bill, project))
|
||||
|
||||
|
@ -543,6 +557,7 @@ def demo():
|
|||
name="demonstration",
|
||||
password=generate_password_hash("demo"),
|
||||
contact_email="demo@notmyidea.org",
|
||||
default_currency="EUR",
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
|
|
|
@ -23,6 +23,7 @@ include_package_data = True
|
|||
zip_safe = False
|
||||
install_requires =
|
||||
blinker==1.4
|
||||
cachetools==4.1.0
|
||||
debts==0.5
|
||||
email_validator==1.0.5
|
||||
Flask-Babel==1.0.0
|
||||
|
@ -37,6 +38,7 @@ install_requires =
|
|||
Flask==1.1.2
|
||||
itsdangerous==1.1.0
|
||||
Jinja2==2.11.2
|
||||
requests==2.22.0
|
||||
SQLAlchemy-Continuum==1.3.9
|
||||
|
||||
[options.extras_require]
|
||||
|
|
Loading…
Reference in a new issue