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