diff --git a/backend/alembic/versions/2025_01_07_2208-98a16d1ef6be_adding_new_table_for_canhistory.py b/backend/alembic/versions/2025_01_07_2208-98a16d1ef6be_adding_new_table_for_canhistory.py new file mode 100644 index 0000000000..33b8fe4792 --- /dev/null +++ b/backend/alembic/versions/2025_01_07_2208-98a16d1ef6be_adding_new_table_for_canhistory.py @@ -0,0 +1,73 @@ +"""Adding new table for CANHistory + +Revision ID: 98a16d1ef6be +Revises: 6615ac7d4eea +Create Date: 2025-01-07 22:08:04.671935+00:00 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '98a16d1ef6be' +down_revision: Union[str, None] = '6615ac7d4eea' +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! ### + sa.Enum('CAN_DATA_IMPORT', 'CAN_NICKNAME_EDITED', 'CAN_DESCRIPTION_EDITED', 'CAN_FUNDING_CREATED', 'CAN_RECEIVED_CREATED', 'CAN_FUNDING_EDITED', 'CAN_RECEIVED_EDITED', 'CAN_FUNDING_DELETED', 'CAN_RECEIVED_DELETED', 'CAN_PORTFOLIO_CREATED', 'CAN_PORTFOLIO_DELETED', 'CAN_PORTFOLIO_EDITED', 'CAN_DIVISION_CREATED', 'CAN_DIVISION_DELETED', 'CAN_DIVISION_EDITED', 'CAN_CARRY_FORWARD_CALCULATED', name='canhistorytype').create(op.get_bind()) + op.create_table('can_history_version', + sa.Column('id', sa.Integer(), autoincrement=False, nullable=False), + sa.Column('can_id', sa.Integer(), autoincrement=False, nullable=True), + sa.Column('ops_event_id', sa.Integer(), autoincrement=False, nullable=True), + sa.Column('history_title', sa.String(), autoincrement=False, nullable=True), + sa.Column('history_message', sa.Text(), autoincrement=False, nullable=True), + sa.Column('timestamp', sa.String(), autoincrement=False, nullable=True), + sa.Column('history_type', postgresql.ENUM('CAN_DATA_IMPORT', 'CAN_NICKNAME_EDITED', 'CAN_DESCRIPTION_EDITED', 'CAN_FUNDING_CREATED', 'CAN_RECEIVED_CREATED', 'CAN_FUNDING_EDITED', 'CAN_RECEIVED_EDITED', 'CAN_FUNDING_DELETED', 'CAN_RECEIVED_DELETED', 'CAN_PORTFOLIO_CREATED', 'CAN_PORTFOLIO_DELETED', 'CAN_PORTFOLIO_EDITED', 'CAN_DIVISION_CREATED', 'CAN_DIVISION_DELETED', 'CAN_DIVISION_EDITED', 'CAN_CARRY_FORWARD_CALCULATED', name='canhistorytype', create_type=False), autoincrement=False, nullable=True), + sa.Column('created_by', sa.Integer(), autoincrement=False, nullable=True), + sa.Column('updated_by', sa.Integer(), autoincrement=False, nullable=True), + sa.Column('created_on', sa.DateTime(), autoincrement=False, nullable=True), + sa.Column('updated_on', sa.DateTime(), autoincrement=False, nullable=True), + sa.Column('transaction_id', sa.BigInteger(), autoincrement=False, nullable=False), + sa.Column('end_transaction_id', sa.BigInteger(), nullable=True), + sa.Column('operation_type', sa.SmallInteger(), nullable=False), + sa.PrimaryKeyConstraint('id', 'transaction_id') + ) + op.create_index(op.f('ix_can_history_version_end_transaction_id'), 'can_history_version', ['end_transaction_id'], unique=False) + op.create_index(op.f('ix_can_history_version_operation_type'), 'can_history_version', ['operation_type'], unique=False) + op.create_index(op.f('ix_can_history_version_transaction_id'), 'can_history_version', ['transaction_id'], unique=False) + op.create_table('can_history', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('can_id', sa.Integer(), nullable=False), + sa.Column('ops_event_id', sa.Integer(), nullable=False), + sa.Column('history_title', sa.String(), nullable=False), + sa.Column('history_message', sa.Text(), nullable=False), + sa.Column('timestamp', sa.String(), nullable=False), + sa.Column('history_type', postgresql.ENUM('CAN_DATA_IMPORT', 'CAN_NICKNAME_EDITED', 'CAN_DESCRIPTION_EDITED', 'CAN_FUNDING_CREATED', 'CAN_RECEIVED_CREATED', 'CAN_FUNDING_EDITED', 'CAN_RECEIVED_EDITED', 'CAN_FUNDING_DELETED', 'CAN_RECEIVED_DELETED', 'CAN_PORTFOLIO_CREATED', 'CAN_PORTFOLIO_DELETED', 'CAN_PORTFOLIO_EDITED', 'CAN_DIVISION_CREATED', 'CAN_DIVISION_DELETED', 'CAN_DIVISION_EDITED', 'CAN_CARRY_FORWARD_CALCULATED', name='canhistorytype', create_type=False), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.Column('created_on', sa.DateTime(), nullable=True), + sa.Column('updated_on', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['can_id'], ['can.id'], ), + sa.ForeignKeyConstraint(['created_by'], ['ops_user.id'], ), + sa.ForeignKeyConstraint(['ops_event_id'], ['ops_event.id'], ), + sa.ForeignKeyConstraint(['updated_by'], ['ops_user.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('can_history') + op.drop_index(op.f('ix_can_history_version_transaction_id'), table_name='can_history_version') + op.drop_index(op.f('ix_can_history_version_operation_type'), table_name='can_history_version') + op.drop_index(op.f('ix_can_history_version_end_transaction_id'), table_name='can_history_version') + op.drop_table('can_history_version') + sa.Enum('CAN_DATA_IMPORT', 'CAN_NICKNAME_EDITED', 'CAN_DESCRIPTION_EDITED', 'CAN_FUNDING_CREATED', 'CAN_RECEIVED_CREATED', 'CAN_FUNDING_EDITED', 'CAN_RECEIVED_EDITED', 'CAN_FUNDING_DELETED', 'CAN_RECEIVED_DELETED', 'CAN_PORTFOLIO_CREATED', 'CAN_PORTFOLIO_DELETED', 'CAN_PORTFOLIO_EDITED', 'CAN_DIVISION_CREATED', 'CAN_DIVISION_DELETED', 'CAN_DIVISION_EDITED', 'CAN_CARRY_FORWARD_CALCULATED', name='canhistorytype').drop(op.get_bind()) + # ### end Alembic commands ### diff --git a/backend/data_tools/data/can_data.json5 b/backend/data_tools/data/can_data.json5 index 27a16f691b..a908dc4c8e 100644 --- a/backend/data_tools/data/can_data.json5 +++ b/backend/data_tools/data/can_data.json5 @@ -531,4 +531,230 @@ funding: 1000000.0, }, ], + can_history: [ + { // 1 + can_id: 500, + ops_event_id: 1, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 2 + can_id: 501, + ops_event_id: 2, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 3 + can_id: 502, + ops_event_id: 3, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 4 + can_id: 503, + ops_event_id: 4, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 5 + can_id: 504, + ops_event_id: 5, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 6 + can_id: 505, + ops_event_id: 6, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 7 + can_id: 506, + ops_event_id: 7, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 8 + can_id: 507, + ops_event_id: 8, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 9 + can_id: 508, + ops_event_id: 9, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 10 + can_id: 509, + ops_event_id: 10, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 11 + can_id: 510, + ops_event_id: 11, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 12 + can_id: 511, + ops_event_id: 12, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 13 + can_id: 512, + ops_event_id: 13, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 14 + can_id: 513, + ops_event_id: 14, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 15 + can_id: 514, + ops_event_id: 15, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 16 + can_id: 515, + ops_event_id: 16, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 17 + can_id: 516, + ops_event_id: 17, + history_title: "CAN Imported!", + history_message: "CAN Imported by Reed on Wednesdsay January 1st", + timestamp: "2025-01-01T00:07:00.000000Z", + history_type: "CAN_DATA_IMPORT" + }, + { // 18 + can_id: 500, + ops_event_id: 18, + history_title: "Nickname Edited", + history_message: "CAN nickname edited by Director Dave to Test CAN", + timestamp: "2025-01-01T00:08:00.000000Z", + history_type: "CAN_NICKNAME_EDITED" + }, + { // 19 + can_id: 500, + ops_event_id: 19, + history_title: "Description Edited", + history_message: "CAN description edited by Director Dave to Healthy Marriages Responsible Fatherhood - OPRE", + timestamp: "2025-01-01T00:09:00.000000Z", + history_type: "CAN_DESCRIPTION_EDITED" + }, + { // 20 + can_id: 500, + ops_event_id: 20, + history_title: "CAN Funding Created", + history_message: "CAN funding created by Director Dave", + timestamp: "2025-01-01T00:10:00.000000Z", + history_type: "CAN_FUNDING_CREATED" + }, + { // 21 + can_id: 500, + ops_event_id: 21, + history_title: "CAN Received Created", + history_message: "CAN received created by Director Dave", + timestamp: "2025-01-01T00:11:00.000000Z", + history_type: "CAN_RECEIVED_CREATED" + }, + { // 22 + can_id: 500, + ops_event_id: 22, + history_title: "CAN Funding Edited", + history_message: "CAN funding edit by Director Dave", + timestamp: "2025-01-01T00:11:30.000000Z", + history_type: "CAN_FUNDING_EDITED" + }, + { // 23 + can_id: 500, + ops_event_id: 23, + history_title: "CAN Received Edited", + history_message: "CAN funding edit by Director Dave", + timestamp: "2025-01-01T00:12:00.000000Z", + history_type: "CAN_RECEIVED_EDITED" + }, + { // 24 + can_id: 500, + ops_event_id: 24, + history_title: "CAN Funding Deleted", + history_message: "CAN funding deleted by Director Dave", + timestamp: "2025-01-01T00:12:00.000000Z", + history_type: "CAN_FUNDING_DELETED" + }, + { // 25 + can_id: 500, + ops_event_id: 25, + history_title: "CAN Received Deleted", + history_message: "CAN received deleted by Director Dave", + timestamp: "2025-01-01T00:12:30.000000Z", + history_type: "CAN_RECEIVED_DELETED" + }, + { // 26 + can_id: 500, + ops_event_id: 26, + history_title: "CAN Carry Forward Calculated", + history_message: "CAN carry forward amount calculated", + timestamp: "2025-01-01T00:13:00.000000Z", + history_type: "CAN_CARRY_FORWARD_CALCULATED" + }, + { // 27 + can_id: 500, + ops_event_id: 27, + history_title: "CAN Funding Created", + history_message: "CAN funding created by Director Dave", + timestamp: "2025-01-01T00:14:00.000000Z", + history_type: "CAN_FUNDING_CREATED" + }, + { // 28 + can_id: 500, + ops_event_id: 28, + history_title: "CAN Funding Deleted", + history_message: "CAN funding deleted by Director Dave", + timestamp: "2025-01-01T00:15:00.000000Z", + history_type: "CAN_FUNDING_DELETED" + }, + ] } diff --git a/backend/data_tools/data/ops_event.json5 b/backend/data_tools/data/ops_event.json5 new file mode 100644 index 0000000000..f49f798ac9 --- /dev/null +++ b/backend/data_tools/data/ops_event.json5 @@ -0,0 +1,178 @@ +{ + ops_event: [ + { + // 1 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 2 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 3 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 4 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 5 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 6 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 7 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 8 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 8 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 9 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 10 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 11 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 12 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 13 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 14 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 15 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 16 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 17 + event_type: "CREATE_NEW_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 18 + event_type: "UPDATE_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 19 + event_type: "UPDATE_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 20 + event_type: "CREATE_CAN_FUNDING_BUDGET", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 21 + event_type: "CREATE_CAN_FUNDING_RECEIVED", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 22 + event_type: "UPDATE_CAN_FUNDING_BUDGET", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 23 + event_type: "UPDATE_CAN_FUNDING_RECEIVED", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 24 + event_type: "DELETE_CAN_FUNDING_BUDGET", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 25 + event_type: "DELETE_CAN_FUNDING_RECEIVED", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 26 + event_type: "UPDATE_CAN", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 27 + event_type: "CREATE_CAN_FUNDING_BUDGET", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + { + // 28 + event_type: "DELETE_CAN_FUNDING_BUDGET", + event_status: "SUCCESS", + event_details: '{"message": "Hello World"}', + }, + ], +} diff --git a/backend/data_tools/scripts/import_test_data.sh b/backend/data_tools/scripts/import_test_data.sh index cb48fb0fb8..9787f5023f 100755 --- a/backend/data_tools/scripts/import_test_data.sh +++ b/backend/data_tools/scripts/import_test_data.sh @@ -18,6 +18,9 @@ fi echo "Upgrading DB..." alembic upgrade head +echo "Loading 'ops_event.json5'..." +DATA=./data_tools/data/ops_event.json5 python ./data_tools/src/import_static_data/import_data.py + echo "Loading 'user_data.json5'..." DATA=./data_tools/data/user_data.json5 python ./data_tools/src/import_static_data/import_data.py @@ -45,4 +48,5 @@ DATA=./data_tools/data/first_contract_data.json5 python ./data_tools/src/import_ echo "Loading 'agreements_and_blin_data.json5'..." DATA=./data_tools/data/agreements_and_blin_data.json5 python ./data_tools/src/import_static_data/import_data.py + echo "Data Loading Complete!" diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 814e509862..270373a3cf 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -7,6 +7,7 @@ from .change_requests import * from .document import * from .events import * +from .can_history import * from .history import * from .notifications import * from .portfolios import * diff --git a/backend/models/can_history.py b/backend/models/can_history.py new file mode 100644 index 0000000000..8c4a600c02 --- /dev/null +++ b/backend/models/can_history.py @@ -0,0 +1,46 @@ +from enum import Enum, auto + +from sqlalchemy import ForeignKey, Integer, Text +from sqlalchemy.dialects.postgresql import ENUM +from sqlalchemy.orm import Mapped, mapped_column + +from models.base import BaseModel + + +class CANHistoryType(Enum): + """The type of history event being described by a CANHistoryModel + """ + + CAN_DATA_IMPORT = auto() + CAN_NICKNAME_EDITED = auto() + CAN_DESCRIPTION_EDITED = auto() + CAN_FUNDING_CREATED = auto() + CAN_RECEIVED_CREATED = auto() + CAN_FUNDING_EDITED = auto() + CAN_RECEIVED_EDITED = auto() + CAN_FUNDING_DELETED = auto() + CAN_RECEIVED_DELETED = auto() + CAN_PORTFOLIO_CREATED = auto() + CAN_PORTFOLIO_DELETED = auto() + CAN_PORTFOLIO_EDITED = auto() + CAN_DIVISION_CREATED = auto() + CAN_DIVISION_DELETED = auto() + CAN_DIVISION_EDITED = auto() + CAN_CARRY_FORWARD_CALCULATED = auto() + +class CANHistory(BaseModel): + __tablename__ = "can_history" + + id: Mapped[int] = BaseModel.get_pk_column() + can_id: Mapped[int] = mapped_column( + Integer, ForeignKey("can.id") + ) + ops_event_id: Mapped[int] = mapped_column( + Integer, ForeignKey("ops_event.id") + ) + history_title: Mapped[str] + history_message: Mapped[str] = mapped_column(Text) + timestamp: Mapped[str] + history_type: Mapped[CANHistoryType] = mapped_column( + ENUM(CANHistoryType), nullable=True + ) diff --git a/backend/ops_api/ops/resources/can_history.py b/backend/ops_api/ops/resources/can_history.py new file mode 100644 index 0000000000..5bbcf58eb5 --- /dev/null +++ b/backend/ops_api/ops/resources/can_history.py @@ -0,0 +1,23 @@ +from flask import Response, request +from flask_jwt_extended import jwt_required + +from ops_api.ops.base_views import BaseListAPI +from ops_api.ops.schemas.can_history import CANHistoryItemSchema, GetHistoryListQueryParametersSchema +from ops_api.ops.services.can_history import CANHistoryService +from ops_api.ops.utils.errors import error_simulator +from ops_api.ops.utils.response import make_response_with_headers + + +class CANHistoryListAPI(BaseListAPI): + def __init__(self, model): + super().__init__(model) + self.service = CANHistoryService() + self._get_schema = GetHistoryListQueryParametersSchema() + + @jwt_required() + @error_simulator + def get(self) -> Response: + data = self._get_schema.dump(self._get_schema.load(request.args)) + result = self.service.get(data.get("can_id"), data.get("limit"), data.get("offset")) + can_history_schema = CANHistoryItemSchema() + return make_response_with_headers([can_history_schema.dump(funding_budget) for funding_budget in result]) diff --git a/backend/ops_api/ops/schemas/can_history.py b/backend/ops_api/ops/schemas/can_history.py new file mode 100644 index 0000000000..4ae6e123c2 --- /dev/null +++ b/backend/ops_api/ops/schemas/can_history.py @@ -0,0 +1,22 @@ +from marshmallow import EXCLUDE, Schema, fields +from marshmallow.validate import Range +from models import CANHistoryType + + +class CANHistoryItemSchema(Schema): + id = fields.Integer(required=True) + can_id = fields.Integer(required=True) + ops_event_id = fields.Integer(required=True) + history_title = fields.String(required=True) + history_message = fields.String(required=True) + timestamp = fields.String(required=True) + history_type = fields.Enum(CANHistoryType) + + +class GetHistoryListQueryParametersSchema(Schema): + class Meta: + unknown = EXCLUDE # Exclude unknown fields + + can_id = fields.Integer(required=True) + limit = fields.Integer(default=10, validate=Range(min=1, error="Limit must be greater than 0"), allow_none=True) + offset = fields.Integer(default=0, validate=Range(min=0, error="Limit must be greater than 0"), allow_none=True) diff --git a/backend/ops_api/ops/services/can_history.py b/backend/ops_api/ops/services/can_history.py new file mode 100644 index 0000000000..3e745fd6e3 --- /dev/null +++ b/backend/ops_api/ops/services/can_history.py @@ -0,0 +1,14 @@ +from flask import current_app +from sqlalchemy import select + +from models import CANHistory + + +class CANHistoryService: + def get(self, can_id, limit, offset) -> list[CANHistory]: + """ + Get a list of CAN History items for an individual can. + """ + stmt = select(CANHistory).where(CANHistory.can_id == can_id).order_by(CANHistory.id).offset(offset).limit(limit) + results = current_app.db_session.execute(stmt).all() + return [can_history for result in results for can_history in result] diff --git a/backend/ops_api/ops/urls.py b/backend/ops_api/ops/urls.py index 79a615651e..7be567de42 100644 --- a/backend/ops_api/ops/urls.py +++ b/backend/ops_api/ops/urls.py @@ -18,6 +18,7 @@ CAN_FUNDING_RECEIVED_ITEM_API_VIEW_FUNC, CAN_FUNDING_RECEIVED_LIST_API_VIEW_FUNC, CAN_FUNDING_SUMMARY_LIST_API_VIEW_FUNC, + CAN_HISTORY_LIST_API_VIEW_FUNC, CAN_ITEM_API_VIEW_FUNC, CAN_LIST_API_VIEW_FUNC, CANS_BY_PORTFOLIO_API_VIEW_FUNC, @@ -175,6 +176,8 @@ def register_api(api_bp: Blueprint) -> None: "/can-funding-summary", view_func=CAN_FUNDING_SUMMARY_LIST_API_VIEW_FUNC, ) + + api_bp.add_url_rule("/can-history/", view_func=CAN_HISTORY_LIST_API_VIEW_FUNC) api_bp.add_url_rule( "/portfolio-funding-summary/", view_func=PORTFOLIO_FUNDING_SUMMARY_ITEM_API_VIEW_FUNC, diff --git a/backend/ops_api/ops/views.py b/backend/ops_api/ops/views.py index 89b5647a43..aa876f952f 100644 --- a/backend/ops_api/ops/views.py +++ b/backend/ops_api/ops/views.py @@ -9,6 +9,7 @@ CANFundingBudget, CANFundingDetails, CANFundingReceived, + CANHistory, ChangeRequest, ContractAgreement, Division, @@ -48,6 +49,7 @@ from ops_api.ops.resources.can_funding_details import CANFundingDetailsItemAPI, CANFundingDetailsListAPI from ops_api.ops.resources.can_funding_received import CANFundingReceivedItemAPI, CANFundingReceivedListAPI from ops_api.ops.resources.can_funding_summary import CANFundingSummaryListAPI +from ops_api.ops.resources.can_history import CANHistoryListAPI from ops_api.ops.resources.cans import CANItemAPI, CANListAPI, CANsByPortfolioAPI from ops_api.ops.resources.change_requests import ChangeRequestListAPI, ChangeRequestReviewAPI from ops_api.ops.resources.contract import ContractItemAPI, ContractListAPI @@ -110,6 +112,7 @@ CAN_FUNDING_RECEIVED_ITEM_API_VIEW_FUNC = CANFundingReceivedItemAPI.as_view( "can-funding-received-item", CANFundingReceived ) +CAN_HISTORY_LIST_API_VIEW_FUNC = CANHistoryListAPI.as_view("can-history-group", CANHistory) # BUDGET LINE ITEM ENDPOINTS BUDGET_LINE_ITEMS_ITEM_API_VIEW_FUNC = BudgetLineItemsItemAPI.as_view("budget-line-items-item", BudgetLineItem) diff --git a/backend/ops_api/tests/conftest.py b/backend/ops_api/tests/conftest.py index e6d880b9d4..2d800a75aa 100644 --- a/backend/ops_api/tests/conftest.py +++ b/backend/ops_api/tests/conftest.py @@ -22,7 +22,6 @@ CANFundingDetails, ChangeRequestStatus, OpsDBHistory, - OpsEvent, Project, User, Vendor, @@ -185,7 +184,6 @@ def loaded_db(app: Flask, app_ctx: None, auth_client: FlaskClient) -> Session: session.rollback() session.execute(delete(OpsDBHistory)) - session.execute(delete(OpsEvent)) session.commit() session.close() diff --git a/backend/ops_api/tests/docker-compose.yml b/backend/ops_api/tests/docker-compose.yml index 1e18698e51..1d03533a4a 100644 --- a/backend/ops_api/tests/docker-compose.yml +++ b/backend/ops_api/tests/docker-compose.yml @@ -31,6 +31,7 @@ services: /bin/sh -c " . .venv/bin/activate && alembic upgrade head && + DATA=./data_tools/data/ops_event.json5 python ./data_tools/src/import_static_data/import_data.py && DATA=./data_tools/data/user_data.json5 python ./data_tools/src/import_static_data/import_data.py && DATA=./data_tools/data/vendor_and_contact_data.json5 python ./data_tools/src/import_static_data/import_data.py && DATA=./data_tools/data/portfolio_data.json5 python ./data_tools/src/import_static_data/import_data.py && diff --git a/backend/ops_api/tests/ops/can_history/test_can_history.py b/backend/ops_api/tests/ops/can_history/test_can_history.py new file mode 100644 index 0000000000..356b7d9686 --- /dev/null +++ b/backend/ops_api/tests/ops/can_history/test_can_history.py @@ -0,0 +1,133 @@ +import pytest + +from models import CANHistory, CANHistoryType +from ops.services.can_history import CANHistoryService + +# from sqlalchemy import select + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history(loaded_db): + test_can_id = 500 + count = loaded_db.query(CANHistory).where(CANHistory.can_id == test_can_id).count() + can_history_service = CANHistoryService() + # Set a limit higher than our test data so we can get all results + response = can_history_service.get(test_can_id, 1000, 0) + assert len(response) == count + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_custom_length(loaded_db): + test_can_id = 500 + test_limit = 5 + can_history_service = CANHistoryService() + # Set a limit higher than our test data so we can get all results + response = can_history_service.get(test_can_id, test_limit, 0) + assert len(response) == test_limit + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_custom_offset(loaded_db): + test_can_id = 500 + can_history_service = CANHistoryService() + # Set a limit higher than our test data so we can get all results + response = can_history_service.get(test_can_id, 5, 1) + offset_first_CAN = response[0] + offset_second_CAN = response[1] + # The CAN with ID 500 has ops events with id 1, then starting at ops event id 18 and moving forward + # Therefore, we expect the ops_event_id to be 18 for the first item in the list offset by 1 + assert offset_first_CAN.ops_event_id == 18 + assert offset_first_CAN.history_type == CANHistoryType.CAN_NICKNAME_EDITED + assert offset_second_CAN.ops_event_id == 19 + assert offset_second_CAN.history_type == CANHistoryType.CAN_DESCRIPTION_EDITED + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_nonexistent_can(): + test_can_id = 300 + can_history_service = CANHistoryService() + # Try to get a non-existent CAN and return an empty result instead of throwing any errors. + response = can_history_service.get(test_can_id, 10, 0) + assert len(response) == 0 + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_list_from_api(auth_client, mocker): + test_can_id = 500 + mock_can_history_list = [] + for x in range(1, 11): + # These should be CanHistoryItem objects and not JSON objects ****** + mock_can_history_list.append( + CANHistory( + can_id=500, + ops_event_id=x, + history_title="CAN Imported!", + history_message="CAN Imported by Reed on Wednesdsay January 1st", + timestamp="2025-01-01T00:07:00.000000Z", + history_type=CANHistoryType.CAN_DATA_IMPORT, + ) + ) + + mocker_get_can_history = mocker.patch("ops_api.ops.services.can_history.CANHistoryService.get") + mocker_get_can_history.return_value = mock_can_history_list + + response = auth_client.get(f"/api/v1/can-history/?can_id={test_can_id}") + assert response.status_code == 200 + assert len(response.json) == 10 + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_list_from_api_with_params(auth_client, mocker): + test_can_id = 500 + mock_can_history_list = [] + for x in range(1, 6): + # These should be CanHistoryItem objects and not JSON objects ****** + mock_can_history_list.append( + CANHistory( + can_id=500, + ops_event_id=x, + history_title="CAN Imported!", + history_message="CAN Imported by Reed on Wednesdsay January 1st", + timestamp="2025-01-01T00:07:00.000000Z", + history_type=CANHistoryType.CAN_DATA_IMPORT, + ) + ) + + mocker_get_can_history = mocker.patch("ops_api.ops.services.can_history.CANHistoryService.get") + mocker_get_can_history.return_value = mock_can_history_list + + response = auth_client.get(f"/api/v1/can-history/?can_id={test_can_id}&limit=5&offset=1") + assert response.status_code == 200 + assert len(response.json) == 5 + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_list_from_api_with_bad_limit(auth_client): + test_can_id = 500 + response = auth_client.get(f"/api/v1/can-history/?can_id={test_can_id}&limit=0") + assert response.status_code == 400 + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_list_from_api_with_bad_offset(auth_client): + test_can_id = 500 + response = auth_client.get(f"/api/v1/can-history/?can_id={test_can_id}&offset=-1") + assert response.status_code == 400 + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_list_from_api_with_no_can_id(auth_client): + response = auth_client.get("/api/v1/can-history/") + assert response.status_code == 400 + + +@pytest.mark.usefixtures("app_ctx") +def test_get_can_history_list_from_api_with_nonexistent_can(auth_client, mocker): + test_can_id = 400 + mock_can_history_list = [] + mocker_get_can_history = mocker.patch("ops_api.ops.services.can_history.CANHistoryService.get") + mocker_get_can_history.return_value = mock_can_history_list + + response = auth_client.get(f"/api/v1/can-history/?can_id={test_can_id}&limit=5&offset=1") + assert response.status_code == 200 + assert len(response.json) == 0 diff --git a/backend/ops_api/tests/ops/history/test_history.py b/backend/ops_api/tests/ops/history/test_history.py index 98013d2ddd..5f45e665bd 100644 --- a/backend/ops_api/tests/ops/history/test_history.py +++ b/backend/ops_api/tests/ops/history/test_history.py @@ -235,7 +235,7 @@ def test_history_expanded_with_web_client(auth_client, loaded_db, test_user, tes "class_name,row_key,expected_status", [ (None, None, 404), - ("BudgetLineItem", "21", 404), # Something that doesn't exist in the history. + ("BudgetLineItem", "31", 404), # Something that doesn't exist in the history. ], ) @pytest.mark.usefixtures("app_ctx")