From 6448d0d7df4f420b3459feedd140da4cdbf6830f Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 18 Jul 2021 00:03:47 +0200 Subject: [PATCH 01/33] Move CurrencyConverter mocking to the base test class This mock was already applied to all tests, because it was done statically in the TestCurrencyConverter class definition. But it was really not clear that it's applied everywhere. Moving this to the setUp() function makes it much clearer. Also, remove useless redefinition in other tests. --- ihatemoney/tests/budget_test.py | 27 ++++++------------- .../tests/common/ihatemoney_testcase.py | 8 ++++++ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 1cda3990..8073458d 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -4,7 +4,6 @@ import json import re from time import sleep import unittest -from unittest.mock import MagicMock from flask import session import pytest @@ -1464,10 +1463,6 @@ class BudgetTestCase(IhatemoneyTestCase): def test_currency_switch(self): - mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} - converter = CurrencyConverter() - converter.get_rates = MagicMock(return_value=mock_data) - # A project should be editable self.post_project("raclette") @@ -1559,14 +1554,16 @@ class BudgetTestCase(IhatemoneyTestCase): }, ) last_bill = project.get_bills().first() - expected_amount = converter.exchange_currency(last_bill.amount, "CAD", "EUR") + expected_amount = self.converter.exchange_currency( + last_bill.amount, "CAD", "EUR" + ) assert last_bill.converted_amount == expected_amount # Switch to USD. Now, NO bill should be in USD, since they already had a currency project.switch_currency("USD") for bill in project.get_bills(): assert bill.original_currency != "USD" - expected_amount = converter.exchange_currency( + expected_amount = self.converter.exchange_currency( bill.amount, bill.original_currency, "USD" ) assert bill.converted_amount == expected_amount @@ -1583,7 +1580,7 @@ class BudgetTestCase(IhatemoneyTestCase): "password": "demo", "contact_email": "demo@notmyidea.org", "project_history": "y", - "default_currency": converter.no_currency, + "default_currency": CurrencyConverter.no_currency, }, ) # A user displayed error should be generated, and its currency should be the same. @@ -1593,10 +1590,6 @@ class BudgetTestCase(IhatemoneyTestCase): def test_currency_switch_to_bill_currency(self): - mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} - converter = CurrencyConverter() - converter.get_rates = MagicMock(return_value=mock_data) - # Default currency is 'XXX', but we should start from a project with a currency self.post_project("raclette", default_currency="USD") @@ -1620,7 +1613,7 @@ class BudgetTestCase(IhatemoneyTestCase): project = models.Project.query.get("raclette") bill = project.get_bills().first() - assert bill.converted_amount == converter.exchange_currency( + assert bill.converted_amount == self.converter.exchange_currency( bill.amount, "EUR", "USD" ) @@ -1631,10 +1624,6 @@ class BudgetTestCase(IhatemoneyTestCase): def test_currency_switch_to_no_currency(self): - mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} - converter = CurrencyConverter() - converter.get_rates = MagicMock(return_value=mock_data) - # Default currency is 'XXX', but we should start from a project with a currency self.post_project("raclette", default_currency="USD") @@ -1670,12 +1659,12 @@ class BudgetTestCase(IhatemoneyTestCase): project = models.Project.query.get("raclette") for bill in project.get_bills_unordered(): - assert bill.converted_amount == converter.exchange_currency( + assert bill.converted_amount == self.converter.exchange_currency( bill.amount, "EUR", "USD" ) # And switch project to no currency: amount should be equal to what was submitted - project.switch_currency(converter.no_currency) + project.switch_currency(CurrencyConverter.no_currency) no_currency_bills = [ (bill.amount, bill.converted_amount) for bill in project.get_bills() ] diff --git a/ihatemoney/tests/common/ihatemoney_testcase.py b/ihatemoney/tests/common/ihatemoney_testcase.py index 067d86ee..4a92f616 100644 --- a/ihatemoney/tests/common/ihatemoney_testcase.py +++ b/ihatemoney/tests/common/ihatemoney_testcase.py @@ -1,9 +1,11 @@ import os +from unittest.mock import MagicMock from flask_testing import TestCase from werkzeug.security import generate_password_hash from ihatemoney import models +from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.run import create_app, db @@ -20,6 +22,12 @@ class BaseTestCase(TestCase): def setUp(self): db.create_all() + # Add dummy data to CurrencyConverter for all tests (since it's a singleton) + mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} + converter = CurrencyConverter() + converter.get_rates = MagicMock(return_value=mock_data) + # Also add it to an attribute to make tests clearer + self.converter = converter def tearDown(self): # clean after testing From 4bf9308908b41bff148a8d8a9ec9fdfacdfa8f08 Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sat, 17 Jul 2021 17:31:49 +0200 Subject: [PATCH 02/33] Add currency to import and export formats --- ihatemoney/forms.py | 2 +- ihatemoney/models.py | 3 + ihatemoney/tests/budget_test.py | 280 ++++++++++++++++++++++++++++++-- ihatemoney/utils.py | 3 +- ihatemoney/web.py | 41 ++++- 5 files changed, 307 insertions(+), 22 deletions(-) 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)) From 3cbe276a69d7f7f4f08468258be48202f5ff3774 Mon Sep 17 00:00:00 2001 From: zorun Date: Sun, 1 Aug 2021 16:46:50 +0200 Subject: [PATCH 03/33] Simplify import code Co-authored-by: Glandos --- ihatemoney/web.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 63692110..712d2b0e 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -452,12 +452,10 @@ def import_project(file, project): 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 currency is absent, empty, or explicitly set to XXX + # set it to project default. + if e.get("currency", "") in ["", "XXX"]: + e["currency"] = project.default_currency if len(e) != len(attr): raise ValueError(_("Invalid JSON")) list_attr = [] @@ -466,12 +464,6 @@ def import_project(file, project): list_attr.sort() if list_attr != attr: 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"]) From 7ad83c7e79a683593aafaaeb99f60f84fd46250e Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 1 Aug 2021 22:18:12 +0200 Subject: [PATCH 04/33] Add more import test cases related to currencies --- ihatemoney/tests/budget_test.py | 211 ++++++++++++++++++++++++++++++-- 1 file changed, 204 insertions(+), 7 deletions(-) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 62c5a5ae..2deb617d 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -1300,8 +1300,8 @@ class BudgetTestCase(IhatemoneyTestCase): set(line.split(",")), set(received_lines[i].strip("\r").split(",")) ) - def test_import_new_project(self): - # Import JSON in an empty project + def test_import_currencies_in_empty_project_with_currency(self): + # Import JSON with currencies in an empty project with a default currency self.post_project("raclette", default_currency="EUR") self.login("raclette") @@ -1347,7 +1347,7 @@ class BudgetTestCase(IhatemoneyTestCase): bills = project.get_pretty_bills() - # Check if all bills has been add + # Check if all bills have been added self.assertEqual(len(bills), len(json_to_import)) # Check if name of bills are ok @@ -1375,8 +1375,205 @@ class BudgetTestCase(IhatemoneyTestCase): self.assertEqual(list_project, list_json) - def test_import_without_currencies(self): - # Import JSON without currencies (from ihatemoney < 5) in an empty project + def test_import_single_currency_in_empty_project_without_currency(self): + # Import JSON with a single currency in an empty project with no + # default currency. It should work by stripping the currency from + # bills. + + self.post_project("raclette") + self.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": "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 have been added + 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"]) + # Currency should have been stripped + self.assertEqual(j["currency"], "XXX") + 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_multiple_currencies_in_empty_project_without_currency(self): + # Import JSON with multiple currencies in an empty project with no + # default currency. It should fail. + + self.post_project("raclette") + 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 should fail + with pytest.raises(ValueError): + import_project(file, project) + + bills = project.get_pretty_bills() + + # Check that there are no bills + self.assertEqual(len(bills), 0) + + def test_import_no_currency_in_empty_project_with_currency(self): + # Import JSON without currencies (from ihatemoney < 5) in an empty + # project with a default currency. + + self.post_project("raclette", default_currency="EUR") + self.login("raclette") + + project = models.Project.query.get("raclette") + + json_to_import = [ + { + "date": "2017-01-01", + "what": "refund", + "amount": 13.33, + "payer_name": "tata", + "payer_weight": 1.0, + "owers": ["fred"], + }, + { + "date": "2016-12-31", + "what": "red wine", + "amount": 200.0, + "payer_name": "fred", + "payer_weight": 1.0, + "owers": ["zorglub", "tata"], + }, + { + "date": "2016-12-31", + "what": "fromage a raclette", + "amount": 10.0, + "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 have been added + 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"]) + # All bills are converted to default project currency + self.assertEqual(j["currency"], "EUR") + 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_no_currency_in_empty_project_without_currency(self): + # Import JSON without currencies (from ihatemoney < 5) in an empty + # project with no default currency. self.post_project("raclette") self.login("raclette") @@ -1419,7 +1616,7 @@ class BudgetTestCase(IhatemoneyTestCase): bills = project.get_pretty_bills() - # Check if all bills has been add + # Check if all bills have been added self.assertEqual(len(bills), len(json_to_import)) # Check if name of bills are ok @@ -1508,7 +1705,7 @@ class BudgetTestCase(IhatemoneyTestCase): bills = project.get_pretty_bills() - # Check if all bills has been add + # Check if all bills have been added self.assertEqual(len(bills), len(json_to_import)) # Check if name of bills are ok From 011df299139418d46d2d158d5421727658acfd7e Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 1 Aug 2021 22:21:29 +0200 Subject: [PATCH 05/33] Simplify exception checking in import test --- ihatemoney/tests/budget_test.py | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 2deb617d..bd424433 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -1762,29 +1762,12 @@ class BudgetTestCase(IhatemoneyTestCase): from ihatemoney.web import import_project - try: + for data in [json_1, json_2]: file = io.StringIO() - json.dump(json_1, file) + json.dump(data, file) file.seek(0) - import_project(file, project) - except ValueError: - self.assertTrue(True) - except Exception: - self.fail("unexpected exception raised") - else: - self.fail("ExpectedException not raised") - - try: - file = io.StringIO() - json.dump(json_2, file) - file.seek(0) - import_project(file, project) - except ValueError: - self.assertTrue(True) - except Exception: - self.fail("unexpected exception raised") - else: - self.fail("ExpectedException not raised") + with pytest.raises(ValueError): + import_project(file, project) def test_access_other_projects(self): """Test that accessing or editing bills and members from another project fails""" From bfd1f3fbc4acbce58af01010a673b143dd8d2412 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Sep 2021 04:00:34 +0000 Subject: [PATCH 06/33] Bump sphinx from 4.1.2 to 4.2.0 Bumps [sphinx](https://github.com/sphinx-doc/sphinx) from 4.1.2 to 4.2.0. - [Release notes](https://github.com/sphinx-doc/sphinx/releases) - [Changelog](https://github.com/sphinx-doc/sphinx/blob/4.x/CHANGES) - [Commits](https://github.com/sphinx-doc/sphinx/compare/v4.1.2...v4.2.0) --- updated-dependencies: - dependency-name: sphinx dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 3463fec8..3bbf6f27 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,7 +56,7 @@ dev = PyMySQL>=0.9,<1.1 doc = - Sphinx==4.1.2 + Sphinx==4.2.0 docutils==0.17.1 [options.entry_points] From 74053fb8fe5a699f936322f2823cd4ad1f272883 Mon Sep 17 00:00:00 2001 From: Clonewayx Date: Thu, 16 Sep 2021 16:36:51 +0200 Subject: [PATCH 07/33] Translated using Weblate (Czech) Currently translated at 23.2% (60 of 258 strings) Co-authored-by: Clonewayx Translate-URL: https://hosted.weblate.org/projects/i-hate-money/i-hate-money/cs/ Translation: I Hate Money/I Hate Money --- .../translations/cs/LC_MESSAGES/messages.mo | Bin 3361 -> 5077 bytes .../translations/cs/LC_MESSAGES/messages.po | 55 ++++++++++-------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/ihatemoney/translations/cs/LC_MESSAGES/messages.mo b/ihatemoney/translations/cs/LC_MESSAGES/messages.mo index ac4542ce511479d3c80673604a5f457e0df45fb5..6ed83ca9eed51dee1a4ffda5ec6cb44d51eb5468 100644 GIT binary patch literal 5077 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 sQsJp\n" +"PO-Revision-Date: 2021-09-05 13:34+0000\n" +"Last-Translator: Clonewayx \n" +"Language-Team: Czech \n" "Language: cs\n" -"Language-Team: Czech \n" -"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;\n" +"X-Generator: Weblate 4.8.1-dev\n" "Generated-By: Babel 2.9.0\n" msgid "" @@ -23,12 +23,11 @@ msgstr "Neplatná částka nebo výraz. Pouze čísla a operátory + - * / jsou msgid "Project name" msgstr "Název projektu" -#, fuzzy msgid "New private code" -msgstr "Přístupový kód" +msgstr "Nový soukromý kód" msgid "Enter a new code if you want to change it" -msgstr "" +msgstr "Pokud chcete provést změnu vložte nový kód" msgid "Email" msgstr "Email" @@ -46,6 +45,8 @@ msgid "" "This project cannot be set to 'no currency' because it contains bills in " "multiple currencies." msgstr "" +"Tento projekt nemůže být nastaven na 'bez měny' protože obsahuje účty v " +"různých měnáh." msgid "Import previously exported JSON file" msgstr "Import exportovaného JSON souboru" @@ -71,14 +72,13 @@ msgstr "" "nový identifikátor" msgid "Enter private code to confirm deletion" -msgstr "" +msgstr "Potvrďte smazání vložením soukromého kódu" msgid "Unknown error" -msgstr "" +msgstr "Neznámá chyba" -#, fuzzy msgid "Invalid private code." -msgstr "Přístupový kód" +msgstr "Neplatný soukromý přístupový kód." msgid "Get in" msgstr "Vstoupit" @@ -145,7 +145,7 @@ msgid "Name" msgstr "Jméno" msgid "Weights should be positive" -msgstr "" +msgstr "Váhy musí být kladné" msgid "Weight" msgstr "Váha" @@ -171,11 +171,11 @@ msgstr "Toto (%(email)s) není validní e-mail" #. List with two items only msgid "{dual_object_0} and {dual_object_1}" -msgstr "" +msgstr "{dual_object_0} a {dual_object_1}" #. Last two items of a list with more than 3 items msgid "{previous_object}, and {end_object}" -msgstr "" +msgstr "{previous_object}, a {end_object}" #. Two items in a middle of a list with more than 5 objects msgid "{previous_object}, {next_object}" @@ -230,37 +230,42 @@ msgid "" "instructions. Please check the email configuration of the server or " "contact the administrator." msgstr "" +"Omlouváme se, během odesílání emailu s instrukcemi pro obnovení hesla se " +"vyskytla chyba. Zkontrolujte si prosím nastavení vašeho emailového serveru " +"nebo kontaktujte administrátora." +#, fuzzy msgid "No token provided" -msgstr "" +msgstr "Nebyl vložen klíč" +#, fuzzy msgid "Invalid token" -msgstr "" +msgstr "Neplatná klíč" msgid "Unknown project" -msgstr "" +msgstr "Neznámý projekt" msgid "Password successfully reset." -msgstr "" +msgstr "Heslo bylo úspěšné obnoveno." msgid "Project successfully uploaded" -msgstr "" +msgstr "Projekt byl úspěšně nahrán." msgid "Invalid JSON" -msgstr "" +msgstr "Neplatný JSON" msgid "Project successfully deleted" -msgstr "" +msgstr "Projekt byl úspěšně smazán" msgid "Error deleting project" -msgstr "" +msgstr "Nastala chyba při mazání projektu" #, python-format msgid "You have been invited to share your expenses for %(project)s" msgstr "" msgid "Your invitations have been sent" -msgstr "" +msgstr "Vaše pozvánka byla odeslána" msgid "" "Sorry, there was an error while trying to send the invitation emails. " From 1842fba115a14b16f8de8877bf683f96abd2d03f Mon Sep 17 00:00:00 2001 From: PPNplus Date: Thu, 16 Sep 2021 16:36:51 +0200 Subject: [PATCH 08/33] Translated using Weblate (Thai) Currently translated at 17.8% (46 of 258 strings) Added translation using Weblate (Thai) Co-authored-by: PPNplus Translate-URL: https://hosted.weblate.org/projects/i-hate-money/i-hate-money/th/ Translation: I Hate Money/I Hate Money --- .../translations/th/LC_MESSAGES/messages.mo | Bin 0 -> 4460 bytes .../translations/th/LC_MESSAGES/messages.po | 880 ++++++++++++++++++ 2 files changed, 880 insertions(+) create mode 100644 ihatemoney/translations/th/LC_MESSAGES/messages.mo create mode 100644 ihatemoney/translations/th/LC_MESSAGES/messages.po diff --git a/ihatemoney/translations/th/LC_MESSAGES/messages.mo b/ihatemoney/translations/th/LC_MESSAGES/messages.mo new file mode 100644 index 0000000000000000000000000000000000000000..f8e36cb2a2fc5ec62e5a91b31396a44d7309e8f6 GIT binary patch literal 4460 zcmb`JU2Ggz6~_l!2$(iNTWHExZ&T9RZN^Rns?tq@>)M;fkhLv)Qv(8`@$R*ElHHlr z%&eUyibxuy^n)S<2`Ficnp!1LsMI!%n0-ZnDj^<_c%Uz3o={b(FCf7yzjN>Gt{pb0 zNL=mR|NS`keEiS3clWJZZanYM#^|4+pL?Ito>0^Ysq+0p`B}KMB5(um2Uinfcq`hrxe>qF00k zw}31;TS2jZYrcLbDE96FKLL(`9|wydA7_y-vH#5+1MsuVzXRR`J_kx%FXi(NDE592 zZUg@aD)4V$5!}M2#9ac#?osdyU<``?m%+QitKhBRn;=J>ck}gsWawZ^64slIVLI6hF5U%ti14@L}*z zU>m%RWbXsN4|3G`12_V{nd1!y%?!wWj7;`yqd=-63kEj9ck2j#KjRp~0wYsUXF zoZBq!7&bT(D?b*m)T&n}M`Z}(%*Gsc1tedQzfwy?)2Nya>TSFXT8$AbB#CY`lUUXD zax!YQbW>0bwY8v+lZ@CYhdJftFjNgMXscxsr>m+Su24E%RXY`wc`-J1617#GPNyQ zsj?%xP8zy4t?fSPoM=XR)el>7E_ZUL6@&Ed${?8&fj*w}1>(eudefb1^KPDcce?66 zs-u`XDyVYJ^VRUQIw+T+1mEqEp?NK(>6YRZzv@2RT8Z6qSWrWAv+g`D$~jkVNI`wA zI5F>*X5Fa?b#S&&n!f+*vopn^LtdP?<;V+Ss0^cmI&k>VAvH5mDyn_O(!}(khbE?` z=8FppUqUokX?ZK!Eo-llJF~BrB+a-mHdYJcL{~@G^pd1xG>lfpa?0Z|--TH1HpJo0 zlcYA**IVFz50$1%#eTrMM|TZPhC#w>;+ES@qDWwHtXcQ`V6UpwyojRTf4F?W-P4zo zV3u{{7D;TC{1?=oB|jOOtG6Pr?sB&qaX|%5GmZD}-b+Qv&i-9{hra4Qq5~aSCKhch z>WiZfx>db836gAa+bbkc9Fl5 zb<{&GV9drJtS^B!sehEA4BA&h~2GeKFD#}p&Y?gM6nsa6# zI*>LdWE-Owj50iY-5mId@e_f^yINl00J_n~X?yZI);ff7Nsh_j9A-KBf*g~$Uo;j@ zvxJN5#wS#s%{q^n7*UB2SYUD83NayaOIGe58@NoE_?~-uU0G2pORPV_ouuVsi@b5fP;vrg%s1mq>|64b@yoI`I4iyPhlp%pP!8Pmr*S*q>yPp` zhH{oj`tOVJ-sH4%#-v{^Imb5L5PrkE{{%K<^2hS*2-)_|2@4z~fC27qNOJ$RGE@`o z6aQVQGPP_Hmgg1AL_jgJXuoYPd$)&Yh^LWzfj3;>i}L9{TzPEi>E1sRdjloqrl7b= zBo$IQZ0pok6LoQov-YA9I8KPY_i(QVc_!~5m26|Mj}&ES2R1_MOzxjD{@K?FGI?uE z0os)1j}WeRD_`=8>(6)DvU*9mWXm$fd2Sn}gF1*sBW4+Qe=zz_26_n;XWY{p41WR2-;$zO0{3$buy<$Q&4ojl`hgY}%`t{38qBZXHeI8t=c*0!}V*m4B TUhlG~_wZkDgYf_HAK$+LhNZ*U literal 0 HcmV?d00001 diff --git a/ihatemoney/translations/th/LC_MESSAGES/messages.po b/ihatemoney/translations/th/LC_MESSAGES/messages.po new file mode 100644 index 00000000..e9476cc6 --- /dev/null +++ b/ihatemoney/translations/th/LC_MESSAGES/messages.po @@ -0,0 +1,880 @@ +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-09-15 15:41+0200\n" +"PO-Revision-Date: 2021-09-16 14:36+0000\n" +"Last-Translator: PPNplus \n" +"Language-Team: Thai \n" +"Language: th\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 4.9-dev\n" + +msgid "" +"Not a valid amount or expression. Only numbers and + - * / operators are " +"accepted." +msgstr "" + +msgid "Project name" +msgstr "ชื่อโครงการ" + +msgid "New private code" +msgstr "" + +msgid "Enter a new code if you want to change it" +msgstr "" + +msgid "Email" +msgstr "อีเมล" + +msgid "Enable project history" +msgstr "เปิดใช้ประวัติโครงการ" + +msgid "Use IP tracking for project history" +msgstr "ใช้การติดตามที่อยู่ IP สำหรับประวัติโครงการ" + +msgid "Default Currency" +msgstr "สกุลเงินค่าเริ่มต้น" + +msgid "" +"This project cannot be set to 'no currency' because it contains bills in " +"multiple currencies." +msgstr "" + +msgid "Import previously exported JSON file" +msgstr "" + +msgid "Import" +msgstr "นำเข้า" + +msgid "Project identifier" +msgstr "" + +msgid "Private code" +msgstr "" + +msgid "Create the project" +msgstr "สร้างโครงการ" + +#, python-format +msgid "" +"A project with this identifier (\"%(project)s\") already exists. Please " +"choose a new identifier" +msgstr "" + +msgid "Enter private code to confirm deletion" +msgstr "" + +msgid "Unknown error" +msgstr "ข้อผิดพลาดที่ไม่รู้จัก" + +msgid "Invalid private code." +msgstr "" + +msgid "Get in" +msgstr "เข้าด้านใน" + +msgid "Admin password" +msgstr "รหัสผ่านผู้ดูแล" + +msgid "Send me the code by email" +msgstr "" + +msgid "This project does not exists" +msgstr "โครงการนี้ไม่มีอยู่" + +msgid "Password mismatch" +msgstr "รหัสผ่านไม่ตรงกัน" + +msgid "Password" +msgstr "รหัสผ่าน" + +msgid "Password confirmation" +msgstr "ยืนยันรหัสผ่าน" + +msgid "Reset password" +msgstr "รีเซ็ตรหัสผ่าน" + +msgid "Date" +msgstr "วันที่" + +msgid "What?" +msgstr "อะไร?" + +msgid "Payer" +msgstr "ผู้จ่าย" + +msgid "Amount paid" +msgstr "จำนวนเงินที่จ่าย" + +msgid "Currency" +msgstr "สกุลเงิน" + +msgid "External link" +msgstr "ลิงก์ภายนอก" + +msgid "A link to an external document, related to this bill" +msgstr "" + +msgid "For whom?" +msgstr "ให้ใคร?" + +msgid "Submit" +msgstr "ส่ง" + +msgid "Submit and add a new one" +msgstr "ส่งและเพิ่มอันใหม่" + +#, python-format +msgid "Project default: %(currency)s" +msgstr "ค่าเริ่มต้นของโครงการ: %(currency)s" + +msgid "Bills can't be null" +msgstr "" + +msgid "Name" +msgstr "ชื่อ" + +msgid "Weights should be positive" +msgstr "" + +msgid "Weight" +msgstr "" + +msgid "Add" +msgstr "เพิ่ม" + +msgid "User name incorrect" +msgstr "ชื่อผู้ใช้ไม่ถูกต้อง" + +msgid "This project already have this member" +msgstr "" + +msgid "People to notify" +msgstr "ผู้คนที่จะแจ้งเตือน" + +msgid "Send invites" +msgstr "ส่งคำเชิญ" + +#, python-format +msgid "The email %(email)s is not valid" +msgstr "อีเมล %(email)s ไม่ถูกต้อง" + +#. List with two items only +msgid "{dual_object_0} and {dual_object_1}" +msgstr "{dual_object_0} และ {dual_object_1}" + +#. Last two items of a list with more than 3 items +msgid "{previous_object}, and {end_object}" +msgstr "{previous_object}, และ {end_object}" + +#. Two items in a middle of a list with more than 5 objects +msgid "{previous_object}, {next_object}" +msgstr "{previous_object}, {next_object}" + +#. First two items of a list with more than 3 items +msgid "{start_object}, {next_object}" +msgstr "{start_object}, {next_object}" + +msgid "No Currency" +msgstr "ไม่มีสกุลเงิน" + +#. Form error with only one error +msgid "{prefix}: {error}" +msgstr "" + +#. Form error with a list of errors +msgid "{prefix}:
{errors}" +msgstr "" + +msgid "Too many failed login attempts, please retry later." +msgstr "เข้าสู่ระบบล้มเหลวหลายครั้ง โปรดลองอีกครั้งในภายหลัง" + +#, python-format +msgid "This admin password is not the right one. Only %(num)d attempts left." +msgstr "" +"รหัสผ่านผู้ดูแลนี้ไม่ถูกต้อง สามารถพยายามเข้าสู่ระบบได้อีก %(num)d ครั้ง" + +msgid "You either provided a bad token or no project identifier." +msgstr "" + +msgid "This private code is not the right one" +msgstr "" + +#, python-format +msgid "You have just created '%(project)s' to share your expenses" +msgstr "" + +msgid "A reminder email has just been sent to you" +msgstr "ส่งอีเมลเตือนความจำให้คุณแล้ว" + +msgid "" +"We tried to send you an reminder email, but there was an error. You can " +"still use the project normally." +msgstr "" + +#, python-format +msgid "The project identifier is %(project)s" +msgstr "" + +msgid "" +"Sorry, there was an error while sending you an email with password reset " +"instructions. Please check the email configuration of the server or " +"contact the administrator." +msgstr "" + +msgid "No token provided" +msgstr "" + +msgid "Invalid token" +msgstr "" + +msgid "Unknown project" +msgstr "" + +msgid "Password successfully reset." +msgstr "" + +msgid "Project successfully uploaded" +msgstr "" + +msgid "Invalid JSON" +msgstr "" + +msgid "Project successfully deleted" +msgstr "" + +msgid "Error deleting project" +msgstr "" + +#, python-format +msgid "You have been invited to share your expenses for %(project)s" +msgstr "" + +msgid "Your invitations have been sent" +msgstr "" + +msgid "" +"Sorry, there was an error while trying to send the invitation emails. " +"Please check the email configuration of the server or contact the " +"administrator." +msgstr "" + +#, python-format +msgid "%(member)s has been added" +msgstr "" + +msgid "Error activating member" +msgstr "" + +#, python-format +msgid "%(name)s is part of this project again" +msgstr "" + +msgid "Error removing member" +msgstr "" + +#, python-format +msgid "" +"User '%(name)s' has been deactivated. It will still appear in the users " +"list until its balance becomes zero." +msgstr "" + +#, python-format +msgid "User '%(name)s' has been removed" +msgstr "" + +#, python-format +msgid "User '%(name)s' has been edited" +msgstr "" + +msgid "The bill has been added" +msgstr "" + +msgid "Error deleting bill" +msgstr "" + +msgid "The bill has been deleted" +msgstr "" + +msgid "The bill has been modified" +msgstr "" + +msgid "Error deleting project history" +msgstr "" + +msgid "Deleted project history." +msgstr "" + +msgid "Error deleting recorded IP addresses" +msgstr "" + +msgid "Deleted recorded IP addresses in project history." +msgstr "" + +msgid "Sorry, we were unable to find the page you've asked for." +msgstr "" + +msgid "The best thing to do is probably to get back to the main page." +msgstr "" + +msgid "Back to the list" +msgstr "" + +msgid "Administration tasks are currently disabled." +msgstr "" + +msgid "The project you are trying to access do not exist, do you want to" +msgstr "" + +msgid "create it" +msgstr "" + +msgid "?" +msgstr "" + +msgid "Create a new project" +msgstr "" + +msgid "Project" +msgstr "" + +msgid "Number of members" +msgstr "" + +msgid "Number of bills" +msgstr "" + +msgid "Newest bill" +msgstr "" + +msgid "Oldest bill" +msgstr "" + +msgid "Actions" +msgstr "" + +msgid "edit" +msgstr "" + +msgid "delete" +msgstr "" + +msgid "show" +msgstr "" + +msgid "The Dashboard is currently deactivated." +msgstr "" + +msgid "Download Mobile Application" +msgstr "" + +msgid "Get it on" +msgstr "" + +msgid "Are you sure?" +msgstr "" + +msgid "Edit project" +msgstr "" + +msgid "Delete project" +msgstr "" + +msgid "Import JSON" +msgstr "" + +msgid "Choose file" +msgstr "" + +msgid "Download project's data" +msgstr "" + +msgid "Bill items" +msgstr "" + +msgid "Download the list of bills with owner, amount, reason,... " +msgstr "" + +msgid "Settle plans" +msgstr "" + +msgid "Download the list of transactions needed to settle the current bills." +msgstr "" + +msgid "Can't remember the password?" +msgstr "หากจำรหัสผ่านไม่ได้" + +msgid "Cancel" +msgstr "ยกเลิก" + +msgid "Privacy Settings" +msgstr "การตั้งค่าความเป็นส่วนตัว" + +msgid "Edit the project" +msgstr "" + +msgid "This will remove all bills and participants in this project!" +msgstr "" + +msgid "Edit this bill" +msgstr "" + +msgid "Add a bill" +msgstr "" + +msgid "Everyone" +msgstr "ทุกคน" + +msgid "No one" +msgstr "ไม่มีใคร" + +msgid "More options" +msgstr "" + +msgid "Add participant" +msgstr "" + +msgid "Edit this member" +msgstr "" + +msgid "john.doe@example.com, mary.moe@site.com" +msgstr "" + +msgid "Send the invitations" +msgstr "" + +msgid "Download" +msgstr "ดาวน์โหลด" + +msgid "Disabled Project History" +msgstr "" + +msgid "Disabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled Project History" +msgstr "" + +msgid "Disabled IP Address Recording" +msgstr "" + +msgid "Enabled Project History & IP Address Recording" +msgstr "" + +msgid "Enabled IP Address Recording" +msgstr "" + +msgid "History Settings Changed" +msgstr "" + +#, python-format +msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s" +msgstr "" + +#, python-format +msgid "Bill %(name)s: %(property_name)s changed to %(after)s" +msgstr "" + +msgid "Confirm Remove IP Adresses" +msgstr "" + +msgid "" +"Are you sure you want to delete all recorded IP addresses from this " +"project?\n" +" The rest of the project history will be unaffected. This " +"action cannot be undone." +msgstr "" + +msgid "Confirm deletion" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Delete Confirmation" +msgstr "" + +msgid "" +"Are you sure you want to erase all history for this project? This action " +"cannot be undone." +msgstr "" + +#, python-format +msgid "Bill %(name)s: added %(owers_list_str)s to owers list" +msgstr "" + +#, python-format +msgid "Bill %(name)s: removed %(owers_list_str)s from owers list" +msgstr "" + +#, python-format +msgid "" +"\n" +" This project has history disabled. New actions won't " +"appear below. You can enable history on the\n" +" settings page\n" +" " +msgstr "" + +msgid "" +"\n" +" The table below reflects actions recorded prior to " +"disabling project history. You can\n" +" clear project history to remove " +"them.

\n" +" " +msgstr "" + +msgid "" +"Some entries below contain IP addresses, even though this project has IP " +"recording disabled. " +msgstr "" + +msgid "Delete stored IP addresses" +msgstr "" + +msgid "No history to erase" +msgstr "" + +msgid "Clear Project History" +msgstr "" + +msgid "No IP Addresses to erase" +msgstr "" + +msgid "Delete Stored IP Addresses" +msgstr "" + +msgid "Time" +msgstr "" + +msgid "Event" +msgstr "" + +msgid "IP address recording can be enabled on the settings page" +msgstr "" + +msgid "IP address recording can be disabled on the settings page" +msgstr "" + +msgid "From IP" +msgstr "" + +#, python-format +msgid "Project %(name)s added" +msgstr "" + +#, python-format +msgid "Bill %(name)s added" +msgstr "" + +#, python-format +msgid "Participant %(name)s added" +msgstr "" + +msgid "Project private code changed" +msgstr "" + +#, python-format +msgid "Project renamed to %(new_project_name)s" +msgstr "" + +#, python-format +msgid "Project contact email changed to %(new_email)s" +msgstr "" + +msgid "Project settings modified" +msgstr "" + +#, python-format +msgid "Participant %(name)s deactivated" +msgstr "" + +#, python-format +msgid "Participant %(name)s reactivated" +msgstr "" + +#, python-format +msgid "Participant %(name)s renamed to %(new_name)s" +msgstr "" + +#, python-format +msgid "Bill %(name)s renamed to %(new_description)s" +msgstr "" + +#, python-format +msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s" +msgstr "" + +msgid "Amount" +msgstr "" + +#, python-format +msgid "Amount in %(currency)s" +msgstr "" + +#, python-format +msgid "Bill %(name)s modified" +msgstr "" + +#, python-format +msgid "Participant %(name)s modified" +msgstr "" + +#, python-format +msgid "Bill %(name)s removed" +msgstr "" + +#, python-format +msgid "Participant %(name)s removed" +msgstr "" + +#, python-format +msgid "Project %(name)s changed in an unknown way" +msgstr "" + +#, python-format +msgid "Bill %(name)s changed in an unknown way" +msgstr "" + +#, python-format +msgid "Participant %(name)s changed in an unknown way" +msgstr "" + +msgid "Nothing to list" +msgstr "" + +msgid "Someone probably cleared the project history." +msgstr "" + +msgid "Manage your shared
expenses, easily" +msgstr "" + +msgid "Try out the demo" +msgstr "" + +msgid "You're sharing a house?" +msgstr "" + +msgid "Going on holidays with friends?" +msgstr "" + +msgid "Simply sharing money with others?" +msgstr "" + +msgid "We can help!" +msgstr "" + +msgid "Log in to an existing project" +msgstr "" + +msgid "Log in" +msgstr "" + +msgid "can't remember your password?" +msgstr "" + +msgid "Create" +msgstr "" + +msgid "" +"Don\\'t reuse a personal password. Choose a private code and send it to " +"your friends" +msgstr "" + +msgid "Account manager" +msgstr "" + +msgid "Bills" +msgstr "" + +msgid "Settle" +msgstr "" + +msgid "Statistics" +msgstr "" + +msgid "Languages" +msgstr "" + +msgid "Projects" +msgstr "" + +msgid "Start a new project" +msgstr "" + +msgid "History" +msgstr "" + +msgid "Settings" +msgstr "" + +msgid "Other projects :" +msgstr "" + +msgid "switch to" +msgstr "" + +msgid "Dashboard" +msgstr "" + +msgid "Logout" +msgstr "" + +msgid "Code" +msgstr "" + +msgid "Mobile Application" +msgstr "" + +msgid "Documentation" +msgstr "" + +msgid "Administation Dashboard" +msgstr "" + +msgid "\"I hate money\" is free software" +msgstr "" + +msgid "you can contribute and improve it!" +msgstr "" + +#, python-format +msgid "%(amount)s each" +msgstr "" + +msgid "you sure?" +msgstr "" + +msgid "Invite people" +msgstr "" + +msgid "You should start by adding participants" +msgstr "" + +msgid "Add a new bill" +msgstr "" + +msgid "Newer bills" +msgstr "" + +msgid "Older bills" +msgstr "" + +msgid "When?" +msgstr "" + +msgid "Who paid?" +msgstr "" + +msgid "For what?" +msgstr "" + +msgid "How much?" +msgstr "" + +#, python-format +msgid "Added on %(date)s" +msgstr "" + +#, python-format +msgid "Everyone but %(excluded)s" +msgstr "" + +msgid "No bills" +msgstr "" + +msgid "Nothing to list yet." +msgstr "" + +msgid "You probably want to" +msgstr "" + +msgid "add a bill" +msgstr "" + +msgid "add participants" +msgstr "" + +msgid "Password reminder" +msgstr "" + +msgid "" +"A link to reset your password has been sent to you, please check your " +"emails." +msgstr "" + +msgid "Return to home page" +msgstr "" + +msgid "Your projects" +msgstr "" + +msgid "Reset your password" +msgstr "" + +msgid "Invite people to join this project" +msgstr "" + +msgid "Share Identifier & code" +msgstr "" + +msgid "" +"You can share the project identifier and the private code by any " +"communication means." +msgstr "" + +msgid "Identifier:" +msgstr "" + +msgid "Share the Link" +msgstr "" + +msgid "You can directly share the following link via your prefered medium" +msgstr "" + +msgid "Send via Emails" +msgstr "" + +msgid "" +"Specify a (comma separated) list of email adresses you want to notify " +"about the\n" +" creation of this budget management project and we will " +"send them an email for you." +msgstr "" + +msgid "Who pays?" +msgstr "" + +msgid "To whom?" +msgstr "ให้ใคร?" + +msgid "Who?" +msgstr "" + +msgid "Balance" +msgstr "" + +msgid "deactivate" +msgstr "" + +msgid "reactivate" +msgstr "" + +msgid "Paid" +msgstr "" + +msgid "Spent" +msgstr "" + +msgid "Expenses by Month" +msgstr "" + +msgid "Period" +msgstr "" From 49abd7fedd49e4fadf5f999dec5b1502aed98af7 Mon Sep 17 00:00:00 2001 From: Daniel Neto Date: Wed, 15 Sep 2021 21:45:43 +0100 Subject: [PATCH 09/33] Fix #838: Round weight to 2 decimal cases --- ihatemoney/templates/sidebar_table_layout.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ihatemoney/templates/sidebar_table_layout.html b/ihatemoney/templates/sidebar_table_layout.html index 5bb5a897..8113faed 100644 --- a/ihatemoney/templates/sidebar_table_layout.html +++ b/ihatemoney/templates/sidebar_table_layout.html @@ -15,7 +15,7 @@ {{ member.name }} {%- if show_weight -%} - (x{{ member.weight|minimal_round(1) }}) + (x{{ member.weight|minimal_round(2) }}) {%- endif -%} {%- if member_edit %} From 7bf7db24bdf71b67892824a00080ca6e961d361d Mon Sep 17 00:00:00 2001 From: Daniel Neto Date: Thu, 16 Sep 2021 21:55:28 +0100 Subject: [PATCH 10/33] #838: Add decima weight round test cases --- ihatemoney/tests/budget_test.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index bd424433..ab74bd2e 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -2102,6 +2102,20 @@ class BudgetTestCase(IhatemoneyTestCase): ] assert no_currency_bills == [(5.0, 5.0), (10.0, 10.0)] + def test_decimals_on_weighted_members_list(self): + + self.post_project("raclette") + + # add three users with different weights + self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 1.0}) + self.client.post("/raclette/members/add", data={"name": "tata", "weight": 1.10}) + self.client.post("/raclette/members/add", data={"name": "fred", "weight": 1.15}) + + # check if weights of the users are 1, 1.1, 1.15 respectively + resp = self.client.get("/raclette/") + self.assertIn("zorglub(x1)", resp.data.decode("utf-8")) + self.assertIn("tata(x1.1)", resp.data.decode("utf-8")) + self.assertIn("fred(x1.15)", resp.data.decode("utf-8")) if __name__ == "__main__": unittest.main() From 60149cd486cf2fb7639806c8cebfa495db3666dd Mon Sep 17 00:00:00 2001 From: Daniel Neto Date: Sun, 19 Sep 2021 11:29:19 +0100 Subject: [PATCH 11/33] Formatting changes after running make black --- ihatemoney/tests/budget_test.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index ab74bd2e..75a2dc35 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -2107,15 +2107,24 @@ class BudgetTestCase(IhatemoneyTestCase): self.post_project("raclette") # add three users with different weights - self.client.post("/raclette/members/add", data={"name": "zorglub", "weight": 1.0}) + self.client.post( + "/raclette/members/add", data={"name": "zorglub", "weight": 1.0} + ) self.client.post("/raclette/members/add", data={"name": "tata", "weight": 1.10}) self.client.post("/raclette/members/add", data={"name": "fred", "weight": 1.15}) # check if weights of the users are 1, 1.1, 1.15 respectively resp = self.client.get("/raclette/") - self.assertIn("zorglub(x1)", resp.data.decode("utf-8")) - self.assertIn("tata(x1.1)", resp.data.decode("utf-8")) - self.assertIn("fred(x1.15)", resp.data.decode("utf-8")) + self.assertIn( + 'zorglub(x1)', resp.data.decode("utf-8") + ) + self.assertIn( + 'tata(x1.1)', resp.data.decode("utf-8") + ) + self.assertIn( + 'fred(x1.15)', resp.data.decode("utf-8") + ) + if __name__ == "__main__": unittest.main() From bf3a3bce33877651e88b089ef7b92a727e0d8bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=BC=D0=B0=D0=BD=20=D0=9F=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Sun, 10 Oct 2021 07:05:15 +0200 Subject: [PATCH 12/33] Translated using Weblate (Russian) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently translated at 81.3% (210 of 258 strings) Co-authored-by: Роман Прокопов Translate-URL: https://hosted.weblate.org/projects/i-hate-money/i-hate-money/ru/ Translation: I Hate Money/I Hate Money --- .../translations/ru/LC_MESSAGES/messages.mo | Bin 23816 -> 22596 bytes .../translations/ru/LC_MESSAGES/messages.po | 17 ++++++++--------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ihatemoney/translations/ru/LC_MESSAGES/messages.mo b/ihatemoney/translations/ru/LC_MESSAGES/messages.mo index c9c234e0d8ecb18ce1c9ac876750bc0d4fd42330..c343e8df3da8496ed666db116c18cb6151a71241 100644 GIT binary patch delta 4828 zcmY+`2~##(D2)}={KdzyOUNm3hfnwWSRZU5iAOHTU^AHRFwy!Y<<2G*W( zT_|^X{}LIz)o@%VG2~c?G0`q#MuzLGG3CvT38H=!k5WG#X-q86YGF(>EX8Qtj)`~_ zx!l~wbo>}cVYA1LnSxU=39FF%z2fhGLLDm=t?{07g+C zg(+Bw^xbU1R=5KjV>uqdYMhL7_%H%5VJPjJTNKpbU$G&6iFDf3p=KnQR`lRF)B`&q z&ob$#>qlTPj>R@O0d@TXWGrSWGHGT5Hp1N)h6ga1_Dux^_2>ku;mfE8eQNz4+ft8g zZ45uA8^2O2Y!cLF`VA%x_+nz2O}SuCs9jOi0ar<)JRLLub`HC2Wp1)wqgFYnGVUVq#Cu97g00vXUxN|P@8!GjcRX<_EOM^ z>8J}Ap&BT$ZbmiyCaNPxP!0G|o3I*n-$&LvNE_xpYKdC3bMB8u4KT^pyQ5~xJD7sj zWDIhTnTQ(sVtc__)C0Go*0{pfucEI10@a~V)=_&Y0yXkjYY$XIIjH-`qeeat8MxQ1 zrl1G-P(9pP~L7EZwj*aauLjVZ$t+=gAaNPFc1R^UU_Oq8<$)Uo$a zulp4Yp?!0Mg4XU+?1E8*bh%(AN(G*c4?iQnaV*e(R@?~OHr>~ z8ET4;qXu>swO49U9r)UQ{-6`{AHs=X-Z^!oG17*KKs_MSo*#l?)N_&5F?qJW81K3m z1sJWgAKy|G!cAj>AqCd-pE5PGwZuD|I;XJ6?o3j+F zP$PNAdIGg4E+eaFK0_^8Xm?}yZZXM7pG*#FW}n5$cnmdz&C;Cv61)^Nb;Ge2T`I)$2__HEwpOKF`BET#0Jv zPpAR?9W|gj)Lv-SR|97KdsEN_6EF;?VJObUz*HkY#>+1~unJiva|QLfKE&A=&Z|;{ z%kT;O8@AEss3mUQ-iJFsJG=C z)LRri&{-lkwxm7`qcI=#eX$tTv30ioHhOj9Bn4hca|2((N2uLgGRRr$gQ!h)17q=? zt%qkh5A1-tJ`1%83sEz=#GYS`dP~Y|{VHnj)MYXMk5fqG9nmK2i@I?d>h+nA8o>^G z-jB?-Icq=v5jE1pA^dlP{ZLEeLETq|DR>UGbpJ%{frr-kpFs1QbiOA%7(;z4w!;e44Ax@cb;J_tX`_r; zg_X#Q#-`>ebjo;84OF2v-96OX@B?aBHyGn|Xbx(mEAZ#|7JiFCW1TOSA5cq?_A{q} z*{Ff+#}EcuDXE`f2Xmd*T%w@Y?GZM{ka5lzMkMOSWYp(_uoQD~JJ#TOEE?~;HQ(Y4 z>Z2z(OXf!|F*lLfHc3nyFN#@&TDl61*86{jf~M*Y#$nTm&Zg>yn)+;Pijz>gy9hht za$DbPt-$7-e;@T4-#`r@^=apiO)cd#@(fu))S3RYZ}{9SCm#~FLtqpGQAdWuPrv6- z`lYQEA}_%I9a@s#+A}k)^uwGbM+wUwIObZtO(9>JM#1ZQd?qggx0E+mR_`Daj=p$#$Y+mV@~XdY|Rj2^B~gNgywg{Y1xn z(vS33g<~&yoiruy5+B)0dJ*0036z{a-6{qXkAHECt1kbLmX$8QE2_fft8D$8KP$RZkbhOoKU_`R3lv;Ogyf*Y)Fu{;>4+LB8~i z2m2>vhWJYdTn`F9>8?5B+mjvTA3L~5gT}jS%4=8DoUW;=-QXLP+1h_%Xh)YXKKs~~ zoOQnHoKC*eIR(Cbx&8g?a>qvc?oE#KP0sJ^+m`S4RpobXR#9_`8#mRK)>PK6;sJ9D KI{02MnD<}G20+RH delta 5891 zcmaLZdstLu0>|-pL{ub2K@_|(;swDJMa`5_Q7o^SVrrVTBaBMIV2HQO1|dTjwdVG0t3A8l-<;z<^v|A$AD{OzFT3{JvC^k5HM zj$Ce@#{u{vPQbG`3sc$}lZLC1`~9Yh0&SW%Fal3vb9@KYhcl>#zQjoU30vbe=ku2B zjN!+0;@6Eh5S!p^N(v}14zU6I2d)`6l4<3OpK*{vw%V~EXOFUL_J_Dw!)`T z9e5S>pf?;p!A{hFLi%9Z@L@Xk#uhjWb8tTD`A3n#o0B*MKSTd03LQEaa|3!%4f;?$ z_dE6VsFCeJP3bPwOdLi%@GSPhf1s{&^Pw8-f%MI!qn2z8s$(-z9nSB_{A-XQ?(tFaF=ub1nS1qsO!JP!T1B-jA=}VmLwN-f00vPjNPc0p$57e)scOjn12VDQ$29J2*OM`odMd_Z4r;09pc)!_}O8F>k{l+~yiIpL=; znZg;=E>B~)+8jer^@*qp??W~4fa40(QmsXGV4HJ(2dbf0P@8fe>i)MK&m)s%E}@pn zAMLgu)EYJ7WT&2rnyMkFCAl5>F%$Wv8Ciguf%T~CcA_4<7j^y=YV9we&i@N_Up!wP z>R^B5xqfpC1#Px$)YRlS&PBb3%TXh*KsB@(_23<-nR*>HQ}3c4d;v9(uTa-tLT%Ek zsCMF5XNo2nXX*XVrqG)c&*N^ia4RnDYHz9-c1{iT(WoiAit4DVyWPQfRD*8RTBl$N z4#%N557mL)sE!{+-aK;_-L!A6P~gYJ_b?_4GjSBIKz_`d{L<&&qNcJHPuH6FMvXWF z`(q|*PZXhMq!by0*@y+W4_S88rKi1=S?JeDW>e5qEylT6j!p1ysGeTNNNmB@mvN}K zB+;=ehC75KIG=%c;UXN2r|=exOtZguMxd5%MH=%@r_5$fXo~7kJ$xVaT3*KHXnNTr zi$-;zBkFHOGHO%xz^0guT7vNyghNQ|n12;Eb3z??0-NDc)Qu+{ zKfq|}7f=m->zu!gy8jncgN<$^cmuMHObqIL27cvY7BCOH@pah@*ZR}#DcRiD7`_6` zY1ED1p}x^#`q_U@N8@bjW3e3f;7iyqg9qX{)LIW?BWMrgI+ma|-74&cJ5bjhcRu%j zO@Xg%^E0wcCVqguhQn|M^)l32eTsU(Wz-V28)(c;j9?HxM%{Oly{WF@4b(dgvS%_4 zwY2?FGdd1=gqez6^!~4-pdRnRc36+hlKBYrzD6(&TC-Tx5+$NGRTk>=<;aU+9z_kL z8r6Z*sJG@EYDsmI1`vg;mg$27_5RNY7kEvONic6AgE0TbDcEm_y{RhjW$HUn4UfsR z|C5=HUh3tz9KS(rN-tYpmSZCBM3&tgLT%!2Ri}OP0|l1TB;9IXFbOA9FGV%<0cu2_ zqek>SYLCS8?a~2nM%_OJbzKE&(*;nQ^;y)^??U=!s!-4U4*l#KbCrU=F!~HL=6)QC z^YCR<1F^&H?}0v;M12fuX7aHU)}uDndDQ3n7P}kVsD>YOT!ot9jZXb|7W1!>zQ+lz zZSn|vt;eGNdQC!2-CT^rm8cGFaq87h{RH;p{6(z5<|FM6Z9=VeEoyIFM0N0Ir=FPY zw;$Lq+rD7}YBN6IScaO?fb;ot*p2#er~VD{0+<$~?6+hjYBNqp-KT%A^j1}(2D0Bd zZy~puPj!LTB>6V`!h2C8UxT~=W;<$RAEB1&Kd1*~+-|=m<56q54AtNo?2cO;522oO z5%t`}(e{ATQ62M-qo65T(~Mb7JrsUy%k4_W4Ih%-%`$<-~ zWN*N9$}31u;@5g=sx|F8o*^4aH^P1kA9ar6JyJ~$tHLpYtPhv$&GjYaGvv>NSKd5I zbZE!lL0XVS#t(bu~FbABBp(@7@jLiUl(qz&0lZXvIc6rv-7d_o=|gNcs5Hs%QW zh`uQgkPGBZa+Z8diiwU8-xj~eHK=)78F zRa}yvo4>&0D+$ze+LMr53cTFnDJU>GG)>W0n&Zshyi9`_>Od|%NL zpL>aCS>RI7g!VL^zt~g4U3nUY*Ld^tOU#_&qQXE-YU+f-qP+Y$`FvWm#9O?OVJ$2% z#op^TH7Mp$4tEf*_u4F+SG2?|T#{dsJC8{)!HKD@8@UDrJ-yCF2GjcWk7&9xv^umY zR2G;y;A&i`(j6+d_E}ZdA*YIx2Ow^bAHjYtesjx3IT!R~8llXWC;ZDd8? z&7pNxH8)j-${31OLw7bAYddY(k2rY!JeSdl(qL)!hS=DBv{}ugYwS@Q>tLY8w65cR zJ>1Yb&i`-x2f4JKr_;!ha2F47cdb2o`f%8-N2(*-QEwd%q)$tW<%Tk=&N>uY&rE3o z>jTrLb!n{ATIbsuJU;xX39V!f4)VL+s$(!TS;cs3c|v8V0&5H>uRkz6G!rTf^u05o bm-*Ey)|>w~m(|m{H?31AL*;=Fr!Dv|bg0js diff --git a/ihatemoney/translations/ru/LC_MESSAGES/messages.po b/ihatemoney/translations/ru/LC_MESSAGES/messages.po index 0b307d94..24845770 100644 --- a/ihatemoney/translations/ru/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/ru/LC_MESSAGES/messages.po @@ -1,19 +1,19 @@ - msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-07-17 17:31+0200\n" -"PO-Revision-Date: 2021-05-10 11:33+0000\n" -"Last-Translator: Vsevolod \n" +"PO-Revision-Date: 2021-09-20 12:38+0000\n" +"Last-Translator: Роман Прокопов \n" +"Language-Team: Russian \n" "Language: ru\n" -"Language-Team: Russian \n" -"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && " -"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" +"%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" +"X-Generator: Weblate 4.9-dev\n" "Generated-By: Babel 2.9.0\n" msgid "" @@ -26,9 +26,8 @@ msgstr "" msgid "Project name" msgstr "Имя проекта" -#, fuzzy msgid "New private code" -msgstr "Приватный код" +msgstr "Новый приватный код" msgid "Enter a new code if you want to change it" msgstr "" From 6044947a9046598259fb5c4e17ad6865df0d6b8f Mon Sep 17 00:00:00 2001 From: Christian H Date: Sun, 10 Oct 2021 07:05:15 +0200 Subject: [PATCH 13/33] Translated using Weblate (German) Currently translated at 98.4% (254 of 258 strings) Co-authored-by: Christian H Translate-URL: https://hosted.weblate.org/projects/i-hate-money/i-hate-money/de/ Translation: I Hate Money/I Hate Money --- .../translations/de/LC_MESSAGES/messages.mo | Bin 22080 -> 22202 bytes .../translations/de/LC_MESSAGES/messages.po | 8 ++++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ihatemoney/translations/de/LC_MESSAGES/messages.mo b/ihatemoney/translations/de/LC_MESSAGES/messages.mo index 27d5ffa303635846021e1241465ad7a0c3818874..0c14afc68e246d976edf3e5963370df6ba1ee2f6 100644 GIT binary patch delta 5498 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 diff --git a/ihatemoney/translations/de/LC_MESSAGES/messages.po b/ihatemoney/translations/de/LC_MESSAGES/messages.po index 46144d0d..60be7506 100644 --- a/ihatemoney/translations/de/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/de/LC_MESSAGES/messages.po @@ -3,8 +3,8 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-07-17 17:31+0200\n" -"PO-Revision-Date: 2021-08-19 20:34+0000\n" -"Last-Translator: corny \n" +"PO-Revision-Date: 2021-09-23 19:36+0000\n" +"Last-Translator: Christian H. \n" "Language-Team: German \n" "Language: de\n" @@ -12,7 +12,7 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.8-dev\n" +"X-Generator: Weblate 4.9-dev\n" "Generated-By: Babel 2.9.0\n" msgid "" @@ -74,7 +74,7 @@ msgstr "" "wähle eine andere Kennung" msgid "Enter private code to confirm deletion" -msgstr "" +msgstr "Geben Sie Ihren privaten Code ein, um die Löschung zu bestätigen" msgid "Unknown error" msgstr "Unbekannter Fehler" From 1c1f1ae8936f5e83aeecb4cb1eed1f47f4dee6de Mon Sep 17 00:00:00 2001 From: phlostically Date: Sun, 10 Oct 2021 07:05:16 +0200 Subject: [PATCH 14/33] Translated using Weblate (Esperanto) Currently translated at 86.8% (224 of 258 strings) Co-authored-by: phlostically Translate-URL: https://hosted.weblate.org/projects/i-hate-money/i-hate-money/eo/ Translation: I Hate Money/I Hate Money --- .../translations/eo/LC_MESSAGES/messages.mo | Bin 19241 -> 18925 bytes .../translations/eo/LC_MESSAGES/messages.po | 36 ++++++++---------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/ihatemoney/translations/eo/LC_MESSAGES/messages.mo b/ihatemoney/translations/eo/LC_MESSAGES/messages.mo index 5be5c4a502915aff5ca0ccc7165d2140d342e0e0..cc3133f3aa10cbb3fabefb98957fd43f0b1e61bf 100644 GIT binary patch delta 5625 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 I>C)l90%SxrrT_o{ diff --git a/ihatemoney/translations/eo/LC_MESSAGES/messages.po b/ihatemoney/translations/eo/LC_MESSAGES/messages.po index c9c0d740..02e59259 100644 --- a/ihatemoney/translations/eo/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/eo/LC_MESSAGES/messages.po @@ -1,18 +1,18 @@ - msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-07-17 17:31+0200\n" -"PO-Revision-Date: 2021-07-10 15:34+0000\n" +"PO-Revision-Date: 2021-10-01 20:35+0000\n" "Last-Translator: phlostically \n" +"Language-Team: Esperanto \n" "Language: eo\n" -"Language-Team: Esperanto \n" -"Plural-Forms: nplurals=2; plural=n != 1\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.9-dev\n" "Generated-By: Babel 2.9.0\n" msgid "" @@ -76,13 +76,11 @@ msgstr "" msgid "Enter private code to confirm deletion" msgstr "" -#, fuzzy msgid "Unknown error" -msgstr "Nekonata projekto" +msgstr "Nekonata eraro" -#, fuzzy msgid "Invalid private code." -msgstr "Nova privata kodo" +msgstr "Nevalida privata kodo." msgid "Get in" msgstr "Eniri" @@ -175,30 +173,30 @@ msgstr "La retpoŝta adreso %(email)s ne validas" #. List with two items only msgid "{dual_object_0} and {dual_object_1}" -msgstr "" +msgstr "{dual_object_0} kaj {dual_object_1}" #. Last two items of a list with more than 3 items msgid "{previous_object}, and {end_object}" -msgstr "" +msgstr "{previous_object} kaj {end_object}" #. Two items in a middle of a list with more than 5 objects msgid "{previous_object}, {next_object}" -msgstr "" +msgstr "{previous_object}, {next_object}" #. First two items of a list with more than 3 items msgid "{start_object}, {next_object}" -msgstr "" +msgstr "{start_object}, {next_object}" msgid "No Currency" msgstr "Neniu valuto" #. Form error with only one error msgid "{prefix}: {error}" -msgstr "" +msgstr "{prefix}: {error}" #. Form error with a list of errors msgid "{prefix}:
{errors}" -msgstr "" +msgstr "{prefix}:
{errors}" msgid "Too many failed login attempts, please retry later." msgstr "Tro da malsukcesaj provoj de salutado; bonvolu reprovi poste." @@ -396,16 +394,14 @@ msgstr "Elŝuti programon por poŝaparato" msgid "Get it on" msgstr "Elŝuti ĝin ĉe" -#, fuzzy msgid "Are you sure?" -msgstr "ĉu vi certas?" +msgstr "Ĉu vi certas?" msgid "Edit project" msgstr "Redakti projekton" -#, fuzzy msgid "Delete project" -msgstr "Redakti projekton" +msgstr "Forviŝi projekton" msgid "Import JSON" msgstr "Enporti JSON-dosieron" @@ -453,7 +449,7 @@ msgid "Everyone" msgstr "Ĉiuj" msgid "No one" -msgstr "" +msgstr "Neniu" msgid "More options" msgstr "" From 879936a4c48d3340c1fb1918e1c912eec2e32638 Mon Sep 17 00:00:00 2001 From: "Frank.wu" Date: Sun, 10 Oct 2021 07:05:16 +0200 Subject: [PATCH 15/33] Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (258 of 258 strings) Co-authored-by: Frank.wu Translate-URL: https://hosted.weblate.org/projects/i-hate-money/i-hate-money/zh_Hans/ Translation: I Hate Money/I Hate Money --- .../zh_Hans/LC_MESSAGES/messages.mo | Bin 16974 -> 20341 bytes .../zh_Hans/LC_MESSAGES/messages.po | 160 ++++++++---------- 2 files changed, 74 insertions(+), 86 deletions(-) diff --git a/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.mo b/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.mo index b2ff6719449b931948dd28ad57101061161f6ffb..969a2e233e378158b385ce57a77a90cdcde1dfd6 100644 GIT binary patch literal 20341 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 T?_IyZ;T1|hvOWB+pWyx**tBWZ delta 6023 zcmZA433L=i0><$gITQnOhC46>ArLu20J#w`kwXFH&|MXDNCt=`8B78J5hrp)}S! z0`{T?I%)k3V`%@3tid$mU{~ynb#MZX#c8PVPaqd>&SGD@ios+mEm{~;8-1t=Gf^uK z+V*nPm2E+7X(4JSj-m#W%N9&JST9{1^7dc(y~gBn9>UfNjsj+iA~3UFc5KLP}!Te^pM= z5sMd41OI>;;CJN5RBsh&$6`m?9kBt9Mct~&sEMapbFK4H3toZRkzJ@;`6_Bh&IGB9 zq4F{6DUauJ^>Fk>wMU~)d<-?glh(PYTa}Mmzy{mD1vSwtsE4uy_5O3#%gAP#Z&9}@ z80C!&R3CN4v9{e2wN-slw`3^tV@C6$9hr{Wf#s<4wxI^zkLrILb?>jB`u~l3Uo>AH zT3~l%+@R@CMGspNYHP+?r=UKDb5K{l2sP14)WBO%J9PxLQ}3b%zJj`t3e@@Eq8`#; zQS&t9K2tTZI6%0Hi@Miw z7>9#!AWlUsU?*zfCy-B`xrAQkH$PJ0$3(X`W-#`{WSooqnA5!I_}8edtjFlO=bcek zoQU1AAL^M1pmrn=xd!tBreg_m+fADeky|+!gSwJQRJ2tyaSG1In)nTBrQc%>tiz)( z8=^iX&8%%PydWGxeY7Dgj(>jJJ^3!R??w`ti@aK z1nP}vtrszh_7&7bU)%ogQSbj9HQ`O22-Zd(BU2aEpNOu9UBCet%h%;P`X3K=jojN` zQ1`BNLge8}K)w=866#7*P*3d?)RyMs06dL+>l#nD$SsUPU17Yno9*w7dM1*sf`e*Y5@&;7&8i+q1sbX=LN7A&O_a@Qq)2(Swq$ztfpt={dF+> z`@a#DM!X>owZ;8W6OTttG#m90<)J26f|_8r^&m#mK8?D^mr&I&LY(HrhWO+3h&ikfIKYJk;P4+~H`whzOf64ZGYtyfVK{R8#BT5ON3hnla6 zZO8Xw|8J+G8yy(kbJ)D^8koxj`m7o)~GY1?mFKeYYVP&-r^ zq@t~?&WBnPw6=D_rnLK@7B&W>aT036S=Obfi8fmcQRD2j9hph;iY7jf+M0`~zXv`?ZDrKJh)uBx?RGd02VgT?i7~hrbs^`hSMed*KcmJUbywv0 zc+@=8urBkP8LHqy)P$>1Z`h7}_n6(7jGrT)DARUOr2k=5dpT;rGpHSV54Dg=>wi$= zHX0nU4QjqF7}P`qspywXGHQ#4qXv8wb>d{yc{8nZtqZJ6tt(OEtVjJe-DLaEpvF0G z+n23h4QBr}@Yi%`!td;XUv2v){+iH$wNX1!4|QG=dQe|JD(btj?6D2quAQWvZNH6E z$$63!?h8i#o_~eqfpNhWMRSL)pM4w?jy(*;&yD09*-Ms@ zp`?iPB=JP$CNhV7O70;Q#7o+eH%OxPzcH0HWIOqssB9&VlG}*ND)KTJPd+3n)yYX! zD0!qNnMN)X72Vn!%U3j7k|VZrF;3A*d_$VGWF1jy9l<o7LYg92p9JMc`EzJbP_`b5|yjuE%F?Bn52`lM5PP)AY3zM1g<6% z$RMKfEIC02kWR!QDt{r56PMH_cVEBPQ8aoJmG{Xua*8pSJ=Q!WAY~?hMS>_y!iM}Z<+xew+3#WUlW5u;w-|ulov>Dbk zc~*`;E7O18LUk(GN>T>Kl> z?`Dvkv_NL(Bnb)Z_~XFD0Y$}e1FL(SI)g?;yF1oY&EHk!91kro3~ek4 ztvXVExYT)YPzPt>piiAygY)CuosRp$G7~Du3#}=tEP0u^|F7}dwopO7lRTuGGk3^C z&c{RUtL3hkSAKYTd1+o$n^k_ejMJRz!%sPH4ZpJwE3VkMpF!CLw_ukEtvy(^w!~DFt*~KhE;hhxjxM0dqH7PzNkOCbxiZFliTx2)8JHq3LM zEpQS>HLg}(wp`EL(8$J{P<|d8$*zSCFAVSUiM&urnF&RR?3MZBCYjK>;}ttfjl1}{ O(6a69k8$RW8uC9>9n2p9 diff --git a/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po b/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po index 26bb5077..3a790c3f 100644 --- a/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/zh_Hans/LC_MESSAGES/messages.po @@ -1,19 +1,18 @@ - msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-07-17 17:31+0200\n" -"PO-Revision-Date: 2020-10-12 04:47+0000\n" -"Last-Translator: Jwen921 \n" +"PO-Revision-Date: 2021-10-10 05:05+0000\n" +"Last-Translator: Frank.wu \n" +"Language-Team: Chinese (Simplified) \n" "Language: zh_Hans\n" -"Language-Team: Chinese (Simplified) " -"" -"\n" -"Plural-Forms: nplurals=1; plural=0\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +"X-Generator: Weblate 4.9-dev\n" "Generated-By: Babel 2.9.0\n" msgid "" @@ -24,12 +23,11 @@ msgstr "金额或符号无效。仅限数字与+-*/符号。" msgid "Project name" msgstr "账目名称" -#, fuzzy msgid "New private code" -msgstr "共享密钥" +msgstr "新的私人代码" msgid "Enter a new code if you want to change it" -msgstr "" +msgstr "如要更改,请输入新代码" msgid "Email" msgstr "邮箱" @@ -46,7 +44,7 @@ msgstr "默认货币" msgid "" "This project cannot be set to 'no currency' because it contains bills in " "multiple currencies." -msgstr "" +msgstr "此项目不能设置为“无货币”,因为它包含多种货币的账单。" msgid "Import previously exported JSON file" msgstr "导入之前的JSON 文件" @@ -70,15 +68,13 @@ msgid "" msgstr "账目(“%(project)s”)已存在,请选择一个新名称" msgid "Enter private code to confirm deletion" -msgstr "" +msgstr "请输入专用代码以确认删除" -#, fuzzy msgid "Unknown error" -msgstr "未知项目" +msgstr "未知错误" -#, fuzzy msgid "Invalid private code." -msgstr "共享密钥" +msgstr "无效的私人代码。" msgid "Get in" msgstr "进入" @@ -171,40 +167,40 @@ msgstr "此邮箱%(email)s不存在" #. List with two items only msgid "{dual_object_0} and {dual_object_1}" -msgstr "" +msgstr "{dual_object_0} 和 {dual_object_1}" #. Last two items of a list with more than 3 items msgid "{previous_object}, and {end_object}" -msgstr "" +msgstr "{previous_object} 和 {end_object}" #. Two items in a middle of a list with more than 5 objects msgid "{previous_object}, {next_object}" -msgstr "" +msgstr "{previous_object},{next_object}" #. First two items of a list with more than 3 items msgid "{start_object}, {next_object}" -msgstr "" +msgstr "{start_object}, {next_object}" msgid "No Currency" -msgstr "没有货币" +msgstr "无货币" #. Form error with only one error msgid "{prefix}: {error}" -msgstr "" +msgstr "{prefix}: {error}" #. Form error with a list of errors msgid "{prefix}:
{errors}" -msgstr "" +msgstr "{prefix}:
{errors}" msgid "Too many failed login attempts, please retry later." -msgstr "输入错误太多次了,请稍后重试。" +msgstr "登录失败次数过多,请稍后重试。" #, python-format msgid "This admin password is not the right one. Only %(num)d attempts left." -msgstr "管理密码有误,只剩 %(num)d次尝试机会" +msgstr "管理密码有误,只剩 %(num)d次尝试机会。" msgid "You either provided a bad token or no project identifier." -msgstr "你输入了错误的符号或没有项目标识符。" +msgstr "你输入了错误的令牌或没有项目标识符。" msgid "This private code is not the right one" msgstr "专用码不正确" @@ -241,7 +237,7 @@ msgid "Unknown project" msgstr "未知项目" msgid "Password successfully reset." -msgstr "密码重置成功" +msgstr "密码重置成功。" msgid "Project successfully uploaded" msgstr "项目成功上传" @@ -253,7 +249,7 @@ msgid "Project successfully deleted" msgstr "项目成功删除" msgid "Error deleting project" -msgstr "" +msgstr "删除项目时出错" #, python-format msgid "You have been invited to share your expenses for %(project)s" @@ -273,20 +269,20 @@ msgid "%(member)s has been added" msgstr "已添加%(member)s" msgid "Error activating member" -msgstr "" +msgstr "激活成员时出错" #, python-format msgid "%(name)s is part of this project again" msgstr "%(name)s 已经在项目里了" msgid "Error removing member" -msgstr "" +msgstr "删除成员时出错" #, python-format msgid "" "User '%(name)s' has been deactivated. It will still appear in the users " "list until its balance becomes zero." -msgstr "用户 '%(name)s'已被暂停,在余额为0之前会继续显示在用户列表里" +msgstr "用户 '%(name)s'已被暂停,在余额为0之前会继续显示在用户列表里。" #, python-format msgid "User '%(name)s' has been removed" @@ -300,7 +296,7 @@ msgid "The bill has been added" msgstr "帐单已添加" msgid "Error deleting bill" -msgstr "" +msgstr "删除账单时出错" msgid "The bill has been deleted" msgstr "账单已删除" @@ -308,26 +304,23 @@ msgstr "账单已删除" msgid "The bill has been modified" msgstr "帐单已修改" -#, fuzzy msgid "Error deleting project history" -msgstr "启用项目历史" +msgstr "删除项目历史记录时出错" -#, fuzzy msgid "Deleted project history." -msgstr "启用项目历史" +msgstr "已删除的项目历史记录。" -#, fuzzy msgid "Error deleting recorded IP addresses" -msgstr "删除已储存的IP地址" +msgstr "删除记录的IP地址时出错" msgid "Deleted recorded IP addresses in project history." -msgstr "" +msgstr "删除项目历史记录中的 IP 地址。" msgid "Sorry, we were unable to find the page you've asked for." -msgstr "对不起,未找到该页面" +msgstr "抱歉,我们无法找到您要求的页面。" msgid "The best thing to do is probably to get back to the main page." -msgstr "最好的办法是返回主页" +msgstr "最好的办法是返回主页。" msgid "Back to the list" msgstr "返回列表" @@ -375,26 +368,22 @@ msgid "show" msgstr "显示" msgid "The Dashboard is currently deactivated." -msgstr "操作面板失效" +msgstr "操作面板失效。" -#, fuzzy msgid "Download Mobile Application" -msgstr "手机软件" +msgstr "下载移动应用程序" -#, fuzzy msgid "Get it on" -msgstr "进入" +msgstr "获取" -#, fuzzy msgid "Are you sure?" -msgstr "确定?" +msgstr "是否确定?" msgid "Edit project" msgstr "编辑项目" -#, fuzzy msgid "Delete project" -msgstr "编辑项目" +msgstr "删除项目" msgid "Import JSON" msgstr "导入json文件" @@ -430,7 +419,7 @@ msgid "Edit the project" msgstr "编辑项目" msgid "This will remove all bills and participants in this project!" -msgstr "" +msgstr "这将删除此项目的所有账单和参与者!" msgid "Edit this bill" msgstr "编辑帐单" @@ -442,10 +431,10 @@ msgid "Everyone" msgstr "每个人" msgid "No one" -msgstr "" +msgstr "无人" msgid "More options" -msgstr "" +msgstr "更多选项" msgid "Add participant" msgstr "添加参与人" @@ -485,11 +474,11 @@ msgstr "历史设置改变" #, python-format msgid "Bill %(name)s: %(property_name)s changed from %(before)s to %(after)s" -msgstr "" +msgstr "账单 %(name)s: %(property_name)s 从 %(before)s 改为 %(after)s" #, python-format msgid "Bill %(name)s: %(property_name)s changed to %(after)s" -msgstr "" +msgstr "账单 %(name)s: %(property_name)s 改为 %(after)s" msgid "Confirm Remove IP Adresses" msgstr "确认移除IP地址" @@ -503,7 +492,6 @@ msgstr "" "你确定要删除此项目里所有的IP地址吗?\n" "项目其他内容不受影响,此操作不可撤回。" -#, fuzzy msgid "Confirm deletion" msgstr "确认删除" @@ -520,11 +508,11 @@ msgstr "确定删除此项目所有记录?此操作不可撤回。" #, python-format msgid "Bill %(name)s: added %(owers_list_str)s to owers list" -msgstr "" +msgstr "帐单 %(name)s:将 %(owers_list_str)s 添加到所有者列表" #, python-format msgid "Bill %(name)s: removed %(owers_list_str)s from owers list" -msgstr "" +msgstr "账单 %(name)s:从所有者列表中删除了 %(owers_list_str)s" #, python-format msgid "" @@ -590,51 +578,51 @@ msgstr "IP地址记录可在设置里禁用" msgid "From IP" msgstr "从IP" -#, fuzzy, python-format +#, python-format msgid "Project %(name)s added" -msgstr "账目名称" +msgstr "项目 %(name)s 已添加" -#, fuzzy, python-format +#, python-format msgid "Bill %(name)s added" -msgstr "帐单已添加" +msgstr "帐单 %(name)s 已添加" #, python-format msgid "Participant %(name)s added" -msgstr "" +msgstr "成员 %(name)s 已添加" msgid "Project private code changed" msgstr "项目专用码已更改" -#, fuzzy, python-format +#, python-format msgid "Project renamed to %(new_project_name)s" -msgstr "项目的标识符是%(project)s" +msgstr "项目的标识符是 %(new_project_name)s" -#, fuzzy, python-format +#, python-format msgid "Project contact email changed to %(new_email)s" -msgstr "项目联系邮箱更改为" +msgstr "项目联系邮箱更改为 %(new_email)s" msgid "Project settings modified" msgstr "项目设置已修改" #, python-format msgid "Participant %(name)s deactivated" -msgstr "" +msgstr "成员 %(name)s 已停用" #, python-format msgid "Participant %(name)s reactivated" -msgstr "" +msgstr "成员 %(name)s 被重新激活" #, python-format msgid "Participant %(name)s renamed to %(new_name)s" -msgstr "" +msgstr "成员 %(name)s 重命名为 %(new_name)s" #, python-format msgid "Bill %(name)s renamed to %(new_description)s" -msgstr "" +msgstr "账单 %(name)s 更名为 %(new_description)s" #, python-format msgid "Participant %(name)s: weight changed from %(old_weight)s to %(new_weight)s" -msgstr "" +msgstr "成员 %(name)s:权重从%(old_weight)s变为%(new_weight)s" msgid "Amount" msgstr "数量" @@ -643,33 +631,33 @@ msgstr "数量" msgid "Amount in %(currency)s" msgstr "%(currency)s的数量是" -#, fuzzy, python-format +#, python-format msgid "Bill %(name)s modified" -msgstr "帐单已修改" +msgstr "帐单 %(name)s 已修改" #, python-format msgid "Participant %(name)s modified" -msgstr "" +msgstr "成员 %(name)s 已修改" -#, fuzzy, python-format +#, python-format msgid "Bill %(name)s removed" -msgstr "用户 '%(name)s'已被移除" +msgstr "账单 %(name)s 已被移除" -#, fuzzy, python-format +#, python-format msgid "Participant %(name)s removed" -msgstr "用户 '%(name)s'已被移除" +msgstr "用户 %(name)s 已被移除" -#, fuzzy, python-format +#, python-format msgid "Project %(name)s changed in an unknown way" -msgstr "未知的改变" +msgstr "项目 %(name)s 以未知方式更改" -#, fuzzy, python-format +#, python-format msgid "Bill %(name)s changed in an unknown way" -msgstr "未知的改变" +msgstr "账单 %(name)s 以一种未知的方式更改" -#, fuzzy, python-format +#, python-format msgid "Participant %(name)s changed in an unknown way" -msgstr "未知的改变" +msgstr "成员 %(name)s 以未知方式更改" msgid "Nothing to list" msgstr "无列表" @@ -815,7 +803,7 @@ msgid "No bills" msgstr "没有账单" msgid "Nothing to list yet." -msgstr "没有列表" +msgstr "没有列表。" msgid "You probably want to" msgstr "你想要" From c13c4c7e3cf1cdacc0549d6d92a04bdaa45b42af Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 10 Oct 2021 11:56:28 +0200 Subject: [PATCH 16/33] Fix duplicate entry in changelog --- CHANGELOG.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 44270012..89515862 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -37,7 +37,6 @@ Added - Add sorting, pagination, and searching to the admin dashboard (#538) - Add Project History page that records all changes (#553) - Add token-based authentication to the API (#504) -- Add translations for Hindi, Portuguese (Brazil), Tamil - Add illustrations as a showcase, currently only for French (#544) - Add a page for downloading mobile application (#688) - Add translations for Greek, Esperanto, Italian, Japanese, Portuguese and Swedish From bbe00ebb579918cf622236817dfa14f1e7b6f0b9 Mon Sep 17 00:00:00 2001 From: Glandos Date: Sun, 10 Oct 2021 14:43:40 +0200 Subject: [PATCH 17/33] Include project code into project authentication token (#802) Fix #780 This a breaking change, the API for authentication is different, as it now requires `project_id`. Token is generated with only the project_id (so it's shorter than before), and signature is done by mixing password with secret key. Thus, it expires on every project code change. --- ihatemoney/api/common.py | 4 +- ihatemoney/models.py | 52 ++++++++++++++------ ihatemoney/templates/invitation_mail.en.j2 | 2 +- ihatemoney/templates/invitation_mail.fr.j2 | 2 +- ihatemoney/templates/password_reminder.en.j2 | 2 +- ihatemoney/templates/password_reminder.fr.j2 | 2 +- ihatemoney/templates/send_invites.html | 4 +- ihatemoney/tests/api_test.py | 4 +- ihatemoney/tests/budget_test.py | 43 ++++++++++++++++ ihatemoney/tests/common/help_functions.py | 11 +++++ ihatemoney/web.py | 18 ++++--- 11 files changed, 114 insertions(+), 30 deletions(-) diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py index ede76e46..fa097dec 100644 --- a/ihatemoney/api/common.py +++ b/ihatemoney/api/common.py @@ -35,7 +35,9 @@ def need_auth(f): auth_token = auth_header.split(" ")[1] except IndexError: abort(401) - project_id = Project.verify_token(auth_token, token_type="non_timed_token") + project_id = Project.verify_token( + auth_token, token_type="auth", project_id=project_id + ) if auth_token and project_id: project = Project.query.get(project_id) if project: diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 68d1fd84..b3d1cba2 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -7,8 +7,8 @@ from flask_sqlalchemy import BaseQuery, SQLAlchemy from itsdangerous import ( BadSignature, SignatureExpired, - TimedJSONWebSignatureSerializer, URLSafeSerializer, + URLSafeTimedSerializer, ) import sqlalchemy from sqlalchemy import orm @@ -339,41 +339,61 @@ class Project(db.Model): db.session.delete(self) db.session.commit() - def generate_token(self, expiration=0): + def generate_token(self, token_type="auth"): """Generate a timed and serialized JsonWebToken - :param expiration: Token expiration time (in seconds) + :param token_type: Either "auth" for authentication (invalidated when project code changed), + or "reset" for password reset (invalidated after expiration) """ - if expiration: - serializer = TimedJSONWebSignatureSerializer( - current_app.config["SECRET_KEY"], expiration + + if token_type == "reset": + serializer = URLSafeTimedSerializer( + current_app.config["SECRET_KEY"], salt=token_type ) - token = serializer.dumps({"project_id": self.id}).decode("utf-8") + token = serializer.dumps([self.id]) else: - serializer = URLSafeSerializer(current_app.config["SECRET_KEY"]) - token = serializer.dumps({"project_id": self.id}) + serializer = URLSafeSerializer( + current_app.config["SECRET_KEY"] + self.password, salt=token_type + ) + token = serializer.dumps([self.id]) + return token @staticmethod - def verify_token(token, token_type="timed_token"): + def verify_token(token, token_type="auth", project_id=None, max_age=3600): """Return the project id associated to the provided token, None if the provided token is expired or not valid. :param token: Serialized TimedJsonWebToken + :param token_type: Either "auth" for authentication (invalidated when project code changed), + or "reset" for password reset (invalidated after expiration) + :param project_id: Project ID. Used for token_type "auth" to use the password as serializer + secret key. + :param max_age: Token expiration time (in seconds). Only used with token_type "reset" """ - if token_type == "timed_token": - serializer = TimedJSONWebSignatureSerializer( - current_app.config["SECRET_KEY"] + loads_kwargs = {} + if token_type == "reset": + serializer = URLSafeTimedSerializer( + current_app.config["SECRET_KEY"], salt=token_type ) + loads_kwargs["max_age"] = max_age else: - serializer = URLSafeSerializer(current_app.config["SECRET_KEY"]) + project = Project.query.get(project_id) if project_id is not None else None + password = project.password if project is not None else "" + serializer = URLSafeSerializer( + current_app.config["SECRET_KEY"] + password, salt=token_type + ) try: - data = serializer.loads(token) + data = serializer.loads(token, **loads_kwargs) except SignatureExpired: return None except BadSignature: return None - return data["project_id"] + + data_project = data[0] if isinstance(data, list) else None + return ( + data_project if project_id is None or data_project == project_id else None + ) def __str__(self): return self.name diff --git a/ihatemoney/templates/invitation_mail.en.j2 b/ihatemoney/templates/invitation_mail.en.j2 index 79fcc427..2b3157b1 100644 --- a/ihatemoney/templates/invitation_mail.en.j2 +++ b/ihatemoney/templates/invitation_mail.en.j2 @@ -4,7 +4,7 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest. -You can log in using this link: {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. +You can log in using this link: {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}. Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }} If your cookie gets deleted or if you log out, you will need to log back in using the first link. diff --git a/ihatemoney/templates/invitation_mail.fr.j2 b/ihatemoney/templates/invitation_mail.fr.j2 index e57d7035..d095cfdb 100644 --- a/ihatemoney/templates/invitation_mail.fr.j2 +++ b/ihatemoney/templates/invitation_mail.fr.j2 @@ -4,7 +4,7 @@ Quelqu'un dont l'adresse email est {{ g.project.contact_email }} vous a invité Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on s’occupe du reste. -Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }}. +Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}. Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }} Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien. diff --git a/ihatemoney/templates/password_reminder.en.j2 b/ihatemoney/templates/password_reminder.en.j2 index c6543546..845ff790 100644 --- a/ihatemoney/templates/password_reminder.en.j2 +++ b/ihatemoney/templates/password_reminder.en.j2 @@ -1,7 +1,7 @@ Hi, You requested to reset the password of the following project: "{{ project.name }}". -You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. +You can reset it here: {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}. This link is only valid for one hour. Hope this helps, diff --git a/ihatemoney/templates/password_reminder.fr.j2 b/ihatemoney/templates/password_reminder.fr.j2 index 17c52c4d..4603a963 100644 --- a/ihatemoney/templates/password_reminder.fr.j2 +++ b/ihatemoney/templates/password_reminder.fr.j2 @@ -1,7 +1,7 @@ Salut, Vous avez demandé à réinitialiser le mot de passe du projet suivant : "{{ project.name }}". -Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(expiration=3600)) }}. +Vous pouvez le réinitialiser ici : {{ url_for(".reset_password", _external=True, token=project.generate_token(token_type="reset")) }}. Ce lien est seulement valide pendant 1 heure. Faites-en bon usage ! diff --git a/ihatemoney/templates/send_invites.html b/ihatemoney/templates/send_invites.html index 53492c85..8b73b175 100644 --- a/ihatemoney/templates/send_invites.html +++ b/ihatemoney/templates/send_invites.html @@ -21,8 +21,8 @@ {{ _("You can directly share the following link via your prefered medium") }}
- - {{ url_for(".authenticate", _external=True, token=g.project.generate_token()) }} + + {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }} diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py index 41f5ab2d..83d5aa2a 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -213,7 +213,9 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette/token", headers=self.get_auth("raclette") ) decoded_resp = json.loads(resp.data.decode("utf-8")) - resp = self.client.get("/authenticate?token={}".format(decoded_resp["token"])) + resp = self.client.get( + f"/authenticate?token={decoded_resp['token']}&project_id=raclette" + ) # Test that we are redirected. self.assertEqual(302, resp.status_code) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 75a2dc35..af33197a 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -4,6 +4,7 @@ import json import re from time import sleep import unittest +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse from flask import session import pytest @@ -11,6 +12,7 @@ from werkzeug.security import check_password_hash, generate_password_hash from ihatemoney import models from ihatemoney.currency_convertor import CurrencyConverter +from ihatemoney.tests.common.help_functions import extract_link from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase from ihatemoney.versioning import LoggingMode @@ -87,11 +89,52 @@ class BudgetTestCase(IhatemoneyTestCase): ) # Test empty and invalid tokens self.client.get("/exit") + # Use another project_id + parsed_url = urlparse(url) + query = parse_qs(parsed_url.query) + query["project_id"] = "invalid" + resp = self.client.get( + urlunparse(parsed_url._replace(query=urlencode(query, doseq=True))) + ) + assert "You either provided a bad token" in resp.data.decode("utf-8") + resp = self.client.get("/authenticate") self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) resp = self.client.get("/authenticate?token=token") self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) + def test_invite_code_invalidation(self): + """Test that invitation link expire after code change""" + self.login("raclette") + self.post_project("raclette") + response = self.client.get("/raclette/invite").data.decode("utf-8") + link = extract_link(response, "share the following link") + + self.client.get("/exit") + response = self.client.get(link) + # Link is valid + assert response.status_code == 302 + + # Change password to invalidate token + # Other data are required, but useless for the test + response = self.client.post( + "/raclette/edit", + data={ + "name": "raclette", + "contact_email": "zorglub@notmyidea.org", + "password": "didoudida", + "default_currency": "XXX", + }, + follow_redirects=True, + ) + assert response.status_code == 200 + assert "alert-danger" not in response.data.decode("utf-8") + + self.client.get("/exit") + response = self.client.get(link, follow_redirects=True) + # Link is invalid + self.assertIn("You either provided a bad token", response.data.decode("utf-8")) + def test_password_reminder(self): # test that it is possible to have an email containing the password of a # project in case people forget it (and it happens!) diff --git a/ihatemoney/tests/common/help_functions.py b/ihatemoney/tests/common/help_functions.py index e9c4dcd1..5a401059 100644 --- a/ihatemoney/tests/common/help_functions.py +++ b/ihatemoney/tests/common/help_functions.py @@ -1,5 +1,16 @@ +from markupsafe import Markup + + def em_surround(string, regex_escape=False): if regex_escape: return r'%s<\/em>' % string else: return '%s' % string + + +def extract_link(data, start_prefix): + base_index = data.find(start_prefix) + start = data.find('href="', base_index) + 6 + end = data.find('">', base_index) + link = Markup(data[start:end]).unescape() + return link diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 712d2b0e..5af15e08 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -199,15 +199,21 @@ def admin(): def authenticate(project_id=None): """Authentication form""" form = AuthenticationForm() + + if not form.id.data and request.args.get("project_id"): + form.id.data = request.args["project_id"] + project_id = form.id.data # Try to get project_id from token first token = request.args.get("token") if token: - project_id = Project.verify_token(token, token_type="non_timed_token") - token_auth = True + verified_project_id = Project.verify_token( + token, token_type="auth", project_id=project_id + ) + if verified_project_id == project_id: + token_auth = True + else: + project_id = None else: - if not form.id.data and request.args.get("project_id"): - form.id.data = request.args["project_id"] - project_id = form.id.data token_auth = False if project_id is None: # User doesn't provide project identifier or a valid token @@ -381,7 +387,7 @@ def reset_password(): return render_template( "reset_password.html", form=form, error=_("No token provided") ) - project_id = Project.verify_token(token) + project_id = Project.verify_token(token, token_type="reset") if not project_id: return render_template( "reset_password.html", form=form, error=_("Invalid token") From c1ef4033b6fde6181a30ab76b53f699cfe1eb7c4 Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 10 Oct 2021 18:34:24 +0200 Subject: [PATCH 18/33] Better document configuration files location and override --- docs/configuration.rst | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index b0733a8a..29ab34f7 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -13,6 +13,21 @@ To know defaults on your deployed instance, simply look at your "Production values" are the recommended values for use in production. +Configuration files +------------------- + +By default, Ihatemoney loads its configuration from ``/etc/ihatemoney/ihatemoney.cfg``. + +If you need to load the configuration from a custom path, you can define the +``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable with the path to the configuration +file. +For instance :: + + export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg" + +The path should be absolute. A relative path will be interpreted as being +inside ``/etc/ihatemoney/``. + `SQLALCHEMY_DATABASE_URI` ------------------------- @@ -142,12 +157,3 @@ possible to configure it to act differently, thanks to the great * **MAIL_PASSWORD** : default **None** * **DEFAULT_MAIL_SENDER** : default **None** -Using an alternate settings path --------------------------------- - -You can put your settings file where you want, and pass its path to the -application using the ``IHATEMONEY_SETTINGS_FILE_PATH`` environment variable. - -For instance :: - - export IHATEMONEY_SETTINGS_FILE_PATH="/path/to/your/conf/file.cfg" From 7554842b1f3ae4497669d0ae2eb1e715e9125f91 Mon Sep 17 00:00:00 2001 From: zorun Date: Sun, 10 Oct 2021 18:39:03 +0200 Subject: [PATCH 19/33] Add URL validation to external link to prevent XSS (#846) Co-authored-by: Baptiste Jonglez --- ihatemoney/forms.py | 3 ++- ihatemoney/tests/budget_test.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index fe1bdcc7..180619c7 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -13,6 +13,7 @@ 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 ( + URL, DataRequired, Email, EqualTo, @@ -292,7 +293,7 @@ class BillForm(FlaskForm): original_currency = SelectField(_("Currency"), validators=[DataRequired()]) external_link = URLField( _("External link"), - validators=[Optional()], + validators=[Optional(), URL()], description=_("A link to an external document, related to this bill"), ) payed_for = SelectMultipleField( diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index af33197a..1539ece7 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -675,6 +675,35 @@ class BudgetTestCase(IhatemoneyTestCase): bill = models.Bill.query.filter(models.Bill.date == "2011-08-01")[0] self.assertEqual(bill.amount, 25.02) + # add a bill with a valid external link + self.client.post( + "/raclette/add", + data={ + "date": "2015-05-05", + "what": "fromage à raclette", + "payer": members_ids[0], + "payed_for": members_ids, + "amount": "42", + "external_link": "https://example.com/fromage", + }, + ) + bill = models.Bill.query.filter(models.Bill.date == "2015-05-05")[0] + self.assertEqual(bill.external_link, "https://example.com/fromage") + + # add a bill with an invalid external link + resp = self.client.post( + "/raclette/add", + data={ + "date": "2015-05-06", + "what": "mauvais fromage à raclette", + "payer": members_ids[0], + "payed_for": members_ids, + "amount": "42000", + "external_link": "javascript:alert('Tu bluffes, Martoni.')", + }, + ) + self.assertIn("Invalid URL", resp.data.decode("utf-8")) + def test_weighted_balance(self): self.post_project("raclette") From e626a1cbea766ce15d528e7379208435aa5cad4f Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 10 Oct 2021 17:45:56 +0200 Subject: [PATCH 20/33] Implement security best practices using Flask-Talisman --- CHANGELOG.rst | 2 ++ conf/entrypoint.sh | 1 + docs/configuration.rst | 15 +++++++++++++++ docs/contributing.rst | 12 ++++++++++-- docs/upgrade.rst | 11 +++++++++++ ihatemoney/conf-templates/ihatemoney.cfg.j2 | 4 ++++ ihatemoney/default_settings.py | 1 + ihatemoney/run.py | 19 +++++++++++++++++++ setup.cfg | 1 + 9 files changed, 64 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 89515862..326b851d 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ This document describes changes between each past release. Breaking changes ---------------- +- Enable session cookie security by default (#845) - Drop support for Python 2 (#483) - Drop support for Python 3.5 (#571) - Drop support for MySQL (#743) @@ -25,6 +26,7 @@ Security - Add CSRF validation on destructive actions (#796) - Ask for private code to delete project or project history (#796) +- Add headers to mitigate Clickjacking, XSS, and other attacks: `X-Frame-Options`, `X-XSS-Protection`, `X-Content-Type-Options`, `Content-Security-Policy`, `Referrer-Policy` (#845) Added ----- diff --git a/conf/entrypoint.sh b/conf/entrypoint.sh index dfe5e12c..e66ad7c8 100755 --- a/conf/entrypoint.sh +++ b/conf/entrypoint.sh @@ -21,6 +21,7 @@ ADMIN_PASSWORD = '$ADMIN_PASSWORD' ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE" +SESSION_COOKIE_SECURE = $SESSION_COOKIE_SECURE EOF # Start gunicorn without forking diff --git a/docs/configuration.rst b/docs/configuration.rst index 29ab34f7..d29ef9f1 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -64,6 +64,21 @@ of the secret key could easily access any project and bypass the private code ve - **Production value:** `ihatemoney conf-example ihatemoney.cfg` sets it to something random, which is good. +`SESSION_COOKIE_SECURE` +----------------------- + +A boolean that controls whether the session cookie will be marked "secure". +If this is the case, browsers will refuse to send the session cookie over plain HTTP. + +- **Default value:** ``True`` +- **Production value:** ``True`` if you run your service over HTTPS, ``False`` if you run + your service over plain HTTP. + +Note: this setting is actually interpreted by Flask, see the +`Flask documentation`_ for details. + +.. _Flask documentation: https://flask.palletsprojects.com/en/2.0.x/config/#SESSION_COOKIE_SECURE + `MAIL_DEFAULT_SENDER` --------------------- diff --git a/docs/contributing.rst b/docs/contributing.rst index 80174b76..5d597b25 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -104,12 +104,20 @@ You can create a ``settings.cfg`` file, with the following content:: DEBUG = True SQLACHEMY_ECHO = DEBUG -You can also set the `TESTING` flag to `True` so no mails are sent -(and no exception is raised) while you're on development mode. Then before running the application, declare its path with :: export IHATEMONEY_SETTINGS_FILE_PATH="$(pwd)/settings.cfg" +You can also set the ``TESTING`` flag to ``True`` so no mails are sent +(and no exception is raised) while you're on development mode. + +In some cases, you may need to disable secure cookies by setting +``SESSION_COOKIE_SECURE`` to ``False``. This is needed if you +access your dev server over the network: with the default value +of ``SESSION_COOKIE_SECURE``, the browser will refuse to send +the session cookie over insecure HTTP, so many features of Ihatemoney +won't work (project login, language change, etc). + .. _contributing-developer: Contributing as a developer diff --git a/docs/upgrade.rst b/docs/upgrade.rst index ec846324..53185413 100644 --- a/docs/upgrade.rst +++ b/docs/upgrade.rst @@ -65,6 +65,17 @@ If so, pick the ``pip`` commands to use in the relevant section(s) of Then follow :ref:`general-procedure` from step 1. in order to complete the update. +Disable session cookie security if running over plain HTTP +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +.. note:: If you are running Ihatemoney over HTTPS, no special action is required. + +Session cookies are now marked "secure" by default to increase security. + +If you run Ihatemoney over plain HTTP, you need to explicitly disable this security +feature by setting ``SESSION_COOKIE_SECURE`` to ``False``, see :ref:`configuration`. + + Switch to MariaDB >= 10.3.2 instead of MySQL ++++++++++++++++++++++++++++++++++++++++++++ diff --git a/ihatemoney/conf-templates/ihatemoney.cfg.j2 b/ihatemoney/conf-templates/ihatemoney.cfg.j2 index 0188c6b1..13a8e9f5 100644 --- a/ihatemoney/conf-templates/ihatemoney.cfg.j2 +++ b/ihatemoney/conf-templates/ihatemoney.cfg.j2 @@ -38,3 +38,7 @@ ACTIVATE_ADMIN_DASHBOARD = False # You can change the timezone used to display time. By default it will be #derived from the server OS. #BABEL_DEFAULT_TIMEZONE = "Europe/Paris" + +# Enable secure cookies. Requires HTTPS. Disable if you run your ihatemoney +# service over plain HTTP. +SESSION_COOKIE_SECURE = True diff --git a/ihatemoney/default_settings.py b/ihatemoney/default_settings.py index 9050bbeb..96795a01 100644 --- a/ihatemoney/default_settings.py +++ b/ihatemoney/default_settings.py @@ -8,6 +8,7 @@ ACTIVATE_DEMO_PROJECT = True ADMIN_PASSWORD = "" ALLOW_PUBLIC_PROJECT_CREATION = True ACTIVATE_ADMIN_DASHBOARD = False +SESSION_COOKIE_SECURE = True SUPPORTED_LANGUAGES = [ "de", "el", diff --git a/ihatemoney/run.py b/ihatemoney/run.py index c8fc5b25..cea6f93e 100644 --- a/ihatemoney/run.py +++ b/ihatemoney/run.py @@ -7,6 +7,7 @@ from flask import Flask, g, render_template, request, session from flask_babel import Babel, format_currency from flask_mail import Mail from flask_migrate import Migrate, stamp, upgrade +from flask_talisman import Talisman from jinja2 import pass_context from markupsafe import Markup import pytz @@ -126,6 +127,24 @@ def create_app( instance_relative_config=instance_relative_config, ) + # If we need to load external JS/CSS/image resources, it needs to be added here, see + # https://github.com/wntrblm/flask-talisman#content-security-policy + csp = { + "default-src": ["'self'"], + # We have several inline javascript scripts :( + "script-src": ["'self'", "'unsafe-inline'"], + "object-src": "'none'", + } + + Talisman( + app, + # Forcing HTTPS is the job of a reverse proxy + force_https=False, + # This is handled separately through the SESSION_COOKIE_SECURE Flask setting + session_cookie_secure=False, + content_security_policy=csp, + ) + # If a configuration object is passed, use it. Otherwise try to find one. load_configuration(app, configuration) app.wsgi_app = PrefixedWSGI(app) diff --git a/setup.cfg b/setup.cfg index 3bbf6f27..f58c1619 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,6 +33,7 @@ install_requires = Flask-Migrate>=2.5.3,<4 # Not following semantic versioning (e.g. https://github.com/miguelgrinberg/flask-migrate/commit/1af28ba273de6c88544623b8dc02dd539340294b) Flask-RESTful>=0.3.9,<1 Flask-SQLAlchemy>=2.4,<3 + Flask-Talisman>=0.8,<1 Flask-WTF>=0.14.3,<1 WTForms>=2.3.1,<2.4 Flask>=2,<3 From 2bcc41bdb3bb73e08864e9caac9664513d3a9694 Mon Sep 17 00:00:00 2001 From: Alexis Metaireau Date: Mon, 11 Oct 2021 17:39:24 +0200 Subject: [PATCH 21/33] Add optional support for a simple CAPTCHA. (#844) * Add optional support for a simple CAPTCHA. * formatting * add test case * Flake8 Co-authored-by: Glandos --- docs/configuration.rst | 5 +++ ihatemoney/conf-templates/ihatemoney.cfg.j2 | 4 ++ ihatemoney/default_settings.py | 1 + ihatemoney/forms.py | 13 ++++++ ihatemoney/templates/forms.html | 5 ++- .../tests/common/ihatemoney_testcase.py | 1 + ihatemoney/tests/main_test.py | 45 +++++++++++++++++++ ihatemoney/web.py | 11 ++++- 8 files changed, 82 insertions(+), 3 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index d29ef9f1..140a12e6 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -155,6 +155,11 @@ Note: this setting is actually interpreted by Flask-Babel, see the .. _Flask-Babel guide for formatting dates: https://pythonhosted.org/Flask-Babel/#formatting-dates +`ENABLE_CAPTCHA` +--------------- + +It is possible to add a simple captcha in order to filter out spammer bots on the form creation. +In order to do so, you just have to set `ENABLE_CAPTCHA = True`. Configuring emails sending -------------------------- diff --git a/ihatemoney/conf-templates/ihatemoney.cfg.j2 b/ihatemoney/conf-templates/ihatemoney.cfg.j2 index 13a8e9f5..9d117c18 100644 --- a/ihatemoney/conf-templates/ihatemoney.cfg.j2 +++ b/ihatemoney/conf-templates/ihatemoney.cfg.j2 @@ -42,3 +42,7 @@ ACTIVATE_ADMIN_DASHBOARD = False # Enable secure cookies. Requires HTTPS. Disable if you run your ihatemoney # service over plain HTTP. SESSION_COOKIE_SECURE = True + +# You can activate an optional CAPTCHA if you want to. It can be helpful +# to filter spammer bots. +# ENABLE_CAPTCHA = True diff --git a/ihatemoney/default_settings.py b/ihatemoney/default_settings.py index 96795a01..3204ed3b 100644 --- a/ihatemoney/default_settings.py +++ b/ihatemoney/default_settings.py @@ -32,3 +32,4 @@ SUPPORTED_LANGUAGES = [ "uk", "zh_Hans", ] +ENABLE_CAPTCHA = False diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 180619c7..3bbe34a5 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -221,6 +221,19 @@ class ProjectForm(EditProjectForm): ) raise ValidationError(Markup(message)) + @classmethod + def enable_captcha(cls): + captchaField = StringField( + _("Which is a real currency: Euro or Petro dollar?"), + validators=[DataRequired()], + ) + setattr(cls, "captcha", captchaField) + + def validate_captcha(form, field): + if not field.data.lower() == _("euro"): + message = _("Please, validate the captcha to proceed.") + raise ValidationError(Markup(message)) + class DestructiveActionProjectForm(FlaskForm): """Used for any important "delete" action linked to a project: diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html index a9965564..9d8e8c99 100644 --- a/ihatemoney/templates/forms.html +++ b/ihatemoney/templates/forms.html @@ -75,6 +75,9 @@ {{ input(form.name) }} {{ input(form.password) }} {{ input(form.contact_email) }} + {% if config['ENABLE_CAPTCHA'] %} + {{ input(form.captcha) }} + {% endif %} {{ input(form.default_currency) }} {% if not home %} {{ submit(form.submit, home=True) }} @@ -171,7 +174,7 @@ - +
{{ _("More options") }} {% if g.project.default_currency != "XXX" %} diff --git a/ihatemoney/tests/common/ihatemoney_testcase.py b/ihatemoney/tests/common/ihatemoney_testcase.py index 4a92f616..2c34e1e1 100644 --- a/ihatemoney/tests/common/ihatemoney_testcase.py +++ b/ihatemoney/tests/common/ihatemoney_testcase.py @@ -15,6 +15,7 @@ class BaseTestCase(TestCase): SQLALCHEMY_DATABASE_URI = os.environ.get( "TESTING_SQLALCHEMY_DATABASE_URI", "sqlite://" ) + ENABLE_CAPTCHA = False def create_app(self): # Pass the test object as a configuration. diff --git a/ihatemoney/tests/main_test.py b/ihatemoney/tests/main_test.py index eaf6a01f..604aedd2 100644 --- a/ihatemoney/tests/main_test.py +++ b/ihatemoney/tests/main_test.py @@ -31,6 +31,7 @@ class ConfigurationTestCase(BaseTestCase): self.assertTrue(self.app.config["ACTIVATE_DEMO_PROJECT"]) self.assertTrue(self.app.config["ALLOW_PUBLIC_PROJECT_CREATION"]) self.assertFalse(self.app.config["ACTIVATE_ADMIN_DASHBOARD"]) + self.assertFalse(self.app.config["ENABLE_CAPTCHA"]) def test_env_var_configuration_file(self): """Test that settings are loaded from a configuration file specified @@ -241,6 +242,50 @@ class EmailFailureTestCase(IhatemoneyTestCase): ) +class CaptchaTestCase(IhatemoneyTestCase): + ENABLE_CAPTCHA = True + + def test_project_creation_with_captcha(self): + with self.app.test_client() as c: + c.post( + "/create", + data={ + "name": "raclette party", + "id": "raclette", + "password": "party", + "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", + }, + ) + assert len(models.Project.query.all()) == 0 + + c.post( + "/create", + data={ + "name": "raclette party", + "id": "raclette", + "password": "party", + "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", + "captcha": "nope", + }, + ) + assert len(models.Project.query.all()) == 0 + + c.post( + "/create", + data={ + "name": "raclette party", + "id": "raclette", + "password": "party", + "contact_email": "raclette@notmyidea.org", + "default_currency": "USD", + "captcha": "euro", + }, + ) + assert len(models.Project.query.all()) == 1 + + class TestCurrencyConverter(unittest.TestCase): converter = CurrencyConverter() mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 5af15e08..19f88976 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -260,9 +260,16 @@ def authenticate(project_id=None): return render_template("authenticate.html", form=form) +def get_project_form(): + if current_app.config.get("ENABLE_CAPTCHA", False): + ProjectForm.enable_captcha() + + return ProjectForm() + + @main.route("/", strict_slashes=False) def home(): - project_form = ProjectForm() + project_form = get_project_form() auth_form = AuthenticationForm() is_demo_project_activated = current_app.config["ACTIVATE_DEMO_PROJECT"] is_public_project_creation_allowed = current_app.config[ @@ -287,7 +294,7 @@ def mobile(): @main.route("/create", methods=["GET", "POST"]) @requires_admin(bypass=("ALLOW_PUBLIC_PROJECT_CREATION", True)) def create_project(): - form = ProjectForm() + form = get_project_form() if request.method == "GET" and "project_id" in request.values: form.name.data = request.values["project_id"] From 2c487dd4b732d971c6cf49e9679629c048652701 Mon Sep 17 00:00:00 2001 From: Youe Graillot Date: Mon, 11 Oct 2021 17:24:36 +0000 Subject: [PATCH 22/33] Continuous integration build and push to dockerhub (#851) * Github Action build and push to dockerhub * Update CONTRIBUTORS --- .github/workflows/dockerhub.yml | 52 +++++++++++++++++++++++++++++++++ CONTRIBUTORS | 3 +- 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/dockerhub.yml diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml new file mode 100644 index 00000000..7577b7fc --- /dev/null +++ b/.github/workflows/dockerhub.yml @@ -0,0 +1,52 @@ +name: CI to Docker Hub + +on: + push: + tags: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Cache Docker layers + uses: actions/cache@v2 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-buildx- + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v3 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPOSITORY }} + + - uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./ + file: ./Dockerfile + builder: ${{ steps.buildx.outputs.name }} + push: true + labels: ${{ steps.meta.outputs.labels }} + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,dest=/tmp/.buildx-cache + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/CONTRIBUTORS b/CONTRIBUTORS index bd4d363e..fb6f4244 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -29,8 +29,8 @@ Glandos Heimen Stoffels James Leong Jocelyn Delalande -Lucas Verney Luc Didry +Lucas Verney Marien Fressinaud Mathieu Leplatre mcnesium @@ -44,6 +44,7 @@ Richard Coates THANOS SIOURDAKIS Toover Xavier Mehrenberger +Youe Graillot zorun The manual drawings are from Coline Billon, they are under CC BY 4.0. From cb8f6aaafbc690a634f1fb946fd8ae4212fd92b4 Mon Sep 17 00:00:00 2001 From: HarshVT8999 <74756717+HarshVT8999@users.noreply.github.com> Date: Mon, 11 Oct 2021 23:00:00 +0530 Subject: [PATCH 23/33] Changed Font Size of the Logo (#828) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Alexis Métaireau --- ihatemoney/static/css/main.css | 1 + 1 file changed, 1 insertion(+) diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index 0cd4368e..13af3675 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -28,6 +28,7 @@ body { } .navbar-brand { font-family: "Lobster", arial, serif; + font-size: 1.5rem; } @media (min-width: 992px) { From 7d9226745f3d1ceb25f3dc31fccd222d171694fa Mon Sep 17 00:00:00 2001 From: Glandos Date: Wed, 13 Oct 2021 22:00:38 +0200 Subject: [PATCH 24/33] Change token path authentication to /PROJECT/join/TOKEN (#843) --- ihatemoney/templates/invitation_mail.en.j2 | 2 +- ihatemoney/templates/invitation_mail.fr.j2 | 2 +- ihatemoney/templates/send_invites.html | 4 +- ihatemoney/tests/api_test.py | 4 +- ihatemoney/tests/budget_test.py | 22 +++++---- ihatemoney/web.py | 53 +++++++++++----------- 6 files changed, 44 insertions(+), 43 deletions(-) diff --git a/ihatemoney/templates/invitation_mail.en.j2 b/ihatemoney/templates/invitation_mail.en.j2 index 2b3157b1..bb38b9aa 100644 --- a/ihatemoney/templates/invitation_mail.en.j2 +++ b/ihatemoney/templates/invitation_mail.en.j2 @@ -4,7 +4,7 @@ Someone using the email address {{ g.project.contact_email }} invited you to sha It's as simple as saying what did you pay for, for whom, and how much did it cost you, we are caring about the rest. -You can log in using this link: {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}. +You can log in using this link: {{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}. Once logged-in, you can use the following link which is easier to remember: {{ url_for(".list_bills", _external=True) }} If your cookie gets deleted or if you log out, you will need to log back in using the first link. diff --git a/ihatemoney/templates/invitation_mail.fr.j2 b/ihatemoney/templates/invitation_mail.fr.j2 index d095cfdb..0cf02f43 100644 --- a/ihatemoney/templates/invitation_mail.fr.j2 +++ b/ihatemoney/templates/invitation_mail.fr.j2 @@ -4,7 +4,7 @@ Quelqu'un dont l'adresse email est {{ g.project.contact_email }} vous a invité Il suffit de renseigner qui a payé pour quoi, pour qui, combien ça a coûté, et on s’occupe du reste. -Vous pouvez vous connecter grâce à ce lien : {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}. +Vous pouvez vous connecter grâce à ce lien : {{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }}. Une fois connecté, vous pourrez utiliser le lien suivant qui est plus facile à mémoriser : {{ url_for(".list_bills", _external=True) }} Si vous êtes déconnecté volontairement ou non, vous devrez utiliser à nouveau le premier lien. diff --git a/ihatemoney/templates/send_invites.html b/ihatemoney/templates/send_invites.html index 8b73b175..d5112926 100644 --- a/ihatemoney/templates/send_invites.html +++ b/ihatemoney/templates/send_invites.html @@ -21,8 +21,8 @@ {{ _("You can directly share the following link via your prefered medium") }}
- - {{ url_for(".authenticate", _external=True, project_id=g.project.id, token=g.project.generate_token()) }} + + {{ url_for(".join_project", _external=True, project_id=g.project.id, token=g.project.generate_token()) }} diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py index 83d5aa2a..9c496873 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -213,9 +213,7 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette/token", headers=self.get_auth("raclette") ) decoded_resp = json.loads(resp.data.decode("utf-8")) - resp = self.client.get( - f"/authenticate?token={decoded_resp['token']}&project_id=raclette" - ) + resp = self.client.get(f"/raclette/join/{decoded_resp['token']}") # Test that we are redirected. self.assertEqual(302, resp.status_code) diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py index 1539ece7..159f016a 100644 --- a/ihatemoney/tests/budget_test.py +++ b/ihatemoney/tests/budget_test.py @@ -4,7 +4,7 @@ import json import re from time import sleep import unittest -from urllib.parse import parse_qs, urlencode, urlparse, urlunparse +from urllib.parse import urlparse, urlunparse from flask import session import pytest @@ -91,17 +91,19 @@ class BudgetTestCase(IhatemoneyTestCase): self.client.get("/exit") # Use another project_id parsed_url = urlparse(url) - query = parse_qs(parsed_url.query) - query["project_id"] = "invalid" resp = self.client.get( - urlunparse(parsed_url._replace(query=urlencode(query, doseq=True))) + urlunparse( + parsed_url._replace( + path=parsed_url.path.replace("raclette/", "invalid_project/") + ) + ), + follow_redirects=True, ) - assert "You either provided a bad token" in resp.data.decode("utf-8") + assert "Create a new project" in resp.data.decode("utf-8") - resp = self.client.get("/authenticate") - self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) - resp = self.client.get("/authenticate?token=token") - self.assertIn("You either provided a bad token", resp.data.decode("utf-8")) + # A token MUST have a point between payload and signature + resp = self.client.get("/raclette/join/token.invalid", follow_redirects=True) + self.assertIn("Provided token is invalid", resp.data.decode("utf-8")) def test_invite_code_invalidation(self): """Test that invitation link expire after code change""" @@ -133,7 +135,7 @@ class BudgetTestCase(IhatemoneyTestCase): self.client.get("/exit") response = self.client.get(link, follow_redirects=True) # Link is invalid - self.assertIn("You either provided a bad token", response.data.decode("utf-8")) + self.assertIn("Provided token is invalid", response.data.decode("utf-8")) def test_password_reminder(self): # test that it is possible to have an email containing the password of a diff --git a/ihatemoney/web.py b/ihatemoney/web.py index 19f88976..f783cd86 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -143,7 +143,8 @@ def pull_project(endpoint, values): raise Redirect303(url_for(".create_project", project_id=project_id)) is_admin = session.get("is_admin") - if session.get(project.id) or is_admin: + is_invitation = endpoint == "main.join_project" + if session.get(project.id) or is_admin or is_invitation: # add project into kwargs and call the original function g.project = project else: @@ -195,6 +196,28 @@ def admin(): ) +@main.route("//join/", methods=["GET"]) +def join_project(token): + project_id = g.project.id + verified_project_id = Project.verify_token( + token, token_type="auth", project_id=project_id + ) + if verified_project_id != project_id: + flash(_("Provided token is invalid"), "danger") + return redirect("/") + + # maintain a list of visited projects + if "projects" not in session: + session["projects"] = [] + # add the project on the top of the list + session["projects"].insert(0, (project_id, g.project.name)) + session[project_id] = True + # Set session to permanent to make language choice persist + session.permanent = True + session.update() + return redirect(url_for(".list_bills")) + + @main.route("/authenticate", methods=["GET", "POST"]) def authenticate(project_id=None): """Authentication form""" @@ -203,26 +226,8 @@ def authenticate(project_id=None): if not form.id.data and request.args.get("project_id"): form.id.data = request.args["project_id"] project_id = form.id.data - # Try to get project_id from token first - token = request.args.get("token") - if token: - verified_project_id = Project.verify_token( - token, token_type="auth", project_id=project_id - ) - if verified_project_id == project_id: - token_auth = True - else: - project_id = None - else: - token_auth = False - if project_id is None: - # User doesn't provide project identifier or a valid token - # return to authenticate form - msg = _("You either provided a bad token or no project identifier.") - form["id"].errors = [msg] - return render_template("authenticate.html", form=form) - project = Project.query.get(project_id) + project = Project.query.get(project_id) if project_id is not None else None if not project: # If the user try to connect to an unexisting project, we will # propose him a link to the creation form. @@ -235,13 +240,9 @@ def authenticate(project_id=None): setattr(g, "project", project) return redirect(url_for(".list_bills")) - # else do form authentication or token authentication + # else do form authentication authentication is_post_auth = request.method == "POST" and form.validate() - if ( - is_post_auth - and check_password_hash(project.password, form.password.data) - or token_auth - ): + if is_post_auth and check_password_hash(project.password, form.password.data): # maintain a list of visited projects if "projects" not in session: session["projects"] = [] From 421cfbe64555c9c09dae97c93eacdde75585b6a8 Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 10 Oct 2021 23:31:16 +0200 Subject: [PATCH 25/33] project: set XXX as default default_currency for API backwards compatibility The new support for currency broke backwards compatibility on the API: $ curl -X POST $URL/api/projects -d 'name=yay&id=yay&password=yay&contact_email=yay@notmyidea.org' {"default_currency": ["This field is required."]} This is a case we were not testing (will be fixed in subsequent commits) To fix this, simply set default_currency to XXX by default. --- ihatemoney/forms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 3bbe34a5..7839a5d9 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -113,7 +113,11 @@ class EditProjectForm(FlaskForm): project_history = BooleanField(_("Enable project history")) ip_recording = BooleanField(_("Use IP tracking for project history")) currency_helper = CurrencyConverter() - default_currency = SelectField(_("Default Currency"), validators=[DataRequired()]) + default_currency = SelectField( + _("Default Currency"), + validators=[DataRequired()], + default=CurrencyConverter.no_currency, + ) def __init__(self, *args, **kwargs): if not hasattr(self, "id"): From 17b4255d824f427b450f3294efeb525eed08b1bb Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 10 Oct 2021 23:36:42 +0200 Subject: [PATCH 26/33] API tests: allow to set currency, but don't set any by default --- ihatemoney/tests/api_test.py | 43 ++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py index 9c496873..7da55425 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -11,20 +11,31 @@ class APITestCase(IhatemoneyTestCase): """Tests the API""" - def api_create(self, name, id=None, password=None, contact=None): + def api_create( + self, name, id=None, password=None, contact=None, default_currency=None + ): id = id or name password = password or name contact = contact or f"{name}@notmyidea.org" - return self.client.post( - "/api/projects", - data={ + if default_currency: + data = { "name": name, "id": id, "password": password, "contact_email": contact, - "default_currency": "USD", - }, + "default_currency": default_currency, + } + else: + data = { + "name": name, + "id": id, + "password": password, + "contact_email": contact, + } + return self.client.post( + "/api/projects", + data=data, ) def api_add_member(self, project, name, weight=1): @@ -85,7 +96,7 @@ class APITestCase(IhatemoneyTestCase): "id": "raclette", "password": "raclette", "contact_email": "not-an-email", - "default_currency": "USD", + "default_currency": "XXX", }, ) @@ -114,7 +125,7 @@ class APITestCase(IhatemoneyTestCase): "members": [], "name": "raclette", "contact_email": "raclette@notmyidea.org", - "default_currency": "USD", + "default_currency": "XXX", "id": "raclette", "logging_preference": 1, } @@ -126,7 +137,7 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette", data={ "contact_email": "yeah@notmyidea.org", - "default_currency": "USD", + "default_currency": "XXX", "password": "raclette", "name": "The raclette party", "project_history": "y", @@ -144,7 +155,7 @@ class APITestCase(IhatemoneyTestCase): expected = { "name": "The raclette party", "contact_email": "yeah@notmyidea.org", - "default_currency": "USD", + "default_currency": "XXX", "members": [], "id": "raclette", "logging_preference": 1, @@ -157,7 +168,7 @@ class APITestCase(IhatemoneyTestCase): "/api/projects/raclette", data={ "contact_email": "yeah@notmyidea.org", - "default_currency": "USD", + "default_currency": "XXX", "password": "tartiflette", "name": "The raclette party", }, @@ -380,7 +391,7 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-08-10", "id": 1, "converted_amount": 25.0, - "original_currency": "USD", + "original_currency": "XXX", "external_link": "https://raclette.fr", } @@ -451,7 +462,7 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-09-10", "external_link": "https://raclette.fr", "converted_amount": 25.0, - "original_currency": "USD", + "original_currency": "XXX", "id": 1, } @@ -529,7 +540,7 @@ class APITestCase(IhatemoneyTestCase): "date": "2011-08-10", "id": id, "external_link": "", - "original_currency": "USD", + "original_currency": "XXX", "converted_amount": expected_amount, } @@ -674,7 +685,7 @@ class APITestCase(IhatemoneyTestCase): "id": 1, "external_link": "", "converted_amount": 25.0, - "original_currency": "USD", + "original_currency": "XXX", } got = json.loads(req.data.decode("utf-8")) self.assertEqual( @@ -717,7 +728,7 @@ class APITestCase(IhatemoneyTestCase): "id": "raclette", "name": "raclette", "logging_preference": 1, - "default_currency": "USD", + "default_currency": "XXX", } self.assertStatus(200, req) From ff2a5cc147c681cbadf4d349e1c027ab3cfe959e Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Wed, 13 Oct 2021 23:45:51 +0200 Subject: [PATCH 27/33] Add API test cases related to currencies --- ihatemoney/tests/api_test.py | 151 ++++++++++++++++++ .../tests/common/ihatemoney_testcase.py | 8 +- ihatemoney/tests/main_test.py | 10 +- 3 files changed, 166 insertions(+), 3 deletions(-) diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py index 7da55425..6d241bd1 100644 --- a/ihatemoney/tests/api_test.py +++ b/ihatemoney/tests/api_test.py @@ -575,6 +575,157 @@ class APITestCase(IhatemoneyTestCase): ) self.assertStatus(400, req) + def test_currencies(self): + # create project with a default currency + resp = self.api_create("raclette", default_currency="EUR") + self.assertTrue(201, resp.status_code) + + # get information about it + resp = self.client.get( + "/api/projects/raclette", headers=self.get_auth("raclette") + ) + + self.assertTrue(200, resp.status_code) + expected = { + "members": [], + "name": "raclette", + "contact_email": "raclette@notmyidea.org", + "default_currency": "EUR", + "id": "raclette", + "logging_preference": 1, + } + decoded_resp = json.loads(resp.data.decode("utf-8")) + self.assertDictEqual(decoded_resp, expected) + + # Add members + self.api_add_member("raclette", "zorglub") + self.api_add_member("raclette", "fred") + self.api_add_member("raclette", "quentin") + + # Add a bill without explicit currency + req = self.client.post( + "/api/projects/raclette/bills", + data={ + "date": "2011-08-10", + "what": "fromage", + "payer": "1", + "payed_for": ["1", "2"], + "amount": "25", + "external_link": "https://raclette.fr", + }, + headers=self.get_auth("raclette"), + ) + + # should return the id + self.assertStatus(201, req) + self.assertEqual(req.data.decode("utf-8"), "1\n") + + # get this bill details + req = self.client.get( + "/api/projects/raclette/bills/1", headers=self.get_auth("raclette") + ) + + # compare with the added info + self.assertStatus(200, req) + expected = { + "what": "fromage", + "payer_id": 1, + "owers": [ + {"activated": True, "id": 1, "name": "zorglub", "weight": 1}, + {"activated": True, "id": 2, "name": "fred", "weight": 1}, + ], + "amount": 25.0, + "date": "2011-08-10", + "id": 1, + "converted_amount": 25.0, + "original_currency": "EUR", + "external_link": "https://raclette.fr", + } + + got = json.loads(req.data.decode("utf-8")) + self.assertEqual( + datetime.date.today(), + datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(), + ) + del got["creation_date"] + self.assertDictEqual(expected, got) + + # Change bill amount and currency + req = self.client.put( + "/api/projects/raclette/bills/1", + data={ + "date": "2011-08-10", + "what": "fromage", + "payer": "1", + "payed_for": ["1", "2"], + "amount": "30", + "external_link": "https://raclette.fr", + "original_currency": "CAD", + }, + headers=self.get_auth("raclette"), + ) + self.assertStatus(200, req) + + # Check result + req = self.client.get( + "/api/projects/raclette/bills/1", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + expected_amount = self.converter.exchange_currency(30.0, "CAD", "EUR") + expected = { + "what": "fromage", + "payer_id": 1, + "owers": [ + {"activated": True, "id": 1, "name": "zorglub", "weight": 1.0}, + {"activated": True, "id": 2, "name": "fred", "weight": 1.0}, + ], + "amount": 30.0, + "date": "2011-08-10", + "id": 1, + "converted_amount": expected_amount, + "original_currency": "CAD", + "external_link": "https://raclette.fr", + } + + got = json.loads(req.data.decode("utf-8")) + del got["creation_date"] + self.assertDictEqual(expected, got) + + # Add a bill with yet another currency + req = self.client.post( + "/api/projects/raclette/bills", + data={ + "date": "2011-09-10", + "what": "Pierogi", + "payer": "1", + "payed_for": ["2", "3"], + "amount": "80", + "original_currency": "PLN", + }, + headers=self.get_auth("raclette"), + ) + + # should return the id + self.assertStatus(201, req) + self.assertEqual(req.data.decode("utf-8"), "2\n") + + # Try to remove default project currency, it should fail + req = self.client.put( + "/api/projects/raclette", + data={ + "contact_email": "yeah@notmyidea.org", + "default_currency": "XXX", + "password": "raclette", + "name": "The raclette party", + }, + headers=self.get_auth("raclette"), + ) + self.assertStatus(400, req) + self.assertIn("This project cannot be set", req.data.decode("utf-8")) + self.assertIn( + "because it contains bills in multiple currencies", req.data.decode("utf-8") + ) + def test_statistics(self): # create a project self.api_create("raclette") diff --git a/ihatemoney/tests/common/ihatemoney_testcase.py b/ihatemoney/tests/common/ihatemoney_testcase.py index 2c34e1e1..a0068660 100644 --- a/ihatemoney/tests/common/ihatemoney_testcase.py +++ b/ihatemoney/tests/common/ihatemoney_testcase.py @@ -24,7 +24,13 @@ class BaseTestCase(TestCase): def setUp(self): db.create_all() # Add dummy data to CurrencyConverter for all tests (since it's a singleton) - mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} + mock_data = { + "USD": 1, + "EUR": 0.8, + "CAD": 1.2, + "PLN": 4, + CurrencyConverter.no_currency: 1, + } converter = CurrencyConverter() converter.get_rates = MagicMock(return_value=mock_data) # Also add it to an attribute to make tests clearer diff --git a/ihatemoney/tests/main_test.py b/ihatemoney/tests/main_test.py index 604aedd2..69392267 100644 --- a/ihatemoney/tests/main_test.py +++ b/ihatemoney/tests/main_test.py @@ -288,7 +288,13 @@ class CaptchaTestCase(IhatemoneyTestCase): class TestCurrencyConverter(unittest.TestCase): converter = CurrencyConverter() - mock_data = {"USD": 1, "EUR": 0.8, "CAD": 1.2, CurrencyConverter.no_currency: 1} + mock_data = { + "USD": 1, + "EUR": 0.8, + "CAD": 1.2, + "PLN": 4, + CurrencyConverter.no_currency: 1, + } converter.get_rates = MagicMock(return_value=mock_data) def test_only_one_instance(self): @@ -299,7 +305,7 @@ class TestCurrencyConverter(unittest.TestCase): def test_get_currencies(self): self.assertCountEqual( self.converter.get_currencies(), - ["USD", "EUR", "CAD", CurrencyConverter.no_currency], + ["USD", "EUR", "CAD", "PLN", CurrencyConverter.no_currency], ) def test_exchange_currency(self): From dc793effce915d256ef5aeec87312b5eaf9c3e79 Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Wed, 13 Oct 2021 22:54:50 +0200 Subject: [PATCH 28/33] Update API doc --- docs/api.rst | 92 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 25 deletions(-) diff --git a/docs/api.rst b/docs/api.rst index 93ca36e9..982f828f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4,7 +4,9 @@ The REST API All of what's possible to do with the website is also possible via a web API. This document explains how the API is organized and how you can query it. -The only supported data format is JSON. +The main supported data format is JSON. When using POST or PUT, you can +either pass data encoded in JSON or in ``application/x-www-form-urlencoded`` +format. Overall organisation ==================== @@ -31,7 +33,7 @@ instead of basic auth. For instance, start by generating the token (of course, you need to authenticate):: $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/token - {"token": "eyJwcm9qZWN0X2lkIjoiZGVtbyJ9.M86C3AiZa_SFEyiddYXdTh2-OOI"} + {"token": "WyJ0ZXN0Il0.Rt04fNMmxp9YslCRq8hB6jE9s1Q"} Make sure to store this token securely: it allows full access to the project. For instance, use it to obtain information about the project (replace PROJECT_TOKEN with @@ -47,7 +49,7 @@ looks like:: This token can also be used to authenticate for a project on the web interface, which can be useful to generate invitation links. You would simply create an URL of the form:: - https://ihatemoney.org/authenticate?token=PROJECT_TOKEN + https://ihatemoney.org/demo/join/PROJECT_TOKEN Such a link grants full access to the project associated with the token. @@ -64,10 +66,16 @@ Creating a project A project needs the following arguments: -* ``name``: The project name (string) +* ``name``: the project name (string) * ``id``: the project identifier (string without special chars or spaces) * ``password``: the project password / secret code (string) -* ``contact_email``: the contact email +* ``contact_email``: the contact email (string) + +Optional arguments: + +* ``default_currency``: the default currency to use for a multi-currency project, + in ISO 4217 format. Bills are converted to this currency for operations like balance + or statistics. Default value: ``XXX`` (no currency). :: @@ -75,7 +83,7 @@ A project needs the following arguments: -d 'name=yay&id=yay&password=yay&contact_email=yay@notmyidea.org' "yay" -As you can see, the API returns the identifier of the project +As you can see, the API returns the identifier of the project. Getting information about the project ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -88,6 +96,7 @@ Getting information about the project:: "id": "demo", "name": "demonstration", "contact_email": "demo@notmyidea.org", + "default_currency": "XXX", "members": [{"id": 11515, "name": "f", "weight": 1.0, "activated": true, "balance": 0}, {"id": 11531, "name": "g", "weight": 1.0, "activated": true, "balance": 0}, {"id": 11532, "name": "peter", "weight": 1.0, "activated": true, "balance": 5.0}, @@ -151,22 +160,55 @@ You can get the list of bills by doing a ``GET`` on $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/bills -Add a bill with a ``POST`` query on ``/api/projects//bills``. you need the -following params: +Or get a specific bill by ID:: -* ``date``: the date of the bill; defaults to current date if not - provided. (format is ``yyyy-mm-dd``) -* ``what``: what have been payed -* ``payer``: by who ? (id) -* ``payed_for``: for who ? (id, to set multiple id use a list, - e.g. ``["id1", "id2"]``) -* ``amount``: amount payed + $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/bills/42 + { + "id": 42, + "payer_id": 11, + "owers": [ + { + "id": 22, + "name": "Alexis", + "weight": 1, + "activated": true + } + ], + "amount": 100, + "date": "2020-12-24", + "creation_date": "2021-01-13", + "what": "Raclette du nouvel an", + "external_link": "", + "original_currency": "XXX", + "converted_amount": 100 + } + +``amount`` is expressed in the ``original_currency`` of the bill, while +``converted_amount`` is expressed in the project ``default_currency``. +Here, they are the same. + +Add a bill with a ``POST`` query on ``/api/projects//bills``. You need the +following required parameters: + +* ``what``: what has been paid (string) +* ``payer``: paid by who? (id) +* ``payed_for``: for who ? (id). To set multiple id, simply pass + the parameter multiple times (x-www-form-urlencoded) or pass a list of id (JSON). +* ``amount``: amount payed (float) + +And optional parameters: + +* ``date``: the date of the bill (``yyyy-mm-dd`` format). Defaults to current date + if not provided. +* ``original_currency``: the currency in which ``amount`` has been paid (ISO 4217 code). + Only makes sense for a project with currencies. Defaults to the project ``default_currency``. +* ``external_link``: an optional URL associated with the bill. Returns the id of the created bill :: $ curl --basic -u demo:demo -X POST\ https://ihatemoney.org/api/projects/demo/bills\ - -d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=200" + -d "date=2011-09-10&what=raclette&payer=1&payed_for=3&payed_for=5&amount=200" 80 You can also ``PUT`` a new version of the bill at @@ -174,7 +216,7 @@ You can also ``PUT`` a new version of the bill at $ curl --basic -u demo:demo -X PUT\ https://ihatemoney.org/api/projects/demo/bills/80\ - -d "date=2011-09-10&what=raclette&payer=31&payed_for=31&amount=250" + -d "date=2011-09-10&what=raclette&payer=1&payed_for=3&payed_for=5&payed_for=1&amount=250" 80 And you can of course ``DELETE`` them at @@ -194,15 +236,15 @@ You can get some project stats with a ``GET`` on $ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/statistics [ { - "balance": 12.5, - "member": {"activated": True, "id": 1, "name": "alexis", "weight": 1.0}, - "paid": 25.0, - "spent": 12.5 + "member": {"activated": true, "id": 1, "name": "alexis", "weight": 1.0}, + "paid": 25.5, + "spent": 15, + "balance": 10.5 }, { - "balance": -12.5, - "member": {"activated": True, "id": 2, "name": "fred", "weight": 1.0}, - "paid": 0, - "spent": 12.5 + "member": {"activated": true, "id": 2, "name": "fred", "weight": 1.0}, + "paid": 5, + "spent": 15.5, + "balance": -10.5 } ] From 19b5b136639253a035fc28c3284fca316c03dc19 Mon Sep 17 00:00:00 2001 From: Baptiste Jonglez Date: Sun, 10 Oct 2021 23:34:38 +0200 Subject: [PATCH 29/33] demo: create Demo project without currency This matches the default settings of both the web interface and the API regarding currencies. --- ihatemoney/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ihatemoney/models.py b/ihatemoney/models.py index b3d1cba2..e0ca0704 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -408,7 +408,7 @@ class Project(db.Model): name="demonstration", password=generate_password_hash("demo"), contact_email="demo@notmyidea.org", - default_currency="EUR", + default_currency="XXX", ) db.session.add(project) db.session.commit() @@ -436,7 +436,7 @@ class Project(db.Model): bill.what = subject bill.owers = [members[name] for name in owers] bill.amount = amount - bill.original_currency = "EUR" + bill.original_currency = "XXX" bill.converted_amount = amount db.session.add(bill) From e2286676f1f4dc37c98afe5bcee1777949e48c08 Mon Sep 17 00:00:00 2001 From: Glandos Date: Wed, 13 Oct 2021 22:27:49 +0200 Subject: [PATCH 30/33] Flash messages must be dimissed manually --- ihatemoney/static/css/main.css | 1 - ihatemoney/templates/layout.html | 17 ++++++----------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index 13af3675..d71c9bd3 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -469,7 +469,6 @@ tr.payer_line .balance-name { position: absolute; top: 4.5rem; width: 100%; - pointer-events: none; } .light { diff --git a/ihatemoney/templates/layout.html b/ihatemoney/templates/layout.html index bd5a412a..137c2a59 100644 --- a/ihatemoney/templates/layout.html +++ b/ihatemoney/templates/layout.html @@ -21,12 +21,6 @@ {% block head %}{% endblock %}