Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CSV bills import (cospend compatible) #951

Merged
merged 21 commits into from
Dec 21, 2021
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 1 addition & 2 deletions ihatemoney/api/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,7 @@ def get(self, project):
def post(self, project):
form = get_billform_for(project, True, meta={"csrf": False})
if form.validate():
bill = Bill()
form.save(bill, project)
bill = form.export(project)
db.session.add(bill)
db.session.commit()
return bill.id, 201
Expand Down
42 changes: 21 additions & 21 deletions ihatemoney/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
)

from ihatemoney.currency_convertor import CurrencyConverter
from ihatemoney.models import LoggingMode, Person, Project
from ihatemoney.models import Bill, LoggingMode, Person, Project
from ihatemoney.utils import (
eval_arithmetic_expression,
render_localized_currency,
Expand Down Expand Up @@ -182,13 +182,15 @@ def update(self, project):
return project


class UploadForm(FlaskForm):
class ImportProjectForm(FlaskForm):
file = FileField(
"JSON",
validators=[FileRequired(), FileAllowed(["json", "JSON"], "JSON only!")],
description=_("Import previously exported JSON file"),
"File",
validators=[
FileRequired(),
FileAllowed(["json", "JSON", "csv", "CSV"], "Incorrect file format"),
],
description=_("Compatible with Cospend"),
)
submit = SubmitField(_("Import"))


class ProjectForm(EditProjectForm):
Expand Down Expand Up @@ -319,33 +321,31 @@ class BillForm(FlaskForm):
submit = SubmitField(_("Submit"))
submit2 = SubmitField(_("Submit and add a new one"))

def export(self, project):
return Bill(
float(self.amount.data),
self.date.data,
self.external_link.data,
str(self.original_currency.data),
Person.query.get_by_ids(self.payed_for.data, project),
self.payer.data,
project.default_currency,
self.what.data,
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could be good to also use named parameters here for consistency

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, forgot to include them here, fixed !


def save(self, bill, project):
bill.payer_id = self.payer.data
bill.amount = self.amount.data
bill.what = self.what.data
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]
bill.owers = Person.query.get_by_ids(self.payed_for.data, project)
bill.original_currency = self.original_currency.data
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)
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]
bill.original_currency = self.original_currency
bill.converted_amount = self.currency_helper.exchange_currency(
bill.amount, bill.original_currency, project.default_currency
)

return bill

def fill(self, bill, project):
self.payer.data = bill.payer_id
self.amount.data = bill.amount
Expand Down
100 changes: 90 additions & 10 deletions ihatemoney/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from collections import defaultdict
from datetime import datetime

from dateutil.parser import parse
from debts import settle
from flask import current_app, g
from flask_sqlalchemy import BaseQuery, SQLAlchemy
Expand All @@ -19,6 +20,7 @@

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,
LoggingMode,
Expand Down Expand Up @@ -320,6 +322,42 @@ def switch_currency(self, new_currency):
db.session.add(self)
db.session.commit()

def import_bills(self, bills: list):
"""Import bills from a list of dictionaries"""
# Add members not already in the project
members_project = [str(m) for m in self.members]
members_new = [
m for m in get_members(bills) if str(m[0]) not in members_project
]
for m in members_new:
Person(name=m[0], project=self, weight=m[1])
db.session.commit()

# Import bills not already in the project
bills_project = self.get_pretty_bills()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I follow here: this is named bills_project but it seems to contain bills instead, right? If that's the case, we should probably rename the variable here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand. It seems to be the bills of the existing project. If this is the case, we could name it project_bills :-)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok !

id_dict = {m.name: m.id for m in self.members}
for b in bills:
same = False
for b_p in bills_project:
if same_bill(b_p, b):
same = True
break
if not same:
# Create bills
db.session.add(
Bill(
amount=b["amount"],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably should check that the data contains this key before using it, otherwise it may fail.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or we might want to try/catch this entire block and display an error message with the format of the required data.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

import_bills is already called from a try/catch block in web.import_project
but I agree we should add another one in import_bills as a safeguard in case we use the method elsewhere
done !

date=parse(b["date"]),
external_link="",
original_currency=b["currency"],
owers=Person.query.get_by_names(b["owers"], self),
payer_id=id_dict[b["payer_name"]],
project_default_currency=self.default_currency,
what=b["what"],
)
)
db.session.commit()

def remove_member(self, member_id):
"""Remove a member from the project.

Expand Down Expand Up @@ -435,16 +473,17 @@ def create_demo_project():
("Alice", 20, ("Amina", "Alice"), "Beer !"),
("Amina", 50, ("Amina", "Alice", "Georg"), "AMAP"),
)
for (payer, amount, owers, subject) in operations:
bill = Bill()
bill.payer_id = members[payer].id
bill.what = subject
bill.owers = [members[name] for name in owers]
bill.amount = amount
bill.original_currency = "XXX"
bill.converted_amount = amount

db.session.add(bill)
for (payer, amount, owers, what) in operations:
db.session.add(
Bill(
amount=amount,
original_currency=project.default_currency,
owers=[members[name] for name in owers],
payer_id=members[payer].id,
project_default_currency=project.default_currency,
what=what,
)
)

db.session.commit()
return project
Expand All @@ -459,6 +498,13 @@ def get_by_name(self, name, project):
.one_or_none()
)

def get_by_names(self, names, project):
return (
Person.query.filter(Person.name.in_(names))
.filter(Person.project_id == project.id)
.all()
)

def get(self, id, project=None):
if not project:
project = g.project
Expand All @@ -468,6 +514,15 @@ def get(self, id, project=None):
.one_or_none()
)

def get_by_ids(self, ids, project=None):
if not project:
project = g.project
return (
Person.query.filter(Person.id.in_(ids))
.filter(Person.project_id == project.id)
.all()
)

query_class = PersonQuery

# Direct SQLAlchemy-Continuum to track changes to this model
Expand Down Expand Up @@ -561,6 +616,31 @@ def delete(self, project, id):

archive = db.Column(db.Integer, db.ForeignKey("archive.id"))

currency_helper = CurrencyConverter()

def __init__(
self,
amount: float,
date: datetime = None,
external_link: str = "",
original_currency: str = "",
owers: list = [],
payer_id: int = None,
project_default_currency: str = "",
what: str = "",
):
super().__init__()
self.amount = amount
self.date = date
self.external_link = external_link
self.original_currency = original_currency
self.owers = owers
self.payer_id = payer_id
self.what = what
self.converted_amount = self.currency_helper.exchange_currency(
self.amount, self.original_currency, project_default_currency
)

@property
def _to_serialize(self):
return {
Expand Down
21 changes: 4 additions & 17 deletions ihatemoney/templates/edit_project.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<div class="container edit-project">

<h2>{{ _("Edit project") }}</h2>
<form class="form-horizontal" method="post">
<form id="edit-project" class="form-horizontal" method="post">
{{ forms.edit_project(edit_form) }}
</form>

Expand All @@ -38,23 +38,10 @@ <h2>{{ _("Delete project") }}</h2>
{{ forms.delete_project(delete_form) }}
</form>

<h2>{{ _("Import JSON") }}</h2>
<form class="form-horizontal" method="post" enctype="multipart/form-data">
{{ import_form.hidden_tag() }}

<div class="custom-file">
<div class="form-group">
{{ import_form.file(class="custom-file-input") }}
<small class="form-text text-muted">
{{ import_form.file.description }}
</small>
</div>
<label class="custom-file-label" for="customFile">{{ _('Choose file') }}</label>
</div>

<div class="actions">
{{ import_form.submit(class="btn btn-primary") }}
</div>
<h2>{{ _("Import project") }}</h2>
<form id="import-project" class="form-horizontal" action="{{ url_for(".import_project") }}" method="post" enctype="multipart/form-data">
{{ forms.import_project(import_form) }}
</form>

<h2>{{ _("Download project's data") }}</h2>
Expand Down
20 changes: 17 additions & 3 deletions ihatemoney/templates/forms.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,27 @@

{% endmacro %}

{% macro upload_json(form) %}
{% macro import_project(form) %}

{% include "display_errors.html" %}
{{ form.hidden_tag() }}
{{ form.file }}

<p><strong>{{ _("Import previously exported project") }}</strong></p>

<div class="custom-file">
<div class="form-group">
{{ form.file(class="custom-file-input", accept=".json,.csv") }}
<small class="form-text text-muted">
{{ form.file.description }}
</small>
</div>
<label class="custom-file-label" for="customFile">{{ _('Choose file') }}</label>
</div>

<div class="actions">
<button class="btn btn-primary">{{ _("Import") }}</button>
<button class="btn btn-primary">{{ _("Import project") }}</button>
</div>

{% endmacro %}

{% macro delete_project_history(form) %}
Expand Down