1
0
Fork 0
mirror of https://github.com/YunoHost-Apps/ihatemoney_ynh.git synced 2024-09-03 19:26:15 +02:00

Merge pull request #131 from JocelynDelalande/members-weights

Added Members weights handling
This commit is contained in:
Alexis Metaireau 2016-06-17 10:06:19 +02:00 committed by GitHub
commit 5084cafe6b
14 changed files with 302 additions and 20 deletions

View file

@ -152,27 +152,35 @@ class BillForm(Form):
class MemberForm(Form): class MemberForm(Form):
name = TextField(_("Name"), validators=[Required()]) name = TextField(_("Name"), validators=[Required()])
weight = CommaDecimalField(_("Weight"), default=1)
submit = SubmitField(_("Add")) submit = SubmitField(_("Add"))
def __init__(self, project, *args, **kwargs): def __init__(self, project, edit=False, *args, **kwargs):
super(MemberForm, self).__init__(*args, **kwargs) super(MemberForm, self).__init__(*args, **kwargs)
self.project = project self.project = project
self.edit = edit
def validate_name(form, field): def validate_name(form, field):
if field.data == form.name.default: if field.data == form.name.default:
raise ValidationError(_("User name incorrect")) raise ValidationError(_("User name incorrect"))
if Person.query.filter(Person.name == field.data)\ if (not form.edit and Person.query.filter(
.filter(Person.project == form.project)\ Person.name == field.data,
.filter(Person.activated == True).all(): Person.project == form.project,
Person.activated == True).all()):
raise ValidationError(_("This project already have this member")) raise ValidationError(_("This project already have this member"))
def save(self, project, person): def save(self, project, person):
# if the user is already bound to the project, just reactivate him # if the user is already bound to the project, just reactivate him
person.name = self.name.data person.name = self.name.data
person.project = project person.project = project
person.weight = self.weight.data
return person return person
def fill(self, member):
self.name.data = member.name
self.weight.data = member.weight
class InviteForm(Form): class InviteForm(Form):
emails = TextAreaField(_("People to notify")) emails = TextAreaField(_("People to notify"))

View file

@ -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 ###

View file

@ -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

View file

@ -40,8 +40,9 @@ class Project(db.Model):
bills = Bill.query.filter(Bill.owers.contains(person)) bills = Bill.query.filter(Bill.owers.contains(person))
for bill in bills.all(): for bill in bills.all():
if person != bill.payer: if person != bill.payer:
should_pay[person] += bill.pay_each() share = bill.pay_each() * person.weight
should_receive[bill.payer] += bill.pay_each() should_pay[person] += share
should_receive[bill.payer] += share
for person in self.members: for person in self.members:
balance = should_receive[person] - should_pay[person] balance = should_receive[person] - should_pay[person]
@ -49,6 +50,10 @@ class Project(db.Model):
return balances 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): def get_transactions_to_settle_bill(self):
"""Return a list of transactions that could be made to settle the bill""" """Return a list of transactions that could be made to settle the bill"""
#cache value for better performance #cache value for better performance
@ -152,13 +157,14 @@ class Person(db.Model):
query_class = PersonQuery query_class = PersonQuery
_to_serialize = ("id", "name", "activated") _to_serialize = ("id", "name", "weight", "activated")
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.String(64), db.ForeignKey("project.id")) project_id = db.Column(db.String(64), db.ForeignKey("project.id"))
bills = db.relationship("Bill", backref="payer") bills = db.relationship("Bill", backref="payer")
name = db.Column(db.UnicodeText) name = db.Column(db.UnicodeText)
weight = db.Column(db.Float, default=1)
activated = db.Column(db.Boolean, default=True) activated = db.Column(db.Boolean, default=True)
def has_bills(self): def has_bills(self):
@ -217,9 +223,10 @@ class Bill(db.Model):
archive = db.Column(db.Integer, db.ForeignKey("archive.id")) archive = db.Column(db.Integer, db.ForeignKey("archive.id"))
def pay_each(self): def pay_each(self):
"""Compute what each person has to pay""" """Compute what each share has to pay"""
if self.owers: 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: else:
return 0 return 0

View file

@ -9,6 +9,8 @@ from raven.contrib.flask import Sentry
from web import main, db, mail from web import main, db, mail
from api import api from api import api
from utils import PrefixedWSGI from utils import PrefixedWSGI
from utils import minimal_round
app = Flask(__name__) app = Flask(__name__)
@ -37,6 +39,9 @@ configure()
app.register_blueprint(main) app.register_blueprint(main)
app.register_blueprint(api) app.register_blueprint(api)
# custom jinja2 filters
app.jinja_env.filters['minimal_round'] = minimal_round
# db # db
db.init_app(app) db.init_app(app)
db.app = app db.app = app

View file

@ -176,6 +176,11 @@ tr.payer_line .balance-name{
color: red; color: red;
} }
.edit button, .edit button:hover {
background: url('../images/edit.png') left no-repeat;
}
.reactivate button, .reactivate button:hover { .reactivate button, .reactivate button:hover {
background: url('../images/reactivate.png') left no-repeat; background: url('../images/reactivate.png') left no-repeat;
color: white; color: white;
@ -189,6 +194,18 @@ tr.payer_line .balance-name{
position: absolute; position: absolute;
} }
.light {
opacity: 0.3;
}
.extra-info {
display: none;
}
tr:hover .extra-info {
display: inline;
}
.modal-body { .modal-body {
max-height:455px; max-height:455px;
} }
@ -208,4 +225,3 @@ tr.payer_line .balance-name{
.row-fluid > .offset3{margin-left:25.5%;} .row-fluid > .offset3{margin-left:25.5%;}
.row-fluid > .offset2{margin-left:17%;} .row-fluid > .offset2{margin-left:17%;}
.row-fluid > .offset1{margin-left:8.5%;} .row-fluid > .offset1{margin-left:8.5%;}

View file

@ -0,0 +1,17 @@
{% extends "layout.html" %}
{% block js %}
$('#cancel-form').click(function(){location.href={{ url_for(".list_bills") }};});
{% endblock %}
{% block top_menu %}
<a href="{{ url_for(".list_bills") }}">{{ _("Back to the list") }}</a>
{% endblock %}
{% block content %}
<form class="form-horizontal" method="post">
{{ forms.edit_member(form, edit) }}
</form>
{% endblock %}

View file

@ -95,6 +95,20 @@
{{ form.name(placeholder=_("Type user name here")) }}<button class="btn">{{ _("Add") }}</button> {{ form.name(placeholder=_("Type user name here")) }}<button class="btn">{{ _("Add") }}</button>
{% endmacro %} {% endmacro %}
{% macro edit_member(form, title=True) %}
<fieldset>
{% if title %}<legend>{{ _("Edit this member") }}</legend>{% endif %}
{% include "display_errors.html" %}
{{ form.hidden_tag() }}
{{ input(form.name) }}
{{ input(form.weight) }}
</fieldset>
<div class="actions">
{{ form.submit(class="btn btn-primary") }}
</div>
{% endmacro %}
{% macro invites(form) %} {% macro invites(form) %}
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{{ input(form.emails) }} {{ input(form.emails) }}

View file

@ -13,9 +13,13 @@
{% if add_bill %} $('#new-bill').click(); {% endif %} {% if add_bill %} $('#new-bill').click(); {% endif %}
// ask for confirmation before removing an user // Hide all members actions
$('.action').each(function(){ $('.action').each(function(){
$(this).hide(); $(this).hide();
});
// ask for confirmation before removing an user
$('.action.delete').each(function(){
var link = $(this).find('button'); var link = $(this).find('button');
link.click(function(){ link.click(function(){
if ($(this).hasClass("confirm")){ if ($(this).hasClass("confirm")){
@ -63,11 +67,16 @@
{% set balance = g.project.balance %} {% set balance = g.project.balance %}
{% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %} {% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id] != 0 %}
<tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}> <tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}>
<td class="balance-name">{{ member.name }}</td> <td class="balance-name">{{ member.name }}
<span class="light{% if not g.project.uses_weights %} extra-info{% endif %}">(x{{ member.weight|minimal_round(1) }})</span>
</td>
{% if member.activated %} {% if member.activated %}
<td> <td>
<form class="action delete" action="{{ url_for(".remove_member", member_id=member.id) }}" method="POST"> <form class="action delete" action="{{ url_for(".remove_member", member_id=member.id) }}" method="POST">
<button type="submit">{{ _("delete") }}</button></form></td> <button type="submit">{{ _("delete") }}</button></form>
<form class="action edit" action="{{ url_for(".edit_member", member_id=member.id) }}" method="GET">
<button type="submit">{{ _("edit") }}</button></form>
</td>
{% else %} {% else %}
<td> <td>
<form class="action reactivate" action="{{ url_for(".reactivate", member_id=member.id) }}" method="POST"> <form class="action reactivate" action="{{ url_for(".reactivate", member_id=member.id) }}" method="POST">

View file

@ -416,6 +416,52 @@ class BudgetTestCase(TestCase):
bill = models.Bill.query.filter(models.Bill.date == '2011-08-01')[0] bill = models.Bill.query.filter(models.Bill.date == '2011-08-01')[0]
self.assertEqual(bill.amount, 25.02) 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): def test_rounding(self):
self.post_project("raclette") self.post_project("raclette")
@ -553,9 +599,10 @@ class APITestCase(TestCase):
'contact_email': contact '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, 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): def get_auth(self, username, password=None):
password = password or username password = password or username
@ -762,8 +809,8 @@ class APITestCase(TestCase):
"what": "fromage", "what": "fromage",
"payer_id": 1, "payer_id": 1,
"owers": [ "owers": [
{"activated": True, "id": 1, "name": "alexis"}, {"activated": True, "id": 1, "name": "alexis", "weight": 1},
{"activated": True, "id": 2, "name": "fred"}], {"activated": True, "id": 2, "name": "fred", "weight": 1}],
"amount": 25.0, "amount": 25.0,
"date": "2011-08-10", "date": "2011-08-10",
"id": 1} "id": 1}
@ -805,8 +852,8 @@ class APITestCase(TestCase):
"what": "beer", "what": "beer",
"payer_id": 2, "payer_id": 2,
"owers": [ "owers": [
{"activated": True, "id": 1, "name": "alexis"}, {"activated": True, "id": 1, "name": "alexis", "weight": 1},
{"activated": True, "id": 2, "name": "fred"}], {"activated": True, "id": 2, "name": "fred", "weight": 1}],
"amount": 25.0, "amount": 25.0,
"date": "2011-09-10", "date": "2011-09-10",
"id": 1} "id": 1}
@ -823,6 +870,65 @@ class APITestCase(TestCase):
headers=self.get_auth("raclette")) headers=self.get_auth("raclette"))
self.assertStatus(404, req) 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): class ServerTestCase(APITestCase):
def setUp(self): def setUp(self):

View file

@ -105,6 +105,10 @@ msgstr "Le montant d'une facture ne peut pas être nul."
msgid "Name" msgid "Name"
msgstr "Nom" msgstr "Nom"
#: forms.py:155
msgid "Weight"
msgstr "Poids"
#: forms.py:155 templates/forms.html:95 #: forms.py:155 templates/forms.html:95
msgid "Add" msgid "Add"
msgstr "Ajouter" msgstr "Ajouter"
@ -497,4 +501,3 @@ msgstr "Qui doit payer ?"
#: templates/settle_bills.html:31 #: templates/settle_bills.html:31
msgid "To whom?" msgid "To whom?"
msgstr "Pour qui ?" msgstr "Pour qui ?"

View file

@ -1,6 +1,7 @@
import re import re
import inspect import inspect
from jinja2 import filters
from flask import redirect from flask import redirect
from werkzeug.routing import HTTPException, RoutingException from werkzeug.routing import HTTPException, RoutingException
@ -63,3 +64,16 @@ class PrefixedWSGI(object):
if scheme: if scheme:
environ['wsgi.url_scheme'] = scheme environ['wsgi.url_scheme'] = scheme
return self.wsgi_app(environ, start_response) 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)

View file

@ -322,6 +322,24 @@ def remove_member(member_id):
return redirect(url_for(".list_bills")) return redirect(url_for(".list_bills"))
@main.route("/<project_id>/members/<member_id>/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("/<project_id>/add", methods=["GET", "POST"]) @main.route("/<project_id>/add", methods=["GET", "POST"])
def add_bill(): def add_bill():
form = get_billform_for(g.project) form = get_billform_for(g.project)