diff --git a/backend/alembic/versions/2024_09_25_2046-d5610e0988b0_adding_canfundingbudget_meta_events.py b/backend/alembic/versions/2024_09_25_2046-d5610e0988b0_adding_canfundingbudget_meta_events.py new file mode 100644 index 0000000000..05efbbfce8 --- /dev/null +++ b/backend/alembic/versions/2024_09_25_2046-d5610e0988b0_adding_canfundingbudget_meta_events.py @@ -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 ### diff --git a/backend/models/events.py b/backend/models/events.py index 0860428f46..d283225bce 100644 --- a/backend/models/events.py +++ b/backend/models/events.py @@ -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() diff --git a/backend/openapi.yml b/backend/openapi.yml index 7513d1fd9c..a752f1b67b 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -271,6 +271,273 @@ paths: example: "0": $ref: "#/components/examples/CAN" + patch: + tags: + - CANs + summary: Update an existing CAN with the provided fields + operationId: patchCan + parameters: + - $ref: "#/components/parameters/simulatedError" + - in: path + name: can_id + description: Id + required: true + schema: + type: integer + format: int32 + minimum: 1 + default: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUpdateCANRequestSchema" + examples: + "0": + $ref: "#/components/examples/CreateUpdateCANRequestSchema" + responses: + "200": + description: CAN Updated + content: + application/json: + schema: + $ref: "#/components/schemas/CAN" + "400": + description: Bad Request + "401": + description: Insufficient Privileges to use this endpoint. + "404": + description: CAN not found + put: + tags: + - CANs + summary: Update an existing CAN + operationId: putCan + parameters: + - $ref: "#/components/parameters/simulatedError" + - in: path + name: can_id + description: Id + required: true + schema: + type: integer + format: int32 + minimum: 1 + default: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUpdateCANRequestSchema" + examples: + "0": + $ref: "#/components/examples/CreateUpdateCANRequestSchema" + responses: + "200": + description: CAN Updated + content: + application/json: + schema: + $ref: "#/components/schemas/CAN" + "400": + description: Bad Request + "401": + description: Insufficient Privileges to use this endpoint. + "404": + description: CAN not found + delete: + tags: + - CANs + operationId: deleteCan + summary: Delete a Common Accounting Number (CAN) + parameters: + - $ref: "#/components/parameters/simulatedError" + - in: path + name: can_id + description: Id + required: true + schema: + type: integer + format: int32 + minimum: 1 + default: 1 + responses: + "200": + description: OK + "400": + description: Bad Request + "401": + description: Unauthorized + /api/v1/can-funding-budgets/: + get: + tags: + - CAN Funding Budgets + operationId: getAllCANFundingBudgets + summary: Get a list of all the CAN funding budgets + parameters: + - $ref: "#/components/parameters/simulatedError" + description: Get CANFundingBudgetss + responses: + "200": + description: OK + post: + tags: + - CAN Funding Budgets + operationId: createCANFundingBudget + summary: Create a new CANFundingBudget object + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUpdateCANFundingBudgetRequest" + examples: + "0": + $ref: "#/components/examples/CreateUpdateCANFundingBudgetRequest" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/FundingBudget" + "400": + description: Bad Request + "401": + description: Insufficient Privileges to use this endpoint. + /api/v1/can-funding-budgets/{can_funding_budget_id}: + get: + tags: + - CAN Funding Budgets + summary: Get an individual CANFundingBudget + operationId: getCANFundingBudgetById + description: Get CANFundingBudget by Id + parameters: + - $ref: "#/components/parameters/simulatedError" + - in: path + name: can_funding_budget_id + description: CAN Funding Budget Id + required: true + schema: + type: integer + format: int32 + minimum: 0 + default: 1 + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/FundingBudget" + example: + "0": + $ref: "#/components/examples/FundingBudget" + "404": + description: The specified CANFundingBudget was not found. + patch: + tags: + - CAN Funding Budgets + summary: Update an existing CANFundingBudget with the provided fields + operationId: patchCanFundingBudget + parameters: + - $ref: "#/components/parameters/simulatedError" + - in: path + name: can_funding_budget_id + description: CAN Funding Budget Id + required: true + schema: + type: integer + format: int32 + minimum: 0 + default: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUpdateCANFundingBudgetRequest" + examples: + "0": + $ref: "#/components/examples/CreateUpdateCANFundingBudgetRequest" + responses: + "200": + description: CANFundingBudget Updated + content: + application/json: + schema: + $ref: "#/components/schemas/FundingBudget" + "400": + description: Bad Request + "401": + description: Insufficient Privileges to use this endpoint. + "404": + description: CANFundingBudget not found + put: + tags: + - CAN Funding Budgets + summary: Update an existing CANFundingBudget + operationId: putCanFundingBudget + parameters: + - $ref: "#/components/parameters/simulatedError" + - in: path + name: can_funding_budget_id + description: CAN Funding Budget Id + required: true + schema: + type: integer + format: int32 + minimum: 0 + default: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateUpdateCANFundingBudgetRequest" + examples: + "0": + $ref: "#/components/examples/CreateUpdateCANFundingBudgetRequest" + responses: + "200": + description: CAN Updated + content: + application/json: + schema: + $ref: "#/components/schemas/FundingBudget" + "400": + description: Bad Request + "401": + description: Insufficient Privileges to use this endpoint. + "404": + description: CAN not found + delete: + tags: + - CAN Funding Budgets + operationId: deleteCanFundingBudget + summary: Delete a CANFundingBudget + parameters: + - $ref: "#/components/parameters/simulatedError" + - in: path + name: can_funding_budget_id + description: CANFundingBudget ID + required: true + schema: + type: integer + format: int32 + minimum: 1 + default: 1 + responses: + "200": + description: OK + "400": + description: Bad Request + "401": + description: Unauthorized + "404": + description: Unable to find specified CANFundingBudget /api/v1/portfolios: get: tags: @@ -2212,6 +2479,21 @@ components: - fiscal_year - fund_code - id + CreateUpdateCANFundingBudgetRequest: + description: A request object for the creation or updating of a FundingBudget + type: object + properties: + fiscal_year: + type: integer + can_id: + type: integer + budget: + type: number + notes: + type: string + required: + - fiscal_year + - can_id FundingBudget: description: Funding Budget Object type: object @@ -2219,7 +2501,7 @@ components: budget: type: number can: - type: integer + $ref: "#/components/schemas/BasicCAN" can_id: type: integer display_name: @@ -3656,6 +3938,25 @@ components: portfolio_id: 6, description: "A very good CAN to use for examples." } + CreateUpdateCANFundingBudgetRequest: + value: | + { + can_id: 500, + fiscal_year: 2024, + budget: 123456, + notes: "A new test Funding Budget" + } + FundingBudget: + value: | + { + budget: 1234567.89 + can: {} + can_id: 500 + display_name: CANFundingBudget.1 + fiscal_year: 2024 + id: 1 + notes: "A funding budget" + versions: {} Notifications: value: | [ diff --git a/backend/ops_api/ops/resources/can_funding_budget.py b/backend/ops_api/ops/resources/can_funding_budget.py new file mode 100644 index 0000000000..fb06399e03 --- /dev/null +++ b/backend/ops_api/ops/resources/can_funding_budget.py @@ -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) diff --git a/backend/ops_api/ops/resources/cans.py b/backend/ops_api/ops/resources/cans.py index ab433318c1..d162442979 100644 --- a/backend/ops_api/ops/resources/cans.py +++ b/backend/ops_api/ops/resources/cans.py @@ -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) diff --git a/backend/ops_api/ops/schemas/cans.py b/backend/ops_api/ops/schemas/cans.py index 2ed35ca7cb..d832a085f5 100644 --- a/backend/ops_api/ops/schemas/cans.py +++ b/backend/ops_api/ops/schemas/cans.py @@ -1,7 +1,6 @@ from marshmallow import Schema, fields from models import CANMethodOfTransfer, PortfolioStatus - from ops_api.ops.schemas.budget_line_items import BudgetLineItemResponseSchema from ops_api.ops.schemas.projects import ProjectSchema from ops_api.ops.schemas.users import SafeUserSchema @@ -95,6 +94,13 @@ class FundingBudgetSchema(Schema): updated_by_user = fields.Nested(SafeUserSchema(), allow_none=True) +class CreateUpdateFundingBudgetSchema(Schema): + fiscal_year = fields.Integer(required=True) + can_id = fields.Integer(required=True) + budget = fields.Float(load_default=None) + notes = fields.String(load_default=None) + + class FundingDetailsSchema(Schema): allotment = fields.String(allow_none=True) allowance = fields.String(allow_none=True) diff --git a/backend/ops_api/ops/services/can_funding_budget.py b/backend/ops_api/ops/services/can_funding_budget.py new file mode 100644 index 0000000000..8f15c277d1 --- /dev/null +++ b/backend/ops_api/ops/services/can_funding_budget.py @@ -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] diff --git a/backend/ops_api/ops/urls.py b/backend/ops_api/ops/urls.py index 2a1997c42c..4bcbe855c9 100644 --- a/backend/ops_api/ops/urls.py +++ b/backend/ops_api/ops/urls.py @@ -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, @@ -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/", 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, diff --git a/backend/ops_api/ops/views.py b/backend/ops_api/ops/views.py index c676032c35..60425c1e61 100644 --- a/backend/ops_api/ops/views.py +++ b/backend/ops_api/ops/views.py @@ -6,6 +6,7 @@ Award, BaseModel, BudgetLineItem, + CANFundingBudget, CANFundingDetails, ChangeRequest, ContractAgreement, @@ -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 @@ -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) diff --git a/backend/ops_api/tests/conftest.py b/backend/ops_api/tests/conftest.py index d08413f027..e79058cec8 100644 --- a/backend/ops_api/tests/conftest.py +++ b/backend/ops_api/tests/conftest.py @@ -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 @@ -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) diff --git a/backend/ops_api/tests/ops/can/test_can.py b/backend/ops_api/tests/ops/can/test_can.py index 3876ae6150..026c384a61 100644 --- a/backend/ops_api/tests/ops/can/test_can.py +++ b/backend/ops_api/tests/ops/can/test_can.py @@ -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() @@ -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() @@ -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 @@ -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) diff --git a/backend/ops_api/tests/ops/can/test_can_funding_budget.py b/backend/ops_api/tests/ops/can/test_can_funding_budget.py new file mode 100644 index 0000000000..f3ca758af9 --- /dev/null +++ b/backend/ops_api/tests/ops/can/test_can_funding_budget.py @@ -0,0 +1,284 @@ +import pytest +from sqlalchemy import select + +from models import CANFundingBudget +from ops.services.can_funding_budget import CANFundingBudgetService + + +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_get_all(auth_client, mocker, test_can_funding_budget): + mocker_get_funding_budget = mocker.patch("ops_api.ops.services.can_funding_budget.CANFundingBudgetService.get_list") + mocker_get_funding_budget.return_value = [test_can_funding_budget] + response = auth_client.get("/api/v1/can-funding-budgets/") + assert response.status_code == 200 + assert len(response.json) == 1 + mocker_get_funding_budget.assert_called_once() + + +def test_service_can_get_all(auth_client, loaded_db): + count = loaded_db.query(CANFundingBudget).count() + budget_service = CANFundingBudgetService() + response = budget_service.get_list() + assert len(response) == count + + +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_get_by_id(auth_client, mocker, test_can_funding_budget): + mocker_get_funding_budget = mocker.patch("ops_api.ops.services.can_funding_budget.CANFundingBudgetService.get") + mocker_get_funding_budget.return_value = test_can_funding_budget + response = auth_client.get(f"/api/v1/can-funding-budgets/{test_can_funding_budget.id}") + assert response.status_code == 200 + assert response.json["fiscal_year"] == 2023 + assert response.json["budget"] == 1140000 + assert response.json["can_id"] == 500 + + +def test_funding_budget_service_get_by_id(test_can_funding_budget): + service = CANFundingBudgetService() + funding_budget = service.get(test_can_funding_budget.id) + assert test_can_funding_budget.id == funding_budget.id + assert test_can_funding_budget.budget == funding_budget.budget + assert test_can_funding_budget.can_id == funding_budget.can_id + + +# Testing CANFundingBudget Creation +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_post_creates_funding_budget(budget_team_auth_client, mocker, loaded_db): + input_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": "This is a note"} + + mock_output_data = CANFundingBudget(can_id=500, fiscal_year=2024, budget=123456, notes="This is a note") + mocker_create_funding_budget = mocker.patch( + "ops_api.ops.services.can_funding_budget.CANFundingBudgetService.create" + ) + mocker_create_funding_budget.return_value = mock_output_data + response = budget_team_auth_client.post("/api/v1/can-funding-budgets/", json=input_data) + + assert response.status_code == 201 + mocker_create_funding_budget.assert_called_once_with(input_data) + assert response.json["id"] == mock_output_data.id + assert response.json["can_id"] == mock_output_data.can_id + assert response.json["fiscal_year"] == mock_output_data.fiscal_year + assert response.json["budget"] == mock_output_data.budget + + +@pytest.mark.usefixtures("app_ctx") +def test_basic_user_cannot_post_funding_budget(basic_user_auth_client): + input_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": "This is a note"} + response = basic_user_auth_client.post("/api/v1/can-funding-budgets/", json=input_data) + + assert response.status_code == 401 + + +def test_service_create_funding_budget(loaded_db): + input_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": "This is a note"} + + service = CANFundingBudgetService() + + new_budget = service.create(input_data) + + funding_budget = loaded_db.execute( + select(CANFundingBudget).where(CANFundingBudget.id == new_budget.id) + ).scalar_one() + + assert funding_budget is not None + assert funding_budget.can_id == 500 + assert funding_budget.notes == "This is a note" + assert funding_budget.fiscal_year == 2024 + assert funding_budget.id == new_budget.id + assert funding_budget == new_budget + + loaded_db.delete(new_budget) + loaded_db.commit() + + +# Testing updating CANs by PATCH +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_patch(budget_team_auth_client, mocker): + test_budget_id = 600 + update_data = { + "notes": "Fake test update", + } + + funding_budget = CANFundingBudget(can_id=500, fiscal_year=2024, budget=123456, notes="This is a note") + mocker_update_funding_budget = mocker.patch( + "ops_api.ops.services.can_funding_budget.CANFundingBudgetService.update" + ) + funding_budget.notes = update_data["notes"] + mocker_update_funding_budget.return_value = funding_budget + response = budget_team_auth_client.patch(f"/api/v1/can-funding-budgets/{test_budget_id}", json=update_data) + + assert response.status_code == 200 + mocker_update_funding_budget.assert_called_once_with(update_data, test_budget_id) + assert response.json["budget"] == funding_budget.budget + assert response.json["notes"] == funding_budget.notes + + +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_patch_404(budget_team_auth_client): + test_budget_id = 518 + update_data = { + "notes": "Test CANFundingBudget Created by unit test", + } + + response = budget_team_auth_client.patch(f"/api/v1/can-funding-budgets/{test_budget_id}", json=update_data) + + assert response.status_code == 404 + + +@pytest.mark.usefixtures("app_ctx") +def test_basic_user_cannot_patch_funding_budgets(basic_user_auth_client): + data = { + "notes": "An updated can description", + } + response = basic_user_auth_client.patch("/api/v1/can-funding-budgets/517", json=data) + + assert response.status_code == 401 + + +def test_service_patch_funding_budget(loaded_db): + update_data = { + "notes": "Test Test Test", + } + + input_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": "This is a note"} + + budget_service = CANFundingBudgetService() + + new_funding_budget = budget_service.create(input_data) + + updated_funding_budget = budget_service.update(update_data, new_funding_budget.id) + + funding_budget = loaded_db.execute( + select(CANFundingBudget).where(CANFundingBudget.id == new_funding_budget.id) + ).scalar_one() + + assert funding_budget is not None + assert funding_budget.budget == 123456 + assert updated_funding_budget.budget == 123456 + assert funding_budget.notes == "Test Test Test" + assert updated_funding_budget.notes == "Test Test Test" + + loaded_db.delete(new_funding_budget) + loaded_db.commit() + + +# Testing updating CANFundingBudgets by PUT +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_put(budget_team_auth_client, mocker): + test_funding_budget_id = 517 + update_data = { + "can_id": 500, + "fiscal_year": 2024, + "budget": 234567, + } + + funding_budget = CANFundingBudget(can_id=500, fiscal_year=2024, budget=123456, notes="This is a note") + + mocker_update_funding_budget = mocker.patch( + "ops_api.ops.services.can_funding_budget.CANFundingBudgetService.update" + ) + funding_budget.budget = update_data["budget"] + mocker_update_funding_budget.return_value = funding_budget + response = budget_team_auth_client.put(f"/api/v1/can-funding-budgets/{test_funding_budget_id}", json=update_data) + + update_data["notes"] = None + assert response.status_code == 200 + mocker_update_funding_budget.assert_called_once_with(update_data, test_funding_budget_id) + assert response.json["budget"] == funding_budget.budget + assert response.json["can_id"] == funding_budget.can_id + + +@pytest.mark.usefixtures("app_ctx") +def test_basic_user_cannot_put_funding_budget(basic_user_auth_client): + data = { + "notes": "An updated can description", + } + response = basic_user_auth_client.put("/api/v1/can-funding-budgets/517", json=data) + + assert response.status_code == 401 + + +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_put_404(budget_team_auth_client): + test_funding_budget_id = 518 + update_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": "Test test test"} + + response = budget_team_auth_client.put(f"/api/v1/can-funding-budgets/{test_funding_budget_id}", json=update_data) + + assert response.status_code == 404 + + +def test_service_update_funding_budget_with_nones(loaded_db): + update_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": None} + + test_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": "Test Notes"} + + funding_budget_service = CANFundingBudgetService() + + new_funding_budget = funding_budget_service.create(test_data) + + updated_funding_budget = funding_budget_service.update(update_data, new_funding_budget.id) + + funding_budget = loaded_db.execute( + select(CANFundingBudget).where(CANFundingBudget.id == updated_funding_budget.id) + ).scalar_one() + + assert funding_budget is not None + assert funding_budget.can_id == 500 + assert updated_funding_budget.can_id == 500 + assert funding_budget.notes is None + assert updated_funding_budget.notes is None + assert funding_budget.fiscal_year == 2024 + assert updated_funding_budget.fiscal_year == 2024 + assert funding_budget.budget == 123456 + assert updated_funding_budget.budget == 123456 + + loaded_db.delete(new_funding_budget) + loaded_db.commit() + + +# Testing deleting CANFundingBudgets +@pytest.mark.usefixtures("app_ctx") +def test_funding_budget_delete(budget_team_auth_client, mocker): + test_funding_budget_id = 517 + + mocker_delete_funding_budget = mocker.patch( + "ops_api.ops.services.can_funding_budget.CANFundingBudgetService.delete" + ) + response = budget_team_auth_client.delete(f"/api/v1/can-funding-budgets/{test_funding_budget_id}") + + assert response.status_code == 200 + mocker_delete_funding_budget.assert_called_once_with(test_funding_budget_id) + assert response.json["message"] == "CANFundingBudget deleted" + assert response.json["id"] == test_funding_budget_id + + +@pytest.mark.usefixtures("app_ctx") +def test_can_delete_404(budget_team_auth_client): + test_can_id = 500 + + response = budget_team_auth_client.delete(f"/api/v1/can-funding-budgets/{test_can_id}") + + assert response.status_code == 404 + + +@pytest.mark.usefixtures("app_ctx") +def test_basic_user_cannot_delete_cans(basic_user_auth_client): + response = basic_user_auth_client.delete("/api/v1/can-funding-budgets/517") + + assert response.status_code == 401 + + +def test_service_delete_can(loaded_db): + test_data = {"can_id": 500, "fiscal_year": 2024, "budget": 123456, "notes": "Test Notes"} + + funding_budget_service = CANFundingBudgetService() + + new_funding_budget = funding_budget_service.create(test_data) + + funding_budget_service.delete(new_funding_budget.id) + + stmt = select(CANFundingBudget).where(CANFundingBudget.id == new_funding_budget.id) + can = loaded_db.scalar(stmt) + + assert can is None diff --git a/frontend/src/pages/agreements/approve/ApproveAgreement.jsx b/frontend/src/pages/agreements/approve/ApproveAgreement.jsx index e8f067bafd..442ab99a56 100644 --- a/frontend/src/pages/agreements/approve/ApproveAgreement.jsx +++ b/frontend/src/pages/agreements/approve/ApproveAgreement.jsx @@ -52,7 +52,7 @@ const ApproveAgreement = () => { hasPermissionToViewPage, isApproverAndAgreementInReview } = useApproveAgreement(); - + if (!hasPermissionToViewPage && is2849Ready) { return ; } @@ -85,6 +85,7 @@ const ApproveAgreement = () => { title={title} subTitle={agreement.name} /> +