From 2b071a1a3bc752bfaa0fd6c0b2d8f8460721d6d8 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 10:33:43 +0200 Subject: [PATCH 01/10] Add members weight in models and budget backend refs #94 --- budget/forms.py | 2 ++ budget/migrations/versions/26d6a218c329_.py | 26 ++++++++++++++++++ budget/models.py | 9 ++++--- budget/tests.py | 30 +++++++++++++++++++++ 4 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 budget/migrations/versions/26d6a218c329_.py diff --git a/budget/forms.py b/budget/forms.py index 2dde57d..918e82a 100644 --- a/budget/forms.py +++ b/budget/forms.py @@ -152,6 +152,7 @@ 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): @@ -170,6 +171,7 @@ class MemberForm(Form): # 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 diff --git a/budget/migrations/versions/26d6a218c329_.py b/budget/migrations/versions/26d6a218c329_.py new file mode 100644 index 0000000..859b9af --- /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/models.py b/budget/models.py index 727200f..16cc6c1 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] @@ -159,6 +160,7 @@ class Person(db.Model): 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): @@ -219,7 +221,8 @@ class Bill(db.Model): def pay_each(self): """Compute what each person 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/tests.py b/budget/tests.py index 760ffc0..021b425 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -416,6 +416,36 @@ 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_rounding(self): self.post_project("raclette") From 06f10d050814e026ebac3ddedd2bec2d5d616eca Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 11:11:09 +0200 Subject: [PATCH 02/10] Added member weights support to API --- budget/models.py | 4 +-- budget/tests.py | 72 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/budget/models.py b/budget/models.py index 16cc6c1..afd29f1 100644 --- a/budget/models.py +++ b/budget/models.py @@ -153,7 +153,7 @@ 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")) @@ -219,7 +219,7 @@ 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: # FIXME: SQL might dot that more efficiently return self.amount / sum(i.weight for i in self.owers) diff --git a/budget/tests.py b/budget/tests.py index 021b425..f4dfcce 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -583,9 +583,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 @@ -792,8 +793,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} @@ -835,8 +836,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} @@ -853,6 +854,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): From b57df5cd368eca946e5699237a954d37fb342b07 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 13:44:50 +0200 Subject: [PATCH 03/10] UI for showing user weights in user list --- budget/static/css/main.css | 4 ++++ budget/templates/list_bills.html | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 97a3e19..2b74dc0 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -189,6 +189,10 @@ tr.payer_line .balance-name{ position: absolute; } +.light { + opacity: 0.3; +} + .modal-body { max-height:455px; } diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 899fdeb..b0ec89a 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -63,7 +63,7 @@ {% 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 }}) {% if member.activated %}
From 85abc0b1fcbee6549425fad87b3cdd55669672d2 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Thu, 20 Aug 2015 13:45:53 +0200 Subject: [PATCH 04/10] Added a template filter not to show zero decimals on user weights --- budget/run.py | 5 +++++ budget/templates/list_bills.html | 2 +- budget/utils.py | 14 ++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/budget/run.py b/budget/run.py index 51670f2..807ad12 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/templates/list_bills.html b/budget/templates/list_bills.html index b0ec89a..1d8e922 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -63,7 +63,7 @@ {% set balance = g.project.balance %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} - {{ member.name }} (x{{ member.weight }}) + {{ member.name }} (x{{ member.weight|minimal_round(1) }}) {% if member.activated %} diff --git a/budget/utils.py b/budget/utils.py index 7717aaa..c849af0 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) From 1147f2ece8eaa868f5f0b89da583d03560ec23ee Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sat, 22 Aug 2015 10:49:15 +0200 Subject: [PATCH 05/10] Ask for confirmation only for deleting users --- budget/templates/list_bills.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 1d8e922..2702d99 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")){ From 64c2cd56dfb58ef78936d39b2e71640cd631653e Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sat, 22 Aug 2015 10:49:35 +0200 Subject: [PATCH 06/10] display an edit button on members list --- budget/static/css/main.css | 5 +++++ budget/templates/list_bills.html | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 2b74dc0..143ce8b 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; diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 2702d99..9bad344 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -71,7 +71,10 @@ {% if member.activated %} - + +
+
+ {% else %}
From ec8fe2326b209d580922876e9b1d32cbe98646b7 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sat, 22 Aug 2015 01:19:27 +0200 Subject: [PATCH 07/10] Added member edit form --- budget/forms.py | 14 ++++++++++---- budget/static/css/main.css | 1 - budget/templates/edit_member.html | 17 +++++++++++++++++ budget/templates/forms.html | 14 ++++++++++++++ budget/templates/list_bills.html | 2 +- budget/web.py | 18 ++++++++++++++++++ 6 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 budget/templates/edit_member.html diff --git a/budget/forms.py b/budget/forms.py index 918e82a..7d6eb51 100644 --- a/budget/forms.py +++ b/budget/forms.py @@ -155,16 +155,18 @@ class MemberForm(Form): 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): @@ -175,6 +177,10 @@ class MemberForm(Form): 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/static/css/main.css b/budget/static/css/main.css index 143ce8b..55dad99 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -217,4 +217,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 0000000..5f097f9 --- /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 ec73515..07e5b3d 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 9bad344..0e0efd8 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -72,7 +72,7 @@
-
+
{% else %} diff --git a/budget/web.py b/budget/web.py index 77de026..63fbe4d 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) From 7a630b78eae90a57033f785a2253fd490868352d Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Sun, 23 Aug 2015 11:58:27 +0200 Subject: [PATCH 08/10] Hide the member weights in members list if all weights are "1". --- budget/models.py | 4 ++++ budget/static/css/main.css | 8 ++++++++ budget/templates/list_bills.html | 4 +++- budget/tests.py | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/budget/models.py b/budget/models.py index afd29f1..852b3e1 100644 --- a/budget/models.py +++ b/budget/models.py @@ -50,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 diff --git a/budget/static/css/main.css b/budget/static/css/main.css index 55dad99..f3fe8a0 100644 --- a/budget/static/css/main.css +++ b/budget/static/css/main.css @@ -198,6 +198,14 @@ tr.payer_line .balance-name{ opacity: 0.3; } +.extra-info { + display: none; +} + +tr:hover .extra-info { + display: inline; +} + .modal-body { max-height:455px; } diff --git a/budget/templates/list_bills.html b/budget/templates/list_bills.html index 0e0efd8..f081334 100644 --- a/budget/templates/list_bills.html +++ b/budget/templates/list_bills.html @@ -67,7 +67,9 @@ {% set balance = g.project.balance %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} - {{ member.name }} (x{{ member.weight|minimal_round(1) }}) + {{ member.name }} + (x{{ member.weight|minimal_round(1) }}) + {% if member.activated %}
diff --git a/budget/tests.py b/budget/tests.py index f4dfcce..82465f9 100644 --- a/budget/tests.py +++ b/budget/tests.py @@ -446,6 +446,22 @@ class BudgetTestCase(TestCase): 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") From d3bb04c1bfdd24cc09db939ac9bbaf540bfdfc0c Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Wed, 15 Jun 2016 10:19:08 +0200 Subject: [PATCH 09/10] Add migration to initialize Person weights That's for Persons that existed before the weights were added to model. --- ...ab0_initialize_all_members_weights_to_1.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 budget/migrations/versions/f629c8ef4ab0_initialize_all_members_weights_to_1.py 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 0000000..5542146 --- /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 From c49a355eb082cff208806f785d52567ddd043c03 Mon Sep 17 00:00:00 2001 From: Jocelyn Delande Date: Wed, 15 Jun 2016 10:30:38 +0200 Subject: [PATCH 10/10] Update translation --- .../translations/fr/LC_MESSAGES/messages.mo | Bin 7894 -> 8551 bytes .../translations/fr/LC_MESSAGES/messages.po | 5 ++++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/budget/translations/fr/LC_MESSAGES/messages.mo b/budget/translations/fr/LC_MESSAGES/messages.mo index 59e62621a2bebac6b42b0d4848dbcf00b6a81076..558d835fc42ff0e74905f1fa174c87e0d574fcc9 100644 GIT binary patch delta 2545 zcmZA1eN5F=9LMo57p`1nc_=|mO%xO>k^)T#E@@QPhcV}VW3_Z_ zO>6nUW=j`yEA<~yb2+ChUFKS?X^iHqS z4prT#@xD(@*lm;uF@-oDZ}vXcCUT*iOESyAK`h3rn2X7y&El{GQ!s@3$s(i+t3y84 zz$F=*aSU$22e2FcW}fv@d60%(s0sArC>+EIcoE0rHB7}jP9MD`@O>hx!%Wmb1*qrC zkpZk01Go~IyEQr6a1`TP7nP~p*z9iX!8E=fz;XBqPQweBi8oObj2~+@6Gx*aQj9?i zA&*!iGFNNGeC$GHst+~6k8lj*+iBPEIcmn=p;q(*^0B*Il-hq#8SpbJwNFAlmxIb& zDQdt5)C8JQ&vl}>U!%4nLLSx8cGQFK;w0?DbUcRY@DeH` zKVcsJi$Tm|nYzCOZ{j+v!CGd!33sCU@sB6}xm2>qM-DDPt)vsRXWLOL+=ZIhK~yGA z;0!#2LA>tn$CDndI2|?d8K{gFIEzqcAmpr0CI6K)ETy3wcc5nW8R|@2MUrBF;VevI z7RpQrm600E!X{M5-Kc@zK%I$R=T2mf_MW?c2(^%-9u+=zf{Qx-0=0rGNH*<9RL6Hw zhwxALJ&TQeGmZm@n&^>koLLz`;m{G<&vcLf0)W_8m^;O zlE%^1jPp?sRv=^8D%6D5Ba5~O>TL9)GO`b~!UL}TB&whDsEH1t7J37P=*wAWfyGqI9TVTYn+5m%j= z`9v8}MXVxTB%UNx^fsuxN_Z7qw3k7GEJj})umW8u3yC$VxS|uS@&eI7v=Pc&3-KH= zlTgwAmPc#d85J9WLoya>^C du|42#3%7S{9QYt*c&xv^E8H3x*gR#8??1=e$Swc? delta 1884 zcmYM!ZD?0j90%~Dvo+g;x>}`9H%HD^+Ji>RP{BE*FrhakVN}$jKr0DCdT@V+;fn;N zvLdL^9!X}TBkPq2BFv)f#jqH(@{KKoiXJ2gvCkJT^ymKX=iYPA|D50Nch3F)+;?ha zZK8GBYsKej-*(^Oy4?N$S#fD8v)!)bQf^>3U*Z%VRnwS7OS&;)zy6EuWkLp3IURSzrXET?vi>o<@15AK5F5(U*kWbjj5hl|yrUE~)hi92e zwP{R%3)yJ?(pxpGVIsbV$!H^+xS6U|o@FX9RJHG6`t4&XcZiO1(kTIqGrvE>Ea9I_ zMVmcL0$r>vaM9!9J+5Px;y3DB>ijw#TX;3wcolD823*TjHbId zaa1{a>JMyXS;WZ1+6Hjk;q&Af)o*~tg0=QWzEyvkH^ zZ{>$n{W4s6m@C{LouU4>yEv~Q5#46C?1j6Twco-e9Aqjo%v9oY&f~Ytz`rt;`iI#I z7b>S&=&bd0r#zp_B+$hqbY0Cw242NPekUbg?qvpkl-YbwRrjy(#VMt{#RR&*D2ut8 zsoayC%AL$!*~Lt_ukvF$%0VYFmfG>^!6~+Bm|#l(7jNWwrUFasoD8&)slX%51TQcH z?XKL*RNx@RSPnDej#l>*%+mbM?7wpE;zgONS1QrMlxjAUc_&keHJs1&%!Hen%(pT9 z_R}hsubIGqVE%}jy~}w%iwXP|X1w)WmiK>yi)6BsiTE9+^G$QT<6OsXKF4If zk6EHGn9PpR_9$aa=9A3-z&Yl39inhq*3v4MhnY%lWfB-{SO5HQhlUKehkg8jt2oI7 zv`AVRxQ{92CNAYxCV>5vOgX}2{5`YA_0o