Compare commits

...

8 commits

Author SHA1 Message Date
zorun
150d288fbc
Merge 0a50941c35 into 05742347c3 2025-01-05 15:49:25 +01:00
singeltary
05742347c3
UI/navigation changes to in "Edit this participant" (#1323)
Some checks are pending
CI / lint (push) Waiting to run
CI / test (mariadb, minimal, 3.11) (push) Blocked by required conditions
CI / test (mariadb, normal, 3.11) (push) Blocked by required conditions
CI / test (mariadb, normal, 3.9) (push) Blocked by required conditions
CI / test (postgresql, minimal, 3.11) (push) Blocked by required conditions
CI / test (postgresql, normal, 3.11) (push) Blocked by required conditions
CI / test (postgresql, normal, 3.9) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.10) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.11) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.12) (push) Blocked by required conditions
CI / test (sqlite, minimal, 3.9) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.10) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.11) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.12) (push) Blocked by required conditions
CI / test (sqlite, normal, 3.9) (push) Blocked by required conditions
CI / docs (push) Waiting to run
Docker build / test (push) Waiting to run
Docker build / build_upload (push) Blocked by required conditions
* Update forms.html

Added navigation element in 'Edit Participants' window

* Update forms.html

Updated button on form for editing participants--"Save" for edits instead of "Add."
2025-01-05 15:49:20 +01:00
bd689f931a
Merge branch 'fix-1336' 2025-01-05 13:01:16 +01:00
67938eabbc
Do not display deactivated users when their balance is really small
Cases has been reported of rounding issues making deactivated users
reapparing. This is due to the fact we're using floats (see #528 for
details)

Fixes #1336
2025-01-05 13:00:54 +01:00
0a50941c35
Update tests to work with the new statistics 2024-12-26 15:22:03 +01:00
43eeed41f4
Rename "Transferred" to "Direct Transfer"
The rationale for this naming choice has been discussed in issue #1299
2024-12-26 08:47:09 +01:00
13fbeecc43
Reformat statistics template 2024-12-26 08:46:44 +01:00
Baptiste Jonglez
5bb311ecc2
Add transferred/received amounts in statistics page 2024-12-26 08:37:44 +01:00
6 changed files with 164 additions and 102 deletions

View file

@ -113,42 +113,57 @@ class Project(db.Model):
@property @property
def full_balance(self): def full_balance(self):
"""Returns a triple of dicts: """Returns a tuple of dicts:
- dict mapping each member to its balance - dict mapping each member to its overall balance
- dict mapping each member to how much he/she should pay others - dict mapping each member to its expenses (i.e. how much he/she
(i.e. how much he/she benefited from bills) benefited from all bills, whoever actually paid)
- dict mapping each member to how much he/she should be paid by - dict mapping each member to how much he/she has paid for bills
others (i.e. how much he/she has paid for bills)
- dict mapping each member to how much he/she has transferred
money to other members
- dict mapping each member to how much he/she has received money
from other members
balance, spent, paid, transferred, received
balance spent paid
""" """
balances, should_pay, should_receive = (defaultdict(int) for time in (1, 2, 3)) balances, spent, paid, transferred, received = (
defaultdict(float) for _ in range(5)
)
for bill in self.get_bills_unordered().all(): for bill in self.get_bills_unordered().all():
total_weight = sum(ower.weight for ower in bill.owers) total_weight = sum(ower.weight for ower in bill.owers)
if bill.bill_type == BillType.EXPENSE: if bill.bill_type == BillType.EXPENSE:
should_receive[bill.payer.id] += bill.converted_amount paid[bill.payer.id] += bill.converted_amount
for ower in bill.owers: for ower in bill.owers:
should_pay[ower.id] += ( spent[ower.id] += ower.weight * bill.converted_amount / total_weight
if bill.bill_type == BillType.REIMBURSEMENT:
transferred[bill.payer.id] += bill.converted_amount
for ower in bill.owers:
received[ower.id] += (
ower.weight * bill.converted_amount / total_weight ower.weight * bill.converted_amount / total_weight
) )
if bill.bill_type == BillType.REIMBURSEMENT:
should_receive[bill.payer.id] += bill.converted_amount
for ower in bill.owers:
should_receive[ower.id] -= bill.converted_amount
for person in self.members: for person in self.members:
balance = should_receive[person.id] - should_pay[person.id] balance = (
paid[person.id]
- spent[person.id]
+ transferred[person.id]
- received[person.id]
)
balances[person.id] = balance balances[person.id] = balance
return ( return (
balances, balances,
should_pay, spent,
should_receive, paid,
transferred,
received,
) )
@property @property
@ -157,17 +172,19 @@ class Project(db.Model):
@property @property
def members_stats(self): def members_stats(self):
"""Compute what each participant has paid """Compute what each participant has spent, paid, transferred and received
:return: one stat dict per participant :return: one stat dict per participant
:rtype list: :rtype list:
""" """
balance, spent, paid = self.full_balance balance, spent, paid, transferred, received = self.full_balance
return [ return [
{ {
"member": member, "member": member,
"spent": -1.0 * spent[member.id],
"paid": paid[member.id], "paid": paid[member.id],
"spent": spent[member.id], "transferred": transferred[member.id],
"received": -1.0 * received[member.id],
"balance": balance[member.id], "balance": balance[member.id],
} }
for member in self.active_members for member in self.active_members

View file

@ -236,7 +236,8 @@
{{ input(form.weight) }} {{ input(form.weight) }}
</fieldset> </fieldset>
<div class="actions"> <div class="actions">
{{ form.submit(class="btn btn-primary") }} <button class="btn btn-secondary input-group-addon" type="submit">{{ _("Save") }}</button>
<a href="{{ url_for(".list_bills") }}" class="btn btn-outline-secondary"> {{_("Cancel") }} </a>
</div> </div>
{% endmacro %} {% endmacro %}

View file

@ -11,7 +11,7 @@
</tr> </tr>
</thead> </thead>
{%- endif %} {%- endif %}
{%- for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2) != 0 %} {%- for member in g.project.members | sort(attribute='name') if member.activated or balance[member.id]|round(2)|abs > 0.01 %}
<tr id="bal-member-{{ member.id }}" action="{% if member.activated %}delete{% else %}reactivate{% endif %}"> <tr id="bal-member-{{ member.id }}" action="{% if member.activated %}delete{% else %}reactivate{% endif %}">
<td class="balance-name">{{ member.name }} <td class="balance-name">{{ member.name }}
{%- if show_weight -%} {%- if show_weight -%}

View file

@ -1,29 +1,39 @@
{% extends "sidebar_table_layout.html" %} {% extends "sidebar_table_layout.html" %} {% block sidebar %}
<div id="table_overflow" class="statistics mr-md-n3">
{% block sidebar %}
<div id="table_overflow" class="statistics mr-md-n3">
{{ balance_table(show_weight=False, show_header=True) }} {{ balance_table(show_weight=False, show_header=True) }}
</div> </div>
{% endblock %} {% endblock %} {% block content %}
<div class="d-flex flex-column">
{% block content %}
<div class="d-flex flex-column">
<table id="bill_table" class="split_bills table table-striped ml-md-n3"> <table id="bill_table" class="split_bills table table-striped ml-md-n3">
<thead><tr><th class="d-md-none">{{ _("Who?") }}</th><th>{{ _("Paid") }}</th><th>{{ _("Spent") }}</th></tr></thead> <thead>
<tr>
<th class="d-md-none">{{ _("Who?") }}</th>
<th>{{ _("Paid") }}</th>
<th>{{ _("Expenses") }}</th>
<th>{{ _("Direct transfer") }}</th>
<th>{{ _("Received") }}</th>
</tr>
</thead>
<tbody> <tbody>
{% for stat in members_stats|sort(attribute='member.name') %} {% for stat in members_stats|sort(attribute='member.name') %}
<tr> <tr>
<td class="d-md-none">{{ stat.member.name }}</td> <td class="d-md-none">{{ stat.member.name }}</td>
<td>{{ stat.paid|currency }}</td> <td>{{ stat.paid|currency }}</td>
<td>{{ stat.spent|currency }}</td> <td>{{ stat.spent|currency }}</td>
<td>{{ stat.transferred|currency }}</td>
<td>{{ stat.received|currency }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<h2>{{ _("Expenses by Month") }}</h2> <h2>{{ _("Expenses by month") }}</h2>
<table id="monthly_stats" class="table table-striped"> <table id="monthly_stats" class="table table-striped">
<thead><tr><th>{{ _("Period") }}</th><th>{{ _("Spent") }}</th></tr></thead> <thead>
<tr>
<th>{{ _("Period") }}</th>
<th>{{ _("Expenses") }}</th>
</tr>
</thead>
<tbody> <tbody>
{% for month in months %} {% for month in months %}
<tr> <tr>
@ -33,6 +43,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -814,7 +814,8 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette/statistics", headers=self.get_auth("raclette") "/api/projects/raclette/statistics", headers=self.get_auth("raclette")
) )
self.assertStatus(200, req) self.assertStatus(200, req)
assert [ received_stats = json.loads(req.data.decode("utf-8"))
assert received_stats == [
{ {
"balance": 12.5, "balance": 12.5,
"member": { "member": {
@ -824,7 +825,9 @@ class TestAPI(IhatemoneyTestCase):
"weight": 1.0, "weight": 1.0,
}, },
"paid": 25.0, "paid": 25.0,
"spent": 12.5, "received": 0.0,
"spent": -12.5,
"transferred": 0.0,
}, },
{ {
"balance": -12.5, "balance": -12.5,
@ -834,10 +837,12 @@ class TestAPI(IhatemoneyTestCase):
"name": "jeanne", "name": "jeanne",
"weight": 1.0, "weight": 1.0,
}, },
"paid": 0, "paid": 0.0,
"spent": 12.5, "received": 0.0,
"spent": -12.5,
"transferred": 0.0,
}, },
] == json.loads(req.data.decode("utf-8")) ]
def test_username_xss(self): def test_username_xss(self):
# create a project # create a project

View file

@ -790,15 +790,18 @@ class TestBudget(IhatemoneyTestCase):
self.client.post("/rent/members/add", data={"name": "bob"}) self.client.post("/rent/members/add", data={"name": "bob"})
self.client.post("/rent/members/add", data={"name": "alice"}) self.client.post("/rent/members/add", data={"name": "alice"})
members_ids = [m.id for m in self.get_project("rent").members] everybody = [m.id for m in self.get_project("rent").members]
# create a bill to test reimbursement bob = everybody[0]
alice = everybody[1]
# create a bill
self.client.post( self.client.post(
"/rent/add", "/rent/add",
data={ data={
"date": "2022-12-12", "date": "2022-12-12",
"what": "december rent", "what": "december rent",
"payer": members_ids[0], # bob "payer": bob,
"payed_for": members_ids, # bob and alice "payed_for": everybody,
"bill_type": "Expense", "bill_type": "Expense",
"amount": "1000", "amount": "1000",
}, },
@ -806,32 +809,40 @@ class TestBudget(IhatemoneyTestCase):
# check balance # check balance
balance = self.get_project("rent").balance balance = self.get_project("rent").balance
assert set(balance.values()), set([500 == -500]) assert set(balance.values()), set([500 == -500])
# check paid
bob_paid = self.get_project("rent").full_balance[2][members_ids[0]] project = self.get_project("rent")
alice_paid = self.get_project("rent").full_balance[2][members_ids[1]] bob_paid = project.full_balance[2][bob]
alice_paid = project.full_balance[2][alice]
assert bob_paid == 1000 assert bob_paid == 1000
assert alice_paid == 0 assert alice_paid == 0
# test reimbursement bill # reimbursement bill
self.client.post( self.client.post(
"/rent/add", "/rent/add",
data={ data={
"date": "2022-12-13", "date": "2022-12-13",
"what": "reimbursement for rent", "what": "reimbursement for rent",
"payer": members_ids[1], # alice "payer": alice,
"payed_for": members_ids[0], # bob "payed_for": bob,
"bill_type": "Reimbursement", "bill_type": "Reimbursement",
"amount": "500", "amount": "500",
}, },
) )
balance = self.get_project("rent").balance balance = project.balance
assert set(balance.values()), set([0 == 0]) assert set(balance.values()), set([0 == 0])
# check paid
bob_paid = self.get_project("rent").full_balance[2][members_ids[0]] # After the reimbursement, the full balance should be populated with
alice_paid = self.get_project("rent").full_balance[2][members_ids[1]] # transfer items
assert bob_paid == 500 bob_paid = project.full_balance[2][bob]
assert alice_paid == 500 alice_paid = project.full_balance[2][alice]
assert bob_paid == 1000
assert alice_paid == 0
bob_received = project.full_balance[4][bob]
alice_transferred = project.full_balance[3][alice]
assert bob_received == 500
assert alice_transferred == 500
def test_weighted_balance(self): def test_weighted_balance(self):
self.post_project("raclette") self.post_project("raclette")
@ -1069,14 +1080,25 @@ class TestBudget(IhatemoneyTestCase):
assert len(project.active_months_range()) == 0 assert len(project.active_months_range()) == 0
assert len(project.monthly_stats) == 0 assert len(project.monthly_stats) == 0
# Check that the "monthly expenses" table is empty # Check that the "monthly expenses" table exists
# and is empty.
response = self.client.get("/raclette/statistics") 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>"
)
assert re.search(regex, response.data.decode("utf-8"))
regex = (
r'<table id="monthly_stats" class="table table-striped">\n'
r" <thead>\n"
r" <tr>\n"
r" <th>Period</th>\n"
r" <th>Expenses</th>\n"
r" </tr>\n"
r" </thead>\n"
r" <tbody>\n"
r" \n"
r" </tbody>\n"
r" </table>"
)
assert re.search(regex, response.data.decode("utf-8"))
# create bills # create bills
self.client.post( self.client.post(
"/raclette/add", "/raclette/add",
@ -1115,22 +1137,29 @@ class TestBudget(IhatemoneyTestCase):
) )
response = self.client.get("/raclette/statistics") response = self.client.get("/raclette/statistics")
regex = r"<td class=\"d-md-none\">{}</td>\s*<td>{}</td>\s*<td>{}</td>" html = response.data.decode("utf-8")
assert re.search(
regex.format("zorglub", r"\$20\.00", r"\$31\.67"), def stat_entry(name, paid, spent, transferred=None, received=None):
response.data.decode("utf-8"), return (
) f'<td class="d-md-none">{name}</td>\n'
assert re.search( f" <td>{paid}</td>\n"
regex.format("jeanne", r"\$20\.00", r"\$5\.83"), f" <td>{spent}</td>\n"
response.data.decode("utf-8"), # f" <td>${spent}</td>\n"
) # f" <td>${transferred}</td>"
assert re.search(
regex.format("tata", r"\$0\.00", r"\$2\.50"), response.data.decode("utf-8")
)
assert re.search(
regex.format("pépé", r"\$0\.00", r"\$0\.00"), response.data.decode("utf-8")
) )
# set_trace()
# regex = (
# r'\s*<td class="d-md-none">{}</td>\n'
# r"\s*<td>{}</td>\n"
# r"\s*<td>{}</td>\n"
# )
assert stat_entry("zorglub", "$20.00", "-$31.67") in html
assert stat_entry("jeanne", "$20.00", "-$5.83") in html
assert stat_entry("tata", "$0.00", "-$2.50") in html
assert stat_entry("pépé", "$0.00", "-$0.00") in html
# Check that the order of participants in the sidebar table is the # Check that the order of participants in the sidebar table is the
# same as in the main table. # same as in the main table.
order = ["jeanne", "pépé", "tata", "zorglub"] order = ["jeanne", "pépé", "tata", "zorglub"]