diff --git a/docs/api.md b/docs/api.md index a4b91948..9c94839b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -242,3 +242,23 @@ You can get some project stats with a `GET` on "balance": -10.5 } ] + +### Currencies + +You can get a list of supported currencies with a `GET` on +`/api/currencies`: + + $ curl --basic https://ihatemoney.org/api/currencies + [ + "XXX", + "AED", + "AFN", + . + . + . + "ZAR", + "ZMW", + "ZWL" + ] + + diff --git a/ihatemoney/api/common.py b/ihatemoney/api/common.py index bc35ac93..44727f5a 100644 --- a/ihatemoney/api/common.py +++ b/ihatemoney/api/common.py @@ -5,6 +5,7 @@ from flask_restful import Resource, abort from werkzeug.security import check_password_hash from wtforms.fields.core import BooleanField +from ihatemoney.currency_convertor import CurrencyConverter from ihatemoney.emails import send_creation_email from ihatemoney.forms import EditProjectForm, MemberForm, ProjectForm, get_billform_for from ihatemoney.models import Bill, Person, Project, db @@ -49,6 +50,13 @@ def need_auth(f): return wrapper +class CurrenciesHandler(Resource): + currency_helper = CurrencyConverter() + + def get(self): + return self.currency_helper.get_currencies() + + class ProjectsHandler(Resource): def post(self): form = ProjectForm(meta={"csrf": False}) @@ -151,8 +159,7 @@ class BillsHandler(Resource): 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 diff --git a/ihatemoney/api/v1/resources.py b/ihatemoney/api/v1/resources.py index dc1708ce..6c46e4f9 100644 --- a/ihatemoney/api/v1/resources.py +++ b/ihatemoney/api/v1/resources.py @@ -5,6 +5,7 @@ from flask_restful import Api from ihatemoney.api.common import ( BillHandler, BillsHandler, + CurrenciesHandler, MemberHandler, MembersHandler, ProjectHandler, @@ -17,6 +18,7 @@ api = Blueprint("api", __name__, url_prefix="/api") CORS(api) restful_api = Api(api) +restful_api.add_resource(CurrenciesHandler, "/currencies") restful_api.add_resource(ProjectsHandler, "/projects") restful_api.add_resource(ProjectHandler, "/projects/") restful_api.add_resource(TokenHandler, "/projects//token") diff --git a/ihatemoney/forms.py b/ihatemoney/forms.py index 4e241c86..fe966778 100644 --- a/ihatemoney/forms.py +++ b/ihatemoney/forms.py @@ -23,7 +23,7 @@ from wtforms.validators import ( ) 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, @@ -182,13 +182,15 @@ class EditProjectForm(FlaskForm): 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): @@ -319,33 +321,31 @@ class BillForm(FlaskForm): submit = SubmitField(_("Submit")) submit2 = SubmitField(_("Submit and add a new one")) + def export(self, project): + return Bill( + amount=float(self.amount.data), + date=self.date.data, + external_link=self.external_link.data, + original_currency=str(self.original_currency.data), + owers=Person.query.get_by_ids(self.payed_for.data, project), + payer_id=self.payer.data, + project_default_currency=project.default_currency, + what=self.what.data, + ) + 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 diff --git a/ihatemoney/models.py b/ihatemoney/models.py index 473e7c0b..7877b410 100644 --- a/ihatemoney/models.py +++ b/ihatemoney/models.py @@ -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 @@ -19,6 +20,7 @@ from werkzeug.security import generate_password_hash 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, @@ -320,6 +322,44 @@ class Project(db.Model): 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 + project_members = [str(m) for m in self.members] + new_members = [ + m for m in get_members(bills) if str(m[0]) not in project_members + ] + for m in new_members: + Person(name=m[0], project=self, weight=m[1]) + db.session.commit() + + # Import bills not already in the project + project_bills = self.get_pretty_bills() + id_dict = {m.name: m.id for m in self.members} + for b in bills: + same = False + for p_b in project_bills: + if same_bill(p_b, b): + same = True + break + if not same: + # Create bills + try: + new_bill = Bill( + amount=b["amount"], + 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"], + ) + except Exception as e: + raise ValueError(f"Unable to import csv data: {repr(e)}") + db.session.add(new_bill) + db.session.commit() + def remove_member(self, member_id): """Remove a member from the project. @@ -435,16 +475,17 @@ class Project(db.Model): ("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 @@ -459,6 +500,13 @@ class Person(db.Model): .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 @@ -468,6 +516,15 @@ class Person(db.Model): .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 @@ -561,6 +618,31 @@ class Bill(db.Model): 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 { diff --git a/ihatemoney/static/css/main.css b/ihatemoney/static/css/main.css index 5964c50a..4c61fbea 100644 --- a/ihatemoney/static/css/main.css +++ b/ihatemoney/static/css/main.css @@ -283,14 +283,6 @@ footer .footer-left { } } -#new-bill { - margin-top: 30px; -} - -#pagination-top { - margin-top: 30px; -} - /* Avoid text color flickering when it loose focus as the modal appears */ .btn-primary[data-toggle="modal"] { color: #fff; @@ -334,6 +326,13 @@ footer .footer-left { flex-basis: auto; } +#bill-toolbar { + position: sticky; + top: 4em; + z-index: 1; + background-color: rgba(255, 255, 255, 0.75); +} + @media (min-width: 768px) { .split_bills, #table_overflow.statistics { diff --git a/ihatemoney/templates/edit_project.html b/ihatemoney/templates/edit_project.html index 7fcc725e..7ea47d9f 100644 --- a/ihatemoney/templates/edit_project.html +++ b/ihatemoney/templates/edit_project.html @@ -29,7 +29,7 @@

{{ _("Edit project") }}

-
+ {{ forms.edit_project(edit_form) }}
@@ -38,23 +38,10 @@ {{ forms.delete_project(delete_form) }} -

{{ _("Import JSON") }}

-
- {{ import_form.hidden_tag() }} -
-
- {{ import_form.file(class="custom-file-input") }} - - {{ import_form.file.description }} - -
- -
- -
- {{ import_form.submit(class="btn btn-primary") }} -
+

{{ _("Import project") }}

+ + {{ forms.import_project(import_form) }}

{{ _("Download project's data") }}

diff --git a/ihatemoney/templates/forms.html b/ihatemoney/templates/forms.html index e6662b76..f93cfc6d 100644 --- a/ihatemoney/templates/forms.html +++ b/ihatemoney/templates/forms.html @@ -118,13 +118,27 @@ {% endmacro %} -{% macro upload_json(form) %} +{% macro import_project(form) %} + {% include "display_errors.html" %} {{ form.hidden_tag() }} - {{ form.file }} -
- + +

{{ _("Import previously exported project") }}

+ +
+
+ {{ form.file(class="custom-file-input", accept=".json,.csv") }} + + {{ form.file.description }} + +
+
+ +
+ +
+ {% endmacro %} {% macro delete_project_history(form) %} diff --git a/ihatemoney/templates/list_bills.html b/ihatemoney/templates/list_bills.html index 5ef22736..dda16f2e 100644 --- a/ihatemoney/templates/list_bills.html +++ b/ihatemoney/templates/list_bills.html @@ -76,9 +76,9 @@
-
+
{% if bills.pages > 1 %} -
    + {% endif %} - + {{ static_include("images/plus.svg") | safe }} {{ _("Add a new bill") }} @@ -146,21 +146,6 @@ {% endfor %} - -{% if bills.pages > 1 %} - -{% endif %} - {% else %}
    diff --git a/ihatemoney/templates/showcase.html b/ihatemoney/templates/showcase.html index df5a8590..ab7055da 100644 --- a/ihatemoney/templates/showcase.html +++ b/ihatemoney/templates/showcase.html @@ -33,7 +33,7 @@