Implement sorting in ticket search

By default tickets are sorted by updated DESC, if not otherwise defined.

Sort order is defined by entering `sort:<term>` in the search box where
`<term>` can be one of `updated` or `created`. More terms can be added
later.

Sort order is descending by default, it can be reversed by specifying
`rsort:<term>` instead.
This commit is contained in:
Ivan Habunek 2021-09-13 20:19:54 +02:00 committed by Drew DeVault
parent 6d1d4764dd
commit 8b056f3d30
4 changed files with 113 additions and 42 deletions

View File

@ -1,5 +1,6 @@
import pytest
from datetime import datetime
from tests import factories as f
from todosrht.search import apply_search
from todosrht.types import Ticket, TicketStatus
@ -95,8 +96,8 @@ def test_ticket_search(client):
assert search("description_4") == [ticket4]
assert search("description_5") == [ticket5]
assert search("lightsabre") == [ticket1, ticket3, ticket5]
assert search("blaster") == [ticket2, ticket4]
assert search("lightsabre") == [ticket5, ticket3, ticket1]
assert search("blaster") == [ticket4, ticket2]
# Search by comment
assert search("parsecs") == [ticket1]
@ -110,29 +111,29 @@ def test_ticket_search(client):
assert search("'force may with you be'") == []
# Search either title, description, or comment
assert search("used_to_test_or") == [ticket1, ticket2, ticket3]
assert search("used_to_test_or") == [ticket3, ticket2, ticket1]
# Search by submitter
assert search("submitter:luke") == [ticket3]
assert search("submitter:leia") == [ticket4, ticket5]
assert search("submitter:han") == [ticket1, ticket2]
assert search("submitter:leia") == [ticket5, ticket4]
assert search("submitter:han") == [ticket2, ticket1]
assert search("!submitter:luke") == [ticket1, ticket2, ticket4, ticket5]
assert search("!submitter:leia") == [ticket1, ticket2, ticket3]
assert search("!submitter:han") == [ticket3, ticket4, ticket5]
assert search("!submitter:luke") == [ticket5, ticket4, ticket2, ticket1]
assert search("!submitter:leia") == [ticket3, ticket2, ticket1]
assert search("!submitter:han") == [ticket5, ticket4, ticket3]
# Search by asignee
assert search("assigned:luke") == [ticket1, ticket2]
assert search("assigned:leia") == [ticket2, ticket3]
assert search("!assigned:luke") == [ticket3, ticket4, ticket5]
assert search("!assigned:leia") == [ticket1, ticket4, ticket5]
assert search("assigned:luke") == [ticket2, ticket1]
assert search("assigned:leia") == [ticket3, ticket2]
assert search("!assigned:luke") == [ticket5, ticket4, ticket3]
assert search("!assigned:leia") == [ticket5, ticket4, ticket1]
assert search("assigned:luke assigned:leia") == [ticket2]
assert search("assigned:luke !assigned:leia") == [ticket1]
assert search("!assigned:luke assigned:leia") == [ticket3]
assert search("!assigned:luke !assigned:leia") == [ticket4, ticket5]
assert search("!assigned:luke !assigned:leia") == [ticket5, ticket4]
assert search("no:assignee") == [ticket4, ticket5]
assert search("!no:assignee") == [ticket1, ticket2, ticket3]
assert search("no:assignee") == [ticket5, ticket4]
assert search("!no:assignee") == [ticket3, ticket2, ticket1]
with pytest.raises(ValueError) as excinfo:
search("no:foo")
@ -140,23 +141,23 @@ def test_ticket_search(client):
assert search("assigned:me") == []
assert search("assigned:me", han.user) == []
assert search("assigned:me", luke.user) == [ticket1, ticket2]
assert search("assigned:me", leia.user) == [ticket2, ticket3]
assert search("assigned:me", luke.user) == [ticket2, ticket1]
assert search("assigned:me", leia.user) == [ticket3, ticket2]
assert search("!assigned:me") == [ticket1, ticket2, ticket3, ticket4, ticket5]
assert search("!assigned:me", han.user) == [ticket1, ticket2, ticket3, ticket4, ticket5]
assert search("!assigned:me", luke.user) == [ticket3, ticket4, ticket5]
assert search("!assigned:me", leia.user) == [ticket1, ticket4, ticket5]
assert search("!assigned:me") == [ticket5, ticket4, ticket3, ticket2, ticket1]
assert search("!assigned:me", han.user) == [ticket5, ticket4, ticket3, ticket2, ticket1]
assert search("!assigned:me", luke.user) == [ticket5, ticket4, ticket3]
assert search("!assigned:me", leia.user) == [ticket5, ticket4, ticket1]
# Search by label
assert search("label:jedi") == [ticket1, ticket5]
assert search("label:sith") == [ticket2, ticket5]
assert search("!label:jedi") == [ticket2, ticket3, ticket4]
assert search("!label:sith") == [ticket1, ticket3, ticket4]
assert search("label:jedi") == [ticket5, ticket1]
assert search("label:sith") == [ticket5, ticket2]
assert search("!label:jedi") == [ticket4, ticket3, ticket2]
assert search("!label:sith") == [ticket4, ticket3, ticket1]
assert search("label:jedi label:sith") == [ticket5]
assert search("no:label") == [ticket3, ticket4]
assert search("!no:label") == [ticket1, ticket2, ticket5]
assert search("no:label") == [ticket4, ticket3]
assert search("!no:label") == [ticket5, ticket2, ticket1]
# Combinations
assert search(
@ -184,14 +185,14 @@ def test_ticket_search_by_status(client):
def search(search_string, user=owner):
return apply_search(query, search_string, user).all()
assert search("") == [ticket1, ticket2, ticket3, ticket4]
assert search("status:any") == [ticket1, ticket2, ticket3, ticket4, ticket5]
assert search("status:open") == [ticket1, ticket2, ticket3, ticket4]
assert search("") == [ticket4, ticket3, ticket2, ticket1]
assert search("status:any") == [ticket5, ticket4, ticket3, ticket2, ticket1]
assert search("status:open") == [ticket4, ticket3, ticket2, ticket1]
assert search("status:closed") == [ticket5]
assert search("!status:any") == []
assert search("!status:open") == [ticket5]
assert search("!status:closed") == [ticket1, ticket2, ticket3, ticket4]
assert search("!status:closed") == [ticket4, ticket3, ticket2, ticket1]
assert search("status:reported") == [ticket1]
assert search("status:confirmed") == [ticket2]
@ -199,12 +200,51 @@ def test_ticket_search_by_status(client):
assert search("status:pending") == [ticket4]
assert search("status:resolved") == [ticket5]
assert search("!status:reported") == [ticket2, ticket3, ticket4, ticket5]
assert search("!status:confirmed") == [ticket1, ticket3, ticket4, ticket5]
assert search("!status:in_progress") == [ticket1, ticket2, ticket4, ticket5]
assert search("!status:pending") == [ticket1, ticket2, ticket3, ticket5]
assert search("!status:resolved") == [ticket1, ticket2, ticket3, ticket4]
assert search("!status:reported") == [ticket5, ticket4, ticket3, ticket2]
assert search("!status:confirmed") == [ticket5, ticket4, ticket3, ticket1]
assert search("!status:in_progress") == [ticket5, ticket4, ticket2, ticket1]
assert search("!status:pending") == [ticket5, ticket3, ticket2, ticket1]
assert search("!status:resolved") == [ticket4, ticket3, ticket2, ticket1]
with pytest.raises(ValueError) as excinfo:
search("status:foo")
assert str(excinfo.value) == "Invalid status: 'foo'"
def test_sorting(client):
owner = f.UserFactory()
tracker = f.TrackerFactory(owner=owner)
ticket1 = f.TicketFactory(tracker=tracker)
ticket2 = f.TicketFactory(tracker=tracker)
ticket3 = f.TicketFactory(tracker=tracker)
ticket4 = f.TicketFactory(tracker=tracker)
ticket5 = f.TicketFactory(tracker=tracker)
db.session.commit()
query = Ticket.query.filter(Ticket.tracker_id == tracker.id)
def search(search_string, user=owner):
return apply_search(query, search_string, user).all()
assert search("") == [ticket5, ticket4, ticket3, ticket2, ticket1]
assert search("sort:updated") == [ticket5, ticket4, ticket3, ticket2, ticket1]
assert search("rsort:updated") == [ticket1, ticket2, ticket3, ticket4, ticket5]
assert search("sort:created") == [ticket5, ticket4, ticket3, ticket2, ticket1]
assert search("rsort:created") == [ticket1, ticket2, ticket3, ticket4, ticket5]
# Changing updated timestamp changes sorting order
ticket3.updated = datetime.utcnow()
db.session.commit()
assert search("") == [ticket3, ticket5, ticket4, ticket2, ticket1]
assert search("sort:updated") == [ticket3, ticket5, ticket4, ticket2, ticket1]
assert search("rsort:updated") == [ticket1, ticket2, ticket4, ticket5, ticket3]
# Sort by created remains the same
assert search("sort:created") == [ticket5, ticket4, ticket3, ticket2, ticket1]
assert search("rsort:created") == [ticket1, ticket2, ticket3, ticket4, ticket5]
with pytest.raises(ValueError) as excinfo:
search("sort:foo")
assert str(excinfo.value).startswith("Invalid sort value: 'foo'.")

View File

@ -90,8 +90,7 @@ def return_tracker(tracker, access, **kwargs):
tickets = (Ticket.query
.filter(Ticket.tracker_id == tracker.id)
.options(subqueryload(Ticket.labels))
.options(subqueryload(Ticket.submitter))
.order_by(Ticket.updated.desc()))
.options(subqueryload(Ticket.submitter)))
try:
terms = request.args.get("search")

View File

@ -62,14 +62,41 @@ def default_filter(value):
Ticket.comments.any(TicketComment.text.ilike(f"%{value}%"))
)
def apply_sort(query, terms, column_map):
for term in terms:
column_name = term.value
if column_name.startswith("-"):
column_name = column_name[1:]
if column_name not in column_map:
valid = ", ".join(f"'{c}'" for c in column_map.keys())
raise ValueError(
f"Invalid {term.key} value: '{column_name}'. "
f"Supported values are: {valid}."
)
column = column_map[column_name]
reverse = term.key == "rsort"
ordering = column.asc() if reverse else column.desc()
query = query.order_by(ordering)
return query
def apply_search(query, search_string, current_user):
terms = list(search.parse_terms(search_string))
sort_terms = [t for t in terms if t.key in ["sort", "rsort"]]
search_terms = [t for t in terms if t.key not in ["sort", "rsort"]]
# If search does not include a status filter, show open tickets
if not any([term.key == "status" for term in terms]):
terms.append(search.Term("status", "open", False))
if not any([term.key == "status" for term in search_terms]):
search_terms.append(search.Term("status", "open", False))
return search.apply_terms(query, terms, default_filter, key_fns={
# Set default sort to 'updated desc' if not specified
if not sort_terms:
sort_terms = [search.Term("sort", "updated", True)]
query = search.apply_terms(query, search_terms, default_filter, key_fns={
"status": status_filter,
"submitter": lambda v: submitter_filter(v, current_user),
"assigned": lambda v: asignee_filter(v, current_user),
@ -77,6 +104,11 @@ def apply_search(query, search_string, current_user):
"no": no_filter,
})
return apply_sort(query, sort_terms, {
"created": Ticket.created,
"updated": Ticket.updated,
})
def find_usernames(query, limit=20):
"""Given a partial username string, returns matching usernames."""
if not query or query == '~':

View File

@ -177,7 +177,7 @@
<input
name="search"
type="text"
placeholder="Search tickets... status:closed label:label{{" submitter:me" if current_user else ""}}"
placeholder="Search tickets... status:closed sort:created label:label{{" submitter:me" if current_user else ""}}"
class="form-control{% if search_error %} is-invalid{% endif %}"
{% if not another %}
autofocus