From 9aa7e62d0f77f27590dac1cae5603e8f7efb891f Mon Sep 17 00:00:00 2001
From: Nicolas Vanvyve
Date: Mon, 13 Jan 2020 21:17:55 +0100
Subject: [PATCH] Import previously exported json data (#518)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fix #417
* New tab upload
* Extract data from JSON
* Add users
* Black format
* Try to add bill
* Import bills
* Add french translation msg
* Black reformat missing
* Deactivated users are supported
* Test import
* Remove temp file in upload_json()
* Incomplete tests
* tests import
* Update ihatemoney/translations/fr/LC_MESSAGES/messages.po
Co-Authored-By: Rémy HUBSCHER
* Remove useless variable and check json format
* Use String.IO and test for wrong json
* Remove coma
Co-authored-by: Rémy HUBSCHER
---
.gitignore | 3 +-
ihatemoney/forms.py | 17 ++
ihatemoney/templates/forms.html | 9 +
ihatemoney/templates/layout.html | 1 +
ihatemoney/templates/upload_json.html | 10 +
ihatemoney/tests/tests.py | 209 ++++++++++++++++++
.../translations/fr/LC_MESSAGES/messages.po | 10 +
ihatemoney/utils.py | 23 ++
ihatemoney/web.py | 105 ++++++++-
9 files changed, 379 insertions(+), 8 deletions(-)
create mode 100644 ihatemoney/templates/upload_json.html
diff --git a/.gitignore b/.gitignore
index b7e8680f..1b9de4d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,4 +10,5 @@ dist
build
.vscode
.env
-.pytest_cache
\ No newline at end of file
+.pytest_cache
+
diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py
index eb1bf2b9..88afd296 100644
--- a/ihatemoney/forms.py
+++ b/ihatemoney/forms.py
@@ -10,6 +10,8 @@ from wtforms.validators import (
NumberRange,
Optional,
)
+from flask_wtf.file import FileField, FileAllowed, FileRequired
+
from flask_babel import lazy_gettext as _
from flask import request
from werkzeug.security import generate_password_hash
@@ -110,6 +112,12 @@ class EditProjectForm(FlaskForm):
return project
+class UploadForm(FlaskForm):
+ file = FileField(
+ "JSON", validators=[FileRequired(), FileAllowed(["json", "JSON"], "JSON only!")]
+ )
+
+
class ProjectForm(EditProjectForm):
id = StringField(_("Project identifier"), validators=[DataRequired()])
password = PasswordField(_("Private code"), validators=[DataRequired()])
@@ -181,6 +189,15 @@ class BillForm(FlaskForm):
bill.external_link = self.external_link.data
bill.date = self.date.data
bill.owers = [Person.query.get(ower, project) for ower in self.payed_for.data]
+ return bill
+
+ def fake_form(self, bill, project):
+ bill.payer_id = self.payer
+ bill.amount = self.amount
+ bill.what = self.what
+ bill.external_link = ""
+ bill.date = self.date
+ bill.owers = [Person.query.get(ower, project) for ower in self.payed_for]
return bill
diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html
index 61127ce7..95606e5d 100644
--- a/ihatemoney/templates/forms.html
+++ b/ihatemoney/templates/forms.html
@@ -85,6 +85,15 @@
{% endmacro %}
+{% macro upload_json(form) %}
+ {% include "display_errors.html" %}
+ {{ form.hidden_tag() }}
+ {{ form.file }}
+
+
+
+{% endmacro %}
+
{% macro add_bill(form, edit=False, title=True) %}
+{% endblock %}
diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py
index 85698025..876a153b 100644
--- a/ihatemoney/tests/tests.py
+++ b/ihatemoney/tests/tests.py
@@ -1190,6 +1190,215 @@ class BudgetTestCase(IhatemoneyTestCase):
resp = self.client.get("/raclette/export/transactions.wrong")
self.assertEqual(resp.status_code, 404)
+ def test_import_new_project(self):
+ # Import JSON in an empty project
+
+ self.post_project("raclette")
+ self.login("raclette")
+
+ project = models.Project.query.get("raclette")
+
+ json_to_import = [
+ {
+ "date": "2017-01-01",
+ "what": "refund",
+ "amount": 13.33,
+ "payer_name": "tata",
+ "payer_weight": 1.0,
+ "owers": ["fred"],
+ },
+ {
+ "date": "2016-12-31",
+ "what": "red wine",
+ "amount": 200.0,
+ "payer_name": "fred",
+ "payer_weight": 1.0,
+ "owers": ["alexis", "tata"],
+ },
+ {
+ "date": "2016-12-31",
+ "what": "fromage a raclette",
+ "amount": 10.0,
+ "payer_name": "alexis",
+ "payer_weight": 2.0,
+ "owers": ["alexis", "fred", "tata", "pepe"],
+ },
+ ]
+
+ from ihatemoney.web import import_project
+
+ file = io.StringIO()
+ json.dump(json_to_import, file)
+ file.seek(0)
+ import_project(file, project)
+
+ bills = project.get_pretty_bills()
+
+ # Check if all bills has been add
+ self.assertEqual(len(bills), len(json_to_import))
+
+ # Check if name of bills are ok
+ b = [e["what"] for e in bills]
+ b.sort()
+ ref = [e["what"] for e in json_to_import]
+ ref.sort()
+
+ self.assertEqual(b, ref)
+
+ # Check if other informations in bill are ok
+ for i in json_to_import:
+ for j in bills:
+ if j["what"] == i["what"]:
+ self.assertEqual(j["payer_name"], i["payer_name"])
+ self.assertEqual(j["amount"], i["amount"])
+ self.assertEqual(j["payer_weight"], i["payer_weight"])
+ self.assertEqual(j["date"], i["date"])
+
+ list_project = [ower for ower in j["owers"]]
+ list_project.sort()
+ list_json = [ower for ower in i["owers"]]
+ list_json.sort()
+
+ self.assertEqual(list_project, list_json)
+
+ def test_import_partial_project(self):
+ # Import a JSON in a project with already existing data
+
+ self.post_project("raclette")
+ self.login("raclette")
+
+ project = models.Project.query.get("raclette")
+
+ self.client.post("/raclette/members/add", data={"name": "alexis", "weight": 2})
+ self.client.post("/raclette/members/add", data={"name": "fred"})
+ self.client.post("/raclette/members/add", data={"name": "tata"})
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2016-12-31",
+ "what": "red wine",
+ "payer": 2,
+ "payed_for": [1, 3],
+ "amount": "200",
+ },
+ )
+
+ json_to_import = [
+ {
+ "date": "2017-01-01",
+ "what": "refund",
+ "amount": 13.33,
+ "payer_name": "tata",
+ "payer_weight": 1.0,
+ "owers": ["fred"],
+ },
+ { # This expense does not have to be present twice.
+ "date": "2016-12-31",
+ "what": "red wine",
+ "amount": 200.0,
+ "payer_name": "fred",
+ "payer_weight": 1.0,
+ "owers": ["alexis", "tata"],
+ },
+ {
+ "date": "2016-12-31",
+ "what": "fromage a raclette",
+ "amount": 10.0,
+ "payer_name": "alexis",
+ "payer_weight": 2.0,
+ "owers": ["alexis", "fred", "tata", "pepe"],
+ },
+ ]
+
+ from ihatemoney.web import import_project
+
+ file = io.StringIO()
+ json.dump(json_to_import, file)
+ file.seek(0)
+ import_project(file, project)
+
+ bills = project.get_pretty_bills()
+
+ # Check if all bills has been add
+ self.assertEqual(len(bills), len(json_to_import))
+
+ # Check if name of bills are ok
+ b = [e["what"] for e in bills]
+ b.sort()
+ ref = [e["what"] for e in json_to_import]
+ ref.sort()
+
+ self.assertEqual(b, ref)
+
+ # Check if other informations in bill are ok
+ for i in json_to_import:
+ for j in bills:
+ if j["what"] == i["what"]:
+ self.assertEqual(j["payer_name"], i["payer_name"])
+ self.assertEqual(j["amount"], i["amount"])
+ self.assertEqual(j["payer_weight"], i["payer_weight"])
+ self.assertEqual(j["date"], i["date"])
+
+ list_project = [ower for ower in j["owers"]]
+ list_project.sort()
+ list_json = [ower for ower in i["owers"]]
+ list_json.sort()
+
+ self.assertEqual(list_project, list_json)
+
+ def test_import_wrong_json(self):
+ self.post_project("raclette")
+ self.login("raclette")
+
+ project = models.Project.query.get("raclette")
+
+ json_1 = [
+ { # wrong keys
+ "checked": False,
+ "dimensions": {"width": 5, "height": 10},
+ "id": 1,
+ "name": "A green door",
+ "price": 12.5,
+ "tags": ["home", "green"],
+ }
+ ]
+
+ json_2 = [
+ { # amount missing
+ "date": "2017-01-01",
+ "what": "refund",
+ "payer_name": "tata",
+ "payer_weight": 1.0,
+ "owers": ["fred"],
+ }
+ ]
+
+ from ihatemoney.web import import_project
+
+ try:
+ file = io.StringIO()
+ json.dump(json_1, file)
+ file.seek(0)
+ import_project(file, project)
+ except ValueError:
+ self.assertTrue(True)
+ except Exception:
+ self.fail("unexpected exception raised")
+ else:
+ self.fail("ExpectedException not raised")
+
+ try:
+ file = io.StringIO()
+ json.dump(json_2, file)
+ file.seek(0)
+ import_project(file, project)
+ except ValueError:
+ self.assertTrue(True)
+ except Exception:
+ self.fail("unexpected exception raised")
+ else:
+ self.fail("ExpectedException not raised")
+
class APITestCase(IhatemoneyTestCase):
diff --git a/ihatemoney/translations/fr/LC_MESSAGES/messages.po b/ihatemoney/translations/fr/LC_MESSAGES/messages.po
index 238e02d3..f4c1f36c 100644
--- a/ihatemoney/translations/fr/LC_MESSAGES/messages.po
+++ b/ihatemoney/translations/fr/LC_MESSAGES/messages.po
@@ -539,6 +539,16 @@ msgstr "A dépensé"
msgid "Balance"
msgstr "Solde"
+msgid "Import"
+msgstr "Importer"
+
+msgid "Project successfully uploaded"
+msgstr "Le projet a été correctement importé"
+
+msgid "Invalid JSON"
+msgstr "Le fichier JSON est invalide"
+
+
#~ msgid ""
#~ "The project identifier is used to "
#~ "log in and for the URL of "
diff --git a/ihatemoney/utils.py b/ihatemoney/utils.py
index cdc11c9c..126b9dee 100644
--- a/ihatemoney/utils.py
+++ b/ihatemoney/utils.py
@@ -4,6 +4,7 @@ import ast
import operator
from io import BytesIO, StringIO
+
import jinja2
from json import dumps, JSONEncoder
from flask import redirect, current_app
@@ -11,6 +12,7 @@ from babel import Locale
from werkzeug.routing import HTTPException, RoutingException
from datetime import datetime, timedelta
+
import csv
@@ -234,3 +236,24 @@ def eval_arithmetic_expression(expr):
raise ValueError("Error evaluating expression: {}".format(expr))
return result
+
+
+def get_members(file):
+ members_list = list()
+ for item in file:
+ if (item["payer_name"], item["payer_weight"]) not in members_list:
+ members_list.append((item["payer_name"], item["payer_weight"]))
+ for item in file:
+ for ower in item["owers"]:
+ if ower not in [i[0] for i in members_list]:
+ members_list.append((ower, 1))
+
+ return members_list
+
+
+def same_bill(bill1, bill2):
+ attr = ["what", "payer_name", "payer_weight", "amount", "date", "owers"]
+ for a in attr:
+ if bill1[a] != bill2[a]:
+ return False
+ return True
diff --git a/ihatemoney/web.py b/ihatemoney/web.py
index fc12e9d5..be39feb8 100644
--- a/ihatemoney/web.py
+++ b/ihatemoney/web.py
@@ -8,8 +8,12 @@ Basically, this blueprint takes care of the authentication and provides
some shortcuts to make your life better when coding (see `pull_project`
and `add_project_id` for a quick overview)
"""
-
+import json
import os
+from functools import wraps
+from smtplib import SMTPRecipientsRefused
+
+from dateutil.parser import parse
from flask import (
abort,
Blueprint,
@@ -24,15 +28,12 @@ from flask import (
send_file,
send_from_directory,
)
-from flask_mail import Message
from flask_babel import get_locale, gettext as _
-from werkzeug.security import check_password_hash, generate_password_hash
-from smtplib import SMTPRecipientsRefused
-from werkzeug.exceptions import NotFound
+from flask_mail import Message
from sqlalchemy import orm
-from functools import wraps
+from werkzeug.exceptions import NotFound
+from werkzeug.security import check_password_hash, generate_password_hash
-from ihatemoney.models import db, Project, Person, Bill
from ihatemoney.forms import (
AdminAuthenticationForm,
AuthenticationForm,
@@ -43,12 +44,16 @@ from ihatemoney.forms import (
ResetPasswordForm,
ProjectForm,
get_billform_for,
+ UploadForm,
)
+from ihatemoney.models import db, Project, Person, Bill
from ihatemoney.utils import (
Redirect303,
list_of_dicts2json,
list_of_dicts2csv,
LoginThrottler,
+ get_members,
+ same_bill,
)
main = Blueprint("main", __name__)
@@ -391,6 +396,92 @@ def edit_project():
)
+@main.route("//upload_json", methods=["GET", "POST"])
+def upload_json():
+ form = UploadForm()
+ if form.validate_on_submit():
+ try:
+ import_project(form.file.data.stream, g.project)
+ flash(_("Project successfully uploaded"))
+ except ValueError:
+ flash(_("Invalid JSON"), category="error")
+ return redirect(url_for("main.list_bills"))
+
+ return render_template("upload_json.html", form=form)
+
+
+def import_project(file, project):
+ json_file = json.load(file)
+
+ # Check if JSON is correct
+ attr = ["what", "payer_name", "payer_weight", "amount", "date", "owers"]
+ attr.sort()
+ for e in json_file:
+ if len(e) != len(attr):
+ raise ValueError
+ list_attr = []
+ for i in e:
+ list_attr.append(i)
+ list_attr.sort()
+ if list_attr != attr:
+ raise ValueError
+
+ # From json : export list of members
+ members_json = get_members(json_file)
+ members = project.members
+ members_already_here = list()
+ for m in members:
+ members_already_here.append(str(m))
+
+ # List all members not in the project and weight associated
+ # List of tuples (name,weight)
+ members_to_add = list()
+ for i in members_json:
+ if str(i[0]) not in members_already_here:
+ members_to_add.append(i)
+
+ # List bills not in the project
+ # Same format than JSON element
+ project_bills = project.get_pretty_bills()
+ bill_to_add = list()
+ for j in json_file:
+ same = False
+ for p in project_bills:
+ if same_bill(p, j):
+ same = True
+ break
+ if not same:
+ bill_to_add.append(j)
+
+ # Add users to DB
+ for m in members_to_add:
+ Person(name=m[0], project=project, weight=m[1])
+ db.session.commit()
+
+ id_dict = {}
+ for i in project.members:
+ id_dict[i.name] = i.id
+
+ # Create bills
+ for b in bill_to_add:
+ owers_id = list()
+ for ower in b["owers"]:
+ owers_id.append(id_dict[ower])
+
+ bill = Bill()
+ form = get_billform_for(project)
+ form.what = b["what"]
+ form.amount = b["amount"]
+ form.date = parse(b["date"])
+ form.payer = id_dict[b["payer_name"]]
+ form.payed_for = owers_id
+
+ db.session.add(form.fake_form(bill, project))
+
+ # Add bills to DB
+ db.session.commit()
+
+
@main.route("//delete")
def delete_project():
g.project.remove_project()