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 #3448] Create API endpoint for POST /users/:userID/save-searches #3472

Open
wants to merge 18 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions api/src/api/users/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@
UserSavedOpportunitiesResponseSchema,
UserSaveOpportunityRequestSchema,
UserSaveOpportunityResponseSchema,
UserSaveSearchRequestSchema,
UserSaveSearchResponseSchema,
UserTokenLogoutResponseSchema,
UserTokenRefreshResponseSchema,
)
from src.auth.api_jwt_auth import api_jwt_auth, refresh_token_expiration
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
Expand Down Expand Up @@ -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("/<uuid:user_id>/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():
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")
17 changes: 16 additions & 1 deletion api/src/api/users/user_schemas.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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})
18 changes: 18 additions & 0 deletions api/src/services/users/create_saved_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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"]
)

with db_session.begin():
db_session.add(saved_search)

return saved_search
13 changes: 13 additions & 0 deletions api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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():
"""
Expand Down
16 changes: 1 addition & 15 deletions api/tests/src/api/users/test_user_save_opportunity_post.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions api/tests/src/api/users/test_user_save_search_post.py
Original file line number Diff line number Diff line change
@@ -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",
},
}