Skip to content

Commit

Permalink
[Issue #3447] Create API endpoint for GET /users/:userID/save-searches (
Browse files Browse the repository at this point in the history
#3521)

## Summary
Fixes #3447

### Time to review: 15 mins

## Changes proposed
Create GET endpoint to return saved searched, accounting for enum values
Add unit tests

## Context for reviewers
This is the final API required for saving user searches in this
iteration.

## Additional information
See unit tests
  • Loading branch information
mikehgrantsgov authored Jan 15, 2025
1 parent e7b7db6 commit 476f3c8
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 1 deletion.
74 changes: 74 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 22 additions & 1 deletion api/src/api/users/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
UserDeleteSavedSearchResponseSchema,
UserGetResponseSchema,
UserSavedOpportunitiesResponseSchema,
UserSavedSearchesResponseSchema,
UserSaveOpportunityRequestSchema,
UserSaveOpportunityResponseSchema,
UserSaveSearchRequestSchema,
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -297,3 +299,22 @@ def user_delete_saved_search(
)

return response.ApiResponse(message="Success")


@user_blueprint.get("/<uuid:user_id>/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)
28 changes: 28 additions & 0 deletions api/src/api/users/user_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
21 changes: 21 additions & 0 deletions api/src/services/users/get_saved_searches.py
Original file line number Diff line number Diff line change
@@ -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)
108 changes: 108 additions & 0 deletions api/tests/src/api/users/test_user_get_saved_searches.py
Original file line number Diff line number Diff line change
@@ -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"])
2 changes: 2 additions & 0 deletions api/tests/src/db/models/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -2001,3 +2001,5 @@ class Meta:
saved_search_id = Generators.UuidObj

name = factory.Faker("sentence")

search_query = factory.LazyAttribute(lambda s: s.search_query)

0 comments on commit 476f3c8

Please sign in to comment.