From 09ca63ed3b05726a2706d7320587445a6fd75427 Mon Sep 17 00:00:00 2001 From: Michael Chouinard <46358556+chouinar@users.noreply.github.com> Date: Wed, 22 May 2024 14:05:43 -0400 Subject: [PATCH] [Issue #2093] Setup the opportunity v1 endpoint which will be backed by the index (navapbc/simpler-grants-gov#44) Fixes #2093 Made a new set of v1 endpoints that are basically copy-pastes of the v0.1 opportunity endpoints Some changes I want to make to the schemas wouldn't make sense without the search index (eg. adding the filter counts to the response). As we have no idea what the actual launch of the v0.1 endpoint is going to look like, I don't want to mess with any of that code or try to make a weird hacky approach that needs to account for both the DB implementation and the search index one. Also, I think we've heard that with the launch of the search index, we'll be "officially" launched, so might as well call in v1 at the same time. Other than adjusting the names of a few schemas in v0.1, I left that implementation alone and just copied the boilerplate that I'll fill out in subsequent tickets. The endpoint appears locally: ![Screenshot 2024-05-20 at 12 18 32 PM](https://github.com/navapbc/simpler-grants-gov/assets/46358556/86231ec1-417a-41c6-ad88-3d06bb6214e5) --------- Co-authored-by: nava-platform-bot --- api/openapi.generated.yml | 740 +++++++++++++++++- .../opportunities_v0_1/opportunity_routes.py | 8 +- .../opportunities_v0_1/opportunity_schemas.py | 26 +- api/src/api/opportunities_v1/__init__.py | 6 + .../opportunities_v1/opportunity_blueprint.py | 9 + .../opportunities_v1/opportunity_routes.py | 66 ++ .../opportunities_v1/opportunity_schemas.py | 298 +++++++ api/src/app.py | 2 + api/src/services/opportunities_v1/__init__.py | 0 .../opportunities_v1/get_opportunity.py | 24 + .../opportunities_v1/search_opportunities.py | 39 + .../test_opportunity_route_search.py | 1 - .../src/api/opportunities_v1/__init__.py | 0 .../src/api/opportunities_v1/conftest.py | 183 +++++ .../opportunities_v1/test_opportunity_auth.py | 21 + .../test_opportunity_route_get.py | 97 +++ .../test_opportunity_route_search.py | 19 + 17 files changed, 1488 insertions(+), 51 deletions(-) create mode 100644 api/src/api/opportunities_v1/__init__.py create mode 100644 api/src/api/opportunities_v1/opportunity_blueprint.py create mode 100644 api/src/api/opportunities_v1/opportunity_routes.py create mode 100644 api/src/api/opportunities_v1/opportunity_schemas.py create mode 100644 api/src/services/opportunities_v1/__init__.py create mode 100644 api/src/services/opportunities_v1/get_opportunity.py create mode 100644 api/src/services/opportunities_v1/search_opportunities.py create mode 100644 api/tests/src/api/opportunities_v1/__init__.py create mode 100644 api/tests/src/api/opportunities_v1/conftest.py create mode 100644 api/tests/src/api/opportunities_v1/test_opportunity_auth.py create mode 100644 api/tests/src/api/opportunities_v1/test_opportunity_route_get.py create mode 100644 api/tests/src/api/opportunities_v1/test_opportunity_route_search.py diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index b6b756ae0..7302ded44 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -23,6 +23,7 @@ tags: - name: Health - name: Opportunity v0 - name: Opportunity v0.1 +- name: Opportunity v1 servers: . paths: /health: @@ -204,7 +205,7 @@ paths: $ref: '#/components/schemas/OpportunitySearch' security: - ApiKeyAuth: [] - /v0.1/opportunities/search: + /v1/opportunities/search: post: parameters: [] responses: @@ -220,7 +221,7 @@ paths: data: type: array items: - $ref: '#/components/schemas/Opportunity' + $ref: '#/components/schemas/OpportunityV1' status_code: type: integer description: The HTTP status code @@ -291,6 +292,118 @@ paths: - $ref: '#/components/schemas/ValidationIssue' description: Authentication error tags: + - Opportunity v1 + summary: Opportunity Search + description: ' + + __ALPHA VERSION__ + + + This endpoint in its current form is primarily for testing and feedback. + + + Features in this endpoint are still under heavy development, and subject to + change. Not for production use. + + + See [Release Phases](https://github.com/github/roadmap?tab=readme-ov-file#release-phases) + for further details. + + ' + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/OpportunitySearchRequestV1' + security: + - ApiKeyAuth: [] + /v0.1/opportunities/search: + post: + parameters: [] + responses: + '200': + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: The message to return + data: + type: array + items: + $ref: '#/components/schemas/OpportunityV01' + status_code: + type: integer + description: The HTTP status code + pagination_info: + description: The pagination information for paginated endpoints + type: &id007 + - object + allOf: + - $ref: '#/components/schemas/PaginationInfo' + warnings: + type: array + items: + type: &id008 + - object + allOf: + - $ref: '#/components/schemas/ValidationIssue' + description: Successful response + '422': + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: The message to return + data: + $ref: '#/components/schemas/ErrorResponse' + status_code: + type: integer + description: The HTTP status code + pagination_info: + description: The pagination information for paginated endpoints + type: *id007 + allOf: + - $ref: '#/components/schemas/PaginationInfo' + warnings: + type: array + items: + type: *id008 + allOf: + - $ref: '#/components/schemas/ValidationIssue' + description: Validation error + '401': + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: The message to return + data: + $ref: '#/components/schemas/ErrorResponse' + status_code: + type: integer + description: The HTTP status code + pagination_info: + description: The pagination information for paginated endpoints + type: *id007 + allOf: + - $ref: '#/components/schemas/PaginationInfo' + warnings: + type: array + items: + type: *id008 + allOf: + - $ref: '#/components/schemas/ValidationIssue' + description: Authentication error + tags: - Opportunity v0.1 summary: Opportunity Search description: ' @@ -313,7 +426,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/OpportunitySearchRequest' + $ref: '#/components/schemas/OpportunitySearchRequestV01' examples: example1: summary: No filters @@ -382,14 +495,14 @@ paths: description: The HTTP status code pagination_info: description: The pagination information for paginated endpoints - type: &id007 + type: &id009 - object allOf: - $ref: '#/components/schemas/PaginationInfo' warnings: type: array items: - type: &id008 + type: &id010 - object allOf: - $ref: '#/components/schemas/ValidationIssue' @@ -410,13 +523,13 @@ paths: description: The HTTP status code pagination_info: description: The pagination information for paginated endpoints - type: *id007 + type: *id009 allOf: - $ref: '#/components/schemas/PaginationInfo' warnings: type: array items: - type: *id008 + type: *id010 allOf: - $ref: '#/components/schemas/ValidationIssue' description: Authentication error @@ -436,13 +549,13 @@ paths: description: The HTTP status code pagination_info: description: The pagination information for paginated endpoints - type: *id007 + type: *id009 allOf: - $ref: '#/components/schemas/PaginationInfo' warnings: type: array items: - type: *id008 + type: *id010 allOf: - $ref: '#/components/schemas/ValidationIssue' description: Not found @@ -461,6 +574,116 @@ paths: change. Not for production use. + See [Release Phases](https://github.com/github/roadmap?tab=readme-ov-file#release-phases) + for further details. + + ' + security: + - ApiKeyAuth: [] + /v1/opportunities/{opportunity_id}: + get: + parameters: + - in: path + name: opportunity_id + schema: + type: integer + required: true + responses: + '200': + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: The message to return + data: + $ref: '#/components/schemas/OpportunityV1' + status_code: + type: integer + description: The HTTP status code + pagination_info: + description: The pagination information for paginated endpoints + type: &id011 + - object + allOf: + - $ref: '#/components/schemas/PaginationInfo' + warnings: + type: array + items: + type: &id012 + - object + allOf: + - $ref: '#/components/schemas/ValidationIssue' + description: Successful response + '401': + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: The message to return + data: + $ref: '#/components/schemas/ErrorResponse' + status_code: + type: integer + description: The HTTP status code + pagination_info: + description: The pagination information for paginated endpoints + type: *id011 + allOf: + - $ref: '#/components/schemas/PaginationInfo' + warnings: + type: array + items: + type: *id012 + allOf: + - $ref: '#/components/schemas/ValidationIssue' + description: Authentication error + '404': + content: + application/json: + schema: + type: object + properties: + message: + type: string + description: The message to return + data: + $ref: '#/components/schemas/ErrorResponse' + status_code: + type: integer + description: The HTTP status code + pagination_info: + description: The pagination information for paginated endpoints + type: *id011 + allOf: + - $ref: '#/components/schemas/PaginationInfo' + warnings: + type: array + items: + type: *id012 + allOf: + - $ref: '#/components/schemas/ValidationIssue' + description: Not found + tags: + - Opportunity v1 + summary: Opportunity Get + description: ' + + __ALPHA VERSION__ + + + This endpoint in its current form is primarily for testing and feedback. + + + Features in this endpoint are still under heavy development, and subject to + change. Not for production use. + + See [Release Phases](https://github.com/github/roadmap?tab=readme-ov-file#release-phases) for further details. @@ -486,20 +709,20 @@ paths: type: string description: The message to return data: - $ref: '#/components/schemas/Opportunity' + $ref: '#/components/schemas/OpportunityV01' status_code: type: integer description: The HTTP status code pagination_info: description: The pagination information for paginated endpoints - type: &id009 + type: &id013 - object allOf: - $ref: '#/components/schemas/PaginationInfo' warnings: type: array items: - type: &id010 + type: &id014 - object allOf: - $ref: '#/components/schemas/ValidationIssue' @@ -520,13 +743,13 @@ paths: description: The HTTP status code pagination_info: description: The pagination information for paginated endpoints - type: *id009 + type: *id013 allOf: - $ref: '#/components/schemas/PaginationInfo' warnings: type: array items: - type: *id010 + type: *id014 allOf: - $ref: '#/components/schemas/ValidationIssue' description: Authentication error @@ -546,13 +769,13 @@ paths: description: The HTTP status code pagination_info: description: The pagination information for paginated endpoints - type: *id009 + type: *id013 allOf: - $ref: '#/components/schemas/PaginationInfo' warnings: type: array items: - type: *id010 + type: *id014 allOf: - $ref: '#/components/schemas/ValidationIssue' description: Not found @@ -768,7 +991,7 @@ components: type: string format: date-time readOnly: true - FundingInstrumentFilter: + FundingInstrumentFilterV1: type: object properties: one_of: @@ -782,7 +1005,7 @@ components: - other type: - string - FundingCategoryFilter: + FundingCategoryFilterV1: type: object properties: one_of: @@ -818,7 +1041,7 @@ components: - other type: - string - ApplicantTypeFilter: + ApplicantTypeFilterV1: type: object properties: one_of: @@ -845,7 +1068,7 @@ components: - unrestricted type: - string - OpportunityStatusFilter: + OpportunityStatusFilterV1: type: object properties: one_of: @@ -859,7 +1082,7 @@ components: - archived type: - string - AgencyFilter: + AgencyFilterV1: type: object properties: one_of: @@ -869,34 +1092,34 @@ components: type: string minLength: 2 example: US-ABC - OpportunitySearchFilter: + OpportunitySearchFilterV1: type: object properties: funding_instrument: type: - object allOf: - - $ref: '#/components/schemas/FundingInstrumentFilter' + - $ref: '#/components/schemas/FundingInstrumentFilterV1' funding_category: type: - object allOf: - - $ref: '#/components/schemas/FundingCategoryFilter' + - $ref: '#/components/schemas/FundingCategoryFilterV1' applicant_type: type: - object allOf: - - $ref: '#/components/schemas/ApplicantTypeFilter' + - $ref: '#/components/schemas/ApplicantTypeFilterV1' opportunity_status: type: - object allOf: - - $ref: '#/components/schemas/OpportunityStatusFilter' + - $ref: '#/components/schemas/OpportunityStatusFilterV1' agency: type: - object allOf: - - $ref: '#/components/schemas/AgencyFilter' + - $ref: '#/components/schemas/AgencyFilterV1' OpportunityPagination: type: object properties: @@ -932,7 +1155,7 @@ components: - page_offset - page_size - sort_direction - OpportunitySearchRequest: + OpportunitySearchRequestV1: type: object properties: query: @@ -945,7 +1168,7 @@ components: type: - object allOf: - - $ref: '#/components/schemas/OpportunitySearchFilter' + - $ref: '#/components/schemas/OpportunitySearchFilterV1' pagination: type: - object @@ -953,7 +1176,456 @@ components: - $ref: '#/components/schemas/OpportunityPagination' required: - pagination - OpportunityAssistanceListing: + OpportunityAssistanceListingV1: + type: object + properties: + program_title: + type: string + description: The name of the program, see https://sam.gov/content/assistance-listings + for more detail + example: Space Technology + assistance_listing_number: + type: string + description: The assistance listing number, see https://sam.gov/content/assistance-listings + for more detail + example: '43.012' + OpportunitySummaryV1: + type: object + properties: + summary_description: + type: string + description: The summary of the opportunity + example: This opportunity aims to unravel the mysteries of the universe. + is_cost_sharing: + type: boolean + description: Whether or not the opportunity has a cost sharing/matching + requirement + is_forecast: + type: boolean + description: Whether the opportunity is forecasted, that is, the information + is only an estimate and not yet official + example: false + close_date: + type: string + format: date + description: The date that the opportunity will close - only set if is_forecast=False + close_date_description: + type: string + description: Optional details regarding the close date + example: Proposals are due earlier than usual. + post_date: + type: string + format: date + description: The date the opportunity was posted + archive_date: + type: string + format: date + description: When the opportunity will be archived + expected_number_of_awards: + type: integer + description: The number of awards the opportunity is expected to award + example: 10 + estimated_total_program_funding: + type: integer + description: The total program funding of the opportunity in US Dollars + example: 10000000 + award_floor: + type: integer + description: The minimum amount an opportunity would award + example: 10000 + award_ceiling: + type: integer + description: The maximum amount an opportunity would award + example: 100000 + additional_info_url: + type: string + description: A URL to a website that can provide additional information + about the opportunity + example: grants.gov + additional_info_url_description: + type: string + description: The text to display for the additional_info_url link + example: Click me for more info + forecasted_post_date: + type: string + format: date + description: Forecasted opportunity only. The date the opportunity is expected + to be posted, and transition out of being a forecast + forecasted_close_date: + type: string + format: date + description: Forecasted opportunity only. The date the opportunity is expected + to be close once posted. + forecasted_close_date_description: + type: string + description: Forecasted opportunity only. Optional details regarding the + forecasted closed date. + example: Proposals will probably be due on this date + forecasted_award_date: + type: string + format: date + description: Forecasted opportunity only. The date the grantor plans to + award the opportunity. + forecasted_project_start_date: + type: string + format: date + description: Forecasted opportunity only. The date the grantor expects the + award recipient should start their project + fiscal_year: + type: integer + description: Forecasted opportunity only. The fiscal year the project is + expected to be funded and launched + funding_category_description: + type: string + description: Additional information about the funding category + example: Economic Support + applicant_eligibility_description: + type: string + description: Additional information about the types of applicants that are + eligible + example: All types of domestic applicants are eligible to apply + agency_code: + type: string + description: The agency who owns the opportunity + example: US-ABC + agency_name: + type: string + description: The name of the agency who owns the opportunity + example: US Alphabetical Basic Corp + agency_phone_number: + type: string + description: The phone number of the agency who owns the opportunity + example: 123-456-7890 + agency_contact_description: + type: string + description: Information regarding contacting the agency who owns the opportunity + example: For more information, reach out to Jane Smith at agency US-ABC + agency_email_address: + type: string + description: The contact email of the agency who owns the opportunity + example: fake_email@grants.gov + agency_email_address_description: + type: string + description: The text for the link to the agency email address + example: Click me to email the agency + funding_instruments: + type: array + items: + enum: + - cooperative_agreement + - grant + - procurement_contract + - other + type: + - string + funding_categories: + type: array + items: + enum: + - recovery_act + - agriculture + - arts + - business_and_commerce + - community_development + - consumer_protection + - disaster_prevention_and_relief + - education + - employment_labor_and_training + - energy + - environment + - food_and_nutrition + - health + - housing + - humanities + - infrastructure_investment_and_jobs_act + - information_and_statistics + - income_security_and_social_services + - law_justice_and_legal_services + - natural_resources + - opportunity_zone_benefits + - regional_development + - science_technology_and_other_research_and_development + - transportation + - affordable_care_act + - other + type: + - string + applicant_types: + type: array + items: + enum: + - state_governments + - county_governments + - city_or_township_governments + - special_district_governments + - independent_school_districts + - public_and_state_institutions_of_higher_education + - private_institutions_of_higher_education + - federally_recognized_native_american_tribal_governments + - other_native_american_tribal_organizations + - public_and_indian_housing_authorities + - nonprofits_non_higher_education_with_501c3 + - nonprofits_non_higher_education_without_501c3 + - individuals + - for_profit_organizations_other_than_small_businesses + - small_businesses + - other + - unrestricted + type: + - string + OpportunityV1: + type: object + properties: + opportunity_id: + type: integer + readOnly: true + description: The internal ID of the opportunity + example: 12345 + opportunity_number: + type: string + description: The funding opportunity number + example: ABC-123-XYZ-001 + opportunity_title: + type: string + description: The title of the opportunity + example: Research into conservation techniques + agency: + type: string + description: The agency who created the opportunity + example: US-ABC + category: + description: The opportunity category + example: !!python/object/apply:src.constants.lookup_constants.OpportunityCategory + - discretionary + enum: + - discretionary + - mandatory + - continuation + - earmark + - other + type: + - string + category_explanation: + type: string + description: Explanation of the category when the category is 'O' (other) + example: null + opportunity_assistance_listings: + type: array + items: + type: + - object + allOf: + - $ref: '#/components/schemas/OpportunityAssistanceListingV1' + summary: + type: + - object + allOf: + - $ref: '#/components/schemas/OpportunitySummaryV1' + 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 + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + FundingInstrumentFilterV01: + type: object + properties: + one_of: + type: array + minItems: 1 + items: + enum: + - cooperative_agreement + - grant + - procurement_contract + - other + type: + - string + FundingCategoryFilterV01: + type: object + properties: + one_of: + type: array + minItems: 1 + items: + enum: + - recovery_act + - agriculture + - arts + - business_and_commerce + - community_development + - consumer_protection + - disaster_prevention_and_relief + - education + - employment_labor_and_training + - energy + - environment + - food_and_nutrition + - health + - housing + - humanities + - infrastructure_investment_and_jobs_act + - information_and_statistics + - income_security_and_social_services + - law_justice_and_legal_services + - natural_resources + - opportunity_zone_benefits + - regional_development + - science_technology_and_other_research_and_development + - transportation + - affordable_care_act + - other + type: + - string + ApplicantTypeFilterV01: + type: object + properties: + one_of: + type: array + minItems: 1 + items: + enum: + - state_governments + - county_governments + - city_or_township_governments + - special_district_governments + - independent_school_districts + - public_and_state_institutions_of_higher_education + - private_institutions_of_higher_education + - federally_recognized_native_american_tribal_governments + - other_native_american_tribal_organizations + - public_and_indian_housing_authorities + - nonprofits_non_higher_education_with_501c3 + - nonprofits_non_higher_education_without_501c3 + - individuals + - for_profit_organizations_other_than_small_businesses + - small_businesses + - other + - unrestricted + type: + - string + OpportunityStatusFilterV01: + type: object + properties: + one_of: + type: array + minItems: 1 + items: + enum: + - forecasted + - posted + - closed + - archived + type: + - string + AgencyFilterV01: + type: object + properties: + one_of: + type: array + minItems: 1 + items: + type: string + minLength: 2 + example: US-ABC + OpportunitySearchFilterV01: + type: object + properties: + funding_instrument: + type: + - object + allOf: + - $ref: '#/components/schemas/FundingInstrumentFilterV01' + funding_category: + type: + - object + allOf: + - $ref: '#/components/schemas/FundingCategoryFilterV01' + applicant_type: + type: + - object + allOf: + - $ref: '#/components/schemas/ApplicantTypeFilterV01' + opportunity_status: + type: + - object + allOf: + - $ref: '#/components/schemas/OpportunityStatusFilterV01' + agency: + type: + - object + allOf: + - $ref: '#/components/schemas/AgencyFilterV01' + OpportunityPagination1: + type: object + properties: + order_by: + type: string + enum: + - opportunity_id + - opportunity_number + - opportunity_title + - post_date + - close_date + - agency_code + description: The field to sort the response by + sort_direction: + description: Whether to sort the response ascending or descending + enum: + - ascending + - descending + type: + - string + page_size: + type: integer + minimum: 1 + 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: + - order_by + - page_offset + - page_size + - sort_direction + OpportunitySearchRequestV01: + type: object + properties: + query: + type: string + minLength: 1 + maxLength: 100 + description: Query string which searches against several text fields + example: research + filters: + type: + - object + allOf: + - $ref: '#/components/schemas/OpportunitySearchFilterV01' + pagination: + type: + - object + allOf: + - $ref: '#/components/schemas/OpportunityPagination1' + required: + - pagination + OpportunityAssistanceListingV01: type: object properties: program_title: @@ -966,7 +1638,7 @@ components: description: The assistance listing number, see https://sam.gov/content/assistance-listings for more detail example: '43.012' - OpportunitySummary: + OpportunitySummaryV01: type: object properties: summary_description: @@ -1150,7 +1822,7 @@ components: - unrestricted type: - string - Opportunity: + OpportunityV01: type: object properties: opportunity_id: @@ -1192,12 +1864,12 @@ components: type: - object allOf: - - $ref: '#/components/schemas/OpportunityAssistanceListing' + - $ref: '#/components/schemas/OpportunityAssistanceListingV01' summary: type: - object allOf: - - $ref: '#/components/schemas/OpportunitySummary' + - $ref: '#/components/schemas/OpportunitySummaryV01' opportunity_status: description: The current status of the opportunity example: !!python/object/apply:src.constants.lookup_constants.OpportunityStatus diff --git a/api/src/api/opportunities_v0_1/opportunity_routes.py b/api/src/api/opportunities_v0_1/opportunity_routes.py index a3b57f6f1..6ae77d6d0 100644 --- a/api/src/api/opportunities_v0_1/opportunity_routes.py +++ b/api/src/api/opportunities_v0_1/opportunity_routes.py @@ -62,10 +62,12 @@ @opportunity_blueprint.post("/opportunities/search") @opportunity_blueprint.input( - opportunity_schemas.OpportunitySearchRequestSchema, arg_name="search_params", examples=examples + opportunity_schemas.OpportunitySearchRequestV01Schema, + arg_name="search_params", + examples=examples, ) # many=True allows us to return a list of opportunity objects -@opportunity_blueprint.output(opportunity_schemas.OpportunitySchema(many=True)) +@opportunity_blueprint.output(opportunity_schemas.OpportunityV01Schema(many=True)) @opportunity_blueprint.auth_required(api_key_auth) @opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION) @flask_db.with_db_session() @@ -90,7 +92,7 @@ def opportunity_search(db_session: db.Session, search_params: dict) -> response. @opportunity_blueprint.get("/opportunities/") -@opportunity_blueprint.output(opportunity_schemas.OpportunitySchema) +@opportunity_blueprint.output(opportunity_schemas.OpportunityV01Schema) @opportunity_blueprint.auth_required(api_key_auth) @opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION) @flask_db.with_db_session() diff --git a/api/src/api/opportunities_v0_1/opportunity_schemas.py b/api/src/api/opportunities_v0_1/opportunity_schemas.py index 257b05cd8..a54384660 100644 --- a/api/src/api/opportunities_v0_1/opportunity_schemas.py +++ b/api/src/api/opportunities_v0_1/opportunity_schemas.py @@ -10,7 +10,7 @@ from src.pagination.pagination_schema import generate_pagination_schema -class OpportunitySummarySchema(Schema): +class OpportunitySummaryV01Schema(Schema): summary_description = fields.String( metadata={ "description": "The summary of the opportunity", @@ -178,7 +178,7 @@ class OpportunitySummarySchema(Schema): applicant_types = fields.List(fields.Enum(ApplicantType)) -class OpportunityAssistanceListingSchema(Schema): +class OpportunityAssistanceListingV01Schema(Schema): program_title = fields.String( metadata={ "description": "The name of the program, see https://sam.gov/content/assistance-listings for more detail", @@ -193,7 +193,7 @@ class OpportunityAssistanceListingSchema(Schema): ) -class OpportunitySchema(Schema): +class OpportunityV01Schema(Schema): opportunity_id = fields.Integer( dump_only=True, metadata={"description": "The internal ID of the opportunity", "example": 12345}, @@ -227,9 +227,9 @@ class OpportunitySchema(Schema): ) opportunity_assistance_listings = fields.List( - fields.Nested(OpportunityAssistanceListingSchema()) + fields.Nested(OpportunityAssistanceListingV01Schema()) ) - summary = fields.Nested(OpportunitySummarySchema()) + summary = fields.Nested(OpportunitySummaryV01Schema()) opportunity_status = fields.Enum( OpportunityStatus, @@ -243,35 +243,35 @@ class OpportunitySchema(Schema): updated_at = fields.DateTime(dump_only=True) -class OpportunitySearchFilterSchema(Schema): +class OpportunitySearchFilterV01Schema(Schema): funding_instrument = fields.Nested( - StrSearchSchemaBuilder("FundingInstrumentFilterSchema") + StrSearchSchemaBuilder("FundingInstrumentFilterV01Schema") .with_one_of(allowed_values=FundingInstrument) .build() ) funding_category = fields.Nested( - StrSearchSchemaBuilder("FundingCategoryFilterSchema") + StrSearchSchemaBuilder("FundingCategoryFilterV01Schema") .with_one_of(allowed_values=FundingCategory) .build() ) applicant_type = fields.Nested( - StrSearchSchemaBuilder("ApplicantTypeFilterSchema") + StrSearchSchemaBuilder("ApplicantTypeFilterV01Schema") .with_one_of(allowed_values=ApplicantType) .build() ) opportunity_status = fields.Nested( - StrSearchSchemaBuilder("OpportunityStatusFilterSchema") + StrSearchSchemaBuilder("OpportunityStatusFilterV01Schema") .with_one_of(allowed_values=OpportunityStatus) .build() ) agency = fields.Nested( - StrSearchSchemaBuilder("AgencyFilterSchema") + StrSearchSchemaBuilder("AgencyFilterV01Schema") .with_one_of(example="US-ABC", minimum_length=2) .build() ) -class OpportunitySearchRequestSchema(Schema): +class OpportunitySearchRequestV01Schema(Schema): query = fields.String( metadata={ "description": "Query string which searches against several text fields", @@ -280,7 +280,7 @@ class OpportunitySearchRequestSchema(Schema): validate=[validators.Length(min=1, max=100)], ) - filters = fields.Nested(OpportunitySearchFilterSchema()) + filters = fields.Nested(OpportunitySearchFilterV01Schema()) pagination = fields.Nested( generate_pagination_schema( diff --git a/api/src/api/opportunities_v1/__init__.py b/api/src/api/opportunities_v1/__init__.py new file mode 100644 index 000000000..c757789dc --- /dev/null +++ b/api/src/api/opportunities_v1/__init__.py @@ -0,0 +1,6 @@ +from src.api.opportunities_v1.opportunity_blueprint import opportunity_blueprint + +# import opportunity_routes module to register the API routes on the blueprint +import src.api.opportunities_v1.opportunity_routes # noqa: F401 E402 isort:skip + +__all__ = ["opportunity_blueprint"] diff --git a/api/src/api/opportunities_v1/opportunity_blueprint.py b/api/src/api/opportunities_v1/opportunity_blueprint.py new file mode 100644 index 000000000..db88ee426 --- /dev/null +++ b/api/src/api/opportunities_v1/opportunity_blueprint.py @@ -0,0 +1,9 @@ +from apiflask import APIBlueprint + +opportunity_blueprint = APIBlueprint( + "opportunity_v1", + __name__, + tag="Opportunity v1", + cli_group="opportunity_v1", + url_prefix="/v1", +) diff --git a/api/src/api/opportunities_v1/opportunity_routes.py b/api/src/api/opportunities_v1/opportunity_routes.py new file mode 100644 index 000000000..0d94996b0 --- /dev/null +++ b/api/src/api/opportunities_v1/opportunity_routes.py @@ -0,0 +1,66 @@ +import logging + +import src.adapters.db as db +import src.adapters.db.flask_db as flask_db +import src.api.opportunities_v1.opportunity_schemas as opportunity_schemas +import src.api.response as response +from src.api.opportunities_v1.opportunity_blueprint import opportunity_blueprint +from src.auth.api_key_auth import api_key_auth +from src.logging.flask_logger import add_extra_data_to_current_request_logs +from src.services.opportunities_v1.get_opportunity import get_opportunity +from src.services.opportunities_v1.search_opportunities import search_opportunities +from src.util.dict_util import flatten_dict + +logger = logging.getLogger(__name__) + +# Descriptions in OpenAPI support markdown https://swagger.io/specification/ +SHARED_ALPHA_DESCRIPTION = """ +__ALPHA VERSION__ + +This endpoint in its current form is primarily for testing and feedback. + +Features in this endpoint are still under heavy development, and subject to change. Not for production use. + +See [Release Phases](https://github.com/github/roadmap?tab=readme-ov-file#release-phases) for further details. +""" + + +@opportunity_blueprint.post("/opportunities/search") +@opportunity_blueprint.input( + opportunity_schemas.OpportunitySearchRequestV1Schema, arg_name="search_params" +) +# many=True allows us to return a list of opportunity objects +@opportunity_blueprint.output(opportunity_schemas.OpportunityV1Schema(many=True)) +@opportunity_blueprint.auth_required(api_key_auth) +@opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION) +def opportunity_search(search_params: dict) -> response.ApiResponse: + add_extra_data_to_current_request_logs(flatten_dict(search_params, prefix="request.body")) + logger.info("POST /v1/opportunities/search") + + opportunities, pagination_info = search_opportunities(search_params) + + add_extra_data_to_current_request_logs( + { + "response.pagination.total_pages": pagination_info.total_pages, + "response.pagination.total_records": pagination_info.total_records, + } + ) + logger.info("Successfully fetched opportunities") + + return response.ApiResponse( + message="Success", data=opportunities, pagination_info=pagination_info + ) + + +@opportunity_blueprint.get("/opportunities/") +@opportunity_blueprint.output(opportunity_schemas.OpportunityV1Schema) +@opportunity_blueprint.auth_required(api_key_auth) +@opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION) +@flask_db.with_db_session() +def opportunity_get(db_session: db.Session, opportunity_id: int) -> response.ApiResponse: + add_extra_data_to_current_request_logs({"opportunity.opportunity_id": opportunity_id}) + logger.info("GET /v1/opportunities/:opportunity_id") + with db_session.begin(): + opportunity = get_opportunity(db_session, opportunity_id) + + return response.ApiResponse(message="Success", data=opportunity) diff --git a/api/src/api/opportunities_v1/opportunity_schemas.py b/api/src/api/opportunities_v1/opportunity_schemas.py new file mode 100644 index 000000000..5f72c7958 --- /dev/null +++ b/api/src/api/opportunities_v1/opportunity_schemas.py @@ -0,0 +1,298 @@ +from src.api.schemas.extension import Schema, fields, validators +from src.api.schemas.search_schema import StrSearchSchemaBuilder +from src.constants.lookup_constants import ( + ApplicantType, + FundingCategory, + FundingInstrument, + OpportunityCategory, + OpportunityStatus, +) +from src.pagination.pagination_schema import generate_pagination_schema + + +class OpportunitySummaryV1Schema(Schema): + summary_description = fields.String( + metadata={ + "description": "The summary of the opportunity", + "example": "This opportunity aims to unravel the mysteries of the universe.", + } + ) + is_cost_sharing = fields.Boolean( + metadata={ + "description": "Whether or not the opportunity has a cost sharing/matching requirement", + } + ) + is_forecast = fields.Boolean( + metadata={ + "description": "Whether the opportunity is forecasted, that is, the information is only an estimate and not yet official", + "example": False, + } + ) + + close_date = fields.Date( + metadata={ + "description": "The date that the opportunity will close - only set if is_forecast=False", + } + ) + close_date_description = fields.String( + metadata={ + "description": "Optional details regarding the close date", + "example": "Proposals are due earlier than usual.", + } + ) + + post_date = fields.Date( + metadata={ + "description": "The date the opportunity was posted", + } + ) + archive_date = fields.Date( + metadata={ + "description": "When the opportunity will be archived", + } + ) + # not including unarchive date at the moment + + expected_number_of_awards = fields.Integer( + metadata={ + "description": "The number of awards the opportunity is expected to award", + "example": 10, + } + ) + estimated_total_program_funding = fields.Integer( + metadata={ + "description": "The total program funding of the opportunity in US Dollars", + "example": 10_000_000, + } + ) + award_floor = fields.Integer( + metadata={ + "description": "The minimum amount an opportunity would award", + "example": 10_000, + } + ) + award_ceiling = fields.Integer( + metadata={ + "description": "The maximum amount an opportunity would award", + "example": 100_000, + } + ) + + additional_info_url = fields.String( + metadata={ + "description": "A URL to a website that can provide additional information about the opportunity", + "example": "grants.gov", + } + ) + additional_info_url_description = fields.String( + metadata={ + "description": "The text to display for the additional_info_url link", + "example": "Click me for more info", + } + ) + + forecasted_post_date = fields.Date( + metadata={ + "description": "Forecasted opportunity only. The date the opportunity is expected to be posted, and transition out of being a forecast" + } + ) + forecasted_close_date = fields.Date( + metadata={ + "description": "Forecasted opportunity only. The date the opportunity is expected to be close once posted." + } + ) + forecasted_close_date_description = fields.String( + metadata={ + "description": "Forecasted opportunity only. Optional details regarding the forecasted closed date.", + "example": "Proposals will probably be due on this date", + } + ) + forecasted_award_date = fields.Date( + metadata={ + "description": "Forecasted opportunity only. The date the grantor plans to award the opportunity." + } + ) + forecasted_project_start_date = fields.Date( + metadata={ + "description": "Forecasted opportunity only. The date the grantor expects the award recipient should start their project" + } + ) + fiscal_year = fields.Integer( + metadata={ + "description": "Forecasted opportunity only. The fiscal year the project is expected to be funded and launched" + } + ) + + funding_category_description = fields.String( + metadata={ + "description": "Additional information about the funding category", + "example": "Economic Support", + } + ) + applicant_eligibility_description = fields.String( + metadata={ + "description": "Additional information about the types of applicants that are eligible", + "example": "All types of domestic applicants are eligible to apply", + } + ) + + agency_code = fields.String( + metadata={ + "description": "The agency who owns the opportunity", + "example": "US-ABC", + } + ) + agency_name = fields.String( + metadata={ + "description": "The name of the agency who owns the opportunity", + "example": "US Alphabetical Basic Corp", + } + ) + agency_phone_number = fields.String( + metadata={ + "description": "The phone number of the agency who owns the opportunity", + "example": "123-456-7890", + } + ) + agency_contact_description = fields.String( + metadata={ + "description": "Information regarding contacting the agency who owns the opportunity", + "example": "For more information, reach out to Jane Smith at agency US-ABC", + } + ) + agency_email_address = fields.String( + metadata={ + "description": "The contact email of the agency who owns the opportunity", + "example": "fake_email@grants.gov", + } + ) + agency_email_address_description = fields.String( + metadata={ + "description": "The text for the link to the agency email address", + "example": "Click me to email the agency", + } + ) + + funding_instruments = fields.List(fields.Enum(FundingInstrument)) + funding_categories = fields.List(fields.Enum(FundingCategory)) + applicant_types = fields.List(fields.Enum(ApplicantType)) + + +class OpportunityAssistanceListingV1Schema(Schema): + program_title = fields.String( + metadata={ + "description": "The name of the program, see https://sam.gov/content/assistance-listings for more detail", + "example": "Space Technology", + } + ) + assistance_listing_number = fields.String( + metadata={ + "description": "The assistance listing number, see https://sam.gov/content/assistance-listings for more detail", + "example": "43.012", + } + ) + + +class OpportunityV1Schema(Schema): + opportunity_id = fields.Integer( + dump_only=True, + metadata={"description": "The internal ID of the opportunity", "example": 12345}, + ) + + opportunity_number = fields.String( + metadata={"description": "The funding opportunity number", "example": "ABC-123-XYZ-001"} + ) + opportunity_title = fields.String( + metadata={ + "description": "The title of the opportunity", + "example": "Research into conservation techniques", + } + ) + agency = fields.String( + metadata={"description": "The agency who created the opportunity", "example": "US-ABC"} + ) + + category = fields.Enum( + OpportunityCategory, + metadata={ + "description": "The opportunity category", + "example": OpportunityCategory.DISCRETIONARY, + }, + ) + category_explanation = fields.String( + metadata={ + "description": "Explanation of the category when the category is 'O' (other)", + "example": None, + } + ) + + opportunity_assistance_listings = fields.List( + fields.Nested(OpportunityAssistanceListingV1Schema()) + ) + summary = fields.Nested(OpportunitySummaryV1Schema()) + + opportunity_status = fields.Enum( + OpportunityStatus, + metadata={ + "description": "The current status of the opportunity", + "example": OpportunityStatus.POSTED, + }, + ) + + created_at = fields.DateTime(dump_only=True) + updated_at = fields.DateTime(dump_only=True) + + +class OpportunitySearchFilterV1Schema(Schema): + funding_instrument = fields.Nested( + StrSearchSchemaBuilder("FundingInstrumentFilterV1Schema") + .with_one_of(allowed_values=FundingInstrument) + .build() + ) + funding_category = fields.Nested( + StrSearchSchemaBuilder("FundingCategoryFilterV1Schema") + .with_one_of(allowed_values=FundingCategory) + .build() + ) + applicant_type = fields.Nested( + StrSearchSchemaBuilder("ApplicantTypeFilterV1Schema") + .with_one_of(allowed_values=ApplicantType) + .build() + ) + opportunity_status = fields.Nested( + StrSearchSchemaBuilder("OpportunityStatusFilterV1Schema") + .with_one_of(allowed_values=OpportunityStatus) + .build() + ) + agency = fields.Nested( + StrSearchSchemaBuilder("AgencyFilterV1Schema") + .with_one_of(example="US-ABC", minimum_length=2) + .build() + ) + + +class OpportunitySearchRequestV1Schema(Schema): + query = fields.String( + metadata={ + "description": "Query string which searches against several text fields", + "example": "research", + }, + validate=[validators.Length(min=1, max=100)], + ) + + filters = fields.Nested(OpportunitySearchFilterV1Schema()) + + pagination = fields.Nested( + generate_pagination_schema( + "OpportunityPaginationSchema", + [ + "opportunity_id", + "opportunity_number", + "opportunity_title", + "post_date", + "close_date", + "agency_code", + ], + ), + required=True, + ) diff --git a/api/src/app.py b/api/src/app.py index 8e617cce8..0d584a683 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -13,6 +13,7 @@ from src.api.healthcheck import healthcheck_blueprint from src.api.opportunities_v0 import opportunity_blueprint as opportunities_v0_blueprint from src.api.opportunities_v0_1 import opportunity_blueprint as opportunities_v0_1_blueprint +from src.api.opportunities_v1 import opportunity_blueprint as opportunities_v1_blueprint from src.api.response import restructure_error_response from src.api.schemas import response_schema from src.auth.api_key_auth import get_app_security_scheme @@ -101,6 +102,7 @@ def register_blueprints(app: APIFlask) -> None: app.register_blueprint(healthcheck_blueprint) app.register_blueprint(opportunities_v0_blueprint) app.register_blueprint(opportunities_v0_1_blueprint) + app.register_blueprint(opportunities_v1_blueprint) app.register_blueprint(data_migration_blueprint) app.register_blueprint(task_blueprint) diff --git a/api/src/services/opportunities_v1/__init__.py b/api/src/services/opportunities_v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/src/services/opportunities_v1/get_opportunity.py b/api/src/services/opportunities_v1/get_opportunity.py new file mode 100644 index 000000000..9b26cfada --- /dev/null +++ b/api/src/services/opportunities_v1/get_opportunity.py @@ -0,0 +1,24 @@ +from sqlalchemy import select +from sqlalchemy.orm import noload, selectinload + +import src.adapters.db as db +from src.api.route_utils import raise_flask_error +from src.db.models.opportunity_models import Opportunity + + +def get_opportunity(db_session: db.Session, opportunity_id: int) -> Opportunity: + opportunity: Opportunity | None = ( + db_session.execute( + select(Opportunity) + .where(Opportunity.opportunity_id == opportunity_id) + .where(Opportunity.is_draft.is_(False)) + .options(selectinload("*"), noload(Opportunity.all_opportunity_summaries)) + ) + .unique() + .scalar_one_or_none() + ) + + if opportunity is None: + raise_flask_error(404, message=f"Could not find Opportunity with ID {opportunity_id}") + + return opportunity diff --git a/api/src/services/opportunities_v1/search_opportunities.py b/api/src/services/opportunities_v1/search_opportunities.py new file mode 100644 index 000000000..1823bc31d --- /dev/null +++ b/api/src/services/opportunities_v1/search_opportunities.py @@ -0,0 +1,39 @@ +import logging +from typing import Sequence, Tuple + +from pydantic import BaseModel, Field + +from src.db.models.opportunity_models import Opportunity +from src.pagination.pagination_models import PaginationInfo, PaginationParams + +logger = logging.getLogger(__name__) + + +class SearchOpportunityFilters(BaseModel): + funding_instrument: dict | None = Field(default=None) + funding_category: dict | None = Field(default=None) + applicant_type: dict | None = Field(default=None) + opportunity_status: dict | None = Field(default=None) + agency: dict | None = Field(default=None) + + +class SearchOpportunityParams(BaseModel): + pagination: PaginationParams + + query: str | None = Field(default=None) + filters: SearchOpportunityFilters | None = Field(default=None) + + +def search_opportunities(raw_search_params: dict) -> Tuple[Sequence[Opportunity], PaginationInfo]: + search_params = SearchOpportunityParams.model_validate(raw_search_params) + + 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=0, + total_pages=0, + ) + + return [], pagination_info diff --git a/api/tests/src/api/opportunities_v0_1/test_opportunity_route_search.py b/api/tests/src/api/opportunities_v0_1/test_opportunity_route_search.py index 4ee7c6ba4..6529fc651 100644 --- a/api/tests/src/api/opportunities_v0_1/test_opportunity_route_search.py +++ b/api/tests/src/api/opportunities_v0_1/test_opportunity_route_search.py @@ -1121,6 +1121,5 @@ def test_opportunity_search_invalid_request_422( ) assert resp.status_code == 422 - print(resp.get_json()) response_data = resp.get_json()["errors"] assert response_data == expected_response_data diff --git a/api/tests/src/api/opportunities_v1/__init__.py b/api/tests/src/api/opportunities_v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/src/api/opportunities_v1/conftest.py b/api/tests/src/api/opportunities_v1/conftest.py new file mode 100644 index 000000000..c00490cff --- /dev/null +++ b/api/tests/src/api/opportunities_v1/conftest.py @@ -0,0 +1,183 @@ +from src.constants.lookup_constants import ( + ApplicantType, + FundingCategory, + FundingInstrument, + OpportunityStatus, +) +from src.db.models.opportunity_models import ( + Opportunity, + OpportunityAssistanceListing, + OpportunitySummary, +) + + +def get_search_request( + page_offset: int = 1, + page_size: int = 5, + order_by: str = "opportunity_id", + sort_direction: str = "descending", + query: str | None = None, + funding_instrument_one_of: list[FundingInstrument] | None = None, + funding_category_one_of: list[FundingCategory] | None = None, + applicant_type_one_of: list[ApplicantType] | None = None, + opportunity_status_one_of: list[OpportunityStatus] | None = None, + agency_one_of: list[str] | None = None, +): + req = { + "pagination": { + "page_offset": page_offset, + "page_size": page_size, + "order_by": order_by, + "sort_direction": sort_direction, + } + } + + filters = {} + + if funding_instrument_one_of is not None: + filters["funding_instrument"] = {"one_of": funding_instrument_one_of} + + if funding_category_one_of is not None: + filters["funding_category"] = {"one_of": funding_category_one_of} + + if applicant_type_one_of is not None: + filters["applicant_type"] = {"one_of": applicant_type_one_of} + + if opportunity_status_one_of is not None: + filters["opportunity_status"] = {"one_of": opportunity_status_one_of} + + if agency_one_of is not None: + filters["agency"] = {"one_of": agency_one_of} + + if len(filters) > 0: + req["filters"] = filters + + if query is not None: + req["query"] = query + + return req + + +##################################### +# Validation utils +##################################### + + +def validate_opportunity(db_opportunity: Opportunity, resp_opportunity: dict): + assert db_opportunity.opportunity_id == resp_opportunity["opportunity_id"] + assert db_opportunity.opportunity_number == resp_opportunity["opportunity_number"] + assert db_opportunity.opportunity_title == resp_opportunity["opportunity_title"] + assert db_opportunity.agency == resp_opportunity["agency"] + assert db_opportunity.category == resp_opportunity["category"] + assert db_opportunity.category_explanation == resp_opportunity["category_explanation"] + + validate_opportunity_summary(db_opportunity.summary, resp_opportunity["summary"]) + validate_assistance_listings( + db_opportunity.opportunity_assistance_listings, + resp_opportunity["opportunity_assistance_listings"], + ) + + assert db_opportunity.opportunity_status == resp_opportunity["opportunity_status"] + + +def validate_opportunity_summary(db_summary: OpportunitySummary, resp_summary: dict): + if db_summary is None: + assert resp_summary is None + return + + assert db_summary.summary_description == resp_summary["summary_description"] + assert db_summary.is_cost_sharing == resp_summary["is_cost_sharing"] + assert db_summary.is_forecast == resp_summary["is_forecast"] + assert str(db_summary.close_date) == str(resp_summary["close_date"]) + assert db_summary.close_date_description == resp_summary["close_date_description"] + assert str(db_summary.post_date) == str(resp_summary["post_date"]) + assert str(db_summary.archive_date) == str(resp_summary["archive_date"]) + assert db_summary.expected_number_of_awards == resp_summary["expected_number_of_awards"] + assert ( + db_summary.estimated_total_program_funding + == resp_summary["estimated_total_program_funding"] + ) + assert db_summary.award_floor == resp_summary["award_floor"] + assert db_summary.award_ceiling == resp_summary["award_ceiling"] + assert db_summary.additional_info_url == resp_summary["additional_info_url"] + assert ( + db_summary.additional_info_url_description + == resp_summary["additional_info_url_description"] + ) + + assert str(db_summary.forecasted_post_date) == str(resp_summary["forecasted_post_date"]) + assert str(db_summary.forecasted_close_date) == str(resp_summary["forecasted_close_date"]) + assert ( + db_summary.forecasted_close_date_description + == resp_summary["forecasted_close_date_description"] + ) + assert str(db_summary.forecasted_award_date) == str(resp_summary["forecasted_award_date"]) + assert str(db_summary.forecasted_project_start_date) == str( + resp_summary["forecasted_project_start_date"] + ) + assert db_summary.fiscal_year == resp_summary["fiscal_year"] + + assert db_summary.funding_category_description == resp_summary["funding_category_description"] + assert ( + db_summary.applicant_eligibility_description + == resp_summary["applicant_eligibility_description"] + ) + + assert db_summary.agency_code == resp_summary["agency_code"] + assert db_summary.agency_name == resp_summary["agency_name"] + assert db_summary.agency_phone_number == resp_summary["agency_phone_number"] + assert db_summary.agency_contact_description == resp_summary["agency_contact_description"] + assert db_summary.agency_email_address == resp_summary["agency_email_address"] + assert ( + db_summary.agency_email_address_description + == resp_summary["agency_email_address_description"] + ) + + assert set(db_summary.funding_instruments) == set(resp_summary["funding_instruments"]) + assert set(db_summary.funding_categories) == set(resp_summary["funding_categories"]) + assert set(db_summary.applicant_types) == set(resp_summary["applicant_types"]) + + +def validate_assistance_listings( + db_assistance_listings: list[OpportunityAssistanceListing], resp_listings: list[dict] +) -> None: + # In order to compare this list, sort them both the same and compare from there + db_assistance_listings.sort(key=lambda a: (a.assistance_listing_number, a.program_title)) + resp_listings.sort(key=lambda a: (a["assistance_listing_number"], a["program_title"])) + + assert len(db_assistance_listings) == len(resp_listings) + for db_assistance_listing, resp_listing in zip( + db_assistance_listings, resp_listings, strict=True + ): + assert ( + db_assistance_listing.assistance_listing_number + == resp_listing["assistance_listing_number"] + ) + assert db_assistance_listing.program_title == resp_listing["program_title"] + + +def validate_search_pagination( + search_response: dict, + search_request: dict, + expected_total_pages: int, + expected_total_records: int, + expected_response_record_count: int, +): + pagination_info = search_response["pagination_info"] + assert pagination_info["page_offset"] == search_request["pagination"]["page_offset"] + assert pagination_info["page_size"] == search_request["pagination"]["page_size"] + assert pagination_info["order_by"] == search_request["pagination"]["order_by"] + assert pagination_info["sort_direction"] == search_request["pagination"]["sort_direction"] + + assert pagination_info["total_pages"] == expected_total_pages + assert pagination_info["total_records"] == expected_total_records + + searched_opportunities = search_response["data"] + assert len(searched_opportunities) == expected_response_record_count + + # Verify data is sorted as expected + reverse = pagination_info["sort_direction"] == "descending" + resorted_opportunities = sorted( + searched_opportunities, key=lambda u: u[pagination_info["order_by"]], reverse=reverse + ) + assert resorted_opportunities == searched_opportunities diff --git a/api/tests/src/api/opportunities_v1/test_opportunity_auth.py b/api/tests/src/api/opportunities_v1/test_opportunity_auth.py new file mode 100644 index 000000000..352c57bfc --- /dev/null +++ b/api/tests/src/api/opportunities_v1/test_opportunity_auth.py @@ -0,0 +1,21 @@ +import pytest + +from tests.src.api.opportunities_v1.conftest import get_search_request + + +@pytest.mark.parametrize( + "method,url,body", + [ + ("POST", "/v1/opportunities/search", get_search_request()), + ("GET", "/v1/opportunities/1", None), + ], +) +def test_opportunity_unauthorized_401(client, api_auth_token, method, url, body): + # open is just the generic method that post/get/etc. call under the hood + response = client.open(url, method=method, json=body, headers={"X-Auth": "incorrect token"}) + + assert response.status_code == 401 + assert ( + response.get_json()["message"] + == "The server could not verify that you are authorized to access the URL requested" + ) diff --git a/api/tests/src/api/opportunities_v1/test_opportunity_route_get.py b/api/tests/src/api/opportunities_v1/test_opportunity_route_get.py new file mode 100644 index 000000000..875cddfd3 --- /dev/null +++ b/api/tests/src/api/opportunities_v1/test_opportunity_route_get.py @@ -0,0 +1,97 @@ +import pytest + +from src.db.models.opportunity_models import Opportunity +from tests.src.api.opportunities_v1.conftest import validate_opportunity +from tests.src.db.models.factories import ( + CurrentOpportunitySummaryFactory, + OpportunityFactory, + OpportunitySummaryFactory, +) + + +@pytest.fixture +def truncate_opportunities(db_session): + # Note that we can't just do db_session.query(Opportunity).delete() as the cascade deletes won't work automatically: + # https://docs.sqlalchemy.org/en/20/orm/queryguide/dml.html#orm-queryguide-update-delete-caveats + # but if we do it individually they will + opportunities = db_session.query(Opportunity).all() + for opp in opportunities: + db_session.delete(opp) + + # Force the deletes to the DB + db_session.commit() + + +##################################### +# GET opportunity tests +##################################### + + +@pytest.mark.parametrize( + "opportunity_params,opportunity_summary_params", + [ + ({}, {}), + # Only an opportunity exists, no other connected records + ( + { + "opportunity_assistance_listings": [], + }, + None, + ), + # Summary exists, but none of the list values set + ( + {}, + { + "link_funding_instruments": [], + "link_funding_categories": [], + "link_applicant_types": [], + }, + ), + # All possible values set to null/empty + # Note this uses traits on the factories to handle setting everything + ({"all_fields_null": True}, {"all_fields_null": True}), + ], +) +def test_get_opportunity_200( + client, api_auth_token, enable_factory_create, opportunity_params, opportunity_summary_params +): + # Split the setup of the opportunity from the opportunity summary to simplify the factory usage a bit + db_opportunity = OpportunityFactory.create( + **opportunity_params, current_opportunity_summary=None + ) # We'll set the current opportunity below + + if opportunity_summary_params is not None: + db_opportunity_summary = OpportunitySummaryFactory.create( + **opportunity_summary_params, opportunity=db_opportunity + ) + CurrentOpportunitySummaryFactory.create( + opportunity=db_opportunity, opportunity_summary=db_opportunity_summary + ) + + resp = client.get( + f"/v1/opportunities/{db_opportunity.opportunity_id}", headers={"X-Auth": api_auth_token} + ) + assert resp.status_code == 200 + response_data = resp.get_json()["data"] + + validate_opportunity(db_opportunity, response_data) + + +def test_get_opportunity_404_not_found(client, api_auth_token, truncate_opportunities): + resp = client.get("/v1/opportunities/1", headers={"X-Auth": api_auth_token}) + assert resp.status_code == 404 + assert resp.get_json()["message"] == "Could not find Opportunity with ID 1" + + +def test_get_opportunity_404_not_found_is_draft(client, api_auth_token, enable_factory_create): + # The endpoint won't return drafts, so this'll be a 404 despite existing + opportunity = OpportunityFactory.create(is_draft=True) + + resp = client.get( + f"/v1/opportunities/{opportunity.opportunity_id}", headers={"X-Auth": api_auth_token} + ) + assert resp.status_code == 404 + assert ( + resp.get_json()["message"] + == f"Could not find Opportunity with ID {opportunity.opportunity_id}" + ) 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 new file mode 100644 index 000000000..6e79419db --- /dev/null +++ b/api/tests/src/api/opportunities_v1/test_opportunity_route_search.py @@ -0,0 +1,19 @@ +from tests.src.api.opportunities_v1.conftest import get_search_request + + +def test_opportunity_route_search_200(client, api_auth_token): + req = get_search_request() + + resp = client.post("/v1/opportunities/search", json=req, headers={"X-Auth": api_auth_token}) + + assert resp.status_code == 200 + + # The endpoint meaningfully only returns the pagination params back + # at the moment, so just validate that for now. + resp_body = resp.get_json() + assert resp_body["pagination_info"]["page_offset"] == req["pagination"]["page_offset"] + assert resp_body["pagination_info"]["page_size"] == req["pagination"]["page_size"] + assert resp_body["pagination_info"]["sort_direction"] == req["pagination"]["sort_direction"] + assert resp_body["pagination_info"]["order_by"] == req["pagination"]["order_by"] + assert resp_body["pagination_info"]["total_records"] == 0 + assert resp_body["pagination_info"]["total_pages"] == 0