mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-05-05 20:51:49 +02:00
Display monthly statistics for the range of months where the project was active
Currently, we display a hard-coded "one year" range of monthly statistics starting from today. This generally is not the intended behaviour: for instance, on an archived project, the bills might all be older than one year, so the table only displays months without any operation. Instead, display all months between the first and last bills. There might be empty months in the middle, but that's intended, because we want all months to be consecutive. If there are no bills, simply display an empty table.
This commit is contained in:
parent
0d13078fc1
commit
af2a711292
3 changed files with 148 additions and 5 deletions
|
@ -1,7 +1,9 @@
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
import datetime
|
import datetime
|
||||||
|
import itertools
|
||||||
|
|
||||||
from dateutil.parser import parse
|
from dateutil.parser import parse
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
from debts import settle
|
from debts import settle
|
||||||
from flask import current_app, g
|
from flask import current_app, g
|
||||||
from flask_sqlalchemy import BaseQuery, SQLAlchemy
|
from flask_sqlalchemy import BaseQuery, SQLAlchemy
|
||||||
|
@ -264,6 +266,41 @@ class Project(db.Model):
|
||||||
.order_by(Bill.id.desc())
|
.order_by(Bill.id.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_newest_bill(self):
|
||||||
|
"""Returns the most recent bill (according to bill date) or None if there are no bills"""
|
||||||
|
# Note that the ORM performs an optimized query with LIMIT
|
||||||
|
return self.get_bills_unordered().order_by(Bill.date.desc()).first()
|
||||||
|
|
||||||
|
def get_oldest_bill(self):
|
||||||
|
"""Returns the least recent bill (according to bill date) or None if there are no bills"""
|
||||||
|
# Note that the ORM performs an optimized query with LIMIT
|
||||||
|
return self.get_bills_unordered().order_by(Bill.date.asc()).first()
|
||||||
|
|
||||||
|
def active_months_range(self):
|
||||||
|
"""Returns a list of dates, representing the range of consecutive months
|
||||||
|
for which the project was active (i.e. has bills).
|
||||||
|
|
||||||
|
Note that the list might contain months during which there was no
|
||||||
|
bills. We only guarantee that there were bills during the first
|
||||||
|
and last month in the list.
|
||||||
|
"""
|
||||||
|
oldest_bill = self.get_oldest_bill()
|
||||||
|
newest_bill = self.get_newest_bill()
|
||||||
|
if oldest_bill is None or newest_bill is None:
|
||||||
|
return []
|
||||||
|
oldest_date = oldest_bill.date
|
||||||
|
newest_date = newest_bill.date
|
||||||
|
newest_month = datetime.date(
|
||||||
|
year=newest_date.year, month=newest_date.month, day=1
|
||||||
|
)
|
||||||
|
# Infinite iterator towards the past
|
||||||
|
all_months = (newest_month - relativedelta(months=i) for i in itertools.count())
|
||||||
|
# Stop when reaching one month before the first date
|
||||||
|
months = itertools.takewhile(
|
||||||
|
lambda x: x > oldest_date - relativedelta(months=1), all_months
|
||||||
|
)
|
||||||
|
return list(months)
|
||||||
|
|
||||||
def get_pretty_bills(self, export_format="json"):
|
def get_pretty_bills(self, export_format="json"):
|
||||||
"""Return a list of project's bills with pretty formatting"""
|
"""Return a list of project's bills with pretty formatting"""
|
||||||
bills = self.get_bills()
|
bills = self.get_bills()
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
import datetime
|
||||||
import re
|
import re
|
||||||
from time import sleep
|
from time import sleep
|
||||||
import unittest
|
import unittest
|
||||||
|
@ -913,6 +914,19 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
# Add a participant with a balance at 0 :
|
# Add a participant with a balance at 0 :
|
||||||
self.client.post("/raclette/members/add", data={"name": "pépé"})
|
self.client.post("/raclette/members/add", data={"name": "pépé"})
|
||||||
|
|
||||||
|
# Check that there are no monthly statistics and no active months
|
||||||
|
project = self.get_project("raclette")
|
||||||
|
self.assertEqual(len(project.active_months_range()), 0)
|
||||||
|
self.assertEqual(len(project.monthly_stats), 0)
|
||||||
|
|
||||||
|
# Check that the "monthly expenses" table is empty
|
||||||
|
response = self.client.get("/raclette/statistics")
|
||||||
|
regex = (
|
||||||
|
r"<table id=\"monthly_stats\".*>\s*<thead>\s*<tr>\s*<th>Period</th>\s*"
|
||||||
|
r"<th>Spent</th>\s*</tr>\s*</thead>\s*<tbody>\s*</tbody>\s*</table>"
|
||||||
|
)
|
||||||
|
self.assertRegex(response.data.decode("utf-8"), regex)
|
||||||
|
|
||||||
# create bills
|
# create bills
|
||||||
self.client.post(
|
self.client.post(
|
||||||
"/raclette/add",
|
"/raclette/add",
|
||||||
|
@ -948,7 +962,7 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get("/raclette/statistics")
|
response = self.client.get("/raclette/statistics")
|
||||||
regex = r"<td class=\"d-md-none\">{}</td>\s+<td>{}</td>\s+<td>{}</td>"
|
regex = r"<td class=\"d-md-none\">{}</td>\s*<td>{}</td>\s*<td>{}</td>"
|
||||||
self.assertRegex(
|
self.assertRegex(
|
||||||
response.data.decode("utf-8"),
|
response.data.decode("utf-8"),
|
||||||
regex.format("zorglub", r"\$20\.00", r"\$31\.67"),
|
regex.format("zorglub", r"\$20\.00", r"\$31\.67"),
|
||||||
|
@ -978,6 +992,99 @@ class BudgetTestCase(IhatemoneyTestCase):
|
||||||
self.assertRegex(response.data.decode("utf-8"), re.compile(regex1, re.DOTALL))
|
self.assertRegex(response.data.decode("utf-8"), re.compile(regex1, re.DOTALL))
|
||||||
self.assertRegex(response.data.decode("utf-8"), re.compile(regex2, re.DOTALL))
|
self.assertRegex(response.data.decode("utf-8"), re.compile(regex2, re.DOTALL))
|
||||||
|
|
||||||
|
# Check monthly expenses again: it should have a single month and the correct amount
|
||||||
|
august = datetime.date(year=2011, month=8, day=1)
|
||||||
|
self.assertEqual(project.active_months_range(), [august])
|
||||||
|
self.assertEqual(dict(project.monthly_stats[2011]), {8: 40.0})
|
||||||
|
|
||||||
|
# Add bills for other months and check monthly expenses again
|
||||||
|
self.client.post(
|
||||||
|
"/raclette/add",
|
||||||
|
data={
|
||||||
|
"date": "2011-12-20",
|
||||||
|
"what": "fromage à raclette",
|
||||||
|
"payer": 2,
|
||||||
|
"payed_for": [1, 2],
|
||||||
|
"amount": "30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
months = [
|
||||||
|
datetime.date(year=2011, month=12, day=1),
|
||||||
|
datetime.date(year=2011, month=11, day=1),
|
||||||
|
datetime.date(year=2011, month=10, day=1),
|
||||||
|
datetime.date(year=2011, month=9, day=1),
|
||||||
|
datetime.date(year=2011, month=8, day=1),
|
||||||
|
]
|
||||||
|
amounts_2011 = {
|
||||||
|
12: 30.0,
|
||||||
|
8: 40.0,
|
||||||
|
}
|
||||||
|
self.assertEqual(project.active_months_range(), months)
|
||||||
|
self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
|
||||||
|
|
||||||
|
# Test more corner cases: first day of month as oldest bill
|
||||||
|
self.client.post(
|
||||||
|
"/raclette/add",
|
||||||
|
data={
|
||||||
|
"date": "2011-08-01",
|
||||||
|
"what": "ice cream",
|
||||||
|
"payer": 2,
|
||||||
|
"payed_for": [1, 2],
|
||||||
|
"amount": "10",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
amounts_2011[8] += 10.0
|
||||||
|
self.assertEqual(project.active_months_range(), months)
|
||||||
|
self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
|
||||||
|
|
||||||
|
# Last day of month as newest bill
|
||||||
|
self.client.post(
|
||||||
|
"/raclette/add",
|
||||||
|
data={
|
||||||
|
"date": "2011-12-31",
|
||||||
|
"what": "champomy",
|
||||||
|
"payer": 1,
|
||||||
|
"payed_for": [1, 2],
|
||||||
|
"amount": "10",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
amounts_2011[12] += 10.0
|
||||||
|
self.assertEqual(project.active_months_range(), months)
|
||||||
|
self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
|
||||||
|
|
||||||
|
# Last day of month as oldest bill
|
||||||
|
self.client.post(
|
||||||
|
"/raclette/add",
|
||||||
|
data={
|
||||||
|
"date": "2011-07-31",
|
||||||
|
"what": "smoothie",
|
||||||
|
"payer": 1,
|
||||||
|
"payed_for": [1, 2],
|
||||||
|
"amount": "20",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
months.append(datetime.date(year=2011, month=7, day=1))
|
||||||
|
amounts_2011[7] = 20.0
|
||||||
|
self.assertEqual(project.active_months_range(), months)
|
||||||
|
self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
|
||||||
|
|
||||||
|
# First day of month as newest bill
|
||||||
|
self.client.post(
|
||||||
|
"/raclette/add",
|
||||||
|
data={
|
||||||
|
"date": "2012-01-01",
|
||||||
|
"what": "more champomy",
|
||||||
|
"payer": 2,
|
||||||
|
"payed_for": [1, 2],
|
||||||
|
"amount": "30",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
months.insert(0, datetime.date(year=2012, month=1, day=1))
|
||||||
|
amounts_2012 = {1: 30.0}
|
||||||
|
self.assertEqual(project.active_months_range(), months)
|
||||||
|
self.assertEqual(dict(project.monthly_stats[2011]), amounts_2011)
|
||||||
|
self.assertEqual(dict(project.monthly_stats[2012]), amounts_2012)
|
||||||
|
|
||||||
def test_settle_page(self):
|
def test_settle_page(self):
|
||||||
self.post_project("raclette")
|
self.post_project("raclette")
|
||||||
response = self.client.get("/raclette/settle_bills")
|
response = self.client.get("/raclette/settle_bills")
|
||||||
|
|
|
@ -8,12 +8,10 @@ Basically, this blueprint takes care of the authentication and provides
|
||||||
some shortcuts to make your life better when coding (see `pull_project`
|
some shortcuts to make your life better when coding (see `pull_project`
|
||||||
and `add_project_id` for a quick overview)
|
and `add_project_id` for a quick overview)
|
||||||
"""
|
"""
|
||||||
from datetime import datetime
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
abort,
|
abort,
|
||||||
|
@ -852,12 +850,13 @@ def strip_ip_addresses():
|
||||||
@main.route("/<project_id>/statistics")
|
@main.route("/<project_id>/statistics")
|
||||||
def statistics():
|
def statistics():
|
||||||
"""Compute what each participant has paid and spent and display it"""
|
"""Compute what each participant has paid and spent and display it"""
|
||||||
today = datetime.now()
|
# Determine range of months between which there are bills
|
||||||
|
months = g.project.active_months_range()
|
||||||
return render_template(
|
return render_template(
|
||||||
"statistics.html",
|
"statistics.html",
|
||||||
members_stats=g.project.members_stats,
|
members_stats=g.project.members_stats,
|
||||||
monthly_stats=g.project.monthly_stats,
|
monthly_stats=g.project.monthly_stats,
|
||||||
months=[today - relativedelta(months=i) for i in range(12)],
|
months=months,
|
||||||
current_view="statistics",
|
current_view="statistics",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue