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 #1364] Update API schema with modified DB schema fields #1448

Merged
merged 18 commits into from
Mar 14, 2024
Merged
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
75 changes: 50 additions & 25 deletions api/src/api/opportunities_v0_1/opportunity_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,6 @@


class OpportunitySummarySchema(Schema):
opportunity_status = fields.Enum(
OpportunityStatus,
metadata={
"description": "The current status of the opportunity",
"example": OpportunityStatus.POSTED,
},
)

summary_description = fields.String(
metadata={
"description": "The summary of the opportunity",
Expand All @@ -29,10 +21,16 @@ class OpportunitySummarySchema(Schema):
"description": "Whether or not the opportunity has a cost sharing/matching requirement",
}
)
is_forecast = fields.Boolean(
metadata={
"description": "Whether the opportunity is forecasted, that is, the information is only an estimate and not yet official",
"example": False,
}
)

close_date = fields.Date(
metadata={
"description": "The date that the opportunity will close",
"description": "The date that the opportunity will close - only set if is_forecast=False",
}
)
close_date_description = fields.String(
Expand Down Expand Up @@ -92,6 +90,38 @@ class OpportunitySummarySchema(Schema):
}
)

forecasted_post_date = fields.Date(
metadata={
"description": "Forecasted opportunity only. The date the opportunity is expected to be posted, and transition out of being a forecast"
}
)
forecasted_close_date = fields.Date(
metadata={
"description": "Forecasted opportunity only. The date the opportunity is expected to be close once posted."
}
)
forecasted_close_date_description = fields.String(
metadata={
"description": "Forecasted opportunity only. Optional details regarding the forecasted closed date.",
"example": "Proposals will probably be due on this date",
}
)
forecasted_award_date = fields.Date(
metadata={
"description": "Forecasted opportunity only. The date the grantor plans to award the opportunity."
}
)
forecasted_project_start_date = fields.Date(
metadata={
"description": "Forecasted opportunity only. The date the grantor expects the award recipient should start their project"
}
)
fiscal_year = fields.Integer(
metadata={
"description": "Forecasted opportunity only. The fiscal year the project is expected to be funded and launched"
}
)

funding_category_description = fields.String(
metadata={
"description": "Additional information about the funding category",
Expand Down Expand Up @@ -142,6 +172,10 @@ class OpportunitySummarySchema(Schema):
}
)

funding_instruments = fields.List(fields.Enum(FundingInstrument))
funding_categories = fields.List(fields.Enum(FundingCategory))
applicant_types = fields.List(fields.Enum(ApplicantType))


class OpportunityAssistanceListingSchema(Schema):
program_title = fields.String(
Expand Down Expand Up @@ -191,27 +225,18 @@ class OpportunitySchema(Schema):
}
)

revision_number = fields.Integer(
metadata={
"description": "The current revision number of the opportunity, counting starts at 0",
"example": 0,
}
)
modified_comments = fields.String(
metadata={
"description": "Details regarding what modification was last made",
"example": None,
}
)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should these be removed if they're not added back anywhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I removed them from the model to avoid any confusion as the summary object has similar values. While I don't know what we want to do with these long-term, the current system returns the summary values, so I removed these in the top-level opportunity for now.


opportunity_assistance_listings = fields.List(
fields.Nested(OpportunityAssistanceListingSchema())
)
summary = fields.Nested(OpportunitySummarySchema())

funding_instruments = fields.List(fields.Enum(FundingInstrument))
funding_categories = fields.List(fields.Enum(FundingCategory))
applicant_types = fields.List(fields.Enum(ApplicantType))
opportunity_status = fields.Enum(
OpportunityStatus,
metadata={
"description": "The current status of the opportunity",
"example": OpportunityStatus.POSTED,
},
)

created_at = fields.DateTime(dump_only=True)
updated_at = fields.DateTime(dump_only=True)
Expand Down
7 changes: 7 additions & 0 deletions api/src/db/models/opportunity_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ def summary(self) -> "OpportunitySummary | None":

return self.current_opportunity_summary.opportunity_summary

@property
def opportunity_status(self) -> OpportunityStatus | None:
if self.current_opportunity_summary is None:
return None

return self.current_opportunity_summary.opportunity_status


class OpportunitySummary(Base, TimestampMixin):
__tablename__ = "opportunity_summary"
Expand Down
2 changes: 2 additions & 0 deletions api/src/services/opportunities_v0_1/search_opportunities.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ def search_opportunities(
# TODO - when we want to sort by non-opportunity table fields we'll need to change this
.order_by(sort_fn(getattr(Opportunity, search_params.pagination.order_by)))
.where(Opportunity.is_draft.is_(False)) # Only ever return non-drafts
# Filter anything without a current opportunity summary
.where(Opportunity.current_opportunity_summary != None) # noqa: E711
Copy link
Contributor

Choose a reason for hiding this comment

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

This makes it so we only pull opportunity records that have a summary when we make the request?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Yes. Opportunities have to have a current opportunity summary (and thus an opportunity status which is that table) in order to be searchable. This is deliberate to avoid showing opportunities before their summaries, which are really the public notices of funding, are actually live/public.

Note this doesn't mean the opportunity is entirely hidden, if you navigate to the page of an opportunity directly, you would be able to find it.

.options(joinedload("*")) # Automatically load all relationships
)

Expand Down
10 changes: 10 additions & 0 deletions api/tests/lib/seed_local_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ def _build_opportunities(db_session: db.Session) -> None:
factories.OpportunityFactory.create_batch(size=5, is_archived_forecast_summary=True)
factories.OpportunityFactory.create_batch(size=5, no_current_summary=True)

# generate a few opportunities with mostly null values
all_null_opportunities = factories.OpportunityFactory.create_batch(size=5, all_fields_null=True)
for all_null_opportunity in all_null_opportunities:
summary = factories.OpportunitySummaryFactory.create(
all_fields_null=True, opportunity=all_null_opportunity
)
factories.CurrentOpportunitySummaryFactory.create(
opportunity=all_null_opportunity, opportunity_summary=summary
)
Copy link
Contributor

@rylew1 rylew1 Mar 14, 2024

Choose a reason for hiding this comment

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

👍


logger.info("Finished creating opportunities")

logger.info("Creating records in the transfer_topportunity table")
Expand Down
111 changes: 87 additions & 24 deletions api/tests/src/api/opportunities_v0_1/test_opportunity_route.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
OpportunityAssistanceListing,
OpportunitySummary,
)
from tests.src.db.models.factories import OpportunityFactory
from tests.src.db.models.factories import (
CurrentOpportunitySummaryFactory,
OpportunityFactory,
OpportunitySummaryFactory,
)


@pytest.fixture
Expand Down Expand Up @@ -84,20 +88,14 @@ def validate_opportunity(db_opportunity: Opportunity, resp_opportunity: dict):
assert db_opportunity.agency == resp_opportunity["agency"]
assert db_opportunity.category == resp_opportunity["category"]
assert db_opportunity.category_explanation == resp_opportunity["category_explanation"]
assert db_opportunity.revision_number == resp_opportunity["revision_number"]
assert db_opportunity.modified_comments == resp_opportunity["modified_comments"]

validate_opportunity_summary(db_opportunity.summary, resp_opportunity["summary"])
validate_assistance_listings(
db_opportunity.opportunity_assistance_listings,
resp_opportunity["opportunity_assistance_listings"],
)

# TODO - these have been moved in the DB model - will be fixed in https://github.com/HHS/simpler-grants-gov/issues/1364
# assert db_opportunity.opportunity_status == resp_opportunity["opportunity_status"]
# assert set(db_opportunity.funding_instruments) == set(resp_opportunity["funding_instruments"])
# assert set(db_opportunity.funding_categories) == set(resp_opportunity["funding_categories"])
# assert set(db_opportunity.applicant_types) == set(resp_opportunity["applicant_types"])
assert db_opportunity.opportunity_status == resp_opportunity["opportunity_status"]


def validate_opportunity_summary(db_summary: OpportunitySummary, resp_summary: dict):
Expand All @@ -107,10 +105,11 @@ def validate_opportunity_summary(db_summary: OpportunitySummary, resp_summary: d

assert db_summary.summary_description == resp_summary["summary_description"]
assert db_summary.is_cost_sharing == resp_summary["is_cost_sharing"]
assert str(db_summary.close_date) == resp_summary["close_date"]
assert db_summary.is_forecast == resp_summary["is_forecast"]
assert str(db_summary.close_date) == str(resp_summary["close_date"])
assert db_summary.close_date_description == resp_summary["close_date_description"]
assert str(db_summary.post_date) == resp_summary["post_date"]
assert str(db_summary.archive_date) == resp_summary["archive_date"]
assert str(db_summary.post_date) == str(resp_summary["post_date"])
assert str(db_summary.archive_date) == str(resp_summary["archive_date"])
assert db_summary.expected_number_of_awards == resp_summary["expected_number_of_awards"]
assert (
db_summary.estimated_total_program_funding
Expand All @@ -123,6 +122,19 @@ def validate_opportunity_summary(db_summary: OpportunitySummary, resp_summary: d
db_summary.additional_info_url_description
== resp_summary["additional_info_url_description"]
)

assert str(db_summary.forecasted_post_date) == str(resp_summary["forecasted_post_date"])
assert str(db_summary.forecasted_close_date) == str(resp_summary["forecasted_close_date"])
assert (
db_summary.forecasted_close_date_description
== resp_summary["forecasted_close_date_description"]
)
assert str(db_summary.forecasted_award_date) == str(resp_summary["forecasted_award_date"])
assert str(db_summary.forecasted_project_start_date) == str(
resp_summary["forecasted_project_start_date"]
)
assert db_summary.fiscal_year == resp_summary["fiscal_year"]

assert db_summary.funding_category_description == resp_summary["funding_category_description"]
assert (
db_summary.applicant_eligibility_description
Expand All @@ -139,6 +151,10 @@ def validate_opportunity_summary(db_summary: OpportunitySummary, resp_summary: d
== resp_summary["agency_email_address_description"]
)

assert set(db_summary.funding_instruments) == set(resp_summary["funding_instruments"])
assert set(db_summary.funding_categories) == set(resp_summary["funding_categories"])
assert set(db_summary.applicant_types) == set(resp_summary["applicant_types"])


def validate_assistance_listings(
db_assistance_listings: list[OpportunityAssistanceListing], resp_listings: list[dict]
Expand Down Expand Up @@ -219,6 +235,30 @@ def test_opportunity_search_paging_and_sorting_200(
validate_search_pagination(search_response, search_request, expected_values)


def test_opportunity_search_filters_when_no_current_summary(
client, api_auth_token, enable_factory_create, truncate_opportunities
):
"""
Verify only opportunities with a current_opportunity_summary are returned
"""
expected_opportunities = OpportunityFactory.create_batch(size=3)
OpportunityFactory.create_batch(size=2, no_current_summary=True)

resp = client.post(
"/v0.1/opportunities/search", json=get_search_request(), headers={"X-Auth": api_auth_token}
)

search_response = resp.get_json()
assert resp.status_code == 200

# Just verify the 3 we created above are returned specifically
opportunities = search_response["data"]
assert len(opportunities) == 3
assert set([opp["opportunity_id"] for opp in opportunities]) == set(
[opp.opportunity_id for opp in expected_opportunities]
)


@pytest.mark.parametrize(
"search_request,expected_response_data",
[
Expand Down Expand Up @@ -283,22 +323,45 @@ def test_opportunity_search_invalid_request_422(


@pytest.mark.parametrize(
"factory_params",
"opportunity_params,opportunity_summary_params",
[
{},
# Set all the non-opportunity model objects to null/empty
{
"current_opportunity_summary": None,
"opportunity_assistance_listings": [],
# TODO: https://github.com/HHS/simpler-grants-gov/issues/1364
# "link_funding_instruments": [],
# "link_funding_categories": [],
# "link_applicant_types": [],
},
({}, {}),
# Only an opportunity exists, no other connected records
(
{
"opportunity_assistance_listings": [],
},
None,
),
# Summary exists, but none of the list values set
(
{},
{
"link_funding_instruments": [],
"link_funding_categories": [],
"link_applicant_types": [],
},
),
# All possible values set to null/empty
# Note this uses traits on the factories to handle setting everything
({"all_fields_null": True}, {"all_fields_null": True}),
],
)
def test_get_opportunity_200(client, api_auth_token, enable_factory_create, factory_params):
db_opportunity = OpportunityFactory.create(**factory_params)
def test_get_opportunity_200(
client, api_auth_token, enable_factory_create, opportunity_params, opportunity_summary_params
):
# Split the setup of the opportunity from the opportunity summary to simplify the factory usage a bit
db_opportunity = OpportunityFactory.create(
**opportunity_params, current_opportunity_summary=None
) # We'll set the current opportunity below

if opportunity_summary_params is not None:
db_opportunity_summary = OpportunitySummaryFactory.create(
**opportunity_summary_params, opportunity=db_opportunity
)
CurrentOpportunitySummaryFactory.create(
opportunity=db_opportunity, opportunity_summary=db_opportunity_summary
)

resp = client.get(
f"/v0.1/opportunities/{db_opportunity.opportunity_id}", headers={"X-Auth": api_auth_token}
Expand Down
45 changes: 45 additions & 0 deletions api/tests/src/db/models/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ class Params:
current_opportunity_summary__is_archived_forecast_summary=True
)

# Set all nullable fields to null
all_fields_null = factory.Trait(
agency=None,
category=None,
category_explanation=None,
current_opportunity_summary=None,
opportunity_assistance_listings=None,
)


class OpportunitySummaryFactory(BaseFactory):
class Meta:
Expand Down Expand Up @@ -300,6 +309,42 @@ class Params:
post_date=factory.Faker("date_between", start_date="+3w", end_date="+4w"),
)

# Set all nullable fields to null
all_fields_null = factory.Trait(
summary_description=None,
is_cost_sharing=None,
post_date=None,
close_date=None,
close_date_description=None,
archive_date=None,
unarchive_date=None,
expected_number_of_awards=None,
estimated_total_program_funding=None,
award_floor=None,
award_ceiling=None,
additional_info_url=None,
additional_info_url_description=None,
forecasted_post_date=None,
forecasted_close_date=None,
forecasted_close_date_description=None,
forecasted_award_date=None,
forecasted_project_start_date=None,
fiscal_year=None,
modification_comments=None,
funding_category_description=None,
applicant_eligibility_description=None,
agency_code=None,
agency_name=None,
agency_phone_number=None,
agency_contact_description=None,
agency_email_address=None,
agency_email_address_description=None,
is_deleted=None,
link_funding_instruments=[],
link_funding_categories=[],
link_applicant_types=[],
)


class CurrentOpportunitySummaryFactory(BaseFactory):
class Meta:
Expand Down
Binary file modified documentation/api/database/erds/full-schema.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.