diff --git a/backend/alembic/versions/2024_09_19_1835-cbbaf27a11ee_adding_create_update_and_delete_events_.py b/backend/alembic/versions/2024_09_19_1835-cbbaf27a11ee_adding_create_update_and_delete_events_.py new file mode 100644 index 0000000000..b69a948d43 --- /dev/null +++ b/backend/alembic/versions/2024_09_19_1835-cbbaf27a11ee_adding_create_update_and_delete_events_.py @@ -0,0 +1,34 @@ +"""Adding create, update, and delete events for CANs + +Revision ID: cbbaf27a11ee +Revises: eb4261420779 +Create Date: 2024-09-19 18:35:25.734075+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 = 'cbbaf27a11ee' +down_revision: Union[str, None] = 'eb4261420779' +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', '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', '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/data_tools/data/user_data.json5 b/backend/data_tools/data/user_data.json5 index 7533e18ebf..5f05482a62 100644 --- a/backend/data_tools/data/user_data.json5 +++ b/backend/data_tools/data/user_data.json5 @@ -62,6 +62,10 @@ "POST_BLI_PACKAGE", "GET_CAN", + "POST_CAN", + "PATCH_CAN", + "PUT_CAN", + "DELETE_CAN", "GET_DIVISION", @@ -254,6 +258,7 @@ "PUT_CAN", "PATCH_CAN", "POST_CAN", + "DELETE_CAN", "GET_DIVISION", diff --git a/backend/models/events.py b/backend/models/events.py index e8a5c41346..0860428f46 100644 --- a/backend/models/events.py +++ b/backend/models/events.py @@ -22,6 +22,11 @@ class OpsEventType(Enum): UPDATE_AGREEMENT = auto() DELETE_AGREEMENT = auto() + # CAN Related Events + CREATE_NEW_CAN = auto() + UPDATE_CAN = auto() + DELETE_CAN = auto() + # Notification Related Events ACKNOWLEDGE_NOTIFICATION = auto() diff --git a/backend/openapi.yml b/backend/openapi.yml index 4d2d9b325e..4db9087697 100644 --- a/backend/openapi.yml +++ b/backend/openapi.yml @@ -218,6 +218,31 @@ paths: responses: "200": description: OK + post: + tags: + - CANs + operationId: createCAN + summary: Create a new CAN object + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateCANRequestSchema" + examples: + "0": + $ref: "#/components/examples/CreateCANRequestSchema" + responses: + "201": + description: Created + content: + application/json: + schema: + $ref: "#/components/schemas/CAN" + "400": + description: Bad Request + "401": + description: Insufficient Privileges to use this endpoint. /api/v1/cans/{can_id}: get: tags: @@ -2033,6 +2058,20 @@ components: type: string id: type: integer + CreateCANRequestSchema: + description: The request object for creating a new Common Accounting Number (CAN) object. + properties: + nick_name: + type: string + number: + type: string + description: + type: string + portfolio_id: + type: integer + required: + - number + - portfolio_id CAN: description: Common Accounting Number (CAN) Object type: object @@ -3584,6 +3623,14 @@ components: "updated_by": 1 } ] + CreateCanRequestSchema: + value: | + { + nick_name: "Very Good CAN", + number: "G998235", + portfolio_id: 6, + description: "A very good CAN to use for examples." + } Notifications: value: | [ diff --git a/backend/ops_api/ops/resources/cans.py b/backend/ops_api/ops/resources/cans.py index 47decbbf1e..ab433318c1 100644 --- a/backend/ops_api/ops/resources/cans.py +++ b/backend/ops_api/ops/resources/cans.py @@ -1,20 +1,21 @@ from dataclasses import dataclass -from typing import List, Optional, cast +from typing import List, Optional import desert from flask import Response, current_app, request from flask_jwt_extended import jwt_required from sqlalchemy import select -from sqlalchemy.orm import InstrumentedAttribute +from models import OpsEventType from models.base import BaseModel from models.cans import CAN 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 CANSchema +from ops_api.ops.schemas.cans import CANSchema, CreateUpdateCANRequestSchema, GetCANListRequestSchema +from ops_api.ops.services.cans import CANService from ops_api.ops.utils.errors import error_simulator -from ops_api.ops.utils.query_helpers import QueryHelper +from ops_api.ops.utils.events import OpsEventHandler from ops_api.ops.utils.response import make_response_with_headers @@ -26,57 +27,87 @@ class ListAPIRequest: class CANItemAPI(BaseItemAPI): def __init__(self, model): super().__init__(model) + self.can_service = CANService() @is_authorized(PermissionType.GET, Permission.CAN) def get(self, id: int) -> Response: schema = CANSchema() - item = self._get_item(id) - - if item: - response = make_response_with_headers(schema.dump(item)) - else: - response = make_response_with_headers({}, 404) - - return response + item = self.can_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 CAN with only the fields provided in the request body. + """ + with OpsEventHandler(OpsEventType.UPDATE_CAN) as meta: + request_data = request.get_json() + # Setting partial to true ignores any missing fields. + schema = CreateUpdateCANRequestSchema(partial=True) + serialized_request = schema.load(request_data) + + updated_can = self.can_service.update(serialized_request, id) + serialized_can = schema.dump(updated_can) + meta.metadata.update({"updated_can": serialized_can}) + return make_response_with_headers(schema.dump(updated_can)) + + @is_authorized(PermissionType.PATCH, Permission.CAN) + def put(self, id: int) -> Response: + """ + Update a CAN with only the fields provided in the request body. + """ + with OpsEventHandler(OpsEventType.UPDATE_CAN) as meta: + request_data = request.get_json() + # Setting partial to true ignores any missing fields. + schema = CreateUpdateCANRequestSchema() + serialized_request = schema.load(request_data) + + updated_can = self.can_service.update(serialized_request, id) + serialized_can = schema.dump(updated_can) + meta.metadata.update({"updated_can": serialized_can}) + return make_response_with_headers(schema.dump(updated_can)) + + @is_authorized(PermissionType.DELETE, Permission.CAN) + 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}) + return make_response_with_headers({"message": "CAN deleted", "id": id}, 200) class CANListAPI(BaseListAPI): def __init__(self, model): super().__init__(model) + self.can_service = CANService() self._get_input_schema = desert.schema(ListAPIRequest) - @staticmethod - def _get_query(search=None): - stmt = select(CAN).order_by(CAN.id) - - query_helper = QueryHelper(stmt) - - if search is not None and len(search) == 0: - query_helper.return_none() - elif search: - query_helper.add_search(cast(InstrumentedAttribute, CAN.number), search) - - stmt = query_helper.get_stmt() - current_app.logger.debug(f"SQL: {stmt}") - - return stmt - @jwt_required() @error_simulator def get(self) -> Response: - errors = self._get_input_schema.validate(request.args) + list_schema = GetCANListRequestSchema() + get_request = list_schema.load(request.args) + result = self.can_service.get_list(**get_request) can_schema = CANSchema() + return make_response_with_headers([can_schema.dump(can) for can in result]) - if errors: - return make_response_with_headers(errors, 400) - - request_data: ListAPIRequest = self._get_input_schema.load(request.args) - stmt = self._get_query(request_data.search) - result = current_app.db_session.execute(stmt).all() - return make_response_with_headers([can_schema.dump(i) for item in result for i in item]) - + @is_authorized(PermissionType.POST, Permission.CAN) def post(self) -> Response: - return "Hello" + """ + Create a new Common Accounting Number (CAN) object. + """ + with OpsEventHandler(OpsEventType.CREATE_NEW_CAN) as meta: + request_data = request.get_json() + schema = CreateUpdateCANRequestSchema() + serialized_request = schema.load(request_data) + + created_can = self.can_service.create(serialized_request) + + can_schema = CANSchema() + serialized_can = can_schema.dump(created_can) + meta.metadata.update({"new_can": serialized_can}) + return make_response_with_headers(serialized_can, 201) class CANsByPortfolioAPI(BaseItemAPI): diff --git a/backend/ops_api/ops/schemas/cans.py b/backend/ops_api/ops/schemas/cans.py index 02101e3b35..216a5e96ba 100644 --- a/backend/ops_api/ops/schemas/cans.py +++ b/backend/ops_api/ops/schemas/cans.py @@ -1,11 +1,23 @@ from marshmallow import Schema, fields -from models import PortfolioStatus +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 +class GetCANListRequestSchema(Schema): + search = fields.String(allow_none=True) + + +class CreateUpdateCANRequestSchema(Schema): + nick_name = fields.String(load_default=None) + number = fields.String(required=True) + description = fields.String(allow_none=True, load_default=None) + portfolio_id = fields.Integer(required=True) + funding_details_id = fields.Integer(allow_none=True, load_default=None) + + class BasicCANSchema(Schema): active_period = fields.Integer(allow_none=True) display_name = fields.String(allow_none=True) @@ -14,6 +26,7 @@ class BasicCANSchema(Schema): description = fields.String(allow_none=True) id = fields.Integer(required=True) portfolio_id = fields.Integer(required=True) + obligate_by = fields.Integer(allow_none=True) projects = fields.List(fields.Nested(ProjectSchema()), default=[]) @@ -51,7 +64,7 @@ class FundingBudgetVersionSchema(Schema): can_id = fields.Integer(required=True) display_name = fields.String(allow_none=True) fiscal_year = fields.Integer(required=True) - id = fields.Integer(required=True) + id = fields.Integer() notes = fields.String(allow_none=True) created_on = fields.DateTime(format="%Y-%m-%dT%H:%M:%S.%fZ", allow_none=True) updated_on = fields.DateTime(format="%Y-%m-%dT%H:%M:%S.%fZ", allow_none=True) @@ -70,7 +83,7 @@ class FundingBudgetSchema(Schema): can_id = fields.Integer(required=True) display_name = fields.String(allow_none=True) fiscal_year = fields.Integer(required=True) - id = fields.Integer(required=True) + id = fields.Integer() notes = fields.String(allow_none=True) versions = fields.List(fields.Nested(FundingBudgetVersionSchema()), default=[]) created_on = fields.DateTime(format="%Y-%m-%dT%H:%M:%S.%fZ", allow_none=True) @@ -90,7 +103,7 @@ class FundingDetailsSchema(Schema): funding_partner = fields.String(allow_none=True) funding_source = fields.String(allow_none=True) id = fields.Integer(required=True) - method_of_transfer = fields.String(allow_none=True) + method_of_transfer = fields.Enum(CANMethodOfTransfer, allow_none=True) sub_allowance = fields.String(allow_none=True) created_on = fields.DateTime(format="%Y-%m-%dT%H:%M:%S.%fZ", allow_none=True) updated_on = fields.DateTime(format="%Y-%m-%dT%H:%M:%S.%fZ", allow_none=True) diff --git a/backend/ops_api/ops/services/cans.py b/backend/ops_api/ops/services/cans.py new file mode 100644 index 0000000000..d8e23a8b9a --- /dev/null +++ b/backend/ops_api/ops/services/cans.py @@ -0,0 +1,103 @@ +from typing import cast + +from flask import current_app +from sqlalchemy import select +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import InstrumentedAttribute +from werkzeug.exceptions import NotFound + +from models import CAN +from ops_api.ops.utils.query_helpers import QueryHelper + + +class CANService: + def _update_fields(self, old_can: CAN, can_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 can_update.items(): + if getattr(old_can, attr) != value: + setattr(old_can, attr, value) + is_changed = True + + return is_changed + + def create(self, create_can_request) -> CAN: + """ + Create a new Common Accounting Number (CAN) object and save it to the database. + """ + new_can = CAN(**create_can_request) + + current_app.db_session.add(new_can) + current_app.db_session.commit() + return new_can + + def update(self, updated_fields, id: int) -> CAN: + """ + Update a CAN with only the provided values in updated_fields. + """ + try: + old_can: CAN = current_app.db_session.execute(select(CAN).where(CAN.id == id)).scalar_one() + + can_was_updated = self._update_fields(old_can, updated_fields) + if can_was_updated: + current_app.db_session.add(old_can) + current_app.db_session.commit() + + return old_can + except NoResultFound: + current_app.logger.exception(f"Could not find a CAN with id {id}") + raise NotFound() + + def delete(self, id: int): + """ + Delete a CAN with given id. Throw a NotFound error if no CAN corresponding to that ID exists.""" + try: + old_can: CAN = current_app.db_session.execute(select(CAN).where(CAN.id == id)).scalar_one() + current_app.db_session.delete(old_can) + current_app.db_session.commit() + except NoResultFound: + current_app.logger.exception(f"Could not find a CAN with id {id}") + raise NotFound() + + def get(self, id: int) -> CAN: + """ + Get an individual CAN by id. + """ + stmt = select(CAN).where(CAN.id == id).order_by(CAN.id) + can = current_app.db_session.scalar(stmt) + + if can: + return can + else: + current_app.logger.exception(f"Could not find a CAN with id {id}") + raise NotFound() + + def get_list(self, search=None) -> list[CAN]: + """ + Get a list of CANs, optionally filtered by a search parameter. + """ + search_query = self._get_query(search) + results = current_app.db_session.execute(search_query).all() + return [can for item in results for can in item] + + @staticmethod + def _get_query(search=None): + """ + Construct a search query that can be used to retrieve a list of CANs. + """ + stmt = select(CAN).order_by(CAN.id) + + query_helper = QueryHelper(stmt) + + if search is not None and len(search) == 0: + query_helper.return_none() + elif search: + query_helper.add_search(cast(InstrumentedAttribute, CAN.number), search) + + stmt = query_helper.get_stmt() + current_app.logger.debug(f"SQL: {stmt}") + + return stmt diff --git a/backend/ops_api/tests/auth_client.py b/backend/ops_api/tests/auth_client.py index 51b80a2920..a08d00da70 100644 --- a/backend/ops_api/tests/auth_client.py +++ b/backend/ops_api/tests/auth_client.py @@ -63,3 +63,67 @@ def open(self, *args, **kwargs): current_app.db_session.commit() return super().open(*args, **kwargs) + + +class BasicUserAuthClient(FlaskClient): + """ + A standard user with User group permissions. Does not have admin permissions. + """ + + def open(self, *args, **kwargs): + user = User( + id="521", + oidc_id="00000000-0000-1111-a111-000000000019", + email="user.demo@email.com", + first_name="User", + last_name="Demo", + division=3, + ) + + additional_claims = {} + if user.roles: + additional_claims["roles"] = [role.name for role in user.roles] + + access_token = create_access_token(identity=user, additional_claims=additional_claims) + refresh_token = create_refresh_token(identity=user) + kwargs.setdefault("headers", {"Authorization": f"Bearer {access_token}"}) + + user_session = _get_or_create_user_session(user, access_token=access_token, refresh_token=refresh_token) + user_session.access_token = access_token + user_session.refresh_token = refresh_token + current_app.db_session.add(user_session) + current_app.db_session.commit() + + return super().open(*args, **kwargs) + + +class BudgetTeamAuthClient(FlaskClient): + """ + A budget team user with relevant role permissions. Does not have admin permissions. + """ + + def open(self, *args, **kwargs): + user = User( + id="523", + oidc_id="00000000-0000-1111-a111-000000000021", + email="budget.team@email.com", + first_name="Budget", + last_name="Team", + division=1, + ) + + additional_claims = {} + if user.roles: + additional_claims["roles"] = [role.name for role in user.roles] + + access_token = create_access_token(identity=user, additional_claims=additional_claims) + refresh_token = create_refresh_token(identity=user) + kwargs.setdefault("headers", {"Authorization": f"Bearer {access_token}"}) + + user_session = _get_or_create_user_session(user, access_token=access_token, refresh_token=refresh_token) + user_session.access_token = access_token + user_session.refresh_token = refresh_token + current_app.db_session.add(user_session) + current_app.db_session.commit() + + return super().open(*args, **kwargs) diff --git a/backend/ops_api/tests/conftest.py b/backend/ops_api/tests/conftest.py index 54b6baf320..d08413f027 100644 --- a/backend/ops_api/tests/conftest.py +++ b/backend/ops_api/tests/conftest.py @@ -15,7 +15,7 @@ from models import CAN, BudgetLineItem, OpsDBHistory, OpsEvent, Project, User, Vendor from ops_api.ops import create_app -from tests.auth_client import AuthClient, NoPermsAuthClient +from tests.auth_client import AuthClient, BasicUserAuthClient, BudgetTeamAuthClient, NoPermsAuthClient @pytest.fixture() @@ -49,6 +49,22 @@ def no_perms_auth_client(app: Flask) -> FlaskClient: # type: ignore [type-arg] return app.test_client() +@pytest.fixture() +def basic_user_auth_client(app: Flask) -> FlaskClient: + """Get a user with just the basic user permissions and not admin perms.""" + app.testing = True + app.test_client_class = BasicUserAuthClient + return app.test_client() + + +@pytest.fixture() +def budget_team_auth_client(app: Flask) -> FlaskClient: + """Get a user with just the budget team permissions and not admin perms.""" + app.testing = True + app.test_client_class = BudgetTeamAuthClient + return app.test_client() + + def is_responsive(db: Engine) -> bool: """Check if the DB is responsive.""" try: @@ -172,6 +188,14 @@ def test_can(loaded_db) -> CAN | None: return loaded_db.get(CAN, 500) +@pytest.fixture() +def unadded_can(): + new_can = CAN( + portfolio_id=6, number="G998235", description="Test CAN created by unit tests", nick_name="My nick name" + ) + return new_can + + @pytest.fixture() def test_bli(loaded_db) -> BudgetLineItem | None: """Get a test BudgetLineItem.""" diff --git a/backend/ops_api/tests/ops/can/test_can.py b/backend/ops_api/tests/ops/can/test_can.py index 1b3525d3ec..3876ae6150 100644 --- a/backend/ops_api/tests/ops/can/test_can.py +++ b/backend/ops_api/tests/ops/can/test_can.py @@ -4,6 +4,7 @@ from sqlalchemy import func, select from models import CAN, BudgetLineItem, CANFundingSource, CANStatus +from ops.services.cans import CANService @pytest.mark.usefixtures("app_ctx") @@ -80,21 +81,39 @@ def test_can_is_inactive(loaded_db, mocker): assert can.status == CANStatus.INACTIVE -def test_can_get_all(auth_client, loaded_db): - count = loaded_db.query(CAN).count() - +@pytest.mark.usefixtures("app_ctx") +def test_can_get_all(auth_client, mocker, test_can): + mocker_get_can = mocker.patch("ops_api.ops.services.cans.CANService.get_list") + mocker_get_can.return_value = [test_can] response = auth_client.get("/api/v1/cans/") assert response.status_code == 200 - assert len(response.json) == count + assert len(response.json) == 1 + mocker_get_can.assert_called_once() + + +def test_service_can_get_all(auth_client, loaded_db): + count = loaded_db.query(CAN).count() + can_service = CANService() + response = can_service.get_list() + assert len(response) == count @pytest.mark.usefixtures("app_ctx") -def test_can_get_by_id(auth_client, loaded_db, test_can): +def test_can_get_by_id(auth_client, mocker, test_can): + mocker_get_can = mocker.patch("ops_api.ops.services.cans.CANService.get") + mocker_get_can.return_value = test_can response = auth_client.get(f"/api/v1/cans/{test_can.id}") assert response.status_code == 200 assert response.json["number"] == "G99HRF2" +def test_can_service_get_by_id(test_can): + service = CANService() + can = service.get(test_can.id) + assert test_can.id == can.id + assert test_can.number == can.number + + @pytest.mark.usefixtures("app_ctx") def test_can_get_portfolio_cans(auth_client, loaded_db): response = auth_client.get("/api/v1/cans/portfolio/1") @@ -118,3 +137,293 @@ def test_get_cans_search_filter(auth_client, loaded_db, test_can): response = auth_client.get("/api/v1/cans/?search=") assert response.status_code == 200 assert len(response.json) == 0 + + +def test_service_get_cans_search_filter(test_can): + can_service = CANService() + response = can_service.get_list("XXX8") + assert len(response) == 1 + assert response[0].id == 512 + + response = can_service.get_list("G99HRF2") + assert len(response) == 1 + assert response[0].id == test_can.id + + response = can_service.get_list("") + assert len(response) == 0 + + +# Testing CAN Creation +@pytest.mark.usefixtures("app_ctx") +def test_can_post_creates_can(budget_team_auth_client, mocker, loaded_db): + input_data = { + "portfolio_id": 6, + "number": "G998235", + "description": "Test CAN Created by unit test", + } + + mock_output_data = CAN(id=517, portfolio_id=6, number="G998235", description="Test CAN Created by unit test") + mocker_create_can = mocker.patch("ops_api.ops.services.cans.CANService.create") + mocker_create_can.return_value = mock_output_data + response = budget_team_auth_client.post("/api/v1/cans/", json=input_data) + + # Add fields that are default populated on load. + input_data["nick_name"] = None + input_data["funding_details_id"] = None + assert response.status_code == 201 + mocker_create_can.assert_called_once_with(input_data) + assert response.json["id"] == mock_output_data.id + assert response.json["portfolio_id"] == mock_output_data.portfolio_id + assert response.json["number"] == mock_output_data.number + assert response.json["description"] == mock_output_data.description + + +@pytest.mark.usefixtures("app_ctx") +def test_basic_user_cannot_post_creates_can(basic_user_auth_client): + data = { + "portfolio_id": 6, + "number": "G998235", + "description": "Test CAN Created by unit test", + } + response = basic_user_auth_client.post("/api/v1/cans/", json=data) + + assert response.status_code == 401 + + +def test_service_create_can(loaded_db): + data = { + "portfolio_id": 6, + "number": "G998235", + "description": "Test CAN Created by unit test", + } + + can_service = CANService() + + new_can = can_service.create(data) + + can = loaded_db.execute(select(CAN).where(CAN.number == "G998235")).scalar_one() + + assert can is not None + assert can.number == "G998235" + assert can.description == "Test CAN Created by unit test" + assert can.portfolio_id == 6 + assert can.id == 517 + assert can == new_can + + loaded_db.delete(new_can) + loaded_db.commit() + + +# Testing updating CANs by PATCH +@pytest.mark.usefixtures("app_ctx") +def test_can_patch(budget_team_auth_client, mocker, unadded_can): + test_can_id = 517 + update_data = { + "description": "Test CAN Created by unit test", + } + + mocker_update_can = mocker.patch("ops_api.ops.services.cans.CANService.update") + unadded_can.description = update_data["description"] + mocker_update_can.return_value = unadded_can + response = budget_team_auth_client.patch(f"/api/v1/cans/{test_can_id}", json=update_data) + + assert response.status_code == 200 + mocker_update_can.assert_called_once_with(update_data, test_can_id) + assert response.json["number"] == unadded_can.number + assert response.json["description"] == unadded_can.description + + +@pytest.mark.usefixtures("app_ctx") +def test_can_patch_404(budget_team_auth_client, mocker, loaded_db, unadded_can): + test_can_id = 518 + update_data = { + "description": "Test CAN Created by unit test", + } + + response = budget_team_auth_client.patch(f"/api/v1/cans/{test_can_id}", json=update_data) + + assert response.status_code == 404 + + +@pytest.mark.usefixtures("app_ctx") +def test_basic_user_cannot_patch_cans(basic_user_auth_client): + data = { + "description": "An updated can description", + } + response = basic_user_auth_client.patch("/api/v1/cans/517", json=data) + + assert response.status_code == 401 + + +def test_service_patch_can(loaded_db): + update_data = { + "description": "Test Test Test", + } + + test_data = { + "portfolio_id": 6, + "number": "G998235", + "description": "Test CAN Created by unit test", + } + + can_service = CANService() + + 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() + + assert can is not None + assert can.number == "G998235" + assert updated_can.number == "G998235" + assert can.description == "Test Test Test" + assert updated_can.description == "Test Test Test" + + loaded_db.delete(new_can) + loaded_db.commit() + + +# Testing updating CANs by PUT +@pytest.mark.usefixtures("app_ctx") +def test_can_put(budget_team_auth_client, mocker, unadded_can): + test_can_id = 517 + update_data = { + "number": "G123456", + "description": "Test CAN Created by unit test", + "portfolio_id": 6, + "funding_details_id": 1, + } + + mocker_update_can = mocker.patch("ops_api.ops.services.cans.CANService.update") + unadded_can.description = update_data["description"] + mocker_update_can.return_value = unadded_can + response = budget_team_auth_client.put(f"/api/v1/cans/{test_can_id}", json=update_data) + + update_data["nick_name"] = None + assert response.status_code == 200 + mocker_update_can.assert_called_once_with(update_data, test_can_id) + assert response.json["number"] == unadded_can.number + assert response.json["description"] == unadded_can.description + + +@pytest.mark.usefixtures("app_ctx") +def test_basic_user_cannot_put_cans(basic_user_auth_client): + data = { + "description": "An updated can description", + } + response = basic_user_auth_client.put("/api/v1/cans/517", json=data) + + assert response.status_code == 401 + + +@pytest.mark.usefixtures("app_ctx") +def test_can_put_404(budget_team_auth_client): + test_can_id = 518 + update_data = { + "number": "G123456", + "description": "Test CAN Created by unit test", + "portfolio_id": 6, + "funding_details_id": 1, + } + + response = budget_team_auth_client.put(f"/api/v1/cans/{test_can_id}", json=update_data) + + assert response.status_code == 404 + + +def test_service_update_can_with_nones(loaded_db): + update_data = { + "nick_name": None, + "number": "G123456", + "description": "Test Test Test", + "portfolio_id": 6, + "funding_details_id": 1, + } + + test_data = { + "portfolio_id": 6, + "number": "G998235", + "nick_name": "My Nickname", + "funding_details_id": 1, + "description": "Test CAN Created by unit test", + } + + can_service = CANService() + + 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() + + assert can is not None + assert can.number == "G123456" + assert updated_can.number == "G123456" + assert can.nick_name is None + assert updated_can.nick_name is None + assert can.portfolio_id == 6 + assert updated_can.portfolio_id == 6 + assert can.description == "Test Test Test" + assert updated_can.description == "Test Test Test" + assert can.funding_details_id == 1 + assert updated_can.funding_details_id == 1 + + loaded_db.delete(new_can) + loaded_db.commit() + + +# Testing updating CANs by PATCH +@pytest.mark.usefixtures("app_ctx") +def test_can_delete(budget_team_auth_client, mocker, unadded_can): + test_can_id = 517 + + mocker_delete_can = mocker.patch("ops_api.ops.services.cans.CANService.delete") + response = budget_team_auth_client.delete(f"/api/v1/cans/{test_can_id}") + + assert response.status_code == 200 + mocker_delete_can.assert_called_once_with(test_can_id) + assert response.json["message"] == "CAN deleted" + assert response.json["id"] == test_can_id + + +@pytest.mark.usefixtures("app_ctx") +def test_can_delete_404(budget_team_auth_client): + test_can_id = 1 + + response = budget_team_auth_client.delete(f"/api/v1/cans/{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/cans/517") + + assert response.status_code == 401 + + +def test_service_delete_can(loaded_db): + + test_data = { + "portfolio_id": 6, + "number": "G998235", + "description": "Test CAN Created by unit test", + } + + can_service = CANService() + + 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) + can = loaded_db.scalar(stmt) + + assert can is None