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 #3295] Create GET /users/:userId/saved-opportunities API schema and stub endpoint #3355

Merged
merged 16 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from 4 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
97 changes: 97 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-opportunities:
get:
parameters:
- in: path
name: user_id
schema:
type: string
required: true
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UserSavedOpportunitiesResponse'
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 Opportunities
security:
- ApiJwtAuth: []
post:
parameters:
- in: path
Expand Down Expand Up @@ -1911,6 +1942,72 @@ components:
type: integer
description: The HTTP status code
example: 200
SavedOpportunitySummaryV1:
type: object
properties:
post_date:
type: string
format: date
description: The date the opportunity was posted
example: '2024-01-01'
close_date:
type: string
format: date
description: The date the opportunity will close
example: '2024-01-01'
is_forecast:
type: boolean
description: Whether the opportunity is forecasted
example: false
SavedOpportunityResponseV1:
type: object
properties:
message:
type: string
description: The message to return
example: Success
data:
description: The REST resource object
status_code:
type: integer
description: The HTTP status code
example: 200
opportunity_id:
type: integer
description: The ID of the saved opportunity
example: 1234
opportunity_title:
type: string
description: The title of the opportunity
example: my title
opportunity_status:
type: string
description: Current status of the opportunity
example: posted
summary:
type:
- object
allOf:
- $ref: '#/components/schemas/SavedOpportunitySummaryV1'
UserSavedOpportunitiesResponse:
type: object
properties:
message:
type: string
description: The message to return
example: Success
data:
type: array
description: List of saved opportunities
items:
type:
- object
allOf:
- $ref: '#/components/schemas/SavedOpportunityResponseV1'
status_code:
type: integer
description: The HTTP status code
example: 200
UserSaveOpportunityRequest:
type: object
properties:
Expand Down
26 changes: 26 additions & 0 deletions api/src/api/opportunities_v1/opportunity_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,3 +528,29 @@ class OpportunitySearchResponseV1Schema(AbstractResponseSchema, PaginationMixinS
OpportunityFacetV1Schema(),
metadata={"description": "Counts of filter/facet values in the full response"},
)


class SavedOpportunitySummaryV1Schema(Schema):
post_date = fields.Date(
metadata={"description": "The date the opportunity was posted", "example": "2024-01-01"}
)
close_date = fields.Date(
metadata={"description": "The date the opportunity will close", "example": "2024-01-01"}
)
is_forecast = fields.Boolean(
metadata={"description": "Whether the opportunity is forecasted", "example": False}
)


class SavedOpportunityResponseV1Schema(AbstractResponseSchema):
opportunity_id = fields.Integer(
metadata={"description": "The ID of the saved opportunity", "example": 1234}
)
opportunity_title = fields.String(
metadata={"description": "The title of the opportunity", "example": "my title"}
)
opportunity_status = fields.String(
metadata={"description": "Current status of the opportunity", "example": "posted"}
)
mikehgrantsgov marked this conversation as resolved.
Show resolved Hide resolved

summary = fields.Nested(SavedOpportunitySummaryV1Schema())
49 changes: 49 additions & 0 deletions api/src/api/users/user_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from src.api.users.user_blueprint import user_blueprint
from src.api.users.user_schemas import (
UserGetResponseSchema,
UserSavedOpportunitiesResponseSchema,
UserSaveOpportunityRequestSchema,
UserSaveOpportunityResponseSchema,
UserTokenLogoutResponseSchema,
Expand All @@ -20,6 +21,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.get_saved_opportunities import get_saved_opportunities
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 @@ -184,3 +186,50 @@ def user_save_opportunity(
)

return response.ApiResponse(message="Success")


@user_blueprint.get("/<uuid:user_id>/saved-opportunities")
@user_blueprint.output(UserSavedOpportunitiesResponseSchema)
@user_blueprint.doc(responses=[200, 401])
@user_blueprint.auth_required(api_jwt_auth)
@flask_db.with_db_session()
def user_get_saved_opportunities(db_session: db.Session, user_id: UUID) -> dict:
logger.info("GET /v1/users/:user_id/saved-opportunities")

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

# Get all saved opportunities for the user with their related opportunity data
saved_opportunities = get_saved_opportunities(db_session, user_id)

# Format the response data
opportunities_data = []
for saved in saved_opportunities:
opp = saved.opportunity
summary = opp.summary
Copy link
Collaborator

Choose a reason for hiding this comment

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

This loop shouldn't be necessary, it should be fine to return the ORM objects and Marshmallow will handle iterating over/making them into JSON for you. If you're hitting an error about objects being detached from a session, then that's because SQLAlchemy lazy-loads all relationships (this loop effectively emits several SQL queries). Easiest way around that is to load in the query itself. It's why in an endpoint like GET opportunity we do .options(selectinload("*")): https://github.com/HHS/simpler-grants-gov/blob/main/api/src/services/opportunities_v1/get_opportunity.py#L21

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yep that makes sense. I thought we had to do more work here than needed. I did update the utility to return pure Opportunity objects so it will fit in the shape of the response.


# Create the summary dict if summary exists
summary_data = None
if summary:
summary_data = {
"post_date": summary.post_date,
"close_date": summary.close_date,
"is_forecast": summary.is_forecast,
}

# Add the opportunity data
opportunities_data.append(
{
"opportunity_id": opp.opportunity_id,
"opportunity_title": opp.opportunity_title,
"opportunity_status": (
opp.opportunity_status.value if opp.opportunity_status else None
),
"summary": summary_data,
}
)

return {"message": "Success", "data": opportunities_data}
Copy link
Collaborator

Choose a reason for hiding this comment

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

We shouldn't be returning a dict, return an ApiResponse object

8 changes: 8 additions & 0 deletions api/src/api/users/user_schemas.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from src.api.opportunities_v1.opportunity_schemas import 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 @@ -75,3 +76,10 @@ class UserSaveOpportunityRequestSchema(Schema):

class UserSaveOpportunityResponseSchema(AbstractResponseSchema):
data = fields.MixinField(metadata={"example": None})


class UserSavedOpportunitiesResponseSchema(AbstractResponseSchema):
data = fields.List(
fields.Nested(SavedOpportunityResponseV1Schema),
metadata={"description": "List of saved opportunities"},
)
20 changes: 20 additions & 0 deletions api/src/services/users/get_saved_opportunities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import logging
from typing import List
from uuid import UUID

from src.adapters import db
from src.db.models.opportunity_models import Opportunity
from src.db.models.user_models import UserSavedOpportunity

logger = logging.getLogger(__name__)


def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> List[UserSavedOpportunity]:
mikehgrantsgov marked this conversation as resolved.
Show resolved Hide resolved
logger.info(f"Getting saved opportunities for user {user_id}")

return (
db_session.query(UserSavedOpportunity)
.join(Opportunity)
.filter(UserSavedOpportunity.user_id == user_id)
.all()
)
mikehgrantsgov marked this conversation as resolved.
Show resolved Hide resolved
78 changes: 78 additions & 0 deletions api/tests/src/api/users/test_user_saved_opportunities_get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
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
mikehgrantsgov marked this conversation as resolved.
Show resolved Hide resolved


@pytest.fixture
def user_auth_token(user, db_session):
token, _ = create_jwt_for_user(user, db_session)
return token


@pytest.fixture(autouse=True)
def clear_opportunities(db_session):
db_session.query(UserSavedOpportunity).delete()
db_session.commit()
yield
mikehgrantsgov marked this conversation as resolved.
Show resolved Hide resolved


def test_user_get_saved_opportunities(
client, user, user_auth_token, enable_factory_create, db_session
):
# Create an opportunity and save it for the user
opportunity = OpportunityFactory.create()
saved = UserSavedOpportunity(user_id=user.user_id, opportunity_id=opportunity.opportunity_id)
db_session.add(saved)
db_session.commit()
mikehgrantsgov marked this conversation as resolved.
Show resolved Hide resolved

# Make the request
response = client.get(
f"/v1/users/{user.user_id}/saved-opportunities", headers={"X-SGG-Token": user_auth_token}
)

assert response.status_code == 200
assert len(response.json["data"]) == 1
assert response.json["data"][0]["opportunity_id"] == opportunity.opportunity_id
assert response.json["data"][0]["opportunity_title"] == opportunity.opportunity_title


def test_get_saved_opportunities_unauthorized_user(client, enable_factory_create, db_session, user):
"""Test that a user cannot view another user's saved opportunities"""
# Create a user and get their token
user = UserFactory.create()
token, _ = create_jwt_for_user(user, db_session)

# Create another user and save some opportunities for them
other_user = UserFactory.create()
opportunity = OpportunityFactory.create()
saved = UserSavedOpportunity(
user_id=other_user.user_id, opportunity_id=opportunity.opportunity_id
)
db_session.add(saved)
db_session.commit()

# Try to get the other user's saved opportunities
response = client.get(
f"/v1/users/{other_user.user_id}/saved-opportunities", headers={"X-SGG-Token": token}
)

assert response.status_code == 401
assert response.json["message"] == "Unauthorized user"

# Try with a non-existent user ID
different_user_id = "123e4567-e89b-12d3-a456-426614174000"
response = client.get(
f"/v1/users/{different_user_id}/saved-opportunities", headers={"X-SGG-Token": token}
)

assert response.status_code == 401
assert response.json["message"] == "Unauthorized user"