diff --git a/ihatemoney/templates/list_bills.html b/ihatemoney/templates/list_bills.html
index 79e25262..be98b445 100644
--- a/ihatemoney/templates/list_bills.html
+++ b/ihatemoney/templates/list_bills.html
@@ -105,6 +105,13 @@
{{ _("Older bills") }} »
{% endif %}
+
+
+
+
+
{{ static_include("images/plus.svg") | safe }}
@@ -113,6 +120,50 @@
+
+
{% if bills.total > 0 %}
diff --git a/ihatemoney/tests/main_test.py b/ihatemoney/tests/main_test.py
index 4d131e0a..d4b3ea4a 100644
--- a/ihatemoney/tests/main_test.py
+++ b/ihatemoney/tests/main_test.py
@@ -293,6 +293,223 @@ class TestModels(IhatemoneyTestCase):
assert "raclette@notmyidea.org" in result5.output
+class TestBillFiltering(IhatemoneyTestCase):
+ def test_filter_by_payer(self):
+ """Test filtering by payer ID"""
+ self.post_project("raclette")
+
+ # add members
+ self.client.post("/raclette/members/add", data={"name": "Alice"})
+ self.client.post("/raclette/members/add", data={"name": "Bob"})
+
+ # create bills
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-01",
+ "what": "Cheese",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "15.0",
+ },
+ )
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-05",
+ "what": "Wine",
+ "payer": 2,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "25.0",
+ },
+ )
+
+ # alice paid for cheese
+ bills = models.Bill.query.filter(models.Bill.payer_id == 1).all()
+ assert len(bills) == 1
+ assert bills[0].what == "Cheese"
+
+ # bob paid for wine
+ bills = models.Bill.query.filter(models.Bill.payer_id == 2).all()
+ assert len(bills) == 1
+ assert bills[0].what == "Wine"
+
+ def test_filter_by_amount_range(self):
+ """Test filtering by amount range"""
+ self.post_project("raclette")
+
+ # add members
+ self.client.post("/raclette/members/add", data={"name": "Alice"})
+ self.client.post("/raclette/members/add", data={"name": "Bob"})
+
+ # create bills
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-01",
+ "what": "Cheese",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "15.0",
+ },
+ )
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-05",
+ "what": "Wine",
+ "payer": 2,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "25.0",
+ },
+ )
+
+ # only wine is more than 20
+ bills = models.Bill.query.filter(models.Bill.amount >= 20).all()
+ assert len(bills) == 1
+ assert bills[0].what == "Wine"
+
+ # only cheese is less than 20
+ bills = models.Bill.query.filter(models.Bill.amount <= 20).all()
+ assert len(bills) == 1
+ assert bills[0].what == "Cheese"
+
+ def test_filter_by_date_range(self):
+ """Test filtering by date range"""
+ self.post_project("raclette")
+
+ # add members
+ self.client.post("/raclette/members/add", data={"name": "Alice"})
+ self.client.post("/raclette/members/add", data={"name": "Bob"})
+
+ # create bills
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-01",
+ "what": "Cheese",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "15.0",
+ },
+ )
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-05",
+ "what": "Wine",
+ "payer": 2,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "25.0",
+ },
+ )
+
+ # wine is March 5th
+ bills = models.Bill.query.filter(models.Bill.date >= "2024-03-02").all()
+ assert len(bills) == 1
+ assert bills[0].what == "Wine"
+
+ # cheese is March 1st
+ bills = models.Bill.query.filter(models.Bill.date <= "2024-03-04").all()
+ assert len(bills) == 1
+ assert bills[0].what == "Cheese"
+
+ def test_filter_by_search_term(self):
+ """Test filtering by search query"""
+ self.post_project("raclette")
+
+ # add members
+ self.client.post("/raclette/members/add", data={"name": "Alice"})
+ self.client.post("/raclette/members/add", data={"name": "Bob"})
+
+ # create bills
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-01",
+ "what": "Cheese",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "15.0",
+ },
+ )
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-05",
+ "what": "Wine",
+ "payer": 2,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "25.0",
+ },
+ )
+
+ bills = models.Bill.query.filter(models.Bill.what.ilike("%Cheese%")).all()
+ assert len(bills) == 1
+ assert bills[0].what == "Cheese"
+
+ bills = models.Bill.query.filter(models.Bill.what.ilike("%Che%")).all()
+ assert len(bills) == 1
+ assert bills[0].what == "Cheese"
+
+ bills = models.Bill.query.filter(models.Bill.what.ilike("%Pizza%")).all()
+ assert len(bills) == 0
+
+ def test_filter_combination(self):
+ """Test filtering by multiple criteria"""
+
+ self.post_project("raclette")
+
+ # add members
+ self.client.post("/raclette/members/add", data={"name": "Alice"})
+ self.client.post("/raclette/members/add", data={"name": "Bob"})
+
+ # create bills
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-01",
+ "what": "Cheese",
+ "payer": 1,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "15.0",
+ },
+ )
+ self.client.post(
+ "/raclette/add",
+ data={
+ "date": "2024-03-05",
+ "what": "Wine",
+ "payer": 2,
+ "payed_for": [1, 2],
+ "bill_type": "Expense",
+ "amount": "25.0",
+ },
+ )
+
+ # only alice's cheese should match
+ bills = models.Bill.query.filter(
+ models.Bill.payer_id == 1, models.Bill.amount <= 20
+ ).all()
+ assert len(bills) == 1
+ assert bills[0].what == "Cheese"
+
+ # no bills match
+ bills = models.Bill.query.filter(
+ models.Bill.payer_id == 2, models.Bill.amount <= 20
+ ).all()
+ assert len(bills) == 0
+
+
class TestEmailFailure(IhatemoneyTestCase):
def test_creation_email_failure_smtp(self):
self.login("raclette")
diff --git a/ihatemoney/web.py b/ihatemoney/web.py
index 37bd811f..6488ad03 100644
--- a/ihatemoney/web.py
+++ b/ihatemoney/web.py
@@ -670,12 +670,50 @@ def list_bills():
):
bill_form.payed_for.data = session["last_selected_payed_for"][g.project.id]
- # Each item will be a (weight_sum, Bill) tuple.
- # TODO: improve this awkward result using column_property:
- # https://docs.sqlalchemy.org/en/14/orm/mapped_sql_expr.html.
- weighted_bills = g.project.get_bill_weights_ordered().paginate(
- per_page=100, error_out=True
- )
+ # filters from form
+ filters = {
+ "search": request.args.get("search", "").strip(),
+ "date_from": request.args.get("date_from", "").strip(),
+ "date_to": request.args.get("date_to", "").strip(),
+ "amount_min": request.args.get("amount_min", "").strip(),
+ "amount_max": request.args.get("amount_max", "").strip(),
+ "payer": request.args.get("payer", "").strip(),
+ }
+
+ filters_active = any(filters.values())
+
+ # standard query if no filters are active
+ if not filters_active:
+ # Each item will be a (weight_sum, Bill) tuple.
+ # TODO: improve this awkward result using column_property:
+ # https://docs.sqlalchemy.org/en/14/orm/mapped_sql_expr.html.
+ weighted_bills = g.project.get_bill_weights_ordered().paginate(per_page=100, error_out=True)
+ else:
+ query = Bill.query.join(Person).filter(Person.project_id == g.project.id)
+
+ if filters["search"]:
+ query = query.filter(Bill.what.ilike(f'%{filters["search"]}%'))
+
+ if filters["date_from"]:
+ query = query.filter(Bill.date >= datetime.datetime.strptime(filters["date_from"], '%Y-%m-%d').date())
+
+ if filters["date_to"]:
+ query = query.filter(Bill.date <= datetime.datetime.strptime(filters["date_to"], '%Y-%m-%d').date())
+
+ if filters["amount_min"]:
+ query = query.filter(Bill.amount >= float(filters["amount_min"]))
+
+ if filters["amount_max"]:
+ query = query.filter(Bill.amount <= float(filters["amount_max"]))
+
+ if filters["payer"]:
+ query = query.filter(Bill.payer_id == filters["payer"])
+
+ filtered_bill_ids = query.with_entities(Bill.id).all()
+
+ bills_query = g.project.get_bill_weights().filter(Bill.id.in_([bill.id for bill in filtered_bill_ids]))
+ bills_query = g.project.order_bills(bills_query)
+ weighted_bills = bills_query.paginate(per_page=100, error_out=True)
return render_template(
"list_bills.html",
@@ -685,6 +723,8 @@ def list_bills():
csrf_form=csrf_form,
add_bill=request.values.get("add_bill", False),
current_view="list_bills",
+ search_active=filters_active,
+ **filters # Unpack filter values for template use
)