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

Add endpoint for CANFundingBudgets #2854

Merged
merged 10 commits into from
Sep 30, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Adding CANFundingBudget meta events

Revision ID: d5610e0988b0
Revises: cbbaf27a11ee
Create Date: 2024-09-25 20:46:07.501389+00:00

"""
from typing import Sequence, Union

import sqlalchemy as sa
from alembic import op
from alembic_postgresql_enum import TableReference

# revision identifiers, used by Alembic.
revision: str = 'd5610e0988b0'
down_revision: Union[str, None] = 'cbbaf27a11ee'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values('ops', 'opseventtype', ['CREATE_BLI', 'UPDATE_BLI', 'DELETE_BLI', 'SEND_BLI_FOR_APPROVAL', 'CREATE_PROJECT', 'CREATE_NEW_AGREEMENT', 'UPDATE_AGREEMENT', 'DELETE_AGREEMENT', 'CREATE_NEW_CAN', 'UPDATE_CAN', 'DELETE_CAN', 'CREATE_CAN_FUNDING_BUDGET', 'UPDATE_CAN_FUNDING_BUDGET', 'DELETE_CAN_FUNDING_BUDGET', 'ACKNOWLEDGE_NOTIFICATION', 'CREATE_BLI_PACKAGE', 'UPDATE_BLI_PACKAGE', 'CREATE_SERVICES_COMPONENT', 'UPDATE_SERVICES_COMPONENT', 'DELETE_SERVICES_COMPONENT', 'CREATE_PROCUREMENT_ACQUISITION_PLANNING', 'UPDATE_PROCUREMENT_ACQUISITION_PLANNING', 'DELETE_PROCUREMENT_ACQUISITION_PLANNING', 'CREATE_DOCUMENT', 'UPDATE_DOCUMENT', 'LOGIN_ATTEMPT', 'LOGOUT', 'GET_USER_DETAILS', 'CREATE_USER', 'UPDATE_USER', 'DEACTIVATE_USER'],
[TableReference(table_schema='ops', table_name='ops_event', column_name='event_type'), TableReference(table_schema='ops', table_name='ops_event_version', column_name='event_type')],
enum_values_to_rename=[])
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.sync_enum_values('ops', 'opseventtype', ['CREATE_BLI', 'UPDATE_BLI', 'DELETE_BLI', 'SEND_BLI_FOR_APPROVAL', 'CREATE_PROJECT', 'CREATE_NEW_AGREEMENT', 'UPDATE_AGREEMENT', 'DELETE_AGREEMENT', 'CREATE_NEW_CAN', 'UPDATE_CAN', 'DELETE_CAN', 'ACKNOWLEDGE_NOTIFICATION', 'CREATE_BLI_PACKAGE', 'UPDATE_BLI_PACKAGE', 'CREATE_SERVICES_COMPONENT', 'UPDATE_SERVICES_COMPONENT', 'DELETE_SERVICES_COMPONENT', 'CREATE_PROCUREMENT_ACQUISITION_PLANNING', 'UPDATE_PROCUREMENT_ACQUISITION_PLANNING', 'DELETE_PROCUREMENT_ACQUISITION_PLANNING', 'CREATE_DOCUMENT', 'UPDATE_DOCUMENT', 'LOGIN_ATTEMPT', 'LOGOUT', 'GET_USER_DETAILS', 'CREATE_USER', 'UPDATE_USER', 'DEACTIVATE_USER'],
[TableReference(table_schema='ops', table_name='ops_event', column_name='event_type'), TableReference(table_schema='ops', table_name='ops_event_version', column_name='event_type')],
enum_values_to_rename=[])
# ### end Alembic commands ###
5 changes: 5 additions & 0 deletions backend/models/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class OpsEventType(Enum):
UPDATE_CAN = auto()
DELETE_CAN = auto()

# CAN Funding Budget Related Events
CREATE_CAN_FUNDING_BUDGET = auto()
UPDATE_CAN_FUNDING_BUDGET = auto()
DELETE_CAN_FUNDING_BUDGET = auto()

# Notification Related Events
ACKNOWLEDGE_NOTIFICATION = auto()

Expand Down
95 changes: 95 additions & 0 deletions backend/ops_api/ops/resources/can_funding_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
from flask import Response, request
from flask_jwt_extended import jwt_required

from models import OpsEventType
from ops_api.ops.auth.auth_types import Permission, PermissionType
from ops_api.ops.auth.decorators import is_authorized
from ops_api.ops.base_views import BaseItemAPI, BaseListAPI
from ops_api.ops.schemas.cans import CreateUpdateFundingBudgetSchema, FundingBudgetSchema
from ops_api.ops.services.can_funding_budget import CANFundingBudgetService
from ops_api.ops.utils.errors import error_simulator
from ops_api.ops.utils.events import OpsEventHandler
from ops_api.ops.utils.response import make_response_with_headers


class CANFundingBudgetItemAPI(BaseItemAPI):
def __init__(self, model):
super().__init__(model)
self.service = CANFundingBudgetService()

@is_authorized(PermissionType.GET, Permission.CAN)
def get(self, id: int) -> Response:
schema = FundingBudgetSchema()
item = self.service.get(id)
return make_response_with_headers(schema.dump(item))

@is_authorized(PermissionType.PATCH, Permission.CAN)
def patch(self, id: int) -> Response:
"""
Update a CANFundingBudget with only the fields provided in the request body.
"""
with OpsEventHandler(OpsEventType.UPDATE_CAN_FUNDING_BUDGET) as meta:
request_data = request.get_json()
# Setting partial to true ignores any missing fields.
schema = CreateUpdateFundingBudgetSchema(partial=True)
serialized_request = schema.load(request_data)

updated_funding_budget = self.service.update(serialized_request, id)
serialized_can_funding_budget = schema.dump(updated_funding_budget)
meta.metadata.update({"updated_can_funding_budget": serialized_can_funding_budget})
return make_response_with_headers(serialized_can_funding_budget)

@is_authorized(PermissionType.PATCH, Permission.CAN)
def put(self, id: int) -> Response:
"""
Update a CANFundingBudget
"""
with OpsEventHandler(OpsEventType.UPDATE_CAN_FUNDING_BUDGET) as meta:
request_data = request.get_json()
schema = CreateUpdateFundingBudgetSchema()
serialized_request = schema.load(request_data)

updated_funding_budget = self.service.update(serialized_request, id)
serialized_funding_budget = schema.dump(updated_funding_budget)
meta.metadata.update({"updated_can_funding_budget": serialized_funding_budget})
return make_response_with_headers(serialized_funding_budget)

@is_authorized(PermissionType.DELETE, Permission.CAN)
def delete(self, id: int) -> Response:
"""
Delete a CANFundingBudget with given id.
"""
with OpsEventHandler(OpsEventType.DELETE_CAN_FUNDING_BUDGET) as meta:
self.service.delete(id)
meta.metadata.update({"Deleted CANFundingBudget": id})
return make_response_with_headers({"message": "CANFundingBudget deleted", "id": id}, 200)


class CANFundingBudgetListAPI(BaseListAPI):
def __init__(self, model):
super().__init__(model)
self.service = CANFundingBudgetService()

@jwt_required()
@error_simulator
def get(self) -> Response:
result = self.service.get_list()
funding_budget_schema = FundingBudgetSchema()
return make_response_with_headers([funding_budget_schema.dump(funding_budget) for funding_budget in result])

@is_authorized(PermissionType.POST, Permission.CAN)
def post(self) -> Response:
"""
Create a new CANFundingBudget object
"""
with OpsEventHandler(OpsEventType.CREATE_CAN_FUNDING_BUDGET) as meta:
request_data = request.get_json()
schema = CreateUpdateFundingBudgetSchema()
serialized_request = schema.load(request_data)

created_funding_budget = self.service.create(serialized_request)

funding_budget_schema = FundingBudgetSchema()
serialized_funding_budget = funding_budget_schema.dump(created_funding_budget)
meta.metadata.update({"new_can_funding_budget": serialized_funding_budget})
return make_response_with_headers(serialized_funding_budget, 201)
2 changes: 1 addition & 1 deletion backend/ops_api/ops/resources/cans.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ def delete(self, id: int) -> Response:
Delete a CAN with given id."""
with OpsEventHandler(OpsEventType.DELETE_CAN) as meta:
self.can_service.delete(id)
meta.metadata.update({"Deleted BudgetLineItem": id})
meta.metadata.update({"Deleted CAN": id})
return make_response_with_headers({"message": "CAN deleted", "id": id}, 200)


Expand Down
7 changes: 7 additions & 0 deletions backend/ops_api/ops/schemas/cans.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@ class FundingBudgetSchema(Schema):
updated_by_user = fields.Nested(SafeUserSchema(), allow_none=True)


class CreateUpdateFundingBudgetSchema(Schema):
rajohnson90 marked this conversation as resolved.
Show resolved Hide resolved
fiscal_year = fields.Integer(required=True)
can_id = fields.Integer(required=True)
budget = fields.Integer(load_default=None)
notes = fields.String(load_default=None)


class FundingDetailsSchema(Schema):
allotment = fields.String(allow_none=True)
allowance = fields.String(allow_none=True)
Expand Down
84 changes: 84 additions & 0 deletions backend/ops_api/ops/services/can_funding_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from flask import current_app
from sqlalchemy import select
from sqlalchemy.exc import NoResultFound
from werkzeug.exceptions import NotFound

from models import CANFundingBudget


class CANFundingBudgetService:
def _update_fields(self, old_funding_budget: CANFundingBudget, budget_update) -> bool:
"""
Update fields on the CAN based on the fields passed in can_update.
Returns true if any fields were updated.
"""
is_changed = False
for attr, value in budget_update.items():
if getattr(old_funding_budget, attr) != value:
setattr(old_funding_budget, attr, value)
is_changed = True

return is_changed

def create(self, create_funding_budget_request) -> CANFundingBudget:
"""
Create a new CAN Funding Budget and save it to the database
"""
new_can = CANFundingBudget(**create_funding_budget_request)

current_app.db_session.add(new_can)
current_app.db_session.commit()
return new_can

def update(self, updated_fields, id: int) -> CANFundingBudget:
"""
Update a CANFundingBudget with only the provided values in updated_fields.
"""
try:
old_budget: CANFundingBudget = current_app.db_session.execute(
select(CANFundingBudget).where(CANFundingBudget.id == id)
).scalar_one()

budget_was_updated = self._update_fields(old_budget, updated_fields)
if budget_was_updated:
current_app.db_session.add(old_budget)
current_app.db_session.commit()

return old_budget
except NoResultFound:
current_app.logger.exception(f"Could not find a CANFundingBudget with id {id}")
raise NotFound()

def delete(self, id: int):
"""
Delete a CANFundingBudget with given id. Throw a NotFound error if no CAN corresponding to that ID exists."""
try:
old_budget: CANFundingBudget = current_app.db_session.execute(
select(CANFundingBudget).where(CANFundingBudget.id == id)
).scalar_one()
current_app.db_session.delete(old_budget)
current_app.db_session.commit()
except NoResultFound:
current_app.logger.exception(f"Could not find a CANFundingBudget with id {id}")
raise NotFound()

def get(self, id: int) -> CANFundingBudget:
"""
Get an individual CAN Funding Budget by id.
"""
stmt = select(CANFundingBudget).where(CANFundingBudget.id == id).order_by(CANFundingBudget.id)
funding_budget = current_app.db_session.scalar(stmt)

if funding_budget:
return funding_budget
else:
current_app.logger.exception(f"Could not find a CAN Funding Budget with id {id}")
raise NotFound()

def get_list(self) -> list[CANFundingBudget]:
"""
Get a list of CAN funding budgets, optionally filtered by a search parameter.
"""
stmt = select(CANFundingBudget).order_by(CANFundingBudget.id)
results = current_app.db_session.execute(stmt).all()
return [can for result in results for can in result]
7 changes: 7 additions & 0 deletions backend/ops_api/ops/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
AZURE_SAS_TOKEN_VIEW_FUNC,
BUDGET_LINE_ITEMS_ITEM_API_VIEW_FUNC,
BUDGET_LINE_ITEMS_LIST_API_VIEW_FUNC,
CAN_FUNDING_BUDGET_ITEM_API_VIEW_FUNC,
CAN_FUNDING_BUDGET_LIST_API_VIEW_FUNC,
CAN_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC,
CAN_ITEM_API_VIEW_FUNC,
CAN_LIST_API_VIEW_FUNC,
Expand Down Expand Up @@ -94,6 +96,11 @@ def register_api(api_bp: Blueprint) -> None:
"/cans/",
view_func=CAN_LIST_API_VIEW_FUNC,
)

api_bp.add_url_rule("/can-funding-budgets/<int:id>", view_func=CAN_FUNDING_BUDGET_ITEM_API_VIEW_FUNC)

api_bp.add_url_rule("/can-funding-budgets/", view_func=CAN_FUNDING_BUDGET_LIST_API_VIEW_FUNC)

api_bp.add_url_rule(
"/ops-db-histories/",
view_func=OPS_DB_HISTORY_LIST_API_VIEW_FUNC,
Expand Down
6 changes: 6 additions & 0 deletions backend/ops_api/ops/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Award,
BaseModel,
BudgetLineItem,
CANFundingBudget,
CANFundingDetails,
ChangeRequest,
ContractAgreement,
Expand Down Expand Up @@ -42,6 +43,7 @@
)
from ops_api.ops.resources.azure import SasToken
from ops_api.ops.resources.budget_line_items import BudgetLineItemsItemAPI, BudgetLineItemsListAPI
from ops_api.ops.resources.can_funding_budget import CANFundingBudgetItemAPI, CANFundingBudgetListAPI
from ops_api.ops.resources.can_funding_summary import CANFundingSummaryItemAPI
from ops_api.ops.resources.cans import CANItemAPI, CANListAPI, CANsByPortfolioAPI
from ops_api.ops.resources.change_requests import ChangeRequestListAPI, ChangeRequestReviewAPI
Expand Down Expand Up @@ -144,6 +146,10 @@
"research-project-funding-summary-group", ResearchProject
)

# FUNDING BUDGET ENDPOINTS
CAN_FUNDING_BUDGET_ITEM_API_VIEW_FUNC = CANFundingBudgetItemAPI.as_view("can-funding-budget-item", CANFundingBudget)
CAN_FUNDING_BUDGET_LIST_API_VIEW_FUNC = CANFundingBudgetListAPI.as_view("can-funding-budget-group", CANFundingBudget)

# PROJECT ENDPOINTS
PROJECT_ITEM_API_VIEW_FUNC = ProjectItemAPI.as_view("projects-item", Project)
PROJECT_LIST_API_VIEW_FUNC = ProjectListAPI.as_view("projects-group", Project)
Expand Down
8 changes: 7 additions & 1 deletion backend/ops_api/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from sqlalchemy.exc import OperationalError
from sqlalchemy.orm import Session

from models import CAN, BudgetLineItem, OpsDBHistory, OpsEvent, Project, User, Vendor
from models import CAN, BudgetLineItem, CANFundingBudget, OpsDBHistory, OpsEvent, Project, User, Vendor
from ops_api.ops import create_app
from tests.auth_client import AuthClient, BasicUserAuthClient, BudgetTeamAuthClient, NoPermsAuthClient

Expand Down Expand Up @@ -205,3 +205,9 @@ def test_bli(loaded_db) -> BudgetLineItem | None:
@pytest.fixture
def utc_today():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT")


@pytest.fixture
def test_can_funding_budget(loaded_db) -> CANFundingBudget | None:
"""Get a test CANFundingBudget."""
return loaded_db.get(CANFundingBudget, 1)
8 changes: 1 addition & 7 deletions backend/ops_api/tests/ops/can/test_can.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,8 +270,6 @@ def test_service_patch_can(loaded_db):

new_can = can_service.create(test_data)

can_service = CANService()

updated_can = can_service.update(update_data, new_can.id)

can = loaded_db.execute(select(CAN).where(CAN.number == "G998235")).scalar_one()
Expand Down Expand Up @@ -355,8 +353,6 @@ def test_service_update_can_with_nones(loaded_db):

new_can = can_service.create(test_data)

can_service = CANService()

updated_can = can_service.update(update_data, new_can.id)

can = loaded_db.execute(select(CAN).where(CAN.id == updated_can.id)).scalar_one()
Expand All @@ -377,7 +373,7 @@ def test_service_update_can_with_nones(loaded_db):
loaded_db.commit()


# Testing updating CANs by PATCH
# Testing deleting CANs
@pytest.mark.usefixtures("app_ctx")
def test_can_delete(budget_team_auth_client, mocker, unadded_can):
test_can_id = 517
Expand Down Expand Up @@ -419,8 +415,6 @@ def test_service_delete_can(loaded_db):

new_can = can_service.create(test_data)

can_service = CANService()

can_service.delete(new_can.id)

stmt = select(CAN).where(CAN.id == new_can.id)
Expand Down
Loading