Compare commits

...

6 commits

Author SHA1 Message Date
Zhongqi Ma
52a62a15bc
Merge 43260d0dc4 into c79671167f 2025-01-22 23:08:00 +01:00
zorun
c79671167f
Add transferred/received amounts in statistics page (#1300)
Some checks failed
CI / lint (push) Has been cancelled
CI / docs (push) Has been cancelled
Docker build / test (push) Has been cancelled
CI / test (mariadb, minimal, 3.11) (push) Has been cancelled
CI / test (mariadb, normal, 3.11) (push) Has been cancelled
CI / test (mariadb, normal, 3.9) (push) Has been cancelled
CI / test (postgresql, minimal, 3.11) (push) Has been cancelled
CI / test (postgresql, normal, 3.11) (push) Has been cancelled
CI / test (postgresql, normal, 3.9) (push) Has been cancelled
CI / test (sqlite, minimal, 3.10) (push) Has been cancelled
CI / test (sqlite, minimal, 3.11) (push) Has been cancelled
CI / test (sqlite, minimal, 3.12) (push) Has been cancelled
CI / test (sqlite, minimal, 3.9) (push) Has been cancelled
CI / test (sqlite, normal, 3.10) (push) Has been cancelled
CI / test (sqlite, normal, 3.11) (push) Has been cancelled
CI / test (sqlite, normal, 3.12) (push) Has been cancelled
CI / test (sqlite, normal, 3.9) (push) Has been cancelled
Docker build / build_upload (push) Has been cancelled
Co-authored-by: Baptiste Jonglez <git@bitsofnetworks.org>
Co-authored-by: Alexis Métaireau <alexis@notmyidea.org>
2025-01-22 23:07:55 +01:00
43260d0dc4
Fix the tests for user reactivation 2025-01-05 16:05:33 +01:00
Baptiste Jonglez
0d7308fccb
Remove unused test 2025-01-05 15:51:50 +01:00
Zhongqi Ma
dff1956c14
Update budget_test.py
Added 2 tests checking for validate_name() in MemberForm()
2025-01-05 15:51:48 +01:00
Zhongqi Ma
cedb2934b8
Updated checks for validate_name() in MemberForm()
The database allows users to deactivate an account with a non-zero value, and create a new user with the same name, reactivating the previous user will allow two users of the same name. This change assures that new user names can not be the same as deactivated users with associated bills (Users that are not deleted from deactivation).
2025-01-05 15:50:43 +01:00
5 changed files with 218 additions and 102 deletions

View file

@ -475,7 +475,6 @@ class MemberForm(FlaskForm):
and Person.query.filter( and Person.query.filter(
Person.name == field.data, Person.name == field.data,
Person.project == self.project, Person.project == self.project,
Person.activated,
).all() ).all()
): # NOQA ): # NOQA
raise ValidationError(_("This project already have this participant")) raise ValidationError(_("This project already have this participant"))

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

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

@ -452,8 +452,8 @@ class TestBudget(IhatemoneyTestCase):
result = self.client.get("/raclette/add") result = self.client.get("/raclette/add")
assert "jeanne" not in result.data.decode("utf-8") assert "jeanne" not in result.data.decode("utf-8")
# adding him again should reactivate him # it should be possible to reactivate him
self.client.post("/raclette/members/add", data={"name": "jeanne"}) self.client.post(f"/raclette/members/{jeanne_id}/reactivate")
assert len(self.get_project("raclette").active_members) == 2 assert len(self.get_project("raclette").active_members) == 2
# adding an user with the same name as another user from a different # adding an user with the same name as another user from a different
@ -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"),
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")
)
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 # 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"]
@ -1938,6 +1967,62 @@ class TestBudget(IhatemoneyTestCase):
# No bills, the previous one was not added # No bills, the previous one was not added
assert "No bills" in resp.data.decode("utf-8") assert "No bills" in resp.data.decode("utf-8")
def test_add_duplicate_user(self):
"""
Adding a user with the same name as a deactivated user with 0 balance
and no associated bills should succeed
"""
self.post_project("raclette")
self.login("raclette")
# adds a member to this project and delete it right after.
self.client.post("/raclette/members/add", data={"name": "zorglub"})
self.client.post("/raclette/members/1/delete")
assert len(self.get_project("raclette").active_members) == 0
assert len(self.get_project("raclette").members) == 0
# try to add this deleted user should be successful
response = self.client.post("/raclette/members/add", data={"name": "zorglub"})
assert len(self.get_project("raclette").members) == 1
def test_add_duplicate_user_with_balance(self):
"""
Adding a user with same name as a deactivated user with non-zero balance
and associated bills should fail
"""
self.post_project("raclette")
# add two participants
self.client.post("/raclette/members/add", data={"name": "Alice"})
self.client.post("/raclette/members/add", data={"name": "Bob"})
members_ids = [m.id for m in self.get_project("raclette").members]
# create one bill
self.client.post(
"/raclette/add",
data={
"date": "2011-08-10",
"what": "fromage à raclette",
"payer": members_ids[0],
"payed_for": members_ids,
"amount": "100",
},
)
# deactivate Bob
self.client.post(
"/raclette/members/%s/delete" % self.get_project("raclette").members[-1].id
)
assert len(self.get_project("raclette").members) == 2
self.client.post("/raclette/members/add", data={"name": "Bob"})
# adding a user with the same name should fail
assert len(self.get_project("raclette").members) == 2
# The only active_member is Alice, this means adding a new Bob failed
assert len(self.get_project("raclette").active_members) == 1
def test_session_projects_migration_to_list(self): def test_session_projects_migration_to_list(self):
"""In https://github.com/spiral-project/ihatemoney/pull/1082, session["projects"] """In https://github.com/spiral-project/ihatemoney/pull/1082, session["projects"]
was migrated from a list to a dict. We need to handle this. was migrated from a list to a dict. We need to handle this.