mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
Merge pull request #324 from JocelynDelalande/jd-stats-api
Add an API endpoint for statistics
This commit is contained in:
commit
667e555d67
7 changed files with 112 additions and 36 deletions
|
@ -17,6 +17,11 @@ Fixed
|
||||||
- Fix the "IOError" crash when running `ihatemoney generate-config` (#308)
|
- Fix the "IOError" crash when running `ihatemoney generate-config` (#308)
|
||||||
- Made the left-hand sidebar scrollable (#318)
|
- Made the left-hand sidebar scrollable (#318)
|
||||||
|
|
||||||
|
Added
|
||||||
|
=====
|
||||||
|
|
||||||
|
- Statistics API (#343)
|
||||||
|
|
||||||
|
|
||||||
2.0 (2017-12-27)
|
2.0 (2017-12-27)
|
||||||
----------------
|
----------------
|
||||||
|
|
22
docs/api.rst
22
docs/api.rst
|
@ -164,3 +164,25 @@ And you can of course `DELETE` them at `/api/projects/<id>/bills/<bill-id>`::
|
||||||
$ curl --basic -u demo:demo -X DELETE\
|
$ curl --basic -u demo:demo -X DELETE\
|
||||||
https://ihatemoney.org/api/projects/demo/bills/80\
|
https://ihatemoney.org/api/projects/demo/bills/80\
|
||||||
"OK"
|
"OK"
|
||||||
|
|
||||||
|
|
||||||
|
Statistics
|
||||||
|
----------
|
||||||
|
|
||||||
|
You can get some project stats with a `GET` on `/api/projects/<id>/statistics`::
|
||||||
|
|
||||||
|
$ curl --basic -u demo:demo https://ihatemoney.org/api/projects/demo/statistics
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"balance": 12.5,
|
||||||
|
"member": {"activated": True, "id": 1, "name": "alexis", "weight": 1.0},
|
||||||
|
"paid": 25.0,
|
||||||
|
"spent": 12.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"balance": -12.5,
|
||||||
|
"member": {"activated": True, "id": 2, "name": "fred", "weight": 1.0},
|
||||||
|
"paid": 0,
|
||||||
|
"spent": 12.5
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
|
@ -65,6 +65,13 @@ class ProjectHandler(Resource):
|
||||||
return form.errors, 400
|
return form.errors, 400
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectStatsHandler(Resource):
|
||||||
|
method_decorators = [need_auth]
|
||||||
|
|
||||||
|
def get(self, project):
|
||||||
|
return project.members_stats
|
||||||
|
|
||||||
|
|
||||||
class APIMemberForm(MemberForm):
|
class APIMemberForm(MemberForm):
|
||||||
""" Member is not disablable via a Form.
|
""" Member is not disablable via a Form.
|
||||||
|
|
||||||
|
@ -163,6 +170,7 @@ class BillHandler(Resource):
|
||||||
restful_api.add_resource(ProjectsHandler, '/projects')
|
restful_api.add_resource(ProjectsHandler, '/projects')
|
||||||
restful_api.add_resource(ProjectHandler, '/projects/<string:project_id>')
|
restful_api.add_resource(ProjectHandler, '/projects/<string:project_id>')
|
||||||
restful_api.add_resource(MembersHandler, "/projects/<string:project_id>/members")
|
restful_api.add_resource(MembersHandler, "/projects/<string:project_id>/members")
|
||||||
|
restful_api.add_resource(ProjectStatsHandler, "/projects/<string:project_id>/statistics")
|
||||||
restful_api.add_resource(MemberHandler, "/projects/<string:project_id>/members/<int:member_id>")
|
restful_api.add_resource(MemberHandler, "/projects/<string:project_id>/members/<int:member_id>")
|
||||||
restful_api.add_resource(BillsHandler, "/projects/<string:project_id>/bills")
|
restful_api.add_resource(BillsHandler, "/projects/<string:project_id>/bills")
|
||||||
restful_api.add_resource(BillHandler, "/projects/<string:project_id>/bills/<int:bill_id>")
|
restful_api.add_resource(BillHandler, "/projects/<string:project_id>/bills/<int:bill_id>")
|
||||||
|
|
|
@ -52,6 +52,26 @@ class Project(db.Model):
|
||||||
|
|
||||||
return balances
|
return balances
|
||||||
|
|
||||||
|
@property
|
||||||
|
def members_stats(self):
|
||||||
|
"""Compute what each member has paid
|
||||||
|
|
||||||
|
:return: one stat dict per member
|
||||||
|
:rtype list:
|
||||||
|
"""
|
||||||
|
return [{
|
||||||
|
'member': member,
|
||||||
|
'paid': sum([
|
||||||
|
bill.amount
|
||||||
|
for bill in self.get_member_bills(member.id).all()
|
||||||
|
]),
|
||||||
|
'spent': sum([
|
||||||
|
bill.pay_each() * member.weight
|
||||||
|
for bill in self.get_bills().all() if member in bill.owers
|
||||||
|
]),
|
||||||
|
'balance': self.balance[member.id]
|
||||||
|
} for member in self.active_members]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def uses_weights(self):
|
def uses_weights(self):
|
||||||
return len([i for i in self.members if i.weight != 1]) > 0
|
return len([i for i in self.members if i.weight != 1]) > 0
|
||||||
|
|
|
@ -3,12 +3,11 @@
|
||||||
{% block sidebar %}
|
{% block sidebar %}
|
||||||
<div id="table_overflow">
|
<div id="table_overflow">
|
||||||
<table class="balance table">
|
<table class="balance table">
|
||||||
{% set balance = g.project.balance %}
|
{% for stat in members_stats| sort(attribute='member.name') %}
|
||||||
{% for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %}
|
<tr>
|
||||||
<tr id="bal-member-{{ member.id }}" action={% if member.activated %}delete{% else %}reactivate{% endif %}>
|
<td class="balance-name">{{ stat.member.name }}</td>
|
||||||
<td class="balance-name">{{ member.name }}</td>
|
<td class="balance-value {% if stat.balance|round(2) > 0 %}positive{% elif stat.balance|round(2) < 0 %}negative{% endif %}">
|
||||||
<td class="balance-value {% if balance[member.id]|round(2) > 0 %}positive{% elif balance[member.id]|round(2) < 0 %}negative{% endif %}">
|
{% if stat.balance|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(stat.balance) }}
|
||||||
{% if balance[member.id]|round(2) > 0 %}+{% endif %}{{ "%.2f" | format(balance[member.id]) }}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
@ -21,12 +20,12 @@
|
||||||
<table id="bill_table" class="split_bills table table-striped">
|
<table id="bill_table" class="split_bills table table-striped">
|
||||||
<thead><tr><th>{{ _("Who?") }}</th><th>{{ _("Paid") }}</th><th>{{ _("Spent") }}</th><th>{{ _("Balance") }}</th></tr></thead>
|
<thead><tr><th>{{ _("Who?") }}</th><th>{{ _("Paid") }}</th><th>{{ _("Spent") }}</th><th>{{ _("Balance") }}</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for member in members %}
|
{% for stat in members_stats %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ member.name }}</td>
|
<td>{{ stat.member.name }}</td>
|
||||||
<td>{{ "%0.2f"|format(paid[member.id]) }}</td>
|
<td>{{ "%0.2f"|format(stat.paid) }}</td>
|
||||||
<td>{{ "%0.2f"|format(spent[member.id]) }}</td>
|
<td>{{ "%0.2f"|format(stat.spent) }}</td>
|
||||||
<td>{{ "%0.2f"|format(balance[member.id]) }}</td>
|
<td>{{ "%0.2f"|format(stat.balance) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -750,24 +750,24 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
})
|
})
|
||||||
|
|
||||||
response = self.client.get("/raclette/statistics")
|
response = self.client.get("/raclette/statistics")
|
||||||
self.assertIn("<td>alexis</td>\n "
|
self.assertIn("<td>alexis</td>\n "
|
||||||
+ "<td>20.00</td>\n "
|
+ "<td>20.00</td>\n "
|
||||||
+ "<td>31.67</td>\n "
|
+ "<td>31.67</td>\n "
|
||||||
+ "<td>-11.67</td>\n",
|
+ "<td>-11.67</td>\n",
|
||||||
response.data.decode('utf-8'))
|
response.data.decode('utf-8'))
|
||||||
self.assertIn("<td>fred</td>\n "
|
self.assertIn("<td>fred</td>\n "
|
||||||
+ "<td>20.00</td>\n "
|
+ "<td>20.00</td>\n "
|
||||||
+ "<td>5.83</td>\n "
|
+ "<td>5.83</td>\n "
|
||||||
+ "<td>14.17</td>\n",
|
+ "<td>14.17</td>\n",
|
||||||
response.data.decode('utf-8'))
|
response.data.decode('utf-8'))
|
||||||
self.assertIn("<td>tata</td>\n "
|
self.assertIn("<td>tata</td>\n "
|
||||||
+ "<td>0.00</td>\n "
|
+ "<td>0.00</td>\n "
|
||||||
+ "<td>2.50</td>\n "
|
+ "<td>2.50</td>\n "
|
||||||
+ "<td>-2.50</td>\n",
|
+ "<td>-2.50</td>\n",
|
||||||
response.data.decode('utf-8'))
|
response.data.decode('utf-8'))
|
||||||
self.assertIn("<td>toto</td>\n "
|
self.assertIn("<td>toto</td>\n "
|
||||||
+ "<td>0.00</td>\n "
|
+ "<td>0.00</td>\n "
|
||||||
+ "<td>0.00</td>\n "
|
+ "<td>0.00</td>\n "
|
||||||
+ "<td>0.00</td>\n",
|
+ "<td>0.00</td>\n",
|
||||||
response.data.decode('utf-8'))
|
response.data.decode('utf-8'))
|
||||||
|
|
||||||
|
@ -1325,6 +1325,40 @@ class APITestCase(IhatemoneyTestCase):
|
||||||
headers=self.get_auth("raclette"))
|
headers=self.get_auth("raclette"))
|
||||||
self.assertStatus(404, req)
|
self.assertStatus(404, req)
|
||||||
|
|
||||||
|
def test_statistics(self):
|
||||||
|
# create a project
|
||||||
|
self.api_create("raclette")
|
||||||
|
|
||||||
|
# add members
|
||||||
|
self.api_add_member("raclette", "alexis")
|
||||||
|
self.api_add_member("raclette", "fred")
|
||||||
|
|
||||||
|
# add a bill
|
||||||
|
req = self.client.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 the list of bills (should be empty)
|
||||||
|
req = self.client.get("/api/projects/raclette/statistics",
|
||||||
|
headers=self.get_auth("raclette"))
|
||||||
|
self.assertStatus(200, req)
|
||||||
|
self.assertEqual([
|
||||||
|
{'balance': 12.5,
|
||||||
|
'member': {'activated': True, 'id': 1,
|
||||||
|
'name': 'alexis', 'weight': 1.0},
|
||||||
|
'paid': 25.0,
|
||||||
|
'spent': 12.5},
|
||||||
|
{'balance': -12.5,
|
||||||
|
'member': {'activated': True, 'id': 2,
|
||||||
|
'name': 'fred', 'weight': 1.0},
|
||||||
|
'paid': 0,
|
||||||
|
'spent': 12.5}],
|
||||||
|
json.loads(req.data.decode('utf-8')))
|
||||||
|
|
||||||
def test_username_xss(self):
|
def test_username_xss(self):
|
||||||
# create a project
|
# create a project
|
||||||
# self.api_create("raclette")
|
# self.api_create("raclette")
|
||||||
|
|
|
@ -566,21 +566,9 @@ def settle_bill():
|
||||||
@main.route("/<project_id>/statistics")
|
@main.route("/<project_id>/statistics")
|
||||||
def statistics():
|
def statistics():
|
||||||
"""Compute what each member has paid and spent and display it"""
|
"""Compute what each member has paid and spent and display it"""
|
||||||
members = g.project.active_members
|
|
||||||
balance = g.project.balance
|
|
||||||
paid = {}
|
|
||||||
spent = {}
|
|
||||||
for member in members:
|
|
||||||
paid[member.id] = sum([bill.amount
|
|
||||||
for bill in g.project.get_member_bills(member.id).all()])
|
|
||||||
spent[member.id] = sum([bill.pay_each() * member.weight
|
|
||||||
for bill in g.project.get_bills().all() if member in bill.owers])
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"statistics.html",
|
"statistics.html",
|
||||||
members=members,
|
members_stats=g.project.members_stats,
|
||||||
balance=balance,
|
|
||||||
paid=paid,
|
|
||||||
spent=spent,
|
|
||||||
current_view='statistics',
|
current_view='statistics',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue