Merge branch 'master' into almet/negative-weights

This commit is contained in:
Alexis Metaireau 2019-01-03 13:29:27 +01:00 committed by GitHub
commit 0a5d1368b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 228 additions and 37 deletions

View file

@ -9,9 +9,14 @@ This document describes changes between each past release.
Added Added
===== =====
- Add CORS headers in the API (#407)
- Document database migrations (#390) - 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) 3.0 (2018-11-25)
---------------- ----------------

View file

@ -116,7 +116,7 @@ Collect all new strings to translate::
Compile them into *.mo* files:: Compile them into *.mo* files::
$ make compile-translations $ make build-translations
Commit both *.mo* and *.po*. Commit both *.mo* and *.po*.

View file

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from flask import Blueprint, request from flask import Blueprint, request
from flask_restful import Resource, Api, abort from flask_restful import Resource, Api, abort
from flask_cors import CORS
from wtforms.fields.core import BooleanField from wtforms.fields.core import BooleanField
from ihatemoney.models import db, Project, Person, Bill from ihatemoney.models import db, Project, Person, Bill
@ -11,6 +12,7 @@ from functools import wraps
api = Blueprint("api", __name__, url_prefix="/api") api = Blueprint("api", __name__, url_prefix="/api")
CORS(api)
restful_api = Api(api) restful_api = Api(api)

View file

@ -8,12 +8,13 @@ from flask import request
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from datetime import datetime from datetime import datetime
from re import match
from jinja2 import Markup from jinja2 import Markup
import email_validator import email_validator
from ihatemoney.models import Project, Person 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): def get_billform_for(project, set_default=True, **kwargs):
@ -44,6 +45,30 @@ class CommaDecimalField(DecimalField):
return super(CommaDecimalField, self).process_formdata(value) 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): class EditProjectForm(FlaskForm):
name = StringField(_("Project name"), validators=[Required()]) name = StringField(_("Project name"), validators=[Required()])
password = StringField(_("Private code"), validators=[Required()]) password = StringField(_("Private code"), validators=[Required()])
@ -117,7 +142,7 @@ class BillForm(FlaskForm):
date = DateField(_("Date"), validators=[Required()], default=datetime.now) date = DateField(_("Date"), validators=[Required()], default=datetime.now)
what = StringField(_("What?"), validators=[Required()]) what = StringField(_("What?"), validators=[Required()])
payer = SelectField(_("Payer"), validators=[Required()], coerce=int) payer = SelectField(_("Payer"), validators=[Required()], coerce=int)
amount = CommaDecimalField(_("Amount paid"), validators=[Required()]) amount = CalculatorStringField(_("Amount paid"), validators=[Required()])
payed_for = SelectMultipleField(_("For whom?"), payed_for = SelectMultipleField(_("For whom?"),
validators=[Required()], coerce=int) validators=[Required()], coerce=int)
submit = SubmitField(_("Submit")) submit = SubmitField(_("Submit"))

View file

@ -1,3 +1,8 @@
msgid ""
"Not a valid amount or expression.Only numbers and + - * / operatorsare "
"accepted."
msgstr ""
msgid "Project name" msgid "Project name"
msgstr "" msgstr ""
@ -367,6 +372,9 @@ msgstr ""
msgid "Add a new bill" msgid "Add a new bill"
msgstr "" msgstr ""
msgid "Added on"
msgstr ""
msgid "When?" msgid "When?"
msgstr "" msgstr ""

View file

@ -13,11 +13,6 @@ db = SQLAlchemy()
class Project(db.Model): class Project(db.Model):
_to_serialize = (
"id", "name", "contact_email", "members", "active_members",
"balance"
)
id = db.Column(db.String(64), primary_key=True) id = db.Column(db.String(64), primary_key=True)
name = db.Column(db.UnicodeText) name = db.Column(db.UnicodeText)
@ -25,6 +20,23 @@ class Project(db.Model):
contact_email = db.Column(db.String(128)) contact_email = db.Column(db.String(128))
members = db.relationship("Person", backref="project") 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 @property
def active_members(self): def active_members(self):
return [m for m in self.members if m.activated] return [m for m in self.members if m.activated]
@ -276,8 +288,6 @@ class Person(db.Model):
query_class = PersonQuery query_class = PersonQuery
_to_serialize = ("id", "name", "weight", "activated")
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.String(64), db.ForeignKey("project.id")) project_id = db.Column(db.String(64), db.ForeignKey("project.id"))
bills = db.relationship("Bill", backref="payer") bills = db.relationship("Bill", backref="payer")
@ -286,6 +296,15 @@ class Person(db.Model):
weight = db.Column(db.Float, default=1) weight = db.Column(db.Float, default=1)
activated = db.Column(db.Boolean, default=True) 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): def has_bills(self):
"""return if the user do have bills or not""" """return if the user do have bills or not"""
bills_as_ower_number = db.session.query(billowers)\ bills_as_ower_number = db.session.query(billowers)\
@ -330,9 +349,6 @@ class Bill(db.Model):
query_class = BillQuery query_class = BillQuery
_to_serialize = ("id", "payer_id", "owers", "amount", "date",
"creation_date", "what")
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
payer_id = db.Column(db.Integer, db.ForeignKey("person.id")) 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")) 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): def pay_each(self):
"""Compute what each share has to pay""" """Compute what each share has to pay"""
if self.owers: if self.owers:

View file

@ -1032,6 +1032,16 @@ class APITestCase(IhatemoneyTestCase):
('%s:%s' % (username, password)).encode('utf-8')).decode('utf-8').replace('\n', '') ('%s:%s' % (username, password)).encode('utf-8')).decode('utf-8').replace('\n', '')
return {"Authorization": "Basic %s" % base64string} 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): def test_basic_auth(self):
# create a project # create a project
resp = self.api_create("raclette") resp = self.api_create("raclette")
@ -1086,12 +1096,10 @@ class APITestCase(IhatemoneyTestCase):
self.assertTrue(200, resp.status_code) self.assertTrue(200, resp.status_code)
expected = { expected = {
"active_members": [], "members": [],
"name": "raclette", "name": "raclette",
"contact_email": "raclette@notmyidea.org", "contact_email": "raclette@notmyidea.org",
"members": [],
"id": "raclette", "id": "raclette",
"balance": {},
} }
decoded_resp = json.loads(resp.data.decode('utf-8')) decoded_resp = json.loads(resp.data.decode('utf-8'))
self.assertDictEqual(decoded_resp, expected) self.assertDictEqual(decoded_resp, expected)
@ -1110,12 +1118,10 @@ class APITestCase(IhatemoneyTestCase):
self.assertEqual(200, resp.status_code) self.assertEqual(200, resp.status_code)
expected = { expected = {
"active_members": [],
"name": "The raclette party", "name": "The raclette party",
"contact_email": "yeah@notmyidea.org", "contact_email": "yeah@notmyidea.org",
"members": [], "members": [],
"id": "raclette", "id": "raclette",
"balance": {},
} }
decoded_resp = json.loads(resp.data.decode('utf-8')) decoded_resp = json.loads(resp.data.decode('utf-8'))
self.assertDictEqual(decoded_resp, expected) self.assertDictEqual(decoded_resp, expected)
@ -1355,6 +1361,87 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
self.assertStatus(404, req) 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): def test_statistics(self):
# create a project # create a project
self.api_create("raclette") self.api_create("raclette")
@ -1451,21 +1538,16 @@ class APITestCase(IhatemoneyTestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
expected = { expected = {
"active_members": [ "members": [
{"activated": True, "id": 1, "name": "alexis", "weight": 1.0}, {"activated": True, "id": 1, "name": "alexis", "weight": 1.0, "balance": 20.0},
{"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0}, {"activated": True, "id": 2, "name": "freddy familly", "weight": 4.0,
{"activated": True, "id": 3, "name": "arnaud", "weight": 1.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", "contact_email": "raclette@notmyidea.org",
"id": "raclette", "id": "raclette",
"name": "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"}
self.assertStatus(200, req) self.assertStatus(200, req)
decoded_req = json.loads(req.data.decode('utf-8')) decoded_req = json.loads(req.data.decode('utf-8'))

View file

@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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" "PO-Revision-Date: 2018-05-15 22:00+0200\n"
"Last-Translator: Adrien CLERC <>\n" "Last-Translator: Adrien CLERC <>\n"
"Language: fr\n" "Language: fr\n"
@ -18,6 +18,13 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.6.0\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" msgid "Project name"
msgstr "Nom de projet" msgstr "Nom de projet"
@ -397,6 +404,9 @@ msgstr "Invitez dautres personnes à rejoindre ce projet !"
msgid "Add a new bill" msgid "Add a new bill"
msgstr "Nouvelle facture" msgstr "Nouvelle facture"
msgid "Added on"
msgstr "Ajouté Sur"
msgid "When?" msgid "When?"
msgstr "Quand ?" msgstr "Quand ?"
@ -497,3 +507,4 @@ msgstr "Solde"
#~ msgid "Invite" #~ msgid "Invite"
#~ msgstr "Invitez" #~ msgstr "Invitez"

View file

@ -1,5 +1,8 @@
from __future__ import division
import base64 import base64
import re import re
import ast
import operator
from io import BytesIO, StringIO from io import BytesIO, StringIO
import jinja2 import jinja2
@ -192,11 +195,7 @@ class IhmJSONEncoder(JSONEncoder):
Taken from the deprecated flask-rest package.""" Taken from the deprecated flask-rest package."""
def default(self, o): def default(self, o):
if hasattr(o, "_to_serialize"): if hasattr(o, "_to_serialize"):
# build up the object return o._to_serialize
data = {}
for attr in o._to_serialize:
data[attr] = getattr(o, attr)
return data
elif hasattr(o, "isoformat"): elif hasattr(o, "isoformat"):
return o.isoformat() return o.isoformat()
else: else:
@ -210,3 +209,33 @@ class IhmJSONEncoder(JSONEncoder):
except ImportError: except ImportError:
pass pass
return JSONEncoder.default(self, o) 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): # <number>
return node.n
elif isinstance(node, ast.BinOp): # <left> <operator> <right>
return operators[type(node.op)](_eval(node.left), _eval(node.right))
elif isinstance(node, ast.UnaryOp): # <operator> <operand> 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

View file

@ -8,6 +8,7 @@ flask-babel
flask-restful>=0.3.6 flask-restful>=0.3.6
jinja2>=2.6 jinja2>=2.6
blinker blinker
flask-cors
six>=1.10 six>=1.10
itsdangerous>=0.24 itsdangerous>=0.24
email_validator>=1.0 email_validator>=1.0