diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 01e21b65..37d7dcbc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,9 +9,14 @@ This document describes changes between each past release. Added ===== +- Add CORS headers in the API (#407) - Document database migrations (#390) -- Do not allow negative weights on users (#366) +- Allow basic math operations in amount field (#413) +Fixed +===== + +- Do not allow negative weights on users (#366) 3.0 (2018-11-25) ---------------- diff --git a/docs/contributing.rst b/docs/contributing.rst index bcb3f162..0350f01c 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -116,7 +116,7 @@ Collect all new strings to translate:: Compile them into *.mo* files:: - $ make compile-translations + $ make build-translations Commit both *.mo* and *.po*. diff --git a/ihatemoney/api.py b/ihatemoney/api.py index 6068cf72..c9c5376b 100644 --- a/ihatemoney/api.py +++ b/ihatemoney/api.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from flask import Blueprint, request from flask_restful import Resource, Api, abort +from flask_cors import CORS from wtforms.fields.core import BooleanField from ihatemoney.models import db, Project, Person, Bill @@ -11,6 +12,7 @@ from functools import wraps api = Blueprint("api", __name__, url_prefix="/api") +CORS(api) restful_api = Api(api) diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 1a12ba37..be04c8f8 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -8,12 +8,13 @@ from flask import request from werkzeug.security import generate_password_hash from datetime import datetime +from re import match from jinja2 import Markup import email_validator from ihatemoney.models import Project, Person -from ihatemoney.utils import slugify +from ihatemoney.utils import slugify, eval_arithmetic_expression def get_billform_for(project, set_default=True, **kwargs): @@ -44,6 +45,30 @@ class CommaDecimalField(DecimalField): return super(CommaDecimalField, self).process_formdata(value) +class CalculatorStringField(StringField): + """ + A class to deal with math ops (+, -, *, /) + in StringField + """ + + def process_formdata(self, valuelist): + if valuelist: + message = _( + "Not a valid amount or expression." + "Only numbers and + - * / operators" + "are accepted." + ) + value = str(valuelist[0]).replace(",", ".") + + # avoid exponents to prevent expensive calculations i.e 2**9999999999**9999999 + if not match(r'^[ 0-9\.\+\-\*/\(\)]{0,200}$', value) or "**" in value: + raise ValueError(Markup(message)) + + valuelist[0] = str(eval_arithmetic_expression(value)) + + return super(CalculatorStringField, self).process_formdata(valuelist) + + class EditProjectForm(FlaskForm): name = StringField(_("Project name"), validators=[Required()]) password = StringField(_("Private code"), validators=[Required()]) @@ -117,7 +142,7 @@ class BillForm(FlaskForm): date = DateField(_("Date"), validators=[Required()], default=datetime.now) what = StringField(_("What?"), validators=[Required()]) payer = SelectField(_("Payer"), validators=[Required()], coerce=int) - amount = CommaDecimalField(_("Amount paid"), validators=[Required()]) + amount = CalculatorStringField(_("Amount paid"), validators=[Required()]) payed_for = SelectMultipleField(_("For whom?"), validators=[Required()], coerce=int) submit = SubmitField(_("Submit")) diff --git a/ihatemoney/messages.pot b/ihatemoney/messages.pot index 348dbb76..fb69539e 100644 --- a/ihatemoney/messages.pot +++ b/ihatemoney/messages.pot @@ -1,3 +1,8 @@ +msgid "" +"Not a valid amount or expression.Only numbers and + - * / operatorsare " +"accepted." +msgstr "" + msgid "Project name" msgstr "" @@ -367,6 +372,9 @@ msgstr "" msgid "Add a new bill" msgstr "" +msgid "Added on" +msgstr "" + msgid "When?" msgstr "" diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 902719d5..b9cff4f8 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -13,11 +13,6 @@ db = SQLAlchemy() class Project(db.Model): - _to_serialize = ( - "id", "name", "contact_email", "members", "active_members", - "balance" - ) - id = db.Column(db.String(64), primary_key=True) name = db.Column(db.UnicodeText) @@ -25,6 +20,23 @@ class Project(db.Model): contact_email = db.Column(db.String(128)) members = db.relationship("Person", backref="project") + @property + def _to_serialize(self): + obj = { + "id": self.id, + "name": self.name, + "contact_email": self.contact_email, + "members": [], + } + + balance = self.balance + for member in self.members: + member_obj = member._to_serialize + member_obj['balance'] = balance.get(member.id, 0) + obj['members'].append(member_obj) + + return obj + @property def active_members(self): return [m for m in self.members if m.activated] @@ -276,8 +288,6 @@ class Person(db.Model): query_class = PersonQuery - _to_serialize = ("id", "name", "weight", "activated") - id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.String(64), db.ForeignKey("project.id")) bills = db.relationship("Bill", backref="payer") @@ -286,6 +296,15 @@ class Person(db.Model): weight = db.Column(db.Float, default=1) activated = db.Column(db.Boolean, default=True) + @property + def _to_serialize(self): + return { + "id": self.id, + "name": self.name, + "weight": self.weight, + "activated": self.activated, + } + def has_bills(self): """return if the user do have bills or not""" bills_as_ower_number = db.session.query(billowers)\ @@ -330,9 +349,6 @@ class Bill(db.Model): query_class = BillQuery - _to_serialize = ("id", "payer_id", "owers", "amount", "date", - "creation_date", "what") - id = db.Column(db.Integer, primary_key=True) payer_id = db.Column(db.Integer, db.ForeignKey("person.id")) @@ -345,6 +361,18 @@ class Bill(db.Model): archive = db.Column(db.Integer, db.ForeignKey("archive.id")) + @property + def _to_serialize(self): + return { + "id": self.id, + "payer_id": self.payer_id, + "owers": self.owers, + "amount": self.amount, + "date": self.date, + "creation_date": self.creation_date, + "what": self.what, + } + def pay_each(self): """Compute what each share has to pay""" if self.owers: diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index 3619045c..d29ec628 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -1032,6 +1032,16 @@ class APITestCase(IhatemoneyTestCase): ('%s:%s' % (username, password)).encode('utf-8')).decode('utf-8').replace('\n', '') return {"Authorization": "Basic %s" % 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") @@ -1086,12 +1096,10 @@ class APITestCase(IhatemoneyTestCase): self.assertTrue(200, resp.status_code) expected = { - "active_members": [], + "members": [], "name": "raclette", "contact_email": "raclette@notmyidea.org", - "members": [], "id": "raclette", - "balance": {}, } decoded_resp = json.loads(resp.data.decode('utf-8')) self.assertDictEqual(decoded_resp, expected) @@ -1110,12 +1118,10 @@ class APITestCase(IhatemoneyTestCase): self.assertEqual(200, resp.status_code) expected = { - "active_members": [], "name": "The raclette party", "contact_email": "yeah@notmyidea.org", "members": [], "id": "raclette", - "balance": {}, } decoded_resp = json.loads(resp.data.decode('utf-8')) self.assertDictEqual(decoded_resp, expected) @@ -1355,6 +1361,87 @@ class APITestCase(IhatemoneyTestCase): 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", "alexis") + 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": "alexis", "weight": 1}, + {"activated": True, "id": 2, "name": "fred", "weight": 1}], + "amount": expected_amount, + "date": "2011-08-10", + "id": id, + } + + 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") @@ -1451,21 +1538,16 @@ class APITestCase(IhatemoneyTestCase): headers=self.get_auth("raclette")) expected = { - "active_members": [ - {"activated": True, "id": 1, "name": "alexis", "weight": 1.0}, - {"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0}, - {"activated": True, "id": 3, "name": "arnaud", "weight": 1.0} + "members": [ + {"activated": True, "id": 1, "name": "alexis", "weight": 1.0, "balance": 20.0}, + {"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0, + "balance": -20.0}, + {"activated": True, "id": 3, "name": "arnaud", "weight": 1.0, "balance": 0}, ], - "balance": {"1": 20.0, "2": -20.0, "3": 0}, "contact_email": "raclette@notmyidea.org", "id": "raclette", - - "members": [ - {"activated": True, "id": 1, "name": "alexis", "weight": 1.0}, - {"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0}, - {"activated": True, "id": 3, "name": "arnaud", "weight": 1.0} - ], - "name": "raclette"} + "name": "raclette", + } self.assertStatus(200, req) decoded_req = json.loads(req.data.decode('utf-8')) diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo index ab8a8316..44b0c6ae 100644 Binary files a/ihatemoney/translations/fr/LC_MESSAGES/messages.mo and b/ihatemoney/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po index d4c64062..d610145f 100644 --- a/ihatemoney/translations/fr/LC_MESSAGES/messages.po +++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2018-08-05 23:41+0200\n" +"POT-Creation-Date: 2019-01-02 23:26-0500\n" "PO-Revision-Date: 2018-05-15 22:00+0200\n" "Last-Translator: Adrien CLERC <>\n" "Language: fr\n" @@ -18,6 +18,13 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.6.0\n" +msgid "" +"Not a valid amount or expression.Only numbers and + - * / operators are " +"accepted." +msgstr "" +"Pas un montant ou une expression valide. Seuls les nombres et les opérateurs" +"+ - * / sont acceptés" + msgid "Project name" msgstr "Nom de projet" @@ -397,6 +404,9 @@ msgstr "Invitez d’autres personnes à rejoindre ce projet !" msgid "Add a new bill" msgstr "Nouvelle facture" +msgid "Added on" +msgstr "Ajouté Sur" + msgid "When?" msgstr "Quand ?" @@ -497,3 +507,4 @@ msgstr "Solde" #~ msgid "Invite" #~ msgstr "Invitez" + diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py index 19340df8..2fac4efb 100644 --- a/ihatemoney/utils.py +++ b/ihatemoney/utils.py @@ -1,5 +1,8 @@ +from __future__ import division import base64 import re +import ast +import operator from io import BytesIO, StringIO import jinja2 @@ -192,11 +195,7 @@ class IhmJSONEncoder(JSONEncoder): Taken from the deprecated flask-rest package.""" def default(self, o): if hasattr(o, "_to_serialize"): - # build up the object - data = {} - for attr in o._to_serialize: - data[attr] = getattr(o, attr) - return data + return o._to_serialize elif hasattr(o, "isoformat"): return o.isoformat() else: @@ -210,3 +209,33 @@ class IhmJSONEncoder(JSONEncoder): except ImportError: pass return JSONEncoder.default(self, o) + + +def eval_arithmetic_expression(expr): + def _eval(node): + # supported operators + operators = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.USub: operator.neg, + } + + if isinstance(node, ast.Num): # + return node.n + elif isinstance(node, ast.BinOp): # + return operators[type(node.op)](_eval(node.left), _eval(node.right)) + elif isinstance(node, ast.UnaryOp): # e.g., -1 + return operators[type(node.op)](_eval(node.operand)) + else: + raise TypeError(node) + + expr = str(expr) + + try: + result = _eval(ast.parse(expr, mode='eval').body) + except (SyntaxError, TypeError, ZeroDivisionError, KeyError): + raise ValueError("Error evaluating expression: {}".format(expr)) + + return result diff --git a/requirements.txt b/requirements.txt index f61c9b93..09221f58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ flask-babel flask-restful>=0.3.6 jinja2>=2.6 blinker +flask-cors six>=1.10 itsdangerous>=0.24 email_validator>=1.0