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
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
(i.e. how much he/she benefited from bills)
- dict mapping each member to its expenses (i.e. how much he/she
benefited from all bills, whoever actually paid)
- dict mapping each member to how much he/she should be paid by
others (i.e. how much he/she has paid for bills)
- dict mapping each member to 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():
total_weight = sum(ower.weight for ower in bill.owers)
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:
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
)
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:
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
return (
balances,
should_pay,
should_receive,
spent,
paid,
transferred,
received,
)
@property
@ -157,17 +172,19 @@ class Project(db.Model):
@property
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
:rtype list:
"""
balance, spent, paid = self.full_balance
balance, spent, paid, transferred, received = self.full_balance
return [
{
"member": member,
"spent": -1.0 * spent[member.id],
"paid": paid[member.id],
"spent": spent[member.id],
"transferred": transferred[member.id],
"received": -1.0 * received[member.id],
"balance": balance[member.id],
}
for member in self.active_members

View file

@ -236,7 +236,8 @@
{{ input(form.weight) }}
</fieldset>
<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>
{% endmacro %}

View file

@ -11,7 +11,7 @@
</tr>
</thead>
{%- 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 %}">
<td class="balance-name">{{ member.name }}
{%- if show_weight -%}
@ -61,4 +61,4 @@
{% endblock %}
{# It must be set outside of the block definition #}
{% set messages_shown = True %}
{% set messages_shown = True %}

View file

@ -1,38 +1,48 @@
{% extends "sidebar_table_layout.html" %}
{% block sidebar %}
<div id="table_overflow" class="statistics mr-md-n3">
{{ balance_table(show_weight=False, show_header=True) }}
</div>
{% endblock %}
{% block content %}
<div class="d-flex flex-column">
<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>
<tbody>
{% for stat in members_stats|sort(attribute='member.name') %}
<tr>
<td class="d-md-none">{{ stat.member.name }}</td>
<td>{{ stat.paid|currency }}</td>
<td>{{ stat.spent|currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>{{ _("Expenses by Month") }}</h2>
<table id="monthly_stats" class="table table-striped">
<thead><tr><th>{{ _("Period") }}</th><th>{{ _("Spent") }}</th></tr></thead>
<tbody>
{% for month in months %}
<tr>
<td>{{ month|dateformat("MMMM yyyy") }}</td>
<td>{{ monthly_stats[month.year][month.month]|currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% extends "sidebar_table_layout.html" %} {% block sidebar %}
<div id="table_overflow" class="statistics mr-md-n3">
{{ balance_table(show_weight=False, show_header=True) }}
</div>
{% endblock %} {% block content %}
<div class="d-flex flex-column">
<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>{{ _("Expenses") }}</th>
<th>{{ _("Direct transfer") }}</th>
<th>{{ _("Received") }}</th>
</tr>
</thead>
<tbody>
{% for stat in members_stats|sort(attribute='member.name') %}
<tr>
<td class="d-md-none">{{ stat.member.name }}</td>
<td>{{ stat.paid|currency }}</td>
<td>{{ stat.spent|currency }}</td>
<td>{{ stat.transferred|currency }}</td>
<td>{{ stat.received|currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h2>{{ _("Expenses by month") }}</h2>
<table id="monthly_stats" class="table table-striped">
<thead>
<tr>
<th>{{ _("Period") }}</th>
<th>{{ _("Expenses") }}</th>
</tr>
</thead>
<tbody>
{% for month in months %}
<tr>
<td>{{ month|dateformat("MMMM yyyy") }}</td>
<td>{{ monthly_stats[month.year][month.month]|currency }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -814,7 +814,8 @@ class TestAPI(IhatemoneyTestCase):
"/api/projects/raclette/statistics", headers=self.get_auth("raclette")
)
self.assertStatus(200, req)
assert [
received_stats = json.loads(req.data.decode("utf-8"))
assert received_stats == [
{
"balance": 12.5,
"member": {
@ -824,7 +825,9 @@ class TestAPI(IhatemoneyTestCase):
"weight": 1.0,
},
"paid": 25.0,
"spent": 12.5,
"received": 0.0,
"spent": -12.5,
"transferred": 0.0,
},
{
"balance": -12.5,
@ -834,10 +837,12 @@ class TestAPI(IhatemoneyTestCase):
"name": "jeanne",
"weight": 1.0,
},
"paid": 0,
"spent": 12.5,
"paid": 0.0,
"received": 0.0,
"spent": -12.5,
"transferred": 0.0,
},
] == json.loads(req.data.decode("utf-8"))
]
def test_username_xss(self):
# 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": "alice"})
members_ids = [m.id for m in self.get_project("rent").members]
# create a bill to test reimbursement
everybody = [m.id for m in self.get_project("rent").members]
bob = everybody[0]
alice = everybody[1]
# create a bill
self.client.post(
"/rent/add",
data={
"date": "2022-12-12",
"what": "december rent",
"payer": members_ids[0], # bob
"payed_for": members_ids, # bob and alice
"payer": bob,
"payed_for": everybody,
"bill_type": "Expense",
"amount": "1000",
},
@ -806,32 +809,40 @@ class TestBudget(IhatemoneyTestCase):
# check balance
balance = self.get_project("rent").balance
assert set(balance.values()), set([500 == -500])
# check paid
bob_paid = self.get_project("rent").full_balance[2][members_ids[0]]
alice_paid = self.get_project("rent").full_balance[2][members_ids[1]]
project = self.get_project("rent")
bob_paid = project.full_balance[2][bob]
alice_paid = project.full_balance[2][alice]
assert bob_paid == 1000
assert alice_paid == 0
# test reimbursement bill
# reimbursement bill
self.client.post(
"/rent/add",
data={
"date": "2022-12-13",
"what": "reimbursement for rent",
"payer": members_ids[1], # alice
"payed_for": members_ids[0], # bob
"payer": alice,
"payed_for": bob,
"bill_type": "Reimbursement",
"amount": "500",
},
)
balance = self.get_project("rent").balance
balance = project.balance
assert set(balance.values()), set([0 == 0])
# check paid
bob_paid = self.get_project("rent").full_balance[2][members_ids[0]]
alice_paid = self.get_project("rent").full_balance[2][members_ids[1]]
assert bob_paid == 500
assert alice_paid == 500
# After the reimbursement, the full balance should be populated with
# transfer items
bob_paid = project.full_balance[2][bob]
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):
self.post_project("raclette")
@ -1069,14 +1080,25 @@ class TestBudget(IhatemoneyTestCase):
assert len(project.active_months_range()) == 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")
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
self.client.post(
"/raclette/add",
@ -1115,22 +1137,29 @@ class TestBudget(IhatemoneyTestCase):
)
response = self.client.get("/raclette/statistics")
regex = r"<td class=\"d-md-none\">{}</td>\s*<td>{}</td>\s*<td>{}</td>"
assert re.search(
regex.format("zorglub", r"\$20\.00", r"\$31\.67"),
response.data.decode("utf-8"),
)
assert re.search(
regex.format("jeanne", r"\$20\.00", r"\$5\.83"),
response.data.decode("utf-8"),
)
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")
)
html = response.data.decode("utf-8")
def stat_entry(name, paid, spent, transferred=None, received=None):
return (
f'<td class="d-md-none">{name}</td>\n'
f" <td>{paid}</td>\n"
f" <td>{spent}</td>\n"
# f" <td>${spent}</td>\n"
# f" <td>${transferred}</td>"
)
# 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
# same as in the main table.
order = ["jeanne", "pépé", "tata", "zorglub"]