Skip to content

Commit

Permalink
[Issue #3685] Save search get opportunity id (#3704)
Browse files Browse the repository at this point in the history
## Summary
Fixes [#{3685}](#3685)

### Time to review: __10 mins__

## Changes proposed
Query the search index limiting the results to only opportunity ID
Store the list of opportunity IDs returned to the DB as a list/array
(does not need to point to opportunity table as foreign keys)
Modify the users query to replace with static pagination 

## Additional information
Updated tests
  • Loading branch information
babebe authored Feb 4, 2025
1 parent c46afb3 commit 0ffc85b
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 21 deletions.
7 changes: 6 additions & 1 deletion api/src/adapters/search/opensearch_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,13 +216,18 @@ def search(
search_query: dict,
include_scores: bool = True,
params: dict | None = None,
includes: list[str] | None = None,
excludes: list[str] | None = None,
) -> SearchResponse:
if params is None:
params = {}

response = self._client.search(
index=index_name, body=search_query, params=params, _source_excludes=excludes
index=index_name,
body=search_query,
params=params,
_source_includes=includes,
_source_excludes=excludes,
)
return SearchResponse.from_opensearch_response(response, include_scores)

Expand Down
8 changes: 5 additions & 3 deletions api/src/api/users/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

import flask

from src.adapters import db
import src.adapters.search.flask_opensearch as flask_opensearch
from src.adapters import db, search
from src.adapters.db import flask_db
from src.api import response
from src.api.route_utils import raise_flask_error
Expand Down Expand Up @@ -249,8 +250,9 @@ def user_get_saved_opportunities(db_session: db.Session, user_id: UUID) -> respo
@user_blueprint.doc(responses=[200, 401])
@user_blueprint.auth_required(api_jwt_auth)
@flask_db.with_db_session()
@flask_opensearch.with_search_client()
def user_save_search(
db_session: db.Session, user_id: UUID, json_data: dict
search_client: search.SearchClient, db_session: db.Session, user_id: UUID, json_data: dict
) -> response.ApiResponse:
logger.info("POST /v1/users/:user_id/saved-searches")

Expand All @@ -261,7 +263,7 @@ def user_save_search(
raise_flask_error(401, "Unauthorized user")

with db_session.begin():
saved_search = create_saved_search(db_session, user_id, json_data)
saved_search = create_saved_search(search_client, db_session, user_id, json_data)

logger.info(
"Saved search for user",
Expand Down
51 changes: 42 additions & 9 deletions api/src/services/opportunities_v1/search_opportunities.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import BaseModel, Field

import src.adapters.search as search
from src.adapters.search.opensearch_response import SearchResponse
from src.api.opportunities_v1.opportunity_schemas import OpportunityV1Schema
from src.pagination.pagination_models import PaginationInfo, PaginationParams, SortDirection
from src.search.search_config import get_search_config
Expand Down Expand Up @@ -53,6 +54,15 @@
ScoringRule.DEFAULT: DEFAULT,
}

STATIC_PAGINATION = {
"pagination": {
"order_by": "post_date",
"page_offset": 1,
"page_size": 1000,
"sort_direction": "descending",
}
}

SCHEMA = OpportunityV1Schema()


Expand Down Expand Up @@ -154,7 +164,7 @@ def _add_aggregations(builder: search.SearchQueryBuilder) -> None:
builder.aggregation_terms("agency", _adjust_field_name("agency_code"), size=1000)


def _get_search_request(params: SearchOpportunityParams) -> dict:
def _get_search_request(params: SearchOpportunityParams, aggregation: bool = True) -> dict:
builder = search.SearchQueryBuilder()

# Make sure total hit count gets counted for more than 10k records
Expand All @@ -176,25 +186,38 @@ def _get_search_request(params: SearchOpportunityParams) -> dict:
# Filters
_add_search_filters(builder, params.filters)

# Aggregations / Facet / Filter Counts
_add_aggregations(builder)
if aggregation:
# Aggregations / Facet / Filter Counts
_add_aggregations(builder)

return builder.build()


def search_opportunities(
search_client: search.SearchClient, raw_search_params: dict
) -> Tuple[Sequence[dict], dict, PaginationInfo]:
search_params = SearchOpportunityParams.model_validate(raw_search_params)

def _search_opportunities(
search_client: search.SearchClient,
search_params: SearchOpportunityParams,
includes: list | None = None,
) -> SearchResponse:
search_request = _get_search_request(search_params)

index_alias = get_search_config().opportunity_search_index_alias
logger.info(
"Querying search index alias %s", index_alias, extra={"search_index_alias": index_alias}
)

response = search_client.search(index_alias, search_request, excludes=["attachments"])
response = search_client.search(
index_alias, search_request, includes=includes, excludes=["attachments"]
)

return response


def search_opportunities(
search_client: search.SearchClient, raw_search_params: dict
) -> Tuple[Sequence[dict], dict, PaginationInfo]:

search_params = SearchOpportunityParams.model_validate(raw_search_params)
response = _search_opportunities(search_client, search_params)

pagination_info = PaginationInfo(
page_offset=search_params.pagination.page_offset,
Expand All @@ -213,3 +236,13 @@ def search_opportunities(
records = SCHEMA.load(response.records, many=True)

return records, response.aggregations, pagination_info


def search_opportunities_id(search_client: search.SearchClient, search_query: dict) -> list:
# Override pagination when calling opensearch
updated_search_query = search_query | STATIC_PAGINATION
search_params = SearchOpportunityParams.model_validate(updated_search_query)

response = _search_opportunities(search_client, search_params, includes=["opportunity_id"])

return [opp["opportunity_id"] for opp in response.records]
13 changes: 10 additions & 3 deletions api/src/services/users/create_saved_search.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import logging
from uuid import UUID

from src.adapters import db
from src.adapters import db, search
from src.db.models.user_models import UserSavedSearch
from src.services.opportunities_v1.search_opportunities import search_opportunities_id

logger = logging.getLogger(__name__)


def create_saved_search(db_session: db.Session, user_id: UUID, json_data: dict) -> UserSavedSearch:
def create_saved_search(
search_client: search.SearchClient, db_session: db.Session, user_id: UUID, json_data: dict
) -> UserSavedSearch:

# Retrieve opportunity IDs
opportunity_ids = search_opportunities_id(search_client, json_data["search_query"])

saved_search = UserSavedSearch(
user_id=user_id,
name=json_data["name"],
search_query=json_data["search_query"],
searched_opportunity_ids=[],
searched_opportunity_ids=opportunity_ids,
)

db_session.add(saved_search)
Expand Down
8 changes: 8 additions & 0 deletions api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,14 @@ def opportunity_index_alias(search_client, monkeypatch_session):
return alias


@pytest.fixture(scope="class")
def opportunity_search_index_class(search_client, monkeypatch):
# Note we don't actually create anything, this is just a random name
alias = f"test-opportunity-index-alias-{uuid.uuid4().int}"
monkeypatch.setenv("OPPORTUNITY_SEARCH_INDEX_ALIAS", alias)
return alias


def _generate_rsa_key_pair():
# Rather than define a private/public key, generate one for the tests
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
Expand Down
87 changes: 82 additions & 5 deletions api/tests/src/api/users/test_user_save_search_post.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,57 @@
import uuid
from datetime import date

import pytest

from src.constants.lookup_constants import FundingInstrument
from src.api.opportunities_v1.opportunity_schemas import OpportunityV1Schema
from src.constants.lookup_constants import (
ApplicantType,
FundingCategory,
FundingInstrument,
OpportunityStatus,
)
from src.db.models.user_models import UserSavedSearch
from tests.src.api.opportunities_v1.conftest import get_search_request
from tests.src.api.opportunities_v1.test_opportunity_route_search import build_opp
from tests.src.db.models.factories import UserFactory

SPORTS = build_opp(
opportunity_title="Research into Sports administrator industry",
opportunity_number="EFG8532950",
agency="USAID",
summary_description="HHS-CDC is looking to further investigate this topic. Car matter style top quality generation effort. Computer purpose while consumer left.",
opportunity_status=OpportunityStatus.FORECASTED,
assistance_listings=[("79.718", "Huff LLC")],
applicant_types=[ApplicantType.OTHER],
funding_instruments=[FundingInstrument.GRANT],
funding_categories=[FundingCategory.SCIENCE_TECHNOLOGY_AND_OTHER_RESEARCH_AND_DEVELOPMENT],
post_date=date(2019, 12, 8),
close_date=date(2024, 12, 28),
is_cost_sharing=True,
expected_number_of_awards=1,
award_floor=402500,
award_ceiling=8050000,
estimated_total_program_funding=5000,
)
MEDICAL_LABORATORY = build_opp(
opportunity_title="Research into Medical laboratory scientific officer industry",
opportunity_number="AO-44-EMC-878",
agency="USAID",
summary_description="HHS-CDC is looking to further investigate this topic. Car matter style top quality generation effort. Computer purpose while consumer left.",
opportunity_status=OpportunityStatus.FORECASTED,
assistance_listings=[("43.012", "Brown LLC")],
applicant_types=[ApplicantType.OTHER],
funding_instruments=[FundingInstrument.GRANT],
funding_categories=[FundingCategory.SCIENCE_TECHNOLOGY_AND_OTHER_RESEARCH_AND_DEVELOPMENT],
post_date=date(2025, 1, 25), #
close_date=date(2025, 6, 4), #
is_cost_sharing=True, #
expected_number_of_awards=1,
award_floor=402500,
award_ceiling=8050000,
estimated_total_program_funding=5000,
)


@pytest.fixture(autouse=True, scope="function")
def clear_saved_searches(db_session):
Expand All @@ -13,6 +60,14 @@ def clear_saved_searches(db_session):
yield


@pytest.fixture
def opportunity_search_index_alias(search_client, monkeypatch):
# Note we don't actually create anything, this is just a random name
alias = f"test-opportunity-search-index-alias-{uuid.uuid4().int}"
monkeypatch.setenv("OPPORTUNITY_SEARCH_INDEX_ALIAS", alias)
return alias


def test_user_save_search_post_unauthorized_user(client, db_session, user, user_auth_token):
# Try to save a search for a different user ID
different_user = UserFactory.create()
Expand Down Expand Up @@ -71,14 +126,31 @@ def test_user_save_search_post_invalid_request(client, user, user_auth_token, db
assert len(saved_searches) == 0


def test_user_save_search_post(client, user, user_auth_token, enable_factory_create, db_session):
def test_user_save_search_post(
client,
opportunity_index,
search_client,
user,
user_auth_token,
enable_factory_create,
db_session,
opportunity_search_index_alias,
monkeypatch,
):
# Test data
search_name = "Test Search"
search_query = get_search_request(
funding_instrument_one_of=[FundingInstrument.GRANT],
agency_one_of=["LOC"],
agency_one_of=["USAID"],
)

# Load into the search index
schema = OpportunityV1Schema()
json_records = [schema.dump(opp) for opp in [SPORTS, MEDICAL_LABORATORY]]
search_client.bulk_upsert(opportunity_index, json_records, "opportunity_id")

search_client.swap_alias_index(opportunity_index, opportunity_search_index_alias)

# Make the request to save a search
response = client.post(
f"/v1/users/{user.user_id}/saved-searches",
Expand All @@ -88,18 +160,23 @@ def test_user_save_search_post(client, user, user_auth_token, enable_factory_cre

assert response.status_code == 200
assert response.json["message"] == "Success"

# Verify the search was saved in the database
saved_search = db_session.query(UserSavedSearch).one()

assert saved_search.user_id == user.user_id
assert saved_search.name == search_name
assert saved_search.search_query == {
"format": "json",
"filters": {"agency": {"one_of": ["LOC"]}, "funding_instrument": {"one_of": ["grant"]}},
"filters": {"agency": {"one_of": ["USAID"]}, "funding_instrument": {"one_of": ["grant"]}},
"pagination": {
"order_by": "opportunity_id",
"page_size": 25,
"page_offset": 1,
"sort_direction": "ascending",
},
}
# Verify pagination for the query was over-written. searched_opportunity_ids should be ordered by "post_date"
assert saved_search.searched_opportunity_ids == [
MEDICAL_LABORATORY.opportunity_id,
SPORTS.opportunity_id,
]

0 comments on commit 0ffc85b

Please sign in to comment.