Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Issue #3447] Create API endpoint for GET /users/:userID/save-searches #3521

Merged
Merged
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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should make this a POST endpoint - we might need to add some filters/sorting stuff (design dependent) and that would follow the same search request schema approach as our actual search endpoint.

Probably not that big a deal to switch later, but worth keeping in mind

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. This will be an easy switch when the requirements arrive.

@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)