diff --git a/spiffworkflow-backend/poetry.lock b/spiffworkflow-backend/poetry.lock index db37e4a6f..be2a78295 100644 --- a/spiffworkflow-backend/poetry.lock +++ b/spiffworkflow-backend/poetry.lock @@ -1789,8 +1789,6 @@ files = [ {file = "psycopg2-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:426f9f29bde126913a20a96ff8ce7d73fd8a216cfb323b1f04da402d452853c3"}, {file = "psycopg2-2.9.9-cp311-cp311-win32.whl", hash = "sha256:ade01303ccf7ae12c356a5e10911c9e1c51136003a9a1d92f7aa9d010fb98372"}, {file = "psycopg2-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:121081ea2e76729acfb0673ff33755e8703d45e926e416cb59bae3a86c6a4981"}, - {file = "psycopg2-2.9.9-cp312-cp312-win32.whl", hash = "sha256:d735786acc7dd25815e89cc4ad529a43af779db2e25aa7c626de864127e5a024"}, - {file = "psycopg2-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:a7653d00b732afb6fc597e29c50ad28087dcb4fbfb28e86092277a559ae4e693"}, {file = "psycopg2-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:5e0d98cade4f0e0304d7d6f25bbfbc5bd186e07b38eac65379309c4ca3193efa"}, {file = "psycopg2-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:7e2dacf8b009a1c1e843b5213a87f7c544b2b042476ed7755be813eaf4e8347a"}, {file = "psycopg2-2.9.9-cp38-cp38-win32.whl", hash = "sha256:ff432630e510709564c01dafdbe996cb552e0b9f3f065eb89bdce5bd31fabf4c"}, @@ -2115,7 +2113,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -2492,51 +2489,37 @@ python-versions = ">=3.6" files = [ {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b42169467c42b692c19cf539c38d4602069d8c1505e97b86387fcf7afb766e1d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:07238db9cbdf8fc1e9de2489a4f68474e70dffcb32232db7c08fa61ca0c7c462"}, + {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:d92f81886165cb14d7b067ef37e142256f1c6a90a65cd156b063a43da1708cfd"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:fff3573c2db359f091e1589c3d7c5fc2f86f5bdb6f24252c2d8e539d4e45f412"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:aa2267c6a303eb483de8d02db2871afb5c5fc15618d894300b88958f729ad74f"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:840f0c7f194986a63d2c2465ca63af8ccbbc90ab1c6001b1978f05119b5e7334"}, - {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:024cfe1fc7c7f4e1aff4a81e718109e13409767e4f871443cbff3dba3578203d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win32.whl", hash = "sha256:c69212f63169ec1cfc9bb44723bf2917cbbd8f6191a00ef3410f5a7fe300722d"}, {file = "ruamel.yaml.clib-0.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:cabddb8d8ead485e255fe80429f833172b4cadf99274db39abc080e068cbcc31"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bef08cd86169d9eafb3ccb0a39edb11d8e25f3dae2b28f5c52fd997521133069"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b16420e621d26fdfa949a8b4b47ade8810c56002f5389970db4ddda51dbff248"}, + {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:b5edda50e5e9e15e54a6a8a0070302b00c518a9d32accc2346ad6c984aacd279"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:25c515e350e5b739842fc3228d662413ef28f295791af5e5110b543cf0b57d9b"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:1707814f0d9791df063f8c19bb51b0d1278b8e9a2353abbb676c2f685dee6afe"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:46d378daaac94f454b3a0e3d8d78cafd78a026b1d71443f4966c696b48a6d899"}, - {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:09b055c05697b38ecacb7ac50bdab2240bfca1a0c4872b0fd309bb07dc9aa3a9"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win32.whl", hash = "sha256:53a300ed9cea38cf5a2a9b069058137c2ca1ce658a874b79baceb8f892f915a7"}, {file = "ruamel.yaml.clib-0.2.8-cp311-cp311-win_amd64.whl", hash = "sha256:c2a72e9109ea74e511e29032f3b670835f8a59bbdc9ce692c5b4ed91ccf1eedb"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:ebc06178e8821efc9692ea7544aa5644217358490145629914d8020042c24aa1"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:edaef1c1200c4b4cb914583150dcaa3bc30e592e907c01117c08b13a07255ec2"}, + {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:7048c338b6c86627afb27faecf418768acb6331fc24cfa56c93e8c9780f815fa"}, {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d176b57452ab5b7028ac47e7b3cf644bcfdc8cacfecf7e71759f7f51a59e5c92"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-manylinux_2_24_aarch64.whl", hash = "sha256:1dc67314e7e1086c9fdf2680b7b6c2be1c0d8e3a8279f2e993ca2a7545fecf62"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3213ece08ea033eb159ac52ae052a4899b56ecc124bb80020d9bbceeb50258e9"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aab7fd643f71d7946f2ee58cc88c9b7bfc97debd71dcc93e03e2d174628e7e2d"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win32.whl", hash = "sha256:5c365d91c88390c8d0a8545df0b5857172824b1c604e867161e6b3d59a827eaa"}, - {file = "ruamel.yaml.clib-0.2.8-cp312-cp312-win_amd64.whl", hash = "sha256:1758ce7d8e1a29d23de54a16ae867abd370f01b5a69e1a3ba75223eaa3ca1a1b"}, {file = "ruamel.yaml.clib-0.2.8-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a5aa27bad2bb83670b71683aae140a1f52b0857a2deff56ad3f6c13a017a26ed"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c58ecd827313af6864893e7af0a3bb85fd529f862b6adbefe14643947cfe2942"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-macosx_12_0_arm64.whl", hash = "sha256:f481f16baec5290e45aebdc2a5168ebc6d35189ae6fea7a58787613a25f6e875"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:77159f5d5b5c14f7c34073862a6b7d34944075d9f93e681638f6d753606c6ce6"}, + {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:3fcc54cb0c8b811ff66082de1680b4b14cf8a81dce0d4fbf665c2265a81e07a1"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:7f67a1ee819dc4562d444bbafb135832b0b909f81cc90f7aa00260968c9ca1b3"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4ecbf9c3e19f9562c7fdd462e8d18dd902a47ca046a2e64dba80699f0b6c09b7"}, - {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:87ea5ff66d8064301a154b3933ae406b0863402a799b16e4a1d24d9fbbcbe0d3"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win32.whl", hash = "sha256:75e1ed13e1f9de23c5607fe6bd1aeaae21e523b32d83bb33918245361e9cc51b"}, {file = "ruamel.yaml.clib-0.2.8-cp37-cp37m-win_amd64.whl", hash = "sha256:3f215c5daf6a9d7bbed4a0a4f760f3113b10e82ff4c5c44bec20a68c8014f675"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1b617618914cb00bf5c34d4357c37aa15183fa229b24767259657746c9077615"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:a6a9ffd280b71ad062eae53ac1659ad86a17f59a0fdc7699fd9be40525153337"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:305889baa4043a09e5b76f8e2a51d4ffba44259f6b4c72dec8ca56207d9c6fe1"}, + {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:665f58bfd29b167039f714c6998178d27ccd83984084c286110ef26b230f259f"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:700e4ebb569e59e16a976857c8798aee258dceac7c7d6b50cab63e080058df91"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e2b4c44b60eadec492926a7270abb100ef9f72798e18743939bdbf037aab8c28"}, - {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:e79e5db08739731b0ce4850bed599235d601701d5694c36570a99a0c5ca41a9d"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win32.whl", hash = "sha256:955eae71ac26c1ab35924203fda6220f84dce57d6d7884f189743e2abe3a9fbe"}, {file = "ruamel.yaml.clib-0.2.8-cp38-cp38-win_amd64.whl", hash = "sha256:56f4252222c067b4ce51ae12cbac231bce32aee1d33fbfc9d17e5b8d6966c312"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:03d1162b6d1df1caa3a4bd27aa51ce17c9afc2046c31b0ad60a0a96ec22f8001"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:bba64af9fa9cebe325a62fa398760f5c7206b215201b0ec825005f1b18b9bccf"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:a1a45e0bb052edf6a1d3a93baef85319733a888363938e1fc9924cb00c8df24c"}, + {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:9eb5dee2772b0f704ca2e45b1713e4e5198c18f515b52743576d196348f374d3"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:da09ad1c359a728e112d60116f626cc9f29730ff3e0e7db72b9a2dbc2e4beed5"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:184565012b60405d93838167f425713180b949e9d8dd0bbc7b49f074407c5a8b"}, - {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a75879bacf2c987c003368cf14bed0ffe99e8e85acfa6c0bfffc21a090f16880"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win32.whl", hash = "sha256:84b554931e932c46f94ab306913ad7e11bba988104c5cff26d90d03f68258cd5"}, {file = "ruamel.yaml.clib-0.2.8-cp39-cp39-win_amd64.whl", hash = "sha256:25ac8c08322002b06fa1d49d1646181f0b2c72f5cbc15a85e80b4c30a544bb15"}, {file = "ruamel.yaml.clib-0.2.8.tar.gz", hash = "sha256:beb2e0404003de9a4cab9753a8805a8fe9320ee6673136ed7f04255fe60bb512"}, @@ -2850,7 +2833,7 @@ doc = ["sphinx", "sphinx_rtd_theme"] type = "git" url = "https://github.com/sartography/SpiffWorkflow" reference = "main" -resolved_reference = "7e15a0b68f926161bfa88e1306f3145ee028fad4" +resolved_reference = "a7ddab83831eb98371a26d94ecc6978287b965c5" [[package]] name = "spiffworkflow-connector-command" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index f254a4bd1..ed8ffed31 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -1330,6 +1330,39 @@ paths: schema: $ref: "#/components/schemas/OkTrue" + /process-instances/{modified_process_model_identifier}/{process_instance_id}/check-can-migrate: + parameters: + - name: modified_process_model_identifier + in: path + required: true + description: The unique id of an existing process model + schema: + type: string + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + get: + tags: + - Process Instances + operationId: spiffworkflow_backend.routes.process_instances_controller.process_instance_check_can_migrate + summary: Checks if a given process instance can be migrated to the newest version of the process model. + responses: + "200": + description: The result + content: + application/json: + schema: + properties: + can_migrate: + type: boolean + description: True if it can migrate and false if not. + process_instance_id: + type: integer + description: Process instance id. + /process-instances/{modified_process_model_identifier}/{process_instance_id}/run: parameters: - name: process_instance_id @@ -1462,6 +1495,33 @@ paths: schema: $ref: "#/components/schemas/OkTrue" + /process-instance-migrate/{modified_process_model_identifier}/{process_instance_id}: + parameters: + - name: modified_process_model_identifier + in: path + required: true + description: The modified process model id + schema: + type: string + - name: process_instance_id + in: path + required: true + description: The unique id of an existing process instance. + schema: + type: integer + post: + operationId: spiffworkflow_backend.routes.process_instances_controller.process_instance_migrate + summary: Migrate a process instance to the new version of its process model. + tags: + - Process Instances + responses: + "200": + description: Empty ok true response on successful reset. + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" + /process-instances/reports: parameters: - name: page diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/error.py b/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/error.py index c9107f1ef..b8c258392 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/error.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/exceptions/error.py @@ -6,7 +6,12 @@ class RefreshTokenStorageError(Exception): pass -# These could be either 'id' OR 'access' tokens and we can't always know which +class ProcessInstanceMigrationNotSafeError(Exception): + pass + + +class ProcessInstanceMigrationError(Exception): + pass class TokenExpiredError(Exception): diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_event.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_event.py index f674a1e3e..a7932d2e7 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_event.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/process_instance_event.py @@ -16,6 +16,7 @@ from spiffworkflow_backend.models.user import UserModel class ProcessInstanceEventType(SpiffEnum): process_instance_error = "process_instance_error" process_instance_force_run = "process_instance_force_run" + process_instance_migrated = "process_instance_migrated" process_instance_resumed = "process_instance_resumed" process_instance_rewound_to_task = "process_instance_rewound_to_task" process_instance_suspended = "process_instance_suspended" diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py index 735e525e5..1e4d9da6f 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/process_instances_controller.py @@ -1,3 +1,4 @@ +from spiffworkflow_backend.exceptions.error import ProcessInstanceMigrationError, ProcessInstanceMigrationNotSafeError from spiffworkflow_backend.helpers.spiff_enum import ProcessInstanceExecutionMode # black and ruff are in competition with each other in import formatting so ignore ruff @@ -546,6 +547,36 @@ def process_instance_reset( return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") +def process_instance_check_can_migrate( + process_instance_id: int, + modified_process_model_identifier: str, +) -> flask.wrappers.Response: + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) + can_migrate = True + try: + ProcessInstanceService.check_process_instance_can_be_migrated(process_instance) + except ProcessInstanceMigrationNotSafeError: + can_migrate = False + return Response( + json.dumps({"can_migrate": can_migrate, "process_instance_id": process_instance.id}), + status=200, + mimetype="application/json", + ) + + +def process_instance_migrate( + process_instance_id: int, + modified_process_model_identifier: str, +) -> flask.wrappers.Response: + process_instance = _find_process_instance_by_id_or_raise(process_instance_id) + if process_instance.status != "suspended": + raise ProcessInstanceMigrationError( + f"The process instance needs to be suspended to migrate it. It is currently: {process_instance.status}" + ) + ProcessInstanceService.migrate_process_instance_to_newest_model_version(process_instance, user=g.user) + return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") + + def process_instance_find_by_id( process_instance_id: int, ) -> flask.wrappers.Response: diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py index 308d37314..ec80a18f9 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/authorization_service.py @@ -70,6 +70,7 @@ PATH_SEGMENTS_FOR_PERMISSION_ALL = [ }, {"path": "/process-data", "relevant_permissions": ["read"]}, {"path": "/process-data-file-download", "relevant_permissions": ["read"]}, + {"path": "/process-instance-migrate", "relevant_permissions": ["create"]}, {"path": "/process-instance-suspend", "relevant_permissions": ["create"]}, {"path": "/process-instance-terminate", "relevant_permissions": ["create"]}, {"path": "/process-model-natural-language", "relevant_permissions": ["create"]}, @@ -637,7 +638,7 @@ class AuthorizationService: def set_support_permissions(cls) -> list[PermissionToAssign]: """Just like elevated permissions minus access to secrets.""" permissions_to_assign = cls.set_basic_permissions() - for process_instance_action in ["resume", "terminate", "suspend", "reset"]: + for process_instance_action in ["migrate", "resume", "terminate", "suspend", "reset"]: permissions_to_assign.append( PermissionToAssign(permission="create", target_uri=f"/process-instance-{process_instance_action}/*") ) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py index 2bae1059e..1cb6a20fa 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_processor.py @@ -19,6 +19,7 @@ from typing import Any from typing import NewType from typing import TypedDict from uuid import UUID +from uuid import uuid4 import dateparser import pytz @@ -36,6 +37,7 @@ from SpiffWorkflow.bpmn.serializer.default.task_spec import EventConverter # ty from SpiffWorkflow.bpmn.serializer.helpers.registry import DefaultRegistry # type: ignore from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer # type: ignore from SpiffWorkflow.bpmn.specs.bpmn_process_spec import BpmnProcessSpec # type: ignore +from SpiffWorkflow.bpmn.util.diff import WorkflowDiff # type: ignore from SpiffWorkflow.bpmn.workflow import BpmnWorkflow # type: ignore from SpiffWorkflow.exceptions import WorkflowException # type: ignore from SpiffWorkflow.serializer.exceptions import MissingSpecError # type: ignore @@ -414,6 +416,7 @@ class CustomBpmnScriptEngine(PythonScriptEngine): # type: ignore IdToBpmnProcessSpecMapping = NewType("IdToBpmnProcessSpecMapping", dict[str, BpmnProcessSpec]) +SubprocessUuidToWorkflowDiffMapping = NewType("SubprocessUuidToWorkflowDiffMapping", dict[UUID, WorkflowDiff]) class ProcessInstanceProcessor: @@ -470,7 +473,7 @@ class ProcessInstanceProcessor: self.bpmn_subprocess_mapping: dict[str, BpmnProcessModel] = {} # this caches the bpmn_process_definition_identifier and task_identifier back to the bpmn_process_id - # in the database. This is to cut down on database queries while adding new tasks to the database. + # intthe database. This is to cut down on database queries while adding new tasks to the database. # Structure: # { "[[BPMN_PROCESS_DEFINITION_IDENTIFIER]]": { # "[[TASK_IDENTIFIER]]": [[TASK_DEFINITION]], @@ -524,6 +527,7 @@ class ProcessInstanceProcessor: bpmn_definition_to_task_definitions_mappings: dict, process_instance_model: ProcessInstanceModel, store_process_instance_events: bool = True, + bpmn_process_instance: BpmnWorkflow | None = None, ) -> None: cls._add_bpmn_process_definitions( bpmn_process_dict, @@ -531,7 +535,10 @@ class ProcessInstanceProcessor: process_instance_model=process_instance_model, force_update=True, ) - bpmn_process_instance = cls.initialize_bpmn_process_instance(bpmn_process_dict) + + if bpmn_process_instance is None: + bpmn_process_instance = cls.initialize_bpmn_process_instance(bpmn_process_dict) + task_model_mapping, bpmn_subprocess_mapping = cls.get_db_mappings_from_bpmn_process_dict(bpmn_process_dict) task_service = TaskService( @@ -1338,6 +1345,42 @@ class ProcessInstanceProcessor: processor.save() processor.suspend() + @classmethod + def update_guids_on_tasks(cls, bpmn_process_instance_dict: dict) -> None: + # old -> new + guid_map = {} + + def get_guid_map(proc_dict: dict) -> None: + for guid in proc_dict["tasks"].keys(): + guid_map[guid] = str(uuid4()) + + def update_guids(proc_dict: dict) -> None: + new_tasks = {} + for task_guid, task_dict in proc_dict["tasks"].items(): + new_guid = guid_map[task_guid] + new_tasks[new_guid] = task_dict + if task_dict["parent"] is not None: + new_tasks[new_guid]["parent"] = guid_map[task_dict["parent"]] + new_children_guids = [guid_map[cg] for cg in task_dict["children"]] + new_tasks[new_guid]["children"] = new_children_guids + new_tasks[new_guid]["id"] = guid_map[task_dict["id"]] + proc_dict["tasks"] = new_tasks + proc_dict["root"] = guid_map[proc_dict["root"]] + proc_dict["last_task"] = guid_map[proc_dict["last_task"]] + + get_guid_map(bpmn_process_instance_dict) + for subproc_dict in bpmn_process_instance_dict["subprocesses"].values(): + get_guid_map(subproc_dict) + + update_guids(bpmn_process_instance_dict) + new_subprocesses = {} + for subproc_guid, subproc_dict in bpmn_process_instance_dict["subprocesses"].items(): + new_guid = guid_map[subproc_guid] + new_subprocesses[new_guid] = subproc_dict + new_subprocesses[new_guid]["parent_task_id"] = guid_map[subproc_dict["parent_task_id"]] + update_guids(new_subprocesses[new_guid]) + bpmn_process_instance_dict["subprocesses"] = new_subprocesses + @staticmethod def get_parser() -> MyCustomParser: parser = MyCustomParser() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py index bfefe0dd8..8c27a0074 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/process_instance_service.py @@ -7,14 +7,20 @@ from datetime import datetime from datetime import timezone from typing import Any from urllib.parse import unquote +from uuid import UUID import sentry_sdk from flask import current_app from flask import g +from SpiffWorkflow.bpmn.specs.bpmn_process_spec import BpmnProcessSpec # type: ignore from SpiffWorkflow.bpmn.specs.control import BoundaryEventSplit # type: ignore from SpiffWorkflow.bpmn.specs.defaults import BoundaryEvent # type: ignore from SpiffWorkflow.bpmn.specs.event_definitions.timer import TimerEventDefinition # type: ignore from SpiffWorkflow.bpmn.util import PendingBpmnEvent # type: ignore +from SpiffWorkflow.bpmn.util.diff import WorkflowDiff # type: ignore +from SpiffWorkflow.bpmn.util.diff import diff_workflow +from SpiffWorkflow.bpmn.util.diff import filter_tasks +from SpiffWorkflow.bpmn.util.diff import migrate_workflow from SpiffWorkflow.task import Task as SpiffTask # type: ignore from SpiffWorkflow.util.deep_merge import DeepMerge # type: ignore from SpiffWorkflow.util.task import TaskState # type: ignore @@ -27,6 +33,7 @@ from spiffworkflow_backend.data_migrations.process_instance_migrator import Proc from spiffworkflow_backend.exceptions.api_error import ApiError from spiffworkflow_backend.exceptions.error import HumanTaskAlreadyCompletedError from spiffworkflow_backend.exceptions.error import HumanTaskNotFoundError +from spiffworkflow_backend.exceptions.error import ProcessInstanceMigrationNotSafeError from spiffworkflow_backend.exceptions.error import UserDoesNotHaveAccessToTaskError from spiffworkflow_backend.helpers.spiff_enum import ProcessInstanceExecutionMode from spiffworkflow_backend.models.db import db @@ -41,6 +48,7 @@ from spiffworkflow_backend.models.process_instance_file_data import ProcessInsta from spiffworkflow_backend.models.process_model import ProcessModelInfo from spiffworkflow_backend.models.process_model_cycle import ProcessModelCycleModel from spiffworkflow_backend.models.task import Task +from spiffworkflow_backend.models.task import TaskModel # noqa: F401 from spiffworkflow_backend.models.user import UserModel from spiffworkflow_backend.services.authorization_service import AuthorizationService from spiffworkflow_backend.services.error_handling_service import ErrorHandlingService @@ -48,7 +56,9 @@ from spiffworkflow_backend.services.git_service import GitCommandError from spiffworkflow_backend.services.git_service import GitService from spiffworkflow_backend.services.jinja_service import JinjaService from spiffworkflow_backend.services.process_instance_processor import CustomBpmnScriptEngine +from spiffworkflow_backend.services.process_instance_processor import IdToBpmnProcessSpecMapping from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor +from spiffworkflow_backend.services.process_instance_processor import SubprocessUuidToWorkflowDiffMapping from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsAlreadyLockedError from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceIsNotEnqueuedError from spiffworkflow_backend.services.process_instance_queue_service import ProcessInstanceQueueService @@ -134,6 +144,82 @@ class ProcessInstanceService: ProcessInstanceQueueService.enqueue_new_process_instance(process_instance_model, run_at_in_seconds) return (process_instance_model, start_configuration) + @classmethod + def check_process_instance_can_be_migrated( + cls, process_instance: ProcessInstanceModel + ) -> tuple[ + ProcessInstanceProcessor, BpmnProcessSpec, IdToBpmnProcessSpecMapping, WorkflowDiff, SubprocessUuidToWorkflowDiffMapping + ]: + (target_bpmn_process_spec, target_subprocess_specs) = ProcessInstanceProcessor.get_process_model_and_subprocesses( + process_instance.process_model_identifier, + ) + processor = ProcessInstanceProcessor( + process_instance, include_task_data_for_completed_tasks=True, include_completed_subprocesses=True + ) + + # tasks that were in the old workflow and are in the new one as well + top_level_bpmn_process_diff, subprocesses_diffs = diff_workflow( + processor._serializer.registry, processor.bpmn_process_instance, target_bpmn_process_spec, target_subprocess_specs + ) + if not cls.can_migrate(top_level_bpmn_process_diff, subprocesses_diffs): + raise ProcessInstanceMigrationNotSafeError( + f"It is not safe to migrate process instance {process_instance.id} to " + f"new version of '{process_instance.process_model_identifier}'" + ) + return ( + processor, + target_bpmn_process_spec, + target_subprocess_specs, + top_level_bpmn_process_diff, + subprocesses_diffs, + ) + + @classmethod + def migrate_process_instance_to_newest_model_version( + cls, process_instance: ProcessInstanceModel, user: UserModel, preserve_old_process_instance: bool = False + ) -> None: + ( + processor, + target_bpmn_process_spec, + target_subprocess_specs, + top_level_bpmn_process_diff, + subprocesses_diffs, + ) = cls.check_process_instance_can_be_migrated(process_instance) + ProcessInstanceTmpService.add_event_to_process_instance( + process_instance, ProcessInstanceEventType.process_instance_rewound_to_task.value + ) + migrate_workflow(top_level_bpmn_process_diff, processor.bpmn_process_instance, target_bpmn_process_spec) + for sp_id, sp in processor.bpmn_process_instance.subprocesses.items(): + migrate_workflow(subprocesses_diffs[sp_id], sp, target_subprocess_specs.get(sp.spec.name)) + processor.bpmn_process_instance.subprocess_specs = target_subprocess_specs + + if preserve_old_process_instance: + # TODO: write tests for this code path - no one has a requirement for it yet + bpmn_process_dict = processor.serialize() + ProcessInstanceProcessor.update_guids_on_tasks(bpmn_process_dict) + new_process_instance, _ = cls.create_process_instance_from_process_model_identifier( + process_instance.process_model_identifier, user + ) + ProcessInstanceProcessor.persist_bpmn_process_dict( + bpmn_process_dict, bpmn_definition_to_task_definitions_mappings={}, process_instance_model=new_process_instance + ) + else: + future_tasks = TaskModel.query.filter( + TaskModel.process_instance_id == process_instance.id, + TaskModel.state.in_(["FUTURE", "MAYBE", "LIKELY"]), # type: ignore + ).all() + for ft in future_tasks: + db.session.delete(ft) + db.session.commit() + + bpmn_process_dict = processor.serialize() + ProcessInstanceProcessor.persist_bpmn_process_dict( + bpmn_process_dict, + bpmn_definition_to_task_definitions_mappings={}, + process_instance_model=process_instance, + bpmn_process_instance=processor.bpmn_process_instance, + ) + @classmethod def create_process_instance_from_process_model_identifier( cls, @@ -642,3 +728,15 @@ class ProcessInstanceService: ) from e raise e return processor + + @classmethod + def can_migrate(cls, top_level_bpmn_process_diff: WorkflowDiff, subprocesses_diffs: dict[UUID, WorkflowDiff]) -> bool: + def safe(result: WorkflowDiff) -> bool: + mask = TaskState.COMPLETED | TaskState.STARTED + tasks = result.changed + result.removed + return len(filter_tasks(tasks, state=mask)) == 0 + + for diff in subprocesses_diffs.values(): + if diff is None or not safe(diff): + return False + return safe(top_level_bpmn_process_diff) diff --git a/spiffworkflow-backend/tests/data/migration-test-with-subprocess/migration-initial.bpmn b/spiffworkflow-backend/tests/data/migration-test-with-subprocess/migration-initial.bpmn new file mode 100644 index 000000000..6397f64fe --- /dev/null +++ b/spiffworkflow-backend/tests/data/migration-test-with-subprocess/migration-initial.bpmn @@ -0,0 +1,71 @@ + + + + + Flow_17db3yp + + + + Flow_0m0he21 + + + + Flow_17db3yp + Flow_0m0he21 + + Flow_01eckoj + + + + Flow_0s7769x + + + + Flow_01eckoj + Flow_0s7769x + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/data/migration-test-with-subprocess/migration-new.bpmn b/spiffworkflow-backend/tests/data/migration-test-with-subprocess/migration-new.bpmn new file mode 100644 index 000000000..859575430 --- /dev/null +++ b/spiffworkflow-backend/tests/data/migration-test-with-subprocess/migration-new.bpmn @@ -0,0 +1,83 @@ + + + + + Flow_17db3yp + + + + Flow_0m0he21 + + + + Flow_17db3yp + Flow_0m0he21 + + Flow_01eckoj + + + + Flow_17fvyk2 + + + + Flow_01eckoj + Flow_0s7769x + + + + Flow_0s7769x + Flow_17fvyk2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py index 734165d36..fe8c0fabe 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/helpers/base_test.py @@ -100,6 +100,38 @@ class BaseTest: return process_model + def create_and_run_process_instance( + self, + client: FlaskClient, + user: UserModel, + process_group_id: str | None = "test_group", + process_model_id: str | None = "random_fact", + bpmn_file_name: str | None = None, + bpmn_file_location: str | None = None, + ) -> tuple[ProcessModelInfo, int]: + process_model = self.create_group_and_model_with_bpmn( + client=client, + user=user, + process_group_id=process_group_id, + process_model_id=process_model_id, + bpmn_file_name=bpmn_file_name, + bpmn_file_location=bpmn_file_location, + ) + + headers = self.logged_in_headers(user) + response = self.create_process_instance_from_process_model_id_with_api(client, process_model.id, headers) + assert response.json is not None + process_instance_id = response.json["id"] + response = client.post( + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_model.id)}/{process_instance_id}/run", + headers=self.logged_in_headers(user), + ) + + assert response.status_code == 200 + assert response.json is not None + + return (process_model, int(process_instance_id)) + def create_process_group( self, process_group_id: str, diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_instances_controller.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_instances_controller.py index d3c76f623..e33ae3585 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_instances_controller.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_instances_controller.py @@ -1,6 +1,12 @@ +import os + from flask.app import Flask from flask.testing import FlaskClient +from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus from spiffworkflow_backend.models.user import UserModel +from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor +from spiffworkflow_backend.services.spec_file_service import SpecFileService from tests.spiffworkflow_backend.helpers.base_test import BaseTest from tests.spiffworkflow_backend.helpers.test_data import load_test_spec @@ -49,3 +55,110 @@ class TestProcessInstancesController(BaseTest): assert "process_instance" in response.json assert response.json["process_instance"]["id"] == process_instance.id assert response.json["uri_type"] is None + + def test_process_instance_migrate( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + process_model, process_instance_id = self.create_and_run_process_instance( + client=client, + user=with_super_admin_user, + process_model_id="migration-test-with-subprocess", + bpmn_file_name="migration-initial.bpmn", + ) + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() + assert process_instance is not None + processor = ProcessInstanceProcessor(process_instance) + assert "manual_task_two" not in processor.bpmn_process_instance.spec.task_specs + + human_task_one = process_instance.active_human_tasks[0] + assert human_task_one.task_model.task_definition.bpmn_identifier == "manual_task_one" + + new_file_path = os.path.join( + app.instance_path, + "..", + "..", + "tests", + "data", + "migration-test-with-subprocess", + "migration-new.bpmn", + ) + with open(new_file_path) as f: + new_contents = f.read().encode() + + SpecFileService.update_file( + process_model_info=process_model, + file_name="migration-initial.bpmn", + binary_data=new_contents, + update_process_cache_only=True, + ) + + processor.suspend() + response = client.post( + f"/v1.0/process-instance-migrate/{self.modify_process_identifier_for_path_param(process_instance.process_model_identifier)}/{process_instance_id}", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert response.status_code == 200 + assert response.json is not None + + processor = ProcessInstanceProcessor(process_instance) + human_task_one = process_instance.active_human_tasks[0] + assert human_task_one.task_model.task_definition.bpmn_identifier == "manual_task_one" + self.complete_next_manual_task(processor) + + human_task_one = process_instance.active_human_tasks[0] + assert human_task_one.task_model.task_definition.bpmn_identifier == "manual_task_two" + self.complete_next_manual_task(processor) + + assert process_instance.status == ProcessInstanceStatus.complete.value + + def test_process_instance_check_can_migrate( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + with_super_admin_user: UserModel, + ) -> None: + process_model, process_instance_id = self.create_and_run_process_instance( + client=client, + user=with_super_admin_user, + process_model_id="migration-test-with-subprocess", + bpmn_file_name="migration-initial.bpmn", + ) + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance_id).first() + assert process_instance is not None + processor = ProcessInstanceProcessor(process_instance) + assert "manual_task_two" not in processor.bpmn_process_instance.spec.task_specs + + human_task_one = process_instance.active_human_tasks[0] + assert human_task_one.task_model.task_definition.bpmn_identifier == "manual_task_one" + + new_file_path = os.path.join( + app.instance_path, + "..", + "..", + "tests", + "data", + "migration-test-with-subprocess", + "migration-new.bpmn", + ) + with open(new_file_path) as f: + new_contents = f.read().encode() + + SpecFileService.update_file( + process_model_info=process_model, + file_name="migration-initial.bpmn", + binary_data=new_contents, + update_process_cache_only=True, + ) + + response = client.get( + f"/v1.0/process-instances/{self.modify_process_identifier_for_path_param(process_instance.process_model_identifier)}/{process_instance_id}/check-can-migrate", + headers=self.logged_in_headers(with_super_admin_user), + ) + assert response.status_code == 200 + assert response.json is not None + assert response.json == {"can_migrate": True, "process_instance_id": process_instance.id} diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py index 9cff58431..53d8d1835 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_authorization_service.py @@ -108,6 +108,10 @@ class TestAuthorizationService(BaseTest): ("/process-groups/some-process-group:some-process-model:*", "delete"), ("/process-groups/some-process-group:some-process-model:*", "read"), ("/process-groups/some-process-group:some-process-model:*", "update"), + ( + "/process-instance-migrate/some-process-group:some-process-model:*", + "create", + ), ( "/process-instance-suspend/some-process-group:some-process-model:*", "create", @@ -192,6 +196,10 @@ class TestAuthorizationService(BaseTest): ("/logs/typeahead-filter-values/some-process-group:some-process-model/*", "read"), ("/message-models/some-process-group:some-process-model/*", "read"), ("/process-data/some-process-group:some-process-model/*", "read"), + ( + "/process-instance-migrate/some-process-group:some-process-model/*", + "create", + ), ( "/process-instance-suspend/some-process-group:some-process-model/*", "create", @@ -530,6 +538,7 @@ class TestAuthorizationService(BaseTest): ("/messages/*", "create"), ("/process-data-file-download/*", "read"), ("/process-data/*", "read"), + ("/process-instance-migrate/*", "create"), ("/process-instance-reset/*", "create"), ("/process-instance-resume/*", "create"), ("/process-instance-suspend/*", "create"), diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py index 64613c89e..8c199fc6a 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_processor.py @@ -1077,6 +1077,53 @@ class TestProcessInstanceProcessor(BaseTest): # mypy thinks this is unreachable but it is reachable. summary can be str | None assert len(process_instance.summary) == 255 # type: ignore + def test_it_can_update_guids_in_bpmn_process_dict( + self, + app: Flask, + client: FlaskClient, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + initiator_user = self.find_or_create_user("initiator_user") + process_model = load_test_spec( + process_model_id="test_group/loopback_to_subprocess", + process_model_source_directory="loopback_to_subprocess", + ) + process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True, execution_strategy_name="greedy") + + assert len(process_instance.active_human_tasks) == 1 + assert len(process_instance.human_tasks) == 1 + human_task_one = process_instance.active_human_tasks[0] + + spiff_task = processor.get_task_by_guid(human_task_one.task_id) + ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task_one) + + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True, execution_strategy_name="greedy") + assert len(process_instance.active_human_tasks) == 1 + assert len(process_instance.human_tasks) == 2 + human_task_two = process_instance.active_human_tasks[0] + spiff_task = processor.get_task_by_guid(human_task_two.task_id) + ProcessInstanceService.complete_form_task(processor, spiff_task, {}, initiator_user, human_task_two) + old_tasks = processor.bpmn_process_instance.get_tasks() + old_task_names = [t.task_spec.name for t in old_tasks] + + bpmn_process_dict = processor.serialize() + task_one_guid = sorted(bpmn_process_dict["tasks"].keys())[0] + subprocess_one_guid = sorted(bpmn_process_dict["subprocesses"].keys())[0] + ProcessInstanceProcessor.update_guids_on_tasks(bpmn_process_dict) + task_two_guid = sorted(bpmn_process_dict["tasks"].keys())[0] + subprocess_two_guid = sorted(bpmn_process_dict["subprocesses"].keys())[0] + + assert task_one_guid != task_two_guid + assert subprocess_one_guid != subprocess_two_guid + + new_bpmn_process_instance = ProcessInstanceProcessor.initialize_bpmn_process_instance(bpmn_process_dict) + new_tasks = new_bpmn_process_instance.get_tasks() + new_task_names = [t.task_spec.name for t in new_tasks] + assert old_task_names == new_task_names + # # To test processing times with multiinstance subprocesses # def test_large_multiinstance( # self, diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_service.py b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_service.py index 1fe4b6bb4..c68137d08 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_service.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/unit/test_process_instance_service.py @@ -1,5 +1,6 @@ import base64 import hashlib +import os from datetime import datetime from datetime import timezone from typing import Any @@ -7,9 +8,15 @@ from typing import Any import pytest from flask.app import Flask from SpiffWorkflow.bpmn.util import PendingBpmnEvent # type: ignore +from spiffworkflow_backend.exceptions.error import ProcessInstanceMigrationNotSafeError +from spiffworkflow_backend.models.process_instance import ProcessInstanceModel +from spiffworkflow_backend.models.process_instance import ProcessInstanceStatus +from spiffworkflow_backend.services.process_instance_processor import ProcessInstanceProcessor from spiffworkflow_backend.services.process_instance_service import ProcessInstanceService +from spiffworkflow_backend.services.spec_file_service import SpecFileService from tests.spiffworkflow_backend.helpers.base_test import BaseTest +from tests.spiffworkflow_backend.helpers.test_data import load_test_spec def _file_content(i: int) -> bytes: @@ -146,3 +153,141 @@ class TestProcessInstanceService(BaseTest): datetime.fromisoformat("2023-04-27T20:15:10.626656+00:00"), ) ) + + def test_it_can_migrate_a_process_instance( + self, + app: Flask, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + initiator_user = self.find_or_create_user("initiator_user") + process_model = load_test_spec( + process_model_id="test_group/migration-test-with-subprocess", + process_model_source_directory="migration-test-with-subprocess", + bpmn_file_name="migration-initial.bpmn", + ) + process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True, execution_strategy_name="greedy") + + initial_tasks = processor.bpmn_process_instance.get_tasks() + assert "manual_task_two" not in processor.bpmn_process_instance.spec.task_specs + + new_file_path = os.path.join( + app.instance_path, + "..", + "..", + "tests", + "data", + "migration-test-with-subprocess", + "migration-new.bpmn", + ) + with open(new_file_path) as f: + new_contents = f.read().encode() + + SpecFileService.update_file( + process_model_info=process_model, + file_name="migration-initial.bpmn", + binary_data=new_contents, + update_process_cache_only=True, + ) + + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first() + ProcessInstanceService.migrate_process_instance_to_newest_model_version(process_instance, user=initiator_user) + + for initial_task in initial_tasks: + new_task = processor.bpmn_process_instance.get_task_from_id(initial_task.id) + assert new_task is not None + assert new_task.last_state_change == initial_task.last_state_change + + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True, execution_strategy_name="greedy") + + human_task_one = process_instance.active_human_tasks[0] + assert human_task_one.task_model.task_definition.bpmn_identifier == "manual_task_one" + self.complete_next_manual_task(processor) + + human_task_one = process_instance.active_human_tasks[0] + assert human_task_one.task_model.task_definition.bpmn_identifier == "manual_task_two" + self.complete_next_manual_task(processor) + + assert process_instance.status == ProcessInstanceStatus.complete.value + + def test_it_can_check_if_a_process_instance_can_be_migrated( + self, + app: Flask, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + initiator_user = self.find_or_create_user("initiator_user") + process_model = load_test_spec( + process_model_id="test_group/migration-test-with-subprocess", + process_model_source_directory="migration-test-with-subprocess", + bpmn_file_name="migration-initial.bpmn", + ) + process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True, execution_strategy_name="greedy") + + new_file_path = os.path.join( + app.instance_path, + "..", + "..", + "tests", + "data", + "migration-test-with-subprocess", + "migration-new.bpmn", + ) + with open(new_file_path) as f: + new_contents = f.read().encode() + + SpecFileService.update_file( + process_model_info=process_model, + file_name="migration-initial.bpmn", + binary_data=new_contents, + update_process_cache_only=True, + ) + + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first() + ProcessInstanceService.check_process_instance_can_be_migrated(process_instance) + + def test_it_raises_if_a_process_instance_cannot_be_migrated_to_new_process_model_version( + self, + app: Flask, + with_db_and_bpmn_file_cleanup: None, + ) -> None: + initiator_user = self.find_or_create_user("initiator_user") + process_model = load_test_spec( + process_model_id="test_group/migration-test-with-subprocess", + process_model_source_directory="migration-test-with-subprocess", + bpmn_file_name="migration-initial.bpmn", + ) + process_instance = self.create_process_instance_from_process_model(process_model=process_model, user=initiator_user) + processor = ProcessInstanceProcessor(process_instance) + processor.do_engine_steps(save=True, execution_strategy_name="greedy") + human_task_one = process_instance.active_human_tasks[0] + assert human_task_one.task_model.task_definition.bpmn_identifier == "manual_task_one" + self.complete_next_manual_task(processor) + assert process_instance.status == ProcessInstanceStatus.complete.value + + new_file_path = os.path.join( + app.instance_path, + "..", + "..", + "tests", + "data", + "migration-test-with-subprocess", + "migration-new.bpmn", + ) + with open(new_file_path) as f: + new_contents = f.read().encode() + + SpecFileService.update_file( + process_model_info=process_model, + file_name="migration-initial.bpmn", + binary_data=new_contents, + update_process_cache_only=True, + ) + + process_instance = ProcessInstanceModel.query.filter_by(id=process_instance.id).first() + + with pytest.raises(ProcessInstanceMigrationNotSafeError): + ProcessInstanceService.check_process_instance_can_be_migrated(process_instance)