diff --git a/ihatemoney/models.py b/ihatemoney/models.py
index 7877b410..d072c555 100644
--- a/ihatemoney/models.py
+++ b/ihatemoney/models.py
@@ -1,7 +1,9 @@
from collections import defaultdict
-from datetime import datetime
+import datetime
+import itertools
from dateutil.parser import parse
+from dateutil.relativedelta import relativedelta
from debts import settle
from flask import current_app, g
from flask_sqlalchemy import BaseQuery, SQLAlchemy
@@ -264,6 +266,41 @@ class Project(db.Model):
.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"):
"""Return a list of project's bills with pretty formatting"""
bills = self.get_bills()
@@ -608,8 +645,8 @@ class Bill(db.Model):
owers = db.relationship(Person, secondary=billowers)
amount = db.Column(db.Float)
- date = db.Column(db.Date, default=datetime.now)
- creation_date = db.Column(db.Date, default=datetime.now)
+ date = db.Column(db.Date, default=datetime.datetime.now)
+ creation_date = db.Column(db.Date, default=datetime.datetime.now)
what = db.Column(db.UnicodeText)
external_link = db.Column(db.UnicodeText)
@@ -623,7 +660,7 @@ class Bill(db.Model):
def __init__(
self,
amount: float,
- date: datetime = None,
+ date: datetime.datetime = None,
external_link: str = "",
original_currency: str = "",
owers: list = [],
diff --git a/ihatemoney/tests/budget_test.py b/ihatemoney/tests/budget_test.py
index b9586bfa..f7781095 100644
--- a/ihatemoney/tests/budget_test.py
+++ b/ihatemoney/tests/budget_test.py
@@ -1,4 +1,5 @@
from collections import defaultdict
+import datetime
import re
from time import sleep
import unittest
@@ -913,6 +914,19 @@ class BudgetTestCase(IhatemoneyTestCase):
# Add a participant with a balance at 0 :
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"
\s*\s*\s*Period | \s*"
+ r"Spent | \s*
\s*\s*\s*\s*
"
+ )
+ self.assertRegex(response.data.decode("utf-8"), regex)
+
# create bills
self.client.post(
"/raclette/add",
@@ -948,7 +962,7 @@ class BudgetTestCase(IhatemoneyTestCase):
)
response = self.client.get("/raclette/statistics")
- regex = r"{} | \s+{} | \s+{} | "
+ regex = r"{} | \s*{} | \s*{} | "
self.assertRegex(
response.data.decode("utf-8"),
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(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):
self.post_project("raclette")
response = self.client.get("/raclette/settle_bills")
diff --git a/ihatemoney/web.py b/ihatemoney/web.py
index e77b41f3..43276041 100644
--- a/ihatemoney/web.py
+++ b/ihatemoney/web.py
@@ -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`
and `add_project_id` for a quick overview)
"""
-from datetime import datetime
from functools import wraps
import json
import os
-from dateutil.relativedelta import relativedelta
from flask import (
Blueprint,
abort,
@@ -852,12 +850,13 @@ def strip_ip_addresses():
@main.route("//statistics")
def statistics():
"""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(
"statistics.html",
members_stats=g.project.members_stats,
monthly_stats=g.project.monthly_stats,
- months=[today - relativedelta(months=i) for i in range(12)],
+ months=months,
current_view="statistics",
)