Skip to content

Commit

Permalink
Support sorting parameters in votes API endpoints, add sort options t…
Browse files Browse the repository at this point in the history
…o UI (#1085)
  • Loading branch information
linusha authored Feb 3, 2025
2 parents 3bc4199 + 4455e4f commit c31faf9
Show file tree
Hide file tree
Showing 18 changed files with 438 additions and 103 deletions.
13 changes: 13 additions & 0 deletions backend/howtheyvote/api/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from collections.abc import Callable


def one_of(*args: str) -> Callable[[str], str]:
"""Returns a function that validates if a given value is one of the provided
values and returns a `ValueError` otherwise."""

def convert(value: str) -> str:
if value in args:
return value
raise ValueError(f"Invalid value {value}")

return convert
111 changes: 75 additions & 36 deletions backend/howtheyvote/api/votes_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
from io import StringIO
from typing import TypeVar

from flask import Blueprint, Response, abort, jsonify, request
from flask import Blueprint, Request, Response, abort, jsonify, request
from sqlalchemy import or_, select
from structlog import get_logger

from ..db import Session
from ..helpers import PROCEDURE_REFERENCE_REGEX, REFERENCE_REGEX, flatten_dict, subset_dict
from ..models import Fragment, Member, PressRelease, Vote, VotePosition
from ..query import fragments_for_records, press_release_references_vote
from .query import DatabaseQuery, Order, SearchQuery
from .query import DatabaseQuery, Order, Query, SearchQuery
from .serializers import (
BaseVoteDict,
MemberVoteDict,
Expand All @@ -31,6 +31,7 @@
serialize_group,
serialize_member,
)
from .util import one_of

log = get_logger(__name__)

Expand Down Expand Up @@ -88,6 +89,23 @@ def index() -> Response:
schema:
type: integer
default: 20
-
in: query
name: sort_by
description: Sort results by this field. Omit to sort by relevance.
schema:
type: string
enum:
- timestamp
-
in: query
name: sort_order
description: Sort results in ascending or descending order
schema:
type: string
enum:
- asc
- desc
responses:
'200':
description: Ok
Expand All @@ -96,23 +114,11 @@ def index() -> Response:
schema:
$ref: '#/components/schemas/VotesQueryResponse'
"""
query = DatabaseQuery(Vote)
query = query.page(request.args.get("page", type=int))
query = query.page_size(request.args.get("page_size", type=int))
query = _query_from_request(DatabaseQuery, request)
query = query.filter("is_main", True)
query = query.where(or_(Vote.title != None, Vote.procedure_title != None)) # noqa: E711

response = query.handle()
results: list[BaseVoteDict] = [
serialize_base_vote(result) for result in response["results"]
]

data = {
**response,
"results": results,
}

return jsonify(data)
return jsonify(_serialize_query(query))


@bp.route("/votes/search")
Expand Down Expand Up @@ -149,6 +155,23 @@ def search() -> Response:
schema:
type: integer
default: 20
-
in: query
name: sort_by
description: Sort results by this field. Omit to sort by relevance.
schema:
type: string
enum:
- timestamp
-
in: query
name: sort_order
description: Sort results in ascending or descending order
schema:
type: string
enum:
- asc
- desc
responses:
'200':
description: Ok
Expand All @@ -158,11 +181,8 @@ def search() -> Response:
$ref: '#/components/schemas/VotesQueryResponse'
"""
q = request.args.get("q", "")

query = SearchQuery(Vote)
query = query.page(request.args.get("page", type=int))
query = query.page_size(request.args.get("page_size", type=int))
q = request.args.get("q", "").strip()
query = _query_from_request(SearchQuery, request)

# Detect document references and apply a filter
references = [match.group(0) for match in REFERENCE_REGEX.finditer(q)]
Expand All @@ -178,23 +198,10 @@ def search() -> Response:
for procedure_reference in procedure_references:
query = query.filter("procedure_reference", procedure_reference)

# Use inverse chronological filter if query is empty
if not q.strip():
query = query.sort("timestamp", Order.DESC)
else:
if q:
query = query.query(q)

response = query.handle()
results: list[BaseVoteDict] = [
serialize_base_vote(result) for result in response["results"]
]

data: VotesQueryResponseDict = {
**response,
"results": results,
}

return jsonify(data)
return jsonify(_serialize_query(query))


@bp.route("/votes/<int:vote_id>")
Expand Down Expand Up @@ -337,6 +344,38 @@ def show_csv(vote_id: int) -> Response:
)


QueryType = TypeVar("QueryType", bound=Query[Vote])


def _query_from_request(cls: type[QueryType], request: Request) -> QueryType:
query = cls(Vote)

# Pagination
query = query.page(request.args.get("page", type=int))
query = query.page_size(request.args.get("page_size", type=int))

# Sort
sort_field = request.args.get("sort_by", type=one_of("timestamp"))
sort_order = request.args.get("sort_order", type=Order)

if sort_field:
query = query.sort(field=sort_field, order=sort_order)

return query


def _serialize_query(query: Query[Vote]) -> VotesQueryResponseDict:
response = query.handle()
results: list[BaseVoteDict] = [
serialize_base_vote(result) for result in response["results"]
]

return {
**response,
"results": results,
}


def _load_fragments(vote: Vote, press_release: PressRelease | None) -> Iterable[Fragment]:
stmt = select(Fragment).where(fragments_for_records([vote, press_release]))
return Session.execute(stmt).scalars()
Expand Down
136 changes: 136 additions & 0 deletions backend/tests/api/test_votes_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,69 @@ def test_votes_api_index_empty_title(db_session, api):
assert res.json["results"][1]["display_title"] == "Vote title"


def test_votes_api_index_sort(db_session, api):
one = Vote(
id=1,
timestamp=datetime.datetime(2024, 1, 1, 0, 0, 0),
title="Vote One",
reference="A9-0043/2024",
procedure_reference="2022/0362(NLE)",
is_main=True,
)

two = Vote(
id=2,
timestamp=datetime.datetime(2024, 7, 1, 0, 0, 0),
title="Vote Two",
reference="A9-0282/2023",
procedure_reference="2022/2148(INI)",
is_main=True,
)

db_session.add_all([one, two])
db_session.commit()

# By default, results are sorted by timestamp in descending order
res = api.get("/api/votes")
assert res.json["total"] == 2
assert res.json["results"][0]["id"] == 2
assert res.json["results"][1]["id"] == 1

# Sorting can be controlled via query params
res = api.get(
"/api/votes",
query_string={
"sort_by": "timestamp",
"sort_order": "asc",
},
)
assert res.json["total"] == 2
assert res.json["results"][0]["id"] == 1
assert res.json["results"][1]["id"] == 2

res = api.get(
"/api/votes",
query_string={
"sort_by": "timestamp",
"sort_order": "desc",
},
)
assert res.json["total"] == 2
assert res.json["results"][0]["id"] == 2
assert res.json["results"][1]["id"] == 1

# Ignores invalid parameters
res = api.get(
"/api/votes",
query_string={
"sort_by": "invalid",
"sort_order": "invalid",
},
)
assert res.status_code == 200
assert res.json["total"] == 2


def test_votes_api_search(db_session, search_index, api):
one = Vote(
id=1,
Expand Down Expand Up @@ -210,6 +273,18 @@ def test_votes_api_search(db_session, search_index, api):
assert res.json["results"][0]["id"] == 2
assert res.json["results"][0]["display_title"] == "Vote Two"

# Ignores invalid parameters
res = api.get(
"/api/votes/search",
query_string={
"q": "vote",
"sort_by": "invalid",
"sort_order": "invalid",
},
)
assert res.status_code == 200
assert res.json["total"] == 2


def test_votes_api_search_references(db_session, search_index, api):
one = Vote(
Expand Down Expand Up @@ -256,6 +331,67 @@ def test_votes_api_search_references(db_session, search_index, api):
assert res.json["total"] == 0


def test_votes_api_search_sort(db_session, search_index, api):
one = Vote(
id=1,
timestamp=datetime.datetime(2024, 1, 1, 0, 0, 0),
title="Vote One",
reference="A9-0043/2024",
procedure_reference="2022/0362(NLE)",
is_main=True,
)

two = Vote(
id=2,
timestamp=datetime.datetime(2024, 7, 1, 0, 0, 0),
title="Vote Two",
reference="A9-0282/2023",
procedure_reference="2022/2148(INI)",
is_main=True,
)

db_session.add_all([one, two])
db_session.commit()
index_search(Vote, [one, two])

# If all results are equally relevant, results are sorted by timestamp in descending order
res = api.get("/api/votes/search", query_string={"q": "vote"})
assert res.json["total"] == 2
assert res.json["results"][0]["id"] == 2
assert res.json["results"][1]["id"] == 1

# By default, results are sorted by relevance
res = api.get("/api/votes/search", query_string={"q": "vote one"})
assert res.json["total"] == 2
assert res.json["results"][0]["id"] == 1
assert res.json["results"][1]["id"] == 2

# Sorting can be controlled via query params
res = api.get(
"/api/votes/search",
query_string={
"q": "vote",
"sort_by": "timestamp",
"sort_order": "asc",
},
)
assert res.json["total"] == 2
assert res.json["results"][0]["id"] == 1
assert res.json["results"][1]["id"] == 2

res = api.get(
"/api/votes/search",
query_string={
"q": "vote",
"sort_by": "timestamp",
"sort_order": "desc",
},
)
assert res.json["total"] == 2
assert res.json["results"][0]["id"] == 2
assert res.json["results"][1]["id"] == 1


def test_votes_api_show(records, db_session, api):
fragment = Fragment(
model="Vote",
Expand Down
3 changes: 2 additions & 1 deletion frontend/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"Select",
"GroupsFilterSelect",
"CountriesFilterSelect",
"PositionFilterSelect"
"PositionFilterSelect",
"SortSelect"
]
}
}
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/api/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,14 @@ export type GetVotesData = {
* Number of results per page
*/
page_size?: number;
/**
* Sort results by this field. Omit to sort by relevance.
*/
sort_by?: 'timestamp';
/**
* Sort results in ascending or descending order
*/
sort_order?: 'asc' | 'desc';
};
};

Expand All @@ -375,6 +383,14 @@ export type SearchVotesData = {
* Search query
*/
q?: string;
/**
* Sort results by this field. Omit to sort by relevance.
*/
sort_by?: 'timestamp';
/**
* Sort results in ascending or descending order
*/
sort_order?: 'asc' | 'desc';
};
};

Expand Down
Loading

0 comments on commit c31faf9

Please sign in to comment.