├── .gitignore ├── License.md ├── Pipfile ├── Pipfile.lock ├── README.md ├── bpmn_model.py ├── bpmn_types.py ├── db_connector.py ├── dmn_model.py ├── dmn_types.py ├── env.template.py ├── example.py ├── models └── model_01.bpmn ├── requirements.txt ├── server.py └── utils └── common.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .vscode 3 | models/diagram_1.bpmn 4 | models/chatbot_model.bpmn 5 | models/test_podproces.bpmn 6 | models/test_call_activity.bpmn 7 | models/test_business_rule.bpmn 8 | models/test_dmn.dmn 9 | models/test_exe_dmn.dmn 10 | py-bpmn-env 11 | database/ -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 Nikola Tanković & Srđan Daniel Simić 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | 8 | [packages] 9 | aiohttp = "==3.7.4.post0" 10 | asyncio = "*" 11 | 12 | [requires] 13 | python_version = "3.9" 14 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "f6b9b69e1a772fdc6e4bceccddb48dde2b41e0e3b1c8d9cd2c854a03389cd9c1" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.9" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "aiohttp": { 20 | "hashes": [ 21 | "sha256:02f46fc0e3c5ac58b80d4d56eb0a7c7d97fcef69ace9326289fb9f1955e65cfe", 22 | "sha256:0563c1b3826945eecd62186f3f5c7d31abb7391fedc893b7e2b26303b5a9f3fe", 23 | "sha256:114b281e4d68302a324dd33abb04778e8557d88947875cbf4e842c2c01a030c5", 24 | "sha256:14762875b22d0055f05d12abc7f7d61d5fd4fe4642ce1a249abdf8c700bf1fd8", 25 | "sha256:15492a6368d985b76a2a5fdd2166cddfea5d24e69eefed4630cbaae5c81d89bd", 26 | "sha256:17c073de315745a1510393a96e680d20af8e67e324f70b42accbd4cb3315c9fb", 27 | "sha256:209b4a8ee987eccc91e2bd3ac36adee0e53a5970b8ac52c273f7f8fd4872c94c", 28 | "sha256:230a8f7e24298dea47659251abc0fd8b3c4e38a664c59d4b89cca7f6c09c9e87", 29 | "sha256:2e19413bf84934d651344783c9f5e22dee452e251cfd220ebadbed2d9931dbf0", 30 | "sha256:393f389841e8f2dfc86f774ad22f00923fdee66d238af89b70ea314c4aefd290", 31 | "sha256:3cf75f7cdc2397ed4442594b935a11ed5569961333d49b7539ea741be2cc79d5", 32 | "sha256:3d78619672183be860b96ed96f533046ec97ca067fd46ac1f6a09cd9b7484287", 33 | "sha256:40eced07f07a9e60e825554a31f923e8d3997cfc7fb31dbc1328c70826e04cde", 34 | "sha256:493d3299ebe5f5a7c66b9819eacdcfbbaaf1a8e84911ddffcdc48888497afecf", 35 | "sha256:4b302b45040890cea949ad092479e01ba25911a15e648429c7c5aae9650c67a8", 36 | "sha256:515dfef7f869a0feb2afee66b957cc7bbe9ad0cdee45aec7fdc623f4ecd4fb16", 37 | "sha256:547da6cacac20666422d4882cfcd51298d45f7ccb60a04ec27424d2f36ba3eaf", 38 | "sha256:5df68496d19f849921f05f14f31bd6ef53ad4b00245da3195048c69934521809", 39 | "sha256:64322071e046020e8797117b3658b9c2f80e3267daec409b350b6a7a05041213", 40 | "sha256:7615dab56bb07bff74bc865307aeb89a8bfd9941d2ef9d817b9436da3a0ea54f", 41 | "sha256:79ebfc238612123a713a457d92afb4096e2148be17df6c50fb9bf7a81c2f8013", 42 | "sha256:7b18b97cf8ee5452fa5f4e3af95d01d84d86d32c5e2bfa260cf041749d66360b", 43 | "sha256:932bb1ea39a54e9ea27fc9232163059a0b8855256f4052e776357ad9add6f1c9", 44 | "sha256:a00bb73540af068ca7390e636c01cbc4f644961896fa9363154ff43fd37af2f5", 45 | "sha256:a5ca29ee66f8343ed336816c553e82d6cade48a3ad702b9ffa6125d187e2dedb", 46 | "sha256:af9aa9ef5ba1fd5b8c948bb11f44891968ab30356d65fd0cc6707d989cd521df", 47 | "sha256:bb437315738aa441251214dad17428cafda9cdc9729499f1d6001748e1d432f4", 48 | "sha256:bdb230b4943891321e06fc7def63c7aace16095be7d9cf3b1e01be2f10fba439", 49 | "sha256:c6e9dcb4cb338d91a73f178d866d051efe7c62a7166653a91e7d9fb18274058f", 50 | "sha256:cffe3ab27871bc3ea47df5d8f7013945712c46a3cc5a95b6bee15887f1675c22", 51 | "sha256:d012ad7911653a906425d8473a1465caa9f8dea7fcf07b6d870397b774ea7c0f", 52 | "sha256:d9e13b33afd39ddeb377eff2c1c4f00544e191e1d1dee5b6c51ddee8ea6f0cf5", 53 | "sha256:e4b2b334e68b18ac9817d828ba44d8fcb391f6acb398bcc5062b14b2cbeac970", 54 | "sha256:e54962802d4b8b18b6207d4a927032826af39395a3bd9196a5af43fc4e60b009", 55 | "sha256:f705e12750171c0ab4ef2a3c76b9a4024a62c4103e3a55dd6f99265b9bc6fcfc", 56 | "sha256:f881853d2643a29e643609da57b96d5f9c9b93f62429dcc1cbb413c7d07f0e1a", 57 | "sha256:fe60131d21b31fd1a14bd43e6bb88256f69dfc3188b3a89d736d6c71ed43ec95" 58 | ], 59 | "index": "pypi", 60 | "version": "==3.7.4.post0" 61 | }, 62 | "async-timeout": { 63 | "hashes": [ 64 | "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", 65 | "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" 66 | ], 67 | "markers": "python_full_version >= '3.5.3'", 68 | "version": "==3.0.1" 69 | }, 70 | "asyncio": { 71 | "hashes": [ 72 | "sha256:83360ff8bc97980e4ff25c964c7bd3923d333d177aa4f7fb736b019f26c7cb41", 73 | "sha256:b62c9157d36187eca799c378e572c969f0da87cd5fc42ca372d92cdb06e7e1de", 74 | "sha256:c46a87b48213d7464f22d9a497b9eef8c1928b68320a2fa94240f969f6fec08c", 75 | "sha256:c4d18b22701821de07bd6aea8b53d21449ec0ec5680645e5317062ea21817d2d" 76 | ], 77 | "index": "pypi", 78 | "version": "==3.4.3" 79 | }, 80 | "attrs": { 81 | "hashes": [ 82 | "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", 83 | "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" 84 | ], 85 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 86 | "version": "==20.3.0" 87 | }, 88 | "chardet": { 89 | "hashes": [ 90 | "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa", 91 | "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5" 92 | ], 93 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", 94 | "version": "==4.0.0" 95 | }, 96 | "idna": { 97 | "hashes": [ 98 | "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", 99 | "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" 100 | ], 101 | "markers": "python_version >= '3.4'", 102 | "version": "==3.1" 103 | }, 104 | "multidict": { 105 | "hashes": [ 106 | "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", 107 | "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93", 108 | "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632", 109 | "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656", 110 | "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79", 111 | "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7", 112 | "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d", 113 | "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5", 114 | "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224", 115 | "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26", 116 | "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea", 117 | "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348", 118 | "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6", 119 | "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76", 120 | "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1", 121 | "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f", 122 | "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952", 123 | "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a", 124 | "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37", 125 | "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9", 126 | "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359", 127 | "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8", 128 | "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da", 129 | "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3", 130 | "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d", 131 | "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf", 132 | "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841", 133 | "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d", 134 | "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93", 135 | "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f", 136 | "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647", 137 | "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635", 138 | "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456", 139 | "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda", 140 | "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5", 141 | "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", 142 | "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" 143 | ], 144 | "markers": "python_version >= '3.6'", 145 | "version": "==5.1.0" 146 | }, 147 | "typing-extensions": { 148 | "hashes": [ 149 | "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", 150 | "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", 151 | "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" 152 | ], 153 | "version": "==3.7.4.3" 154 | }, 155 | "yarl": { 156 | "hashes": [ 157 | "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e", 158 | "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434", 159 | "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366", 160 | "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3", 161 | "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec", 162 | "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959", 163 | "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e", 164 | "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c", 165 | "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6", 166 | "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a", 167 | "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6", 168 | "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424", 169 | "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e", 170 | "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f", 171 | "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50", 172 | "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2", 173 | "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc", 174 | "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4", 175 | "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970", 176 | "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10", 177 | "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0", 178 | "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406", 179 | "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896", 180 | "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643", 181 | "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721", 182 | "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478", 183 | "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724", 184 | "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e", 185 | "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8", 186 | "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96", 187 | "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25", 188 | "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76", 189 | "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2", 190 | "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2", 191 | "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c", 192 | "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", 193 | "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" 194 | ], 195 | "markers": "python_version >= '3.6'", 196 | "version": "==1.6.3" 197 | } 198 | }, 199 | "develop": {} 200 | } 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A python module for parsing and executing BPMN models 2 | 3 | ## Supported BPMN elements so far: 4 | 5 | ### Start/end events 6 | 7 | ### User Task 8 | - Form fields 9 | - Id, Type, Label 10 | - Validation, Properties 11 | - Element Documentation 12 | 13 | ### Service Task & Send Task 14 | - Supported implementation at the moment is **Connector** 15 | - Connector Id **must** be `http-connector` 16 | - Input parameters needs to have following parameters: 17 | - **url** -> location of your web service, eg. http://myservice.com/api/call, and it should be **String or Expression** type 18 | - **method** -> http method that you will use for request, eg. GET, and it should be **String or Expression** type 19 | - **url_parameter** -> in case you want to send URL parameter with your request, it should be **Map** type with _key_ and _value_. It is worth noting that _value_ can be simple expression eg. `${my_process_variable}`. Eg. URL request result for _key_ `task_id` and _value_ `${current_task}`, if `current_task` is inside process variables, will be http://myservice.com/api/call?task_id=1 20 | - Input variables and output variables for Service and Send task **must** be specified inside **Input/Output** tab 21 | - Input parameters type at the moment can be **String or Expression** and if you plan to use process variables you should use expression, eg. `${my_process_variable}`, only **String or Expression** type is currently supporting expressions 22 | - All input variables will be send as JSON to the service 23 | - Output parameters will be saved to process variables by their _name_, **String or Expression** is currently advised although only Script is not supported 24 | - It is expected that service response with JSON 25 | - It will try to match Output parameter _name_ with keys inside JSON -> if found -> process_variables\[_name_] = response\[_name_] 26 | 27 | ### Call Activity 28 | - CallActivity Type **must** be BPMN 29 | - Called Element **must** be *process_id* of process you wish to start 30 | - Binding **must** be **deployment** if you wish to call process from other BPMN diagram, other bindings assumes that called process is inside the same diagraas Call Activity 31 | 32 | ### Gateways (Exclusive, Parallel) 33 | 34 | ### Sequence flow with conditions 35 | - To use conditions on sequence flows you **must** chose Condition Type **Expression** and in Expression you need to write `key:value` where `key` is the process variable name and `value` is string 36 | - eg. `standard:BPMN` -> engine will check -> `process_variables["standard"]` == `"BPMN"` -> if True returns True -> process continues on that Sequence Flow 37 | - In the future real expression will be possible 38 | 39 | ### Collaboration Diagrams 40 | - In case there is more then 1 Pool in Collaboration diagram you **MUST** specify in **Extensions/Properties** a property with _name_ `is_main` and _value_ `True` for your **main** Pool so the engine knows where to start the process 41 | 42 | --- 43 | 44 | ## Pending features: 45 | - full fledged REST API 46 | - in/out variables for Call Activity 47 | - all standard BPMN elements 48 | - ... 49 | 50 | --- 51 | 52 | ## Example BPMN model used for demo: 53 | ![image](https://user-images.githubusercontent.com/714889/114159824-81c65d80-9926-11eb-8b74-6d5dd9bb82ea.png) 54 | 55 | > The package can be used as a standalone server exposing a REST API (see `server.py`) 56 | 57 | Example execution trace: 58 | 59 | ```python 60 | Running process 1 61 | ----------------- 62 | [1] --> msg in: t_wrong 63 | [1] Waiting for user... [UserTask(Which option?)] 64 | [1] --> msg in: t0 65 | [1] DOING: UserTask(Which option?) 66 | [1] Waiting for user... [UserTask(Down), UserTask(Up)] 67 | [1] --> msg in: tup 68 | [1] DOING: UserTask(Up) 69 | [1] Waiting for user... [UserTask(Down), ParallelGateway(ParallelGateway_0vffee4)] 70 | [1] --> msg in: t_wrong 71 | [1] Waiting for user... [UserTask(Down), ParallelGateway(ParallelGateway_0vffee4)] 72 | [1] --> msg in: tdown 73 | [1] DOING: UserTask(Down) 74 | [1] DOING: ManualTask(Manual Task 2) 75 | [1] DOING: ServiceTask(Task 3) 76 | [1] - checking variables={} with ['option==1']... 77 | [1] DONE: Result is False 78 | [1] - going down default path... 79 | [1] Waiting for user... [UserTask(Task down)] 80 | [1] --> msg in: t_wrong 81 | [1] Waiting for user... [UserTask(Task down)] 82 | [1] --> msg in: tup2 83 | [1] Waiting for user... [UserTask(Task down)] 84 | [1] --> msg in: t_wrong 85 | [1] Waiting for user... [UserTask(Task down)] 86 | [1] --> msg in: tdown2 87 | [1] DOING: UserTask(Task down) 88 | [1] DONE 89 | Running process 2 90 | ----------------- 91 | [2] --> msg in: t_wrong 92 | [2] Waiting for user... [UserTask(Which option?)] 93 | [2] --> msg in: t0 94 | [2] DOING: UserTask(Which option?) 95 | [2] Waiting for user... [UserTask(Down), UserTask(Up)] 96 | [2] --> msg in: tup 97 | [2] DOING: UserTask(Up) 98 | [2] Waiting for user... [UserTask(Down), ParallelGateway(ParallelGateway_0vffee4)] 99 | [2] --> msg in: t_wrong 100 | [2] Waiting for user... [UserTask(Down), ParallelGateway(ParallelGateway_0vffee4)] 101 | [2] --> msg in: tdown 102 | [2] DOING: UserTask(Down) 103 | [2] DOING: ManualTask(Manual Task 2) 104 | [2] DOING: ServiceTask(Task 3) 105 | [2] - checking variables={} with ['option==1']... 106 | [2] DONE: Result is False 107 | [2] - going down default path... 108 | [2] Waiting for user... [UserTask(Task down)] 109 | [2] --> msg in: t_wrong 110 | [2] Waiting for user... [UserTask(Task down)] 111 | [2] --> msg in: tup2 112 | [2] Waiting for user... [UserTask(Task down)] 113 | [2] --> msg in: t_wrong 114 | [2] Waiting for user... [UserTask(Task down)] 115 | [2] --> msg in: tdown2 116 | [2] DOING: UserTask(Task down) 117 | [2] DONE 118 | ``` 119 | -------------------------------------------------------------------------------- /bpmn_model.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | import xml.etree.ElementTree as ET 3 | from bpmn_types import * 4 | from pprint import pprint 5 | from copy import deepcopy 6 | from collections import defaultdict, deque 7 | from functools import partial 8 | import asyncio 9 | import db_connector 10 | from datetime import datetime 11 | import os 12 | from uuid import uuid4 13 | import env 14 | 15 | instance_models = {} 16 | 17 | 18 | def get_model_for_instance(iid): 19 | return instance_models.get(iid, None) 20 | 21 | 22 | class UserFormMessage: 23 | def __init__(self, task_id, form_data={}): 24 | self.task_id = task_id 25 | self.form_data = form_data 26 | 27 | 28 | class BpmnModel: 29 | def __init__(self, model_path): 30 | self.pending = [] 31 | self.elements = {} 32 | self.flow = defaultdict(list) 33 | self.instances = {} 34 | self.process_elements = {} 35 | self.process_pending = defaultdict(list) 36 | self.main_collaboration_process = None 37 | self.model_path = model_path 38 | self.subprocesses = {} 39 | self.main_process = SimpleNamespace() 40 | 41 | model_tree = ET.parse(os.path.join("models", self.model_path)) 42 | model_root = model_tree.getroot() 43 | processes = model_root.findall("bpmn:process", NS) 44 | for process in processes: 45 | p = BPMN_MAPPINGS["bpmn:process"]() 46 | p.parse(process) 47 | self.process_elements[p._id] = {} 48 | # Check for Collaboration 49 | if len(processes) > 1 and p.is_main_in_collaboration: 50 | self.main_collaboration_process = p._id 51 | self.main_process.name = p.name 52 | self.main_process.id = p._id 53 | else: 54 | self.main_process.name = p.name 55 | self.main_process.id = p._id 56 | # Parse all elements in the process 57 | for tag, _type in BPMN_MAPPINGS.items(): 58 | for e in process.findall(f"{tag}", NS): 59 | t = _type() 60 | t.parse(e) 61 | if isinstance(t, CallActivity): 62 | self.subprocesses[t.called_element] = t.deployment 63 | if isinstance(t, SequenceFlow): 64 | self.flow[t.source].append(t) 65 | if isinstance(t, ExclusiveGateway): 66 | if t.default: 67 | self.elements[t.default].default = True 68 | if isinstance(t, StartEvent): 69 | self.pending.append(t) 70 | self.process_pending[p._id].append(t) 71 | self.elements[t._id] = t 72 | self.process_elements[p._id][t._id] = t 73 | # Check if there is single deployement subprocess 74 | for k, v in self.subprocesses.items(): 75 | if v: 76 | self.handle_deployment_subprocesses() 77 | break 78 | 79 | def to_json(self): 80 | return { 81 | "model_path": self.model_path, 82 | "main_process": self.main_process.__dict__, 83 | "tasks": [ 84 | x.to_json() for x in self.elements.values() if isinstance(x, UserTask) 85 | ], 86 | "instances": [i._id for i in self.instances.values()], 87 | } 88 | 89 | async def create_instance(self, _id, variables, process=None): 90 | queue = asyncio.Queue() 91 | if not process: 92 | if self.main_collaboration_process: 93 | # If Collaboration diagram 94 | process = self.main_collaboration_process 95 | else: 96 | # If Process diagram 97 | process = list(self.process_elements)[0] 98 | instance = BpmnInstance( 99 | _id, model=self, variables=variables, in_queue=queue, process=process 100 | ) 101 | self.instances[_id] = instance 102 | return instance 103 | 104 | # Takes model_path needed for deployed subprocess 105 | def handle_deployment_subprocesses(self): 106 | models_directory = self.model_path.split("/")[:-1] 107 | models_directory = "/".join(models_directory) + "/" 108 | 109 | other_models_list = [] 110 | 111 | for m in os.listdir(models_directory): 112 | if models_directory + m == self.model_path: 113 | continue 114 | other_model = BpmnModel(models_directory + m) 115 | other_models_list.append(other_model) 116 | for other_model in other_models_list: 117 | for subprocess_key in self.subprocesses.keys(): 118 | for process_key in other_model.process_elements.keys(): 119 | if subprocess_key == process_key: 120 | self.subprocesses[subprocess_key] = other_model.model_path 121 | 122 | 123 | class BpmnInstance: 124 | def __init__(self, _id, model, variables, in_queue, process): 125 | instance_models[_id] = model 126 | self._id = _id 127 | self.model = model 128 | self.variables = deepcopy(variables) 129 | self.in_queue = in_queue 130 | self.state = "initialized" 131 | self.pending = deepcopy(self.model.process_pending[process]) 132 | self.process = process 133 | 134 | def to_json(self): 135 | return { 136 | "id": self._id, 137 | "variables": self.variables, 138 | "state": self.state, 139 | "model": self.model.to_json(), 140 | "pending": [x._id for x in self.pending], 141 | "env": env.SYSTEM_VARS, 142 | } 143 | 144 | @classmethod 145 | def check_condition(cls, state, condition, log): 146 | log(f"\t- checking variables={state} with {condition}... ") 147 | ok = False 148 | if condition: 149 | key = condition.partition(":")[0] 150 | value = condition.partition(":")[2] 151 | if key in state and state[key] == value: 152 | ok = True 153 | log("\t DONE: Result is", ok) 154 | return ok 155 | 156 | async def run_from_log(self, log): 157 | for l in log: 158 | if l.get("activity_id") in self.model.elements: 159 | pending_elements_list = [] 160 | for p in l.get("pending"): 161 | pending_elements_list.append(self.model.elements[p]) 162 | self.pending = pending_elements_list 163 | self.variables = {**l.get("activity_variables"), **self.variables} 164 | return self 165 | 166 | async def run_subprocess(self, process_id): 167 | new_subproces_instance_id = str(uuid4()) 168 | if not self.model.subprocesses[process_id]: 169 | new_subprocess_instance = await self.model.create_instance( 170 | new_subproces_instance_id, {}, process_id 171 | ) 172 | finished_subprocess = await new_subprocess_instance.run() 173 | else: 174 | subprocess_model = BpmnModel(self.model.subprocesses[process_id]) 175 | new_subproces_instance = await subprocess_model.create_instance( 176 | new_subproces_instance_id, {}, process_id 177 | ) 178 | finished_subprocess = await new_subproces_instance.run() 179 | return True 180 | 181 | async def run(self): 182 | 183 | self.state = "running" 184 | _id = self._id 185 | prefix = f"\t[{_id}]" 186 | log = partial(print, prefix) # if _id == "2" else lambda *x: x 187 | 188 | in_queue = self.in_queue 189 | # Take only elements of running process 190 | elements = deepcopy(self.model.process_elements[self.process]) 191 | flow = deepcopy(self.model.flow) 192 | queue = deque() 193 | 194 | while len(self.pending) > 0: 195 | 196 | # process incoming messages 197 | if not in_queue.empty(): 198 | queue.append(in_queue.get_nowait()) 199 | # print("Check", _id, id(queue), id(in_queue)) 200 | 201 | exit = False 202 | can_continue = False 203 | 204 | message = queue.pop() if len(queue) else None 205 | if message: 206 | log("--> msg in:", message and message.task_id) 207 | 208 | # Helper current dictionary 209 | current_and_variables_dict = {} 210 | 211 | for idx, current in enumerate(self.pending): 212 | # Helper variables dict 213 | before_variables = deepcopy(self.variables) 214 | 215 | if isinstance(current, StartEvent): 216 | # Helper variables for DB insert 217 | new_variables = { 218 | k: self.variables[k] 219 | for k in set(self.variables) - set(before_variables) 220 | } 221 | current_and_variables_dict[current._id] = new_variables 222 | # Create new running instance 223 | db_connector.add_running_instance(instance_id=self._id) 224 | 225 | if isinstance(current, EndEvent): 226 | exit = True 227 | del self.pending[idx] 228 | # Add EndEvent to DB 229 | db_connector.add_event( 230 | model_name=self.model.model_path, 231 | instance_id=self._id, 232 | activity_id=current._id, 233 | timestamp=datetime.now(), 234 | pending=[], 235 | activity_variables={}, 236 | ) 237 | break 238 | 239 | if isinstance(current, UserTask): 240 | if ( 241 | message 242 | and isinstance(message, UserFormMessage) 243 | and message.task_id == current._id 244 | ): 245 | user_action = message.form_data 246 | 247 | log("DOING:", current) 248 | if user_action: 249 | log("\t- user sent:", user_action) 250 | can_continue = current.run(self.variables, user_action) 251 | # Helper variables for DB insert 252 | new_variables = { 253 | k: self.variables[k] 254 | for k in set(self.variables) - set(before_variables) 255 | } 256 | current_and_variables_dict[current._id] = new_variables 257 | 258 | elif isinstance(current, ServiceTask): 259 | log("DOING:", current) 260 | can_continue = await current.run(self.variables, _id) 261 | # Helper variables for DB insert 262 | new_variables = { 263 | k: self.variables[k] 264 | for k in set(self.variables) - set(before_variables) 265 | } 266 | current_and_variables_dict[current._id] = new_variables 267 | 268 | elif isinstance(current, SendTask): 269 | log("DOING:", current) 270 | can_continue = current.run(self.variables, _id) 271 | # Helper variables for DB insert 272 | new_variables = { 273 | k: self.variables[k] 274 | for k in set(self.variables) - set(before_variables) 275 | } 276 | current_and_variables_dict[current._id] = new_variables 277 | 278 | elif isinstance(current, CallActivity): 279 | # TODO implement Variables tab CallActivity 280 | log("DOING:", current) 281 | can_continue = await self.run_subprocess(current.called_element) 282 | # Helper variables for DB insert 283 | new_variables = { 284 | k: self.variables[k] 285 | for k in set(self.variables) - set(before_variables) 286 | } 287 | current_and_variables_dict[current._id] = new_variables 288 | 289 | else: 290 | if isinstance(current, Task): 291 | log("DOING:", current) 292 | can_continue = current.run() 293 | # Helper variables for DB insert 294 | new_variables = { 295 | k: self.variables[k] 296 | for k in set(self.variables) - set(before_variables) 297 | } 298 | current_and_variables_dict[current._id] = new_variables 299 | 300 | if can_continue: 301 | del self.pending[idx] 302 | break 303 | 304 | if exit: 305 | break 306 | 307 | default = current.default if isinstance(current, ExclusiveGateway) else None 308 | 309 | if can_continue: 310 | next_tasks = [] 311 | if current._id in flow: 312 | default_fallback = None 313 | for sequence in flow[current._id]: 314 | if sequence._id == default: 315 | default_fallback = elements[sequence.target] 316 | continue 317 | 318 | if sequence.condition: 319 | if self.check_condition( 320 | self.variables, sequence.condition, log 321 | ): 322 | next_tasks.append(elements[sequence.target]) 323 | else: 324 | next_tasks.append(elements[sequence.target]) 325 | 326 | if not next_tasks and default_fallback: 327 | log("\t- going down default path...") 328 | next_tasks.append(default_fallback) 329 | 330 | for next_task in next_tasks: 331 | if next_task not in self.pending: 332 | self.pending.append(next_task) 333 | # log("-----> Adding", next_task) 334 | # log("n", next_task) 335 | if isinstance(next_task, ParallelGateway): 336 | next_task.add_token() 337 | else: 338 | log("Waiting for user...", self.pending) 339 | queue.append(await in_queue.get()) 340 | 341 | # Insert finished events into DB 342 | for c in current_and_variables_dict: 343 | # Add each current into DB 344 | db_connector.add_event( 345 | model_name=self.model.model_path, 346 | instance_id=self._id, 347 | activity_id=c, 348 | timestamp=datetime.now(), 349 | pending=[pending._id for pending in self.pending], 350 | activity_variables=current_and_variables_dict[c], 351 | ) 352 | 353 | log("DONE") 354 | self.state = "finished" 355 | self.pending = [] 356 | # Running instance finished 357 | db_connector.finish_running_instance(self._id) 358 | return self.variables 359 | -------------------------------------------------------------------------------- /bpmn_types.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | import env 4 | from utils.common import parse_expression 5 | 6 | NS = { 7 | "bpmn": "http://www.omg.org/spec/BPMN/20100524/MODEL", 8 | "camunda": "http://camunda.org/schema/1.0/bpmn", 9 | } 10 | 11 | BPMN_MAPPINGS = {} 12 | 13 | 14 | def bpmn_tag(tag): 15 | def wrap(object): 16 | object.tag = tag 17 | BPMN_MAPPINGS[tag] = object 18 | return object 19 | 20 | return wrap 21 | 22 | 23 | class BpmnObject(object): 24 | def __repr__(self): 25 | return f"{type(self).__name__}({self.name or self._id})" 26 | 27 | def to_json(self): 28 | return { 29 | "_id": self._id, 30 | "name": self.name, 31 | } 32 | 33 | def parse(self, element): 34 | self._id = element.attrib["id"] 35 | self.name = element.attrib["name"] if "name" in element.attrib else None 36 | 37 | def run(self): 38 | return True 39 | 40 | 41 | @bpmn_tag("bpmn:process") 42 | class Process(BpmnObject): 43 | def __init__(self): 44 | self.is_main_in_collaboration = None 45 | self.name = None 46 | 47 | def parse(self, element): 48 | super(Process, self).parse(element) 49 | # Extensions should exists only if it's Collaboration diagram 50 | self.name = element.attrib["name"] 51 | if element.find(".bpmn:extensionElements", NS): 52 | ext = element.find(".bpmn:extensionElements", NS) 53 | for p in ext.findall(".//camunda:property", NS): 54 | # Find property is_main 55 | if p.attrib["name"] == "is_main" and p.attrib["value"] == "True": 56 | self.is_main_in_collaboration = True 57 | 58 | 59 | @bpmn_tag("bpmn:sequenceFlow") 60 | class SequenceFlow(BpmnObject): 61 | def __init__(self): 62 | self.source = None 63 | self.target = None 64 | self.condition = None 65 | 66 | def parse(self, element): 67 | super(SequenceFlow, self).parse(element) 68 | self.source = element.attrib["sourceRef"] 69 | self.target = element.attrib["targetRef"] 70 | for c in element.findall("bpmn:conditionExpression", NS): 71 | self.condition = c.text 72 | 73 | def __repr__(self): 74 | condition = f" w. {len(self.condition)} con. " if self.condition else "" 75 | return f"{type(self).__name__}({self._id}): {self.source} -> {self.target}{condition}" 76 | 77 | pass 78 | 79 | 80 | @bpmn_tag("bpmn:task") 81 | class Task(BpmnObject): 82 | def parse(self, element): 83 | super(Task, self).parse(element) 84 | 85 | def get_info(self): 86 | return {"type": self.tag} 87 | 88 | 89 | @bpmn_tag("bpmn:manualTask") 90 | class ManualTask(Task): 91 | pass 92 | 93 | 94 | @bpmn_tag("bpmn:userTask") 95 | class UserTask(Task): 96 | def __init__(self): 97 | self.form_fields = {} 98 | self.documentation = "" 99 | 100 | def parse(self, element): 101 | super(UserTask, self).parse(element) 102 | for f in element.findall(".//camunda:formField", NS): 103 | form_field_properties_dict = {} 104 | form_field_validations_dict = {} 105 | 106 | self.form_fields[f.attrib["id"]] = {} 107 | self.form_fields[f.attrib["id"]]["type"] = f.attrib["type"] 108 | if "label" in f.attrib: 109 | self.form_fields[f.attrib["id"]]["label"] = f.attrib["label"] 110 | else: 111 | self.form_fields[f.attrib["id"]]["label"] = "" 112 | 113 | for p in f.findall(".//camunda:property", NS): 114 | form_field_properties_dict[p.attrib["id"]] = parse_expression( 115 | p.attrib["value"], env.SYSTEM_VARS | env.DS 116 | ) 117 | 118 | for v in f.findall(".//camunda:constraint", NS): 119 | form_field_validations_dict[v.attrib["name"]] = v.attrib["config"] 120 | 121 | self.form_fields[f.attrib["id"]]["validation"] = form_field_validations_dict 122 | self.form_fields[f.attrib["id"]]["properties"] = form_field_properties_dict 123 | 124 | for d in element.findall(".//bpmn:documentation", NS): 125 | self.documentation = d.text 126 | 127 | def run(self, state, user_input): 128 | for k, v in user_input.items(): 129 | if k in self.form_fields: 130 | state[k] = v 131 | return True 132 | 133 | def get_info(self): 134 | info = super(UserTask, self).get_info() 135 | return { 136 | **info, 137 | "form_fields": self.form_fields, 138 | "documentation": self.documentation, 139 | } 140 | 141 | 142 | @bpmn_tag("bpmn:serviceTask") 143 | class ServiceTask(Task): 144 | def __init__(self): 145 | self.properties_fields = {} 146 | self.input_variables = {} 147 | self.output_variables = {} 148 | self.connector_fields = { 149 | "connector_id": "", 150 | "input_variables": {}, 151 | "output_variables": {}, 152 | } 153 | 154 | def parse(self, element): 155 | super(ServiceTask, self).parse(element) 156 | 157 | datasources = {} 158 | try: 159 | datasources = env.DS 160 | except Exception: 161 | print("No DS in env.py") 162 | 163 | for ee in element.findall(".//bpmn:extensionElements", NS): 164 | # Find direct children inputOutput, Input/Output tab in Camunda 165 | self._parse_input_output_variables( 166 | ee, self.input_variables, self.output_variables 167 | ) 168 | # Find connector data, Connector tab in Camunda 169 | for con in ee.findall(".camunda:connector", NS): 170 | self._parse_input_output_variables( 171 | con, 172 | self.connector_fields["input_variables"], 173 | self.connector_fields["output_variables"], 174 | ) 175 | connector_id = con.find("camunda:connectorId", NS).text 176 | if connector_id in datasources: 177 | ds = datasources[connector_id] 178 | self.connector_fields["connector_id"] = ds["type"] 179 | self.connector_fields["input_variables"]["base_url"] = ds["url"] 180 | 181 | def _parse_input_output_variables(self, element, input_dict, output_dict): 182 | for io in element.findall(".camunda:inputOutput", NS): 183 | for inparam in io.findall(".camunda:inputParameter", NS): 184 | self._parse_input_output_parameters(inparam, input_dict) 185 | for outparam in io.findall(".camunda:outputParameter", NS): 186 | self._parse_input_output_parameters(outparam, output_dict) 187 | 188 | def _parse_input_output_parameters(self, element, dictionary): 189 | if element.findall(".camunda:list", NS): 190 | helper_list = [] 191 | for lv in element.find("camunda:list", NS): 192 | helper_list.append(lv.text) if lv.text else "" 193 | dictionary[element.attrib["name"]] = helper_list 194 | elif element.findall(".camunda:map", NS): 195 | helper_dict = {} 196 | for mv in element.find("camunda:map", NS): 197 | helper_dict[mv.attrib["key"]] = mv.text 198 | dictionary[element.attrib["name"]] = helper_dict 199 | elif element.findall(".camunda:script", NS): 200 | # script not supported 201 | pass 202 | else: 203 | dictionary[element.attrib["name"]] = element.text if element.text else "" 204 | 205 | async def run_connector(self, variables, instance_id): 206 | # Check for URL parameters 207 | parameters = {} 208 | if self.connector_fields["input_variables"].get("url_parameter"): 209 | for key, value in self.connector_fields["input_variables"][ 210 | "url_parameter" 211 | ].items(): 212 | # Parse expression and add to parameters 213 | parameters[key] = parse_expression(value, variables) 214 | 215 | # JSON data for API 216 | data = {} 217 | for key, value in self.input_variables.items(): 218 | # Parse expression if it exists 219 | if isinstance(value, str): 220 | value = parse_expression(value, variables) 221 | elif isinstance(value, list): 222 | value = [parse_expression(v, variables) for v in value] 223 | elif isinstance(value, dict): 224 | for k, v in value.items(): 225 | value[k] = parse_expression(v, variables) 226 | # Special case for instance id 227 | if key == "id_instance": 228 | value = instance_id 229 | # Add parsed value to data 230 | data[key] = value 231 | # system vars 232 | data = {**data, **env.SYSTEM_VARS} 233 | 234 | url = os.path.join( 235 | self.connector_fields["input_variables"].get("base_url", ""), 236 | self.connector_fields["input_variables"]["url"].lstrip("/"), 237 | ) 238 | 239 | # Check method and make request 240 | if method := self.connector_fields["input_variables"].get("method"): 241 | if method == "POST": 242 | call_function = requests.post 243 | elif method == "PATCH": 244 | call_function = requests.patch 245 | else: 246 | call_function = requests.get 247 | 248 | response = call_function( 249 | url, 250 | params=parameters, 251 | json=data, 252 | ) 253 | 254 | if response.status_code not in (200, 201): 255 | raise Exception(response.text) 256 | 257 | # Check for output variables 258 | if self.output_variables: 259 | r = response.json() 260 | for key in self.output_variables: 261 | if key in r: 262 | variables[key] = r[key] 263 | 264 | async def run(self, variables, instance_id): 265 | if self.connector_fields["connector_id"] == "http-connector": 266 | await self.run_connector(variables, instance_id) 267 | return True 268 | 269 | 270 | @bpmn_tag("bpmn:sendTask") 271 | class SendTask(ServiceTask): 272 | def parse(self, element): 273 | super(SendTask, self).parse(element) 274 | 275 | 276 | @bpmn_tag("bpmn:callActivity") 277 | class CallActivity(Task): 278 | def __init__(self): 279 | self.deployment = False 280 | self.called_element = "" 281 | 282 | def parse(self, element): 283 | super(CallActivity, self).parse(element) 284 | if element.attrib.get("calledElement"): 285 | self.called_element = element.attrib["calledElement"] 286 | if ( 287 | element.attrib.get(f"{{{NS['camunda']}}}calledElementBinding") 288 | and element.attrib.get(f"{{{NS['camunda']}}}calledElementBinding") 289 | == "deployment" 290 | ): 291 | self.deployment = True 292 | 293 | 294 | @bpmn_tag("bpmn:businessRule") 295 | class BusinessRule(ServiceTask): 296 | def __init__(self): 297 | self.decision_ref = None 298 | 299 | def parse(self, element): 300 | super(BusinessRule, self).parse(element) 301 | 302 | 303 | @bpmn_tag("bpmn:event") 304 | class Event(BpmnObject): 305 | pass 306 | 307 | 308 | @bpmn_tag("bpmn:startEvent") 309 | class StartEvent(Event): 310 | pass 311 | 312 | 313 | @bpmn_tag("bpmn:endEvent") 314 | class EndEvent(Event): 315 | pass 316 | 317 | 318 | @bpmn_tag("bpmn:gateway") 319 | class Gateway(BpmnObject): 320 | def parse(self, element): 321 | self.incoming = len(element.findall("bpmn:incoming", NS)) 322 | self.outgoing = len(element.findall("bpmn:outgoing", NS)) 323 | super(Gateway, self).parse(element) 324 | 325 | 326 | @bpmn_tag("bpmn:parallelGateway") 327 | class ParallelGateway(Gateway): 328 | def add_token(self): 329 | self.incoming -= 1 330 | 331 | def run(self): 332 | return self.incoming == 0 333 | 334 | 335 | @bpmn_tag("bpmn:exclusiveGateway") 336 | class ExclusiveGateway(Gateway): 337 | def __init__(self): 338 | self.default = False 339 | super(ExclusiveGateway, self).__init__() 340 | 341 | def parse(self, element): 342 | self.default = ( 343 | element.attrib["default"] if "default" in element.attrib else None 344 | ) 345 | super(ExclusiveGateway, self).parse(element) 346 | -------------------------------------------------------------------------------- /db_connector.py: -------------------------------------------------------------------------------- 1 | from pony.orm import * 2 | from datetime import datetime 3 | import env 4 | import os 5 | 6 | DB = Database() 7 | 8 | 9 | class Event(DB.Entity): 10 | model_name = Required(str) 11 | instance_id = Required(str) 12 | activity_id = Required(str) 13 | timestamp = Required(datetime, precision=6) 14 | pending = Required(StrArray) 15 | activity_variables = Required(Json) 16 | 17 | 18 | class RunningInstance(DB.Entity): 19 | running = Required(bool) 20 | instance_id = Required(str, unique=True) 21 | 22 | 23 | def setup_db(provider=None, recreate=False): 24 | 25 | if not provider: 26 | provider == env.DB["provider"] 27 | 28 | if not os.path.isdir("database"): 29 | os.mkdir("database") 30 | 31 | if provider == "postgres": 32 | DB.bind(**env.DB) 33 | else: 34 | DB.bind(provider="sqlite", filename="database/database.sqlite", create_db=True) 35 | 36 | DB.generate_mapping() 37 | 38 | if recreate: 39 | DB.drop_all_tables(with_all_data=True) 40 | DB.create_tables() 41 | 42 | 43 | @db_session 44 | def add_event( 45 | model_name, instance_id, activity_id, timestamp, pending, activity_variables 46 | ): 47 | Event( 48 | model_name=model_name, 49 | instance_id=instance_id, 50 | activity_id=activity_id, 51 | timestamp=timestamp, 52 | pending=pending, 53 | activity_variables=activity_variables, 54 | ) 55 | 56 | 57 | @db_session 58 | def add_running_instance(instance_id): 59 | RunningInstance(instance_id=instance_id, running=True) 60 | 61 | 62 | @db_session 63 | def finish_running_instance(instance): 64 | finished_instance = RunningInstance.get(instance_id=instance) 65 | finished_instance.running = False 66 | 67 | 68 | @db_session 69 | def get_running_instances_log(): 70 | log = [] 71 | running_instances = RunningInstance.select(lambda ri: ri.running == True)[:] 72 | for instance in running_instances: 73 | instance_dict = {} 74 | instance_dict[instance.instance_id] = {} 75 | events = Event.select(lambda e: e.instance_id == instance.instance_id).order_by( 76 | Event.timestamp 77 | )[:] 78 | events_list = [] 79 | for event in events: 80 | model_path = event.model_name 81 | event_dict = {} 82 | event_dict["activity_id"] = event.activity_id 83 | event_dict["pending"] = event.pending 84 | event_dict["activity_variables"] = event.activity_variables 85 | events_list.append(event_dict) 86 | 87 | instance_dict[instance.instance_id]["model_path"] = model_path 88 | instance_dict[instance.instance_id]["events"] = events_list 89 | log.append(instance_dict) 90 | 91 | return log 92 | -------------------------------------------------------------------------------- /dmn_model.py: -------------------------------------------------------------------------------- 1 | import xml.etree.ElementTree as ET 2 | from dmn_types import * 3 | from collections import deque 4 | from copy import deepcopy 5 | 6 | 7 | class DmnModel: 8 | def __init__(self, model_path): 9 | self.model_path = model_path 10 | self.decisions = {} 11 | 12 | model_tree = ET.parse(self.model_path) 13 | model_root = model_tree.getroot() 14 | decisions = model_root.findall("dmn:decision", NS) 15 | for decision in decisions: 16 | d = DMN_MAPPINGS["dmn:decision"]() 17 | d.parse(decision) 18 | self.decisions[d._id] = d 19 | 20 | async def create_instance(self, _id, bpmn_input_variables): 21 | instance = DmnInstance(_id, bpmn_input_variables, model=self) 22 | return instance 23 | 24 | 25 | class DmnInstance: 26 | def __init__(self, _id, bpmn_input_variables, model): 27 | self._id = _id 28 | self.bpmn_input_variables = bpmn_input_variables 29 | self.model = model 30 | self.decisions = model.decisions 31 | self.decisions_queue = deque(self.sort_required_decision_list()) 32 | 33 | print("Final Decision queue : ", self.decisions_queue) 34 | 35 | def sort_required_decision_list(self): 36 | helper_list = [] 37 | for current, _ in self.model.decisions.items(): 38 | helper_list.append(current) 39 | list_copy = deepcopy(helper_list) 40 | if not self.decisions[current].required_decisions: 41 | helper_list.remove(current) 42 | helper_list.insert(0, current) 43 | continue 44 | for pos, dec in enumerate(list_copy): 45 | # Current is required for decisions in helper list 46 | if current in self.decisions[dec].required_decisions: 47 | # Current is already in good position 48 | if helper_list.index(current) < helper_list.index(dec): 49 | continue 50 | # Put current before decision it is required for 51 | else: 52 | helper_list.remove(current) 53 | helper_list.insert(pos, current) 54 | if dec in self.decisions[current].required_decisions: 55 | # Current is before its required decision... 56 | # I don't think this case is possible, but additional testing is needed 57 | if helper_list.index(current) < helper_list.index(dec): 58 | print("Intervention needed") 59 | return helper_list 60 | 61 | async def run(self): 62 | decisions_queue = deepcopy(self.decisions_queue) 63 | input_variables = deepcopy(self.bpmn_input_variables) 64 | while decisions_queue: 65 | current_decision = decisions_queue.popleft() 66 | current_decision = self.decisions[current_decision] 67 | output = current_decision.run(input_variables) 68 | input_variables = {**output, **input_variables} 69 | return output 70 | -------------------------------------------------------------------------------- /dmn_types.py: -------------------------------------------------------------------------------- 1 | NS = {"dmn": "https://www.omg.org/spec/DMN/20191111/MODEL/"} 2 | 3 | DMN_MAPPINGS = {} 4 | 5 | 6 | def dmn_tag(tag): 7 | def wrap(object): 8 | object.tag = tag 9 | DMN_MAPPINGS[tag] = object 10 | return object 11 | 12 | return wrap 13 | 14 | 15 | class DmnObject(object): 16 | def __repr__(self): 17 | return f"{type(self).__name__}({self.name or self._id})" 18 | 19 | def parse(self, element): 20 | self._id = element.attrib["id"] 21 | self.name = element.attrib["name"] if "name" in element.attrib else None 22 | 23 | def run(self): 24 | return True 25 | 26 | 27 | @dmn_tag("dmn:decision") 28 | class Decision(DmnObject): 29 | def __init__(self): 30 | self.required_decisions = [] 31 | self.decision_table = None 32 | 33 | def parse(self, element): 34 | super(Decision, self).parse(element) 35 | for req_decision in element.findall(".//dmn:requiredDecision", NS): 36 | self.required_decisions.append(req_decision.attrib["href"][1:]) 37 | self.decision_table = DecisionTable() 38 | self.decision_table.parse(element.find("dmn:decisionTable", NS)) 39 | 40 | def run(self, variables): 41 | return self.decision_table.run(variables) 42 | 43 | 44 | class DecisionTable(DmnObject): 45 | def __init__(self): 46 | self.hit_policy = None 47 | self.input_variables = [] 48 | self.output_names = [] 49 | self.rules = [] 50 | 51 | def parse(self, element): 52 | super(DecisionTable, self).parse(element) 53 | self.hit_policy = ( 54 | element.attrib["hitPolicy"] if "hitPolicy" in element.attrib else "UNIQUE" 55 | ) 56 | # The input expression determines the input value of a column 57 | for input_expression in element.findall(".//dmn:inputExpression", NS): 58 | self.input_variables.append(input_expression.find("dmn:text", NS).text) 59 | for output in element.findall("dmn:output", NS): 60 | self.output_names.append(output.attrib["name"]) 61 | for rule in element.findall("dmn:rule", NS): 62 | rule_dict = {"input": {}, "output": {}} 63 | for position, input_entry in enumerate(rule.findall("dmn:inputEntry", NS)): 64 | rule_dict["input"][self.input_variables[position]] = input_entry.find( 65 | "dmn:text", NS 66 | ).text 67 | for position, output_entry in enumerate( 68 | rule.findall("dmn:outputEntry", NS) 69 | ): 70 | rule_dict["output"][self.output_names[position]] = output_entry.find( 71 | "dmn:text", NS 72 | ).text 73 | self.rules.append(rule_dict) 74 | 75 | @staticmethod 76 | def check_rule(rule, variables): 77 | check_list = [] 78 | for column in rule: 79 | if not rule[column]: 80 | check_list.append(True) 81 | continue 82 | try: 83 | variables[column] 84 | except KeyError: 85 | check_list.append(False) 86 | continue 87 | if rule[column] == variables[column]: 88 | check_list.append(True) 89 | else: 90 | check_list.append(False) 91 | return all(check_list) 92 | 93 | def unique_hit_policy_run(self, variables): 94 | pass 95 | 96 | def first_hit_policy_run(self, variables): 97 | for rule in self.rules: 98 | if self.check_rule(rule["input"], variables): 99 | return rule["output"] 100 | 101 | def run(self, variables): 102 | if self.hit_policy == "UNIQUE": 103 | output = self.unique_hit_policy_run(variables) 104 | if self.hit_policy == "FIRST": 105 | output = self.first_hit_policy_run(variables) 106 | return output 107 | -------------------------------------------------------------------------------- /env.template.py: -------------------------------------------------------------------------------- 1 | SYSTEM_VARS = {"_frontend_url": "http://localhost:9001"} 2 | DB = { 3 | "provider": "postgres", 4 | "user": "FILLME", 5 | "password": "FILLME", 6 | "host": "localhost", 7 | "database": "bpmn_praksa", 8 | } 9 | DS = { 10 | "airtable": {"type": "http-connector", "url": "http://0.0.0.0:8082"}, 11 | "notification": {"type": "http-connector", "url": "http://0.0.0.0:8081"}, 12 | "pdf": {"type": "http-connector", "url": "http://0.0.0.0:8083"}, 13 | } 14 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from bpmn_model import BpmnModel, UserFormMessage 3 | from db_connector import setup_db 4 | import random 5 | 6 | 7 | m = BpmnModel("model_01.bpmn") 8 | NUM_INSTANCES = 1 9 | setup_db(provider="sqlite", recreate=True) 10 | 11 | 12 | async def get_workload(): 13 | return [await m.create_instance(str(i + 1), {}) for i in range(NUM_INSTANCES)] 14 | 15 | 16 | async def simulate_user(q): 17 | WAIT = 0.01 18 | 19 | def auto(text): 20 | return "" 21 | 22 | def ask(text): 23 | text = auto(text) 24 | # sys.stdout.write(f"\t[?] {text}") 25 | # sys.stdout.flush() 26 | # text = sys.stdin.readline().strip() 27 | return ( 28 | { 29 | key: value 30 | for statement in (text.split(",") if "," in text else [text]) 31 | for key, value in statement.split("=") 32 | } 33 | if text 34 | else {} 35 | ) 36 | 37 | q.put_nowait(UserFormMessage("t_wrong", "null")) # Wrong message 38 | await asyncio.sleep(WAIT) 39 | 40 | a = random.randint(1, 2) 41 | default = f"option={a}" 42 | data = ask(f"Form input: [{default}]") 43 | q.put_nowait(UserFormMessage("t0", data if data != "" else default)) 44 | await asyncio.sleep(WAIT) 45 | 46 | q.put_nowait(UserFormMessage("tup", ask("Form input [tup]: "))) 47 | await asyncio.sleep(WAIT) 48 | 49 | q.put_nowait(UserFormMessage("t_wrong", "null")) # Wrong message 50 | await asyncio.sleep(WAIT) 51 | 52 | q.put_nowait(UserFormMessage("tdown", ask("Form input [tdown]: "))) 53 | await asyncio.sleep(WAIT) 54 | 55 | q.put_nowait(UserFormMessage("t_wrong", "null")) # Wrong message 56 | await asyncio.sleep(WAIT) 57 | 58 | q.put_nowait(UserFormMessage("tup2", ask("Form input [tup2]: "))) 59 | await asyncio.sleep(WAIT) 60 | 61 | q.put_nowait(UserFormMessage("t_wrong", "null")) # Wrong message 62 | await asyncio.sleep(WAIT) 63 | 64 | q.put_nowait(UserFormMessage("tdown2", ask("Form input [tdown2]: "))) 65 | await asyncio.sleep(WAIT) 66 | 67 | 68 | def run_serial(): 69 | async def serial(): 70 | instances = await get_workload() 71 | for i, p in enumerate(instances): 72 | print(f"Running process {i+1}\n-----------------") 73 | await asyncio.gather(simulate_user(p.in_queue), p.run()) 74 | 75 | asyncio.run(serial()) 76 | 77 | 78 | def run_parallel(): 79 | async def parallel(): 80 | instances = await get_workload() 81 | users = [simulate_user(i.in_queue) for i in instances] 82 | processes = [p.run() for p in instances] 83 | await asyncio.gather(*users, *processes) 84 | 85 | print(f"Running processes\n-----------------") 86 | asyncio.run(parallel()) 87 | 88 | 89 | # run_parallel() 90 | run_serial() 91 | print("END") 92 | -------------------------------------------------------------------------------- /models/model_01.bpmn: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | ParallelGateway_0vffee4 10 | mt 11 | ExclusiveGateway_0ms7zfc 12 | EndEvent_16dmtu6 13 | tup2 14 | t0 15 | ParallelGateway_05lp38c 16 | tup 17 | 18 | 19 | StartEvent_1 20 | Task_1xs7acu 21 | ExclusiveGateway_111m58w 22 | tdown2 23 | tdown 24 | 25 | 26 | 27 | SequenceFlow_1xhi1kt 28 | SequenceFlow_1keqevg 29 | SequenceFlow_1h2dv1o 30 | 31 | 32 | SequenceFlow_1h2dv1o 33 | SequenceFlow_0b203rr 34 | 35 | 36 | SequenceFlow_01mc012 37 | SequenceFlow_0uy1pg3 38 | SequenceFlow_0yryyr8 39 | 40 | 41 | SequenceFlow_0yryyr8 42 | 43 | 44 | SequenceFlow_0a71cf1 45 | 46 | 47 | SequenceFlow_0b203rr 48 | SequenceFlow_0d1prpw 49 | 50 | 51 | SequenceFlow_0d1prpw 52 | SequenceFlow_0iwa4bi 53 | SequenceFlow_1k3v5iz 54 | 55 | 56 | SequenceFlow_0iwa4bi 57 | SequenceFlow_01mc012 58 | 59 | 60 | SequenceFlow_1k3v5iz 61 | SequenceFlow_0uy1pg3 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | SequenceFlow_0a71cf1 70 | SequenceFlow_1jdxquy 71 | 72 | 73 | SequenceFlow_0snwxlv 74 | SequenceFlow_1xhi1kt 75 | 76 | 77 | SequenceFlow_1jdxquy 78 | SequenceFlow_0g56lcu 79 | SequenceFlow_0snwxlv 80 | 81 | 82 | SequenceFlow_0g56lcu 83 | SequenceFlow_1keqevg 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | option==1 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.7.4.post0 2 | aiohttp-cors==0.7.0 3 | async-timeout==3.0.1 4 | asyncio==3.4.3 5 | attrs==21.2.0 6 | certifi==2021.5.30 7 | chardet==4.0.0 8 | charset-normalizer==2.0.4 9 | idna==3.2 10 | multidict==5.1.0 11 | pony==0.7.14 12 | requests==2.26.0 13 | typing-extensions==3.10.0.0 14 | urllib3==1.26.6 15 | yarl==1.6.3 16 | psycopg2==2.9.1 17 | gunicorn==20.1.0 -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import os 3 | from aiohttp import web 4 | from uuid import uuid4 5 | import asyncio 6 | from bpmn_model import BpmnModel, UserFormMessage, get_model_for_instance 7 | import aiohttp_cors 8 | import db_connector 9 | from functools import reduce 10 | 11 | # Setup database 12 | db_connector.setup_db() 13 | routes = web.RouteTableDef() 14 | 15 | # uuid4 = lambda: 2 # hardcoded for easy testing 16 | 17 | models = {} 18 | for file in os.listdir("models"): 19 | if file.endswith(".bpmn"): 20 | m = BpmnModel(file) 21 | models[file] = m 22 | 23 | 24 | async def run_as_server(app): 25 | app["bpmn_models"] = models 26 | log = db_connector.get_running_instances_log() 27 | for l in log: 28 | for key, data in l.items(): 29 | if data["model_path"] in app["bpmn_models"]: 30 | instance = await app["bpmn_models"][data["model_path"]].create_instance( 31 | key, {} 32 | ) 33 | instance = await instance.run_from_log(data["events"]) 34 | asyncio.create_task(instance.run()) 35 | 36 | 37 | @routes.get("/model") 38 | async def get_models(request): 39 | data = [m.to_json() for m in models.values()] 40 | return web.json_response({"status": "ok", "results": data}) 41 | 42 | 43 | @routes.get("/model/{model_name}") 44 | async def get_model(request): 45 | model_name = request.match_info.get("model_name") 46 | return web.FileResponse( 47 | path=os.path.join("models", app["bpmn_models"][model_name].model_path) 48 | ) 49 | 50 | 51 | @routes.post("/model/{model_name}/instance") 52 | async def handle_new_instance(request): 53 | _id = str(uuid4()) 54 | model = request.match_info.get("model_name") 55 | instance = await app["bpmn_models"][model].create_instance(_id, {}) 56 | asyncio.create_task(instance.run()) 57 | return web.json_response({"id": _id}) 58 | 59 | 60 | @routes.post("/instance/{instance_id}/task/{task_id}/form") 61 | async def handle_form(request): 62 | post = await request.json() 63 | instance_id = request.match_info.get("instance_id") 64 | task_id = request.match_info.get("task_id") 65 | m = get_model_for_instance(instance_id) 66 | m.instances[instance_id].in_queue.put_nowait(UserFormMessage(task_id, post)) 67 | 68 | return web.json_response({"status": "OK"}) 69 | 70 | 71 | @routes.get("/instance") 72 | async def search_instance(request): 73 | params = request.rel_url.query 74 | queries = [] 75 | try: 76 | strip_lower = lambda x: x.strip().lower() 77 | check_colon = lambda x: x if ":" in x else f":{x}" 78 | 79 | queries = list( 80 | tuple( 81 | map( 82 | strip_lower, 83 | check_colon(q).split(":"), 84 | ) 85 | ) 86 | for q in params["q"].split(",") 87 | ) 88 | except: 89 | return web.json_response({"error": "invalid_query"}, status=400) 90 | 91 | result_ids = [] 92 | for (att, value) in queries: 93 | ids = [] 94 | for m in models.values(): 95 | for _id, instance in m.instances.items(): 96 | search_atts = [] 97 | if not att: 98 | search_atts = list(instance.variables.keys()) 99 | else: 100 | for key in instance.variables.keys(): 101 | if not att or att in key.lower(): 102 | search_atts.append(key) 103 | search_atts = filter( 104 | lambda x: isinstance(instance.variables[x], str), search_atts 105 | ) 106 | 107 | for search_att in search_atts: 108 | if search_att and value in instance.variables[search_att].lower(): 109 | # data.append(instance.to_json()) 110 | ids.append(_id) 111 | result_ids.append(set(ids)) 112 | 113 | ids = reduce(lambda a, x: a.intersection(x), result_ids[:-1], result_ids[0]) 114 | 115 | data = [] 116 | for _id in ids: 117 | data.append(get_model_for_instance(_id).instances[_id].to_json()) 118 | 119 | return web.json_response({"status": "ok", "results": data}) 120 | 121 | 122 | @routes.get("/instance/{instance_id}/task/{task_id}") 123 | async def handle_task_info(request): 124 | instance_id = request.match_info.get("instance_id") 125 | task_id = request.match_info.get("task_id") 126 | m = get_model_for_instance(instance_id) 127 | if not m: 128 | raise aiohttp.web.HTTPNotFound 129 | instance = m.instances[instance_id] 130 | task = instance.model.elements[task_id] 131 | 132 | return web.json_response(task.get_info()) 133 | 134 | 135 | @routes.get("/instance/{instance_id}") 136 | async def handle_instance_info(request): 137 | instance_id = request.match_info.get("instance_id") 138 | m = get_model_for_instance(instance_id) 139 | if not m: 140 | raise aiohttp.web.HTTPNotFound 141 | instance = m.instances[instance_id].to_json() 142 | 143 | return web.json_response(instance) 144 | 145 | 146 | app = None 147 | 148 | 149 | def run(): 150 | global app 151 | app = web.Application() 152 | app.on_startup.append(run_as_server) 153 | app.add_routes(routes) 154 | 155 | cors = aiohttp_cors.setup( 156 | app, 157 | defaults={ 158 | "*": aiohttp_cors.ResourceOptions( 159 | allow_credentials=True, 160 | expose_headers="*", 161 | allow_headers="*", 162 | allow_methods="*", 163 | ) 164 | }, 165 | ) 166 | 167 | for route in list(app.router.routes()): 168 | cors.add(route) 169 | 170 | return app 171 | 172 | 173 | async def serve(): 174 | return run() 175 | 176 | 177 | if __name__ == "__main__": 178 | app = run() 179 | web.run_app(app, port=9000) 180 | -------------------------------------------------------------------------------- /utils/common.py: -------------------------------------------------------------------------------- 1 | class SafeDict(dict): 2 | def __missing__(self, key): 3 | return "${" + key + "}" 4 | 5 | 6 | def parse_expression(expression, process_variables): 7 | if (key := expression.replace("${", "").replace("}", "")) in process_variables: 8 | return process_variables[key] 9 | 10 | return expression.replace("${", "{").format_map(SafeDict(process_variables)) 11 | 12 | 13 | if __name__ == "__main__": 14 | test = "___${a[nice]}___" 15 | print(parse_expression(test, {"a": {"nice": ["OK"]}})) 16 | --------------------------------------------------------------------------------