Repository: spiral-project/ihatemoney

Issue:
High integer can corrupt demo database
So yesterday, I was trying out the demo on ihatemoney.org. And, I entered in a really high integer (something as big as 9347242149381274732472348728748723473278472843.) Now, it is easy to repair, but it requires editing your database. I did this on my own database again (nothing important is on it), and it gave me an internal server error. And yes, the ihatemoney.org demo I accidently corrupted still isn't working.
**Steps to reproduce**
1. Go into demo
2. Add a new bill
3. Add a big number, like 9347242149381274732472348728748723473278472843
Some error handling needs to be added.

Comments:

Patch:
diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py
index 2a7ba24..f1e852e 100644
--- a/ihatemoney/forms.py
+++ b/ihatemoney/forms.py
@@ -1,4 +1,5 @@
 from datetime import datetime
+import decimal
 from re import match
 from types import SimpleNamespace
 
@@ -26,6 +27,7 @@ try:
     from wtforms.fields.html5 import URLField
 except ModuleNotFoundError:
     from wtforms.fields import URLField
+
 from wtforms.validators import (
     URL,
     DataRequired,
@@ -382,8 +384,11 @@ class BillForm(FlaskForm):
         self.payed_for.data = self.payed_for.default
 
     def validate_amount(self, field):
-        if field.data == 0:
+        if field.data == "0":
             raise ValidationError(_("Bills can't be null"))
+        elif decimal.Decimal(field.data) > decimal.MAX_EMAX:
+            # See https://github.com/python-babel/babel/issues/821
+            raise ValidationError(f"Result is too high: {field.data}")
 
 
 class MemberForm(FlaskForm):
diff --git a/ihatemoney/models.py b/ihatemoney/models.py
index 8385ae7..3ff1087 100644
--- a/ihatemoney/models.py
+++ b/ihatemoney/models.py
@@ -21,7 +21,6 @@ from sqlalchemy_continuum.plugins import FlaskPlugin
 from werkzeug.security import generate_password_hash
 
 from ihatemoney.currency_convertor import CurrencyConverter
-from ihatemoney.patch_sqlalchemy_continuum import PatchedBuilder
 from ihatemoney.utils import get_members, same_bill
 from ihatemoney.versioning import (
     ConditionalVersioningManager,
@@ -36,9 +35,6 @@ make_versioned(
         # Conditionally Disable the versioning based on each
         # project's privacy preferences
         tracking_predicate=version_privacy_predicate,
-        # Patch in a fix to a SQLAchemy-Continuum Bug.
-        # See patch_sqlalchemy_continuum.py
-        builder=PatchedBuilder(),
     ),
     plugins=[
         FlaskPlugin(
diff --git a/ihatemoney/patch_sqlalchemy_continuum.py b/ihatemoney/patch_sqlalchemy_continuum.py
deleted file mode 100644
index eecfe6f..0000000
--- a/ihatemoney/patch_sqlalchemy_continuum.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""
-A temporary work-around to patch SQLAlchemy-continuum per:
-https://github.com/kvesteri/sqlalchemy-continuum/pull/242
-
-Source code reproduced under their license:
-
-    Copyright (c) 2012, Konsta Vesterinen
-
-    All rights reserved.
-
-    Redistribution and use in source and binary forms, with or without
-    modification, are permitted provided that the following conditions are met:
-
-    * Redistributions of source code must retain the above copyright notice, this
-      list of conditions and the following disclaimer.
-
-    * Redistributions in binary form must reproduce the above copyright notice,
-      this list of conditions and the following disclaimer in the documentation
-      and/or other materials provided with the distribution.
-
-    * The names of the contributors may not be used to endorse or promote products
-      derived from this software without specific prior written permission.
-
-    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
-    ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
-    WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-    DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY DIRECT,
-    INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
-    BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
-    DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
-    LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
-    OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
-    ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-"""
-
-import sqlalchemy as sa
-from sqlalchemy_continuum import Operation
-from sqlalchemy_continuum.builder import Builder
-from sqlalchemy_continuum.expression_reflector import VersionExpressionReflector
-from sqlalchemy_continuum.relationship_builder import RelationshipBuilder
-from sqlalchemy_continuum.utils import adapt_columns, option
-
-
-class PatchedRelationShipBuilder(RelationshipBuilder):
-    def association_subquery(self, obj):
-        """
-        Returns an EXISTS clause that checks if an association exists for given
-        SQLAlchemy declarative object. This query is used by
-        many_to_many_criteria method.
-
-        Example query:
-
-        .. code-block:: sql
-
-        EXISTS (
-            SELECT 1
-            FROM article_tag_version
-            WHERE article_id = 3
-            AND tag_id = tags_version.id
-            AND operation_type != 2
-            AND EXISTS (
-                SELECT 1
-                FROM article_tag_version as article_tag_version2
-                WHERE article_tag_version2.tag_id = article_tag_version.tag_id
-                AND article_tag_version2.tx_id <=5
-                AND article_tag_version2.article_id = 3
-                GROUP BY article_tag_version2.tag_id
-                HAVING
-                    MAX(article_tag_version2.tx_id) =
-                    article_tag_version.tx_id
-            )
-        )
-
-        :param obj: SQLAlchemy declarative object
-        """
-
-        tx_column = option(obj, "transaction_column_name")
-        join_column = self.property.primaryjoin.right.name
-        object_join_column = self.property.primaryjoin.left.name
-        reflector = VersionExpressionReflector(obj, self.property)
-
-        association_table_alias = self.association_version_table.alias()
-        association_cols = [
-            association_table_alias.c[association_col.name]
-            for _, association_col in self.remote_to_association_column_pairs
-        ]
-
-        association_exists = sa.exists(
-            sa.select([1])
-            .where(
-                sa.and_(
-                    association_table_alias.c[tx_column] <= getattr(obj, tx_column),
-                    association_table_alias.c[join_column]
-                    == getattr(obj, object_join_column),
-                    *[
-                        association_col
-                        == self.association_version_table.c[association_col.name]
-                        for association_col in association_cols
-                    ],
-                )
-            )
-            .group_by(*association_cols)
-            .having(
-                sa.func.max(association_table_alias.c[tx_column])
-                == self.association_version_table.c[tx_column]
-            )
-            .correlate(self.association_version_table)
-        )
-        return sa.exists(
-            sa.select([1])
-            .where(
-                sa.and_(
-                    reflector(self.property.primaryjoin),
-                    association_exists,
-                    self.association_version_table.c.operation_type != Operation.DELETE,
-                    adapt_columns(self.property.secondaryjoin),
-                )
-            )
-            .correlate(self.local_cls, self.remote_cls)
-        )
-
-
-class PatchedBuilder(Builder):
-    def build_relationships(self, version_classes):
-        """
-        Builds relationships for all version classes.
-
-        :param version_classes: list of generated version classes
-        """
-        for cls in version_classes:
-            if not self.manager.option(cls, "versioning"):
-                continue
-
-            for prop in sa.inspect(cls).iterate_properties:
-                if prop.key == "versions":
-                    continue
-                builder = PatchedRelationShipBuilder(self.manager, cls, prop)
-                builder()
diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html
index f93cfc6..78398ce 100644
--- a/ihatemoney/templates/forms.html
+++ b/ihatemoney/templates/forms.html
@@ -166,7 +166,9 @@
     {{ input(form.date, inline=True) }}
     {{ input(form.what, inline=True) }}
     {{ input(form.payer, inline=True, class="form-control custom-select") }}
-    {{ input(form.amount, inline=True) }}
+    <div data-toggle="tooltip" data-placement="top" title='{{ _("Simple operations are allowed, e.g. (18+36.2)/3") }}'>
+        {{ input(form.amount, inline=True) }}
+    </div>
 
     <div class="form-group row">
         <label class="col-3" for="payed_for">{{ _("For whom?") }}</label>
diff --git a/setup.cfg b/setup.cfg
index 2e4fd40..e80866b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -40,7 +40,7 @@ install_requires =
     itsdangerous>=2,<3
     Jinja2>=3,<4
     requests>=2.22,<3
-    SQLAlchemy-Continuum>=1.3.9,<2
+    SQLAlchemy-Continuum>=1.3.12,<2
     SQLAlchemy>=1.3.0,<1.4  # New 1.4 changes API, see #728
     python-dateutil
 


Test patch:
diff --git a/ihatemoney/tests/api_test.py b/ihatemoney/tests/api_test.py
index f5475b1..69c6ab8 100644
--- a/ihatemoney/tests/api_test.py
+++ b/ihatemoney/tests/api_test.py
@@ -910,6 +910,25 @@ class APITestCase(IhatemoneyTestCase):
         self.assertEqual(resp.data.decode("utf-8").count("<td> -- </td>"), 2)
         self.assertNotIn("127.0.0.1", resp.data.decode("utf-8"))
 
+    def test_amount_is_null(self):
+        self.api_create("raclette")
+        # add participants
+        self.api_add_member("raclette", "zorglub")
+
+        # add a bill null amount
+        req = self.client.post(
+            "/api/projects/raclette/bills",
+            data={
+                "date": "2011-08-10",
+                "what": "fromage",
+                "payer": "1",
+                "payed_for": ["1"],
+                "amount": "0",
+            },
+            headers=self.get_auth("raclette"),
+        )
+        self.assertStatus(400, req)
+
     def test_project_creation_with_mixed_case(self):
         self.api_create("Raclette")
         # get information about it
@@ -918,6 +937,26 @@ class APITestCase(IhatemoneyTestCase):
         )
         self.assertStatus(200, resp)
 
+    def test_amount_too_high(self):
+        self.api_create("raclette")
+        # add participants
+        self.api_add_member("raclette", "zorglub")
+
+        # add a bill with too high amount
+        # See https://github.com/python-babel/babel/issues/821
+        req = self.client.post(
+            "/api/projects/raclette/bills",
+            data={
+                "date": "2011-08-10",
+                "what": "fromage",
+                "payer": "1",
+                "payed_for": ["1"],
+                "amount": "9347242149381274732472348728748723473278472843.12",
+            },
+            headers=self.get_auth("raclette"),
+        )
+        self.assertStatus(400, req)
+
 
 if __name__ == "__main__":
     unittest.main()
diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py
index f778109..d94c618 100644
--- a/ihatemoney/tests/budget_test.py
+++ b/ihatemoney/tests/budget_test.py
@@ -1531,6 +1531,30 @@ class BudgetTestCase(IhatemoneyTestCase):
         ]
         assert no_currency_bills == [(5.0, 5.0), (10.0, 10.0)]
 
+    def test_amount_is_null(self):
+        self.post_project("raclette")
+
+        # add participants
+        self.client.post("/raclette/members/add", data={"name": "zorglub"})
+
+        # null amount
+        resp = self.client.post(
+            "/raclette/add",
+            data={
+                "date": "2016-12-31",
+                "what": "fromage à raclette",
+                "payer": 1,
+                "payed_for": [1],
+                "amount": "0",
+                "original_currency": "EUR",
+            },
+        )
+        assert '<p class="alert alert-danger">' in resp.data.decode("utf-8")
+
+        resp = self.client.get("/raclette/")
+        # No bills, the previous one was not added
+        assert "No bills" in resp.data.decode("utf-8")
+
     def test_decimals_on_weighted_members_list(self):
 
         self.post_project("raclette")
@@ -1554,6 +1578,32 @@ class BudgetTestCase(IhatemoneyTestCase):
             'fred<span class="light">(x1.15)</span>', resp.data.decode("utf-8")
         )
 
+    def test_amount_too_high(self):
+        self.post_project("raclette")
+
+        # add participants
+        self.client.post("/raclette/members/add", data={"name": "zorglub"})
+
+        # High amount should be rejected.
+        # See https://github.com/python-babel/babel/issues/821
+        resp = self.client.post(
+            "/raclette/add",
+            data={
+                "date": "2016-12-31",
+                "what": "fromage à raclette",
+                "payer": 1,
+                "payed_for": [1],
+                "amount": "9347242149381274732472348728748723473278472843.12",
+                "original_currency": "EUR",
+            },
+        )
+        assert '<p class="alert alert-danger">' in resp.data.decode("utf-8")
+
+        # Without any check, the following request will fail.
+        resp = self.client.get("/raclette/")
+        # No bills, the previous one was not added
+        assert "No bills" in resp.data.decode("utf-8")
+
 
 if __name__ == "__main__":
     unittest.main()