From 373b03b9f632216f53ef2cb9469ab1b01516ef90 Mon Sep 17 00:00:00 2001 From: Rwolfe-Nava <87499456+Rwolfe-Nava@users.noreply.github.com> Date: Mon, 5 Aug 2024 15:56:16 -0400 Subject: [PATCH] [Task #2056] Add post_date and close_date filters to search endpoint schema (navapbc/simpler-grants-govnavapbc/simpler-grants-gov#168) Fixes #2056 - Added .with_start_date to search_schema builder to allow building a date field with key of "start_date" - Added .with_end_date to search_schema builder to allow building a date field with key of "end_date" - Added post_date and close_date properties to OpportunitySearchFilterV1Schema class, which utilize the above to build schema filters for post_date and close_date which can utilize start_date and/or end_date fields. - Added two unit tests in test_opportunity_route_search that will test the data validation of these new filters. One test is for 200 response cases and the other test is for 422 (invalid) response cases. Note: As noted in the AC of Issue #163, this PR does NOT include implementation of the filters. Currently, these filters do nothing as they haven't been tied to any sort of query. This PR is just to lay the ground work. --------- Co-authored-by: nava-platform-bot --- api/openapi.generated.yml | 41 ++++++++++++ .../opportunities_v1/opportunity_routes.py | 4 ++ .../opportunities_v1/opportunity_schemas.py | 10 ++- api/src/api/schemas/search_schema.py | 67 ++++++++++++++++--- .../src/api/opportunities_v1/conftest.py | 8 +++ .../test_opportunity_route_search.py | 63 +++++++++++++++++ 6 files changed, 184 insertions(+), 9 deletions(-) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 06968471a..e6f64c7e0 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 c1861a31f..c705cb20d 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 815f7b417..e4392e96a 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 79841f1cd..8047ff1ee 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 402a636d0..8b5e4c977 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 f889d6390..045fb096c 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", [