diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 4abc86d3..fe1bdcc7 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -321,7 +321,7 @@ 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.no_currency + bill.original_currency = self.original_currency bill.converted_amount = self.currency_helper.exchange_currency( bill.amount, bill.original_currency, project.default_currency ) diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 04415fa6..68d1fd84 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -179,6 +179,7 @@ class Project(db.Model): "ower": transaction["ower"].name, "receiver": transaction["receiver"].name, "amount": round(transaction["amount"], 2), + "currency": transaction["currency"], } ) return pretty_transactions @@ -192,6 +193,7 @@ class Project(db.Model): "ower": members[ower_id], "receiver": members[receiver_id], "amount": amount, + "currency": self.default_currency, } for ower_id, amount, receiver_id in settle_plan ] @@ -269,6 +271,7 @@ class Project(db.Model): { "what": bill.what, "amount": round(bill.amount, 2), + "currency": bill.original_currency, "date": str(bill.date), "payer_name": Person.query.get(bill.payer_id).name, "payer_weight": Person.query.get(bill.payer_id).weight, diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 8073458d..62c5a5ae 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -1008,6 +1008,8 @@ class BudgetTestCase(IhatemoneyTestCase): ) def test_export(self): + # Export a simple project without currencies + self.post_project("raclette") # add members @@ -1025,7 +1027,6 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 1, "payed_for": [1, 2, 3, 4], "amount": "10.0", - "original_currency": "USD", }, ) @@ -1037,7 +1038,6 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 2, "payed_for": [1, 3], "amount": "200", - "original_currency": "USD", }, ) @@ -1049,7 +1049,6 @@ class BudgetTestCase(IhatemoneyTestCase): "payer": 3, "payed_for": [2], "amount": "13.33", - "original_currency": "USD", }, ) @@ -1060,6 +1059,7 @@ class BudgetTestCase(IhatemoneyTestCase): "date": "2017-01-01", "what": "refund", "amount": 13.33, + "currency": "XXX", "payer_name": "tata", "payer_weight": 1.0, "owers": ["fred"], @@ -1068,6 +1068,7 @@ class BudgetTestCase(IhatemoneyTestCase): "date": "2016-12-31", "what": "red wine", "amount": 200.0, + "currency": "XXX", "payer_name": "fred", "payer_weight": 1.0, "owers": ["zorglub", "tata"], @@ -1076,6 +1077,7 @@ class BudgetTestCase(IhatemoneyTestCase): "date": "2016-12-31", "what": "fromage \xe0 raclette", "amount": 10.0, + "currency": "XXX", "payer_name": "zorglub", "payer_weight": 2.0, "owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"], @@ -1086,10 +1088,10 @@ class BudgetTestCase(IhatemoneyTestCase): # generate csv export of bills resp = self.client.get("/raclette/export/bills.csv") expected = [ - "date,what,amount,payer_name,payer_weight,owers", - "2017-01-01,refund,13.33,tata,1.0,fred", - '2016-12-31,red wine,200.0,fred,1.0,"zorglub, tata"', - '2016-12-31,fromage à raclette,10.0,zorglub,2.0,"zorglub, fred, tata, pépé"', + "date,what,amount,currency,payer_name,payer_weight,owers", + "2017-01-01,refund,XXX,13.33,tata,1.0,fred", + '2016-12-31,red wine,XXX,200.0,fred,1.0,"zorglub, tata"', + '2016-12-31,fromage à raclette,10.0,XXX,zorglub,2.0,"zorglub, fred, tata, pépé"', ] received_lines = resp.data.decode("utf-8").split("\n") @@ -1101,9 +1103,19 @@ class BudgetTestCase(IhatemoneyTestCase): # generate json export of transactions resp = self.client.get("/raclette/export/transactions.json") expected = [ - {"amount": 2.00, "receiver": "fred", "ower": "p\xe9p\xe9"}, - {"amount": 55.34, "receiver": "fred", "ower": "tata"}, - {"amount": 127.33, "receiver": "fred", "ower": "zorglub"}, + { + "amount": 2.00, + "currency": "XXX", + "receiver": "fred", + "ower": "p\xe9p\xe9", + }, + {"amount": 55.34, "currency": "XXX", "receiver": "fred", "ower": "tata"}, + { + "amount": 127.33, + "currency": "XXX", + "receiver": "fred", + "ower": "zorglub", + }, ] self.assertEqual(json.loads(resp.data.decode("utf-8")), expected) @@ -1112,10 +1124,10 @@ class BudgetTestCase(IhatemoneyTestCase): resp = self.client.get("/raclette/export/transactions.csv") expected = [ - "amount,receiver,ower", - "2.0,fred,pépé", - "55.34,fred,tata", - "127.33,fred,zorglub", + "amount,currency,receiver,ower", + "2.0,XXX,fred,pépé", + "55.34,XXX,fred,tata", + "127.33,XXX,fred,zorglub", ] received_lines = resp.data.decode("utf-8").split("\n") @@ -1128,9 +1140,244 @@ class BudgetTestCase(IhatemoneyTestCase): resp = self.client.get("/raclette/export/transactions.wrong") self.assertEqual(resp.status_code, 404) + def test_export_with_currencies(self): + self.post_project("raclette", default_currency="EUR") + + # add members + self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 2}) + self.client.post("/raclette/members/add", data={"name": "fred"}) + self.client.post("/raclette/members/add", data={"name": "tata"}) + self.client.post("/raclette/members/add", data={"name": "pépé"}) + + # create bills + self.client.post( + "/raclette/add", + data={ + "date": "2016-12-31", + "what": "fromage à raclette", + "payer": 1, + "payed_for": [1, 2, 3, 4], + "amount": "10.0", + "original_currency": "EUR", + }, + ) + + self.client.post( + "/raclette/add", + data={ + "date": "2016-12-31", + "what": "poutine from Québec", + "payer": 2, + "payed_for": [1, 3], + "amount": "100", + "original_currency": "CAD", + }, + ) + + self.client.post( + "/raclette/add", + data={ + "date": "2017-01-01", + "what": "refund", + "payer": 3, + "payed_for": [2], + "amount": "13.33", + "original_currency": "EUR", + }, + ) + + # generate json export of bills + resp = self.client.get("/raclette/export/bills.json") + expected = [ + { + "date": "2017-01-01", + "what": "refund", + "amount": 13.33, + "currency": "EUR", + "payer_name": "tata", + "payer_weight": 1.0, + "owers": ["fred"], + }, + { + "date": "2016-12-31", + "what": "poutine from Qu\xe9bec", + "amount": 100.0, + "currency": "CAD", + "payer_name": "fred", + "payer_weight": 1.0, + "owers": ["zorglub", "tata"], + }, + { + "date": "2016-12-31", + "what": "fromage \xe0 raclette", + "amount": 10.0, + "currency": "EUR", + "payer_name": "zorglub", + "payer_weight": 2.0, + "owers": ["zorglub", "fred", "tata", "p\xe9p\xe9"], + }, + ] + self.assertEqual(json.loads(resp.data.decode("utf-8")), expected) + + # generate csv export of bills + resp = self.client.get("/raclette/export/bills.csv") + expected = [ + "date,what,amount,currency,payer_name,payer_weight,owers", + "2017-01-01,refund,13.33,EUR,tata,1.0,fred", + '2016-12-31,poutine from Québec,100.0,CAD,fred,1.0,"zorglub, tata"', + '2016-12-31,fromage à raclette,10.0,EUR,zorglub,2.0,"zorglub, fred, tata, pépé"', + ] + received_lines = resp.data.decode("utf-8").split("\n") + + for i, line in enumerate(expected): + self.assertEqual( + set(line.split(",")), set(received_lines[i].strip("\r").split(",")) + ) + + # generate json export of transactions (in EUR!) + resp = self.client.get("/raclette/export/transactions.json") + expected = [ + { + "amount": 2.00, + "currency": "EUR", + "receiver": "fred", + "ower": "p\xe9p\xe9", + }, + {"amount": 10.89, "currency": "EUR", "receiver": "fred", "ower": "tata"}, + {"amount": 38.45, "currency": "EUR", "receiver": "fred", "ower": "zorglub"}, + ] + + self.assertEqual(json.loads(resp.data.decode("utf-8")), expected) + + # generate csv export of transactions + resp = self.client.get("/raclette/export/transactions.csv") + + expected = [ + "amount,currency,receiver,ower", + "2.0,EUR,fred,pépé", + "10.89,EUR,fred,tata", + "38.45,EUR,fred,zorglub", + ] + received_lines = resp.data.decode("utf-8").split("\n") + + for i, line in enumerate(expected): + self.assertEqual( + set(line.split(",")), set(received_lines[i].strip("\r").split(",")) + ) + + # Change project currency to CAD + project = models.Project.query.get("raclette") + project.switch_currency("CAD") + + # generate json export of transactions (now in CAD!) + resp = self.client.get("/raclette/export/transactions.json") + expected = [ + { + "amount": 3.00, + "currency": "CAD", + "receiver": "fred", + "ower": "p\xe9p\xe9", + }, + {"amount": 16.34, "currency": "CAD", "receiver": "fred", "ower": "tata"}, + {"amount": 57.67, "currency": "CAD", "receiver": "fred", "ower": "zorglub"}, + ] + + self.assertEqual(json.loads(resp.data.decode("utf-8")), expected) + + # generate csv export of transactions + resp = self.client.get("/raclette/export/transactions.csv") + + expected = [ + "amount,currency,receiver,ower", + "3.0,CAD,fred,pépé", + "16.34,CAD,fred,tata", + "57.67,CAD,fred,zorglub", + ] + received_lines = resp.data.decode("utf-8").split("\n") + + for i, line in enumerate(expected): + self.assertEqual( + set(line.split(",")), set(received_lines[i].strip("\r").split(",")) + ) + def test_import_new_project(self): # Import JSON in an empty project + self.post_project("raclette", default_currency="EUR") + self.login("raclette") + + project = models.Project.query.get("raclette") + + json_to_import = [ + { + "date": "2017-01-01", + "what": "refund", + "amount": 13.33, + "currency": "EUR", + "payer_name": "tata", + "payer_weight": 1.0, + "owers": ["fred"], + }, + { + "date": "2016-12-31", + "what": "poutine from québec", + "amount": 50.0, + "currency": "CAD", + "payer_name": "fred", + "payer_weight": 1.0, + "owers": ["zorglub", "tata"], + }, + { + "date": "2016-12-31", + "what": "fromage a raclette", + "amount": 10.0, + "currency": "EUR", + "payer_name": "zorglub", + "payer_weight": 2.0, + "owers": ["zorglub", "fred", "tata", "pepe"], + }, + ] + + from ihatemoney.web import import_project + + file = io.StringIO() + json.dump(json_to_import, file) + file.seek(0) + import_project(file, project) + + bills = project.get_pretty_bills() + + # Check if all bills has been add + self.assertEqual(len(bills), len(json_to_import)) + + # Check if name of bills are ok + b = [e["what"] for e in bills] + b.sort() + ref = [e["what"] for e in json_to_import] + ref.sort() + + self.assertEqual(b, ref) + + # Check if other informations in bill are ok + for i in json_to_import: + for j in bills: + if j["what"] == i["what"]: + self.assertEqual(j["payer_name"], i["payer_name"]) + self.assertEqual(j["amount"], i["amount"]) + self.assertEqual(j["currency"], i["currency"]) + self.assertEqual(j["payer_weight"], i["payer_weight"]) + self.assertEqual(j["date"], i["date"]) + + list_project = [ower for ower in j["owers"]] + list_project.sort() + list_json = [ower for ower in i["owers"]] + list_json.sort() + + self.assertEqual(list_project, list_json) + + def test_import_without_currencies(self): + # Import JSON without currencies (from ihatemoney < 5) in an empty project + self.post_project("raclette") self.login("raclette") @@ -1189,6 +1436,7 @@ class BudgetTestCase(IhatemoneyTestCase): if j["what"] == i["what"]: self.assertEqual(j["payer_name"], i["payer_name"]) self.assertEqual(j["amount"], i["amount"]) + self.assertEqual(j["currency"], "XXX") self.assertEqual(j["payer_weight"], i["payer_weight"]) self.assertEqual(j["date"], i["date"]) @@ -1226,6 +1474,7 @@ class BudgetTestCase(IhatemoneyTestCase): "date": "2017-01-01", "what": "refund", "amount": 13.33, + "currency": "XXX", "payer_name": "tata", "payer_weight": 1.0, "owers": ["fred"], @@ -1234,6 +1483,7 @@ class BudgetTestCase(IhatemoneyTestCase): "date": "2016-12-31", "what": "red wine", "amount": 200.0, + "currency": "XXX", "payer_name": "fred", "payer_weight": 1.0, "owers": ["zorglub", "tata"], @@ -1242,6 +1492,7 @@ class BudgetTestCase(IhatemoneyTestCase): "date": "2016-12-31", "what": "fromage a raclette", "amount": 10.0, + "currency": "XXX", "payer_name": "zorglub", "payer_weight": 2.0, "owers": ["zorglub", "fred", "tata", "pepe"], @@ -1274,6 +1525,7 @@ class BudgetTestCase(IhatemoneyTestCase): if j["what"] == i["what"]: self.assertEqual(j["payer_name"], i["payer_name"]) self.assertEqual(j["amount"], i["amount"]) + self.assertEqual(j["currency"], i["currency"]) self.assertEqual(j["payer_weight"], i["payer_weight"]) self.assertEqual(j["date"], i["date"]) diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index 7c840f46..66f5b6a4 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -271,7 +271,7 @@ def get_members(file): def same_bill(bill1, bill2): - attr = ["what", "payer_name", "payer_weight", "amount", "date", "owers"] + attr = ["what", "payer_name", "payer_weight", "amount", "currency", "date", "owers"] for a in attr: if bill1[a] != bill2[a]: return False @@ -370,6 +370,7 @@ def localize_list(items, surround_with_em=True): def render_localized_currency(code, detailed=True): + # We cannot use CurrencyConvertor.no_currency here because of circular dependencies if code == "XXX": return _("No Currency") locale = get_locale() or "en_US" diff --git a/ihatemoney/web.py b/ihatemoney/web.py index ccc5304c..63692110 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -36,6 +36,7 @@ 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, @@ -412,8 +413,8 @@ def edit_project(): flash(_("Project successfully uploaded")) return redirect(url_for("main.list_bills")) - except ValueError: - flash(_("Invalid JSON"), category="danger") + except ValueError as e: + flash(e.args[0], category="danger") # Edit form if edit_form.validate_on_submit(): @@ -447,17 +448,45 @@ def import_project(file, project): json_file = json.load(file) # Check if JSON is correct - attr = ["what", "payer_name", "payer_weight", "amount", "date", "owers"] + attr = ["what", "payer_name", "payer_weight", "amount", "currency", "date", "owers"] attr.sort() + currencies = set() for e in json_file: + # Add compatibility with versions < 5 that did not have currencies + if "currency" not in e: + e["currency"] = CurrencyConverter.no_currency + # Empty currency should be converted to "XXX" + if e["currency"] == "": + e["currency"] = CurrencyConverter.no_currency if len(e) != len(attr): - raise ValueError + raise ValueError(_("Invalid JSON")) list_attr = [] for i in e: list_attr.append(i) list_attr.sort() if list_attr != attr: - raise ValueError + raise ValueError(_("Invalid JSON")) + # If the project has a default currency, convert bills that have no currency + if ( + project.default_currency != CurrencyConverter.no_currency + and e["currency"] == CurrencyConverter.no_currency + ): + e["currency"] = project.default_currency + # Keep track of currencies + currencies.add(e["currency"]) + + # Additional checks if project has no default currency + if project.default_currency == CurrencyConverter.no_currency: + # If bills have currencies, they must be consistent + if len(currencies - {CurrencyConverter.no_currency}) >= 2: + raise ValueError( + _( + "Cannot add bills in multiple currencies to a project without default currency" + ) + ) + # Strip currency from bills (since it's the same for every bill) + for e in json_file: + e["currency"] = CurrencyConverter.no_currency # From json : export list of members members_json = get_members(json_file) @@ -505,10 +534,10 @@ def import_project(file, project): form = get_billform_for(project) form.what = b["what"] form.amount = b["amount"] + form.original_currency = b["currency"] 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))