diff --git a/budget/forms.py b/budget/forms.py index 2dde57da..7d6eb51e 100644 --- a/budget/forms.py +++ b/budget/forms.py @@ -152,27 +152,35 @@ class BillForm(Form): class MemberForm(Form): name = TextField(_("Name"), validators=[Required()]) + weight = CommaDecimalField(_("Weight"), default=1) submit = SubmitField(_("Add")) - def __init__(self, project, *args, **kwargs): + def __init__(self, project, edit=False, *args, **kwargs): super(MemberForm, self).__init__(*args, **kwargs) self.project = project + self.edit = edit def validate_name(form, field): if field.data == form.name.default: raise ValidationError(_("User name incorrect")) - if Person.query.filter(Person.name == field.data)\ - .filter(Person.project == form.project)\ - .filter(Person.activated == True).all(): + if (not form.edit and Person.query.filter( + Person.name == field.data, + Person.project == form.project, + Person.activated == True).all()): raise ValidationError(_("This project already have this member")) def save(self, project, person): # if the user is already bound to the project, just reactivate him person.name = self.name.data person.project = project + person.weight = self.weight.data return person + def fill(self, member): + self.name.data = member.name + self.weight.data = member.weight + class InviteForm(Form): emails = TextAreaField(_("People to notify")) diff --git a/budget/migrations/versions/26d6a218c329_.py b/budget/migrations/versions/26d6a218c329_.py new file mode 100644 index 00000000..859b9af0 --- /dev/null +++ b/budget/migrations/versions/26d6a218c329_.py @@ -0,0 +1,26 @@ +"""Add Person.weight column + +Revision ID: 26d6a218c329 +Revises: b9a10d5d63ce +Create Date: 2016-06-15 09:22:04.069447 + +""" + +# revision identifiers, used by Alembic. +revision = '26d6a218c329' +down_revision = 'b9a10d5d63ce' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('person', sa.Column('weight', sa.Float(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('person', 'weight') + ### end Alembic commands ### diff --git a/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py b/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py new file mode 100644 index 00000000..5542146c --- /dev/null +++ b/budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py @@ -0,0 +1,39 @@ +"""Initialize all members weights to 1 + +Revision ID: f629c8ef4ab0 +Revises: 26d6a218c329 +Create Date: 2016-06-15 09:40:30.400862 + +""" + +# revision identifiers, used by Alembic. +revision = 'f629c8ef4ab0' +down_revision = '26d6a218c329' + +from alembic import op +import sqlalchemy as sa + +# Snapshot of the person table +person_helper = sa.Table( + 'person', sa.MetaData(), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('project_id', sa.String(length=64), nullable=True), + sa.Column('name', sa.UnicodeText(), nullable=True), + sa.Column('activated', sa.Boolean(), nullable=True), + sa.Column('weight', sa.Float(), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ), + sa.PrimaryKeyConstraint('id') +) + + +def upgrade(): + op.execute( + person_helper.update() + .where(person_helper.c.weight == None) + .values(weight=1) + ) + + +def downgrade(): + # Downgrade path is not possible, because information has been lost. + pass diff --git a/budget/models.py b/budget/models.py index 727200f1..852b3e19 100644 --- a/budget/models.py +++ b/budget/models.py @@ -40,8 +40,9 @@ class Project(db.Model): bills = Bill.query.filter(Bill.owers.contains(person)) for bill in bills.all(): if person != bill.payer: - should_pay[person] += bill.pay_each() - should_receive[bill.payer] += bill.pay_each() + share = bill.pay_each() * person.weight + should_pay[person] += share + should_receive[bill.payer] += share for person in self.members: balance = should_receive[person] - should_pay[person] @@ -49,6 +50,10 @@ class Project(db.Model): return balances + @property + def uses_weights(self): + return len([i for i in self.members if i.weight != 1]) > 0 + def get_transactions_to_settle_bill(self): """Return a list of transactions that could be made to settle the bill""" #cache value for better performance @@ -152,13 +157,14 @@ class Person(db.Model): query_class = PersonQuery - _to_serialize = ("id", "name", "activated") + _to_serialize = ("id", "name", "weight", "activated") id = db.Column(db.Integer, primary_key=True) project_id = db.Column(db.String(64), db.ForeignKey("project.id")) bills = db.relationship("Bill", backref="payer") name = db.Column(db.UnicodeText) + weight = db.Column(db.Float, default=1) activated = db.Column(db.Boolean, default=True) def has_bills(self): @@ -217,9 +223,10 @@ class Bill(db.Model): archive = db.Column(db.Integer, db.ForeignKey("archive.id")) def pay_each(self): - """Compute what each person has to pay""" + """Compute what each share has to pay""" if self.owers: - return self.amount / len(self.owers) + # FIXME: SQL might dot that more efficiently + return self.amount / sum(i.weight for i in self.owers) else: return 0 diff --git a/budget/run.py b/budget/run.py index 51670f2b..807ad12a 100644 --- a/budget/run.py +++ b/budget/run.py @@ -9,6 +9,8 @@ from raven.contrib.flask import Sentry from web import main, db, mail from api import api from utils import PrefixedWSGI +from utils import minimal_round + app = Flask(__name__) @@ -37,6 +39,9 @@ configure() app.register_blueprint(main) app.register_blueprint(api) +# custom jinja2 filters +app.jinja_env.filters['minimal_round'] = minimal_round + # db db.init_app(app) db.app = app diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 97a3e195..f3fe8a0b 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -176,6 +176,11 @@ tr.payer_line .balance-name{ color: red; } +.edit button, .edit button:hover { + background: url('../images/edit.png') left no-repeat; + +} + .reactivate button, .reactivate button:hover { background: url('../images/reactivate.png') left no-repeat; color: white; @@ -189,6 +194,18 @@ tr.payer_line .balance-name{ position: absolute; } +.light { + opacity: 0.3; +} + +.extra-info { + display: none; +} + +tr:hover .extra-info { + display: inline; +} + .modal-body { max-height:455px; } @@ -208,4 +225,3 @@ tr.payer_line .balance-name{ .row-fluid > .offset3{margin-left:25.5%;} .row-fluid > .offset2{margin-left:17%;} .row-fluid > .offset1{margin-left:8.5%;} - diff --git a/budget/templates/edit_member.html b/budget/templates/edit_member.html new file mode 100644 index 00000000..5f097f9f --- /dev/null +++ b/budget/templates/edit_member.html @@ -0,0 +1,17 @@ +{% extends "layout.html" %} + +{% block js %} + $('#cancel-form').click(function(){location.href={{ url_for(".list_bills") }};}); +{% endblock %} + + +{% block top_menu %} +{{ _("Back to the list") }} +{% endblock %} + +{% block content %} + +
+ {{ forms.edit_member(form, edit) }} +
+{% endblock %} diff --git a/budget/templates/forms.html b/budget/templates/forms.html index ec735159..07e5b3d2 100644 --- a/budget/templates/forms.html +++ b/budget/templates/forms.html @@ -95,6 +95,20 @@ {{ form.name(placeholder=_("Type user name here")) }} {% endmacro %} +{% macro edit_member(form, title=True) %} +
+ {% if title %}{{ _("Edit this member") }}{% endif %} + {% include "display_errors.html" %} + {{ form.hidden_tag() }} + {{ input(form.name) }} + {{ input(form.weight) }} +
+
+ {{ form.submit(class="btn btn-primary") }} +
+{% endmacro %} + + {% macro invites(form) %} {{ form.hidden_tag() }} {{ input(form.emails) }} diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 899fdeba..f0813347 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -13,9 +13,13 @@ {% if add_bill %} $('#new-bill').click(); {% endif %} - // ask for confirmation before removing an user + // Hide all members actions $('.action').each(function(){ $(this).hide(); + }); + + // ask for confirmation before removing an user + $('.action.delete').each(function(){ var link = $(this).find('button'); link.click(function(){ if ($(this).hasClass("confirm")){ @@ -63,11 +67,16 @@ {% set balance = g.project.balance %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} - {{ member.name }} + {{ member.name }} + (x{{ member.weight|minimal_round(1) }}) + {% if member.activated %}
-
+ +
+
+ {% else %}
diff --git a/budget/tests.py b/budget/tests.py index 760ffc0b..82465f99 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -416,6 +416,52 @@ class BudgetTestCase(TestCase): bill = models.Bill.query.filter(models.Bill.date == '2011-08-01')[0] self.assertEqual(bill.amount, 25.02) + def test_weighted_balance(self): + self.post_project("raclette") + + # add two persons + self.app.post("/raclette/members/add", data={'name': 'alexis'}) + self.app.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) + + members_ids = [m.id for m in + models.Project.query.get("raclette").members] + + # test balance + self.app.post("/raclette/add", data={ + 'date': '2011-08-10', + 'what': u'fromage à raclette', + 'payer': members_ids[0], + 'payed_for': members_ids, + 'amount': '10', + }) + + self.app.post("/raclette/add", data={ + 'date': '2011-08-10', + 'what': u'pommes de terre', + 'payer': members_ids[1], + 'payed_for': members_ids, + 'amount': '10', + }) + + balance = models.Project.query.get("raclette").balance + self.assertEqual(set(balance.values()), set([6, -6])) + + def test_weighted_members_list(self): + self.post_project("raclette") + + # add two persons + self.app.post("/raclette/members/add", data={'name': 'alexis'}) + self.app.post("/raclette/members/add", data={'name': 'tata', 'weight': 1}) + + resp = self.app.get("/raclette/") + self.assertIn('extra-info', resp.data) + + self.app.post("/raclette/members/add", data={'name': 'freddy familly', 'weight': 4}) + + resp = self.app.get("/raclette/") + self.assertNotIn('extra-info', resp.data) + + def test_rounding(self): self.post_project("raclette") @@ -553,9 +599,10 @@ class APITestCase(TestCase): 'contact_email': contact }) - def api_add_member(self, project, name): + def api_add_member(self, project, name, weight=1): self.app.post("/api/projects/%s/members" % project, - data={"name": name}, headers=self.get_auth(project)) + data={"name": name, "weight": weight}, + headers=self.get_auth(project)) def get_auth(self, username, password=None): password = password or username @@ -762,8 +809,8 @@ class APITestCase(TestCase): "what": "fromage", "payer_id": 1, "owers": [ - {"activated": True, "id": 1, "name": "alexis"}, - {"activated": True, "id": 2, "name": "fred"}], + {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 2, "name": "fred", "weight": 1}], "amount": 25.0, "date": "2011-08-10", "id": 1} @@ -805,8 +852,8 @@ class APITestCase(TestCase): "what": "beer", "payer_id": 2, "owers": [ - {"activated": True, "id": 1, "name": "alexis"}, - {"activated": True, "id": 2, "name": "fred"}], + {"activated": True, "id": 1, "name": "alexis", "weight": 1}, + {"activated": True, "id": 2, "name": "fred", "weight": 1}], "amount": 25.0, "date": "2011-09-10", "id": 1} @@ -823,6 +870,65 @@ class APITestCase(TestCase): headers=self.get_auth("raclette")) self.assertStatus(404, req) + def test_weighted_bills(self): + # create a project + self.api_create("raclette") + + # add members + self.api_add_member("raclette", "alexis") + self.api_add_member("raclette", "freddy familly", 4) + self.api_add_member("raclette", "arnaud") + + # add a bill + req = self.app.post("/api/projects/raclette/bills", data={ + 'date': '2011-08-10', + 'what': "fromage", + 'payer': "1", + 'payed_for': ["1", "2"], + 'amount': '25', + }, headers=self.get_auth("raclette")) + + # get this bill details + req = self.app.get("/api/projects/raclette/bills/1", + 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": "freddy familly", "weight": 4}], + "amount": 25.0, + "date": "2011-08-10", + "id": 1} + self.assertDictEqual(expected, json.loads(req.data)) + + # getting it should return a 404 + req = self.app.get("/api/projects/raclette", + headers=self.get_auth("raclette")) + + expected = { + "active_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} + ], + "balance": {"1": 20.0, "2": -20.0, "3": 0}, + "contact_email": "raclette@notmyidea.org", + "id": "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", + "password": "raclette"} + + self.assertStatus(200, req) + self.assertEqual(expected, json.loads(req.data)) class ServerTestCase(APITestCase): def setUp(self): diff --git a/budget/translations/fr/LC_MESSAGES/messages.mo b/budget/translations/fr/LC_MESSAGES/messages.mo index 59e62621..558d835f 100644 Binary files a/budget/translations/fr/LC_MESSAGES/messages.mo and b/budget/translations/fr/LC_MESSAGES/messages.mo differ diff --git a/budget/translations/fr/LC_MESSAGES/messages.po b/budget/translations/fr/LC_MESSAGES/messages.po index 776e6e21..d915cb38 100644 --- a/budget/translations/fr/LC_MESSAGES/messages.po +++ b/budget/translations/fr/LC_MESSAGES/messages.po @@ -105,6 +105,10 @@ msgstr "Le montant d'une facture ne peut pas être nul." msgid "Name" msgstr "Nom" +#: forms.py:155 +msgid "Weight" +msgstr "Poids" + #: forms.py:155 templates/forms.html:95 msgid "Add" msgstr "Ajouter" @@ -497,4 +501,3 @@ msgstr "Qui doit payer ?" #: templates/settle_bills.html:31 msgid "To whom?" msgstr "Pour qui ?" - diff --git a/budget/utils.py b/budget/utils.py index 7717aaaf..c849af0c 100644 --- a/budget/utils.py +++ b/budget/utils.py @@ -1,6 +1,7 @@ import re import inspect +from jinja2 import filters from flask import redirect from werkzeug.routing import HTTPException, RoutingException @@ -63,3 +64,16 @@ class PrefixedWSGI(object): if scheme: environ['wsgi.url_scheme'] = scheme return self.wsgi_app(environ, start_response) + + +def minimal_round(*args, **kw): + """ Jinja2 filter: rounds, but display only non-zero decimals + + from http://stackoverflow.com/questions/28458524/ + """ + # Use the original round filter, to deal with the extra arguments + res = filters.do_round(*args, **kw) + # Test if the result is equivalent to an integer and + # return depending on it + ires = int(res) + return (res if res != ires else ires) diff --git a/budget/web.py b/budget/web.py index 77de026a..63fbe4d5 100644 --- a/budget/web.py +++ b/budget/web.py @@ -322,6 +322,24 @@ def remove_member(member_id): return redirect(url_for(".list_bills")) +@main.route("//members//edit", + methods=["POST", "GET"]) +def edit_member(member_id): + member = Person.query.get(member_id, g.project) + if not member: + raise werkzeug.exceptions.NotFound() + form = MemberForm(g.project, edit=True) + + if request.method == 'POST' and form.validate(): + form.save(g.project, member) + db.session.commit() + flash(_("User '%(name)s' has been edited", name=member.name)) + return redirect(url_for(".list_bills")) + + form.fill(member) + return render_template("edit_member.html", form=form, edit=True) + + @main.route("//add", methods=["GET", "POST"]) def add_bill(): form = get_billform_for(g.project)