diff --git a/rapid/master/data/migrations/versions/d5c1f4b7a9e0_pipeline_action_conditions.py b/rapid/master/data/migrations/versions/d5c1f4b7a9e0_pipeline_action_conditions.py new file mode 100644 index 0000000..b604af6 --- /dev/null +++ b/rapid/master/data/migrations/versions/d5c1f4b7a9e0_pipeline_action_conditions.py @@ -0,0 +1,78 @@ +""" +Add PipelineActionCondition and ActionInstanceCondition tables + +Revision ID: d5c1f4b7a9e0 +Revises: cf1a4a9becad +Create Date: 2025-09-24 09:58:00 + +""" + +# revision identifiers, used by Alembic. +revision = 'd5c1f4b7a9e0' +down_revision = 'cf1a4a9becad' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + 'pipeline_action_conditions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('pipeline_id', sa.Integer(), nullable=False), + sa.Column('action_id', sa.Integer(), nullable=False), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.Column('expression', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['action_id'], ['actions.id'], ), + sa.ForeignKeyConstraint(['pipeline_id'], ['pipelines.id'], ), + sa.ForeignKeyConstraint(['status_id'], ['statuses.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('pipeline_id', 'action_id', name='uq_pipeline_action_condition'), + mysql_engine='InnoDB' + ) + op.create_index(op.f('ix_pipeline_action_conditions_action_id'), 'pipeline_action_conditions', ['action_id'], unique=False) + op.create_index(op.f('ix_pipeline_action_conditions_id'), 'pipeline_action_conditions', ['id'], unique=False) + op.create_index(op.f('ix_pipeline_action_conditions_pipeline_id'), 'pipeline_action_conditions', ['pipeline_id'], unique=False) + op.create_index(op.f('ix_pipeline_action_conditions_status_id'), 'pipeline_action_conditions', ['status_id'], unique=False) + + op.create_table( + 'action_instance_conditions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('action_instance_id', sa.Integer(), nullable=False), + sa.Column('pipeline_id', sa.Integer(), nullable=False), + sa.Column('action_id', sa.Integer(), nullable=False), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.Column('expression', sa.Text(), nullable=False), + sa.ForeignKeyConstraint(['action_id'], ['actions.id'], ), + sa.ForeignKeyConstraint(['action_instance_id'], ['action_instances.id'], ), + sa.ForeignKeyConstraint(['pipeline_id'], ['pipelines.id'], ), + sa.ForeignKeyConstraint(['status_id'], ['statuses.id'], ), + sa.PrimaryKeyConstraint('id'), + mysql_engine='InnoDB' + ) + op.create_index(op.f('ix_action_instance_conditions_action_id'), 'action_instance_conditions', ['action_id'], unique=False) + op.create_index(op.f('ix_action_instance_conditions_action_instance_id'), 'action_instance_conditions', ['action_instance_id'], unique=False) + op.create_index(op.f('ix_action_instance_conditions_id'), 'action_instance_conditions', ['id'], unique=False) + op.create_index(op.f('ix_action_instance_conditions_pipeline_id'), 'action_instance_conditions', ['pipeline_id'], unique=False) + op.create_index(op.f('ix_action_instance_conditions_status_id'), 'action_instance_conditions', ['status_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_action_instance_conditions_status_id'), table_name='action_instance_conditions') + op.drop_index(op.f('ix_action_instance_conditions_pipeline_id'), table_name='action_instance_conditions') + op.drop_index(op.f('ix_action_instance_conditions_id'), table_name='action_instance_conditions') + op.drop_index(op.f('ix_action_instance_conditions_action_instance_id'), table_name='action_instance_conditions') + op.drop_index(op.f('ix_action_instance_conditions_action_id'), table_name='action_instance_conditions') + op.drop_table('action_instance_conditions') + + op.drop_index(op.f('ix_pipeline_action_conditions_status_id'), table_name='pipeline_action_conditions') + op.drop_index(op.f('ix_pipeline_action_conditions_pipeline_id'), table_name='pipeline_action_conditions') + op.drop_index(op.f('ix_pipeline_action_conditions_id'), table_name='pipeline_action_conditions') + op.drop_index(op.f('ix_pipeline_action_conditions_action_id'), table_name='pipeline_action_conditions') + op.drop_table('pipeline_action_conditions') + # ### end Alembic commands ### diff --git a/rapid/workflow/data/models.py b/rapid/workflow/data/models.py index d2828a3..9df1389 100644 --- a/rapid/workflow/data/models.py +++ b/rapid/workflow/data/models.py @@ -17,7 +17,7 @@ import datetime from sqlalchemy.orm import relationship -from sqlalchemy import Column, String, ForeignKey, Integer, Boolean, Text +from sqlalchemy import Column, String, ForeignKey, Integer, Boolean, Text, UniqueConstraint from rapid.lib import get_declarative_base from rapid.lib.constants import StatusConstants @@ -230,4 +230,29 @@ class PipelineEvent(BaseModel, Base): conditional = Column(Text, nullable=False) config = Column(Text, nullable=False) +class PipelineActionCondition(BaseModel, Base): + pipeline_id = Column(Integer, ForeignKey('pipelines.id'), nullable=False, index=True) + action_id = Column(Integer, ForeignKey('actions.id'), nullable=False, index=True) + status_id = Column(Integer, ForeignKey('statuses.id'), nullable=False, index=True) + expression = Column(Text, nullable=False) + + pipeline = relationship('Pipeline') + action = relationship('Action') + status = relationship('Status') + + __table_args__ = ( + UniqueConstraint('pipeline_id', 'action_id', name='uq_pipeline_action_condition'), + ) + +class ActionInstanceCondition(BaseModel, Base): + action_instance_id = Column(Integer, ForeignKey('action_instances.id'), nullable=False, index=True) + pipeline_id = Column(Integer, ForeignKey('pipelines.id'), nullable=False, index=True) + action_id = Column(Integer, ForeignKey('actions.id'), nullable=False, index=True) + status_id = Column(Integer, ForeignKey('statuses.id'), nullable=False, index=True) + expression = Column(Text, nullable=False) + + action_instance = relationship('ActionInstance') + pipeline = relationship('Pipeline') + action = relationship('Action') + status = relationship('Status') \ No newline at end of file diff --git a/tests/workflow/test_conditions_endpoints.py b/tests/workflow/test_conditions_endpoints.py new file mode 100644 index 0000000..18bf806 --- /dev/null +++ b/tests/workflow/test_conditions_endpoints.py @@ -0,0 +1,133 @@ +import json +from unittest.mock import MagicMock, patch + +from rapid.workflow.api_controller import APIRouter +from tests.framework.unit_test import UnitTest + + +class TestPipelineActionConditionsEndpoints(UnitTest): + def setUp(self): + self.controller = self.fill_object_with_mocks(APIRouter) + self.request = MagicMock() + self.controller.http_wrapper.current_request.return_value = self.request + + self.fake_session = MagicMock() + self.get_db_session_patcher = patch('rapid.workflow.api_controller.get_db_session', return_value=[self.fake_session]) + self.get_db_session_patcher.start() + self.addCleanup(self.get_db_session_patcher.stop) + self.controller._load_objects() + self.dal = MagicMock() + + def test_endpoints_are_discoverable(self): + assert self.controller._is_valid('pipeline_action_conditions') + assert self.controller._is_valid('action_instance_conditions') + + def test_metadata_available_for_new_models(self): + resp = self.controller.metadata('pipeline_action_conditions') + data = json.loads(resp.get_data(as_text=True)) + + assert 'pipeline_id' in data + assert 'action_id' in data + assert 'status_id' in data + assert 'expression' in data + + resp = self.controller.metadata('action_instance_conditions') + + data = json.loads(resp.get_data(as_text=True)) + assert 'action_instance_id' in data + assert 'pipeline_id' in data + assert 'action_id' in data + assert 'status_id' in data + assert 'expression' in data + + @patch.object(APIRouter, APIRouter._retrieve_dal.__name__) + def test_pipeline_action_conditions_create(self, mock_retrieve_dal): + body = {"pipeline_id": 1, "action_id": 2, "status_id": 9, "expression": "x == 1"} + self.request.get_json.return_value = body + + self.dal.create_object.return_value = {"id": 123, **body} + mock_retrieve_dal.return_value = self.dal + + resp = self.controller.create('pipeline_action_conditions') + + data = json.loads(resp.get_data(as_text=True)) + assert data['id'] == 123 + assert data['pipeline_id'] == 1 + self.dal.create_object.assert_called() + + @patch.object(APIRouter, APIRouter._retrieve_dal.__name__) + def test_pipeline_action_conditions_edit(self, mock_retrieve_dal): + self.request.json = {"expression": "x == 2"} + + instance = MagicMock() + instance.serialize.return_value = {"id": 123, "expression": "x == 2"} + self.dal.edit_object.return_value = instance + mock_retrieve_dal.return_value = self.dal + + resp = self.controller.edit_object('pipeline_action_conditions', 123) + + data = json.loads(resp.get_data(as_text=True)) + assert data['id'] == 123 + assert data['expression'] == 'x == 2' + self.dal.edit_object.assert_called() + + @patch.object(APIRouter, APIRouter._retrieve_dal.__name__) + def test_pipeline_action_conditions_delete(self, mock_retrieve_dal): + instance = MagicMock() + instance.serialize.return_value = {"id": 123} + self.dal.delete_object.return_value = instance + mock_retrieve_dal.return_value = self.dal + + resp = self.controller.delete_object('pipeline_action_conditions', 123) + + data = json.loads(resp.get_data(as_text=True)) + assert data['id'] == 123 + self.dal.delete_object.assert_called() + + @patch.object(APIRouter, APIRouter._retrieve_dal.__name__) + def test_action_instance_conditions_create(self, mock_retrieve_dal): + body = {"action_instance_id": 1, "pipeline_id": 2, "action_id": 3, "status_id": 9, "expression": "x == 1"} + self.request.get_json.return_value = body + + self.dal.create_object.return_value = {"id": 456, **body} + mock_retrieve_dal.return_value = self.dal + + resp = self.controller.create('action_instance_conditions') + + data = json.loads(resp.get_data(as_text=True)) + assert data['id'] == 456 + assert data['action_instance_id'] == 1 + assert data['pipeline_id'] == 2 + assert data['action_id'] == 3 + assert data['status_id'] == 9 + assert data['expression'] == 'x == 1' + self.dal.create_object.assert_called() + + @patch.object(APIRouter, APIRouter._retrieve_dal.__name__) + def test_action_instance_conditions_edit(self, mock_retrieve_dal): + self.request.json = {"expression": "x == 3"} + + instance = MagicMock() + instance.serialize.return_value = {"id": 456, "expression": "x == 3"} + self.dal.edit_object.return_value = instance + mock_retrieve_dal.return_value = self.dal + + resp = self.controller.edit_object('action_instance_conditions', 456) + + data = json.loads(resp.get_data(as_text=True)) + assert data['id'] == 456 + assert data['expression'] == 'x == 3' + self.dal.edit_object.assert_called() + + @patch.object(APIRouter, APIRouter._retrieve_dal.__name__) + def test_action_instance_conditions_delete(self, mock_retrieve_dal): + instance = MagicMock() + instance.serialize.return_value = {"id": 456} + self.dal.delete_object.return_value = instance + mock_retrieve_dal.return_value = self.dal + + resp = self.controller.delete_object('action_instance_conditions', 456) + + data = json.loads(resp.get_data(as_text=True)) + assert data['id'] == 456 + self.dal.delete_object.assert_called()