diff --git a/indico/queries/workflow_components.py b/indico/queries/workflow_components.py index a41bb7b9..4b4c86e5 100644 --- a/indico/queries/workflow_components.py +++ b/indico/queries/workflow_components.py @@ -11,6 +11,7 @@ NewQuestionnaireArguments, Workflow, ) +from indico.types.workflow import ComponentValidationResult if TYPE_CHECKING: # pragma: no cover from typing import Any, Iterator, List, Optional, Union @@ -538,3 +539,220 @@ def requests( workflow_id=self.workflow_id, component=self.component, ) + + +class ValidateComponentUpdate(GraphQLRequest["ComponentValidationResult"]): + """ + Validate a component update before applying it. + Returns information about components that will be deleted, links that will be + removed/updated/added as a result of the update. + + Args: + component_id(int): the id of the component to validate. + workflow_id(int): the id of the workflow containing the component. + component(dict): the component data with config to validate. + + Returns: + ComponentValidationResult: validation result containing: + - valid: whether the update is valid + - components_to_delete: list of components that will be deleted + - links_to_remove: list of links that will be removed + - links_to_update: list of links that will be updated + - links_to_add: list of links that will be added + """ + + query = """ + query validateComponentUpdate( + $componentId: Int!, + $workflowId: Int!, + $component: JSONString! + ) { + validateComponentUpdate( + componentId: $componentId, + workflowId: $workflowId, + component: $component + ) { + valid + componentsToDelete { + id + name + componentType + modelGroupName + reason + } + linksToRemove { + id + headId + tailId + config + } + linksToUpdate { + id + headId + tailId + config + } + linksToAdd { + headId + tailId + config + } + } + } + """ + + def __init__( + self, + component_id: int, + workflow_id: int, + component: "AnyDict", + ): + super().__init__( + self.query, + variables={ + "componentId": component_id, + "workflowId": workflow_id, + "component": jsons.dumps(component), + }, + ) + + def process_response(self, response: "Payload") -> "ComponentValidationResult": + return ComponentValidationResult( + **super().parse_payload(response)["validateComponentUpdate"] + ) + + +class _UpdateComponent(GraphQLRequest["Workflow"]): + query = """ + mutation updateComponent( + $componentId: Int!, + $workflowId: Int!, + $component: JSONString! + ) { + updateComponent( + componentId: $componentId, + workflowId: $workflowId, + component: $component + ) { + workflow { + id + name + status + reviewEnabled + autoReviewEnabled + createdAt + createdBy + submissionRunnable + components { + id + componentType + reviewable + filteredClasses + ... on ContentLengthComponent { + minimum + maximum + } + ... on ModelGroupComponent { + taskType + modelType + modelGroup { + status + id + name + taskType + questionnaireId + classNames + selectedModel { + id + } + } + } + } + componentLinks { + id + headComponentId + tailComponentId + config + filters { + classes + } + } + datasetId + } + } + } + """ + + def __init__( + self, + component_id: int, + workflow_id: int, + component: "AnyDict", + ): + super().__init__( + self.query, + variables={ + "componentId": component_id, + "workflowId": workflow_id, + "component": jsons.dumps(component), + }, + ) + + def process_response(self, response: "Payload") -> "Workflow": + return Workflow( + **super().parse_payload(response)["updateComponent"]["workflow"] + ) + + +class UpdateComponent(RequestChain["Workflow"]): + """ + Update a component in a workflow. + + Args: + component_id(int): the id of the component to update. + workflow_id(int): the id of the workflow containing the component. + component(dict): the component data with config to update. + auto_validate(bool): if True, validates the update first and raises + IndicoInputError if validation fails. Defaults to False. + + Returns: + Workflow: the updated workflow. + """ + + previous: "Any" = None + + def __init__( + self, + component_id: int, + workflow_id: int, + component: "AnyDict", + auto_validate: bool = True, + ): + self.component_id = component_id + self.workflow_id = workflow_id + self.component = component + self.auto_validate = auto_validate + + def requests( + self, + ) -> "Iterator[Union[ValidateComponentUpdate, _UpdateComponent]]": + if self.auto_validate: + yield ValidateComponentUpdate( + component_id=self.component_id, + workflow_id=self.workflow_id, + component=self.component, + ) + if not self.previous.valid: + raise IndicoInputError( + "Component update validation failed. " + f"Components to delete: {len(self.previous.components_to_delete)}, " + f"Links to remove: {len(self.previous.links_to_remove)}, " + f"Links to update: {len(self.previous.links_to_update)}, " + f"Links to add: {len(self.previous.links_to_add)}" + ) + + yield _UpdateComponent( + component_id=self.component_id, + workflow_id=self.workflow_id, + component=self.component, + ) diff --git a/indico/types/workflow.py b/indico/types/workflow.py index 6b9f3685..962682da 100644 --- a/indico/types/workflow.py +++ b/indico/types/workflow.py @@ -9,6 +9,40 @@ from indico.typing import AnyDict +class ComponentToDeleteInfo(BaseType): + id: int + name: str + component_type: str + model_group_name: str + reason: str + + +class LinkInfoBase(BaseType): + head_id: int + tail_id: int + config: JSONType + + +class LinkToAddInfo(LinkInfoBase): + pass + + +class LinkToRemoveInfo(LinkInfoBase): + id: int + + +class LinkToUpdateInfo(LinkInfoBase): + id: int + + +class ComponentValidationResult(BaseType): + valid: bool + components_to_delete: List[ComponentToDeleteInfo] + links_to_remove: List[LinkToRemoveInfo] + links_to_update: List[LinkToUpdateInfo] + links_to_add: List[LinkToAddInfo] + + class WorkflowComponent(BaseType): """ A component, such as a Model Group or Content Length filter, that is present on a workflow. diff --git a/tests/unit/queries/test_update_component.py b/tests/unit/queries/test_update_component.py new file mode 100644 index 00000000..0ed2c9f1 --- /dev/null +++ b/tests/unit/queries/test_update_component.py @@ -0,0 +1,269 @@ +import json + +import pytest + +from indico.errors import IndicoInputError +from indico.queries.workflow_components import ( + UpdateComponent, + ValidateComponentUpdate, + _UpdateComponent, +) +from indico.types.workflow import ( + ComponentToDeleteInfo, + ComponentValidationResult, + LinkToAddInfo, + LinkToRemoveInfo, + LinkToUpdateInfo, +) + + +class TestComponentValidationResultType: + def test_component_validation_result_from_dict(self): + data = { + "valid": True, + "componentsToDelete": [ + { + "id": 7, + "name": "Downstream Component", + "componentType": "MODEL_GROUP", + "modelGroupName": "Downstream Model Group", + "reason": "Downstream of removed filter link", + } + ], + "linksToRemove": [ + { + "id": 3, + "headId": 5, + "tailId": 6, + "config": '{"filters": {"field": {"id": 100}}}', + } + ], + "linksToUpdate": [ + { + "id": 4, + "headId": 5, + "tailId": 7, + "config": '{"filters": {"field": {"id": 101}}}', + } + ], + "linksToAdd": [ + { + "headId": 5, + "tailId": 8, + "config": '{"filters": {"field": {"id": 102}}}', + } + ], + } + + result = ComponentValidationResult(**data) + + assert result.valid is True + assert len(result.components_to_delete) == 1 + assert len(result.links_to_remove) == 1 + assert len(result.links_to_update) == 1 + assert len(result.links_to_add) == 1 + + def test_component_to_delete_info(self): + data = { + "id": 7, + "name": "Test Component", + "componentType": "MODEL_GROUP", + "modelGroupName": "Test Model Group", + "reason": "Test reason", + } + + info = ComponentToDeleteInfo(**data) + + assert info.id == 7 + assert info.name == "Test Component" + assert info.component_type == "MODEL_GROUP" + assert info.model_group_name == "Test Model Group" + assert info.reason == "Test reason" + + def test_link_to_remove_info(self): + data = { + "id": 3, + "headId": 5, + "tailId": 6, + "config": '{"filters": {"field": {"id": 100}}}', + } + + info = LinkToRemoveInfo(**data) + + assert info.id == 3 + assert info.head_id == 5 + assert info.tail_id == 6 + assert info.config == {"filters": {"field": {"id": 100}}} + + def test_link_to_update_info(self): + data = { + "id": 4, + "headId": 5, + "tailId": 7, + "config": '{"filters": {}}', + } + + info = LinkToUpdateInfo(**data) + + assert info.id == 4 + assert info.head_id == 5 + assert info.tail_id == 7 + + def test_link_to_add_info(self): + data = { + "headId": 5, + "tailId": 8, + "config": '{"filters": {"passed": true}}', + } + + info = LinkToAddInfo(**data) + + assert info.head_id == 5 + assert info.tail_id == 8 + assert info.config == {"filters": {"passed": True}} + + def test_empty_lists(self): + data = { + "valid": True, + "componentsToDelete": [], + "linksToRemove": [], + "linksToUpdate": [], + "linksToAdd": [], + } + + result = ComponentValidationResult(**data) + + assert result.valid is True + assert len(result.components_to_delete) == 0 + assert len(result.links_to_remove) == 0 + assert len(result.links_to_update) == 0 + assert len(result.links_to_add) == 0 + + +class TestValidateComponentUpdateQuery: + def test_query_variables(self): + component_data = {"config": {"model_group_id": 1}} + query = ValidateComponentUpdate( + component_id=5, + workflow_id=10, + component=component_data, + ) + + variables = query.kwargs["json"]["variables"] + + assert variables["componentId"] == 5 + assert variables["workflowId"] == 10 + assert json.loads(variables["component"]) == component_data + + def test_process_response(self): + query = ValidateComponentUpdate( + component_id=5, + workflow_id=10, + component={"config": {}}, + ) + response = { + "data": { + "validateComponentUpdate": { + "valid": True, + "componentsToDelete": [], + "linksToRemove": [], + "linksToUpdate": [], + "linksToAdd": [], + } + } + } + + result = query.process_response(response) + + assert isinstance(result, ComponentValidationResult) + assert result.valid is True + + +class TestUpdateComponentMutation: + def test_internal_mutation_variables(self): + component_data = {"config": {"model_group_id": 1}} + mutation = _UpdateComponent( + component_id=5, + workflow_id=10, + component=component_data, + ) + + variables = mutation.kwargs["json"]["variables"] + + assert variables["componentId"] == 5 + assert variables["workflowId"] == 10 + assert json.loads(variables["component"]) == component_data + + def test_request_chain_without_auto_validate(self): + component_data = {"config": {"model_group_id": 1}} + chain = UpdateComponent( + component_id=5, + workflow_id=10, + component=component_data, + auto_validate=False, + ) + + requests = list(chain.requests()) + + assert len(requests) == 1 + assert isinstance(requests[0], _UpdateComponent) + + def test_request_chain_with_auto_validate_valid(self): + component_data = {"config": {"model_group_id": 1}} + chain = UpdateComponent( + component_id=5, + workflow_id=10, + component=component_data, + auto_validate=True, + ) + + requests_iter = chain.requests() + first_request = next(requests_iter) + + assert isinstance(first_request, ValidateComponentUpdate) + + chain.previous = ComponentValidationResult( + valid=True, + components_to_delete=[], + links_to_remove=[], + links_to_update=[], + links_to_add=[], + ) + + second_request = next(requests_iter) + + assert isinstance(second_request, _UpdateComponent) + + def test_request_chain_with_auto_validate_invalid(self): + component_data = {"config": {"model_group_id": 1}} + chain = UpdateComponent( + component_id=5, + workflow_id=10, + component=component_data, + auto_validate=True, + ) + + requests_iter = chain.requests() + next(requests_iter) + + chain.previous = ComponentValidationResult( + valid=False, + components_to_delete=[ + ComponentToDeleteInfo( + id=7, + name="Test", + component_type="MODEL_GROUP", + model_group_name="Test MG", + reason="Test reason", + ) + ], + links_to_remove=[], + links_to_update=[], + links_to_add=[], + ) + + with pytest.raises(IndicoInputError) as exc_info: + next(requests_iter) + + assert "Component update validation failed" in str(exc_info.value) + assert "Components to delete: 1" in str(exc_info.value)