diff --git a/.travis.yml b/.travis.yml index ca72fb95..cc4db2b7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,9 @@ python: - "3.6" - "3.7" - "3.8" + - "3.9" script: tox +before_install: + - python -m pip install --upgrade pip virtualenv install: - pip install tox-travis diff --git a/Dockerfile b/Dockerfile index 5645a62d..bd34055b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,9 @@ ENV NIGHTLY="" \ ACTIVATE_DEMO_PROJECT="True" \ ADMIN_PASSWORD="" \ ALLOW_PUBLIC_PROJECT_CREATION="True" \ - ACTIVATE_ADMIN_DASHBOARD="False" + ACTIVATE_ADMIN_DASHBOARD="False" \ + BABEL_DEFAULT_TIMEZONE="UTC" \ + GREENLET_TEST_CPP="no" RUN apk update && apk add git gcc libc-dev libffi-dev openssl-dev wget &&\ mkdir -p /etc/ihatemoney &&\ diff --git a/Makefile b/Makefile index 63002664..30de0dd7 100644 --- a/Makefile +++ b/Makefile @@ -46,7 +46,7 @@ test: install-dev ## Run the tests .PHONY: black black: install-dev ## Run the tests - $(VENV)/bin/black --target-version=py34 . + $(VENV)/bin/black --target-version=py36 . .PHONY: isort isort: install-dev ## Run the tests diff --git a/README.rst b/README.rst index 84d73b4a..cab19292 100644 --- a/README.rst +++ b/README.rst @@ -9,6 +9,10 @@ I hate money :target: https://hosted.weblate.org/engage/i-hate-money/?utm_source=widget :alt: Translation status from Weblate +.. image:: https://img.shields.io/liberapay/receives/IHateMoney.svg?logo=liberapay + :target: https://liberapay.com/IHateMoney/donate + :alt: Donate + *I hate money* is a web application made to ease shared budget management. It keeps track of who bought what, when, and for whom; and helps to settle the bills. @@ -25,7 +29,7 @@ encouraged to do so. Requirements ============ -* **Python**: 3.6, 3.7, 3.8. +* **Python**: version 3.6 to 3.9. * **Backends**: MySQL, PostgreSQL, SQLite, Memory. Contributing @@ -35,6 +39,8 @@ Do you wish to contribute to IHateMoney? Fantastic! There's a lot of very useful help on the official `contributing `_ page. +You can also `donate some money `_. All funds will be used to maintain the `hosted version `_. + Translation status ================== diff --git a/conf/entrypoint.sh b/conf/entrypoint.sh index 0d5a7a39..c1b7019f 100755 --- a/conf/entrypoint.sh +++ b/conf/entrypoint.sh @@ -20,6 +20,7 @@ ACTIVATE_DEMO_PROJECT = $ACTIVATE_DEMO_PROJECT ADMIN_PASSWORD = '$ADMIN_PASSWORD' ALLOW_PUBLIC_PROJECT_CREATION = $ALLOW_PUBLIC_PROJECT_CREATION ACTIVATE_ADMIN_DASHBOARD = $ACTIVATE_ADMIN_DASHBOARD +BABEL_DEFAULT_TIMEZONE = "$BABEL_DEFAULT_TIMEZONE" EOF if [ "$NIGHTLY" == "True" -o "$NIGHTLY" == "true" ]; then diff --git a/docs/configuration.rst b/docs/configuration.rst index 5b787075..7e29da1c 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -31,6 +31,7 @@ connection string. This will look like:: SQLALCHEMY_DATABASE_URI = 'postgresql://myuser:mypass@localhost/dbname?client_encoding=utf8' +.. _the SQLAlchemy documentation: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls `SECRET_KEY` ------------ @@ -96,7 +97,26 @@ if set to ``"somestring"``, it will be served from a "folder" - **Default value:** ``""`` (empty string) -.. _the SQLAlchemy documentation: http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls +`BABEL_DEFAULT_TIMEZONE` +------------------------ + +The timezone that will be used to convert date and time when displaying them +to the user (all times are always stored in UTC internally). +If not set, it will default to the timezone configured on the Operating System +of the server running ihatemoney, which may or may not be what you want. + +- **Default value:** *unset* (use the timezone of the server Operating System) +- **Production value:** Set to the timezone of your expected users, with a + format such as ``"Europe/Paris"``. See `this list of TZ database names`_ + for a complete list. + +Note: this setting is actually interpreted by Flask-Babel, see the +`Flask-Babel guide for formatting dates`_ for details. + +.. _this list of TZ database name: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List + +.. _Flask-Babel guide for formatting dates: https://pythonhosted.org/Flask-Babel/#formatting-dates + Configuring emails sending -------------------------- diff --git a/docs/installation.rst b/docs/installation.rst index b33fb55d..82ac12f2 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -19,7 +19,7 @@ Requirements «Ihatemoney» depends on: -* **Python**: either 3.6, 3.7 or 3.8 will work. +* **Python**: version 3.6 to 3.9 included will work. * **A Backend**: to choose among MySQL, PostgreSQL, SQLite or Memory. * **Virtual environment** (recommended): `python3-venv` package under Debian/Ubuntu. diff --git a/docs/requirements.txt b/docs/requirements.txt index faa22bff..b207c098 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -Sphinx==3.3.0 -docutils==0.16 +Sphinx==3.5.3 +docutils==0.17 diff --git a/ihatemoney/conf-templates/ihatemoney.cfg.j2 b/ihatemoney/conf-templates/ihatemoney.cfg.j2 index c0545912..0188c6b1 100644 --- a/ihatemoney/conf-templates/ihatemoney.cfg.j2 +++ b/ihatemoney/conf-templates/ihatemoney.cfg.j2 @@ -34,3 +34,7 @@ ALLOW_PUBLIC_PROJECT_CREATION = True # If set to True, an administration dashboard is available. 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" diff --git a/ihatemoney/currency_convertor.py b/ihatemoney/currency_convertor.py index 10026eea..881d5428 100644 --- a/ihatemoney/currency_convertor.py +++ b/ihatemoney/currency_convertor.py @@ -14,7 +14,7 @@ class Singleton(type): class CurrencyConverter(object, metaclass=Singleton): # Get exchange rates no_currency = "XXX" - api_url = "https://api.exchangeratesapi.io/latest?base=USD" + api_url = "https://api.exchangerate.host/latest?base=USD" def __init__(self): pass diff --git a/ihatemoney/default_settings.py b/ihatemoney/default_settings.py index a3cf4077..d16cf17d 100644 --- a/ihatemoney/default_settings.py +++ b/ihatemoney/default_settings.py @@ -18,10 +18,12 @@ SUPPORTED_LANGUAGES = [ "nb_NO", "nl", "pl", + "pt", "pt_BR", "ru", "ta", "tr", "uk", "zh_Hans", + "ja", ] diff --git a/ihatemoney/history.py b/ihatemoney/history.py index 994f00bc..4c2ab081 100644 --- a/ihatemoney/history.py +++ b/ihatemoney/history.py @@ -80,7 +80,7 @@ def get_history(project, human_readable_names=True): object_str = describe_version(version) common_properties = { - "time": version.transaction.issued_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + "time": version.transaction.issued_at, "operation_type": version.operation_type, "object_type": object_type, "object_desc": object_str, diff --git a/ihatemoney/migrations/env.py b/ihatemoney/migrations/env.py index 0bd0031e..1a443181 100755 --- a/ihatemoney/migrations/env.py +++ b/ihatemoney/migrations/env.py @@ -77,7 +77,7 @@ def run_migrations_online(): target_metadata=target_metadata, include_object=include_object, process_revision_directives=process_revision_directives, - **current_app.extensions["migrate"].configure_args + **current_app.extensions["migrate"].configure_args, ) try: diff --git a/ihatemoney/patch_sqlalchemy_continuum.py b/ihatemoney/patch_sqlalchemy_continuum.py index dbbd9083..eecfe6fa 100644 --- a/ihatemoney/patch_sqlalchemy_continuum.py +++ b/ihatemoney/patch_sqlalchemy_continuum.py @@ -96,7 +96,7 @@ class PatchedRelationShipBuilder(RelationshipBuilder): association_col == self.association_version_table.c[association_col.name] for association_col in association_cols - ] + ], ) ) .group_by(*association_cols) diff --git a/ihatemoney/run.py b/ihatemoney/run.py index c8052707..89020873 100644 --- a/ihatemoney/run.py +++ b/ihatemoney/run.py @@ -2,6 +2,7 @@ import os import os.path import warnings +from babel.dates import LOCALTZ from flask import Flask, g, render_template, request, session from flask_babel import Babel, format_currency from flask_mail import Mail @@ -152,8 +153,10 @@ def create_app( app.jinja_env.filters["minimal_round"] = minimal_round app.jinja_env.filters["localize_list"] = localize_list - # Translations - babel = Babel(app) + # Translations and time zone (used to display dates). The timezone is + # taken from the BABEL_DEFAULT_TIMEZONE settings, and falls back to + # the local timezone of the server OS by using LOCALTZ. + babel = Babel(app, default_timezone=str(LOCALTZ)) # Undocumented currencyformat filter from flask_babel is forwarding to Babel format_currency # We overwrite it to remove the currency sign ¤ when there is no currency @@ -169,7 +172,7 @@ def create_app( number, currency if currency != CurrencyConverter.no_currency else "", *args, - **kwargs + **kwargs, ).strip() app.jinja_env.filters["currency"] = currency diff --git a/ihatemoney/static/css/download_mobile_app.css b/ihatemoney/static/css/download_mobile_app.css new file mode 100644 index 00000000..8fe87bb9 --- /dev/null +++ b/ihatemoney/static/css/download_mobile_app.css @@ -0,0 +1,13 @@ +.get-it-from { + width: 100px; + min-height: 110px; + border: 5px solid floralwhite; + background-color: white; margin: 10px; +} +.get-it-from:hover { + opacity: 80%; +} +main { + background: linear-gradient(150deg, #abe128 0%, #43ca61 100%); + font-family: 'Comfortaa', arial, serif; +} diff --git a/ihatemoney/static/images/app-store.svg b/ihatemoney/static/images/app-store.svg new file mode 100644 index 00000000..ac111e59 --- /dev/null +++ b/ihatemoney/static/images/app-store.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ihatemoney/static/images/f-droid.svg b/ihatemoney/static/images/f-droid.svg new file mode 100644 index 00000000..bac1d085 --- /dev/null +++ b/ihatemoney/static/images/f-droid.svg @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ihatemoney/static/images/google-play.png b/ihatemoney/static/images/google-play.png new file mode 100644 index 00000000..a93fdeab Binary files /dev/null and b/ihatemoney/static/images/google-play.png differ diff --git a/ihatemoney/templates/download_mobile_app.html b/ihatemoney/templates/download_mobile_app.html new file mode 100644 index 00000000..b343c71f --- /dev/null +++ b/ihatemoney/templates/download_mobile_app.html @@ -0,0 +1,26 @@ +{% extends "layout.html" %} + +{% block css %} + +{% endblock %} + +{% block body %} +
+
+

{{_("Download Mobile Application")}}

+
+

{{_("Get it on")}}

+ +
+{% endblock %} diff --git a/ihatemoney/templates/history.html b/ihatemoney/templates/history.html index 1b25b736..199b2c17 100644 --- a/ihatemoney/templates/history.html +++ b/ihatemoney/templates/history.html @@ -162,7 +162,7 @@ {% for event in history %} - + {{ event.time|datetimeformat("medium") }}
+ {% block css %}{% endblock %} @@ -144,14 +145,14 @@ {{ static_include("images/git.svg") | safe }} - + {{ static_include("images/mobile-alt.svg") | safe }} {{ static_include("images/book.svg") | safe }} {% if g.show_admin_dashboard_link %} - + {{ static_include("images/cog.svg") | safe }} {% endif %} diff --git a/ihatemoney/templates/list_bills.html b/ihatemoney/templates/list_bills.html index 12c50727..849fc15b 100644 --- a/ihatemoney/templates/list_bills.html +++ b/ihatemoney/templates/list_bills.html @@ -110,7 +110,7 @@ + title="{{ _('Added on %(date)s', date=bill.creation_date|dateformat("long") if bill.creation_date else bill.date|dateformat("long")) }}"> {{ bill.date }} diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py new file mode 100644 index 00000000..41f5ab2d --- /dev/null +++ b/ihatemoney/tests/api_test.py @@ -0,0 +1,748 @@ +import base64 +import datetime +import json +import unittest + +from ihatemoney.tests.common.help_functions import em_surround +from ihatemoney.tests.common.ihatemoney_testcase import IhatemoneyTestCase + + +class APITestCase(IhatemoneyTestCase): + + """Tests the API""" + + def api_create(self, name, id=None, password=None, contact=None): + id = id or name + password = password or name + contact = contact or f"{name}@notmyidea.org" + + return self.client.post( + "/api/projects", + data={ + "name": name, + "id": id, + "password": password, + "contact_email": contact, + "default_currency": "USD", + }, + ) + + def api_add_member(self, project, name, weight=1): + self.client.post( + f"/api/projects/{project}/members", + data={"name": name, "weight": weight}, + headers=self.get_auth(project), + ) + + def get_auth(self, username, password=None): + password = password or username + base64string = ( + base64.encodebytes(f"{username}:{password}".encode("utf-8")) + .decode("utf-8") + .replace("\n", "") + ) + return {"Authorization": f"Basic {base64string}"} + + def test_cors_requests(self): + # Create a project and test that CORS headers are present if requested. + resp = self.api_create("raclette") + self.assertStatus(201, resp) + + # Try to do an OPTIONS requests and see if the headers are correct. + resp = self.client.options( + "/api/projects/raclette", headers=self.get_auth("raclette") + ) + self.assertEqual(resp.headers["Access-Control-Allow-Origin"], "*") + + def test_basic_auth(self): + # create a project + resp = self.api_create("raclette") + self.assertStatus(201, resp) + + # try to do something on it being unauth should return a 401 + resp = self.client.get("/api/projects/raclette") + self.assertStatus(401, resp) + + # PUT / POST / DELETE / GET on the different resources + # should also return a 401 + for verb in ("post",): + for resource in ("/raclette/members", "/raclette/bills"): + url = "/api/projects" + resource + self.assertStatus(401, getattr(self.client, verb)(url), verb + resource) + + for verb in ("get", "delete", "put"): + for resource in ("/raclette", "/raclette/members/1", "/raclette/bills/1"): + url = "/api/projects" + resource + + self.assertStatus(401, getattr(self.client, verb)(url), verb + resource) + + def test_project(self): + # wrong email should return an error + resp = self.client.post( + "/api/projects", + data={ + "name": "raclette", + "id": "raclette", + "password": "raclette", + "contact_email": "not-an-email", + "default_currency": "USD", + }, + ) + + self.assertTrue(400, resp.status_code) + self.assertEqual( + '{"contact_email": ["Invalid email address."]}\n', resp.data.decode("utf-8") + ) + + # create it + resp = self.api_create("raclette") + self.assertTrue(201, resp.status_code) + + # create it twice should return a 400 + resp = self.api_create("raclette") + + self.assertTrue(400, resp.status_code) + self.assertIn("id", json.loads(resp.data.decode("utf-8"))) + + # 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": "USD", + "id": "raclette", + "logging_preference": 1, + } + decoded_resp = json.loads(resp.data.decode("utf-8")) + self.assertDictEqual(decoded_resp, expected) + + # edit should work + resp = self.client.put( + "/api/projects/raclette", + data={ + "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", + "password": "raclette", + "name": "The raclette party", + "project_history": "y", + }, + headers=self.get_auth("raclette"), + ) + + self.assertEqual(200, resp.status_code) + + resp = self.client.get( + "/api/projects/raclette", headers=self.get_auth("raclette") + ) + + self.assertEqual(200, resp.status_code) + expected = { + "name": "The raclette party", + "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", + "members": [], + "id": "raclette", + "logging_preference": 1, + } + decoded_resp = json.loads(resp.data.decode("utf-8")) + self.assertDictEqual(decoded_resp, expected) + + # password change is possible via API + resp = self.client.put( + "/api/projects/raclette", + data={ + "contact_email": "yeah@notmyidea.org", + "default_currency": "USD", + "password": "tartiflette", + "name": "The raclette party", + }, + headers=self.get_auth("raclette"), + ) + + self.assertEqual(200, resp.status_code) + + resp = self.client.get( + "/api/projects/raclette", headers=self.get_auth("raclette", "tartiflette") + ) + self.assertEqual(200, resp.status_code) + + # delete should work + resp = self.client.delete( + "/api/projects/raclette", headers=self.get_auth("raclette", "tartiflette") + ) + + # get should return a 401 on an unknown resource + resp = self.client.get( + "/api/projects/raclette", headers=self.get_auth("raclette") + ) + self.assertEqual(401, resp.status_code) + + def test_token_creation(self): + """Test that token of project is generated""" + + # Create project + resp = self.api_create("raclette") + self.assertTrue(201, resp.status_code) + + # Get token + resp = self.client.get( + "/api/projects/raclette/token", headers=self.get_auth("raclette") + ) + + self.assertEqual(200, resp.status_code) + + decoded_resp = json.loads(resp.data.decode("utf-8")) + + # Access with token + resp = self.client.get( + "/api/projects/raclette/token", + headers={"Authorization": f"Basic {decoded_resp['token']}"}, + ) + + self.assertEqual(200, resp.status_code) + + def test_token_login(self): + resp = self.api_create("raclette") + # Get token + resp = self.client.get( + "/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"])) + # Test that we are redirected. + self.assertEqual(302, resp.status_code) + + def test_member(self): + # create a project + self.api_create("raclette") + + # get the list of members (should be empty) + req = self.client.get( + "/api/projects/raclette/members", headers=self.get_auth("raclette") + ) + + self.assertStatus(200, req) + self.assertEqual("[]\n", req.data.decode("utf-8")) + + # add a member + req = self.client.post( + "/api/projects/raclette/members", + data={"name": "Zorglub"}, + headers=self.get_auth("raclette"), + ) + + # the id of the new member should be returned + self.assertStatus(201, req) + self.assertEqual("1\n", req.data.decode("utf-8")) + + # the list of members should contain one member + req = self.client.get( + "/api/projects/raclette/members", headers=self.get_auth("raclette") + ) + + self.assertStatus(200, req) + self.assertEqual(len(json.loads(req.data.decode("utf-8"))), 1) + + # Try to add another member with the same name. + req = self.client.post( + "/api/projects/raclette/members", + data={"name": "Zorglub"}, + headers=self.get_auth("raclette"), + ) + self.assertStatus(400, req) + + # edit the member + req = self.client.put( + "/api/projects/raclette/members/1", + data={"name": "Fred", "weight": 2}, + headers=self.get_auth("raclette"), + ) + + self.assertStatus(200, req) + + # get should return the new name + req = self.client.get( + "/api/projects/raclette/members/1", headers=self.get_auth("raclette") + ) + + self.assertStatus(200, req) + self.assertEqual("Fred", json.loads(req.data.decode("utf-8"))["name"]) + self.assertEqual(2, json.loads(req.data.decode("utf-8"))["weight"]) + + # edit this member with same information + # (test PUT idemopotence) + req = self.client.put( + "/api/projects/raclette/members/1", + data={"name": "Fred"}, + headers=self.get_auth("raclette"), + ) + + self.assertStatus(200, req) + + # de-activate the user + req = self.client.put( + "/api/projects/raclette/members/1", + data={"name": "Fred", "activated": False}, + headers=self.get_auth("raclette"), + ) + self.assertStatus(200, req) + + req = self.client.get( + "/api/projects/raclette/members/1", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + self.assertEqual(False, json.loads(req.data.decode("utf-8"))["activated"]) + + # re-activate the user + req = self.client.put( + "/api/projects/raclette/members/1", + data={"name": "Fred", "activated": True}, + headers=self.get_auth("raclette"), + ) + + req = self.client.get( + "/api/projects/raclette/members/1", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + self.assertEqual(True, json.loads(req.data.decode("utf-8"))["activated"]) + + # delete a member + + req = self.client.delete( + "/api/projects/raclette/members/1", headers=self.get_auth("raclette") + ) + + self.assertStatus(200, req) + + # the list of members should be empty + req = self.client.get( + "/api/projects/raclette/members", headers=self.get_auth("raclette") + ) + + self.assertStatus(200, req) + self.assertEqual("[]\n", req.data.decode("utf-8")) + + def test_bills(self): + # create a project + self.api_create("raclette") + + # add members + self.api_add_member("raclette", "zorglub") + self.api_add_member("raclette", "fred") + self.api_add_member("raclette", "quentin") + + # get the list of bills (should be empty) + req = self.client.get( + "/api/projects/raclette/bills", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + + self.assertEqual("[]\n", req.data.decode("utf-8")) + + # add a bill + 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": "USD", + "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) + + # the list of bills should length 1 + req = self.client.get( + "/api/projects/raclette/bills", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + self.assertEqual(1, len(json.loads(req.data.decode("utf-8")))) + + # edit with errors should return an error + req = self.client.put( + "/api/projects/raclette/bills/1", + data={ + "date": "201111111-08-10", # not a date + "what": "fromage", + "payer": "1", + "payed_for": ["1", "2"], + "amount": "25", + "external_link": "https://raclette.fr", + }, + headers=self.get_auth("raclette"), + ) + + self.assertStatus(400, req) + self.assertEqual( + '{"date": ["This field is required."]}\n', req.data.decode("utf-8") + ) + + # edit a bill + req = self.client.put( + "/api/projects/raclette/bills/1", + data={ + "date": "2011-09-10", + "what": "beer", + "payer": "2", + "payed_for": ["1", "2"], + "amount": "25", + "external_link": "https://raclette.fr", + }, + headers=self.get_auth("raclette"), + ) + + # check its fields + req = self.client.get( + "/api/projects/raclette/bills/1", headers=self.get_auth("raclette") + ) + creation_date = datetime.datetime.strptime( + json.loads(req.data.decode("utf-8"))["creation_date"], "%Y-%m-%d" + ).date() + + expected = { + "what": "beer", + "payer_id": 2, + "owers": [ + {"activated": True, "id": 1, "name": "zorglub", "weight": 1}, + {"activated": True, "id": 2, "name": "fred", "weight": 1}, + ], + "amount": 25.0, + "date": "2011-09-10", + "external_link": "https://raclette.fr", + "converted_amount": 25.0, + "original_currency": "USD", + "id": 1, + } + + got = json.loads(req.data.decode("utf-8")) + self.assertEqual( + creation_date, + datetime.datetime.strptime(got["creation_date"], "%Y-%m-%d").date(), + ) + del got["creation_date"] + self.assertDictEqual(expected, got) + + # delete a bill + req = self.client.delete( + "/api/projects/raclette/bills/1", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + + # getting it should return a 404 + req = self.client.get( + "/api/projects/raclette/bills/1", headers=self.get_auth("raclette") + ) + self.assertStatus(404, req) + + def test_bills_with_calculation(self): + # create a project + self.api_create("raclette") + + # add members + self.api_add_member("raclette", "zorglub") + self.api_add_member("raclette", "fred") + + # valid amounts + input_expected = [ + ("((100 + 200.25) * 2 - 100) / 2", 250.25), + ("3/2", 1.5), + ("2 + 1 * 5 - 2 / 1", 5), + ] + + for i, pair in enumerate(input_expected): + input_amount, expected_amount = pair + id = i + 1 + + req = self.client.post( + "/api/projects/raclette/bills", + data={ + "date": "2011-08-10", + "what": "fromage", + "payer": "1", + "payed_for": ["1", "2"], + "amount": input_amount, + }, + headers=self.get_auth("raclette"), + ) + + # should return the id + self.assertStatus(201, req) + self.assertEqual(req.data.decode("utf-8"), "{}\n".format(id)) + + # get this bill's details + req = self.client.get( + "/api/projects/raclette/bills/{}".format(id), + 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": expected_amount, + "date": "2011-08-10", + "id": id, + "external_link": "", + "original_currency": "USD", + "converted_amount": expected_amount, + } + + 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) + + # should raise errors + erroneous_amounts = [ + "lambda ", # letters + "(20 + 2", # invalid expression + "20/0", # invalid calc + "9999**99999999999999999", # exponents + "2" * 201, # greater than 200 chars, + ] + + for amount in erroneous_amounts: + req = self.client.post( + "/api/projects/raclette/bills", + data={ + "date": "2011-08-10", + "what": "fromage", + "payer": "1", + "payed_for": ["1", "2"], + "amount": amount, + }, + headers=self.get_auth("raclette"), + ) + self.assertStatus(400, req) + + def test_statistics(self): + # create a project + self.api_create("raclette") + + # add members + self.api_add_member("raclette", "zorglub") + self.api_add_member("raclette", "fred") + + # add a bill + req = self.client.post( + "/api/projects/raclette/bills", + data={ + "date": "2011-08-10", + "what": "fromage", + "payer": "1", + "payed_for": ["1", "2"], + "amount": "25", + }, + headers=self.get_auth("raclette"), + ) + + # get the list of bills (should be empty) + req = self.client.get( + "/api/projects/raclette/statistics", headers=self.get_auth("raclette") + ) + self.assertStatus(200, req) + self.assertEqual( + [ + { + "balance": 12.5, + "member": { + "activated": True, + "id": 1, + "name": "zorglub", + "weight": 1.0, + }, + "paid": 25.0, + "spent": 12.5, + }, + { + "balance": -12.5, + "member": { + "activated": True, + "id": 2, + "name": "fred", + "weight": 1.0, + }, + "paid": 0, + "spent": 12.5, + }, + ], + json.loads(req.data.decode("utf-8")), + ) + + def test_username_xss(self): + # create a project + # self.api_create("raclette") + self.post_project("raclette") + self.login("raclette") + + # add members + self.api_add_member("raclette", "