mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-05 20:51:49 +02:00
Merge branch 'master' into almet/negative-weights
This commit is contained in:
commit
0a5d1368b3
11 changed files with 228 additions and 37 deletions
|
@ -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)
|
||||||
----------------
|
----------------
|
||||||
|
|
|
@ -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*.
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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 ""
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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'))
|
||||||
|
|
Binary file not shown.
|
@ -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 d’autres 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"
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue