diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 06968471a3..e6f64c7e0a 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -182,6 +182,11 @@ paths: one_of: - forecasted - posted + post_date: + start_date: '2024-01-01' + end_date: '2024-02-01' + close_date: + start_date: '2024-01-01' pagination: order_by: opportunity_id page_offset: 1 @@ -826,6 +831,32 @@ components: type: string minLength: 2 example: USAID + PostDateFilterV1: + type: object + properties: + start_date: + type: + - string + - 'null' + format: date + end_date: + type: + - string + - 'null' + format: date + CloseDateFilterV1: + type: object + properties: + start_date: + type: + - string + - 'null' + format: date + end_date: + type: + - string + - 'null' + format: date OpportunitySearchFilterV1: type: object properties: @@ -854,6 +885,16 @@ components: - object allOf: - $ref: '#/components/schemas/AgencyFilterV1' + post_date: + type: + - object + allOf: + - $ref: '#/components/schemas/PostDateFilterV1' + close_date: + type: + - object + allOf: + - $ref: '#/components/schemas/CloseDateFilterV1' OpportunityPaginationV1: type: object properties: diff --git a/api/src/api/opportunities_v1/opportunity_routes.py b/api/src/api/opportunities_v1/opportunity_routes.py index c1861a31f2..c705cb20d7 100644 --- a/api/src/api/opportunities_v1/opportunity_routes.py +++ b/api/src/api/opportunities_v1/opportunity_routes.py @@ -54,6 +54,10 @@ "funding_category": {"one_of": ["recovery_act", "arts", "natural_resources"]}, "funding_instrument": {"one_of": ["cooperative_agreement", "grant"]}, "opportunity_status": {"one_of": ["forecasted", "posted"]}, + "post_date": {"start_date": "2024-01-01", "end_date": "2024-02-01"}, + "close_date": { + "start_date": "2024-01-01", + }, }, "pagination": { "order_by": "opportunity_id", diff --git a/api/src/api/opportunities_v1/opportunity_schemas.py b/api/src/api/opportunities_v1/opportunity_schemas.py index 815f7b4172..e4392e96a8 100644 --- a/api/src/api/opportunities_v1/opportunity_schemas.py +++ b/api/src/api/opportunities_v1/opportunity_schemas.py @@ -2,7 +2,7 @@ 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 StrSearchSchemaBuilder +from src.api.schemas.search_schema import DateSearchSchemaBuilder, StrSearchSchemaBuilder from src.constants.lookup_constants import ( ApplicantType, FundingCategory, @@ -321,6 +321,14 @@ class OpportunitySearchFilterV1Schema(Schema): .build() ) + post_date = fields.Nested( + DateSearchSchemaBuilder("PostDateFilterV1Schema").with_start_date().with_end_date().build() + ) + + close_date = fields.Nested( + DateSearchSchemaBuilder("CloseDateFilterV1Schema").with_start_date().with_end_date().build() + ) + class OpportunityFacetV1Schema(Schema): opportunity_status = fields.Dict( diff --git a/api/src/api/schemas/search_schema.py b/api/src/api/schemas/search_schema.py index 79841f1cd1..8047ff1ee3 100644 --- a/api/src/api/schemas/search_schema.py +++ b/api/src/api/schemas/search_schema.py @@ -35,7 +35,17 @@ def validates_non_empty(self, data: dict, **kwargs: Any) -> None: ) -class StrSearchSchemaBuilder: +class BaseSearchSchemaBuilder: + def __init__(self, schema_class_name: str): + # The schema class name is used on the endpoint + self.schema_fields: dict[str, fields.MixinField] = {} + self.schema_class_name = schema_class_name + + def build(self) -> Schema: + return BaseSearchSchema.from_dict(self.schema_fields, name=self.schema_class_name) # type: ignore + + +class StrSearchSchemaBuilder(BaseSearchSchemaBuilder): """ Builder for setting up a filter in a search endpoint schema. @@ -70,11 +80,6 @@ class OpportunitySearchFilterSchema(Schema): ) """ - def __init__(self, schema_class_name: str): - # The schema class name is used on the endpoint - self.schema_fields: dict[str, fields.MixinField] = {} - self.schema_class_name = schema_class_name - def with_one_of( self, *, @@ -103,5 +108,51 @@ def with_one_of( return self - def build(self) -> Schema: - return BaseSearchSchema.from_dict(self.schema_fields, name=self.schema_class_name) # type: ignore + +class DateSearchSchemaBuilder(BaseSearchSchemaBuilder): + """ + Builder for setting up a filter for a range of dates in the search endpoint schema. + + Example of what this might look like: + { + "filters": { + "post_date": { + "start_date": "YYYY-MM-DD", + "end_date": "YYYY-MM-DD" + } + } + } + + Support for start_date and + end_date filters have been partially implemented. + + Usage:: + # In a search request schema, you would use it like so: + + example_start_date_field = fields.Nested( + DateSearchSchemaBuilder("ExampleStartDateFieldSchema") + .with_start_date() + .build() + ) + + example_end_date_field = fields.Nested( + DateSearchSchemaBuilder("ExampleEndDateFieldSchema") + .with_end_date() + .build() + ) + + example_startend_date_field = fields.Nested( + DateSearchSchemaBuilder("ExampleStartEndDateFieldSchema") + .with_start_date() + .with_end_date() + .build() + ) + """ + + def with_start_date(self) -> "DateSearchSchemaBuilder": + self.schema_fields["start_date"] = fields.Date(allow_none=True) + return self + + def with_end_date(self) -> "DateSearchSchemaBuilder": + self.schema_fields["end_date"] = fields.Date(allow_none=True) + return self diff --git a/api/tests/src/api/opportunities_v1/conftest.py b/api/tests/src/api/opportunities_v1/conftest.py index 402a636d03..8b5e4c9771 100644 --- a/api/tests/src/api/opportunities_v1/conftest.py +++ b/api/tests/src/api/opportunities_v1/conftest.py @@ -37,6 +37,8 @@ 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, + post_date: dict | None = None, + close_date: dict | None = None, format: str | None = None, ): req = { @@ -65,6 +67,12 @@ def get_search_request( if agency_one_of is not None: filters["agency"] = {"one_of": agency_one_of} + if post_date is not None: + filters["post_date"] = post_date + + if close_date is not None: + filters["close_date"] = close_date + if len(filters) > 0: req["filters"] = filters diff --git a/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py b/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py index f889d63904..045fb096cc 100644 --- a/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py +++ b/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py @@ -710,6 +710,69 @@ def test_search_filters_200( ): call_search_and_validate(client, api_auth_token, search_request, expected_results) + @pytest.mark.parametrize( + "search_request", + [ + # Post Date + (get_search_request(post_date={"start_date": None})), + (get_search_request(post_date={"end_date": None})), + (get_search_request(post_date={"start_date": "2020-01-01"})), + (get_search_request(post_date={"end_date": "2020-02-01"})), + (get_search_request(post_date={"start_date": None, "end_date": None})), + (get_search_request(post_date={"start_date": "2020-01-01", "end_date": None})), + (get_search_request(post_date={"start_date": None, "end_date": "2020-02-01"})), + (get_search_request(post_date={"start_date": "2020-01-01", "end_date": "2020-02-01"})), + # Close Date + (get_search_request(close_date={"start_date": None})), + (get_search_request(close_date={"end_date": None})), + (get_search_request(close_date={"start_date": "2020-01-01"})), + (get_search_request(close_date={"end_date": "2020-02-01"})), + (get_search_request(close_date={"start_date": None, "end_date": None})), + (get_search_request(close_date={"start_date": "2020-01-01", "end_date": None})), + (get_search_request(close_date={"start_date": None, "end_date": "2020-02-01"})), + (get_search_request(close_date={"start_date": "2020-01-01", "end_date": "2020-02-01"})), + ], + ) + def test_search_validate_date_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", + [ + # Post Date + (get_search_request(post_date={"start_date": "I am not a date"})), + (get_search_request(post_date={"start_date": "123-456-789"})), + (get_search_request(post_date={"start_date": "5"})), + (get_search_request(post_date={"start_date": 5})), + (get_search_request(post_date={"end_date": "I am not a date"})), + (get_search_request(post_date={"end_date": "123-456-789"})), + (get_search_request(post_date={"end_date": "5"})), + (get_search_request(post_date={"end_date": 5})), + # Close Date + (get_search_request(close_date={"start_date": "I am not a date"})), + (get_search_request(close_date={"start_date": "123-456-789"})), + (get_search_request(close_date={"start_date": "5"})), + (get_search_request(close_date={"start_date": 5})), + (get_search_request(close_date={"end_date": "I am not a date"})), + (get_search_request(close_date={"end_date": "123-456-789"})), + (get_search_request(close_date={"end_date": "5"})), + (get_search_request(close_date={"end_date": 5})), + ], + ) + def test_search_validate_date_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 date." + @pytest.mark.parametrize( "search_request, expected_results", [