diff --git a/src/spiffworkflow_backend/routes/process_api_blueprint.py b/src/spiffworkflow_backend/routes/process_api_blueprint.py index c3a2a24a..fadd914c 100644 --- a/src/spiffworkflow_backend/routes/process_api_blueprint.py +++ b/src/spiffworkflow_backend/routes/process_api_blueprint.py @@ -7,6 +7,7 @@ import uuid from typing import Any from typing import Dict from typing import Optional +from typing import TypedDict from typing import Union import connexion # type: ignore @@ -69,6 +70,22 @@ from spiffworkflow_backend.services.service_task_service import ServiceTaskServi from spiffworkflow_backend.services.spec_file_service import SpecFileService from spiffworkflow_backend.services.user_service import UserService + +class TaskDataSelectOption(TypedDict): + """TaskDataSelectOption.""" + + value: str + label: str + + +class ReactJsonSchemaSelectOption(TypedDict): + """ReactJsonSchemaSelectOption.""" + + type: str + title: str + enum: list[str] + + process_api_blueprint = Blueprint("process_api", __name__) @@ -918,9 +935,14 @@ def task_show(process_instance_id: int, task_id: str) -> flask.wrappers.Response task.data, process_model_with_form, ) + # form_contents is a str + form_dict = json.loads(form_contents) + + if task.data: + _update_form_schema_with_task_data_as_needed(form_dict, task.data) if form_contents: - task.form_schema = form_contents + task.form_schema = form_dict if form_ui_schema_file_name: ui_form_contents = prepare_form_data( @@ -1005,9 +1027,13 @@ def script_unit_test_create( process_group_id: str, process_model_id: str, body: Dict[str, Union[str, bool, int]] ) -> flask.wrappers.Response: """Script_unit_test_run.""" - bpmn_task_identifier = get_required_parameter_or_raise("bpmn_task_identifier", body) - input_json = get_required_parameter_or_raise("input_json", body) - expected_output_json = get_required_parameter_or_raise("expected_output_json", body) + bpmn_task_identifier = _get_required_parameter_or_raise( + "bpmn_task_identifier", body + ) + input_json = _get_required_parameter_or_raise("input_json", body) + expected_output_json = _get_required_parameter_or_raise( + "expected_output_json", body + ) process_model = get_process_model(process_model_id, process_group_id) file = SpecFileService.get_files(process_model, process_model.primary_file_name)[0] @@ -1094,9 +1120,11 @@ def script_unit_test_run( # FIXME: We should probably clear this somewhere else but this works current_app.config["THREAD_LOCAL_DATA"].process_instance_id = None - python_script = get_required_parameter_or_raise("python_script", body) - input_json = get_required_parameter_or_raise("input_json", body) - expected_output_json = get_required_parameter_or_raise("expected_output_json", body) + python_script = _get_required_parameter_or_raise("python_script", body) + input_json = _get_required_parameter_or_raise("input_json", body) + expected_output_json = _get_required_parameter_or_raise( + "expected_output_json", body + ) result = ScriptUnitTestRunner.run_with_script_and_pre_post_contexts( python_script, input_json, expected_output_json @@ -1316,7 +1344,7 @@ def delete_allowed_process_path(allowed_process_path_id: int) -> Response: return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") -def get_required_parameter_or_raise(parameter: str, post_body: dict[str, Any]) -> Any: +def _get_required_parameter_or_raise(parameter: str, post_body: dict[str, Any]) -> Any: """Get_required_parameter_or_raise.""" return_value = None if parameter in post_body: @@ -1332,3 +1360,61 @@ def get_required_parameter_or_raise(parameter: str, post_body: dict[str, Any]) - ) return return_value + + +# originally from: https://bitcoden.com/answers/python-nested-dictionary-update-value-where-any-nested-key-matches +def _update_form_schema_with_task_data_as_needed( + in_dict: dict, task_data: dict +) -> None: + """Update_nested.""" + for k, value in in_dict.items(): + if "anyOf" == k: + # value will look like the array on the right of "anyOf": ["options_from_task_data_var:awesome_options"] + if isinstance(value, list): + if len(value) == 1: + first_element_in_value_list = value[0] + if isinstance(first_element_in_value_list, str): + if first_element_in_value_list.startswith( + "options_from_task_data_var:" + ): + task_data_var = first_element_in_value_list.replace( + "options_from_task_data_var:", "" + ) + + if task_data_var not in task_data: + raise ( + ApiError( + code="missing_task_data_var", + message=f"Task data is missing variable: {task_data_var}", + status_code=500, + ) + ) + + select_options_from_task_data = task_data.get(task_data_var) + if isinstance(select_options_from_task_data, list): + if all( + "value" in d and "label" in d + for d in select_options_from_task_data + ): + + def map_function( + task_data_select_option: TaskDataSelectOption, + ) -> ReactJsonSchemaSelectOption: + """Map_function.""" + return { + "type": "string", + "enum": [task_data_select_option["value"]], + "title": task_data_select_option["label"], + } + + options_for_react_json_schema_form = list( + map(map_function, select_options_from_task_data) + ) + + in_dict[k] = options_for_react_json_schema_form + elif isinstance(value, dict): + _update_form_schema_with_task_data_as_needed(value, task_data) + elif isinstance(value, list): + for o in value: + if isinstance(o, dict): + _update_form_schema_with_task_data_as_needed(o, task_data) diff --git a/tests/data/dynamic_enum_select_fields/color_question.json b/tests/data/dynamic_enum_select_fields/color_question.json new file mode 100644 index 00000000..20ea1c12 --- /dev/null +++ b/tests/data/dynamic_enum_select_fields/color_question.json @@ -0,0 +1,18 @@ +{ + "definitions": { + "Color": { + "title": "Color", + "type": "string", + "anyOf": ["options_from_task_data_var:awesome_color_options"] + } + }, + "title": "Select Color", + "type": "object", + "required": ["selectedColor"], + "properties": { + "selectedColor": { + "$ref": "#/definitions/Color", + "title": "Select color" + } + } +} diff --git a/tests/data/dynamic_enum_select_fields/dynamic_enums_ask_for_color.bpmn b/tests/data/dynamic_enum_select_fields/dynamic_enums_ask_for_color.bpmn new file mode 100644 index 00000000..7d21851b --- /dev/null +++ b/tests/data/dynamic_enum_select_fields/dynamic_enums_ask_for_color.bpmn @@ -0,0 +1,57 @@ + + + + + Flow_1my9ag5 + + + + + Flow_13mlau2 + + + + Flow_1my9ag5 + Flow_0b04rbg + awesome_color_options = [{"value": "blue", "label": "Blue"}, {"value": "green", "label": "Green"}] + + + + + + + + Flow_0b04rbg + Flow_13mlau2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/dynamic_enum_select_fields/workflow.json b/tests/data/dynamic_enum_select_fields/workflow.json new file mode 100644 index 00000000..e1f5aaf2 --- /dev/null +++ b/tests/data/dynamic_enum_select_fields/workflow.json @@ -0,0 +1,17 @@ +{ + "description": "Dynamic Enum Select Fields", + "display_name": "Dynamic Enum Select Fields", + "display_order": 10, + "exception_notification_addresses": [], + "fault_or_suspend_on_exception": "fault", + "files": [], + "id": "dynamic-enum-select-fields", + "is_master_spec": false, + "is_review": false, + "libraries": [], + "library": false, + "primary_file_name": "dynamic_enums_ask_for_color.bpmn", + "primary_process_id": "Proccess_0e253c6", + "process_group_id": "category_number_one", + "standalone": false +} diff --git a/tests/spiffworkflow_backend/integration/test_api_blueprint.py b/tests/spiffworkflow_backend/integration/test_api_blueprint.py deleted file mode 100644 index 32a1dc44..00000000 --- a/tests/spiffworkflow_backend/integration/test_api_blueprint.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Test Api Blueprint.""" -# TODO: possibly get this test working again -# import json -# from typing import Union -# -# from flask.testing import FlaskClient -# from flask_bpmn.models.db import db -# -# from spiffworkflow_backend.models.process_instance import ProcessInstanceModel -# -# -# def test_user_can_be_created_and_deleted(client: FlaskClient) -> None: -# """Test_user_can_be_created_and_deleted.""" -# process_instance = ProcessInstanceModel.query.filter().first() -# if process_instance is not None: -# db.session.delete(process_instance) -# db.session.commit() -# -# last_response = None -# tasks = [ -# {"task_identifier": "1", "answer": {"Product Name": "G", "Quantity": "2"}}, -# {"task_identifier": "1", "answer": {"Sleeve Type": "Short"}}, -# {"task_identifier": "1", "answer": {"Continue shopping?": "N"}}, -# {"task_identifier": "1", "answer": {"Shipping Method": "Overnight"}}, -# {"task_identifier": "1", "answer": {"Shipping Address": "Somewhere"}}, -# {"task_identifier": "1", "answer": {"Place Order": "Y"}}, -# {"task_identifier": "1", "answer": {"Card Number": "MY_CARD"}}, -# {"task_identifier": "2", "answer": {"Was the customer charged?": "Y"}}, -# {"task_identifier": "1", "answer": {"Was the product available?": "Y"}}, -# {"task_identifier": "1", "answer": {"Was the order shipped?": "Y"}}, -# ] -# for task in tasks: -# run_task(client, task, last_response) -# -# process_instance = ProcessInstanceModel.query.filter().first() -# if process_instance is not None: -# db.session.delete(process_instance) -# db.session.commit() -# -# -# def run_task( -# client: FlaskClient, request_body: dict, last_response: Union[None, str] -# ) -> None: -# """Run_task.""" -# response = client.post( -# "/run_process", -# content_type="application/json", -# data=json.dumps(request_body), -# ) -# assert response.status_code == 200 diff --git a/tests/spiffworkflow_backend/integration/test_process_api.py b/tests/spiffworkflow_backend/integration/test_process_api.py index 27094791..c4ca9d77 100644 --- a/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/tests/spiffworkflow_backend/integration/test_process_api.py @@ -14,6 +14,7 @@ from tests.spiffworkflow_backend.helpers.test_data import load_test_spec from spiffworkflow_backend.exceptions.process_entity_not_found_error import ( ProcessEntityNotFoundError, ) +from spiffworkflow_backend.models.active_task import ActiveTaskModel from spiffworkflow_backend.models.process_group import ProcessGroup from spiffworkflow_backend.models.process_group import ProcessGroupSchema from spiffworkflow_backend.models.process_instance import ProcessInstanceModel @@ -956,6 +957,44 @@ class TestProcessApi(BaseTest): assert task_event.user_id == user.id # TODO: When user tasks work, we need to add some more assertions for action, task_state, etc. + def test_task_show( + self, app: Flask, client: FlaskClient, with_db_and_bpmn_file_cleanup: None + ) -> None: + """Test_process_instance_run_user_task.""" + process_group_id = "my_process_group" + process_model_id = "dynamic_enum_select_fields" + + user = self.find_or_create_user() + headers = self.logged_in_headers(user) + response = self.create_process_instance( + client, process_group_id, process_model_id, headers + ) + assert response.json is not None + process_instance_id = response.json["id"] + + response = client.post( + f"/v1.0/process-models/{process_group_id}/{process_model_id}/process-instances/{process_instance_id}/run", + headers=self.logged_in_headers(user), + ) + + assert response.json is not None + active_tasks = ( + db.session.query(ActiveTaskModel) + .filter(ActiveTaskModel.process_instance_id == process_instance_id) + .all() + ) + assert len(active_tasks) == 1 + active_task = active_tasks[0] + response = client.get( + f"/v1.0/tasks/{process_instance_id}/{active_task.task_id}", + headers=self.logged_in_headers(user), + ) + assert response.json is not None + assert ( + response.json["form_schema"]["definitions"]["Color"]["anyOf"][1]["title"] + == "Green" + ) + def test_process_instance_list_with_default_list( self, app: Flask, client: FlaskClient, with_db_and_bpmn_file_cleanup: None ) -> None: