Skip to content

Commit

Permalink
[Issue #3295] Create GET /users/:userId/saved-opportunities API schem…
Browse files Browse the repository at this point in the history
…a and stub endpoint (#3355)

## Summary
Fixes #3295

### Time to review: 25 mins

## Changes proposed
Create GET /users/:userId/saved-opportunities API schema

## Context for reviewers
Overlaps with the implementation ticket, as it was easier to add the
service and implementation vs. stub + implement in 2 different PRs

## Additional information
See attached unit tests.
  • Loading branch information
mikehgrantsgov authored Jan 3, 2025
1 parent 8929058 commit 57aae5c
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 0 deletions.
96 changes: 96 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,71 @@ 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:
opportunity_id:
type: integer
description: The ID of the saved opportunity
example: 1234
opportunity_title:
type:
- string
- 'null'
description: The title of the opportunity
example: my title
opportunity_status:
description: The current status of the opportunity
example: !!python/object/apply:src.constants.lookup_constants.OpportunityStatus
- posted
enum:
- forecasted
- posted
- closed
- archived
type:
- string
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
31 changes: 31 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,34 @@ 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(Schema):
opportunity_id = fields.Integer(
metadata={"description": "The ID of the saved opportunity", "example": 1234}
)
opportunity_title = fields.String(
allow_none=True,
metadata={"description": "The title of the opportunity", "example": "my title"},
)
opportunity_status = fields.Enum(
OpportunityStatus,
metadata={
"description": "The current status of the opportunity",
"example": OpportunityStatus.POSTED,
},
)

summary = fields.Nested(SavedOpportunitySummaryV1Schema())
22 changes: 22 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,23 @@ 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) -> response.ApiResponse:
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)

return response.ApiResponse(message="Success", data=saved_opportunities)
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"},
)
27 changes: 27 additions & 0 deletions api/src/services/users/get_saved_opportunities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import logging
from uuid import UUID

from sqlalchemy import select
from sqlalchemy.orm import selectinload

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[Opportunity]:
logger.info(f"Getting saved opportunities for user {user_id}")

saved_opportunities = (
db_session.execute(
select(Opportunity)
.join(UserSavedOpportunity)
.where(UserSavedOpportunity.user_id == user_id)
.options(selectinload("*"))
)
.scalars()
.all()
)
return list(saved_opportunities)
74 changes: 74 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,74 @@
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,
UserSavedOpportunityFactory,
)


@pytest.fixture
def user(enable_factory_create, db_session):
return UserFactory.create()


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


@pytest.fixture(autouse=True, scope="function")
def clear_opportunities(db_session):
db_session.query(UserSavedOpportunity).delete()
db_session.commit()
yield


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(opportunity_title="Test Opportunity")
UserSavedOpportunityFactory.create(user=user, opportunity=opportunity)

# 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 an opportunity for them
other_user = UserFactory.create()
opportunity = OpportunityFactory.create()
UserSavedOpportunityFactory.create(user=other_user, opportunity=opportunity)

# 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"
11 changes: 11 additions & 0 deletions api/tests/src/db/models/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -1975,3 +1975,14 @@ class Meta:
expires_at = factory.Faker("date_time_between", start_date="+1d", end_date="+10d")

is_valid = True


class UserSavedOpportunityFactory(BaseFactory):
class Meta:
model = user_models.UserSavedOpportunity

user = factory.SubFactory(UserFactory)
user_id = factory.LazyAttribute(lambda o: o.user.user_id)

opportunity = factory.SubFactory(OpportunityFactory)
opportunity_id = factory.LazyAttribute(lambda o: o.opportunity.opportunity_id)

0 comments on commit 57aae5c

Please sign in to comment.