diff --git a/api/src/api/opportunities_v0_1/__init__.py b/api/src/api/opportunities_v0_1/__init__.py deleted file mode 100644 index 42e1617fb..000000000 --- a/api/src/api/opportunities_v0_1/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from src.api.opportunities_v0_1.opportunity_blueprint import opportunity_blueprint - -# import opportunity_routes module to register the API routes on the blueprint -import src.api.opportunities_v0_1.opportunity_routes # noqa: F401 E402 isort:skip - -__all__ = ["opportunity_blueprint"] diff --git a/api/src/api/opportunities_v0_1/opportunity_blueprint.py b/api/src/api/opportunities_v0_1/opportunity_blueprint.py deleted file mode 100644 index 851862542..000000000 --- a/api/src/api/opportunities_v0_1/opportunity_blueprint.py +++ /dev/null @@ -1,12 +0,0 @@ -from apiflask import APIBlueprint - -opportunity_blueprint = APIBlueprint( - "opportunity_v0_1", - __name__, - tag="Opportunity v0.1", - cli_group="opportunity_v0_1", - url_prefix="/v0.1", - # Before we fully deprecate the v0.1 endpoints - # we want to hide them from Swagger/OpenAPI - enable_openapi=False, -) diff --git a/api/src/api/opportunities_v0_1/opportunity_routes.py b/api/src/api/opportunities_v0_1/opportunity_routes.py deleted file mode 100644 index 9006c9455..000000000 --- a/api/src/api/opportunities_v0_1/opportunity_routes.py +++ /dev/null @@ -1,105 +0,0 @@ -import logging - -import src.adapters.db as db -import src.adapters.db.flask_db as flask_db -import src.api.opportunities_v0_1.opportunity_schemas as opportunity_schemas -import src.api.response as response -from src.api.opportunities_v0_1.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_v0_1.get_opportunity import get_opportunity -from src.services.opportunities_v0_1.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. -""" - -examples = { - "example1": { - "summary": "No filters", - "value": { - "pagination": { - "order_by": "opportunity_id", - "page_offset": 1, - "page_size": 25, - "sort_direction": "ascending", - }, - }, - }, - "example2": { - "summary": "All filters", - "value": { - "query": "research", - "filters": { - "agency": {"one_of": ["US-ABC", "HHS"]}, - "applicant_type": { - "one_of": ["state_governments", "county_governments", "individuals"] - }, - "funding_category": {"one_of": ["recovery_act", "arts", "natural_resources"]}, - "funding_instrument": {"one_of": ["cooperative_agreement", "grant"]}, - "opportunity_status": {"one_of": ["forecasted", "posted"]}, - }, - "pagination": { - "order_by": "opportunity_id", - "page_offset": 1, - "page_size": 25, - "sort_direction": "descending", - }, - }, - }, -} - - -@opportunity_blueprint.post("/opportunities/search") -@opportunity_blueprint.input( - 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.OpportunitySearchResponseV01Schema) -@opportunity_blueprint.auth_required(api_key_auth) -@opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION) -@flask_db.with_db_session() -def opportunity_search(db_session: db.Session, search_params: dict) -> response.ApiResponse: - add_extra_data_to_current_request_logs(flatten_dict(search_params, prefix="request.body")) - logger.info("POST /v0.1/opportunities/search") - - with db_session.begin(): - opportunities, pagination_info = search_opportunities(db_session, 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.OpportunityGetResponseV01Schema) -@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 /v0.1/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_v0_1/opportunity_schemas.py b/api/src/api/opportunities_v0_1/opportunity_schemas.py deleted file mode 100644 index bd757ce94..000000000 --- a/api/src/api/opportunities_v0_1/opportunity_schemas.py +++ /dev/null @@ -1,310 +0,0 @@ -from src.api.schemas.extension import Schema, fields, validators -from src.api.schemas.response_schema import AbstractResponseSchema, PaginationMixinSchema -from src.api.schemas.search_schema import StrSearchSchemaBuilder -from src.constants.lookup_constants import ( - ApplicantType, - FundingCategory, - FundingInstrument, - OpportunityCategory, - OpportunityStatus, -) -from src.pagination.pagination_schema import generate_pagination_schema - - -class OpportunitySummaryV01Schema(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 OpportunityAssistanceListingV01Schema(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 OpportunityV01Schema(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"} - ) - agency_code = 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(OpportunityAssistanceListingV01Schema()) - ) - summary = fields.Nested(OpportunitySummaryV01Schema()) - - 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 OpportunitySearchFilterV01Schema(Schema): - funding_instrument = fields.Nested( - StrSearchSchemaBuilder("FundingInstrumentFilterV01Schema") - .with_one_of(allowed_values=FundingInstrument) - .build() - ) - funding_category = fields.Nested( - StrSearchSchemaBuilder("FundingCategoryFilterV01Schema") - .with_one_of(allowed_values=FundingCategory) - .build() - ) - applicant_type = fields.Nested( - StrSearchSchemaBuilder("ApplicantTypeFilterV01Schema") - .with_one_of(allowed_values=ApplicantType) - .build() - ) - opportunity_status = fields.Nested( - StrSearchSchemaBuilder("OpportunityStatusFilterV01Schema") - .with_one_of(allowed_values=OpportunityStatus) - .build() - ) - agency = fields.Nested( - StrSearchSchemaBuilder("AgencyFilterV01Schema") - .with_one_of(example="US-ABC", minimum_length=2) - .build() - ) - - -class OpportunitySearchRequestV01Schema(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(OpportunitySearchFilterV01Schema()) - - pagination = fields.Nested( - generate_pagination_schema( - "OpportunityPaginationSchema", - [ - "opportunity_id", - "opportunity_number", - "opportunity_title", - "post_date", - "close_date", - "agency_code", - ], - ), - required=True, - ) - - -class OpportunityGetResponseV01Schema(AbstractResponseSchema): - data = fields.Nested(OpportunityV01Schema()) - - -class OpportunitySearchResponseV01Schema(AbstractResponseSchema, PaginationMixinSchema): - data = fields.Nested(OpportunityV01Schema(many=True)) diff --git a/api/src/app.py b/api/src/app.py index adad0f240..5fc359470 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -17,7 +17,6 @@ from src.api.agencies_v1 import agency_blueprint as agencies_v1_blueprint from src.api.extracts_v1 import extract_blueprint as extracts_v1_blueprint from src.api.healthcheck import healthcheck_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 @@ -132,7 +131,6 @@ def error_processor(error: exceptions.HTTPError) -> Tuple[dict, int, Any]: def register_blueprints(app: APIFlask) -> None: app.register_blueprint(healthcheck_blueprint) - app.register_blueprint(opportunities_v0_1_blueprint) app.register_blueprint(opportunities_v1_blueprint) app.register_blueprint(extracts_v1_blueprint) app.register_blueprint(agencies_v1_blueprint) diff --git a/api/src/services/opportunities_v0_1/__init__.py b/api/src/services/opportunities_v0_1/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/api/src/services/opportunities_v0_1/get_opportunity.py b/api/src/services/opportunities_v0_1/get_opportunity.py deleted file mode 100644 index 1d2184680..000000000 --- a/api/src/services/opportunities_v0_1/get_opportunity.py +++ /dev/null @@ -1,24 +0,0 @@ -from sqlalchemy import select -from sqlalchemy.orm import joinedload - -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(joinedload("*")) - ) - .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_v0_1/search_opportunities.py b/api/src/services/opportunities_v0_1/search_opportunities.py deleted file mode 100644 index cc780fdd3..000000000 --- a/api/src/services/opportunities_v0_1/search_opportunities.py +++ /dev/null @@ -1,266 +0,0 @@ -import logging -from typing import Any, Sequence, Tuple - -from pydantic import BaseModel, Field -from sqlalchemy import Select, asc, desc, nulls_last, or_, select -from sqlalchemy.orm import InstrumentedAttribute, noload, selectinload - -import src.adapters.db as db -from src.db.models.opportunity_models import ( - CurrentOpportunitySummary, - LinkOpportunitySummaryApplicantType, - LinkOpportunitySummaryFundingCategory, - LinkOpportunitySummaryFundingInstrument, - Opportunity, - OpportunityAssistanceListing, - OpportunitySummary, -) -from src.pagination.pagination_models import PaginationInfo, PaginationParams -from src.pagination.paginator import Paginator - -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 _join_stmt_to_current_summary(stmt: Select[tuple[Any]]) -> Select[tuple[Any]]: - # Utility method to add this join to a select statement as we do this in a few places - # - # We need to add joins so that the where/order_by clauses - # can query against the tables that are relevant for these filters - return stmt.join(CurrentOpportunitySummary).join( - OpportunitySummary, - CurrentOpportunitySummary.opportunity_summary_id - == OpportunitySummary.opportunity_summary_id, - ) - - -def _add_query_filters(stmt: Select[tuple[Any]], query: str | None) -> Select[tuple[Any]]: - if query is None or len(query) == 0: - return stmt - - ilike_query = f"%{query}%" - - # Add a left join to the assistance listing table to filter by any of its values - stmt = stmt.outerjoin( - OpportunityAssistanceListing, - Opportunity.opportunity_id == OpportunityAssistanceListing.opportunity_id, - ) - - """ - This adds the following to the inner query (assuming the query value is "example") - - WHERE - (opportunity.opportunity_title ILIKE '%example%' - OR opportunity.opportunity_number ILIKE '%example%' - OR opportunity.agency ILIKE '%example%' - OR opportunity_summary.summary_description ILIKE '%example%' - OR opportunity_assistance_listing.assistance_listing_number = 'example' - OR opportunity_assistance_listing.program_title ILIKE '%example%')) - - Note that SQLAlchemy escapes everything and queries are actually written like: - - opportunity.opportunity_number ILIKE % (opportunity_number_1) - """ - stmt = stmt.where( - or_( - # Title partial match - Opportunity.opportunity_title.ilike(ilike_query), - # Number partial match - Opportunity.opportunity_number.ilike(ilike_query), - # Agency (code) partial match - Opportunity.agency_code.ilike(ilike_query), - # Summary description partial match - OpportunitySummary.summary_description.ilike(ilike_query), - # assistance listing number matches exactly or program title partial match - OpportunityAssistanceListing.assistance_listing_number == query, - OpportunityAssistanceListing.program_title.ilike(ilike_query), - ) - ) - - return stmt - - -def _add_filters( - stmt: Select[tuple[Any]], filters: SearchOpportunityFilters | None -) -> Select[tuple[Any]]: - if filters is None: - return stmt - - if filters.opportunity_status is not None: - one_of_opportunity_statuses = filters.opportunity_status.get("one_of") - - if one_of_opportunity_statuses: - stmt = stmt.where( - CurrentOpportunitySummary.opportunity_status.in_(one_of_opportunity_statuses) - ) - - if filters.funding_instrument is not None: - stmt = stmt.join(LinkOpportunitySummaryFundingInstrument) - - one_of_funding_instruments = filters.funding_instrument.get("one_of") - if one_of_funding_instruments: - stmt = stmt.where( - LinkOpportunitySummaryFundingInstrument.funding_instrument.in_( - one_of_funding_instruments - ) - ) - - if filters.funding_category is not None: - stmt = stmt.join(LinkOpportunitySummaryFundingCategory) - - one_of_funding_categories = filters.funding_category.get("one_of") - if one_of_funding_categories: - stmt = stmt.where( - LinkOpportunitySummaryFundingCategory.funding_category.in_( - one_of_funding_categories - ) - ) - - if filters.applicant_type is not None: - stmt = stmt.join(LinkOpportunitySummaryApplicantType) - - one_of_applicant_types = filters.applicant_type.get("one_of") - if one_of_applicant_types: - stmt = stmt.where( - LinkOpportunitySummaryApplicantType.applicant_type.in_(one_of_applicant_types) - ) - - if filters.agency is not None: - # Note that we filter against the agency code in the opportunity, not in the summary - one_of_agencies = filters.agency.get("one_of") - if one_of_agencies: - stmt = stmt.where(Opportunity.agency_code.in_(one_of_agencies)) - - return stmt - - -def _add_order_by( - stmt: Select[tuple[Opportunity]], pagination: PaginationParams -) -> Select[tuple[Opportunity]]: - # This generates an order by command like: - # - # ORDER BY opportunity.opportunity_id DESC NULLS LAST - - # This determines whether we use ascending or descending when building the query - sort_fn = asc if pagination.is_ascending else desc - - match pagination.order_by: - case "opportunity_id": - field: InstrumentedAttribute = Opportunity.opportunity_id - case "opportunity_number": - field = Opportunity.opportunity_number - case "opportunity_title": - field = Opportunity.opportunity_title - case "post_date": - field = OpportunitySummary.post_date - # Need to add joins to the query stmt to order by field from opportunity summary - stmt = _join_stmt_to_current_summary(stmt) - case "close_date": - field = OpportunitySummary.close_date - # Need to add joins to the query stmt to order by field from opportunity summary - stmt = _join_stmt_to_current_summary(stmt) - case "agency_code": - field = Opportunity.agency_code - case _: - # If this exception happens, it means our API schema - # allows for values we don't have implemented. This - # means we can't determine how to sort / need to correct - # the mismatch. - msg = f"Unconfigured sort_by parameter {pagination.order_by} provided, cannot determine how to sort." - raise Exception(msg) - - # Any values that are null will automatically be sorted to the end - return stmt.order_by(nulls_last(sort_fn(field))) - - -def search_opportunities( - db_session: db.Session, raw_search_params: dict -) -> Tuple[Sequence[Opportunity], PaginationInfo]: - search_params = SearchOpportunityParams.model_validate(raw_search_params) - - """ - We create an inner query which handles all of the filtering and returns - a set of opportunity IDs for the outer query to filter against. This query - ends up looking like (varying based on exact filters): - - SELECT - opportunity.opportunity_id - FROM opportunity - JOIN current_opportunity_summary ON opportunity.opportunity_id = current_opportunity_summary.opportunity_id - JOIN opportunity_summary ON current_opportunity_summary.opportunity_summary_id = opportunity_summary.opportunity_summary_id - JOIN link_opportunity_summary_funding_instrument ON opportunity_summary.opportunity_summary_id = link_opportunity_summary_funding_instrument.opportunity_summary_id - JOIN link_opportunity_summary_funding_category ON opportunity_summary.opportunity_summary_id = link_opportunity_summary_funding_category.opportunity_summary_id - JOIN link_opportunity_summary_applicant_type ON opportunity_summary.opportunity_summary_id = link_opportunity_summary_applicant_type.opportunity_summary_id - WHERE - opportunity.is_draft IS FALSE - AND(EXISTS ( - SELECT - 1 FROM current_opportunity_summary - WHERE - opportunity.opportunity_id = current_opportunity_summary.opportunity_id)) - AND link_opportunity_summary_funding_instrument.funding_instrument_id IN(1, 2, 3, 4)) - """ - inner_stmt = ( - select(Opportunity.opportunity_id).where( - Opportunity.is_draft.is_(False) - ) # Only ever return non-drafts - # Filter anything without a current opportunity summary - .where(Opportunity.current_opportunity_summary != None) # noqa: E711 - ) - - # Current + Opportunity Summary are always needed so just add them here - inner_stmt = _join_stmt_to_current_summary(inner_stmt) - inner_stmt = _add_query_filters(inner_stmt, search_params.query) - inner_stmt = _add_filters(inner_stmt, search_params.filters) - - # - # - """ - The outer query handles sorting and filters against the inner query described above. - This ends up looking like (joins to current opportunity if ordering by other fields): - - SELECT - opportunity.opportunity_id, - opportunity.opportunity_title, - -- and so on for the opportunity table fields - FROM opportunity - WHERE - opportunity.opportunity_id in ( /* the above subquery */ ) - ORDER BY - opportunity.opportunity_id DESC NULLS LAST - LIMIT 25 OFFSET 100 - """ - stmt = ( - select(Opportunity).where(Opportunity.opportunity_id.in_(inner_stmt)) - # selectinload makes it so all relationships are loaded and attached to the Opportunity - # records that we end up fetching. It emits a separate "select * from table where opportunity_id in (x, y ,z)" - # for each relationship. This is used instead of joinedload as it ends up more performant for complex models - # Note that we set all_opportunity_summaries to noload as we don't need it in this API, and it would make the queries load more than necessary - # - # See: https://docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html#what-kind-of-loading-to-use - .options(selectinload("*"), noload(Opportunity.all_opportunity_summaries)) - ) - - stmt = _add_order_by(stmt, search_params.pagination) - - paginator: Paginator[Opportunity] = Paginator( - Opportunity, stmt, db_session, page_size=search_params.pagination.page_size - ) - opportunities = paginator.page_at(page_offset=search_params.pagination.page_offset) - pagination_info = PaginationInfo.from_pagination_params(search_params.pagination, paginator) - - return opportunities, pagination_info diff --git a/api/tests/src/api/opportunities_v0_1/__init__.py b/api/tests/src/api/opportunities_v0_1/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/api/tests/src/api/opportunities_v0_1/conftest.py b/api/tests/src/api/opportunities_v0_1/conftest.py deleted file mode 100644 index f67b81c7e..000000000 --- a/api/tests/src/api/opportunities_v0_1/conftest.py +++ /dev/null @@ -1,281 +0,0 @@ -# This file contains utilities for testing the opportunities routes -from datetime import date - -from src.constants.lookup_constants import ( - ApplicantType, - FundingCategory, - FundingInstrument, - OpportunityStatus, -) -from src.db.models.opportunity_models import ( - Opportunity, - OpportunityAssistanceListing, - OpportunitySummary, -) -from tests.src.db.models.factories import ( - CurrentOpportunitySummaryFactory, - LinkOpportunitySummaryApplicantTypeFactory, - LinkOpportunitySummaryFundingCategoryFactory, - LinkOpportunitySummaryFundingInstrumentFactory, - OpportunityAssistanceListingFactory, - OpportunityFactory, - OpportunitySummaryFactory, -) - -##################################### -# Data setup utils -##################################### - - -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 - - -def setup_opportunity( - opportunity_id: int, - /, # all named params after this - has_current_opportunity: bool = True, - has_other_non_current_opportunity: bool = False, - is_draft: bool = False, - opportunity_title: str = "Default opportunity title", - opportunity_number: str | None = None, - opportunity_status: OpportunityStatus | None = OpportunityStatus.POSTED, - summary_description: str = "Default summary description", - funding_instruments: list[FundingInstrument] | None = None, - funding_categories: list[FundingCategory] | None = None, - applicant_types: list[ApplicantType] | None = None, - agency: str | None = "DEFAULT-ABC", - assistance_listings: list[tuple[str, str]] | None = None, - # These values use the passed in value (including None), otherwise the factory value - post_date: date | str | None = "UNSET", - close_date: date | str | None = "UNSET", -): - if opportunity_number is None: - opportunity_number = f"OPP-NUMBER-{opportunity_id}" - - opportunity = OpportunityFactory.create( - opportunity_id=opportunity_id, - no_current_summary=True, - agency_code=agency, - is_draft=is_draft, - opportunity_title=opportunity_title, - opportunity_number=opportunity_number, - ) - - if assistance_listings: - for assistance_listing_number, program_title in assistance_listings: - OpportunityAssistanceListingFactory.create( - opportunity=opportunity, - assistance_listing_number=assistance_listing_number, - program_title=program_title, - ) - - if has_current_opportunity: - dates = {} - - if post_date != "UNSET": - dates["post_date"] = post_date - if close_date != "UNSET": - dates["close_date"] = close_date - - opportunity_summary = OpportunitySummaryFactory.create( - opportunity=opportunity, - revision_number=2, - no_link_values=True, - summary_description=summary_description, - **dates, - ) - CurrentOpportunitySummaryFactory.create( - opportunity=opportunity, - opportunity_summary=opportunity_summary, - opportunity_status=opportunity_status, - ) - - if funding_instruments: - for funding_instrument in funding_instruments: - LinkOpportunitySummaryFundingInstrumentFactory.create( - opportunity_summary=opportunity_summary, funding_instrument=funding_instrument - ) - - if funding_categories: - for funding_category in funding_categories: - LinkOpportunitySummaryFundingCategoryFactory.create( - opportunity_summary=opportunity_summary, funding_category=funding_category - ) - - if applicant_types: - for applicant_type in applicant_types: - LinkOpportunitySummaryApplicantTypeFactory.create( - opportunity_summary=opportunity_summary, applicant_type=applicant_type - ) - - if has_other_non_current_opportunity: - OpportunitySummaryFactory.create(opportunity=opportunity, revision_number=1) - - -##################################### -# 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_code == resp_opportunity["agency_code"] - 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_v0_1/test_opportunity_route_get.py b/api/tests/src/api/opportunities_v0_1/test_opportunity_route_get.py deleted file mode 100644 index cdb599bab..000000000 --- a/api/tests/src/api/opportunities_v0_1/test_opportunity_route_get.py +++ /dev/null @@ -1,118 +0,0 @@ -import pytest - -from src.db.models.opportunity_models import Opportunity -from tests.src.api.opportunities_v0_1.conftest import get_search_request, 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"/v0.1/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("/v0.1/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"/v0.1/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}" - ) - - -##################################### -# Auth tests -##################################### -@pytest.mark.parametrize( - "method,url,body", - [ - ("POST", "/v0.1/opportunities/search", get_search_request()), - ("GET", "/v0.1/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_v0_1/test_opportunity_route_search.py b/api/tests/src/api/opportunities_v0_1/test_opportunity_route_search.py deleted file mode 100644 index 6529fc651..000000000 --- a/api/tests/src/api/opportunities_v0_1/test_opportunity_route_search.py +++ /dev/null @@ -1,1125 +0,0 @@ -from datetime import date -from enum import IntEnum - -import pytest - -from src.constants.lookup_constants import ( - ApplicantType, - FundingCategory, - FundingInstrument, - OpportunityStatus, -) -from src.util.dict_util import flatten_dict -from tests.conftest import BaseTestClass -from tests.src.api.opportunities_v0_1.conftest import ( - get_search_request, - setup_opportunity, - validate_search_pagination, -) -from tests.src.db.models.factories import OpportunityFactory - -##################################### -# Pagination and ordering tests -##################################### - - -class TestSearchPagination(BaseTestClass): - @pytest.fixture(scope="class") - def setup_scenarios(self, truncate_opportunities, enable_factory_create): - # This test is just focused on testing the pagination - # We don't need to worry about anything specific as there is no filtering - OpportunityFactory.create_batch(size=10) - - @pytest.mark.parametrize( - "search_request,expected_values", - [ - ### Verifying page offset and size work properly - # In a few of these tests we specify all possible values - # for a given filter. This is to make sure that the one-to-many - # relationships don't cause the counts to get thrown off - ( - get_search_request( - page_offset=1, - page_size=5, - funding_instrument_one_of=[e for e in FundingInstrument], - ), - dict(total_pages=2, total_records=10, response_record_count=5), - ), - ( - get_search_request( - page_offset=2, page_size=3, funding_category_one_of=[e for e in FundingCategory] - ), - dict(total_pages=4, total_records=10, response_record_count=3), - ), - ( - get_search_request(page_offset=3, page_size=4), - dict(total_pages=3, total_records=10, response_record_count=2), - ), - ( - get_search_request(page_offset=10, page_size=1), - dict(total_pages=10, total_records=10, response_record_count=1), - ), - ( - get_search_request(page_offset=100, page_size=5), - dict(total_pages=2, total_records=10, response_record_count=0), - ), - ], - ) - def test_opportunity_search_paging_200( - self, - client, - api_auth_token, - enable_factory_create, - search_request, - expected_values, - setup_scenarios, - ): - resp = client.post( - "/v0.1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} - ) - - search_response = resp.get_json() - assert resp.status_code == 200 - - validate_search_pagination( - search_response, - search_request, - expected_values["total_pages"], - expected_values["total_records"], - expected_values["response_record_count"], - ) - - -class TestSearchOrdering(BaseTestClass): - @pytest.fixture(scope="class") - def setup_scenarios(self, truncate_opportunities, enable_factory_create): - setup_opportunity( - 1, - opportunity_number="dddd", - opportunity_title="zzzz", - post_date=date(2024, 3, 1), - close_date=None, - agency="mmmm", - ) - setup_opportunity( - 2, - opportunity_number="eeee", - opportunity_title="yyyy", - post_date=date(2024, 2, 1), - close_date=date(2024, 12, 1), - agency="nnnn", - ) - setup_opportunity( - 3, - opportunity_number="aaaa", - opportunity_title="wwww", - post_date=date(2024, 5, 1), - close_date=date(2024, 11, 1), - agency="llll", - ) - setup_opportunity( - 4, - opportunity_number="bbbb", - opportunity_title="uuuu", - post_date=date(2024, 4, 1), - close_date=date(2024, 10, 1), - agency="kkkk", - ) - setup_opportunity( - 5, - opportunity_number="cccc", - opportunity_title="xxxx", - post_date=date(2024, 1, 1), - close_date=date(2024, 9, 1), - agency="oooo", - ) - - @pytest.mark.parametrize( - "search_request,expected_order", - [ - ### These various scenarios are setup so that the order will be different depending on the field - ### See the set values in the above setup method - # Opportunity ID - ( - get_search_request(order_by="opportunity_id", sort_direction="ascending"), - [1, 2, 3, 4, 5], - ), - ( - get_search_request(order_by="opportunity_id", sort_direction="descending"), - [5, 4, 3, 2, 1], - ), - # Opportunity number - ( - get_search_request(order_by="opportunity_number", sort_direction="ascending"), - [3, 4, 5, 1, 2], - ), - ( - get_search_request(order_by="opportunity_number", sort_direction="descending"), - [2, 1, 5, 4, 3], - ), - # Opportunity title - ( - get_search_request(order_by="opportunity_title", sort_direction="ascending"), - [4, 3, 5, 2, 1], - ), - ( - get_search_request(order_by="opportunity_title", sort_direction="descending"), - [1, 2, 5, 3, 4], - ), - # Post date - (get_search_request(order_by="post_date", sort_direction="ascending"), [5, 2, 1, 4, 3]), - ( - get_search_request(order_by="post_date", sort_direction="descending"), - [3, 4, 1, 2, 5], - ), - # Close date - # note that opportunity id 1's value is null which always goes to the end regardless of direction - ( - get_search_request(order_by="close_date", sort_direction="ascending"), - [5, 4, 3, 2, 1], - ), - ( - get_search_request(order_by="close_date", sort_direction="descending"), - [2, 3, 4, 5, 1], - ), - # Agency - ( - get_search_request(order_by="agency_code", sort_direction="ascending"), - [4, 3, 1, 2, 5], - ), - ( - get_search_request(order_by="agency_code", sort_direction="descending"), - [5, 2, 1, 3, 4], - ), - ], - ) - def test_opportunity_sorting_200( - self, client, api_auth_token, search_request, expected_order, setup_scenarios - ): - resp = client.post( - "/v0.1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} - ) - - search_response = resp.get_json() - assert resp.status_code == 200 - - returned_opportunity_ids = [record["opportunity_id"] for record in search_response["data"]] - - assert returned_opportunity_ids == expected_order - - -##################################### -# Search querying & filtering -##################################### - - -class Scenario(IntEnum): - DRAFT_OPPORTUNITY = 0 - NO_CURRENT_SUMMARY = 1 - NO_CURRENT_SUMMARY_BUT_HAS_SUMMARY = 2 - - # Scenarios where the opportunity status is set, but all other values are null/empty list - POSTED_NULL_OTHER_VALUES = 3 - FORECASTED_NULL_OTHER_VALUES = 4 - CLOSED_NULL_OTHER_VALUES = 5 - ARCHIVED_NULL_OTHER_VALUES = 6 - - # Posted opportunity status, has every enum value so will always appear when filtering by those - POSTED_ALL_ENUM_VALUES = 7 - - ### Various different scenarios, see where we generate these to get a better idea of what is set for each - - # Applicant types: State governments / county governments - # Agency: DIFFERENT-ABC - POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES = 8 - - # Funding instruments: Cooperative agreement / procurement contract - # Funding categories: Health / food and nutrition - FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES = 9 - - # Funding categories: Food and nutrition / energy - # Agency: DIFFERENT-XYZ - CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES = 10 - - # Funding instruments: grant - # Applicant types: individuals - ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE = 11 - - # Funding instrument: Procurement contract - # Funding category: environment - # Applicant type: Small businesses - POSTED_ONE_OF_EACH_ENUM = 12 - - # Opportunity title has a percentage sign which we search against - POSTED_OPPORTUNITY_TITLE_HAS_PERCENT = 13 - - # Description has several random characters that we search against - CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS = 14 - - -def search_scenario_id_fnc(val): - # Because we have a lot of tests, having the ID output by pytest simply be - # search_request11-expected_scenarios11 - # can be a bit difficult to follow. This method is attached to the tests in the - # below class to try and roughly format the output into something readable to help - # you find the test we want. - # - # Note that Pytest calls this method once for each parametrized value, so the list - # represents the expected results, and the dict represents the search request object. - if isinstance(val, list): - return "Expected:" + ",".join([v.name for v in val]) - - if isinstance(val, dict): - # The pagination doesn't matter much for these tests - # so exclude it from the test name ID - copy_dict = val.copy() - del copy_dict["pagination"] - # Note that pytest seems to disallow periods in the ID names, so flatten - # it using / instead - return str(flatten_dict(copy_dict, separator="/")) - - # fallback in case we setup anything else to just use the value as is - return val - - -class TestSearchScenarios(BaseTestClass): - """ - Group the scenario tests in a class for performance. As the setup for these - tests is slow, but can be shared across all of them, initialize them once - and then run the tests. This reduced the runtime from ~30s to ~3s. - """ - - @pytest.fixture(scope="class") - def setup_scenarios(self, truncate_opportunities, enable_factory_create): - # Won't be returned ever because it's a draft opportunity - setup_opportunity(Scenario.DRAFT_OPPORTUNITY, is_draft=True) - - # No summary / current to be queried against, never returned - setup_opportunity(Scenario.NO_CURRENT_SUMMARY, has_current_opportunity=False) - - # Won't be returned in any results as there is no link in the current_opportunity_summary table - setup_opportunity( - Scenario.NO_CURRENT_SUMMARY_BUT_HAS_SUMMARY, - has_current_opportunity=False, - has_other_non_current_opportunity=True, - ) - - # These don't contain any agency or list values - # The first one does have a non-current opportunity that isn't used - setup_opportunity( - Scenario.POSTED_NULL_OTHER_VALUES, - opportunity_status=OpportunityStatus.POSTED, - agency=None, - has_other_non_current_opportunity=True, - ) - setup_opportunity( - Scenario.FORECASTED_NULL_OTHER_VALUES, - opportunity_status=OpportunityStatus.FORECASTED, - agency=None, - ) - setup_opportunity( - Scenario.CLOSED_NULL_OTHER_VALUES, - opportunity_status=OpportunityStatus.CLOSED, - agency=None, - ) - setup_opportunity( - Scenario.ARCHIVED_NULL_OTHER_VALUES, - opportunity_status=OpportunityStatus.ARCHIVED, - agency=None, - ) - - setup_opportunity( - Scenario.POSTED_ALL_ENUM_VALUES, - opportunity_status=OpportunityStatus.POSTED, - opportunity_title="I have collected every enum known to humankind", - funding_instruments=[e for e in FundingInstrument], - funding_categories=[e for e in FundingCategory], - applicant_types=[e for e in ApplicantType], - ) - - setup_opportunity( - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - opportunity_status=OpportunityStatus.POSTED, - opportunity_title="I have collected very few enum known to humankind", - applicant_types=[ApplicantType.STATE_GOVERNMENTS, ApplicantType.COUNTY_GOVERNMENTS], - agency="DIFFERENT-ABC", - ) - - setup_opportunity( - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - opportunity_status=OpportunityStatus.FORECASTED, - funding_instruments=[ - FundingInstrument.COOPERATIVE_AGREEMENT, - FundingInstrument.PROCUREMENT_CONTRACT, - ], - funding_categories=[FundingCategory.HEALTH, FundingCategory.FOOD_AND_NUTRITION], - ) - - setup_opportunity( - Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES, - summary_description="I am a description for an opportunity", - opportunity_status=OpportunityStatus.CLOSED, - funding_categories=[FundingCategory.FOOD_AND_NUTRITION, FundingCategory.ENERGY], - agency="DIFFERENT-XYZ", - ) - - setup_opportunity( - Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE, - opportunity_status=OpportunityStatus.ARCHIVED, - funding_instruments=[FundingInstrument.GRANT], - applicant_types=[ApplicantType.INDIVIDUALS], - ) - - setup_opportunity( - Scenario.POSTED_ONE_OF_EACH_ENUM, - opportunity_status=OpportunityStatus.POSTED, - funding_instruments=[FundingInstrument.PROCUREMENT_CONTRACT], - funding_categories=[FundingCategory.ENVIRONMENT], - applicant_types=[ApplicantType.SMALL_BUSINESSES], - ) - - setup_opportunity( - Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT, - opportunity_title="Investigate 50% of everything", - assistance_listings=[ - ("01.234", "The first example assistance listing"), - ("56.78", "The second example assistance listing"), - ], - ) - - setup_opportunity( - Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS, - opportunity_status=OpportunityStatus.CLOSED, - summary_description="/$!-/%^&hello*~%%%//%@#$", - assistance_listings=[ - ("01.234", "The first example assistance listing"), - ("99.999", "The third example assistance listing"), - ], - ) - - @pytest.mark.parametrize( - "search_request,expected_scenarios", - [ - # No filters, should return everything returnable - ( - get_search_request(page_size=25), - [ - s - for s in Scenario - if s - not in [ - Scenario.DRAFT_OPPORTUNITY, - Scenario.NO_CURRENT_SUMMARY, - Scenario.NO_CURRENT_SUMMARY_BUT_HAS_SUMMARY, - ] - ], - ), - ### Opportunity status tests - # Just posted - ( - get_search_request( - page_size=25, opportunity_status_one_of=[OpportunityStatus.POSTED] - ), - [ - Scenario.POSTED_ONE_OF_EACH_ENUM, - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.POSTED_NULL_OTHER_VALUES, - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT, - ], - ), - # Just forecasted - ( - get_search_request( - page_size=25, opportunity_status_one_of=[OpportunityStatus.FORECASTED] - ), - [ - Scenario.FORECASTED_NULL_OTHER_VALUES, - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - ], - ), - # Closed or archived - ( - get_search_request( - page_size=25, - opportunity_status_one_of=[ - OpportunityStatus.CLOSED, - OpportunityStatus.ARCHIVED, - ], - ), - [ - Scenario.CLOSED_NULL_OTHER_VALUES, - Scenario.ARCHIVED_NULL_OTHER_VALUES, - Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES, - Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE, - Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS, - ], - ), - # Posted or forecasted - ( - get_search_request( - page_size=25, - opportunity_status_one_of=[ - OpportunityStatus.POSTED, - OpportunityStatus.FORECASTED, - ], - ), - [ - Scenario.POSTED_ONE_OF_EACH_ENUM, - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.POSTED_NULL_OTHER_VALUES, - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - Scenario.FORECASTED_NULL_OTHER_VALUES, - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT, - ], - ), - ### Agency field tests - ### By default agency is set to "DEFAULT-ABC" with a few overriding that to "DIFFERENT-" - # Should only return the agencies that start "DIFFERENT-" - ( - get_search_request(page_size=25, agency_one_of=["DIFFERENT-ABC", "DIFFERENT-XYZ"]), - [ - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES, - ], - ), - # Should not return anything as no agency begins with this - (get_search_request(page_size=25, agency_one_of=["no agency starts with this"]), []), - # Should only return a single agency as it's the only one that has this name - ( - get_search_request(page_size=25, agency_one_of=["DIFFERENT-XYZ"]), - [Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES], - ), - # Should return everything with agency set as these are all the values we set - ( - get_search_request( - page_size=25, agency_one_of=["DEFAULT-ABC", "DIFFERENT-ABC", "DIFFERENT-XYZ"] - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES, - Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE, - Scenario.POSTED_ONE_OF_EACH_ENUM, - Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT, - Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS, - ], - ), - ### Testing the one-to-many enum values - ### By default we didn't set these values, so testing against only directly defined data - # An applicant type we set, and one we only set on the "all" scenario. - ( - get_search_request( - page_size=25, - applicant_type_one_of=[ - ApplicantType.STATE_GOVERNMENTS, - ApplicantType.NONPROFITS_NON_HIGHER_EDUCATION_WITH_501C3, - ], - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - ], - ), - # A different applicant type set by a different scenario - ( - get_search_request(page_size=25, applicant_type_one_of=[ApplicantType.INDIVIDUALS]), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE, - ], - ), - # Applicant types only set by the scenario that set every enum - ( - get_search_request( - page_size=25, - applicant_type_one_of=[ - ApplicantType.INDEPENDENT_SCHOOL_DISTRICTS, - ApplicantType.PUBLIC_AND_INDIAN_HOUSING_AUTHORITIES, - ], - ), - [Scenario.POSTED_ALL_ENUM_VALUES], - ), - # Procurement contract funding instrument was configured on a few - ( - get_search_request( - page_size=25, funding_instrument_one_of=[FundingInstrument.PROCUREMENT_CONTRACT] - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - Scenario.POSTED_ONE_OF_EACH_ENUM, - ], - ), - # Funding instrument only configured on the all-enum scenario - ( - get_search_request( - page_size=25, funding_instrument_one_of=[FundingInstrument.OTHER] - ), - [Scenario.POSTED_ALL_ENUM_VALUES], - ), - # Multiple funding instruments gets everything we configured - ( - get_search_request( - page_size=25, - funding_instrument_one_of=[ - FundingInstrument.PROCUREMENT_CONTRACT, - FundingInstrument.GRANT, - ], - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - Scenario.POSTED_ONE_OF_EACH_ENUM, - Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE, - ], - ), - # A few scenarios set the food & nutrition funding category - ( - get_search_request( - page_size=25, funding_category_one_of=[FundingCategory.FOOD_AND_NUTRITION] - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES, - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - ], - ), - # Only the all-enum scenario sets any of these funding categories - ( - get_search_request( - page_size=25, - funding_category_one_of=[ - FundingCategory.ARTS, - FundingCategory.OPPORTUNITY_ZONE_BENEFITS, - FundingCategory.HUMANITIES, - ], - ), - [Scenario.POSTED_ALL_ENUM_VALUES], - ), - ### Various tests with multiple filters - # Agency starts with different, and applicant type gives only a single result - ( - get_search_request( - page_size=25, - agency_one_of=["DIFFERENT-ABC"], - applicant_type_one_of=[ApplicantType.COUNTY_GOVERNMENTS], - ), - [Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES], - ), - # Posted/forecasted opportunity with procurement funding instrument gives several - ( - get_search_request( - page_size=25, - opportunity_status_one_of=[ - OpportunityStatus.POSTED, - OpportunityStatus.FORECASTED, - ], - funding_instrument_one_of=[FundingInstrument.PROCUREMENT_CONTRACT], - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES, - Scenario.POSTED_ONE_OF_EACH_ENUM, - ], - ), - # Passing us a filter with every enum value will return everything that has something set - ( - get_search_request( - page_size=25, - funding_instrument_one_of=[e for e in FundingInstrument], - funding_category_one_of=[e for e in FundingCategory], - applicant_type_one_of=[e for e in ApplicantType], - opportunity_status_one_of=[e for e in OpportunityStatus], - ), - [Scenario.POSTED_ONE_OF_EACH_ENUM, Scenario.POSTED_ALL_ENUM_VALUES], - ), - # In addition to the all-enum scenario, the other two scenarios share no applicant type / funding instrument, but the search returns both as we query for both - ( - get_search_request( - page_size=25, - applicant_type_one_of=[ - ApplicantType.SMALL_BUSINESSES, - ApplicantType.INDIVIDUALS, - ], - funding_instrument_one_of=[ - FundingInstrument.GRANT, - FundingInstrument.PROCUREMENT_CONTRACT, - ], - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE, - Scenario.POSTED_ONE_OF_EACH_ENUM, - ], - ), - ### A few scenarios that are too specific to return anything - ( - get_search_request( - page_size=25, - opportunity_status_one_of=[OpportunityStatus.ARCHIVED], - funding_instrument_one_of=[FundingInstrument.PROCUREMENT_CONTRACT], - ), - [], - ), - ( - get_search_request( - page_size=25, - opportunity_status_one_of=[OpportunityStatus.FORECASTED], - agency_one_of=["DIFFERENT-ABC", "DIFFERENT-XYZ"], - ), - [], - ), - ], - ids=search_scenario_id_fnc, - ) - def test_opportunity_search_filters_200( - self, client, api_auth_token, search_request, expected_scenarios, setup_scenarios - ): - self.query_and_validate_results(client, api_auth_token, search_request, expected_scenarios) - - @pytest.mark.parametrize( - "search_request,expected_scenarios", - [ - ### Verify that passing in a percentage sign (which is used in ilike) works still - ( - get_search_request(page_size=25, query="50% of everything"), - [Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT], - ), - ( - get_search_request(page_size=25, query="50%"), - [Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT], - ), - ### Can query against opportunity number (note it gets generated as OPP-NUMBER-{scenario} automatically) - ( - get_search_request( - page_size=25, - query=f"OPP-NUMBER-{Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES}", - ), - [Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES], - ), - ( - get_search_request( - page_size=25, - query=f"NUMBER-{Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE}", - ), - [Scenario.ARCHIVED_ONLY_ONE_FUNDING_INSTRUMENT_ONE_APPLICANT_TYPE], - ), - ### These all fetch the same description which has a lot of weird characters in it - # just the readable part - ( - get_search_request(page_size=25, query="hello"), - [Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS], - ), - # The whole thing - ( - get_search_request(page_size=25, query="/$!-/%^&hello*~%%%//%@#$"), - [Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS], - ), - # part of it - ( - get_search_request(page_size=25, query="*~%%%"), - [Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS], - ), - ### Can query against agency similar to the agency filter itself - ( - get_search_request(page_size=25, query="diffeRENT"), - [ - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES, - ], - ), - ( - get_search_request(page_size=25, query="diffeRENT-xYz"), - [Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES], - ), - ### Assistance listing number + program title queries - ( - get_search_request(page_size=25, query="01.234"), - [ - Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT, - Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS, - ], - ), - ( - get_search_request(page_size=25, query="56.78"), - [Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT], - ), - ( - get_search_request(page_size=25, query="99.999"), - [Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS], - ), - ( - get_search_request(page_size=25, query="example assistance listing"), - [ - Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT, - Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS, - ], - ), - ( - get_search_request(page_size=25, query="second example"), - [Scenario.POSTED_OPPORTUNITY_TITLE_HAS_PERCENT], - ), - ( - get_search_request(page_size=25, query="the third example"), - [Scenario.CLOSED_SUMMARY_DESCRIPTION_MANY_CHARACTERS], - ), - ### A few queries that return nothing as they're way too specific, even if they sort've overlap actual values - (get_search_request(query="different types of words that are so specific"), []), - (get_search_request(query="US-ABC DIFFERENT US-XYZ"), []), - (get_search_request(query="01.234.56.78.99"), []), - (get_search_request(query="the fourth example"), []), - ], - ids=search_scenario_id_fnc, - ) - def test_opportunity_query_string_200( - self, client, api_auth_token, search_request, expected_scenarios, setup_scenarios - ): - self.query_and_validate_results(client, api_auth_token, search_request, expected_scenarios) - - @pytest.mark.parametrize( - "search_request,expected_scenarios", - [ - # There are two forecasted records, but only one has an agency of "default-abc" - ( - get_search_request( - page_size=25, - query="default-abc", - opportunity_status_one_of=[OpportunityStatus.FORECASTED], - ), - [Scenario.FORECASTED_FUNDING_INSTRUMENTS_AND_CATEGORIES], - ), - # There are a few opportunities with "humankind" in their title - ( - get_search_request( - page_size=25, - query="humankind", - applicant_type_one_of=[ - ApplicantType.STATE_GOVERNMENTS, - ApplicantType.FEDERALLY_RECOGNIZED_NATIVE_AMERICAN_TRIBAL_GOVERNMENTS, - ], - ), - [ - Scenario.POSTED_ALL_ENUM_VALUES, - Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES, - ], - ), - # Like the previous one, but the query is more specific and only gets one of the scenarios - ( - get_search_request( - page_size=25, - query="very few enum known to humankind", - applicant_type_one_of=[ - ApplicantType.STATE_GOVERNMENTS, - ApplicantType.FEDERALLY_RECOGNIZED_NATIVE_AMERICAN_TRIBAL_GOVERNMENTS, - ], - ), - [Scenario.POSTED_NON_DEFAULT_AGENCY_WITH_APP_TYPES], - ), - # Agency filtered by one_of, query hits something in the summary description - ( - get_search_request( - page_size=25, - query="i am a description", - agency_one_of=["DIFFERENT-XYZ", "DIFFERENT-abc"], - ), - [Scenario.CLOSED_NON_DEFAULT_AGENCY_WITH_FUNDING_CATEGORIES], - ), - ### A few scenarios that don't return any results because filters/query make it too specific - ( - get_search_request( - page_size=25, - query="humankind", - opportunity_status_one_of=[ - OpportunityStatus.FORECASTED, - OpportunityStatus.CLOSED, - OpportunityStatus.ARCHIVED, - ], - ), - [], - ), - ( - get_search_request( - page_size=25, - query="different", - funding_instrument_one_of=[FundingInstrument.GRANT], - ), - [], - ), - ( - get_search_request( - page_size=25, - query="words that don't hit anything", - applicant_type_one_of=[ - ApplicantType.STATE_GOVERNMENTS, - ApplicantType.FEDERALLY_RECOGNIZED_NATIVE_AMERICAN_TRIBAL_GOVERNMENTS, - ], - ), - [], - ), - ], - ids=search_scenario_id_fnc, - ) - def test_opportunity_query_and_filter_200( - self, client, api_auth_token, search_request, expected_scenarios, setup_scenarios - ): - # Basically a combo of the above two tests, testing requests with both query text and filters set - self.query_and_validate_results(client, api_auth_token, search_request, expected_scenarios) - - def query_and_validate_results( - self, client, api_auth_token, search_request, expected_scenarios - ): - resp = client.post( - "/v0.1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} - ) - - search_response = resp.get_json() - assert resp.status_code == 200 - - returned_scenarios = set([record["opportunity_id"] for record in search_response["data"]]) - expected_scenarios = set(expected_scenarios) - - if expected_scenarios != returned_scenarios: - # Find the difference in the expected scenarios and print them nicely to make debugging this test easier - - scenarios_not_found = [ - Scenario(e).name for e in expected_scenarios - returned_scenarios - ] - scenarios_not_expected = [ - Scenario(e).name for e in returned_scenarios - expected_scenarios - ] - - assert ( - expected_scenarios == returned_scenarios - ), f"Scenarios did not match. Search did not return expected scenarios: {scenarios_not_found}, and returned extra scenarios: {scenarios_not_expected}" - - # Verify that the pagnation response makes sense - expected_total_pages = 1 - expected_total_records = len(expected_scenarios) - if expected_total_records == 0: - # page count will be 0 for 0 results - expected_total_pages = 0 - validate_search_pagination( - search_response, - search_request, - expected_total_pages=expected_total_pages, - expected_total_records=expected_total_records, - expected_response_record_count=expected_total_records, - ) - - -##################################### -# Request validation tests -##################################### - - -@pytest.mark.parametrize( - "search_request,expected_response_data", - [ - ( - {}, - [ - { - "field": "pagination", - "message": "Missing data for required field.", - "type": "required", - }, - ], - ), - ( - get_search_request(page_offset=-1, page_size=-1), - [ - { - "field": "pagination.page_size", - "message": "Must be greater than or equal to 1.", - "type": "min_or_max_value", - }, - { - "field": "pagination.page_offset", - "message": "Must be greater than or equal to 1.", - "type": "min_or_max_value", - }, - ], - ), - ( - get_search_request(order_by="fake_field", sort_direction="up"), - [ - { - "field": "pagination.order_by", - "message": "Value must be one of: opportunity_id, opportunity_number, opportunity_title, post_date, close_date, agency_code", - "type": "invalid_choice", - }, - { - "field": "pagination.sort_direction", - "message": "Must be one of: ascending, descending.", - "type": "invalid_choice", - }, - ], - ), - # The one_of enum filters - ( - get_search_request( - funding_instrument_one_of=["not_a_valid_value"], - funding_category_one_of=["also_not_valid", "here_too"], - applicant_type_one_of=["not_an_applicant_type"], - opportunity_status_one_of=["also not real"], - ), - [ - { - "field": "filters.funding_instrument.one_of.0", - "message": f"Must be one of: {', '.join(FundingInstrument)}.", - "type": "invalid_choice", - }, - { - "field": "filters.funding_category.one_of.0", - "message": f"Must be one of: {', '.join(FundingCategory)}.", - "type": "invalid_choice", - }, - { - "field": "filters.funding_category.one_of.1", - "message": f"Must be one of: {', '.join(FundingCategory)}.", - "type": "invalid_choice", - }, - { - "field": "filters.applicant_type.one_of.0", - "message": f"Must be one of: {', '.join(ApplicantType)}.", - "type": "invalid_choice", - }, - { - "field": "filters.opportunity_status.one_of.0", - "message": f"Must be one of: {', '.join(OpportunityStatus)}.", - "type": "invalid_choice", - }, - ], - ), - # Too short of agency - ( - get_search_request(agency_one_of=["a"]), - [ - { - "field": "filters.agency.one_of.0", - "message": "Shorter than minimum length 2.", - "type": "min_length", - } - ], - ), - # Too short of a query - ( - get_search_request(query=""), - [ - { - "field": "query", - "message": "Length must be between 1 and 100.", - "type": "min_or_max_length", - } - ], - ), - # Too long of a query - ( - get_search_request(query="A" * 101), - [ - { - "field": "query", - "message": "Length must be between 1 and 100.", - "type": "min_or_max_length", - } - ], - ), - # Verify that if the one_of lists are empty, we get a validation error - ( - get_search_request( - funding_instrument_one_of=[], - funding_category_one_of=[], - applicant_type_one_of=[], - opportunity_status_one_of=[], - agency_one_of=[], - ), - [ - { - "field": "filters.funding_instrument.one_of", - "message": "Shorter than minimum length 1.", - "type": "min_length", - }, - { - "field": "filters.funding_category.one_of", - "message": "Shorter than minimum length 1.", - "type": "min_length", - }, - { - "field": "filters.applicant_type.one_of", - "message": "Shorter than minimum length 1.", - "type": "min_length", - }, - { - "field": "filters.opportunity_status.one_of", - "message": "Shorter than minimum length 1.", - "type": "min_length", - }, - { - "field": "filters.agency.one_of", - "message": "Shorter than minimum length 1.", - "type": "min_length", - }, - ], - ), - # Validate that if a filter is provided, but empty, we'll provide an exception - # note that the get_search_request() method isn't great for constructing this particular - # case - so we manually define the request instead - ( - { - "pagination": { - "page_offset": 1, - "page_size": 5, - "order_by": "opportunity_id", - "sort_direction": "descending", - }, - "filters": { - "funding_instrument": {}, - "funding_category": {}, - "applicant_type": {}, - "opportunity_status": {}, - "agency": {}, - }, - }, - [ - { - "field": "filters.funding_instrument", - "message": "At least one filter rule must be provided.", - "type": "invalid", - }, - { - "field": "filters.funding_category", - "message": "At least one filter rule must be provided.", - "type": "invalid", - }, - { - "field": "filters.applicant_type", - "message": "At least one filter rule must be provided.", - "type": "invalid", - }, - { - "field": "filters.opportunity_status", - "message": "At least one filter rule must be provided.", - "type": "invalid", - }, - { - "field": "filters.agency", - "message": "At least one filter rule must be provided.", - "type": "invalid", - }, - ], - ), - ], -) -def test_opportunity_search_invalid_request_422( - client, api_auth_token, search_request, expected_response_data -): - resp = client.post( - "/v0.1/opportunities/search", json=search_request, headers={"X-Auth": api_auth_token} - ) - assert resp.status_code == 422 - - response_data = resp.get_json()["errors"] - assert response_data == expected_response_data