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*\s*" + r"\s*\s*\s*\s*\s*
PeriodSpent
" + ) + 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", )