diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 477cfde33..f8ba0f216 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -432,6 +432,37 @@ paths: security: - ApiKeyAuth: [] /v1/users/{user_id}/saved-searches: + get: + parameters: + - in: path + name: user_id + schema: + type: string + required: true + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserSavedSearchesResponse' + description: Successful response + '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 Get Saved Searches + security: + - ApiJwtAuth: [] post: parameters: - in: path @@ -2053,6 +2084,49 @@ components: type: integer description: The HTTP status code example: 200 + SavedSearchResponse: + type: object + properties: + saved_search_id: + type: string + format: uuid + description: The ID of the saved search + example: !!python/object:uuid.UUID + int: 82637552140693101888240202082641616217 + name: + type: string + description: Name of the saved search + example: Grant opportunities in California + search_query: + description: The saved search query parameters + type: + - object + allOf: + - $ref: '#/components/schemas/OpportunitySearchRequestV1' + created_at: + type: string + format: date-time + description: When the search was saved + example: '2024-01-01T00:00:00Z' + UserSavedSearchesResponse: + type: object + properties: + message: + type: string + description: The message to return + example: Success + data: + type: array + description: List of saved searches + items: + type: + - object + allOf: + - $ref: '#/components/schemas/SavedSearchResponse' + status_code: + type: integer + description: The HTTP status code + example: 200 UserSaveSearchRequest: type: object properties: diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index a2aa52ff9..172624c96 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -14,6 +14,7 @@ UserDeleteSavedSearchResponseSchema, UserGetResponseSchema, UserSavedOpportunitiesResponseSchema, + UserSavedSearchesResponseSchema, UserSaveOpportunityRequestSchema, UserSaveOpportunityResponseSchema, UserSaveSearchRequestSchema, @@ -29,6 +30,7 @@ from src.services.users.delete_saved_opportunity import delete_saved_opportunity from src.services.users.delete_saved_search import delete_saved_search from src.services.users.get_saved_opportunities import get_saved_opportunities +from src.services.users.get_saved_searches import get_saved_searches from src.services.users.get_user import get_user from src.services.users.login_gov_callback_handler import ( handle_login_gov_callback_request, @@ -279,7 +281,7 @@ def user_delete_saved_search( ) -> response.ApiResponse: logger.info("DELETE /v1/users/:user_id/saved-searches/:saved_search_id") - user_token_session: UserTokenSession = api_jwt_auth.current_user # type: ignore + user_token_session: UserTokenSession = api_jwt_auth.get_user_token_session() # Verify the authenticated user matches the requested user_id if user_token_session.user_id != user_id: @@ -297,3 +299,22 @@ def user_delete_saved_search( ) return response.ApiResponse(message="Success") + + +@user_blueprint.get("//saved-searches") +@user_blueprint.output(UserSavedSearchesResponseSchema) +@user_blueprint.doc(responses=[200, 401]) +@user_blueprint.auth_required(api_jwt_auth) +@flask_db.with_db_session() +def user_get_saved_searches(db_session: db.Session, user_id: UUID) -> response.ApiResponse: + logger.info("GET /v1/users/:user_id/saved-searches") + + user_token_session: UserTokenSession = api_jwt_auth.get_user_token_session() + + # Verify the authenticated user matches the requested user_id + if user_token_session.user_id != user_id: + raise_flask_error(401, "Unauthorized user") + + saved_searches = get_saved_searches(db_session, user_id) + + return response.ApiResponse(message="Success", data=saved_searches) diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index 1dde072bd..69416b91c 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -104,5 +104,33 @@ class UserSaveSearchResponseSchema(AbstractResponseSchema): data = fields.MixinField(metadata={"example": None}) +class SavedSearchResponseSchema(Schema): + saved_search_id = fields.UUID( + metadata={ + "description": "The ID of the saved search", + "example": "123e4567-e89b-12d3-a456-426614174000", + } + ) + name = fields.String( + metadata={ + "description": "Name of the saved search", + "example": "Grant opportunities in California", + } + ) + search_query = fields.Nested( + OpportunitySearchRequestV1Schema, + metadata={"description": "The saved search query parameters"}, + ) + created_at = fields.DateTime( + metadata={"description": "When the search was saved", "example": "2024-01-01T00:00:00Z"} + ) + + +class UserSavedSearchesResponseSchema(AbstractResponseSchema): + data = fields.List( + fields.Nested(SavedSearchResponseSchema), metadata={"description": "List of saved searches"} + ) + + class UserDeleteSavedSearchResponseSchema(AbstractResponseSchema): data = fields.MixinField(metadata={"example": None}) diff --git a/api/src/services/users/get_saved_searches.py b/api/src/services/users/get_saved_searches.py new file mode 100644 index 000000000..6a8c526c9 --- /dev/null +++ b/api/src/services/users/get_saved_searches.py @@ -0,0 +1,21 @@ +from uuid import UUID + +from sqlalchemy import select + +from src.adapters import db +from src.db.models.user_models import UserSavedSearch + + +def get_saved_searches(db_session: db.Session, user_id: UUID) -> list[UserSavedSearch]: + """Get all saved searches for a user""" + saved_searches = ( + db_session.execute( + select(UserSavedSearch) + .where(UserSavedSearch.user_id == user_id) + .order_by(UserSavedSearch.created_at.desc()) + ) + .scalars() + .all() + ) + + return list(saved_searches) diff --git a/api/tests/src/api/users/test_user_get_saved_searches.py b/api/tests/src/api/users/test_user_get_saved_searches.py new file mode 100644 index 000000000..9e1a20c1c --- /dev/null +++ b/api/tests/src/api/users/test_user_get_saved_searches.py @@ -0,0 +1,108 @@ +import uuid +from datetime import datetime, timezone + +import pytest +from sqlalchemy import delete + +from src.constants.lookup_constants import FundingInstrument +from src.db.models.user_models import UserSavedSearch, UserTokenSession +from tests.src.db.models.factories import UserFactory, UserSavedSearchFactory + + +@pytest.fixture +def saved_searches(user, db_session): + searches = [ + UserSavedSearchFactory.create( + user=user, + name="Test Search 1", + search_query={ + "query": "python", + "filters": {"funding_instrument": {"one_of": [FundingInstrument.GRANT]}}, + }, + created_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + UserSavedSearchFactory.create( + user=user, + name="Test Search 2", + search_query={ + "query": "python", + "filters": { + "keywords": "java", + "funding_instrument": {"one_of": [FundingInstrument.COOPERATIVE_AGREEMENT]}, + }, + }, + created_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + ] + db_session.commit() + return searches + + +@pytest.fixture(autouse=True) +def clear_data(db_session): + db_session.execute(delete(UserSavedSearch)) + db_session.execute(delete(UserTokenSession)) + db_session.commit() + yield + + +def test_user_get_saved_searches_unauthorized_user( + client, db_session, user, user_auth_token, saved_searches +): + # Try to get searches for a different user ID + different_user = UserFactory.create() + db_session.commit() + + response = client.get( + f"/v1/users/{different_user.user_id}/saved-searches", + headers={"X-SGG-Token": user_auth_token}, + ) + + assert response.status_code == 401 + assert response.json["message"] == "Unauthorized user" + + +def test_user_get_saved_searches_no_auth(client, db_session, user, saved_searches): + # Try to get searches without authentication + response = client.get( + f"/v1/users/{user.user_id}/saved-searches", + ) + + assert response.status_code == 401 + assert response.json["message"] == "Unable to process token" + + +def test_user_get_saved_searches_empty(client, user, user_auth_token): + response = client.get( + f"/v1/users/{user.user_id}/saved-searches", + headers={"X-SGG-Token": user_auth_token}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Success" + assert response.json["data"] == [] + + +def test_user_get_saved_searches(client, user, user_auth_token, saved_searches): + response = client.get( + f"/v1/users/{user.user_id}/saved-searches", + headers={"X-SGG-Token": user_auth_token}, + ) + + assert response.status_code == 200 + assert response.json["message"] == "Success" + + data = response.json["data"] + assert len(data) == 2 + + # Verify the searches are returned in descending order by created_at + assert data[0]["name"] == "Test Search 2" + assert data[0]["search_query"]["filters"]["funding_instrument"]["one_of"] == [ + "cooperative_agreement" + ] + assert data[1]["name"] == "Test Search 1" + assert data[1]["search_query"]["filters"]["funding_instrument"]["one_of"] == ["grant"] + + # Verify UUIDs are properly serialized + assert uuid.UUID(data[0]["saved_search_id"]) + assert uuid.UUID(data[1]["saved_search_id"]) diff --git a/api/tests/src/db/models/factories.py b/api/tests/src/db/models/factories.py index e82823100..a64476dea 100644 --- a/api/tests/src/db/models/factories.py +++ b/api/tests/src/db/models/factories.py @@ -2001,3 +2001,5 @@ class Meta: saved_search_id = Generators.UuidObj name = factory.Faker("sentence") + + search_query = factory.LazyAttribute(lambda s: s.search_query)