Skip to content

Commit

Permalink
Merge branch 'main' into mikehgrantsgov/3296-add-delete-opp-endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
mikehgrantsgov committed Jan 3, 2025
2 parents 07c4d32 + 1afd3d4 commit 0462892
Show file tree
Hide file tree
Showing 41 changed files with 1,693 additions and 903 deletions.
17 changes: 10 additions & 7 deletions .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,7 @@
"dependencies"
],
"branchPrefix": "renovate/",
"reviewers": [
"sammysteiner",
"acouch",
"jamesbursa",
"coilysiren",
"aplybeah"
],
"reviewersFromCodeOwners": true,
"rangeStrategy": "update-lockfile",
"timezone": "America/New_York",
"vulnerabilityAlerts": {
Expand Down Expand Up @@ -136,6 +130,15 @@
"analytics/poetry.lock"
],
"matchPackagePatterns": ["kaleido"]
},
{
"description": "Don't upgrade Python itself as annual releases reflect as minor version bumps",
"enabled": false,
"matchFileNames": [
"analytics/pyproject.toml",
"api/pyproject.toml"
],
"matchPackagePatterns": ["python"]
}
]
}
442 changes: 224 additions & 218 deletions analytics/poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions analytics/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ db-migrate = "analytics.cli:migrate_database"

[tool.poetry.dependencies]
dynaconf = "^3.2.4"
jinja2 = ">=3.1.5"
kaleido = "0.2.1"
notebook = "^7.0.0" # Goal is to replace this with another method of presenting charts
pandas = "^2.0.3"
Expand Down
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 @@ -1948,6 +1979,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
647 changes: 326 additions & 321 deletions api/poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ pydantic-settings = "^2.0.3"
flask-cors = "^5.0.0"
opensearch-py = "^2.5.0"
pyjwt = "^2.9.0"
newrelic = "10.3.1"
newrelic = "10.4.0"
jinja2 = ">=3.1.5"

[tool.poetry.group.dev.dependencies]
black = "^24.0.0"
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 @@ -12,6 +12,7 @@
from src.api.users.user_schemas import (
UserDeleteSavedOpportunityResponseSchema,
UserGetResponseSchema,
UserSavedOpportunitiesResponseSchema,
UserSaveOpportunityRequestSchema,
UserSaveOpportunityResponseSchema,
UserTokenLogoutResponseSchema,
Expand All @@ -22,6 +23,7 @@
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.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
from src.services.users.login_gov_callback_handler import (
handle_login_gov_callback_request,
Expand Down Expand Up @@ -209,3 +211,23 @@ def user_delete_saved_opportunity(
delete_saved_opportunity(db_session, user_id, opportunity_id)

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 @@ -79,3 +80,10 @@ class UserSaveOpportunityResponseSchema(AbstractResponseSchema):

class UserDeleteSavedOpportunityResponseSchema(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"
Loading

0 comments on commit 0462892

Please sign in to comment.