diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 764b3ce59..f7626b5ee 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -431,6 +431,49 @@ paths: ' security: - ApiKeyAuth: [] + /v1/users/{user_id}/saved-searches: + post: + parameters: + - in: path + name: user_id + schema: + type: string + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSaveSearchResponse' + description: Successful response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Validation error + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Not found + tags: + - User v1 + summary: User Save Search + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserSaveSearchRequest' + security: + - ApiJwtAuth: [] /v1/users/{user_id}/saved-opportunities: get: parameters: @@ -1973,6 +2016,33 @@ components: type: integer description: The HTTP status code example: 200 + UserSaveSearchRequest: + type: object + properties: + name: + type: string + description: Name of the saved search + example: Example search + search_query: + type: + - object + allOf: + - $ref: '#/components/schemas/OpportunitySearchRequestV1' + required: + - name + UserSaveSearchResponse: + type: object + properties: + message: + type: string + description: The message to return + example: Success + data: + example: null + status_code: + type: integer + description: The HTTP status code + example: 200 SavedOpportunitySummaryV1: type: object properties: diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index fc8c4cb9f..b643dbc75 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -15,6 +15,8 @@ UserSavedOpportunitiesResponseSchema, UserSaveOpportunityRequestSchema, UserSaveOpportunityResponseSchema, + UserSaveSearchRequestSchema, + UserSaveSearchResponseSchema, UserTokenLogoutResponseSchema, UserTokenRefreshResponseSchema, ) @@ -22,6 +24,7 @@ from src.auth.auth_utils import with_login_redirect_error_handler from src.auth.login_gov_jwt_auth import get_final_redirect_uri, get_login_gov_redirect_uri from src.db.models.user_models import UserSavedOpportunity, UserTokenSession +from src.services.users.create_saved_search import create_saved_search from src.services.users.delete_saved_opportunity import delete_saved_opportunity from src.services.users.get_saved_opportunities import get_saved_opportunities from src.services.users.get_user import get_user @@ -231,3 +234,34 @@ def user_get_saved_opportunities(db_session: db.Session, user_id: UUID) -> respo saved_opportunities = get_saved_opportunities(db_session, user_id) return response.ApiResponse(message="Success", data=saved_opportunities) + + +@user_blueprint.post("//saved-searches") +@user_blueprint.input(UserSaveSearchRequestSchema, location="json") +@user_blueprint.output(UserSaveSearchResponseSchema) +@user_blueprint.doc(responses=[200, 401]) +@user_blueprint.auth_required(api_jwt_auth) +@flask_db.with_db_session() +def user_save_search( + db_session: db.Session, user_id: UUID, json_data: dict +) -> response.ApiResponse: + logger.info("POST /v1/users/:user_id/saved-searches") + + user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore + + # Verify the authenticated user matches the requested user_id + if user_token_session.user_id != user_id: + raise_flask_error(401, "Unauthorized user") + + with db_session.begin(): + saved_search = create_saved_search(db_session, user_id, json_data) + + logger.info( + "Saved search for user", + extra={ + "user.id": str(user_id), + "saved_search.id": str(saved_search.saved_search_id), + }, + ) + + return response.ApiResponse(message="Success") diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index 651fee7df..08ad32eb2 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -1,4 +1,7 @@ -from src.api.opportunities_v1.opportunity_schemas import SavedOpportunityResponseV1Schema +from src.api.opportunities_v1.opportunity_schemas import ( + OpportunitySearchRequestV1Schema, + SavedOpportunityResponseV1Schema, +) from src.api.schemas.extension import Schema, fields from src.api.schemas.response_schema import AbstractResponseSchema from src.constants.lookup_constants import ExternalUserType @@ -87,3 +90,15 @@ class UserSavedOpportunitiesResponseSchema(AbstractResponseSchema): fields.Nested(SavedOpportunityResponseV1Schema), metadata={"description": "List of saved opportunities"}, ) + + +class UserSaveSearchRequestSchema(Schema): + name = fields.String( + required=True, + metadata={"description": "Name of the saved search", "example": "Example search"}, + ) + search_query = search_query = fields.Nested(OpportunitySearchRequestV1Schema) + + +class UserSaveSearchResponseSchema(AbstractResponseSchema): + data = fields.MixinField(metadata={"example": None}) diff --git a/api/src/services/users/create_saved_search.py b/api/src/services/users/create_saved_search.py new file mode 100644 index 000000000..7a3b02075 --- /dev/null +++ b/api/src/services/users/create_saved_search.py @@ -0,0 +1,17 @@ +import logging +from uuid import UUID + +from src.adapters import db +from src.db.models.user_models import UserSavedSearch + +logger = logging.getLogger(__name__) + + +def create_saved_search(db_session: db.Session, user_id: UUID, json_data: dict) -> UserSavedSearch: + saved_search = UserSavedSearch( + user_id=user_id, name=json_data["name"], search_query=json_data["search_query"] + ) + + db_session.add(saved_search) + + return saved_search diff --git a/api/tests/conftest.py b/api/tests/conftest.py index efc09ebe4..292db8df8 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -19,6 +19,7 @@ import tests.src.db.models.factories as factories from src.adapters import search from src.adapters.oauth.login_gov.mock_login_gov_oauth_client import MockLoginGovOauthClient +from src.auth.api_jwt_auth import create_jwt_for_user from src.constants.schema import Schemas from src.db import models from src.db.models.foreign import metadata as foreign_metadata @@ -32,6 +33,18 @@ logger = logging.getLogger(__name__) +@pytest.fixture +def user(enable_factory_create, db_session): + return factories.UserFactory.create() + + +@pytest.fixture +def user_auth_token(user, db_session): + token, _ = create_jwt_for_user(user, db_session) + db_session.commit() + return token + + @pytest.fixture(scope="session", autouse=True) def env_vars(): """ diff --git a/api/tests/src/api/users/test_user_save_opportunity_post.py b/api/tests/src/api/users/test_user_save_opportunity_post.py index f50082b42..77c3f4dc7 100644 --- a/api/tests/src/api/users/test_user_save_opportunity_post.py +++ b/api/tests/src/api/users/test_user_save_opportunity_post.py @@ -2,22 +2,8 @@ import pytest -from src.auth.api_jwt_auth import create_jwt_for_user from src.db.models.user_models import UserSavedOpportunity -from tests.src.db.models.factories import OpportunityFactory, UserFactory - - -@pytest.fixture -def user(enable_factory_create, db_session): - user = UserFactory.create() - db_session.commit() - return user - - -@pytest.fixture -def user_auth_token(user, db_session): - token, _ = create_jwt_for_user(user, db_session) - return token +from tests.src.db.models.factories import OpportunityFactory @pytest.fixture(autouse=True) diff --git a/api/tests/src/api/users/test_user_save_search_post.py b/api/tests/src/api/users/test_user_save_search_post.py new file mode 100644 index 000000000..c8e0ba2bd --- /dev/null +++ b/api/tests/src/api/users/test_user_save_search_post.py @@ -0,0 +1,105 @@ +import pytest + +from src.constants.lookup_constants import FundingInstrument +from src.db.models.user_models import UserSavedSearch +from tests.src.api.opportunities_v1.conftest import get_search_request +from tests.src.db.models.factories import UserFactory + + +@pytest.fixture(autouse=True, scope="function") +def clear_saved_searches(db_session): + db_session.query(UserSavedSearch).delete() + db_session.commit() + yield + + +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() + + search_query = get_search_request( + funding_instrument_one_of=[FundingInstrument.GRANT], + agency_one_of=["LOC"], + ) + + response = client.post( + f"/v1/users/{different_user.user_id}/saved-searches", + headers={"X-SGG-Token": user_auth_token}, + json={"name": "Test Search", "search_query": search_query}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Unauthorized user" + + # Verify no search was saved + saved_searches = db_session.query(UserSavedSearch).all() + assert len(saved_searches) == 0 + + +def test_user_save_search_post_no_auth(client, db_session, user): + search_query = get_search_request( + funding_instrument_one_of=[FundingInstrument.GRANT], + agency_one_of=["LOC"], + ) + + # Try to save a search without authentication + response = client.post( + f"/v1/users/{user.user_id}/saved-searches", + json={"name": "Test Search", "search_query": search_query}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Unable to process token" + + # Verify no search was saved + saved_searches = db_session.query(UserSavedSearch).all() + assert len(saved_searches) == 0 + + +def test_user_save_search_post_invalid_request(client, user, user_auth_token, db_session): + # Make request with missing required fields + response = client.post( + f"/v1/users/{user.user_id}/saved-searches", + headers={"X-SGG-Token": user_auth_token}, + json={}, + ) + + assert response.status_code == 422 # Validation error + + # Verify no search was saved + saved_searches = db_session.query(UserSavedSearch).all() + assert len(saved_searches) == 0 + + +def test_user_save_search_post(client, user, user_auth_token, enable_factory_create, db_session): + # Test data + search_name = "Test Search" + search_query = get_search_request( + funding_instrument_one_of=[FundingInstrument.GRANT], + agency_one_of=["LOC"], + ) + + # Make the request to save a search + response = client.post( + f"/v1/users/{user.user_id}/saved-searches", + headers={"X-SGG-Token": user_auth_token}, + json={"name": search_name, "search_query": search_query}, + ) + + 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"]}}, + "pagination": { + "order_by": "opportunity_id", + "page_size": 25, + "page_offset": 1, + "sort_direction": "ascending", + }, + }