From c8cbe43ee28dd07642ecce0b30b2545e7ee5596e Mon Sep 17 00:00:00 2001 From: zorun Date: Tue, 18 Jan 2022 14:32:43 +0100 Subject: [PATCH] Display monthly statistics for the range of months where the project was active (#885) * Change the way we import datetime This makes it easier to use datetime.date later. * 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. Co-authored-by: Baptiste Jonglez --- ihatemoney/models.py | 45 +++++++++++-- ihatemoney/tests/budget_test.py | 109 +++++++++++++++++++++++++++++++- ihatemoney/web.py | 7 +- 3 files changed, 152 insertions(+), 9 deletions(-) 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", )