From 0c543cc4293c54aaa142e5cf81362b761c98704d Mon Sep 17 00:00:00 2001 From: Michael Huneke Date: Mon, 23 Dec 2024 09:03:26 -0500 Subject: [PATCH 01/13] Create GET endpoint --- .../opportunities_v1/opportunity_schemas.py | 26 +++++++++ api/src/api/users/user_routes.py | 53 +++++++++++++++++++ api/src/api/users/user_schemas.py | 8 +++ .../services/users/get_saved_opportunities.py | 20 +++++++ .../test_user_saved_opportunities_get.py | 47 ++++++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 api/src/services/users/get_saved_opportunities.py create mode 100644 api/tests/src/api/users/test_user_saved_opportunities_get.py diff --git a/api/src/api/opportunities_v1/opportunity_schemas.py b/api/src/api/opportunities_v1/opportunity_schemas.py index f1cfac930..3064a0c0a 100644 --- a/api/src/api/opportunities_v1/opportunity_schemas.py +++ b/api/src/api/opportunities_v1/opportunity_schemas.py @@ -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"} + ) + + summary = fields.Nested(SavedOpportunitySummaryV1Schema()) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index b180a7ef9..43260c95c 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -6,11 +6,16 @@ from src.adapters import db from src.adapters.db import flask_db from src.api import response +from src.api.opportunities_v1.opportunity_schemas import ( + SavedOpportunityResponseV1Schema, + SavedOpportunitySummaryV1Schema, +) from src.api.route_utils import raise_flask_error from src.api.users import user_schemas from src.api.users.user_blueprint import user_blueprint from src.api.users.user_schemas import ( UserGetResponseSchema, + UserSavedOpportunitiesResponseSchema, UserSaveOpportunityRequestSchema, UserSaveOpportunityResponseSchema, UserTokenLogoutResponseSchema, @@ -20,6 +25,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, @@ -184,3 +190,50 @@ def user_save_opportunity( ) return response.ApiResponse(message="Success") + + +@user_blueprint.get("//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 + + # 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} diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py index 5e9a9ce59..73fe5751a 100644 --- a/api/src/api/users/user_schemas.py +++ b/api/src/api/users/user_schemas.py @@ -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 @@ -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"}, + ) diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py new file mode 100644 index 000000000..51e4ce6ac --- /dev/null +++ b/api/src/services/users/get_saved_opportunities.py @@ -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]: + logger.info(f"Getting saved opportunities for user {user_id}") + + return ( + db_session.query(UserSavedOpportunity) + .join(Opportunity) + .filter(UserSavedOpportunity.user_id == user_id) + .all() + ) diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py new file mode 100644 index 000000000..9c4411954 --- /dev/null +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -0,0 +1,47 @@ +import uuid + +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 + + +@pytest.fixture(autouse=True) +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() + saved = UserSavedOpportunity(user_id=user.user_id, opportunity_id=opportunity.opportunity_id) + db_session.add(saved) + db_session.commit() + + # 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 From 901e0baa93ef4eb7a45d65dc2b70984d63335c0c Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Mon, 23 Dec 2024 20:14:17 +0000 Subject: [PATCH 02/13] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 50e6b7f47..eee818db2 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -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 @@ -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: From 833360773272be4f0fb2e4ba9f5bc7cd2dabb263 Mon Sep 17 00:00:00 2001 From: Michael Huneke Date: Tue, 24 Dec 2024 08:58:24 -0500 Subject: [PATCH 03/13] Fix lint --- api/src/api/users/user_routes.py | 4 ---- api/tests/src/api/users/test_user_saved_opportunities_get.py | 2 -- 2 files changed, 6 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 43260c95c..4a719cd74 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -6,10 +6,6 @@ from src.adapters import db from src.adapters.db import flask_db from src.api import response -from src.api.opportunities_v1.opportunity_schemas import ( - SavedOpportunityResponseV1Schema, - SavedOpportunitySummaryV1Schema, -) from src.api.route_utils import raise_flask_error from src.api.users import user_schemas from src.api.users.user_blueprint import user_blueprint diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py index 9c4411954..ef578ea15 100644 --- a/api/tests/src/api/users/test_user_saved_opportunities_get.py +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -1,5 +1,3 @@ -import uuid - import pytest from src.auth.api_jwt_auth import create_jwt_for_user From 70b802e99d53b057ae226b9e1385c8eb2b241e57 Mon Sep 17 00:00:00 2001 From: Michael Huneke Date: Fri, 27 Dec 2024 10:13:07 -0500 Subject: [PATCH 04/13] Add test --- .../test_user_saved_opportunities_get.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py index ef578ea15..4a9c1da91 100644 --- a/api/tests/src/api/users/test_user_saved_opportunities_get.py +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -43,3 +43,36 @@ def test_user_get_saved_opportunities( 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" From 9179efc3d06037b4e6882907ea4d014532061676 Mon Sep 17 00:00:00 2001 From: Mike H Date: Thu, 2 Jan 2025 10:03:57 -0500 Subject: [PATCH 05/13] Update api/src/api/opportunities_v1/opportunity_schemas.py Co-authored-by: Michael Chouinard <46358556+chouinar@users.noreply.github.com> --- api/src/api/opportunities_v1/opportunity_schemas.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/src/api/opportunities_v1/opportunity_schemas.py b/api/src/api/opportunities_v1/opportunity_schemas.py index 3064a0c0a..7945c5ad5 100644 --- a/api/src/api/opportunities_v1/opportunity_schemas.py +++ b/api/src/api/opportunities_v1/opportunity_schemas.py @@ -549,8 +549,12 @@ class SavedOpportunityResponseV1Schema(AbstractResponseSchema): 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"} + opportunity_status = fields.Enum( + OpportunityStatus, + metadata={ + "description": "The current status of the opportunity", + "example": OpportunityStatus.POSTED, + }, ) summary = fields.Nested(SavedOpportunitySummaryV1Schema()) From 898ad5b38412f454b2062007bec82bcaa5bc9c9d Mon Sep 17 00:00:00 2001 From: Mike H Date: Thu, 2 Jan 2025 10:04:03 -0500 Subject: [PATCH 06/13] Update api/src/services/users/get_saved_opportunities.py Co-authored-by: Michael Chouinard <46358556+chouinar@users.noreply.github.com> --- api/src/services/users/get_saved_opportunities.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 51e4ce6ac..390d82952 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -1,5 +1,4 @@ import logging -from typing import List from uuid import UUID from src.adapters import db @@ -9,7 +8,7 @@ logger = logging.getLogger(__name__) -def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> List[UserSavedOpportunity]: +def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> list[UserSavedOpportunity]: logger.info(f"Getting saved opportunities for user {user_id}") return ( From 2b241e1db3668184dc0da0c4fb9b5a857546aa3c Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Thu, 2 Jan 2025 15:06:17 +0000 Subject: [PATCH 07/13] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index eee818db2..60b149dcf 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -1981,9 +1981,16 @@ components: description: The title of the opportunity example: my title opportunity_status: - type: string - description: Current status of the opportunity - example: posted + 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 From 3b639681d2c9a43c0b8f0f0b18899a682953c6bf Mon Sep 17 00:00:00 2001 From: Michael Huneke Date: Thu, 2 Jan 2025 10:06:21 -0500 Subject: [PATCH 08/13] Update to new select api --- api/src/services/users/get_saved_opportunities.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 390d82952..0b630e0af 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -1,6 +1,8 @@ import logging from uuid import UUID +from sqlalchemy import select + from src.adapters import db from src.db.models.opportunity_models import Opportunity from src.db.models.user_models import UserSavedOpportunity @@ -11,9 +13,11 @@ def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> list[UserSavedOpportunity]: logger.info(f"Getting saved opportunities for user {user_id}") - return ( - db_session.query(UserSavedOpportunity) + stmt = ( + select(UserSavedOpportunity) .join(Opportunity) - .filter(UserSavedOpportunity.user_id == user_id) - .all() + .where(UserSavedOpportunity.user_id == user_id) ) + + result = db_session.execute(stmt) + return list(result.scalars().all()) From 88a81e61c1998354ecd4f9512948c31314552d8e Mon Sep 17 00:00:00 2001 From: Michael Huneke Date: Thu, 2 Jan 2025 10:07:18 -0500 Subject: [PATCH 09/13] Update to api response return type --- api/src/api/users/user_routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 4a719cd74..a6569c770 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -193,7 +193,7 @@ def user_save_opportunity( @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: +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 From d4c1dea2f38242472c5b97c2b3877b0878530cd9 Mon Sep 17 00:00:00 2001 From: Michael Huneke Date: Thu, 2 Jan 2025 10:25:40 -0500 Subject: [PATCH 10/13] Scope the fixture to functions in this file / update factory --- api/src/api/users/user_routes.py | 2 +- .../test_user_saved_opportunities_get.py | 20 +++++++++---------- api/tests/src/db/models/factories.py | 11 ++++++++++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index a6569c770..7ca6a5d09 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -232,4 +232,4 @@ def user_get_saved_opportunities(db_session: db.Session, user_id: UUID) -> respo } ) - return {"message": "Success", "data": opportunities_data} + return response.ApiResponse(message="Success", data=opportunities_data) diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py index 4a9c1da91..7d00bf208 100644 --- a/api/tests/src/api/users/test_user_saved_opportunities_get.py +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -2,7 +2,11 @@ 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 +from tests.src.db.models.factories import ( + OpportunityFactory, + UserFactory, + UserSavedOpportunityFactory, +) @pytest.fixture @@ -18,7 +22,7 @@ def user_auth_token(user, db_session): return token -@pytest.fixture(autouse=True) +@pytest.fixture(autouse=True, scope="function") def clear_opportunities(db_session): db_session.query(UserSavedOpportunity).delete() db_session.commit() @@ -30,9 +34,7 @@ def test_user_get_saved_opportunities( ): # 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() + UserSavedOpportunityFactory.create(user=user, opportunity=opportunity) # Make the request response = client.get( @@ -51,14 +53,10 @@ def test_get_saved_opportunities_unauthorized_user(client, enable_factory_create user = UserFactory.create() token, _ = create_jwt_for_user(user, db_session) - # Create another user and save some opportunities for them + # Create another user and save an opportunity 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() + UserSavedOpportunityFactory.create(user=other_user, opportunity=opportunity) # Try to get the other user's saved opportunities response = client.get( diff --git a/api/tests/src/db/models/factories.py b/api/tests/src/db/models/factories.py index 9115eb0f6..ba6188000 100644 --- a/api/tests/src/db/models/factories.py +++ b/api/tests/src/db/models/factories.py @@ -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) \ No newline at end of file From 6e852ba996d5c41c7458e41797616092573669ed Mon Sep 17 00:00:00 2001 From: Mike H Date: Thu, 2 Jan 2025 10:27:17 -0500 Subject: [PATCH 11/13] Update api/tests/src/api/users/test_user_saved_opportunities_get.py Co-authored-by: Michael Chouinard <46358556+chouinar@users.noreply.github.com> --- api/tests/src/api/users/test_user_saved_opportunities_get.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py index 7d00bf208..80fba30a4 100644 --- a/api/tests/src/api/users/test_user_saved_opportunities_get.py +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -11,9 +11,7 @@ @pytest.fixture def user(enable_factory_create, db_session): - user = UserFactory.create() - db_session.commit() - return user + return UserFactory.create() @pytest.fixture From c6a73d25f237c04d68b5edef28ea2beeebdff0bb Mon Sep 17 00:00:00 2001 From: Michael Huneke Date: Thu, 2 Jan 2025 10:48:45 -0500 Subject: [PATCH 12/13] Lint / update API to return correct shape of opportunity --- .../opportunities_v1/opportunity_schemas.py | 5 ++-- api/src/api/users/user_routes.py | 29 +------------------ .../services/users/get_saved_opportunities.py | 20 ++++++++----- .../test_user_saved_opportunities_get.py | 2 +- api/tests/src/db/models/factories.py | 2 +- 5 files changed, 18 insertions(+), 40 deletions(-) diff --git a/api/src/api/opportunities_v1/opportunity_schemas.py b/api/src/api/opportunities_v1/opportunity_schemas.py index 7945c5ad5..682d0dd7b 100644 --- a/api/src/api/opportunities_v1/opportunity_schemas.py +++ b/api/src/api/opportunities_v1/opportunity_schemas.py @@ -542,12 +542,13 @@ class SavedOpportunitySummaryV1Schema(Schema): ) -class SavedOpportunityResponseV1Schema(AbstractResponseSchema): +class SavedOpportunityResponseV1Schema(Schema): 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"} + allow_none=True, + metadata={"description": "The title of the opportunity", "example": "my title"}, ) opportunity_status = fields.Enum( OpportunityStatus, diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py index 7ca6a5d09..bc957575e 100644 --- a/api/src/api/users/user_routes.py +++ b/api/src/api/users/user_routes.py @@ -205,31 +205,4 @@ def user_get_saved_opportunities(db_session: db.Session, user_id: UUID) -> respo # 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 - - # 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 response.ApiResponse(message="Success", data=opportunities_data) + return response.ApiResponse(message="Success", data=saved_opportunities) diff --git a/api/src/services/users/get_saved_opportunities.py b/api/src/services/users/get_saved_opportunities.py index 0b630e0af..4cfd32a77 100644 --- a/api/src/services/users/get_saved_opportunities.py +++ b/api/src/services/users/get_saved_opportunities.py @@ -2,6 +2,7 @@ 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 @@ -10,14 +11,17 @@ logger = logging.getLogger(__name__) -def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> list[UserSavedOpportunity]: +def get_saved_opportunities(db_session: db.Session, user_id: UUID) -> list[Opportunity]: logger.info(f"Getting saved opportunities for user {user_id}") - stmt = ( - select(UserSavedOpportunity) - .join(Opportunity) - .where(UserSavedOpportunity.user_id == user_id) + saved_opportunities = ( + db_session.execute( + select(Opportunity) + .join(UserSavedOpportunity) + .where(UserSavedOpportunity.user_id == user_id) + .options(selectinload("*")) + ) + .scalars() + .all() ) - - result = db_session.execute(stmt) - return list(result.scalars().all()) + return list(saved_opportunities) diff --git a/api/tests/src/api/users/test_user_saved_opportunities_get.py b/api/tests/src/api/users/test_user_saved_opportunities_get.py index 7d00bf208..d6ae341a9 100644 --- a/api/tests/src/api/users/test_user_saved_opportunities_get.py +++ b/api/tests/src/api/users/test_user_saved_opportunities_get.py @@ -33,7 +33,7 @@ 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 = OpportunityFactory.create(opportunity_title="Test Opportunity") UserSavedOpportunityFactory.create(user=user, opportunity=opportunity) # Make the request diff --git a/api/tests/src/db/models/factories.py b/api/tests/src/db/models/factories.py index ba6188000..677f32911 100644 --- a/api/tests/src/db/models/factories.py +++ b/api/tests/src/db/models/factories.py @@ -1985,4 +1985,4 @@ class Meta: user_id = factory.LazyAttribute(lambda o: o.user.user_id) opportunity = factory.SubFactory(OpportunityFactory) - opportunity_id = factory.LazyAttribute(lambda o: o.opportunity.opportunity_id) \ No newline at end of file + opportunity_id = factory.LazyAttribute(lambda o: o.opportunity.opportunity_id) From 770b51600d8cc50b2459ee65b143bc5778af75ef Mon Sep 17 00:00:00 2001 From: nava-platform-bot Date: Thu, 2 Jan 2025 15:50:59 +0000 Subject: [PATCH 13/13] Create ERD diagram and Update OpenAPI spec --- api/openapi.generated.yml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 60b149dcf..bf10766db 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -1962,22 +1962,14 @@ components: 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 + type: + - string + - 'null' description: The title of the opportunity example: my title opportunity_status: