From 8b3e8672cc56096a4ff715b01eccfff86ad96934 Mon Sep 17 00:00:00 2001 From: Michael Chouinard Date: Wed, 29 Jan 2025 14:52:57 -0500 Subject: [PATCH 01/15] [Issue #3579] Update opportunity CSV generation --- api/src/pagination/pagination_schema.py | 62 +------------------ .../opportunities_v1/opportunity_to_csv.py | 13 ++-- 2 files changed, 7 insertions(+), 68 deletions(-) diff --git a/api/src/pagination/pagination_schema.py b/api/src/pagination/pagination_schema.py index 46533aafa..19bc12531 100644 --- a/api/src/pagination/pagination_schema.py +++ b/api/src/pagination/pagination_schema.py @@ -4,65 +4,9 @@ from src.pagination.pagination_models import SortDirection -class PaginationSchema(Schema): - # DEPRECATED: Do not use this directly anymore - only used for the v0 endpoint - # use generate_pagination_schema instead - page_size = fields.Integer( - required=True, - validate=[validators.Range(min=1)], - metadata={"description": "The size of the page to fetch", "example": 25}, - ) - page_offset = fields.Integer( - required=True, - validate=[validators.Range(min=1)], - metadata={"description": "The page number to fetch, starts counting from 1", "example": 1}, - ) - - -def generate_sorting_schema( - cls_name: str, order_by_fields: list[str] | None = None +def generate_pagination_schema( + cls_name: str, order_by_fields: list[str], max_page_size: int = 5000 ) -> Type[Schema]: - """ - DEPRECATED: Use generate_pagination_schema instead - this is only for the legacy v0 endpoints - - Generate a schema that describes the sorting for a pagination endpoint. - - cls_name will be what the model is named internally by Marshmallow and what OpenAPI shows. - order_by_fields can be a list of fields that the endpoint allows you to sort the response by - - This is functionally equivalent to specifying your own class like so: - - class MySortingSchema(Schema): - order_by = fields.String( - validate=[validators.OneOf(["id","created_at","updated_at"])], - required=True, - metadata={"description": "The field to sort the response by"} - ) - sort_direction = fields.Enum( - SortDirection, - required=True, - metadata={"description": "Whether to sort the response ascending or descending"}, - ) - """ - if order_by_fields is None: - order_by_fields = ["id", "created_at", "updated_at"] - - ordering_schema_fields = { - "order_by": fields.String( - validate=[validators.OneOf(order_by_fields)], - required=True, - metadata={"description": "The field to sort the response by"}, - ), - "sort_direction": fields.Enum( - SortDirection, - required=True, - metadata={"description": "Whether to sort the response ascending or descending"}, - ), - } - return Schema.from_dict(ordering_schema_fields, name=cls_name) # type: ignore - - -def generate_pagination_schema(cls_name: str, order_by_fields: list[str]) -> Type[Schema]: """ Generate a schema that describes the pagination for a pagination endpoint. @@ -108,7 +52,7 @@ class MyPaginationSchema(Schema): ), "page_size": fields.Integer( required=True, - validate=[validators.Range(min=1)], + validate=[validators.Range(min=1, max=max_page_size)], metadata={"description": "The size of the page to fetch", "example": 25}, ), "page_offset": fields.Integer( diff --git a/api/src/services/opportunities_v1/opportunity_to_csv.py b/api/src/services/opportunities_v1/opportunity_to_csv.py index 068dba887..50264aa29 100644 --- a/api/src/services/opportunities_v1/opportunity_to_csv.py +++ b/api/src/services/opportunities_v1/opportunity_to_csv.py @@ -9,7 +9,7 @@ "opportunity_number", "opportunity_title", "opportunity_status", - "agency", + "agency_code", "category", "category_explanation", "post_date", @@ -29,10 +29,8 @@ "funding_category_description", "applicant_types", "applicant_eligibility_description", - "agency_code", "agency_name", "top_level_agency_name", - "agency_phone_number", "agency_contact_description", "agency_email_address", "agency_email_address_description", @@ -60,7 +58,8 @@ def _process_assistance_listing(assistance_listings: list[dict]) -> str: def opportunities_to_csv(opportunities: Sequence[dict], output: io.StringIO) -> None: - opportunities_to_write: list[dict] = [] + writer = csv.DictWriter(output, fieldnames=CSV_FIELDS, quoting=csv.QUOTE_ALL) + writer.writeheader() for opportunity in opportunities: opp = flatten_dict(opportunity) @@ -83,8 +82,4 @@ def opportunities_to_csv(opportunities: Sequence[dict], output: io.StringIO) -> out_opportunity[k] = v - opportunities_to_write.append(out_opportunity) - - writer = csv.DictWriter(output, fieldnames=CSV_FIELDS, quoting=csv.QUOTE_ALL) - writer.writeheader() - writer.writerows(opportunities_to_write) + writer.writerow(out_opportunity) From 8fc7c309e2263af2d5b0e75445cdf0cf297f7f09 Mon Sep 17 00:00:00 2001 From: Michael Chouinard Date: Wed, 29 Jan 2025 15:06:59 -0500 Subject: [PATCH 02/15] Add unit tests --- .../test_opportunity_route_search.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 2a4f9b45a..3072564c7 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 @@ -555,6 +555,28 @@ def test_sorting_and_pagination_200( ): call_search_and_validate(client, api_auth_token, search_request, expected_results) + @pytest.mark.parametrize( + "search_request", + [ + get_search_request(page_size=0), + get_search_request(page_size=-1), + get_search_request(page_size=5001), + ], + ids=search_scenario_id_fnc, + ) + def test_page_size_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"] == "Must be greater than or equal to 1 and less than or equal to 5000." + ) + @pytest.mark.parametrize( "search_request, expected_results", [ From f9b8c0ac5f34c119e3b511a98d32407ec4bce7d1 Mon Sep 17 00:00:00 2001 From: Michael Chouinard Date: Thu, 30 Jan 2025 15:31:46 -0500 Subject: [PATCH 03/15] [Issue #3577] Modify sorting to support sorting by multiple fields --- api/src/api/agencies_v1/agency_routes.py | 8 +- api/src/api/extracts_v1/extract_routes.py | 8 +- .../opportunities_v1/opportunity_routes.py | 63 +++++++-- .../opportunities_v1/opportunity_schemas.py | 1 + api/src/pagination/pagination_models.py | 50 ++----- api/src/pagination/pagination_schema.py | 83 ++++++++--- .../opportunities_v1/search_opportunities.py | 20 +-- .../src/api/opportunities_v1/conftest.py | 9 +- .../test_opportunity_route_search.py | 132 ++++++++++++------ .../api/users/test_user_save_search_post.py | 8 +- 10 files changed, 250 insertions(+), 132 deletions(-) diff --git a/api/src/api/agencies_v1/agency_routes.py b/api/src/api/agencies_v1/agency_routes.py index e3bbfe901..3d6ee09c2 100644 --- a/api/src/api/agencies_v1/agency_routes.py +++ b/api/src/api/agencies_v1/agency_routes.py @@ -16,10 +16,14 @@ "summary": "No filters", "value": { "pagination": { - "order_by": "created_at", + "sort_order": [ + { + "order_by": "created_at", + "sort_direction": "descending", + } + ], "page_offset": 1, "page_size": 25, - "sort_direction": "descending", }, }, }, diff --git a/api/src/api/extracts_v1/extract_routes.py b/api/src/api/extracts_v1/extract_routes.py index af222f095..59398b827 100644 --- a/api/src/api/extracts_v1/extract_routes.py +++ b/api/src/api/extracts_v1/extract_routes.py @@ -16,10 +16,14 @@ "summary": "No filters", "value": { "pagination": { - "order_by": "created_at", + "sort_order": [ + { + "order_by": "created_at", + "sort_direction": "descending", + } + ], "page_offset": 1, "page_size": 25, - "sort_direction": "descending", }, }, }, diff --git a/api/src/api/opportunities_v1/opportunity_routes.py b/api/src/api/opportunities_v1/opportunity_routes.py index 6500f2bdc..1825f8840 100644 --- a/api/src/api/opportunities_v1/opportunity_routes.py +++ b/api/src/api/opportunities_v1/opportunity_routes.py @@ -36,10 +36,14 @@ "summary": "No filters", "value": { "pagination": { - "order_by": "opportunity_id", + "sort_order": [ + { + "order_by": "opportunity_id", + "sort_direction": "ascending", + } + ], "page_offset": 1, "page_size": 25, - "sort_direction": "ascending", }, }, }, @@ -61,10 +65,14 @@ }, }, "pagination": { - "order_by": "opportunity_id", + "sort_order": [ + { + "order_by": "opportunity_id", + "sort_direction": "ascending", + } + ], "page_offset": 1, "page_size": 25, - "sort_direction": "descending", }, }, }, @@ -76,10 +84,14 @@ "opportunity_status": {"one_of": ["forecasted", "posted"]}, }, "pagination": { - "order_by": "opportunity_id", + "sort_order": [ + { + "order_by": "opportunity_id", + "sort_direction": "ascending", + } + ], "page_offset": 1, "page_size": 25, - "sort_direction": "descending", }, }, }, @@ -91,10 +103,14 @@ "opportunity_status": {"one_of": ["forecasted", "posted"]}, }, "pagination": { - "order_by": "opportunity_id", + "sort_order": [ + { + "order_by": "opportunity_id", + "sort_direction": "ascending", + } + ], "page_offset": 1, "page_size": 100, - "sort_direction": "ascending", }, }, }, @@ -108,27 +124,48 @@ "estimated_total_program_funding": {"min": 100000, "max": 250000}, }, "pagination": { - "order_by": "opportunity_id", + "sort_order": [ + { + "order_by": "opportunity_id", + "sort_direction": "ascending", + } + ], "page_offset": 1, "page_size": 25, - "sort_direction": "descending", }, }, }, "example6": { - "summary": "FIlter by assistance listing numbers", + "summary": "Filter by assistance listing numbers", "value": { "filters": { "assistance_listing_number": {"one_of": ["43.001", "47.049"]}, }, "pagination": { - "order_by": "opportunity_id", + "sort_order": [ + { + "order_by": "opportunity_id", + "sort_direction": "ascending", + } + ], "page_offset": 1, "page_size": 25, - "sort_direction": "descending", }, }, }, + "example7": { + "summary": "Primary sort agency_code desc, secondary sort opportunity_id asc", + "value": { + "pagination": { + "page_offset": 1, + "page_size": 25, + "sort_order": [ + {"order_by": "agency_code", "sort_direction": "descending"}, + {"order_by": "opportunity_id", "sort_direction": "ascending"}, + ], + } + }, + }, } diff --git a/api/src/api/opportunities_v1/opportunity_schemas.py b/api/src/api/opportunities_v1/opportunity_schemas.py index e4d436784..428ef2561 100644 --- a/api/src/api/opportunities_v1/opportunity_schemas.py +++ b/api/src/api/opportunities_v1/opportunity_schemas.py @@ -485,6 +485,7 @@ class OpportunitySearchRequestV1Schema(Schema): "close_date", "agency_code", ], + default_sort_order=[{"order_by": "opportunity_id", "sort_direction": "descending"}], ), required=True, ) diff --git a/api/src/pagination/pagination_models.py b/api/src/pagination/pagination_models.py index e1a93e018..21e634c65 100644 --- a/api/src/pagination/pagination_models.py +++ b/api/src/pagination/pagination_models.py @@ -2,7 +2,7 @@ from enum import StrEnum from typing import Self -from pydantic import BaseModel +from pydantic import BaseModel, Field from src.pagination.paginator import Paginator @@ -17,48 +17,34 @@ def short_form(self) -> str: return "asc" -class SortingParamsV0(BaseModel): +class SortOrderParams(BaseModel): order_by: str sort_direction: SortDirection - @property - def is_ascending(self) -> bool: - return self.sort_direction == SortDirection.ASCENDING - - -class PagingParamsV0(BaseModel): - page_size: int - page_offset: int - - -class PaginationParamsV0(BaseModel): - sorting: SortingParamsV0 - paging: PagingParamsV0 - class PaginationParams(BaseModel): page_offset: int page_size: int + sort_order: list[SortOrderParams] = Field(default_factory=list) + + +@dataclasses.dataclass +class SortOrder: order_by: str sort_direction: SortDirection - @property - def is_ascending(self) -> bool: - return self.sort_direction == SortDirection.ASCENDING - @dataclasses.dataclass class PaginationInfo: page_offset: int page_size: int - order_by: str - sort_direction: SortDirection - total_records: int total_pages: int + sort_order: list[SortOrder] + @classmethod def from_pagination_params( cls, pagination_params: PaginationParams, paginator: Paginator @@ -66,21 +52,9 @@ def from_pagination_params( return cls( page_offset=pagination_params.page_offset, page_size=pagination_params.page_size, - order_by=pagination_params.order_by, - sort_direction=pagination_params.sort_direction, - total_records=paginator.total_records, - total_pages=paginator.total_pages, - ) - - @classmethod - def from_pagination_models( - cls, pagination_params: PaginationParamsV0, paginator: Paginator - ) -> Self: - return cls( - page_offset=pagination_params.paging.page_offset, - page_size=pagination_params.paging.page_size, - order_by=pagination_params.sorting.order_by, - sort_direction=pagination_params.sorting.sort_direction, total_records=paginator.total_records, total_pages=paginator.total_pages, + sort_order=[ + SortOrder(p.order_by, p.sort_direction) for p in pagination_params.sort_order + ], ) diff --git a/api/src/pagination/pagination_schema.py b/api/src/pagination/pagination_schema.py index 19bc12531..728b7ccbf 100644 --- a/api/src/pagination/pagination_schema.py +++ b/api/src/pagination/pagination_schema.py @@ -1,11 +1,34 @@ from typing import Type +from marshmallow import pre_load + from src.api.schemas.extension import Schema, fields, validators from src.pagination.pagination_models import SortDirection +class BasePaginationSchema(Schema): + + @pre_load + def before_load(self, item: dict, many: bool, **kwargs: dict) -> dict: + # If sort_order is used, don't change anything + # We'll assume they've migrated properly + if item.get("sort_order") is not None: + return item + + # While we wait for the frontend to start using the new multi-sort, automatically + # setup a monosort for them from the old fields. + if item.get("order_by") is not None and item.get("sort_direction") is not None: + item["sort_order"] = [ + {"order_by": item["order_by"], "sort_direction": item["sort_direction"]} + ] + return item + + def generate_pagination_schema( - cls_name: str, order_by_fields: list[str], max_page_size: int = 5000 + cls_name: str, + order_by_fields: list[str], + max_page_size: int = 5000, + default_sort_order: list[dict] | None = None, ) -> Type[Schema]: """ Generate a schema that describes the pagination for a pagination endpoint. @@ -39,16 +62,34 @@ class MyPaginationSchema(Schema): """ + sort_order_schema = Schema.from_dict( + { + "order_by": fields.String( + validate=[validators.OneOf(order_by_fields)], + required=True, + metadata={"description": "The field to sort the response by"}, + ), + "sort_direction": fields.Enum( + SortDirection, + required=True, + metadata={"description": "Whether to sort the response ascending or descending"}, + ), + }, + name=f"SortOrder{cls_name}", + ) + + additional_sort_order_params: dict = {} + if default_sort_order is not None: + additional_sort_order_params["load_default"] = default_sort_order + else: + additional_sort_order_params["required"] = True + pagination_schema_fields = { - "order_by": fields.String( - validate=[validators.OneOf(order_by_fields)], - required=True, - metadata={"description": "The field to sort the response by"}, - ), - "sort_direction": fields.Enum( - SortDirection, - required=True, - metadata={"description": "Whether to sort the response ascending or descending"}, + "sort_order": fields.List( + fields.Nested(sort_order_schema()), + metadata={"description": "The list of sorting rules"}, + validate=[validators.Length(min=1, max=5)], + **additional_sort_order_params, ), "page_size": fields.Integer( required=True, @@ -64,7 +105,17 @@ class MyPaginationSchema(Schema): }, ), } - return Schema.from_dict(pagination_schema_fields, name=cls_name) # type: ignore + return BasePaginationSchema.from_dict(pagination_schema_fields, name=cls_name) # type: ignore + + +class SortOrderSchema(Schema): + order_by = fields.String( + metadata={"description": "The field that the records were sorted by", "example": "id"} + ) + sort_direction = fields.Enum( + SortDirection, + metadata={"description": "The direction the records are sorted"}, + ) class PaginationInfoSchema(Schema): @@ -82,10 +133,8 @@ class PaginationInfoSchema(Schema): total_pages = fields.Integer( metadata={"description": "The total number of pages that can be fetched", "example": 2} ) - order_by = fields.String( - metadata={"description": "The field that the records were sorted by", "example": "id"} - ) - sort_direction = fields.Enum( - SortDirection, - metadata={"description": "The direction the records are sorted"}, + + sort_order = fields.List( + fields.Nested(SortOrderSchema()), + metadata={"description": "The sort order passed in originally"}, ) diff --git a/api/src/services/opportunities_v1/search_opportunities.py b/api/src/services/opportunities_v1/search_opportunities.py index 54c5577ad..4eea361b4 100644 --- a/api/src/services/opportunities_v1/search_opportunities.py +++ b/api/src/services/opportunities_v1/search_opportunities.py @@ -6,7 +6,12 @@ import src.adapters.search as search from src.api.opportunities_v1.opportunity_schemas import OpportunityV1Schema -from src.pagination.pagination_models import PaginationInfo, PaginationParams, SortDirection +from src.pagination.pagination_models import ( + PaginationInfo, + PaginationParams, + SortDirection, + SortOrder, +) from src.search.search_config import get_search_config from src.search.search_models import ( BoolSearchFilter, @@ -95,11 +100,8 @@ def _adjust_field_name(field: str) -> str: def _get_sort_by(pagination: PaginationParams) -> list[tuple[str, SortDirection]]: sort_by: list[tuple[str, SortDirection]] = [] - sort_by.append((_adjust_field_name(pagination.order_by), pagination.sort_direction)) - - # Add a secondary sort for relevancy to sort by post date (matching the sort direction) - if pagination.order_by == "relevancy": - sort_by.append((_adjust_field_name("post_date"), pagination.sort_direction)) + for sort_order in pagination.sort_order: + sort_by.append((_adjust_field_name(sort_order.order_by), sort_order.sort_direction)) return sort_by @@ -199,10 +201,12 @@ def search_opportunities( pagination_info = PaginationInfo( page_offset=search_params.pagination.page_offset, page_size=search_params.pagination.page_size, - order_by=search_params.pagination.order_by, - sort_direction=search_params.pagination.sort_direction, total_records=response.total_records, total_pages=int(math.ceil(response.total_records / search_params.pagination.page_size)), + sort_order=[ + SortOrder(order_by=p.order_by, sort_direction=p.sort_direction) + for p in search_params.pagination.sort_order + ], ) # While the data returned is already JSON/dicts like we want to return diff --git a/api/tests/src/api/opportunities_v1/conftest.py b/api/tests/src/api/opportunities_v1/conftest.py index 12bffa7ea..503df0f7e 100644 --- a/api/tests/src/api/opportunities_v1/conftest.py +++ b/api/tests/src/api/opportunities_v1/conftest.py @@ -30,8 +30,7 @@ def truncate_opportunities(db_session): def get_search_request( page_offset: int = 1, page_size: int = 25, - order_by: str = "opportunity_id", - sort_direction: str = "ascending", + sort_order: list[dict] | None = None, query: str | None = None, experimental: dict | None = None, funding_instrument_one_of: list[FundingInstrument] | None = None, @@ -49,12 +48,14 @@ def get_search_request( close_date: dict | None = None, format: str | None = None, ): + if sort_order is None: + sort_order = [{"order_by": "opportunity_id", "sort_direction": "ascending"}] + req = { "pagination": { "page_offset": page_offset, "page_size": page_size, - "order_by": order_by, - "sort_direction": sort_direction, + "sort_order": sort_order, } } 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 3072564c7..35a8e77a6 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 @@ -370,8 +370,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=25, page_offset=1, - order_by="opportunity_id", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], ), OPPORTUNITIES, ), @@ -379,8 +380,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=2, - order_by="opportunity_id", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], ), OPPORTUNITIES[3:6], ), @@ -388,8 +390,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=25, page_offset=1, - order_by="opportunity_id", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.DESCENDING} + ], ), OPPORTUNITIES[::-1], ), @@ -398,8 +401,12 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=1, - order_by="opportunity_number", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + { + "order_by": "opportunity_number", + "sort_direction": SortDirection.ASCENDING, + } + ], ), [LOC_TEACHING, LOC_HIGHER_EDUCATION, DOC_MANUFACTURING], ), @@ -407,8 +414,12 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=2, page_offset=3, - order_by="opportunity_number", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + { + "order_by": "opportunity_number", + "sort_direction": SortDirection.DESCENDING, + } + ], ), [NASA_K12_DIVERSITY, NASA_SPACE_FELLOWSHIP], ), @@ -417,8 +428,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=4, page_offset=2, - order_by="opportunity_title", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "opportunity_title", "sort_direction": SortDirection.ASCENDING} + ], ), [NASA_SPACE_FELLOWSHIP, LOC_HIGHER_EDUCATION, DOC_SPACE_COAST, NASA_K12_DIVERSITY], ), @@ -426,8 +438,12 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=5, page_offset=1, - order_by="opportunity_title", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + { + "order_by": "opportunity_title", + "sort_direction": SortDirection.DESCENDING, + } + ], ), [ LOC_TEACHING, @@ -442,8 +458,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=2, page_offset=1, - order_by="post_date", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "post_date", "sort_direction": SortDirection.ASCENDING} + ], ), [DOC_MANUFACTURING, DOC_SPACE_COAST], ), @@ -451,8 +468,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=1, - order_by="post_date", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + {"order_by": "post_date", "sort_direction": SortDirection.DESCENDING} + ], ), [LOC_TEACHING, DOS_DIGITAL_LITERACY, LOC_HIGHER_EDUCATION], ), @@ -460,8 +478,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=12, - order_by="post_date", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + {"order_by": "post_date", "sort_direction": SortDirection.DESCENDING} + ], ), [], ), @@ -470,8 +489,10 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=2, page_offset=1, - order_by="relevancy", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "relevancy", "sort_direction": SortDirection.ASCENDING}, + {"order_by": "post_date", "sort_direction": SortDirection.ASCENDING}, + ], ), [DOC_MANUFACTURING, DOC_SPACE_COAST], ), @@ -479,8 +500,10 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=1, - order_by="relevancy", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + {"order_by": "relevancy", "sort_direction": SortDirection.DESCENDING}, + {"order_by": "post_date", "sort_direction": SortDirection.DESCENDING}, + ], ), [LOC_TEACHING, DOS_DIGITAL_LITERACY, LOC_HIGHER_EDUCATION], ), @@ -488,8 +511,10 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=12, - order_by="relevancy", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + {"order_by": "relevancy", "sort_direction": SortDirection.DESCENDING}, + {"order_by": "post_date", "sort_direction": SortDirection.DESCENDING}, + ], ), [], ), @@ -498,8 +523,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=4, page_offset=1, - order_by="close_date", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "close_date", "sort_direction": SortDirection.ASCENDING} + ], ), [LOC_TEACHING, NASA_K12_DIVERSITY, DOC_SPACE_COAST, DOS_DIGITAL_LITERACY], ), @@ -507,8 +533,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=1, - order_by="close_date", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + {"order_by": "close_date", "sort_direction": SortDirection.DESCENDING} + ], ), [DOC_MANUFACTURING, NASA_SUPERSONIC, NASA_SPACE_FELLOWSHIP], ), @@ -517,8 +544,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=5, page_offset=2, - order_by="close_date", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "close_date", "sort_direction": SortDirection.ASCENDING} + ], ), [NASA_SUPERSONIC, DOC_MANUFACTURING, NASA_INNOVATIONS, LOC_HIGHER_EDUCATION], ), @@ -527,8 +555,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=5, page_offset=1, - order_by="agency_code", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "agency_code", "sort_direction": SortDirection.ASCENDING} + ], ), [ DOC_SPACE_COAST, @@ -542,8 +571,9 @@ def setup_search_data(self, opportunity_index, opportunity_index_alias, search_c get_search_request( page_size=3, page_offset=1, - order_by="agency_code", - sort_direction=SortDirection.DESCENDING, + sort_order=[ + {"order_by": "agency_code", "sort_direction": SortDirection.DESCENDING} + ], ), [NASA_SPACE_FELLOWSHIP, NASA_INNOVATIONS, NASA_SUPERSONIC], ), @@ -1402,7 +1432,10 @@ def test_search_validate_award_values_nullability_422( # and don't want to break this every time we adjust those. ( get_search_request( - order_by="opportunity_id", sort_direction=SortDirection.ASCENDING, query="space" + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], + query="space", ), [ NASA_SPACE_FELLOWSHIP, @@ -1414,38 +1447,45 @@ def test_search_validate_award_values_nullability_422( ), ( get_search_request( - order_by="opportunity_id", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], query="43.008", ), [NASA_SPACE_FELLOWSHIP, NASA_K12_DIVERSITY, LOC_TEACHING], ), ( get_search_request( - order_by="opportunity_id", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], query="012ADV*", ), [LOC_TEACHING, LOC_HIGHER_EDUCATION], ), ( get_search_request( - order_by="opportunity_id", sort_direction=SortDirection.ASCENDING, query="DOC*" + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], + query="DOC*", ), [DOC_SPACE_COAST, DOC_MANUFACTURING], ), ( get_search_request( - order_by="opportunity_id", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], query="Aeronautics", ), [NASA_SUPERSONIC], ), ( get_search_request( - order_by="opportunity_id", - sort_direction=SortDirection.ASCENDING, + sort_order=[ + {"order_by": "opportunity_id", "sort_direction": SortDirection.ASCENDING} + ], query="literacy", ), [LOC_TEACHING, DOS_DIGITAL_LITERACY], diff --git a/api/tests/src/api/users/test_user_save_search_post.py b/api/tests/src/api/users/test_user_save_search_post.py index c8e0ba2bd..26e4c2ee1 100644 --- a/api/tests/src/api/users/test_user_save_search_post.py +++ b/api/tests/src/api/users/test_user_save_search_post.py @@ -97,9 +97,13 @@ def test_user_save_search_post(client, user, user_auth_token, enable_factory_cre "format": "json", "filters": {"agency": {"one_of": ["LOC"]}, "funding_instrument": {"one_of": ["grant"]}}, "pagination": { - "order_by": "opportunity_id", "page_size": 25, "page_offset": 1, - "sort_direction": "ascending", + "sort_order": [ + { + "order_by": "opportunity_id", + "sort_direction": "ascending", + } + ], }, } From 8c972eb6c3983bb8fc085849fdcc52589f6b57f4 Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Thu, 30 Jan 2025 20:55:54 +0000 Subject: [PATCH 04/15] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 152 ++++++++++++++++++++++++++++---------- 1 file changed, 115 insertions(+), 37 deletions(-) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 8dc5c738e..522bb5d4d 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -81,10 +81,11 @@ paths: summary: No filters value: pagination: - order_by: created_at + sort_order: + - order_by: created_at + sort_direction: descending page_offset: 1 page_size: 25 - sort_direction: descending security: - ApiKeyAuth: [] /v1/agencies: @@ -122,10 +123,11 @@ paths: summary: No filters value: pagination: - order_by: created_at + sort_order: + - order_by: created_at + sort_direction: descending page_offset: 1 page_size: 25 - sort_direction: descending security: - ApiKeyAuth: [] /v1/users/login: @@ -249,10 +251,11 @@ paths: summary: No filters value: pagination: - order_by: opportunity_id + sort_order: + - order_by: opportunity_id + sort_direction: ascending page_offset: 1 page_size: 25 - sort_direction: ascending example2: summary: All filters value: @@ -286,10 +289,11 @@ paths: close_date: start_date: '2024-01-01' pagination: - order_by: opportunity_id + sort_order: + - order_by: opportunity_id + sort_direction: ascending page_offset: 1 page_size: 25 - sort_direction: descending example3: summary: Query & opportunity_status filters value: @@ -300,10 +304,11 @@ paths: - forecasted - posted pagination: - order_by: opportunity_id + sort_order: + - order_by: opportunity_id + sort_direction: ascending page_offset: 1 page_size: 25 - sort_direction: descending example4: summary: CSV file response value: @@ -314,10 +319,11 @@ paths: - forecasted - posted pagination: - order_by: opportunity_id + sort_order: + - order_by: opportunity_id + sort_direction: ascending page_offset: 1 page_size: 100 - sort_direction: ascending example5: summary: Filter by award fields value: @@ -332,12 +338,13 @@ paths: min: 100000 max: 250000 pagination: - order_by: opportunity_id + sort_order: + - order_by: opportunity_id + sort_direction: ascending page_offset: 1 page_size: 25 - sort_direction: descending example6: - summary: FIlter by assistance listing numbers + summary: Filter by assistance listing numbers value: filters: assistance_listing_number: @@ -345,10 +352,23 @@ paths: - '43.001' - '47.049' pagination: - order_by: opportunity_id + sort_order: + - order_by: opportunity_id + sort_direction: ascending page_offset: 1 page_size: 25 - sort_direction: descending + example7: + summary: Primary sort agency_code desc, secondary sort opportunity_id + asc + value: + pagination: + page_offset: 1 + page_size: 25 + sort_order: + - order_by: agency_code + sort_direction: descending + - order_by: opportunity_id + sort_direction: ascending security: - ApiKeyAuth: [] /v1/users/{user_id}: @@ -819,7 +839,7 @@ components: - object allOf: - $ref: '#/components/schemas/DateRange' - ExtractMetadataPaginationV1: + SortOrderExtractMetadataPaginationV1: type: object properties: order_by: @@ -834,6 +854,22 @@ components: - descending type: - string + required: + - order_by + - sort_direction + ExtractMetadataPaginationV1: + type: object + properties: + sort_order: + type: array + minItems: 1 + maxItems: 5 + description: The list of sorting rules + items: + type: + - object + allOf: + - $ref: '#/components/schemas/SortOrderExtractMetadataPaginationV1' page_size: type: integer minimum: 1 @@ -846,10 +882,9 @@ components: description: The page number to fetch, starts counting from 1 example: 1 required: - - order_by - page_offset - page_size - - sort_direction + - sort_order ExtractMetadataRequest: type: object properties: @@ -875,6 +910,20 @@ components: - $ref: '#/components/schemas/ExtractMetadataPaginationV1' required: - pagination + SortOrder: + type: object + properties: + order_by: + type: string + description: The field that the records were sorted by + example: id + sort_direction: + description: The direction the records are sorted + enum: + - ascending + - descending + type: + - string PaginationInfo: type: object properties: @@ -894,17 +943,14 @@ components: type: integer description: The total number of pages that can be fetched example: 2 - order_by: - type: string - description: The field that the records were sorted by - example: id - sort_direction: - description: The direction the records are sorted - enum: - - ascending - - descending - type: - - string + sort_order: + type: array + description: The sort order passed in originally + items: + type: + - object + allOf: + - $ref: '#/components/schemas/SortOrder' ExtractMetadataResponse: type: object properties: @@ -961,7 +1007,7 @@ components: properties: agency_id: type: integer - AgencyPaginationV1: + SortOrderAgencyPaginationV1: type: object properties: order_by: @@ -976,6 +1022,22 @@ components: - descending type: - string + required: + - order_by + - sort_direction + AgencyPaginationV1: + type: object + properties: + sort_order: + type: array + minItems: 1 + maxItems: 5 + description: The list of sorting rules + items: + type: + - object + allOf: + - $ref: '#/components/schemas/SortOrderAgencyPaginationV1' page_size: type: integer minimum: 1 @@ -988,10 +1050,9 @@ components: description: The page number to fetch, starts counting from 1 example: 1 required: - - order_by - page_offset - page_size - - sort_direction + - sort_order AgencyListRequest: type: object properties: @@ -1447,7 +1508,7 @@ components: - agency type: - string - OpportunityPaginationV1: + SortOrderOpportunityPaginationV1: type: object properties: order_by: @@ -1468,6 +1529,25 @@ components: - descending type: - string + required: + - order_by + - sort_direction + OpportunityPaginationV1: + type: object + properties: + sort_order: + type: array + default: + - order_by: opportunity_id + sort_direction: descending + minItems: 1 + maxItems: 5 + description: The list of sorting rules + items: + type: + - object + allOf: + - $ref: '#/components/schemas/SortOrderOpportunityPaginationV1' page_size: type: integer minimum: 1 @@ -1480,10 +1560,8 @@ components: description: The page number to fetch, starts counting from 1 example: 1 required: - - order_by - page_offset - page_size - - sort_direction OpportunitySearchRequestV1: type: object properties: From c68d065248583db5f73298c9181b9dc070a135dc Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Thu, 6 Feb 2025 18:05:14 +0000 Subject: [PATCH 05/15] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 56cb0ff37..3b41ebe4e 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -1561,6 +1561,8 @@ components: description: Query string which searches against several text fields example: research query_operator: + default: !!python/object/apply:src.api.opportunities_v1.opportunity_schemas.SearchQueryOperator + - AND description: Query operator for combining search conditions example: OR enum: @@ -2429,3 +2431,4 @@ components: type: apiKey in: header name: X-SGG-Token + From a37a9e8fd6931a1b83c0cccc80376ff4c7376f8d Mon Sep 17 00:00:00 2001 From: bruk Date: Fri, 7 Feb 2025 14:16:52 -0500 Subject: [PATCH 06/15] update schema --- api/src/api/agencies_v1/agency_schema.py | 3 ++- api/src/services/agencies_v1/get_agencies.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/api/agencies_v1/agency_schema.py b/api/src/api/agencies_v1/agency_schema.py index 2333b3ee2..5e01de992 100644 --- a/api/src/api/agencies_v1/agency_schema.py +++ b/api/src/api/agencies_v1/agency_schema.py @@ -12,7 +12,8 @@ class AgencyListRequestSchema(Schema): pagination = fields.Nested( generate_pagination_schema( "AgencyPaginationV1Schema", - ["created_at"], + ["agency_code, agency_name, created_at"], + default_sort_order=[{"order_by": "created_at", "sort_direction": "descending"}], ), required=True, ) diff --git a/api/src/services/agencies_v1/get_agencies.py b/api/src/services/agencies_v1/get_agencies.py index 9348522b8..c3dd100c6 100644 --- a/api/src/services/agencies_v1/get_agencies.py +++ b/api/src/services/agencies_v1/get_agencies.py @@ -27,13 +27,15 @@ class AgencyListParams(BaseModel): def get_agencies( db_session: db.Session, list_params: AgencyListParams ) -> Tuple[Sequence[Agency], PaginationInfo]: + search_params = AgencyListParams.model_validate(list_params) + stmt = ( select(Agency).options(joinedload(Agency.top_level_agency), joinedload("*")) # Exclude test agencies .where(Agency.is_test_agency.isnot(True)) ) - # TODO https://github.com/HHS/simpler-grants-gov/issues/3697 + # use the sorting parameters from the request stmt.order_by(asc("agency_code")) From 6318297c52e75a4e2a1a5eb8b6308143d60678a3 Mon Sep 17 00:00:00 2001 From: bruk Date: Mon, 10 Feb 2025 09:48:57 -0500 Subject: [PATCH 07/15] cleanup --- api/src/api/agencies_v1/agency_schema.py | 4 ++-- api/src/services/agencies_v1/get_agencies.py | 16 +++++++++++----- .../src/api/agencies_v1/test_agencies_routes.py | 12 ++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/api/src/api/agencies_v1/agency_schema.py b/api/src/api/agencies_v1/agency_schema.py index 5e01de992..3e8841511 100644 --- a/api/src/api/agencies_v1/agency_schema.py +++ b/api/src/api/agencies_v1/agency_schema.py @@ -12,8 +12,8 @@ class AgencyListRequestSchema(Schema): pagination = fields.Nested( generate_pagination_schema( "AgencyPaginationV1Schema", - ["agency_code, agency_name, created_at"], - default_sort_order=[{"order_by": "created_at", "sort_direction": "descending"}], + ["agency_code", "agency_name", "created_at"], + default_sort_order=[{"order_by": "agency_code", "sort_direction": "ascending"}], ), required=True, ) diff --git a/api/src/services/agencies_v1/get_agencies.py b/api/src/services/agencies_v1/get_agencies.py index c3dd100c6..e87e53bd1 100644 --- a/api/src/services/agencies_v1/get_agencies.py +++ b/api/src/services/agencies_v1/get_agencies.py @@ -2,12 +2,12 @@ from typing import Sequence, Tuple from pydantic import BaseModel, Field -from sqlalchemy import asc, select +from sqlalchemy import asc, desc, select from sqlalchemy.orm import joinedload import src.adapters.db as db from src.db.models.agency_models import Agency -from src.pagination.pagination_models import PaginationInfo, PaginationParams +from src.pagination.pagination_models import PaginationInfo, PaginationParams, SortDirection from src.pagination.paginator import Paginator logger = logging.getLogger(__name__) @@ -27,7 +27,6 @@ class AgencyListParams(BaseModel): def get_agencies( db_session: db.Session, list_params: AgencyListParams ) -> Tuple[Sequence[Agency], PaginationInfo]: - search_params = AgencyListParams.model_validate(list_params) stmt = ( select(Agency).options(joinedload(Agency.top_level_agency), joinedload("*")) @@ -35,9 +34,16 @@ def get_agencies( .where(Agency.is_test_agency.isnot(True)) ) - + order_cols: list = [] # use the sorting parameters from the request - stmt.order_by(asc("agency_code")) + for order in list_params.pagination.sort_order: + column = getattr(Agency, order.order_by) + if order.sort_direction == SortDirection.ASCENDING: + order_cols.append(asc(column)) + elif order.sort_direction == SortDirection.DESCENDING: + order_cols.append(desc(column)) + + stmt = stmt.order_by(*order_cols) if list_params.filters: if list_params.filters.agency_name: diff --git a/api/tests/src/api/agencies_v1/test_agencies_routes.py b/api/tests/src/api/agencies_v1/test_agencies_routes.py index d4dbdaeef..88cc21440 100644 --- a/api/tests/src/api/agencies_v1/test_agencies_routes.py +++ b/api/tests/src/api/agencies_v1/test_agencies_routes.py @@ -32,11 +32,11 @@ def test_agencies_get_default_dates( payload = { "filters": {}, "pagination": { - "page": 1, "page_size": 10, "page_offset": 1, - "order_by": "created_at", - "sort_direction": "descending", + "sort_order": [ + {"order_by": "created_at", "sort_direction": "descending"}, + ], }, } response = client.post("/v1/agencies", headers={"X-Auth": api_auth_token}, json=payload) @@ -60,11 +60,11 @@ def test_agencies_get_with_sub_agencies( payload = { "filters": {}, "pagination": { - "page": 1, "page_size": 10, "page_offset": 1, - "order_by": "created_at", - "sort_direction": "descending", + "sort_order": [ + {"order_by": "created_at", "sort_direction": "descending"}, + ], }, } From 5519a09bf792e23b1111c1ca52bf9d8e624b1f47 Mon Sep 17 00:00:00 2001 From: bruk Date: Mon, 10 Feb 2025 11:23:18 -0500 Subject: [PATCH 08/15] wip --- api/src/api/users/user_routes.py | 8 +++-- api/src/api/users/user_schemas.py | 12 ++++++++ .../services/users/get_saved_opportunities.py | 30 ++++++++++++------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 319be99a9..7d2637cc0 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -14,6 +14,7 @@ UserDeleteSavedOpportunityResponseSchema, UserDeleteSavedSearchResponseSchema, UserGetResponseSchema, + UserSavedOpportunitiesRequestSchema, UserSavedOpportunitiesResponseSchema, UserSavedSearchesResponseSchema, UserSaveOpportunityRequestSchema, @@ -224,12 +225,13 @@ def user_delete_saved_opportunity( return response.ApiResponse(message="Success") -@user_blueprint.get("//saved-opportunities") +@user_blueprint.post("//saved-opportunities/list") +@user_blueprint.input(UserSavedOpportunitiesRequestSchema, location="json") @user_blueprint.output(UserSavedOpportunitiesResponseSchema) @user_blueprint.doc(responses=[200, 401]) @user_blueprint.auth_required(api_jwt_auth) @flask_db.with_db_session() -def user_get_saved_opportunities(db_session: db.Session, user_id: UUID) -> response.ApiResponse: +def user_get_saved_opportunities(db_session: db.Session, user_id: UUID, json_data: dict) -> response.ApiResponse: logger.info("GET /v1/users/:user_id/saved-opportunities") user_token_session: UserTokenSession = api_jwt_auth.get_user_token_session() @@ -239,7 +241,7 @@ def user_get_saved_opportunities(db_session: db.Session, user_id: UUID) -> respo raise_flask_error(401, "Unauthorized user") # Get all saved opportunities for the user with their related opportunity data - saved_opportunities = get_saved_opportunities(db_session, user_id) + saved_opportunities = get_saved_opportunities(db_session, user_id, json_data) return response.ApiResponse(message="Success", data=saved_opportunities) diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index eba898bfe..9e5e8a77a 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -5,6 +5,7 @@ from src.api.schemas.extension import Schema, fields from src.api.schemas.response_schema import AbstractResponseSchema from src.constants.lookup_constants import ExternalUserType +from src.pagination.pagination_schema import generate_pagination_schema class UserTokenHeaderSchema(Schema): @@ -85,6 +86,17 @@ class UserDeleteSavedOpportunityResponseSchema(AbstractResponseSchema): data = fields.MixinField(metadata={"example": None}) +class UserSavedOpportunitiesRequestSchema(Schema): + pagination = fields.Nested( + generate_pagination_schema( + "UserGetSavedOpportunityPaginationV1Schema", + ["created_at", "updated_at", "opportunity_title", "close_date"], + default_sort_order=[{"order_by": "created_at", "sort_direction": "descending"}], + ), + required=True, + ) + + class UserSavedOpportunitiesResponseSchema(AbstractResponseSchema): data = fields.List( fields.Nested(SavedOpportunityResponseV1Schema), diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 4cfd32a77..85128a2d4 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -1,27 +1,35 @@ import logging from uuid import UUID +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import selectinload from src.adapters import db from src.db.models.opportunity_models import Opportunity from src.db.models.user_models import UserSavedOpportunity +from src.pagination.pagination_models import PaginationParams logger = logging.getLogger(__name__) +class SavedOpportunityListParams(BaseModel): + opportunity: PaginationParams -def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> list[Opportunity]: +def get_saved_opportunities(db_session: db.Session, user_id: UUID, raw_opportunity_params: dict ) -> list[Opportunity]: logger.info(f"Getting saved opportunities for user {user_id}") - saved_opportunities = ( - db_session.execute( - select(Opportunity) - .join(UserSavedOpportunity) - .where(UserSavedOpportunity.user_id == user_id) - .options(selectinload("*")) - ) - .scalars() - .all() - ) + opportunity_params = SavedOpportunityListParams.model_validate(raw_opportunity_params) + + stmt = select(Opportunity).join(UserSavedOpportunity).where(UserSavedOpportunity.user_id == user_id).options(selectinload("*")) + + order_cols: list = [] + for order in opportunity_params.pagination.sort_order: + column = getattr(opportunity_params, order.order_by) + + if order.sort_direction == SortDirection.ASCENDING: + order_cols.append(asc(column)) + elif order.sort_direction == SortDirection.DESCENDING: + order_cols.append(desc(column)) + + return list(saved_opportunities) From 04d33a4d4e3678711666b84d4cad8babcd1dd220 Mon Sep 17 00:00:00 2001 From: bruk Date: Mon, 10 Feb 2025 11:54:45 -0500 Subject: [PATCH 09/15] use util func --- api/src/api/users/user_routes.py | 5 ++-- .../services/users/get_saved_opportunities.py | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 7d2637cc0..1579147ca 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -241,9 +241,10 @@ def user_get_saved_opportunities(db_session: db.Session, user_id: UUID, json_dat raise_flask_error(401, "Unauthorized user") # Get all saved opportunities for the user with their related opportunity data - saved_opportunities = get_saved_opportunities(db_session, user_id, json_data) + saved_opportunities, pagination_info = get_saved_opportunities(db_session, user_id, json_data) - return response.ApiResponse(message="Success", data=saved_opportunities) + return response.ApiResponse(message="Success", data=saved_opportunities, pagination_info=pagination_info, +) @user_blueprint.post("//saved-searches") diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 85128a2d4..0693c2a2e 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -1,4 +1,5 @@ import logging +from typing import Sequence, Tuple from uuid import UUID from pydantic import BaseModel @@ -7,29 +8,34 @@ from src.adapters import db from src.db.models.opportunity_models import Opportunity -from src.db.models.user_models import UserSavedOpportunity -from src.pagination.pagination_models import PaginationParams +from src.db.models.user_models import UserSavedOpportunity, UserSavedSearch +from src.pagination.pagination_models import PaginationParams, PaginationInfo +from src.pagination.paginator import Paginator logger = logging.getLogger(__name__) class SavedOpportunityListParams(BaseModel): opportunity: PaginationParams -def get_saved_opportunities(db_session: db.Session, user_id: UUID, raw_opportunity_params: dict ) -> list[Opportunity]: +def get_saved_opportunities(db_session: db.Session, user_id: UUID, raw_opportunity_params: dict ) -> Tuple[Sequence[Opportunity], PaginationInfo]: logger.info(f"Getting saved opportunities for user {user_id}") opportunity_params = SavedOpportunityListParams.model_validate(raw_opportunity_params) stmt = select(Opportunity).join(UserSavedOpportunity).where(UserSavedOpportunity.user_id == user_id).options(selectinload("*")) - order_cols: list = [] - for order in opportunity_params.pagination.sort_order: - column = getattr(opportunity_params, order.order_by) + stmt = apply_sorting(stmt, UserSavedOpportunity, opportunity_params.pagination.sort_order) + + paginator: Paginator[UserSavedSearch] = Paginator( + UserSavedOpportunity, stmt, db_session, page_size=opportunity_params.pagination.page_size + ) + + paginated_search = paginator.page_at(page_offset=opportunity_params.pagination.page_offset) + + pagination_info = PaginationInfo.from_pagination_params(opportunity_params.pagination, paginator) + + return paginated_search, pagination_info - if order.sort_direction == SortDirection.ASCENDING: - order_cols.append(asc(column)) - elif order.sort_direction == SortDirection.DESCENDING: - order_cols.append(desc(column)) return list(saved_opportunities) From 2e3e87d6fb167c1683f74aa6944a2101dad6fd9a Mon Sep 17 00:00:00 2001 From: bruk Date: Mon, 10 Feb 2025 20:47:44 -0500 Subject: [PATCH 10/15] update func and test --- api/src/api/users/user_routes.py | 13 ++- .../services/users/get_saved_opportunities.py | 79 +++++++++++++++---- .../test_user_saved_opportunities_get.py | 78 ++++++++++++++++-- 3 files changed, 145 insertions(+), 25 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 1579147ca..8a03a114b 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -231,8 +231,10 @@ def user_delete_saved_opportunity( @user_blueprint.doc(responses=[200, 401]) @user_blueprint.auth_required(api_jwt_auth) @flask_db.with_db_session() -def user_get_saved_opportunities(db_session: db.Session, user_id: UUID, json_data: dict) -> response.ApiResponse: - logger.info("GET /v1/users/:user_id/saved-opportunities") +def user_get_saved_opportunities( + db_session: db.Session, user_id: UUID, json_data: dict +) -> response.ApiResponse: + logger.info("POST /v1/users/:user_id/saved-opportunities/list") user_token_session: UserTokenSession = api_jwt_auth.get_user_token_session() @@ -243,8 +245,11 @@ def user_get_saved_opportunities(db_session: db.Session, user_id: UUID, json_dat # Get all saved opportunities for the user with their related opportunity data saved_opportunities, pagination_info = get_saved_opportunities(db_session, user_id, json_data) - return response.ApiResponse(message="Success", data=saved_opportunities, pagination_info=pagination_info, -) + return response.ApiResponse( + message="Success", + data=saved_opportunities, + pagination_info=pagination_info, + ) @user_blueprint.post("//saved-searches") diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 0693c2a2e..711406b11 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -3,39 +3,88 @@ from uuid import UUID from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import asc, desc, select from sqlalchemy.orm import selectinload +from sqlalchemy.sql import Select from src.adapters import db -from src.db.models.opportunity_models import Opportunity -from src.db.models.user_models import UserSavedOpportunity, UserSavedSearch -from src.pagination.pagination_models import PaginationParams, PaginationInfo +from src.db.models.opportunity_models import ( + CurrentOpportunitySummary, + Opportunity, + OpportunitySummary, +) +from src.db.models.user_models import UserSavedOpportunity +from src.pagination.pagination_models import PaginationInfo, PaginationParams, SortDirection from src.pagination.paginator import Paginator logger = logging.getLogger(__name__) + class SavedOpportunityListParams(BaseModel): - opportunity: PaginationParams + pagination: PaginationParams + + +def add_sort_order(stmt: Select, sort_order: list) -> Select: + model_mapping = { + "opportunity_title": Opportunity.opportunity_title, + "close_date": OpportunitySummary.close_date, + } + + order_cols: list = [] + for order in sort_order: + column = ( + model_mapping.get(order.order_by) + if order.order_by in model_mapping + else getattr(UserSavedOpportunity, order.order_by) + ) + + if ( + order.sort_direction == SortDirection.ASCENDING + ): # defaults to nulls at the end when asc order + order_cols.append(asc(column)) + elif order.sort_direction == SortDirection.DESCENDING: + order_col = desc(column) + if order.order_by == "close_date": + order_cols.append(order_col.nullslast()) + order_cols.append(order_col) + + return stmt.order_by(*order_cols) -def get_saved_opportunities(db_session: db.Session, user_id: UUID, raw_opportunity_params: dict ) -> Tuple[Sequence[Opportunity], PaginationInfo]: + +def get_saved_opportunities( + db_session: db.Session, user_id: UUID, raw_opportunity_params: dict +) -> Tuple[Sequence[Opportunity], PaginationInfo]: logger.info(f"Getting saved opportunities for user {user_id}") opportunity_params = SavedOpportunityListParams.model_validate(raw_opportunity_params) - stmt = select(Opportunity).join(UserSavedOpportunity).where(UserSavedOpportunity.user_id == user_id).options(selectinload("*")) + stmt = ( + select(Opportunity) + .join( + UserSavedOpportunity, UserSavedOpportunity.opportunity_id == Opportunity.opportunity_id + ) + .join( + CurrentOpportunitySummary, + CurrentOpportunitySummary.opportunity_id == Opportunity.opportunity_id, + ) + .join( + OpportunitySummary, + CurrentOpportunitySummary.opportunity_summary_id + == OpportunitySummary.opportunity_summary_id, + ) + .options(selectinload("*")) + ) - stmt = apply_sorting(stmt, UserSavedOpportunity, opportunity_params.pagination.sort_order) + stmt = add_sort_order(stmt, opportunity_params.pagination.sort_order) - paginator: Paginator[UserSavedSearch] = Paginator( - UserSavedOpportunity, stmt, db_session, page_size=opportunity_params.pagination.page_size + paginator: Paginator[Opportunity] = Paginator( + Opportunity, stmt, db_session, page_size=opportunity_params.pagination.page_size ) paginated_search = paginator.page_at(page_offset=opportunity_params.pagination.page_offset) - pagination_info = PaginationInfo.from_pagination_params(opportunity_params.pagination, paginator) + pagination_info = PaginationInfo.from_pagination_params( + opportunity_params.pagination, paginator + ) return paginated_search, pagination_info - - - - return list(saved_opportunities) diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py index 2167cdfe3..fcd9615ef 100644 --- a/api/tests/src/api/users/test_user_saved_opportunities_get.py +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -35,8 +35,15 @@ def test_user_get_saved_opportunities( UserSavedOpportunityFactory.create(user=user, opportunity=opportunity) # Make the request - response = client.get( - f"/v1/users/{user.user_id}/saved-opportunities", headers={"X-SGG-Token": user_auth_token} + response = client.post( + f"/v1/users/{user.user_id}/saved-opportunities/list", + headers={"X-SGG-Token": user_auth_token}, + json={ + "pagination": { + "page_offset": 1, + "page_size": 25, + } + }, ) assert response.status_code == 200 @@ -57,8 +64,15 @@ def test_get_saved_opportunities_unauthorized_user(client, enable_factory_create UserSavedOpportunityFactory.create(user=other_user, opportunity=opportunity) # Try to get the other user's saved opportunities - response = client.get( - f"/v1/users/{other_user.user_id}/saved-opportunities", headers={"X-SGG-Token": token} + response = client.post( + f"/v1/users/{other_user.user_id}/saved-opportunities/list", + headers={"X-SGG-Token": token}, + json={ + "pagination": { + "page_offset": 1, + "page_size": 25, + } + }, ) assert response.status_code == 401 @@ -66,9 +80,61 @@ def test_get_saved_opportunities_unauthorized_user(client, enable_factory_create # Try with a non-existent user ID different_user_id = "123e4567-e89b-12d3-a456-426614174000" - response = client.get( - f"/v1/users/{different_user_id}/saved-opportunities", headers={"X-SGG-Token": token} + response = client.post( + f"/v1/users/{different_user_id}/saved-opportunities/list", + headers={"X-SGG-Token": token}, + json={ + "pagination": { + "page_offset": 1, + "page_size": 25, + } + }, ) assert response.status_code == 401 assert response.json["message"] == "Unauthorized user" + + +def test_get_saved_opportunities_pagination( + client, enable_factory_create, db_session, user, user_auth_token +): + """Test multi sorting in pagination works""" + # Create multiple opportunities and save it for the user + opportunity_1 = OpportunityFactory.create(opportunity_title="Test Opportunity One") + opportunity_2 = OpportunityFactory.create(opportunity_title="Test Opportunity Two") + opportunity_3 = OpportunityFactory.create(opportunity_title="Test Opportunity Three") + + UserSavedOpportunityFactory.create( + user=user, opportunity=opportunity_1, updated_at="2024-10-01" + ) + UserSavedOpportunityFactory.create( + user=user, opportunity=opportunity_2, updated_at="2024-05-01" + ) + UserSavedOpportunityFactory.create( + user=user, opportunity=opportunity_3, updated_at="2024-05-01" + ) + + # Make the request + response = client.post( + f"/v1/users/{user.user_id}/saved-opportunities/list", + headers={"X-SGG-Token": user_auth_token}, + json={ + "pagination": { + "page_offset": 1, + "page_size": 25, + "sort_order": [ + {"order_by": "updated_at", "sort_direction": "ascending"}, + {"order_by": "opportunity_title", "sort_direction": "ascending"}, + ], + } + }, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Success" + + # Verify the searches are returned in ascending order by close_date, then by opportunity_title + opportunities = response.json["data"] + assert [opp["opportunity_id"] for opp in opportunities] == [ + opp.opportunity_id for opp in [opportunity_3, opportunity_2, opportunity_1] + ] From 5010dd3e7c31858165ad0b6834c118feb23b2f48 Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Tue, 11 Feb 2025 15:31:41 +0000 Subject: [PATCH 11/15] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 271 ++++++++++++++++++++++++-------------- 1 file changed, 174 insertions(+), 97 deletions(-) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 3aff7def1..aeb0e36b2 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -495,37 +495,6 @@ paths: security: - ApiJwtAuth: [] /v1/users/{user_id}/saved-opportunities: - get: - parameters: - - in: path - name: user_id - schema: - type: string - required: true - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/UserSavedOpportunitiesResponse' - description: Successful response - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: Authentication error - '404': - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - description: Not found - tags: - - User v1 - summary: User Get Saved Opportunities - security: - - ApiJwtAuth: [] post: parameters: - in: path @@ -659,6 +628,49 @@ paths: ' security: - ApiKeyAuth: [] + /v1/users/{user_id}/saved-opportunities/list: + post: + parameters: + - in: path + name: user_id + schema: + type: string + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSavedOpportunitiesResponse' + description: Successful response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Validation error + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Not found + tags: + - User v1 + summary: User Get Saved Opportunities + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserSavedOpportunitiesRequest' + security: + - ApiJwtAuth: [] /v1/users/{user_id}/saved-searches/{saved_search_id}: put: parameters: @@ -1072,6 +1084,8 @@ components: order_by: type: string enum: + - agency_code + - agency_name - created_at description: The field to sort the response by sort_direction: @@ -1089,6 +1103,9 @@ components: properties: sort_order: type: array + default: + - order_by: agency_code + sort_direction: ascending minItems: 1 maxItems: 5 description: The list of sorting rules @@ -1111,7 +1128,6 @@ components: required: - page_offset - page_size - - sort_order AgencyListRequest: type: object properties: @@ -2218,71 +2234,6 @@ components: type: integer description: The HTTP status code example: 200 - SavedOpportunitySummaryV1: - type: object - properties: - post_date: - type: string - format: date - description: The date the opportunity was posted - example: '2024-01-01' - close_date: - type: string - format: date - description: The date the opportunity will close - example: '2024-01-01' - is_forecast: - type: boolean - description: Whether the opportunity is forecasted - example: false - SavedOpportunityResponseV1: - type: object - properties: - opportunity_id: - type: integer - description: The ID of the saved opportunity - example: 1234 - opportunity_title: - type: - - string - - 'null' - description: The title of the opportunity - example: my title - opportunity_status: - description: The current status of the opportunity - example: !!python/object/apply:src.constants.lookup_constants.OpportunityStatus - - posted - enum: - - forecasted - - posted - - closed - - archived - type: - - string - summary: - type: - - object - allOf: - - $ref: '#/components/schemas/SavedOpportunitySummaryV1' - UserSavedOpportunitiesResponse: - type: object - properties: - message: - type: string - description: The message to return - example: Success - data: - type: array - description: List of saved opportunities - items: - type: - - object - allOf: - - $ref: '#/components/schemas/SavedOpportunityResponseV1' - status_code: - type: integer - description: The HTTP status code - example: 200 UserSaveOpportunityRequest: type: object properties: @@ -2437,6 +2388,132 @@ components: type: integer description: The HTTP status code example: 200 + SortOrderUserGetSavedOpportunityPaginationV1: + type: object + properties: + order_by: + type: string + enum: + - created_at + - updated_at + - opportunity_title + - close_date + description: The field to sort the response by + sort_direction: + description: Whether to sort the response ascending or descending + enum: + - ascending + - descending + type: + - string + required: + - order_by + - sort_direction + UserGetSavedOpportunityPaginationV1: + type: object + properties: + sort_order: + type: array + default: + - order_by: created_at + sort_direction: descending + minItems: 1 + maxItems: 5 + description: The list of sorting rules + items: + type: + - object + allOf: + - $ref: '#/components/schemas/SortOrderUserGetSavedOpportunityPaginationV1' + page_size: + type: integer + minimum: 1 + maximum: 5000 + description: The size of the page to fetch + example: 25 + page_offset: + type: integer + minimum: 1 + description: The page number to fetch, starts counting from 1 + example: 1 + required: + - page_offset + - page_size + UserSavedOpportunitiesRequest: + type: object + properties: + pagination: + type: + - object + allOf: + - $ref: '#/components/schemas/UserGetSavedOpportunityPaginationV1' + required: + - pagination + SavedOpportunitySummaryV1: + type: object + properties: + post_date: + type: string + format: date + description: The date the opportunity was posted + example: '2024-01-01' + close_date: + type: string + format: date + description: The date the opportunity will close + example: '2024-01-01' + is_forecast: + type: boolean + description: Whether the opportunity is forecasted + example: false + SavedOpportunityResponseV1: + type: object + properties: + opportunity_id: + type: integer + description: The ID of the saved opportunity + example: 1234 + opportunity_title: + type: + - string + - 'null' + description: The title of the opportunity + example: my title + opportunity_status: + description: The current status of the opportunity + example: !!python/object/apply:src.constants.lookup_constants.OpportunityStatus + - posted + enum: + - forecasted + - posted + - closed + - archived + type: + - string + summary: + type: + - object + allOf: + - $ref: '#/components/schemas/SavedOpportunitySummaryV1' + UserSavedOpportunitiesResponse: + type: object + properties: + message: + type: string + description: The message to return + example: Success + data: + type: array + description: List of saved opportunities + items: + type: + - object + allOf: + - $ref: '#/components/schemas/SavedOpportunityResponseV1' + status_code: + type: integer + description: The HTTP status code + example: 200 UserUpdateSavedSearchRequest: type: object properties: From bf126f0076cc48d4ab1dce94a9745928a467e038 Mon Sep 17 00:00:00 2001 From: bruk Date: Wed, 12 Feb 2025 11:56:10 -0500 Subject: [PATCH 12/15] add test case --- .../services/users/get_saved_opportunities.py | 2 +- .../test_user_saved_opportunities_get.py | 121 ++++++++++++++---- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 711406b11..dcdf2e017 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -45,7 +45,7 @@ def add_sort_order(stmt: Select, sort_order: list) -> Select: elif order.sort_direction == SortDirection.DESCENDING: order_col = desc(column) if order.order_by == "close_date": - order_cols.append(order_col.nullslast()) + order_col = order_col.nullslast() order_cols.append(order_col) return stmt.order_by(*order_cols) diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py index fcd9615ef..57f6d78f1 100644 --- a/api/tests/src/api/users/test_user_saved_opportunities_get.py +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -1,13 +1,79 @@ +from datetime import date + import pytest from src.auth.api_jwt_auth import create_jwt_for_user +from src.constants.lookup_constants import ( + ApplicantType, + FundingCategory, + FundingInstrument, + OpportunityStatus, +) from src.db.models.user_models import UserSavedOpportunity +from tests.src.api.opportunities_v1.test_opportunity_route_search import build_opp from tests.src.db.models.factories import ( OpportunityFactory, UserFactory, UserSavedOpportunityFactory, ) +AWARD = build_opp( + opportunity_title="Hutchinson and Sons 1972 award", + opportunity_number="ZW-29-AWD-622", + agency="USAID", + summary_description="The purpose of this Notice of Funding Opportunity (NOFO) is to support research into Insurance claims handler and how we might Innovative didactic hardware.", + opportunity_status=OpportunityStatus.FORECASTED, + assistance_listings=[("95.579", "Moore-Murray")], + applicant_types=[ApplicantType.OTHER], + funding_instruments=[FundingInstrument.GRANT], + funding_categories=[FundingCategory.SCIENCE_TECHNOLOGY_AND_OTHER_RESEARCH_AND_DEVELOPMENT], + post_date=date(2020, 12, 8), + close_date=date(2025, 12, 8), + is_cost_sharing=True, + expected_number_of_awards=1, + award_floor=42500, + award_ceiling=850000, + estimated_total_program_funding=6000, +) + +NATURE = build_opp( + opportunity_title="Research into Conservation officer, nature industry", + opportunity_number="IP-67-EXT-978", + agency="USAID", + summary_description="The purpose of this Notice of Funding Opportunity (NOFO) is to support research into Forensic psychologist and how we might Synchronized fault-tolerant workforce.", + opportunity_status=OpportunityStatus.FORECASTED, + assistance_listings=[("86.606", "Merritt, Williams and Church")], + applicant_types=[ApplicantType.OTHER], + funding_instruments=[FundingInstrument.GRANT], + funding_categories=[FundingCategory.SCIENCE_TECHNOLOGY_AND_OTHER_RESEARCH_AND_DEVELOPMENT], + post_date=date(2002, 10, 8), + close_date=date(2026, 12, 28), + is_cost_sharing=True, + expected_number_of_awards=1, + award_floor=502500, + award_ceiling=9050000, + estimated_total_program_funding=6000, +) + +EMBASSY = build_opp( + opportunity_title="Embassy program for Conservation officer, nature in Albania", + opportunity_number="USDOJ-61-543", + agency="USAID", + summary_description="
Method the home father forget million partner become. Short long after ready husband any.
", + opportunity_status=OpportunityStatus.FORECASTED, + assistance_listings=[("85.997", "Albania")], + applicant_types=[ApplicantType.OTHER], + funding_instruments=[FundingInstrument.GRANT], + funding_categories=[FundingCategory.SCIENCE_TECHNOLOGY_AND_OTHER_RESEARCH_AND_DEVELOPMENT], + post_date=date(2002, 10, 8), + close_date=None, + is_cost_sharing=True, + expected_number_of_awards=1, + award_floor=502500, + award_ceiling=9050000, + estimated_total_program_funding=6000, +) + @pytest.fixture def user(enable_factory_create, db_session): @@ -95,46 +161,57 @@ def test_get_saved_opportunities_unauthorized_user(client, enable_factory_create assert response.json["message"] == "Unauthorized user" -def test_get_saved_opportunities_pagination( - client, enable_factory_create, db_session, user, user_auth_token +@pytest.mark.parametrize( + "sort_order,expected_result", + [ + ( + # Multi-Sort + [ + {"order_by": "updated_at", "sort_direction": "ascending"}, + {"order_by": "opportunity_title", "sort_direction": "descending"}, + ], + [AWARD, NATURE, EMBASSY], + ), + # Order by close_date, None should be last + ( + [{"order_by": "close_date", "sort_direction": "ascending"}], + [AWARD, NATURE, EMBASSY], + ), + # Default order + (None, [EMBASSY, AWARD, NATURE]), + ], +) +def test_get_saved_opportunities_sorting( + client, enable_factory_create, db_session, user, user_auth_token, sort_order, expected_result ): - """Test multi sorting in pagination works""" - # Create multiple opportunities and save it for the user - opportunity_1 = OpportunityFactory.create(opportunity_title="Test Opportunity One") - opportunity_2 = OpportunityFactory.create(opportunity_title="Test Opportunity Two") - opportunity_3 = OpportunityFactory.create(opportunity_title="Test Opportunity Three") UserSavedOpportunityFactory.create( - user=user, opportunity=opportunity_1, updated_at="2024-10-01" + user=user, opportunity=NATURE, updated_at="2024-10-01", created_at="2024-01-01" ) UserSavedOpportunityFactory.create( - user=user, opportunity=opportunity_2, updated_at="2024-05-01" + user=user, opportunity=AWARD, updated_at="2024-05-01", created_at="2024-01-02" ) UserSavedOpportunityFactory.create( - user=user, opportunity=opportunity_3, updated_at="2024-05-01" + user=user, opportunity=EMBASSY, updated_at="2024-12-01", created_at="2024-01-03" ) # Make the request + pagination = {"pagination": {"page_offset": 1, "page_size": 25}} + if sort_order: + pagination["pagination"]["sort_order"] = sort_order + response = client.post( f"/v1/users/{user.user_id}/saved-opportunities/list", headers={"X-SGG-Token": user_auth_token}, - json={ - "pagination": { - "page_offset": 1, - "page_size": 25, - "sort_order": [ - {"order_by": "updated_at", "sort_direction": "ascending"}, - {"order_by": "opportunity_title", "sort_direction": "ascending"}, - ], - } - }, + json=pagination, ) assert response.status_code == 200 assert response.json["message"] == "Success" - # Verify the searches are returned in ascending order by close_date, then by opportunity_title opportunities = response.json["data"] + + assert len(opportunities) == len(expected_result) assert [opp["opportunity_id"] for opp in opportunities] == [ - opp.opportunity_id for opp in [opportunity_3, opportunity_2, opportunity_1] + opp.opportunity_id for opp in expected_result ] From 18f15a6305f862e562987e50893a310fb75220bf Mon Sep 17 00:00:00 2001 From: bruk Date: Wed, 12 Feb 2025 12:13:24 -0500 Subject: [PATCH 13/15] fix lint --- api/src/services/users/get_saved_opportunities.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index dcdf2e017..4b869610f 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -25,15 +25,12 @@ class SavedOpportunityListParams(BaseModel): def add_sort_order(stmt: Select, sort_order: list) -> Select: - model_mapping = { - "opportunity_title": Opportunity.opportunity_title, - "close_date": OpportunitySummary.close_date, - } + model_mapping = {"opportunity_title": Opportunity, "close_date": OpportunitySummary} order_cols: list = [] for order in sort_order: column = ( - model_mapping.get(order.order_by) + getattr(model_mapping[order.order_by], order.order_by) if order.order_by in model_mapping else getattr(UserSavedOpportunity, order.order_by) ) @@ -43,10 +40,10 @@ def add_sort_order(stmt: Select, sort_order: list) -> Select: ): # defaults to nulls at the end when asc order order_cols.append(asc(column)) elif order.sort_direction == SortDirection.DESCENDING: - order_col = desc(column) if order.order_by == "close_date": - order_col = order_col.nullslast() - order_cols.append(order_col) + order_cols.append(desc(column).nullslast()) + continue + order_cols.append(desc(column)) return stmt.order_by(*order_cols) From f5d233e59fa02e9e85eac954e8378a55a50fa50a Mon Sep 17 00:00:00 2001 From: bruk Date: Wed, 12 Feb 2025 12:39:50 -0500 Subject: [PATCH 14/15] use nulls_last(col) --- api/src/services/users/get_saved_opportunities.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 4b869610f..4df38e26d 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -3,7 +3,7 @@ from uuid import UUID from pydantic import BaseModel -from sqlalchemy import asc, desc, select +from sqlalchemy import asc, desc, nulls_last, select from sqlalchemy.orm import selectinload from sqlalchemy.sql import Select @@ -40,10 +40,9 @@ def add_sort_order(stmt: Select, sort_order: list) -> Select: ): # defaults to nulls at the end when asc order order_cols.append(asc(column)) elif order.sort_direction == SortDirection.DESCENDING: - if order.order_by == "close_date": - order_cols.append(desc(column).nullslast()) - continue - order_cols.append(desc(column)) + order_cols.append( + nulls_last(desc(column)) if order.order_by == "close_date" else desc(column) + ) return stmt.order_by(*order_cols) From 0d26f21c97a5dce22c0ba2ed1ee527732d87239f Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Wed, 19 Feb 2025 22:34:05 +0000 Subject: [PATCH 15/15] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 75 ++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 71a4617be..bc1d79472 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -580,6 +580,49 @@ paths: $ref: '#/components/schemas/UserSavedSearchesRequest' security: - ApiJwtAuth: [] + /v1/users/{user_id}/saved-opportunities/list: + post: + parameters: + - in: path + name: user_id + schema: + type: string + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSavedOpportunitiesResponse' + description: Successful response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Validation error + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Not found + tags: + - User v1 + summary: User Get Saved Opportunities + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserSavedOpportunitiesRequest' + security: + - ApiJwtAuth: [] /v1/users/{user_id}/saved-searches/{saved_search_id}: put: parameters: @@ -2267,38 +2310,6 @@ components: type: integer description: The HTTP status code example: 200 - OpportunityVersionV1: - type: object - properties: - opportunity: - type: - - object - allOf: - - $ref: '#/components/schemas/OpportunityV1' - forecasts: - type: array - items: - $ref: '#/components/schemas/OpportunitySummaryV1' - non_forecasts: - type: array - items: - $ref: '#/components/schemas/OpportunitySummaryV1' - OpportunityVersionsGetResponseV1: - type: object - properties: - message: - type: string - description: The message to return - example: Success - data: - type: - - object - allOf: - - $ref: '#/components/schemas/OpportunityVersionV1' - status_code: - type: integer - description: The HTTP status code - example: 200 SortOrderUserGetSavedOpportunityPaginationV1: type: object properties: