diff --git a/ihatemoney/messages.pot b/ihatemoney/messages.pot index 18782fed..58c3b727 100644 --- a/ihatemoney/messages.pot +++ b/ihatemoney/messages.pot @@ -204,6 +204,9 @@ msgstr "" msgid "The bill has been deleted" msgstr "" +msgid "UNDO" +msgstr "" + msgid "The bill has been modified" msgstr "" diff --git a/ihatemoney/templates/list_bills_post_delete.html b/ihatemoney/templates/list_bills_post_delete.html new file mode 100644 index 00000000..66b0dd20 --- /dev/null +++ b/ihatemoney/templates/list_bills_post_delete.html @@ -0,0 +1,185 @@ +{% extends "sidebar_table_layout.html" %} + +{% block title %} - {{ g.project.name }}{% endblock %} +{% block js %} + {% if add_bill %} $('#new-bill > a').click(); {% endif %} + + // ask for confirmation before removing an user + $('.action.delete').each(function(){ + var link = $(this).find('button'); + link.click(function(){ + if ($(this).hasClass("confirm")){ + return true; + } + $(this).html("{{_("you sure?")}}"); + $(this).addClass("confirm"); + return false; + }); + }); + + var highlight_owers = function(){ + var ower_ids = $(this).attr("owers").split(','); + var payer_id = $(this).attr("payer"); + $.each(ower_ids, function(i, val){ + $('#bal-member-'+val).addClass("ower_line"); + }); + $("#bal-member-"+payer_id).addClass("payer_line"); + }; + + var unhighlight_owers = function(){ + $('[id^="bal-member-"]').removeClass("ower_line payer_line"); + }; + + $('#bill_table tbody tr').hover(highlight_owers, unhighlight_owers); + +{% endblock %} + +{% block sidebar %} + +
+ + {{ static_include("images/paper-plane.svg") | safe }} + {{ _("Invite people") }} + +
+{% endblock %} + +{% block content %} + + + {{ _("Undo delete") }} + + + + + {{ static_include("images/plus.svg") | safe }} + {{ _("Add a new bill") }} + + + + +{% if bills.pages > 1 %} + + « {{ _("Newer bills") }} + + + + {{ _("Older bills") }} » + +{% endif %} + + {% if bills.total > 0 %} +
+ + + + + {% for bill in bills.items %} + + + + + + + + + {% endfor %} + +
{{ _("When?") }}{{ _("Who paid?") }}{{ _("For what?") }}{{ _("For whom?") }}{{ _("How much?") }}{{ _("Actions") }}
+ + {{ bill.date }} + + {{ bill.payer }}{{ bill.what }}{% if bill.owers|length == g.project.members|length -%} + {{ _("Everyone") }} + {%- elif bill.owers|length > g.project.members|length / 2 + 1 -%} + {{ _("Everyone but %(excluded)s", excluded=g.project.members|reject('in', bill.owers)|join(', ', 'name')) }} + {%- else -%} + {{ bill.owers|join(', ', 'name') }} + {%- endif %}{{ "%0.2f"|format(bill.amount) }} ({{ "%0.2f"|format(bill.pay_each()) }} {{ _("each") }}) + {{ _('edit') }} + {{ _('delete') }} + {% if bill.external_link %} + {{ _('see') }} + {% endif %} +
+ +{% if bills.pages > 1 %} + +{% endif %} + + {% else %} +
+
+
+ {{ static_include("images/hand-holding-heart.svg") | safe }} +

{{ _('No bills')}}

+

+ {{ _("Nothing to list yet.")}}
+ {{ _("You probably want to") }} + {%- if g.project.members %} + {{- _("add a bill") -}} + ? + {% else %} + {{- _('add participants') -}} + ? + {%- endif -%} +

+
+
+ {% endif %} +{% endblock %} diff --git a/ihatemoney/tests/tests.py b/ihatemoney/tests/tests.py index b27fafcc..eddfe0ca 100644 --- a/ihatemoney/tests/tests.py +++ b/ihatemoney/tests/tests.py @@ -673,6 +673,7 @@ class BudgetTestCase(IhatemoneyTestCase): balance = models.Project.query.get("raclette").balance self.assertEqual(set(balance.values()), set([19.0, -19.0])) + # Bill with negative amount self.client.post( "/raclette/add", @@ -787,6 +788,61 @@ class BudgetTestCase(IhatemoneyTestCase): self.client.post("/raclette/members/add", data={"name": "fred"}) self.client.post("/raclette/members/add", data={"name": "tata"}) + # create bills, then delete and undo + self.client.post( + "/raclette/add", + data={ + "date": "2020-04-22", + "what": "Glover Apts. Rent April", + "payer": 1, + "payed_for": [1, 2, 3], + "amount": "852.00", + }, + ) + + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([568.00, -284.00, -284.00])) + + self.client.post( + "/raclette/add", + data={ + "data": "2020-05-22", + "what": "Glover Apts. Rent May", + "payer": 2, + "payed_for": [1, 2, 3], + "amount": "852.00", + }, + ) + + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([284.00, 284.00, -568.00])) + + bill = models.Bill.query.one() + self.assertEqual(bill.amount, 852.00) + + # delete the first bill + self.client.get("/raclette/delete/%s" % bill.id) + self.assertEqual(1, len(models.Bill.query.all()), "bill deletion") + + # check balance + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([-284.00, 568.00, -284.00])) + + # undo delete + self.client.get("/raclette/undo") + + # recheck balance + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([284.00, 284.00, -568.00])) + + # delete both bills + bill = models.Bill.query.one() + self.client.get("/raclette/delete/%s" % bill.id) + bill = models.Bill.query.one() + self.client.get("/raclette/delete/%s" % bill.id) + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([0, 0, 0])) + # create bills self.client.post( "/raclette/add", diff --git a/ihatemoney/web.py b/ihatemoney/web.py index a12eae19..bdcbecb4 100644 --- a/ihatemoney/web.py +++ b/ihatemoney/web.py @@ -12,6 +12,7 @@ from datetime import datetime from functools import wraps import json import os +import copy from smtplib import SMTPRecipientsRefused from dateutil.parser import parse @@ -64,6 +65,8 @@ main = Blueprint("main", __name__) login_throttler = LoginThrottler(max_attempts=3, delay=1) +ghost_billform = None + def requires_admin(bypass=None): """Require admin permissions for @requires_admin decorated endpoints. @@ -213,8 +216,8 @@ def authenticate(project_id=None): project = Project.query.get(project_id) if not project: - # If the user try to connect to an unexisting project, we will - # propose him a link to the creation form. + # If the user tries to connect to an unexisting project, we will + # provide them with a link to the creation form. return render_template( "authenticate.html", form=form, create_project=project_id ) @@ -620,7 +623,7 @@ def add_member(): if form.validate(): member = form.save(g.project, Person()) db.session.commit() - flash(_("%(member)s had been added", member=member.name)) + flash(_("%(member)s has been added", member=member.name)) return redirect(url_for(".list_bills")) return render_template("add_member.html", form=form) @@ -699,17 +702,62 @@ def add_bill(): return render_template("add_bill.html", form=form) -@main.route("//delete/") +@main.route("//delete/", methods=["GET", "POST"]) def delete_bill(bill_id): + global ghost_billform + ghost_billform = get_billform_for(g.project) # fixme: everyone is able to delete a bill bill = Bill.query.get(g.project, bill_id) if not bill: return redirect(url_for(".list_bills")) + # save the deleted bill, so that it can be restored if the + # user chooses to undo this action + ghost_billform.fill(bill) + db.session.delete(bill) db.session.commit() flash(_("The bill has been deleted")) + return redirect(url_for(".post_delete")) + + +@main.route("//post_delete", methods=["GET", "POST"]) +def post_delete(): + # this functions identically to list_bills, however + # the undo action button is added + bill_form = get_billform_for(g.project) + # set the last selected payer as default choice if exists + if "last_selected_payer" in session: + bill_form.payer.data = session["last_selected_payer"] + # Preload the "owers" relationship for all bills + bills = ( + g.project.get_bills() + .options(orm.subqueryload(Bill.owers)) + .paginate(per_page=100, error_out=True) + ) + + return render_template( + "list_bills_post_delete.html", + bills=bills, + member_form=MemberForm(g.project), + bill_form=bill_form, + add_bill=request.values.get("add_bill", False), + current_view="list_bills_post_delete", + ) + + +@main.route("//undo", methods=["GET", "POST"]) +def undo_delete_bill(): + global ghost_billform + args = {} + + bill = Bill() + db.session.add(ghost_billform.save(bill, g.project)) + db.session.commit() + + flash(_("Restored bill")) + return redirect(url_for(".list_bills"))