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 )