mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
Merge 7f7b05c0d1
into 56bee93346
This commit is contained in:
commit
90709c7c4b
3 changed files with 314 additions and 6 deletions
|
@ -105,6 +105,13 @@
|
||||||
<li class="page-item {% if bills.page == bills.pages %}disabled{% endif %}"><a class="page-link" href="{{ url_for('main.list_bills', page=bills.next_num) }}">{{ _("Older bills") }} »</a></li>
|
<li class="page-item {% if bills.page == bills.pages %}disabled{% endif %}"><a class="page-link" href="{{ url_for('main.list_bills', page=bills.next_num) }}">{{ _("Older bills") }} »</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap align-items-center mr-2 mb-2">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-toggle="collapse" data-target="#filterCollapse" aria-expanded="false" aria-controls="filterCollapse">
|
||||||
|
{{ _("Filter") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span id="new-bill" class="ml-auto pb-2" {% if not g.project.members %} data-toggle="tooltip" title="{{_('You should start by adding participants')}}" {% endif %}>
|
<span id="new-bill" class="ml-auto pb-2" {% if not g.project.members %} data-toggle="tooltip" title="{{_('You should start by adding participants')}}" {% endif %}>
|
||||||
<a href="{{ url_for('.add_bill') }}" class="btn btn-primary {% if not g.project.members %} disabled {% endif %}" data-toggle="modal" data-keyboard="true" data-target="#bill-form" autofocus>
|
<a href="{{ url_for('.add_bill') }}" class="btn btn-primary {% if not g.project.members %} disabled {% endif %}" data-toggle="modal" data-keyboard="true" data-target="#bill-form" autofocus>
|
||||||
<i class="icon icon-white before-text">{{ static_include("images/plus.svg") | safe }}</i>
|
<i class="icon icon-white before-text">{{ static_include("images/plus.svg") | safe }}</i>
|
||||||
|
@ -113,6 +120,50 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="collapse {% if search_active %}show{% endif %} mb-3" id="filterCollapse">
|
||||||
|
<div class="card card-body">
|
||||||
|
<form method="GET" action="{{ url_for('main.list_bills') }}" class="form-inline">
|
||||||
|
<div class="form-row w-100">
|
||||||
|
<div class="col-md-2 mb-2">
|
||||||
|
<label for="search" class="sr-only">{{ _("Search") }}</label>
|
||||||
|
<input type="text" class="form-control form-control-sm w-100" id="search" name="search" placeholder="{{ _('Search in For what?') }}" value="{{ search }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-2">
|
||||||
|
<label for="payer" class="sr-only">{{ _("Payer") }}</label>
|
||||||
|
<select class="form-control form-control-sm w-100" id="payer" name="payer">
|
||||||
|
<option value="">{{ _('Any payer') }}</option>
|
||||||
|
{% for member in g.project.active_members %}
|
||||||
|
<option value="{{ member.id }}" {% if payer|string == member.id|string %}selected{% endif %}>
|
||||||
|
{{ member.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-2">
|
||||||
|
<label for="date_from" class="sr-only">{{ _("Start date") }}</label>
|
||||||
|
<input type="date" class="form-control form-control-sm w-100" id="date_from" name="date_from" value="{{ date_from }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 mb-2">
|
||||||
|
<label for="date_to" class="sr-only">{{ _("End date") }}</label>
|
||||||
|
<input type="date" class="form-control form-control-sm w-100" id="date_to" name="date_to" value="{{ date_to }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 mb-2">
|
||||||
|
<label for="amount_min" class="sr-only">{{ _("Amount min") }}</label>
|
||||||
|
<input type="text" class="form-control form-control-sm w-100" id="amount_min" name="amount_min" placeholder="{{ _('Min cost') }}" value="{{ amount_min }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-1 mb-2">
|
||||||
|
<label for="amount_max" class="sr-only">{{ _("Amount max") }}</label>
|
||||||
|
<input type="text" class="form-control form-control-sm w-100" id="amount_max" name="amount_max" placeholder="{{ _('Max cost') }}" value="{{ amount_max }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8 mb-2 text-left">
|
||||||
|
<button type="submit" class="btn btn-sm btn-primary">{{ _("Apply filters") }}</button>
|
||||||
|
<a href="{{ url_for('main.list_bills') }}" class="btn btn-sm btn-outline-secondary ml-2">{{ _("Clear filters") }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if bills.total > 0 %}
|
{% if bills.total > 0 %}
|
||||||
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
|
<table id="bill_table" class="col table table-striped table-hover table-responsive-sm">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
|
@ -293,6 +293,223 @@ class TestModels(IhatemoneyTestCase):
|
||||||
assert "raclette@notmyidea.org" in result5.output
|
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):
|
class TestEmailFailure(IhatemoneyTestCase):
|
||||||
def test_creation_email_failure_smtp(self):
|
def test_creation_email_failure_smtp(self):
|
||||||
self.login("raclette")
|
self.login("raclette")
|
||||||
|
|
|
@ -670,12 +670,50 @@ def list_bills():
|
||||||
):
|
):
|
||||||
bill_form.payed_for.data = session["last_selected_payed_for"][g.project.id]
|
bill_form.payed_for.data = session["last_selected_payed_for"][g.project.id]
|
||||||
|
|
||||||
# Each item will be a (weight_sum, Bill) tuple.
|
# filters from form
|
||||||
# TODO: improve this awkward result using column_property:
|
filters = {
|
||||||
# https://docs.sqlalchemy.org/en/14/orm/mapped_sql_expr.html.
|
"search": request.args.get("search", "").strip(),
|
||||||
weighted_bills = g.project.get_bill_weights_ordered().paginate(
|
"date_from": request.args.get("date_from", "").strip(),
|
||||||
per_page=100, error_out=True
|
"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(
|
return render_template(
|
||||||
"list_bills.html",
|
"list_bills.html",
|
||||||
|
@ -685,6 +723,8 @@ def list_bills():
|
||||||
csrf_form=csrf_form,
|
csrf_form=csrf_form,
|
||||||
add_bill=request.values.get("add_bill", False),
|
add_bill=request.values.get("add_bill", False),
|
||||||
current_view="list_bills",
|
current_view="list_bills",
|
||||||
|
search_active=filters_active,
|
||||||
|
**filters # Unpack filter values for template use
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue