mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
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:
parent
7cb43f58c0
commit
c8cbe43ee2
3 changed files with 152 additions and 9 deletions
|
@ -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 = [],
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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",
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in a new issue