Skip to content
This repository has been archived by the owner on Sep 18, 2024. It is now read-only.

Commit

Permalink
Merge branch 'main' into chouinar/16-actual-impl
Browse files Browse the repository at this point in the history
  • Loading branch information
chouinar committed Jun 25, 2024
2 parents 2edec64 + 94be4d0 commit 7dfe55b
Show file tree
Hide file tree
Showing 14 changed files with 1,118 additions and 420 deletions.
84 changes: 84 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,54 @@ paths:
change. Not for production use.
See [Release Phases](https://github.com/github/roadmap?tab=readme-ov-file#release-phases)
for further details.
'
security:
- ApiKeyAuth: []
/v1/opportunities/{opportunity_id}/versions:
get:
parameters:
- in: path
name: opportunity_id
schema:
type: integer
required: true
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/OpportunityVersionsGetResponseV1'
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:
- Opportunity v1
summary: Opportunity Versions Get
description: '
__ALPHA VERSION__
This endpoint in its current form is primarily for testing and feedback.
Features in this endpoint are still under heavy development, and subject to
change. Not for production use.
See [Release Phases](https://github.com/github/roadmap?tab=readme-ov-file#release-phases)
for further details.
Expand Down Expand Up @@ -1036,6 +1084,10 @@ components:
- 'null'
description: The text for the link to the agency email address
example: Click me to email the agency
version_number:
type: integer
description: The version number of the opportunity summary
example: 1
funding_instruments:
type: array
items:
Expand Down Expand Up @@ -1765,6 +1817,38 @@ components:
type: integer
description: The HTTP status code
example: 200
OpportunityVersionV1:
type: object
properties:
opportunity:
type:
- object
allOf:
- $ref: '#/components/schemas/OpportunityV1'
forecasts:
type: array
items:
$ref: '#/components/schemas/OpportunitySummaryV1'
non_forecasts:
type: array
items:
$ref: '#/components/schemas/OpportunitySummaryV1'
OpportunityVersionsGetResponseV1:
type: object
properties:
message:
type: string
description: The message to return
example: Success
data:
type:
- object
allOf:
- $ref: '#/components/schemas/OpportunityVersionV1'
status_code:
type: integer
description: The HTTP status code
example: 200
securitySchemes:
ApiKeyAuth:
type: apiKey
Expand Down
746 changes: 378 additions & 368 deletions api/poetry.lock

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion api/src/api/opportunities_v1/opportunity_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from src.api.opportunities_v1.opportunity_blueprint import opportunity_blueprint
from src.auth.api_key_auth import api_key_auth
from src.logging.flask_logger import add_extra_data_to_current_request_logs
from src.services.opportunities_v1.get_opportunity import get_opportunity
from src.services.opportunities_v1.get_opportunity import get_opportunity, get_opportunity_versions
from src.services.opportunities_v1.search_opportunities import search_opportunities
from src.util.dict_util import flatten_dict

Expand Down Expand Up @@ -125,3 +125,17 @@ def opportunity_get(db_session: db.Session, opportunity_id: int) -> response.Api
opportunity = get_opportunity(db_session, opportunity_id)

return response.ApiResponse(message="Success", data=opportunity)


@opportunity_blueprint.get("/opportunities/<int:opportunity_id>/versions")
@opportunity_blueprint.output(opportunity_schemas.OpportunityVersionsGetResponseV1Schema)
@opportunity_blueprint.auth_required(api_key_auth)
@opportunity_blueprint.doc(description=SHARED_ALPHA_DESCRIPTION)
@flask_db.with_db_session()
def opportunity_versions_get(db_session: db.Session, opportunity_id: int) -> response.ApiResponse:
add_extra_data_to_current_request_logs({"opportunity.opportunity_id": opportunity_id})
logger.info("GET /v1/opportunities/:opportunity_id/versions")
with db_session.begin():
data = get_opportunity_versions(db_session, opportunity_id)

return response.ApiResponse(message="Success", data=data)
14 changes: 14 additions & 0 deletions api/src/api/opportunities_v1/opportunity_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ class OpportunitySummaryV1Schema(Schema):
},
)

version_number = fields.Integer(
metadata={"description": "The version number of the opportunity summary", "example": 1}
)

funding_instruments = fields.List(fields.Enum(FundingInstrument))
funding_categories = fields.List(fields.Enum(FundingCategory))
applicant_types = fields.List(fields.Enum(ApplicantType))
Expand Down Expand Up @@ -383,6 +387,16 @@ class OpportunityGetResponseV1Schema(AbstractResponseSchema):
data = fields.Nested(OpportunityV1Schema())


class OpportunityVersionV1Schema(Schema):
opportunity = fields.Nested(OpportunityV1Schema())
forecasts = fields.Nested(OpportunitySummaryV1Schema(many=True))
non_forecasts = fields.Nested(OpportunitySummaryV1Schema(many=True))


class OpportunityVersionsGetResponseV1Schema(AbstractResponseSchema):
data = fields.Nested(OpportunityVersionV1Schema())


class OpportunitySearchResponseV1Schema(AbstractResponseSchema, PaginationMixinSchema):
data = fields.Nested(OpportunityV1Schema(many=True))

Expand Down
35 changes: 35 additions & 0 deletions api/src/db/models/opportunity_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ def opportunity_status(self) -> OpportunityStatus | None:

return self.current_opportunity_summary.opportunity_status

@property
def all_forecasts(self) -> list["OpportunitySummary"]:
# Utility method for getting all forecasted summary records attached to the opportunity
# Note this will include historical and deleted records.
return [summary for summary in self.all_opportunity_summaries if summary.is_forecast]

@property
def all_non_forecasts(self) -> list["OpportunitySummary"]:
# Utility method for getting all forecasted summary records attached to the opportunity
# Note this will include historical and deleted records.
return [summary for summary in self.all_opportunity_summaries if not summary.is_forecast]


class OpportunitySummary(ApiSchemaTable, TimestampMixin):
__tablename__ = "opportunity_summary"
Expand Down Expand Up @@ -191,6 +203,29 @@ class OpportunitySummary(ApiSchemaTable, TimestampMixin):
creator=lambda obj: LinkOpportunitySummaryApplicantType(applicant_type=obj),
)

def for_json(self) -> dict:
json_valid_dict = super().for_json()

# The proxy values don't end up in the JSON as they aren't columns
# so manually add them.
json_valid_dict["funding_instruments"] = self.funding_instruments
json_valid_dict["funding_categories"] = self.funding_categories
json_valid_dict["applicant_types"] = self.applicant_types

return json_valid_dict

def can_summary_be_public(self, current_date: date) -> bool:
"""
Utility method to check whether a summary object
"""
if self.is_deleted:
return False

if self.post_date is None or self.post_date > current_date:
return False

return True


class OpportunityAssistanceListing(ApiSchemaTable, TimestampMixin):
__tablename__ = "opportunity_assistance_listing"
Expand Down
97 changes: 86 additions & 11 deletions api/src/services/opportunities_v1/get_opportunity.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,99 @@
from datetime import date

from sqlalchemy import select
from sqlalchemy.orm import noload, selectinload

import src.adapters.db as db
import src.util.datetime_util as datetime_util
from src.api.route_utils import raise_flask_error
from src.db.models.opportunity_models import Opportunity
from src.db.models.opportunity_models import Opportunity, OpportunitySummary


def get_opportunity(db_session: db.Session, opportunity_id: int) -> Opportunity:
opportunity: Opportunity | None = (
db_session.execute(
select(Opportunity)
.where(Opportunity.opportunity_id == opportunity_id)
.where(Opportunity.is_draft.is_(False))
.options(selectinload("*"), noload(Opportunity.all_opportunity_summaries))
)
.unique()
.scalar_one_or_none()
def _fetch_opportunity(
db_session: db.Session, opportunity_id: int, load_all_opportunity_summaries: bool
) -> Opportunity:
stmt = (
select(Opportunity)
.where(Opportunity.opportunity_id == opportunity_id)
.where(Opportunity.is_draft.is_(False))
.options(selectinload("*"))
)

if not load_all_opportunity_summaries:
stmt = stmt.options(noload(Opportunity.all_opportunity_summaries))

opportunity = db_session.execute(stmt).unique().scalar_one_or_none()

if opportunity is None:
raise_flask_error(404, message=f"Could not find Opportunity with ID {opportunity_id}")

return opportunity


def get_opportunity(db_session: db.Session, opportunity_id: int) -> Opportunity:
return _fetch_opportunity(db_session, opportunity_id, load_all_opportunity_summaries=False)


def get_opportunity_versions(db_session: db.Session, opportunity_id: int) -> dict:
opportunity = _fetch_opportunity(
db_session, opportunity_id, load_all_opportunity_summaries=True
)

now_us_eastern = datetime_util.get_now_us_eastern_date()

forecasts = _filter_summaries(opportunity.all_forecasts, now_us_eastern)
non_forecasts = _filter_summaries(opportunity.all_non_forecasts, now_us_eastern)

return {"opportunity": opportunity, "forecasts": forecasts, "non_forecasts": non_forecasts}


def _filter_summaries(
summaries: list[OpportunitySummary], current_date: date
) -> list[OpportunitySummary]:
# Find the most recent summary
most_recent_summary: OpportunitySummary | None = None
for summary in summaries:
if summary.revision_number is None:
most_recent_summary = summary
summaries.remove(summary)
break

# If there is no most recent summary, even if there is any history records
# we have to filter all of the summaries. Effectively this would mean the most recent
# was deleted, and we never show deleted summaries (or anything that comes before them).
if most_recent_summary is None:
return []

# If the most recent summary isn't able to be public itself, we can't display any history
# for this type of summary object.
if not most_recent_summary.can_summary_be_public(current_date):
return []

summaries_to_keep = [most_recent_summary]

# We want to process these in reverse order (most recent first)
# as soon as we hit one that we need to filter, we stop adding records.
#
# For example, if a summary is marked as deleted, we won't add that, and
# we also filter out all summaries that came before it (by just breaking the loop)
summaries = sorted(summaries, key=lambda s: s.version_number, reverse=True) # type: ignore

for summary in summaries:
if summary.is_deleted:
break

if summary.post_date is None:
break

# If a historical record was updated (or initially created) before
# its own post date (ie. would have been visible when created) regardless
# of what the current date may be
# TODO - leaving this out of the implementation for the moment
# as we need to investigate why this is being done and if there is a better
# way as this ends up filtering out records we don't want removed
# if summary.updated_at.date() < summary.post_date:
# break

summaries_to_keep.append(summary)

return summaries_to_keep
22 changes: 8 additions & 14 deletions api/src/task/opportunities/set_current_opportunities_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,16 @@ def determine_current_and_status(
# Note that if it cannot, we do not want to use an earlier revision
# even if that revision doesn't have the same issue. Only the latest
# revisions of forecast/non-forecast records are ever an option
if not self.can_summary_be_public(latest_forecasted_summary):
if (
latest_forecasted_summary is not None
and not latest_forecasted_summary.can_summary_be_public(self.current_date)
):
latest_forecasted_summary = None

if not self.can_summary_be_public(latest_non_forecasted_summary):
if (
latest_non_forecasted_summary is not None
and not latest_non_forecasted_summary.can_summary_be_public(self.current_date)
):
latest_non_forecasted_summary = None

if latest_forecasted_summary is None and latest_non_forecasted_summary is None:
Expand All @@ -180,18 +186,6 @@ def determine_current_and_status(
cast(OpportunitySummary, latest_forecasted_summary)
)

def can_summary_be_public(self, summary: OpportunitySummary | None) -> bool:
if summary is None:
return False

if summary.is_deleted:
return False

if summary.post_date is None or summary.post_date > self.current_date:
return False

return True

def determine_opportunity_status(
self, opportunity_summary: OpportunitySummary
) -> OpportunityStatus:
Expand Down
Loading

0 comments on commit 7dfe55b

Please sign in to comment.