diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index eb72562dd..bc1d79472 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -495,7 +495,7 @@ paths: security: - ApiJwtAuth: [] /v1/users/{user_id}/saved-opportunities: - get: + post: parameters: - in: path name: user_id @@ -507,8 +507,14 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserSavedOpportunitiesResponse' + $ref: '#/components/schemas/UserSaveOpportunityResponse' description: Successful response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Validation error '401': content: application/json: @@ -523,9 +529,15 @@ paths: description: Not found tags: - User v1 - summary: User Get Saved Opportunities + summary: User Save Opportunity + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserSaveOpportunityRequest' security: - ApiJwtAuth: [] + /v1/users/{user_id}/saved-searches/list: post: parameters: - in: path @@ -538,7 +550,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserSaveOpportunityResponse' + $ref: '#/components/schemas/UserSavedSearchesResponse' description: Successful response '422': content: @@ -560,15 +572,15 @@ paths: description: Not found tags: - User v1 - summary: User Save Opportunity + summary: User Get Saved Searches requestBody: content: application/json: schema: - $ref: '#/components/schemas/UserSaveOpportunityRequest' + $ref: '#/components/schemas/UserSavedSearchesRequest' security: - ApiJwtAuth: [] - /v1/users/{user_id}/saved-searches/list: + /v1/users/{user_id}/saved-opportunities/list: post: parameters: - in: path @@ -581,7 +593,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/UserSavedSearchesResponse' + $ref: '#/components/schemas/UserSavedOpportunitiesResponse' description: Successful response '422': content: @@ -603,12 +615,12 @@ paths: description: Not found tags: - User v1 - summary: User Get Saved Searches + summary: User Get Saved Opportunities requestBody: content: application/json: schema: - $ref: '#/components/schemas/UserSavedSearchesRequest' + $ref: '#/components/schemas/UserSavedOpportunitiesRequest' security: - ApiJwtAuth: [] /v1/users/{user_id}/saved-searches/{saved_search_id}: @@ -2176,71 +2188,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: @@ -2363,6 +2310,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: diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 4e8659283..e2ee03555 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, UserSavedSearchesRequestSchema, UserSavedSearchesResponseSchema, @@ -225,13 +226,16 @@ 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: - 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() @@ -240,9 +244,13 @@ 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, 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/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index e917aea01..6d99fca4c 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -86,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..4df38e26d 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -1,27 +1,86 @@ import logging +from typing import Sequence, Tuple from uuid import UUID -from sqlalchemy import select +from pydantic import BaseModel +from sqlalchemy import asc, desc, nulls_last, 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.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__) -def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> list[Opportunity]: +class SavedOpportunityListParams(BaseModel): + pagination: PaginationParams + + +def add_sort_order(stmt: Select, sort_order: list) -> Select: + model_mapping = {"opportunity_title": Opportunity, "close_date": OpportunitySummary} + + order_cols: list = [] + for order in sort_order: + column = ( + getattr(model_mapping[order.order_by], 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_cols.append( + nulls_last(desc(column)) if order.order_by == "close_date" else desc(column) + ) + + 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]: 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("*")) + opportunity_params = SavedOpportunityListParams.model_validate(raw_opportunity_params) + + stmt = ( + select(Opportunity) + .join( + UserSavedOpportunity, UserSavedOpportunity.opportunity_id == Opportunity.opportunity_id ) - .scalars() - .all() + .join( + CurrentOpportunitySummary, + CurrentOpportunitySummary.opportunity_id == Opportunity.opportunity_id, + ) + .join( + OpportunitySummary, + CurrentOpportunitySummary.opportunity_summary_id + == OpportunitySummary.opportunity_summary_id, + ) + .options(selectinload("*")) + ) + + stmt = add_sort_order(stmt, opportunity_params.pagination.sort_order) + + paginator: Paginator[Opportunity] = Paginator( + Opportunity, stmt, db_session, page_size=opportunity_params.pagination.page_size ) - return list(saved_opportunities) + + 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 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..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): @@ -35,8 +101,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 +130,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 +146,72 @@ 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" + + +@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 +): + + UserSavedOpportunityFactory.create( + user=user, opportunity=NATURE, updated_at="2024-10-01", created_at="2024-01-01" + ) + UserSavedOpportunityFactory.create( + user=user, opportunity=AWARD, updated_at="2024-05-01", created_at="2024-01-02" + ) + UserSavedOpportunityFactory.create( + 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, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Success" + + opportunities = response.json["data"] + + assert len(opportunities) == len(expected_result) + assert [opp["opportunity_id"] for opp in opportunities] == [ + opp.opportunity_id for opp in expected_result + ]