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 <git@bitsofnetworks.org>
This commit is contained in:
zorun 2022-01-18 14:32:43 +01:00 committed by GitHub
parent 7cb43f58c0
commit c8cbe43ee2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 152 additions and 9 deletions

View file

@ -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 = [],

View file

@ -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"<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
self.client.post(
"/raclette/add",
@ -948,7 +962,7 @@ class BudgetTestCase(IhatemoneyTestCase):
)
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(
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")

View file

@ -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("/<project_id>/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",
)