Skip to content
This repository has been archived by the owner on Sep 18, 2024. It is now read-only.

Commit

Permalink
[Issue #188] More filters in search schema
Browse files Browse the repository at this point in the history
  • Loading branch information
chouinar committed Sep 6, 2024
1 parent 2b23dd4 commit 8f86a81
Show file tree
Hide file tree
Showing 5 changed files with 287 additions and 11 deletions.
44 changes: 43 additions & 1 deletion api/src/api/opportunities_v1/opportunity_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

from src.api.schemas.extension import Schema, fields, validators
from src.api.schemas.response_schema import AbstractResponseSchema, PaginationMixinSchema
from src.api.schemas.search_schema import DateSearchSchemaBuilder, StrSearchSchemaBuilder
from src.api.schemas.search_schema import (
BoolSearchSchemaBuilder,
DateSearchSchemaBuilder,
IntegerSearchSchemaBuilder,
StrSearchSchemaBuilder,
)
from src.constants.lookup_constants import (
ApplicantType,
FundingCategory,
Expand Down Expand Up @@ -320,6 +325,43 @@ class OpportunitySearchFilterV1Schema(Schema):
.with_one_of(example="USAID", minimum_length=2)
.build()
)
assistance_listing_number = fields.Nested(
StrSearchSchemaBuilder("AssistanceListingNumberFilterV1Schema")
.with_one_of(
example="45.149", pattern=r"^\d{2}\.\d{2,3}$"
) # Always of the format ##.## or ##.###
.build()
)
is_cost_sharing = fields.Nested(
BoolSearchSchemaBuilder("IsCostSharingFilterV1Schema").with_one_of(example=True).build()
)
expected_number_of_awards = fields.Nested(
IntegerSearchSchemaBuilder("ExpectedNumberAwardsFilterV1Schema")
.with_minimum_value(example=0)
.with_maximum_value(example=25)
.build()
)

award_floor = fields.Nested(
IntegerSearchSchemaBuilder("AwardFloorFilterV1Schema")
.with_minimum_value(example=0)
.with_maximum_value(example=10_000)
.build()
)

award_ceiling = fields.Nested(
IntegerSearchSchemaBuilder("AwardCeilingFilterV1Schema")
.with_minimum_value(example=0)
.with_maximum_value(example=10_000_000)
.build()
)

estimated_total_program_funding = fields.Nested(
IntegerSearchSchemaBuilder("EstimatedTotalProgramFundingFilterV1Schema")
.with_minimum_value(example=0)
.with_maximum_value(example=10_000_000)
.build()
)

post_date = fields.Nested(
DateSearchSchemaBuilder("PostDateFilterV1Schema").with_start_date().with_end_date().build()
Expand Down
2 changes: 2 additions & 0 deletions api/src/api/schemas/extension/field_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from src.api.schemas.extension.schema_common import MarshmallowErrorContainer
from src.validation.validation_constants import ValidationErrorType

Validator = validators.Validator # re-export


class Regexp(validators.Regexp):
REGEX_ERROR = MarshmallowErrorContainer(
Expand Down
62 changes: 60 additions & 2 deletions api/src/api/schemas/search_schema.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from enum import StrEnum
from typing import Any, Type
from typing import Any, Pattern, Type

from marshmallow import ValidationError, validates_schema

Expand Down Expand Up @@ -84,18 +84,30 @@ def with_one_of(
self,
*,
allowed_values: Type[StrEnum] | None = None,
pattern: str | Pattern | None = None,
example: str | None = None,
minimum_length: int | None = None
) -> "StrSearchSchemaBuilder":
if pattern is not None and allowed_values is not None:
raise Exception("Cannot specify both a pattern and allowed_values")

metadata = {}
if example:
metadata["example"] = example

# We assume it's just a list of strings
if allowed_values is None:
params: dict = {"metadata": metadata}

field_validators: list[validators.Validator] = []
if minimum_length is not None:
params["validate"] = [validators.Length(min=2)]
field_validators.append(validators.Length(min=minimum_length))

if pattern is not None:
field_validators.append(validators.Regexp(regex=pattern))

if len(field_validators) > 0:
params["validate"] = field_validators

list_type: fields.MixinField = fields.String(**params)

Expand All @@ -109,6 +121,52 @@ def with_one_of(
return self


class IntegerSearchSchemaBuilder(BaseSearchSchemaBuilder):
def with_minimum_value(
self, example: int | None = None, positive_only: bool = True
) -> "IntegerSearchSchemaBuilder":
metadata = {}
if example is not None:
metadata["example"] = example

field_validators = []
if positive_only:
field_validators.append(validators.Range(min=0))

self.schema_fields["min"] = fields.Integer(
allow_none=True, metadata=metadata, validate=field_validators
)
return self

def with_maximum_value(
self, example: int | None = None, positive_only: bool = True
) -> "IntegerSearchSchemaBuilder":
metadata = {}
if example is not None:
metadata["example"] = example

field_validators = []
if positive_only:
field_validators.append(validators.Range(min=0))

self.schema_fields["max"] = fields.Integer(
allow_none=True, metadata=metadata, validate=field_validators
)
return self


class BoolSearchSchemaBuilder(BaseSearchSchemaBuilder):
def with_one_of(self, example: bool | None = None) -> "BoolSearchSchemaBuilder":
metadata = {}
if example is not None:
metadata["example"] = example

self.schema_fields["one_of"] = fields.List(
fields.Boolean(metadata=metadata), allow_none=True
)
return self


class DateSearchSchemaBuilder(BaseSearchSchemaBuilder):
"""
Builder for setting up a filter for a range of dates in the search endpoint schema.
Expand Down
24 changes: 24 additions & 0 deletions api/tests/src/api/opportunities_v1/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ def get_search_request(
applicant_type_one_of: list[ApplicantType] | None = None,
opportunity_status_one_of: list[OpportunityStatus] | None = None,
agency_one_of: list[str] | None = None,
assistance_listing_one_of: list[str] = None,
is_cost_sharing_one_of: list[bool | str] | None = None,
expected_number_of_awards: dict | None = None,
award_floor: dict | None = None,
award_ceiling: dict | None = None,
estimated_total_program_funding: dict | None = None,
post_date: dict | None = None,
close_date: dict | None = None,
format: str | None = None,
Expand Down Expand Up @@ -67,6 +73,24 @@ def get_search_request(
if agency_one_of is not None:
filters["agency"] = {"one_of": agency_one_of}

if assistance_listing_one_of is not None:
filters["assistance_listing_number"] = {"one_of": assistance_listing_one_of}

if is_cost_sharing_one_of is not None:
filters["is_cost_sharing"] = {"one_of": is_cost_sharing_one_of}

if expected_number_of_awards is not None:
filters["expected_number_of_awards"] = expected_number_of_awards

if award_floor is not None:
filters["award_floor"] = award_floor

if award_ceiling is not None:
filters["award_ceiling"] = award_ceiling

if estimated_total_program_funding is not None:
filters["estimated_total_program_funding"] = estimated_total_program_funding

if post_date is not None:
filters["post_date"] = post_date

Expand Down
166 changes: 158 additions & 8 deletions api/tests/src/api/opportunities_v1/test_opportunity_route_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ def search_scenario_id_fnc(val):


class TestOpportunityRouteSearch(BaseTestClass):
@pytest.fixture(scope="class")
@pytest.fixture(scope="class", autouse=True)
def setup_search_data(self, opportunity_index, opportunity_index_alias, search_client):
# Load into the search index
schema = OpportunityV1Schema()
Expand Down Expand Up @@ -489,7 +489,7 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c
ids=search_scenario_id_fnc,
)
def test_sorting_and_pagination_200(
self, client, api_auth_token, setup_search_data, search_request, expected_results
self, client, api_auth_token, search_request, expected_results
):
call_search_and_validate(client, api_auth_token, search_request, expected_results)

Expand Down Expand Up @@ -705,9 +705,7 @@ def test_sorting_and_pagination_200(
],
ids=search_scenario_id_fnc,
)
def test_search_filters_200(
self, client, api_auth_token, setup_search_data, search_request, expected_results
):
def test_search_filters_200(self, client, api_auth_token, search_request, expected_results):
call_search_and_validate(client, api_auth_token, search_request, expected_results)

@pytest.mark.parametrize(
Expand Down Expand Up @@ -773,6 +771,160 @@ def test_search_validate_date_filters_422(self, client, api_auth_token, search_r
assert json["message"] == "Validation error"
assert error["message"] == "Not a valid date."

@pytest.mark.parametrize(
"search_request",
[
get_search_request(assistance_listing_one_of=["12.345", "67.89"]),
get_search_request(assistance_listing_one_of=["98.765"]),
get_search_request(assistance_listing_one_of=["67.89", "54.24", "12.345", "86.753"]),
],
)
def test_search_validate_assistance_listing_filters_200(
self, client, api_auth_token, search_request
):
resp = client.post(
"/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token}
)
assert resp.status_code == 200

@pytest.mark.parametrize(
"search_request",
[
get_search_request(assistance_listing_one_of=["12.345", "675.89"]),
get_search_request(assistance_listing_one_of=["hello"]),
get_search_request(assistance_listing_one_of=["67.89", "54.2412"]),
get_search_request(assistance_listing_one_of=["1.1"]),
get_search_request(assistance_listing_one_of=["12.hello"]),
get_search_request(assistance_listing_one_of=["fourfive.sixseveneight"]),
get_search_request(assistance_listing_one_of=["11..11"]),
],
)
def test_search_validate_assistance_listing_filters_422(
self, client, api_auth_token, search_request
):
resp = client.post(
"/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token}
)
assert resp.status_code == 422

json = resp.get_json()
error = json["errors"][0]
assert json["message"] == "Validation error"
assert error["message"] == "String does not match expected pattern."

@pytest.mark.parametrize(
"search_request",
[
get_search_request(is_cost_sharing_one_of=[True, False]),
get_search_request(is_cost_sharing_one_of=["1", "0"]),
get_search_request(is_cost_sharing_one_of=["t", "f"]),
get_search_request(is_cost_sharing_one_of=["true", "false"]),
get_search_request(is_cost_sharing_one_of=["on", "off"]),
get_search_request(is_cost_sharing_one_of=["yes", "no"]),
],
)
def test_search_validate_is_cost_sharing_200(self, client, api_auth_token, search_request):
resp = client.post(
"/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token}
)
assert resp.status_code == 200

@pytest.mark.parametrize(
"search_request",
[
get_search_request(is_cost_sharing_one_of=["hello"]),
get_search_request(is_cost_sharing_one_of=[True, "definitely"]),
get_search_request(is_cost_sharing_one_of=[5, 6]),
get_search_request(is_cost_sharing_one_of=["2024-01-01"]),
get_search_request(is_cost_sharing_one_of=[{}]),
],
)
def test_search_validate_is_cost_sharing_filters_422(
self, client, api_auth_token, search_request
):
resp = client.post(
"/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token}
)
assert resp.status_code == 422

json = resp.get_json()
error = json["errors"][0]
assert json["message"] == "Validation error"
assert error["message"] == "Not a valid boolean."

@pytest.mark.parametrize(
"search_request",
[
get_search_request(
expected_number_of_awards={"min": 0},
award_floor={"max": 35},
award_ceiling={"max": "10000000"},
estimated_total_program_funding={"min": "123456"},
),
get_search_request(
expected_number_of_awards={"min": 1, "max": 2},
award_floor={"min": 0, "max": 1000},
award_ceiling={"min": 10000, "max": 10000000},
estimated_total_program_funding={"min": 123456, "max": 345678},
),
get_search_request(expected_number_of_awards={"min": 1, "max": 2}),
get_search_request(award_floor={"min": 0, "max": 1000}),
get_search_request(award_ceiling={"min": "10000", "max": 10000000}),
get_search_request(estimated_total_program_funding={"min": 123456, "max": "345678"}),
],
)
def test_search_validate_award_values_200(self, client, api_auth_token, search_request):
resp = client.post(
"/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token}
)
assert resp.status_code == 200

@pytest.mark.parametrize(
"search_request",
[
get_search_request(estimated_total_program_funding={"min": "hello", "max": "345678"}),
get_search_request(award_floor={"min": "one"}),
get_search_request(award_ceiling={"min": {}, "max": "123e4f5"}),
],
)
def test_search_validate_award_values_422(self, client, api_auth_token, search_request):
resp = client.post(
"/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token}
)
assert resp.status_code == 422

json = resp.get_json()
assert json["message"] == "Validation error"
for error in json["errors"]:
assert error["message"] == "Not a valid integer."

@pytest.mark.parametrize(
"search_request",
[
get_search_request(
expected_number_of_awards={"min": -1},
award_floor={"max": -2},
award_ceiling={"max": "-10000000"},
estimated_total_program_funding={"min": "-123456"},
),
get_search_request(expected_number_of_awards={"min": -1, "max": 10000000}),
get_search_request(
estimated_total_program_funding={"max": "-5"}, award_floor={"max": "-9"}
),
],
)
def test_search_validate_award_values_negative_422(
self, client, api_auth_token, search_request
):
resp = client.post(
"/v1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token}
)

json = resp.get_json()
assert json["message"] == "Validation error"
for error in json["errors"]:
assert error["message"] == "Must be greater than or equal to 0."

@pytest.mark.parametrize(
"search_request, expected_results",
[
Expand Down Expand Up @@ -831,9 +983,7 @@ def test_search_validate_date_filters_422(self, client, api_auth_token, search_r
],
ids=search_scenario_id_fnc,
)
def test_search_query_200(
self, client, api_auth_token, setup_search_data, search_request, expected_results
):
def test_search_query_200(self, client, api_auth_token, search_request, expected_results):
# This test isn't looking to validate opensearch behavior, just that we've connected fields properly and
# results being returned are as expected.
call_search_and_validate(client, api_auth_token, search_request, expected_results)

0 comments on commit 8f86a81

Please sign in to comment.