Merge pull request #27 from sartography/improvement/better-interactive-workflow-runner

Improvement/better interactive workflow runner
This commit is contained in:
Elizabeth Esswein 2024-02-09 09:40:20 -05:00 committed by GitHub
commit fdcdf1eade
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 2774 additions and 931 deletions

1
.gitignore vendored
View File

@ -6,3 +6,4 @@ docs/build
docs/_build
__pycache__
*.log
*.db

View File

@ -1,4 +1,6 @@
FROM python:3.10.4-slim-bullseye
FROM python:3.11-slim-bookworm
RUN apt-get update && apt-get install -y git sqlite3
WORKDIR /app
COPY requirements.txt ./
@ -6,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt
ADD . .
ENTRYPOINT [ "python", "./spiff-bpmn-runner.py" ]
ENTRYPOINT [ "./runner.py -e spiff_example.spiff.file" ]

View File

@ -43,30 +43,34 @@ the models and application can be found there.
Models
^^^^^^
Example BPMN and DMN files can be found in the :code:`bpmn` directory of this repository.
Example BPMN and DMN files can be found in the `bpmn` directory of this repository.
There are several versions of a product ordering process of variying complexity located in the
`bpmn/tutorial` directory of the repo which contain most of the elements that SpiffWorkflow supports. These
diagrams can be viewed in any BPMN editor, but many of them have custom extensions created with
`bpmn-js-spiffworflow <https://github.com/sartography/bpmn-js-spiffworkflow>`_.
Running a Workflow
^^^^^^^^^^^^^^^^^^
Loading Workflows
^^^^^^^^^^^^^^^^^
To execute the complete workflow:
To add a workflow via the command line and store serialized specs in JSON files:
.. code:: bash
.. code-block:: console
./spiff-bpmn-runner.py -p order_product \
-d bpmn/tutorial/product_prices.dmn bpmn/tutorial/shipping_costs.dmn \
-b bpmn/tutorial/top_level_multi.bpmn bpmn/tutorial/call_activity_multi.bpmn
./runner.py -e spiff_example.spiff.file add \
-p order_product \
-b bpmn/tutorial/{top_level,call_activity}.bpmn \
-d bpmn/tutorial/{product_prices,shipping_costs}.dmn
To restore a saved workflow:
Running Workflows
^^^^^^^^^^^^^^^^^
.. code:: bash
To run the curses application using serialized JSON files:
./spiff-bpmn-runner.py -r <saved_workflow_file>
.. code-block:: console
To see all program options:
./runner.py -e spiff_example.spiff.file
.. code:: bash
./spiff-bpmn-runner.py --help
Select the 'Start Workflow' screen and start the process.
Run in docker
^^^^^^^^^^^^^

View File

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="https://www.omg.org/spec/DMN/20191111/DMNDI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/" id="product_price" name="DRD" namespace="http://camunda.org/schema/1.0/dmn" exporter="Camunda Modeler" exporterVersion="4.11.1">
<decision id="product_prices" name="Product Prices">
<decisionTable id="DecisionTable_0irsx4u">
<input id="Input_1">
<inputExpression id="InputExpression_1" typeRef="string" expressionLanguage="python">
<text>product_name</text>
</inputExpression>
</input>
<output id="Output_1" label="product_price" typeRef="long" />
<rule id="DecisionRule_0yzfvwg">
<description>Product A</description>
<inputEntry id="UnaryTests_09p3f1m">
<text>"product_a"</text>
</inputEntry>
<outputEntry id="LiteralExpression_04z7pbc">
<text>15.00</text>
</outputEntry>
</rule>
<rule id="DecisionRule_06r9cs6">
<description>Product B</description>
<inputEntry id="UnaryTests_081dqoi">
<text>"product_b"</text>
</inputEntry>
<outputEntry id="LiteralExpression_09pcew9">
<text>15.00</text>
</outputEntry>
</rule>
<rule id="DecisionRule_1p0fmx5">
<description>Product C: color</description>
<inputEntry id="UnaryTests_0iajjkd">
<text>"product_c"</text>
</inputEntry>
<outputEntry id="LiteralExpression_0xey55a">
<text>25.00</text>
</outputEntry>
</rule>
<rule id="DecisionRule_1yvfyok">
<description>Product D: color, size</description>
<inputEntry id="UnaryTests_012k20z">
<text>"product_d"</text>
</inputEntry>
<outputEntry id="LiteralExpression_0l1h1st">
<text>20.00</text>
</outputEntry>
</rule>
<rule id="DecisionRule_034qyb9">
<description>Product E: color, size, style</description>
<inputEntry id="UnaryTests_1751ikg">
<text>"product_e"</text>
</inputEntry>
<outputEntry id="LiteralExpression_1yaavlq">
<text>25.00</text>
</outputEntry>
</rule>
<rule id="DecisionRule_1m9eyai">
<description>Product F: color, size, style</description>
<inputEntry id="UnaryTests_0w5l948">
<text>"product_f"</text>
</inputEntry>
<outputEntry id="LiteralExpression_0lic6qk">
<text>30.00</text>
</outputEntry>
</rule>
<rule id="DecisionRule_08r1zzy">
<description>Product G: style</description>
<inputEntry id="UnaryTests_1wn1qis">
<text>"product_g"</text>
</inputEntry>
<outputEntry id="LiteralExpression_1vi74nu">
<text>25.00</text>
</outputEntry>
</rule>
</decisionTable>
</decision>
<dmndi:DMNDI>
<dmndi:DMNDiagram>
<dmndi:DMNShape dmnElementRef="product_prices">
<dc:Bounds height="80" width="180" x="160" y="100" />
</dmndi:DMNShape>
</dmndi:DMNDiagram>
</dmndi:DMNDI>
</definitions>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/" xmlns:dmndi="https://www.omg.org/spec/DMN/20191111/DMNDI/" xmlns:dc="http://www.omg.org/spec/DMN/20180521/DC/" id="Definitions_1f7g629" name="DRD" namespace="http://camunda.org/schema/1.0/dmn" exporter="Camunda Modeler" exporterVersion="4.11.1">
<decision id="shipping_costs" name="Shipping Costs">
<decisionTable id="DecisionTable_1ywyzrl">
<input id="Input_1">
<inputExpression id="InputExpression_1" typeRef="string" expressionLanguage="python">
<text>shipping_method</text>
</inputExpression>
</input>
<output id="Output_1" name="shipping_cost" typeRef="long" />
<rule id="DecisionRule_1hgbw82">
<description>Ground</description>
<inputEntry id="UnaryTests_0nlmjvp">
<text>"standard"</text>
</inputEntry>
<outputEntry id="LiteralExpression_1a2wcms">
<text>5.00</text>
</outputEntry>
</rule>
<rule id="DecisionRule_03844nt">
<description>Express</description>
<inputEntry id="UnaryTests_03npf6a">
<text>"overnight"</text>
</inputEntry>
<outputEntry id="LiteralExpression_1nm5aox">
<text>25.00</text>
</outputEntry>
</rule>
</decisionTable>
</decision>
<dmndi:DMNDI>
<dmndi:DMNDiagram>
<dmndi:DMNShape dmnElementRef="shipping_costs">
<dc:Bounds height="80" width="180" x="160" y="100" />
</dmndi:DMNShape>
</dmndi:DMNDiagram>
</dmndi:DMNDI>
</definitions>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="false">
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="true">
<bpmn:exclusiveGateway id="Gateway_0ocn7fn" name="Is Color Customizable?" default="Flow_1h8w6f7">
<bpmn:incoming>Flow_104dmrv</bpmn:incoming>
<bpmn:outgoing>Flow_0ikn93z</bpmn:outgoing>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="false">
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="true">
<bpmn:exclusiveGateway id="Gateway_0ocn7fn" name="Is Color Customizable?" default="Flow_1h8w6f7">
<bpmn:incoming>Flow_104dmrv</bpmn:incoming>
<bpmn:outgoing>Flow_0ikn93z</bpmn:outgoing>
@ -115,6 +115,82 @@ product_price = None</spiffworkflow:preScript>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="customize_product">
<bpmndi:BPMNEdge id="Flow_0nzk0dv_di" bpmnElement="Flow_0nzk0dv">
<di:waypoint x="1340" y="207" />
<di:waypoint x="1412" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0uy2bcm_di" bpmnElement="Flow_0uy2bcm">
<di:waypoint x="1020" y="290" />
<di:waypoint x="1120" y="290" />
<di:waypoint x="1120" y="247" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="870" y="232" />
<di:waypoint x="870" y="290" />
<di:waypoint x="920" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="876" y="258" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1gj4orb_di" bpmnElement="Flow_1gj4orb">
<di:waypoint x="1170" y="207" />
<di:waypoint x="1240" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="810" y="120" />
<di:waypoint x="870" y="120" />
<di:waypoint x="870" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="640" y="182" />
<di:waypoint x="640" y="120" />
<di:waypoint x="710" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="646" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1r5bppm_di" bpmnElement="Flow_1r5bppm">
<di:waypoint x="895" y="207" />
<di:waypoint x="1070" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1012" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="590" y="290" />
<di:waypoint x="640" y="290" />
<di:waypoint x="640" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="665" y="207" />
<di:waypoint x="845" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="748" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="205" y="207" />
<di:waypoint x="260" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="465" y="207" />
<di:waypoint x="615" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="532" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="360" y="207" />
<di:waypoint x="415" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="440" y="232" />
<di:waypoint x="440" y="290" />
<di:waypoint x="490" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="446" y="257" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="Gateway_0ocn7fn_di" bpmnElement="Gateway_0ocn7fn" isMarkerVisible="true">
<dc:Bounds x="415" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
@ -151,94 +227,18 @@ product_price = None</spiffworkflow:preScript>
<bpmndi:BPMNShape id="Activity_0aeqvs6_di" bpmnElement="Activity_1x0wxtq">
<dc:Bounds x="710" y="80" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0clnuqi_di" bpmnElement="Activity_1mkqpod">
<dc:Bounds x="920" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_10j4iim_di" bpmnElement="Event_10j4iim">
<dc:Bounds x="1412" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1397" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0clnuqi_di" bpmnElement="Activity_1mkqpod">
<dc:Bounds x="920" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_01p1fli_di" bpmnElement="Activity_1mvycv2">
<dc:Bounds x="1240" y="167" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="440" y="232" />
<di:waypoint x="440" y="290" />
<di:waypoint x="490" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="446" y="257" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="360" y="207" />
<di:waypoint x="415" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="465" y="207" />
<di:waypoint x="615" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="532" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="205" y="207" />
<di:waypoint x="260" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="665" y="207" />
<di:waypoint x="845" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="748" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="590" y="290" />
<di:waypoint x="640" y="290" />
<di:waypoint x="640" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1r5bppm_di" bpmnElement="Flow_1r5bppm">
<di:waypoint x="895" y="207" />
<di:waypoint x="1070" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1012" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="640" y="182" />
<di:waypoint x="640" y="120" />
<di:waypoint x="710" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="646" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="810" y="120" />
<di:waypoint x="870" y="120" />
<di:waypoint x="870" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1gj4orb_di" bpmnElement="Flow_1gj4orb">
<di:waypoint x="1170" y="207" />
<di:waypoint x="1240" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="870" y="232" />
<di:waypoint x="870" y="290" />
<di:waypoint x="920" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="876" y="258" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0uy2bcm_di" bpmnElement="Flow_0uy2bcm">
<di:waypoint x="1020" y="290" />
<di:waypoint x="1120" y="290" />
<di:waypoint x="1120" y="247" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0nzk0dv_di" bpmnElement="Flow_0nzk0dv">
<di:waypoint x="1340" y="207" />
<di:waypoint x="1412" y="207" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="false">
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="true">
<bpmn:exclusiveGateway id="Gateway_0ocn7fn" name="Is Color Customizable?" default="Flow_1h8w6f7">
<bpmn:incoming>Flow_06k811b</bpmn:incoming>
<bpmn:outgoing>Flow_0ikn93z</bpmn:outgoing>
@ -92,134 +92,134 @@
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="customize_product">
<bpmndi:BPMNShape id="Gateway_0ocn7fn_di" bpmnElement="Gateway_0ocn7fn" isMarkerVisible="true">
<dc:Bounds x="415" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="404" y="152" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_0y2l88d_di" bpmnElement="Gateway_0y2l88d" isMarkerVisible="true">
<dc:Bounds x="615" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="653" y="216" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0263vxi_di" bpmnElement="Activity_0263vxi">
<dc:Bounds x="490" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1iupgqu_di" bpmnElement="Gateway_1iupgqu" isMarkerVisible="true">
<dc:Bounds x="845" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="893" y="166" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0aeqvs6_di" bpmnElement="Activity_1x0wxtq">
<dc:Bounds x="710" y="80" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1qku44o_di" bpmnElement="Gateway_1qku44o" isMarkerVisible="true">
<dc:Bounds x="1045" y="182" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_10j4iim_di" bpmnElement="Event_10j4iim">
<dc:Bounds x="1142" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1127" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_09a7t9p_di" bpmnElement="Event_09a7t9p">
<dc:Bounds x="-8" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="-25" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1uazifo_di" bpmnElement="Activity_1uazifo">
<dc:Bounds x="80" y="167" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1j06imw_di" bpmnElement="Activity_1ln3xkw">
<dc:Bounds x="240" y="167" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0clnuqi_di" bpmnElement="Activity_1mkqpod">
<dc:Bounds x="930" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="440" y="232" />
<di:waypoint x="440" y="290" />
<di:waypoint x="490" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="446" y="257" width="19" height="14" />
</bpmndi:BPMNLabel>
<bpmndi:BPMNEdge id="Flow_06k811b_di" bpmnElement="Flow_06k811b">
<di:waypoint x="520" y="207" />
<di:waypoint x="595" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="180" y="207" />
<di:waypoint x="240" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="465" y="207" />
<di:waypoint x="615" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="532" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="28" y="207" />
<di:waypoint x="80" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="665" y="207" />
<di:waypoint x="845" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="748" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="590" y="290" />
<di:waypoint x="640" y="290" />
<di:waypoint x="640" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="640" y="182" />
<di:waypoint x="640" y="120" />
<di:waypoint x="710" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="646" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="810" y="120" />
<di:waypoint x="870" y="120" />
<di:waypoint x="870" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="870" y="232" />
<di:waypoint x="870" y="290" />
<di:waypoint x="930" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="877" y="258" width="18" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_076jkq7_di" bpmnElement="Flow_076jkq7">
<di:waypoint x="895" y="207" />
<di:waypoint x="1045" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1002" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
<bpmndi:BPMNEdge id="Flow_0ndmg19_di" bpmnElement="Flow_0ndmg19">
<di:waypoint x="1275" y="207" />
<di:waypoint x="1322" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0wedkbj_di" bpmnElement="Flow_0wedkbj">
<di:waypoint x="1030" y="290" />
<di:waypoint x="1070" y="290" />
<di:waypoint x="1070" y="232" />
<di:waypoint x="1210" y="290" />
<di:waypoint x="1250" y="290" />
<di:waypoint x="1250" y="232" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1036" y="272" width="18" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ndmg19_di" bpmnElement="Flow_0ndmg19">
<di:waypoint x="1095" y="207" />
<di:waypoint x="1142" y="207" />
<bpmndi:BPMNEdge id="Flow_076jkq7_di" bpmnElement="Flow_076jkq7">
<di:waypoint x="1075" y="207" />
<di:waypoint x="1225" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1182" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06k811b_di" bpmnElement="Flow_06k811b">
<di:waypoint x="340" y="207" />
<di:waypoint x="415" y="207" />
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="1050" y="232" />
<di:waypoint x="1050" y="290" />
<di:waypoint x="1110" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1057" y="258" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="990" y="120" />
<di:waypoint x="1050" y="120" />
<di:waypoint x="1050" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="820" y="182" />
<di:waypoint x="820" y="120" />
<di:waypoint x="890" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="826" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="770" y="290" />
<di:waypoint x="820" y="290" />
<di:waypoint x="820" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="845" y="207" />
<di:waypoint x="1025" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="928" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="208" y="207" />
<di:waypoint x="260" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="645" y="207" />
<di:waypoint x="795" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="712" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="360" y="207" />
<di:waypoint x="420" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="620" y="232" />
<di:waypoint x="620" y="290" />
<di:waypoint x="670" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="626" y="257" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="Gateway_0ocn7fn_di" bpmnElement="Gateway_0ocn7fn" isMarkerVisible="true">
<dc:Bounds x="595" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="584" y="152" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1uazifo_di" bpmnElement="Activity_1uazifo">
<dc:Bounds x="260" y="167" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_09a7t9p_di" bpmnElement="Event_09a7t9p">
<dc:Bounds x="172" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="155" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_0y2l88d_di" bpmnElement="Gateway_0y2l88d" isMarkerVisible="true">
<dc:Bounds x="795" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="833" y="216" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0263vxi_di" bpmnElement="Activity_0263vxi">
<dc:Bounds x="670" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1iupgqu_di" bpmnElement="Gateway_1iupgqu" isMarkerVisible="true">
<dc:Bounds x="1025" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1073" y="166" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0aeqvs6_di" bpmnElement="Activity_1x0wxtq">
<dc:Bounds x="890" y="80" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_10j4iim_di" bpmnElement="Event_10j4iim">
<dc:Bounds x="1322" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1307" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0clnuqi_di" bpmnElement="Activity_1mkqpod">
<dc:Bounds x="1110" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1qku44o_di" bpmnElement="Gateway_1qku44o" isMarkerVisible="true">
<dc:Bounds x="1225" y="182" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1j06imw_di" bpmnElement="Activity_1ln3xkw">
<dc:Bounds x="420" y="167" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="false">
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="true">
<bpmn:exclusiveGateway id="Gateway_0ocn7fn" name="Is Color Customizable?" default="Flow_1h8w6f7">
<bpmn:incoming>Flow_06k811b</bpmn:incoming>
<bpmn:outgoing>Flow_0ikn93z</bpmn:outgoing>
@ -88,7 +88,7 @@
<bpmn:extensionElements>
<spiffworkflow:serviceTaskOperator id="lookup_product_info" resultVariable="product_info">
<spiffworkflow:parameters>
<spiffworkflow:parameter id="product_name" type="str" value="product_name"/>
<spiffworkflow:parameter id="product_name" type="str" value="product_name" />
</spiffworkflow:parameters>
</spiffworkflow:serviceTaskOperator>
<spiffworkflow:postScript>product_info = product_info_from_dict(product_info)</spiffworkflow:postScript>
@ -99,134 +99,134 @@
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="customize_product">
<bpmndi:BPMNShape id="Gateway_0ocn7fn_di" bpmnElement="Gateway_0ocn7fn" isMarkerVisible="true">
<dc:Bounds x="415" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="404" y="152" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_0y2l88d_di" bpmnElement="Gateway_0y2l88d" isMarkerVisible="true">
<dc:Bounds x="615" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="653" y="216" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0263vxi_di" bpmnElement="Activity_0263vxi">
<dc:Bounds x="490" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1iupgqu_di" bpmnElement="Gateway_1iupgqu" isMarkerVisible="true">
<dc:Bounds x="845" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="893" y="166" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0aeqvs6_di" bpmnElement="Activity_1x0wxtq">
<dc:Bounds x="710" y="80" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1qku44o_di" bpmnElement="Gateway_1qku44o" isMarkerVisible="true">
<dc:Bounds x="1045" y="182" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_10j4iim_di" bpmnElement="Event_10j4iim">
<dc:Bounds x="1142" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1127" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_09a7t9p_di" bpmnElement="Event_09a7t9p">
<dc:Bounds x="-8" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="-25" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1uazifo_di" bpmnElement="Activity_1uazifo">
<dc:Bounds x="80" y="167" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1j06imw_di" bpmnElement="Activity_1ln3xkw">
<dc:Bounds x="240" y="167" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0clnuqi_di" bpmnElement="Activity_1mkqpod">
<dc:Bounds x="930" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="440" y="232" />
<di:waypoint x="440" y="290" />
<di:waypoint x="490" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="446" y="257" width="19" height="14" />
</bpmndi:BPMNLabel>
<bpmndi:BPMNEdge id="Flow_06k811b_di" bpmnElement="Flow_06k811b">
<di:waypoint x="520" y="207" />
<di:waypoint x="595" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="180" y="207" />
<di:waypoint x="240" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="465" y="207" />
<di:waypoint x="615" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="532" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="28" y="207" />
<di:waypoint x="80" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="665" y="207" />
<di:waypoint x="845" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="748" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="590" y="290" />
<di:waypoint x="640" y="290" />
<di:waypoint x="640" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="640" y="182" />
<di:waypoint x="640" y="120" />
<di:waypoint x="710" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="646" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="810" y="120" />
<di:waypoint x="870" y="120" />
<di:waypoint x="870" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="870" y="232" />
<di:waypoint x="870" y="290" />
<di:waypoint x="930" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="877" y="258" width="18" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_076jkq7_di" bpmnElement="Flow_076jkq7">
<di:waypoint x="895" y="207" />
<di:waypoint x="1045" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1002" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
<bpmndi:BPMNEdge id="Flow_0ndmg19_di" bpmnElement="Flow_0ndmg19">
<di:waypoint x="1275" y="207" />
<di:waypoint x="1322" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0wedkbj_di" bpmnElement="Flow_0wedkbj">
<di:waypoint x="1030" y="290" />
<di:waypoint x="1070" y="290" />
<di:waypoint x="1070" y="232" />
<di:waypoint x="1210" y="290" />
<di:waypoint x="1250" y="290" />
<di:waypoint x="1250" y="232" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1036" y="272" width="18" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ndmg19_di" bpmnElement="Flow_0ndmg19">
<di:waypoint x="1095" y="207" />
<di:waypoint x="1142" y="207" />
<bpmndi:BPMNEdge id="Flow_076jkq7_di" bpmnElement="Flow_076jkq7">
<di:waypoint x="1075" y="207" />
<di:waypoint x="1225" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1182" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06k811b_di" bpmnElement="Flow_06k811b">
<di:waypoint x="340" y="207" />
<di:waypoint x="415" y="207" />
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="1050" y="232" />
<di:waypoint x="1050" y="290" />
<di:waypoint x="1110" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1057" y="258" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="990" y="120" />
<di:waypoint x="1050" y="120" />
<di:waypoint x="1050" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="820" y="182" />
<di:waypoint x="820" y="120" />
<di:waypoint x="890" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="826" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="770" y="290" />
<di:waypoint x="820" y="290" />
<di:waypoint x="820" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="845" y="207" />
<di:waypoint x="1025" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="928" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="208" y="207" />
<di:waypoint x="260" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="645" y="207" />
<di:waypoint x="795" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="712" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="360" y="207" />
<di:waypoint x="420" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="620" y="232" />
<di:waypoint x="620" y="290" />
<di:waypoint x="670" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="626" y="257" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="Gateway_0ocn7fn_di" bpmnElement="Gateway_0ocn7fn" isMarkerVisible="true">
<dc:Bounds x="595" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="584" y="152" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1uazifo_di" bpmnElement="Activity_1uazifo">
<dc:Bounds x="260" y="167" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_09a7t9p_di" bpmnElement="Event_09a7t9p">
<dc:Bounds x="172" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="155" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_0y2l88d_di" bpmnElement="Gateway_0y2l88d" isMarkerVisible="true">
<dc:Bounds x="795" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="833" y="216" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0263vxi_di" bpmnElement="Activity_0263vxi">
<dc:Bounds x="670" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1iupgqu_di" bpmnElement="Gateway_1iupgqu" isMarkerVisible="true">
<dc:Bounds x="1025" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1073" y="166" width="73" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0aeqvs6_di" bpmnElement="Activity_1x0wxtq">
<dc:Bounds x="890" y="80" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_10j4iim_di" bpmnElement="Event_10j4iim">
<dc:Bounds x="1322" y="189" width="36" height="36" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1307" y="232" width="70" height="27" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0clnuqi_di" bpmnElement="Activity_1mkqpod">
<dc:Bounds x="1110" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_1qku44o_di" bpmnElement="Gateway_1qku44o" isMarkerVisible="true">
<dc:Bounds x="1225" y="182" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_1j06imw_di" bpmnElement="Activity_1ln3xkw">
<dc:Bounds x="420" y="167" width="100" height="80" />
<bpmndi:BPMNLabel />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="end_it_all" isExecutable="true">
<bpmn:startEvent id="StartEvent_1">
<bpmn:outgoing>Flow_0m91lb4</bpmn:outgoing>
</bpmn:startEvent>
<bpmn:sequenceFlow id="Flow_0m91lb4" sourceRef="StartEvent_1" targetRef="collect_info" />
<bpmn:scriptTask id="collect_info" name="Collect Info">
<bpmn:incoming>Flow_0m91lb4</bpmn:incoming>
<bpmn:outgoing>Flow_14oh26j</bpmn:outgoing>
<bpmn:script>from os import getpid
pid = getpid()</bpmn:script>
</bpmn:scriptTask>
<bpmn:sequenceFlow id="Flow_14oh26j" sourceRef="collect_info" targetRef="confirm" />
<bpmn:userTask id="confirm" name="Confirm">
<bpmn:extensionElements>
<spiffworkflow:properties>
<spiffworkflow:property name="formJsonSchemaFilename" value="dangerous.json" />
</spiffworkflow:properties>
<spiffworkflow:instructionsForEndUser>Process ID: {{ pid }}</spiffworkflow:instructionsForEndUser>
</bpmn:extensionElements>
<bpmn:incoming>Flow_14oh26j</bpmn:incoming>
<bpmn:outgoing>Flow_030s9hw</bpmn:outgoing>
</bpmn:userTask>
<bpmn:exclusiveGateway id="Gateway_0dq17uq" default="Flow_1mp6zm4">
<bpmn:incoming>Flow_030s9hw</bpmn:incoming>
<bpmn:outgoing>Flow_1mp6zm4</bpmn:outgoing>
<bpmn:outgoing>Flow_0jsq853</bpmn:outgoing>
</bpmn:exclusiveGateway>
<bpmn:sequenceFlow id="Flow_030s9hw" sourceRef="confirm" targetRef="Gateway_0dq17uq" />
<bpmn:endEvent id="Event_1gxr06r">
<bpmn:incoming>Flow_1mp6zm4</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_1mp6zm4" sourceRef="Gateway_0dq17uq" targetRef="Event_1gxr06r" />
<bpmn:sequenceFlow id="Flow_0jsq853" sourceRef="Gateway_0dq17uq" targetRef="end_it">
<bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">kill_it == 'Y'</bpmn:conditionExpression>
</bpmn:sequenceFlow>
<bpmn:endEvent id="Event_1f51fmu">
<bpmn:incoming>Flow_0uef7p4</bpmn:incoming>
</bpmn:endEvent>
<bpmn:sequenceFlow id="Flow_0uef7p4" sourceRef="end_it" targetRef="Event_1f51fmu" />
<bpmn:scriptTask id="end_it" name="End It">
<bpmn:incoming>Flow_0jsq853</bpmn:incoming>
<bpmn:outgoing>Flow_0uef7p4</bpmn:outgoing>
<bpmn:script>from os import kill
from signal import SIGKILL
kill(pid, SIGKILL)</bpmn:script>
</bpmn:scriptTask>
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="end_it_all">
<bpmndi:BPMNEdge id="Flow_14oh26j_di" bpmnElement="Flow_14oh26j">
<di:waypoint x="370" y="117" />
<di:waypoint x="420" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0m91lb4_di" bpmnElement="Flow_0m91lb4">
<di:waypoint x="215" y="117" />
<di:waypoint x="270" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_030s9hw_di" bpmnElement="Flow_030s9hw">
<di:waypoint x="520" y="117" />
<di:waypoint x="575" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1mp6zm4_di" bpmnElement="Flow_1mp6zm4">
<di:waypoint x="625" y="117" />
<di:waypoint x="862" y="117" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0jsq853_di" bpmnElement="Flow_0jsq853">
<di:waypoint x="600" y="142" />
<di:waypoint x="600" y="230" />
<di:waypoint x="690" y="230" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0uef7p4_di" bpmnElement="Flow_0uef7p4">
<di:waypoint x="790" y="230" />
<di:waypoint x="862" y="230" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
<dc:Bounds x="179" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_19ubvxe_di" bpmnElement="collect_info">
<dc:Bounds x="270" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0epjwdj_di" bpmnElement="confirm">
<dc:Bounds x="420" y="77" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Gateway_0dq17uq_di" bpmnElement="Gateway_0dq17uq" isMarkerVisible="true">
<dc:Bounds x="575" y="92" width="50" height="50" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1f51fmu_di" bpmnElement="Event_1f51fmu">
<dc:Bounds x="862" y="212" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Event_1gxr06r_di" bpmnElement="Event_1gxr06r">
<dc:Bounds x="862" y="99" width="36" height="36" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="Activity_0fakvwk_di" bpmnElement="end_it">
<dc:Bounds x="690" y="190" width="100" height="80" />
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="false">
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:spiffworkflow="http://spiffworkflow.org/bpmn/schema/1.0/core" xmlns:modeler="http://camunda.org/schema/modeler/1.0" id="Definitions_0ibnyhd" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="4.11.1" modeler:executionPlatform="Camunda Platform" modeler:executionPlatformVersion="7.15.0">
<bpmn:process id="customize_product" isExecutable="true">
<bpmn:ioSpecification>
<bpmn:dataOutput id="product_name" name="Product Name" />
<bpmn:dataOutput id="product_quantity" name="Product Quantity" />
@ -98,18 +98,78 @@
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="customize_product">
<bpmndi:BPMNShape id="DataOutput-791976010-1DI" bpmnElement="product_quantity">
<dc:Bounds x="1212" y="45" width="36" height="50" />
<bpmndi:BPMNEdge id="Flow_0uy2bcm_di" bpmnElement="Flow_0uy2bcm">
<di:waypoint x="1020" y="290" />
<di:waypoint x="1120" y="290" />
<di:waypoint x="1120" y="247" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="870" y="232" />
<di:waypoint x="870" y="290" />
<di:waypoint x="920" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1189" y="102" width="82" height="14" />
<dc:Bounds x="876" y="258" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="DataOutput-278882889-1DI" bpmnElement="product_name">
<dc:Bounds x="1102" y="45" width="36" height="50" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1gj4orb_di" bpmnElement="Flow_1gj4orb">
<di:waypoint x="1170" y="207" />
<di:waypoint x="1232" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="810" y="120" />
<di:waypoint x="870" y="120" />
<di:waypoint x="870" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="640" y="182" />
<di:waypoint x="640" y="120" />
<di:waypoint x="710" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1085" y="102" width="71" height="14" />
<dc:Bounds x="646" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNShape>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1r5bppm_di" bpmnElement="Flow_1r5bppm">
<di:waypoint x="895" y="207" />
<di:waypoint x="1070" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1012" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="590" y="290" />
<di:waypoint x="640" y="290" />
<di:waypoint x="640" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="665" y="207" />
<di:waypoint x="845" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="748" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="205" y="207" />
<di:waypoint x="260" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="465" y="207" />
<di:waypoint x="615" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="532" y="183" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="360" y="207" />
<di:waypoint x="415" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="440" y="232" />
<di:waypoint x="440" y="290" />
<di:waypoint x="490" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="446" y="257" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNShape id="Gateway_0ocn7fn_di" bpmnElement="Gateway_0ocn7fn" isMarkerVisible="true">
<dc:Bounds x="415" y="182" width="50" height="50" />
<bpmndi:BPMNLabel>
@ -155,78 +215,18 @@
<bpmndi:BPMNShape id="Activity_0clnuqi_di" bpmnElement="Activity_1mkqpod">
<dc:Bounds x="920" y="250" width="100" height="80" />
</bpmndi:BPMNShape>
<bpmndi:BPMNEdge id="Flow_0ikn93z_di" bpmnElement="Flow_0ikn93z">
<di:waypoint x="440" y="232" />
<di:waypoint x="440" y="290" />
<di:waypoint x="490" y="290" />
<bpmndi:BPMNShape id="DataOutput-278882889-1DI" bpmnElement="product_name">
<dc:Bounds x="1102" y="45" width="36" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="446" y="257" width="19" height="14" />
<dc:Bounds x="1085" y="102" width="71" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_104dmrv_di" bpmnElement="Flow_104dmrv">
<di:waypoint x="360" y="207" />
<di:waypoint x="415" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1h8w6f7_di" bpmnElement="Flow_1h8w6f7">
<di:waypoint x="465" y="207" />
<di:waypoint x="615" y="207" />
</bpmndi:BPMNShape>
<bpmndi:BPMNShape id="DataOutput-791976010-1DI" bpmnElement="product_quantity">
<dc:Bounds x="1212" y="45" width="36" height="50" />
<bpmndi:BPMNLabel>
<dc:Bounds x="532" y="183" width="15" height="14" />
<dc:Bounds x="1189" y="102" width="82" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_06gb1zr_di" bpmnElement="Flow_06gb1zr">
<di:waypoint x="205" y="207" />
<di:waypoint x="260" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0b4pvj2_di" bpmnElement="Flow_0b4pvj2">
<di:waypoint x="665" y="207" />
<di:waypoint x="845" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="748" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_16qjxga_di" bpmnElement="Flow_16qjxga">
<di:waypoint x="590" y="290" />
<di:waypoint x="640" y="290" />
<di:waypoint x="640" y="232" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1r5bppm_di" bpmnElement="Flow_1r5bppm">
<di:waypoint x="895" y="207" />
<di:waypoint x="1070" y="207" />
<bpmndi:BPMNLabel>
<dc:Bounds x="1012" y="189" width="15" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0apn5fw_di" bpmnElement="Flow_0apn5fw">
<di:waypoint x="640" y="182" />
<di:waypoint x="640" y="120" />
<di:waypoint x="710" y="120" />
<bpmndi:BPMNLabel>
<dc:Bounds x="646" y="148" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1y8t5or_di" bpmnElement="Flow_1y8t5or">
<di:waypoint x="810" y="120" />
<di:waypoint x="870" y="120" />
<di:waypoint x="870" y="182" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_1gj4orb_di" bpmnElement="Flow_1gj4orb">
<di:waypoint x="1170" y="207" />
<di:waypoint x="1232" y="207" />
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_043j5w0_di" bpmnElement="Flow_043j5w0">
<di:waypoint x="870" y="232" />
<di:waypoint x="870" y="290" />
<di:waypoint x="920" y="290" />
<bpmndi:BPMNLabel>
<dc:Bounds x="876" y="258" width="19" height="14" />
</bpmndi:BPMNLabel>
</bpmndi:BPMNEdge>
<bpmndi:BPMNEdge id="Flow_0uy2bcm_di" bpmnElement="Flow_0uy2bcm">
<di:waypoint x="1020" y="290" />
<di:waypoint x="1120" y="290" />
<di:waypoint x="1120" y="247" />
</bpmndi:BPMNEdge>
</bpmndi:BPMNShape>
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>

View File

@ -0,0 +1,13 @@
{
"title": "Kill This Process?",
"type": "object",
"required": [
"kill_it"
],
"properties": {
"kill_it": {
"title": "Kill it?",
"type": "string"
}
}
}

View File

@ -1,84 +0,0 @@
#!/usr/bin/env python
import sys, traceback
from jinja2 import Template
from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer
from SpiffWorkflow.bpmn.specs.defaults import ManualTask, NoneTask
from SpiffWorkflow.camunda.parser.CamundaParser import CamundaParser
from SpiffWorkflow.camunda.specs.user_task import UserTask, EnumFormField
from SpiffWorkflow.camunda.serializer.config import CAMUNDA_SPEC_CONFIG
from SpiffWorkflow.util.deep_merge import DeepMerge
from runner.runner import SimpleBpmnRunner
from runner.shared import create_arg_parser
from runner.product_info import registry
from runner.script_engine import custom_script_engine
parser = CamundaParser()
wf_spec_converter = BpmnWorkflowSerializer.configure_workflow_spec_converter(CAMUNDA_SPEC_CONFIG)
serializer = BpmnWorkflowSerializer(wf_spec_converter, registry)
def update_data(dct, name, value):
path = name.split('.')
current = dct
for component in path[:-1]:
if component not in current:
current[component] = {}
current = current[component]
current[path[-1]] = value
def display_task(task):
print(f'\n{task.task_spec.bpmn_name}')
if task.task_spec.documentation is not None:
template = Template(task.task_spec.documentation)
print(template.render(task.data))
def complete_user_task(task):
display_task(task)
dct = {}
for field in task.task_spec.form.fields:
if isinstance(field, EnumFormField):
option_map = dict([ (opt.name, opt.id) for opt in field.options ])
options = "(" + ', '.join(option_map) + ")"
prompt = f"{field.label} {options} "
option = input(prompt)
while option not in option_map:
print(f'Invalid selection!')
option = input(prompt)
response = option_map[option]
else:
response = input(f"{field.label} ")
if field.type == "long":
response = int(response)
update_data(dct, field.id, response)
DeepMerge.merge(task.data, dct)
def complete_manual_task(task):
display_task(task)
input("Press any key to complete task.")
if __name__ == '__main__':
arg_parser = create_arg_parser()
args = arg_parser.parse_args()
handlers = {
UserTask: complete_user_task,
ManualTask: complete_manual_task,
NoneTask: complete_manual_task,
}
try:
runner = SimpleBpmnRunner(parser, serializer, script_engine=custom_script_engine, handlers=handlers)
if args.restore is not None:
runner.restore(args.restore)
else:
runner.parse(args.process or args.collaboration, args.bpmn, args.dmn, args.collaboration is not None)
runner.run_workflow(args.step)
except Exception as exc:
sys.stderr.write(traceback.format_exc())
sys.exit(1)

View File

@ -1,3 +1,3 @@
SpiffWorkflow==2.0
SpiffWorkflow @ git+https://github.com/sartography/SpiffWorkflow@main
Jinja2==3.1.2
RestrictedPython==6.0

29
runner.py Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env python
import curses
import importlib
import sys, traceback
from argparse import ArgumentParser
from spiff_example.curses_ui import CursesUI
from spiff_example.cli import add_subparsers, configure_logging
if __name__ == '__main__':
parser = ArgumentParser('Simple BPMN App')
parser.add_argument('-e', '--engine', dest='engine', required=True, metavar='MODULE', help='load engine from %(metavar)s')
subparsers = parser.add_subparsers(dest='subcommand')
add_subparsers(subparsers)
args = parser.parse_args()
config = importlib.import_module(args.engine)
try:
if args.subcommand is None:
curses.wrapper(CursesUI, config.engine)
else:
configure_logging()
args.func(config.engine, args)
except Exception as exc:
sys.stderr.write(traceback.format_exc())
sys.exit(1)

View File

@ -1,150 +0,0 @@
import json
from SpiffWorkflow.task import TaskState
from SpiffWorkflow.bpmn.workflow import BpmnWorkflow
class SimpleBpmnRunner:
def __init__(self, parser, serializer, script_engine=None, handlers=None):
self.parser = parser
self.serializer = serializer
self.script_engine = script_engine
self.handlers = handlers or {}
self.workflow = None
def parse(self, name, bpmn_files, dmn_files=None, collaboration=False):
self.parser.add_bpmn_files(bpmn_files)
if dmn_files:
self.parser.add_dmn_files(dmn_files)
if collaboration:
top_level, subprocesses = self.parser.get_collaboration(name)
else:
top_level = self.parser.get_spec(name)
subprocesses = self.parser.get_subprocess_specs(name)
self.workflow = BpmnWorkflow(top_level, subprocesses, script_engine=self.script_engine)
def restore(self, filename):
with open(filename) as fh:
self.workflow = self.serializer.deserialize_json(fh.read())
if self.script_engine is not None:
self.workflow.script_engine = self.script_engine
def dump(self):
filename = input('Enter filename: ')
with open(filename, 'w') as fh:
dct = self.serializer.workflow_to_dict(self.workflow)
dct[self.serializer.VERSION_KEY] = self.serializer.VERSION
fh.write(json.dumps(dct, indent=2, separators=[', ', ': ']))
def get_task_description(self, task, include_state=True):
task_spec = task.task_spec
lane = f'{task_spec.lane}' if task_spec.lane is not None else '-'
name = task_spec.bpmn_name if task_spec.bpmn_name is not None else '-'
description = task_spec.description if task_spec.description is not None else 'Task'
state = f'{task.get_state_name()}' if include_state else ''
return f'[{lane}] {name} ({description}: {task_spec.bpmn_id}) {state}'
def get_task_details(self, task):
print(self.get_task_description(task))
print(json.dumps(self.serializer.data_converter.convert(task.data), indent=2, separators=[', ', ': ']))
def list_tasks(self, tasks, heading, include_state=True):
print(f'\n{heading}\n')
for task in tasks:
print(f'{self.get_task_description(task, include_state)}')
def show_prompt(self, prompt, options):
print()
for action, description in options.items():
print(f'\t<{action}> {description}')
action = input(prompt)
while action not in options:
print("Invalid selection")
action = input(prompt)
return action
def show_workflow_options(self, ready_tasks):
options = {}
if len(ready_tasks):
options['r'] = 'Run a task'
options['p'] = 'List past tasks'
options['f'] = 'List future tasks'
options['a'] = 'List all tasks'
options['d'] = 'Dump workflow state'
options['w'] = 'Wait'
return self.show_prompt('\nSelect action: ', options)
def select_task(self, tasks, heading=None):
if heading is not None:
print(f'\n{heading}')
options = {}
for idx, task in enumerate(tasks):
options[str(idx + 1)] = self.get_task_description(task, False)
value = self.show_prompt('\nSelect task: ', options)
return tasks[int(value) - 1]
def advance(self):
engine_tasks = [t for t in self.workflow.get_tasks(TaskState.READY) if not t.task_spec.manual]
while len(engine_tasks) > 0:
for task in engine_tasks:
task.run()
self.workflow.refresh_waiting_tasks()
engine_tasks = [t for t in self.workflow.get_tasks(TaskState.READY) if not t.task_spec.manual]
def run_workflow(self, step=False):
while not self.workflow.is_completed():
if not step:
self.advance()
tasks = self.workflow.get_tasks(TaskState.READY|TaskState.WAITING)
runnable = [t for t in tasks if t.state == TaskState.READY]
human_tasks = [t for t in runnable if t.task_spec.manual]
current_tasks = human_tasks if not step else runnable
self.list_tasks(tasks, 'Ready and Waiting Tasks')
if len(current_tasks) > 0:
action = self.show_workflow_options(current_tasks)
else:
action = None
if len(tasks) > 0:
input("\nPress any key to update task list")
if action == 'r':
task = self.select_task(current_tasks)
handler = self.handlers.get(type(task.task_spec))
if handler is not None:
handler(task)
task.run()
elif action == 'p':
finished = [t for t in self.workflow.get_tasks(TaskState.FINISHED_MASK) if t.task_spec.bpmn_id is not None]
task = self.select_task(finished, 'View Task Details')
self.get_task_details(task)
elif action == 'a':
self.list_tasks([t for t in self.workflow.get_tasks() if t.task_spec.bpmn_id is not None], 'All Tasks')
elif action == 'f':
self.list_tasks([t for t in self.workflow.get_tasks(TaskState.FUTURE) if t.task_spec.bpmn_id is not None], 'Future Tasks')
elif action == 'd':
self.dump()
self.workflow.refresh_waiting_tasks()
while action != 'q':
action = self.show_prompt('\nSelect action: ', {
'a': 'List all tasks',
'v': 'View workflow data',
'q': 'Quit',
})
if action == 'a':
self.list_tasks([t for t in self.workflow.get_tasks() if t.task_spec.bpmn_id is not None], "All Tasks")
elif action == 'v':
dct = self.serializer.data_converter.convert(self.workflow.data)
print('\n' + json.dumps(dct, indent=2, separators=[', ', ': ']))

View File

@ -1,83 +0,0 @@
import subprocess, os, json
import datetime
from RestrictedPython import safe_globals
from SpiffWorkflow.bpmn.PythonScriptEngine import PythonScriptEngine
from SpiffWorkflow.bpmn.PythonScriptEngineEnvironment import BasePythonScriptEngineEnvironment, TaskDataEnvironment
from SpiffWorkflow.util.deep_merge import DeepMerge
from runner.product_info import (
lookup_product_info,
lookup_shipping_cost,
loads,
dumps,
product_info_to_dict,
product_info_from_dict
)
env_globals = {
'lookup_product_info': lookup_product_info,
'lookup_shipping_cost': lookup_shipping_cost,
'datetime': datetime,
}
custom_env = TaskDataEnvironment(env_globals)
custom_script_engine = PythonScriptEngine(environment=custom_env)
restricted_env = TaskDataEnvironment(safe_globals)
restricted_script_engine = PythonScriptEngine(environment=restricted_env)
class SubprocessScriptingEnvironment(BasePythonScriptEngineEnvironment):
def __init__(self, executable, **kwargs):
super().__init__(**kwargs)
self.executable = executable
def evaluate(self, expression, context, external_methods=None):
output = self.run(['eval', '-e', expression], context, external_methods)
return self.parse_output(output)
def execute(self, script, context, external_methods=None):
output = self.run(['exec', '-s', script], context, external_methods)
DeepMerge.merge(context, self.parse_output(output))
return True
def run(self, args, context, external_methods):
cmd = [self.executable] + args + ['-l', dumps(context)]
if external_methods is not None:
cmd.extend(['-g', dumps(external_methods)])
return subprocess.run(cmd, capture_output=True)
def parse_output(self, output):
if output.stderr:
raise Exception(output.stderr)
return loads(output.stdout)
executable = os.path.join(os.path.dirname(__file__), 'subprocess.py')
subprocess_script_engine = PythonScriptEngine(environment=SubprocessScriptingEnvironment(executable))
service_task_env = TaskDataEnvironment({
'product_info_from_dict': product_info_from_dict,
'datetime': datetime,
})
class ServiceTaskEngine(PythonScriptEngine):
def __init__(self):
super().__init__(environment=service_task_env)
def call_service(self, operation_name, operation_params, task_data):
if operation_name == 'lookup_product_info':
product_info = lookup_product_info(operation_params['product_name']['value'])
result = product_info_to_dict(product_info)
elif operation_name == 'lookup_shipping_cost':
result = lookup_shipping_cost(operation_params['shipping_method']['value'])
else:
raise Exception("Unknown Service!")
return json.dumps(result)
service_task_engine = ServiceTaskEngine()

View File

@ -1,46 +0,0 @@
import argparse
import json
import logging
def create_arg_parser():
parser = argparse.ArgumentParser('Simple BPMN runner')
group = parser.add_mutually_exclusive_group()
group.add_argument('-p', '--process', dest='process', help='The top-level BPMN Process ID')
group.add_argument('-c', '--collabortion', dest='collaboration', help='The ID of the collaboration')
parser.add_argument('-b', '--bpmn', dest='bpmn', nargs='+', help='BPMN files to load')
parser.add_argument('-d', '--dmn', dest='dmn', nargs='*', help='DMN files to load')
parser.add_argument('-r', '--restore', dest='restore', metavar='FILE', help='Restore state from %(metavar)s')
parser.add_argument('-s', '--step', dest='step', action='store_true', help='Display prompt at every step')
parser.add_argument('-l', '--log-level', dest='log_level', metavar='LEVEL', help='Use log level %(metavar)s', default='WARN')
return parser
def configure_logging(log_level, filename):
logging.addLevelName(15, 'DATA')
def get_logger(name, fmt):
logger = logging.getLogger(name)
formatter = logging.Formatter(fmt)
handler = logging.StreamHandler()
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
spiff_log = get_logger('spiff', '%(asctime)s [%(name)s:%(levelname)s] (%(workflow_name)s:%(task_spec)s) %(message)s')
metrics_log = get_logger('spiff.metrics', '%(asctime)s [%(name)s:%(levelname)s] (%(task_type)s:%(action)s) %(elapsed)2.4f')
metrics_log.propagate = False
def log_updates(rec):
with open(filename, 'a') as fh:
fh.write(json.dumps({
'task_id': str(rec.task_id),
'timestamp': rec.created,
'data': rec.data,
}))
fh.write('\n')
return 0
data_log = logging.getLogger('spiff.data')
data_log.addFilter(log_updates)
spiff_log.setLevel(log_level)

View File

@ -1,33 +0,0 @@
#!/usr/bin/env python
import argparse
from product_info import lookup_product_info, lookup_shipping_cost, dumps, loads
if __name__ == '__main__':
parent = argparse.ArgumentParser()
subparsers = parent.add_subparsers(dest='method')
shared = argparse.ArgumentParser('Context', add_help=False)
shared.add_argument('-g', '--globals', dest='globals')
shared.add_argument('-l', '--locals', dest='locals', required=True)
eval_args = subparsers.add_parser('eval', parents=[shared])
eval_args.add_argument('-e', '--expr', dest='expr', type=str, required=True)
exec_args = subparsers.add_parser('exec', parents=[shared])
exec_args.add_argument('-s', '--script', dest='script', type=str, required=True)
args = parent.parse_args()
global_ctx = globals()
if args.globals is not None:
global_ctx.update(loads(args.globals))
local_ctx = loads(args.locals)
if args.method == 'eval':
result = eval(args.expr, global_ctx, local_ctx)
elif args.method == 'exec':
exec(args.script, global_ctx, local_ctx)
result = local_ctx
print(dumps(result))

View File

@ -1,83 +0,0 @@
#!/usr/bin/env python
import sys, traceback
import json, os
from jinja2 import Template
from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer
from SpiffWorkflow.spiff.parser.process import SpiffBpmnParser, BpmnValidator
from SpiffWorkflow.spiff.serializer.config import SPIFF_SPEC_CONFIG
from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask, NoneTask
from runner.runner import SimpleBpmnRunner
from runner.shared import create_arg_parser, configure_logging
from runner.product_info import registry
from runner.script_engine import custom_script_engine
parser = SpiffBpmnParser(validator=BpmnValidator())
wf_spec_converter = BpmnWorkflowSerializer.configure_workflow_spec_converter(SPIFF_SPEC_CONFIG)
serializer = BpmnWorkflowSerializer(wf_spec_converter, registry)
forms_dir = 'bpmn/tutorial/forms'
def display_instructions(task):
text = task.task_spec.extensions.get('instructionsForEndUser')
print(f'\n{task.task_spec.bpmn_name}')
if text is not None:
template = Template(text)
print(template.render(task.data))
def complete_user_task(task):
display_instructions(task)
filename = task.task_spec.extensions['properties']['formJsonSchemaFilename']
schema = json.load(open(os.path.join(forms_dir, filename)))
data = {}
for field, config in schema['properties'].items():
if 'oneOf' in config:
option_map = dict([ (v['title'], v['const']) for v in config['oneOf'] ])
options = "(" + ', '.join(option_map) + ")"
prompt = f"{field} {options} "
option = input(prompt)
while option not in option_map:
print(f'Invalid selection!')
option = input(prompt)
response = option_map[option]
else:
response = input(f"{config['title']} ")
if config['type'] == 'integer':
response = int(response)
data[field] = response
task.update_data(data)
def complete_manual_task(task):
display_instructions(task)
input("Press any key to complete task")
if __name__ == '__main__':
arg_parser = create_arg_parser()
args = arg_parser.parse_args()
configure_logging(args.log_level, 'data.log')
handlers = {
UserTask: complete_user_task,
ManualTask: complete_manual_task,
NoneTask: complete_manual_task,
}
try:
runner = SimpleBpmnRunner(parser, serializer, script_engine=custom_script_engine, handlers=handlers)
if args.restore is not None:
runner.restore(args.restore)
else:
runner.parse(args.process or args.collaboration, args.bpmn, args.dmn, args.collaboration is not None)
runner.run_workflow(args.step)
except Exception as exc:
sys.stderr.write(traceback.format_exc())
sys.exit(1)

View File

@ -0,0 +1,69 @@
from jinja2 import Template
from SpiffWorkflow.util.deep_merge import DeepMerge
from SpiffWorkflow.camunda.specs.user_task import EnumFormField
from ..curses_ui.user_input import Field
class TaskHandler:
def __init__(self, task):
self.task = task
def get_documentation(self):
text = f'{self.task.task_spec.bpmn_name}'
if self.task.task_spec.documentation is not None:
template = Template(self.task.task_spec.documentation)
text += template.render(self.task.data)
text += '\n\n'
return text
def update_data(self, dct, name, value):
path = name.split('.')
current = dct
for component in path[:-1]:
if component not in current:
current[component] = {}
current = current[component]
current[path[-1]] = value
def on_complete(self, results):
self.task.run()
class ManualTaskHandler(TaskHandler):
def get_configuration(self):
return self.get_documentation(), []
class UserTaskHandler(TaskHandler):
def get_configuration(self):
return self.get_documentation(), self.get_fields()
def create_field(self, field):
if isinstance(field, EnumFormField):
option_map = dict((opt.name, opt.id) for opt in field.options)
label = field.label + ' (' + ', '.join(option_map) + ')'
def validate(value):
if value not in option_map:
raise Exception(f'Invalid option: {value}')
else:
return option_map[value]
field = Field(field.id, label, lambda v: v, validate, '')
elif field.type == 'long':
field = Field(field.id, field.label, lambda v: str(v) if v is not None else '', int, None)
else:
field = Field(field.id, field.label, str, str, '')
return field
def get_fields(self):
return [self.create_field(f) for f in self.task.task_spec.form.fields]
def on_complete(self, results):
dct = {}
for name, value in results.items():
self.update_data(dct, name, value)
DeepMerge.merge(self.task.data, dct)
super().on_complete(results)

View File

@ -0,0 +1,44 @@
import sqlite3
import logging
from SpiffWorkflow.camunda.serializer import DEFAULT_CONFIG
from SpiffWorkflow.camunda.parser import CamundaParser
from SpiffWorkflow.camunda.specs import UserTask
from SpiffWorkflow.bpmn.specs.defaults import ManualTask, NoneTask
from SpiffWorkflow.bpmn import BpmnWorkflow
from SpiffWorkflow.bpmn.util.subworkflow import BpmnSubWorkflow
from SpiffWorkflow.bpmn.specs import BpmnProcessSpec
from ..serializer.sqlite import (
SqliteSerializer,
WorkflowConverter,
SubworkflowConverter,
WorkflowSpecConverter
)
from ..engine import BpmnEngine
from .curses_handlers import UserTaskHandler, ManualTaskHandler
logger = logging.getLogger('spiff_engine')
logger.setLevel(logging.INFO)
DEFAULT_CONFIG[BpmnWorkflow] = WorkflowConverter
DEFAULT_CONFIG[BpmnSubWorkflow] = SubworkflowConverter
DEFAULT_CONFIG[BpmnProcessSpec] = WorkflowSpecConverter
dbname = 'camunda.db'
with sqlite3.connect(dbname) as db:
SqliteSerializer.initialize(db)
registry = SqliteSerializer.configure(DEFAULT_CONFIG)
serializer = SqliteSerializer(dbname, registry=registry)
parser = CamundaParser()
handlers = {
UserTask: UserTaskHandler,
ManualTask: ManualTaskHandler,
NoneTask: ManualTaskHandler,
}
engine = BpmnEngine(parser, serializer, handlers)

View File

@ -0,0 +1 @@
from .subcommands import add_subparsers, configure_logging

View File

@ -0,0 +1,55 @@
import sys
import json
import logging
def configure_logging():
spiff_logger = logging.getLogger('spiff')
spiff_handler = logging.StreamHandler()
spiff_handler.setFormatter('%(asctime)s [%(name)s:%(levelname)s] (%(workflow_spec)s:%(task_spec)s) %(message)s')
spiff_logger.addHandler(spiff_handler)
metrics_logger = logging.getLogger('spiff.metrics')
metrics_handler = logging.StreamHandler()
metrics_handler.setFormatter('%(asctime)s [%(name)s:%(levelname)s] (%(workflow_spec)s:%(task_spec)s) %(elasped)s')
metrics_logger.addHandler(metrics_handler)
def add(engine, args):
if args.process is not None:
engine.add_spec(args.process, args.bpmn, args.dmn)
else:
engine.add_collaboration(args.collaboration, args.bpmn, args.dmn)
def show_library(engine, args):
for spec_id, name, filename in engine.list_specs():
sys.stdout.write(f'{spec_id} {name:<20s} {filename}\n')
def run(engine, args):
wf_id = engine.start_workflow(args.spec_id)
wf = engine.get_workflow(wf_id)
engine.run_until_user_input_required(wf)
engine.update_workflow(wf, wf_id)
if not args.active and not wf.is_completed():
raise Exception('Expected the workflow to complete')
sys.stdout.write(json.dumps(wf.data, indent=2, separators=[', ', ': ']))
sys.stdout.write('\n')
def add_subparsers(subparsers):
add_spec = subparsers.add_parser('add', help='Add a worfklow spec')
group = add_spec.add_mutually_exclusive_group(required=True)
group.add_argument('-p', '--process', dest='process', metavar='BPMN ID', help='The top-level BPMN Process ID')
group.add_argument('-c', '--collabortion', dest='collaboration', metavar='BPMN ID', help='The ID of the collaboration')
add_spec.add_argument('-b', '--bpmn', dest='bpmn', nargs='+', metavar='FILE', help='BPMN files to load')
add_spec.add_argument('-d', '--dmn', dest='dmn', nargs='*', metavar='FILE', help='DMN files to load')
add_spec.set_defaults(func=add)
list_specs = subparsers.add_parser('list', help='List available specs')
list_specs.set_defaults(func=show_library)
run_wf = subparsers.add_parser('run', help='Run a workflow')
run_wf.add_argument('-s', '--spec-id', dest='spec_id', metavar='SPEC ID', help='The ID of the spec to run')
run_wf.add_argument('-a', '--active-ok', dest='active', action='store_true', help='Suppress exception if the workflow does not complete')
run_wf.set_defaults(func=run)

View File

@ -0,0 +1 @@
from .ui import CursesUI

View File

@ -0,0 +1,75 @@
import curses, curses.ascii
class Region:
def __init__(self):
self.top = 0
self.left = 0
self.height = 1
self.width = 1
@property
def bottom(self):
return self.top + self.height - 1
@property
def right(self):
return self.left + self.width - 1
@property
def box(self):
return self.top, self.left, self.bottom, self.right
def resize(self, top, left, height, width):
self.top, self.left, self.height, self.width = top, left, height, width
class Content:
def __init__(self, region):
self.region = region
self.screen = curses.newpad(self.region.height, self.region.width)
self.screen.keypad(True)
self.screen.scrollok(True)
self.screen.idlok(True)
self.screen.attron(curses.COLOR_WHITE)
self.content_height = 1
self.first_visible = 0
self.menu = ['[ESC] return to main menu']
@property
def last_visible(self):
return self.first_visible + self.region.height - 1
def scroll_up(self, y):
if self.first_visible > 0 and y == self.first_visible:
self.first_visible -= 1
self.screen.move(max(0, y - 1), 0)
def scroll_down(self, y):
if self.last_visible < self.content_height - 1 and y == self.last_visible - 1:
self.first_visible += 1
self.screen.move(min(self.content_height - 1, y + 1), 0)
def page_up(self, y):
self.first_visible = max(0, y - self.region.height)
self.screen.move(self.first_visible, 0)
def page_down(self, y):
self.first_visible = min(self.content_height - self.region.height, y + self.region.height)
self.screen.move(self.first_visible, 0)
def draw(self):
pass
def handle_key(self, ch, y, x):
pass
def resize(self):
self.screen.resize(max(self.region.height, self.content_height), self.region.width)
self.screen.noutrefresh(self.first_visible, 0, *self.region.box)

View File

@ -0,0 +1,71 @@
import curses, curses.ascii
from .content import Content
class ListView(Content):
def __init__(self, region, header, select_action, delete_action):
super().__init__(region)
self.header = header
self.select_action = select_action
self.delete_action = delete_action
self._items = []
self.item_ids = []
self.selected = 0
self.selected_attr = curses.color_pair(6) | curses.A_BOLD
self.menu = [
'[ENTER] run',
'[d] delete'
] + self.menu
@property
def items(self):
return self._items
@items.setter
def items(self, items):
if len(items) > 0:
item_ids, items = zip(*[(item[0], item[1:]) for item in items])
self.item_ids = list(item_ids)
self._items = [ [str(v) if v is not None else '' for v in item] for item in items ]
else:
self._items, self.item_ids = [], []
def draw(self):
self.screen.erase()
self.screen.move(0, 0)
col_widths = [2] + [max([len(val) for val in item]) for item in zip(*[self.header] + list(self.items))]
fmt = ' '.join([ f'{{{idx}:{w}s}}' for idx, w in enumerate(col_widths) ])
self.screen.addstr(fmt.format('', *self.header) + '\n', curses.A_BOLD)
for idx, item in enumerate(self.items):
attr = self.selected_attr if idx == self.selected else 0
self.screen.addstr('\n' + fmt.format('*', *item), attr)
self.screen.move(self.selected + 2, 0)
self.screen.noutrefresh(self.first_visible, 0, *self.region.box)
def handle_key(self, ch, y, x):
if ch == curses.ascii.NL:
item_id = self.item_ids[self.selected]
self.select_action(item_id)
elif ch == curses.KEY_DOWN and self.selected < len(self.items) - 1:
self.selected += 1
self.draw()
elif ch == curses.KEY_UP and self.selected > 0:
self.selected -= 1
self.draw()
elif chr(ch).lower() == 'd':
item_id = self.item_ids[self.selected]
self.delete_action(item_id)
self._items.pop(self.selected)
self.item_ids.pop(self.selected)
if self.selected == len(self.item_ids):
self.selected = max(0, self.selected - 1)
self.draw()

View File

@ -0,0 +1,76 @@
import curses, curses.ascii
import traceback, logging
from datetime import datetime
from .content import Content
logger = logging.getLogger('root')
class LogHandler(logging.Handler):
def __init__(self, write):
super().__init__()
self.write = write
def emit(self, record):
self.write(record)
class LogView(Content):
def __init__(self, region):
super().__init__(region)
logger.addHandler(LogHandler(self.write))
self.styles = {
'ERROR': curses.color_pair(9),
'WARNING': curses.color_pair(11),
}
self.menu = ['[ESC] return to previous screen']
self.previous_state = None
def write(self, record):
y, x = curses.getsyx()
if record.exc_info is not None and record.levelno == 40:
trace = traceback.format_exception(*record.exc_info)
else:
trace = []
self.content_height += len(trace) + 1
self.first_visible = max(0, self.content_height - self.region.height)
self.resize()
dt = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S.%f')
if record.name == 'spiff':
message = f'{dt} [{record.name}:{record.levelname}] ({record.workflow_spec}:{record.task_spec}) {record.msg}'
else:
message = f'{dt} [{record.name}:{record.levelname}] {record.msg}'
self.screen.addstr(f'\n{message}', self.styles.get(record.levelname, 0))
for line in trace:
self.screen.addstr(f'\n{line}')
self.screen.clrtoeol()
self.screen.refresh(self.first_visible, 0, *self.region.box)
curses.setsyx(y, x)
def draw(self):
curses.curs_set(1)
def handle_key(self, ch, y, x):
if ch == curses.KEY_UP:
self.scroll_up(y)
elif ch == curses.KEY_DOWN:
self.scroll_down(y)
elif ch == curses.KEY_PPAGE:
self.page_up(y)
elif ch == curses.KEY_NPAGE:
self.page_down(y)
self.screen.refresh(self.first_visible, 0, *self.region.box)

View File

@ -0,0 +1,40 @@
import curses, curses.ascii
from .content import Content
class Menu(Content):
def __init__(self, region, menu_items):
super().__init__(region)
self.current_option = 0
self.options, self.handlers = zip(*menu_items)
self.menu = None
def draw(self):
curses.curs_set(0)
self.screen.erase()
mid_x = self.region.width // 2
mid_y = self.region.height // 2
self.screen.move(1, mid_x)
for idx, option in enumerate(self.options):
attr = curses.A_BOLD if idx == self.current_option else 0
self.screen.addstr(mid_y + idx, mid_x - len(option) // 2, f'{option}\n', attr)
self.screen.noutrefresh(self.first_visible, 0, *self.region.box)
def handle_key(self, ch, y, x):
if ch == curses.KEY_DOWN and self.current_option < len(self.options) - 1:
self.current_option += 1
self.draw()
self.screen.noutrefresh(self.first_visible, 0, *self.region.box)
elif ch == curses.KEY_UP and self.current_option > 0:
self.current_option -= 1
self.draw()
self.screen.noutrefresh(self.first_visible, 0, *self.region.box)
elif ch == curses.ascii.NL:
self.handlers[self.current_option]()

View File

@ -0,0 +1,125 @@
import curses, curses.ascii
from .content import Content
class SpecView:
def __init__(self, left, right, add_spec):
self.left = Content(left)
self.right = Content(right)
self.add_spec = add_spec
self.bpmn_id = None
self.bpmn_files = []
self.dmn_files = []
self.bpmn_id_line = 2
self.bpmn_line = 4
self.dmn_line = 6
self.add_line = 8
self.screen = self.right.screen
self.menu = self.right.menu
def can_edit(self, lineno):
return lineno in [self.bpmn_id_line, self.bpmn_line, self.dmn_line]
def bpmn_filename(self, lineno):
return lineno > self.bpmn_line and lineno <= self.bpmn_line + len(self.bpmn_files)
def dmn_filename(self, lineno):
return lineno > self.dmn_line and lineno <= self.dmn_line + len(self.dmn_files)
def draw(self, lineno=None, clear=False):
self.bpmn_line = 2 + self.bpmn_id_line
self.dmn_line = 2 + self.bpmn_line + len(self.bpmn_files)
self.add_line = 2 + self.dmn_line + len(self.dmn_files)
self.left.screen.erase()
self.right.screen.erase()
self.left.screen.addstr(self.bpmn_id_line, self.left.region.width - 13, 'Process ID: ')
self.left.screen.addstr(self.bpmn_line, self.left.region.width - 13, 'BPMN files: ')
self.left.screen.addstr(self.dmn_line, self.left.region.width - 12, 'DMN files: ')
if self.bpmn_id is not None:
self.right.screen.addstr(self.bpmn_id_line, 0, self.bpmn_id)
for offset, filename in enumerate(self.bpmn_files):
self.right.screen.addstr(self.bpmn_line + offset + 1, 0, f'[X] {filename}')
for offset, filename in enumerate(self.dmn_files):
self.right.screen.addstr(self.dmn_line + offset + 1, 0, f'[X] {filename}')
self.right.screen.addstr(self.add_line, 0, '[Add]', curses.A_BOLD)
self.right.screen.addstr(' (Press ESC to cancel)')
self.right.screen.move(lineno or self.bpmn_id_line, 0)
if clear:
self.right.screen.clrtoeol()
self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box)
self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box)
curses.curs_set(1)
curses.ungetch(curses.KEY_LEFT)
def handle_key(self, ch, y, x):
if ch == curses.KEY_BACKSPACE and self.can_edit(y):
self.right.screen.move(y, max(0, x - 1))
self.right.screen.delch(y, max(0, x - 1))
elif ch == curses.KEY_LEFT and self.can_edit(y):
self.right.screen.move(y, max(0, x - 1))
elif ch == curses.KEY_RIGHT and self.can_edit(y):
line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip()
self.right.screen.move(y, min(len(line), x + 1))
elif ch == curses.KEY_DOWN:
if self.bpmn_filename(y + 1) or self.dmn_filename(y + 1):
self.right.screen.move(y + 1, 1)
elif ch == curses.KEY_UP:
if y - 1 == self.bpmn_line or y - 1 == self.dmn_line:
self.right.screen.move(y - 1, 0)
elif self.bpmn_filename(y - 1) or self.dmn_filename(y - 1):
self.right.screen.move(y - 1, 1)
elif ch == curses.ascii.TAB:
if y == self.bpmn_id_line:
self.right.screen.move(self.bpmn_line, 0)
elif y == self.bpmn_line or self.bpmn_filename(y):
self.right.screen.move(self.dmn_line, 0)
elif y == self.dmn_line or self.dmn_filename(y):
self.right.screen.move(self.add_line, 1)
elif y == self.add_line:
self.right.screen.move(self.bpmn_id_line, 0)
elif ch == curses.ascii.NL:
text = self.right.screen.instr(y, 0, x).decode('utf-8').strip()
if y == self.bpmn_id_line and text != '':
self.bpmn_id = text
self.right.screen.addstr(y, 0, text, curses.A_ITALIC)
self.right.screen.move(self.bpmn_line, 0)
elif y == self.bpmn_line and text != '':
self.bpmn_files.append(text)
self.draw(self.bpmn_line, True)
elif y == self.dmn_line and text != '':
self.dmn_files.append(text)
self.draw(self.dmn_line, True)
elif self.bpmn_filename(y):
self.bpmn_files.pop(y - self.bpmn_line - 1)
self.draw(self.bpmn_line)
elif self.dmn_filename(y):
self.dmn_files.pop(y - self.dmn_line - 1)
self.draw(self.dmn_line)
elif y == self.add_line:
spec_id = self.add_spec(self.bpmn_id, self.bpmn_files, self.dmn_files or None)
self.bpmn_id = None
self.bpmn_files = []
self.dmn_files = []
self.draw()
elif curses.ascii.isprint(ch):
self.right.screen.echochar(ch)
self.left.screen.noutrefresh(0, 0, *self.left.region.box)
self.right.screen.noutrefresh(0, 0, *self.right.region.box)
def resize(self):
self.left.resize()
self.right.resize()

View File

@ -0,0 +1,194 @@
import curses, curses.ascii
import sys
import logging
from datetime import datetime
from .content import Region, Content
from .menu import Menu
from .log_view import LogView
from .list_view import ListView
from .workflow_view import WorkflowView, default_view, run_view
from .spec_view import SpecView
from .user_input import UserInput, Field
logger = logging.getLogger(__name__)
class CursesUI:
def __init__(self, window, engine):
for i in range(1, int(curses.COLOR_PAIRS / 256)):
curses.init_pair(i, i, 0)
self.engine = engine
self.window = window
self.window.attron(curses.COLOR_WHITE)
self.window.nodelay(True)
self.left = Region()
self.right = Region()
self.top = Region()
self.menu = Region()
self.bottom = Region()
self.menu_content = Content(self.menu)
self._states = {
'main_menu': Menu(self.top, [
('Add spec', lambda: self.set_state('add_spec')),
('Start Workflow', lambda: self.set_state('start_workflow')),
('Resume workflow', lambda: self.set_state('resume_workflow')),
('List workflows', lambda: self.set_state('list_workflows')),
('Quit', self.quit),
]),
'add_spec': SpecView(self.left, self.right, self.engine.add_spec),
'log_view': LogView(self.bottom),
'start_workflow': ListView(
self.top,
['Name', 'Filename'],
self.start_workflow,
self.engine.delete_workflow_spec,
),
'resume_workflow': ListView(
self.top,
['Spec', 'Active tasks', 'Started', 'Updated'],
self.run_workflow,
self.engine.delete_workflow
),
'list_workflows': ListView(
self.top,
['Spec', 'Active tasks', 'Started', 'Updated', 'Ended'],
self.view_workflow,
self.engine.delete_workflow,
),
'view_workflow': WorkflowView(self),
'user_input': UserInput(self.left, self.right),
}
self.resize()
self._state = None
self.state = 'main_menu'
self.run()
@property
def state(self):
return self._states[self._state]
@state.setter
def state(self, state):
self._state = state
self.menu_content.screen.erase()
if self.state.menu is not None:
for action in self.state.menu:
self.menu_content.screen.addstr(f'{action} ')
self.menu_content.screen.noutrefresh(0, 0, *self.menu.box)
self.state.draw()
curses.doupdate()
def set_state(self, state): # For callbacks on different screens
if state == 'start_workflow':
self._switch_to_list('start_workflow', self.engine.list_specs())
elif state == 'resume_workflow':
self._switch_to_list('resume_workflow', self.engine.list_workflows())
elif state == 'list_workflows':
self._switch_to_list('list_workflows', self.engine.list_workflows(True))
self.state = state
def run(self):
while True:
y, x = self.state.screen.getyx()
ch = self.state.screen.getch()
if ch == curses.KEY_RESIZE:
self.resize()
self.state.draw()
elif ch == curses.ascii.ESC:
if self._state in ['log_view', 'view_workflow']:
self.set_state(self.state._previous_state)
elif self._state == 'user_input':
self.set_state('view_workflow')
else:
self.state = 'main_menu'
elif chr(ch) == ';':
self._states['log_view']._previous_state = self._state
self.state = 'log_view'
else:
try:
self.state.handle_key(ch, y, x)
except Exception as exc:
logger.error(str(exc), exc_info=True)
curses.doupdate()
def start_workflow(self, spec_id):
wf_id = self.engine.start_workflow(spec_id)
self.set_workflow(wf_id, False, run_view)
def _switch_to_list(self, state, items):
self._states[state].items = items
self.state = state
def run_workflow(self, wf_id):
self.set_workflow(wf_id, False, run_view)
def view_workflow(self, wf_id):
self.set_workflow(wf_id, True, default_view)
def set_workflow(self, wf_id, step, filters):
workflow = self.engine.get_workflow(wf_id)
self._states['view_workflow'].set_workflow(workflow, wf_id)
self._states['view_workflow'].step = step
self._states['view_workflow']._previous_state = 'list_workflows' if step else 'resume_workflow'
self._states['view_workflow'].current_filter = filters.copy()
self._run_workflow()
def _run_workflow(self):
if not self._states['view_workflow'].step:
self.engine.run_until_user_input_required(self._states['view_workflow'].workflow)
self.state = 'view_workflow'
def show_filters(self, fields):
def on_complete(results):
self._states['view_workflow'].current_filter.update(results)
self.state = 'view_workflow'
self._states['user_input'].configure('', fields, on_complete)
self.state = 'user_input'
def complete_task(self, task):
handler = self.engine.handler(task)
if handler is not None:
instructions, fields = handler.get_configuration()
def on_complete(results):
handler.on_complete(results)
self._run_workflow()
self._states['user_input'].configure(instructions, fields, on_complete)
self.state = 'user_input'
else:
task.run()
self._run_workflow()
def quit(self):
sys.exit(0)
def resize(self):
y, x = self.window.getmaxyx()
div_y, div_x = y // 4, x // 3
self.top.resize(1, 1, y - div_y - 3, x - 2)
self.left.resize(1, 1, y - div_y - 3, div_x - 1)
self.right.resize(1, div_x, y - div_y - 3, x - div_x - 1)
self.menu.resize(y - div_y - 1, 1, 1, x - 2)
self.bottom.resize(y - div_y + 1, 0, div_y - 1, x)
for state in self._states.values():
state.resize()
self.menu_content.resize()
self.window.hline(y - div_y, 0, curses.ACS_HLINE, x)
self.window.refresh()

View File

@ -0,0 +1,110 @@
import curses, curses.ascii
from .content import Content
class Field:
def __init__(self, name, label, to_str, from_str, value):
self.name = name
self.label = label
self.to_str = to_str
self.from_str = from_str
self.value = value
class UserInput:
def __init__(self, left, right):
self.left = Content(left)
self.right = Content(right)
self.screen = self.right.screen
self.instructions = ''
self.fields = []
self.on_complete = None
self.current_field = 0
self.offsets = []
self.menu = ['[ESC] to cancel']
def configure(self, instructions, fields, on_complete):
self.instructions = instructions
self.fields = fields
self.on_complete = on_complete
self.offsets = []
self.current_field = 0
self.results = {}
def draw(self, itemno=None):
self.left.screen.erase()
self.right.screen.erase()
self.left.screen.move(0, 0)
self.right.screen.move(0, 0)
if self.instructions != '':
self.right.screen.addstr(2, 0, self.instructions)
y, x = self.right.screen.getyx()
for idx, field in enumerate(self.fields):
offset = y + idx * 2
self.offsets.append(offset)
text = f'{field.label}: '
self.left.screen.addstr(offset, self.left.region.width - len(text), text, curses.A_BOLD)
self.right.screen.addstr(offset, 0, field.to_str(field.value))
y, x = self.right.screen.getyx()
offset = y + 2
self.right.screen.addstr(offset, 0, '[Done]', curses.A_BOLD)
self.offsets.append(offset)
self.right.screen.move(self.offsets[itemno or 0], 0)
self.left.screen.noutrefresh(0, 0, *self.left.region.box)
self.right.screen.noutrefresh(0, 0, *self.right.region.box)
curses.curs_set(1)
curses.ungetch(curses.KEY_LEFT)
def handle_key(self, ch, y, x):
if ch == curses.KEY_BACKSPACE:
self.right.screen.move(y, max(0, x - 1))
self.right.screen.delch(y, max(0, x - 1))
elif ch == curses.ascii.STX:
self.right.screen.move(y, 0)
elif ch == curses.ascii.ETX:
line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip()
self.right.screen.move(y, len(line) - 1)
elif ch == curses.KEY_LEFT:
self.right.screen.move(y, max(0, x - 1))
elif ch == curses.KEY_RIGHT:
line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip()
self.right.screen.move(y, min(len(line), x + 1))
elif ch == curses.ascii.TAB:
self.current_field = 0 if self.current_field == len(self.offsets) - 1 else self.current_field + 1
self.right.screen.move(self.offsets[self.current_field], 0)
elif curses.ascii.isprint(ch):
self.right.screen.echochar(ch)
self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box)
self.right.screen.noutrefresh(self.left.first_visible, 0, *self.right.region.box)
if ch == curses.ascii.NL:
if self.current_field < len(self.offsets) - 1:
field = self.fields[self.current_field]
line = self.right.screen.instr(y, 0, self.right.region.width).decode('utf-8').rstrip()
self.right.screen.addstr(self.offsets[self.current_field], 0, line, curses.A_ITALIC)
field.value = field.from_str(line)
curses.ungetch(curses.ascii.TAB)
self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box)
self.right.screen.noutrefresh(self.left.first_visible, 0, *self.right.region.box)
else:
self.on_complete(dict((f.name, f.value) for f in self.fields))
def resize(self):
self.left.resize()
self.right.resize()

View File

@ -0,0 +1,228 @@
import curses, curses.ascii
import json
from datetime import datetime
from SpiffWorkflow.util.task import TaskState
from .content import Content
from .user_input import Field
default_view = {
'state': TaskState.ANY_MASK,
'spec_name': None,
'updated_ts': 0,
}
run_view = {
'state': TaskState.READY|TaskState.WAITING,
'spec_name': None,
'updated_ts': 0,
}
class WorkflowView:
def __init__(self, app):
self.left = Content(app.left)
self.right = Content(app.right)
self.engine = app.engine
self.complete_task = app.complete_task
self._show_filters = app.show_filters
self.workflow = None
self.workflow_id = None
self.current_filter = default_view
self.step = True
self.task_view = 'list'
self.info_view = 'task'
self.scroll = 'left'
self.tasks = []
self.selected = 0
self._previous_state = None
self.screen = self.left.screen
self.menu = [
'[l]ist/tree view',
'[w]orkflow/task data view',
'[f]ilter tasks',
'[r]efresh tasks',
'[s]ave workflow state',
]
self.styles = {
TaskState.MAYBE: curses.color_pair(4),
TaskState.LIKELY: curses.color_pair(4),
TaskState.FUTURE: curses.color_pair(6),
TaskState.WAITING: curses.color_pair(3),
TaskState.READY: curses.color_pair(2),
TaskState.STARTED: curses.color_pair(6),
TaskState.ERROR: curses.color_pair(1),
TaskState.CANCELLED: curses.color_pair(5),
}
def set_workflow(self, workflow, wf_id):
self.workflow = workflow
self.workflow_id = wf_id
def draw(self):
self.update_task_tree()
self.update_info()
def update_task_tree(self):
self.tasks = [t for t in self.workflow.get_tasks(**self.current_filter)]
if self.selected > len(self.tasks) - 1:
self.selected = 0
self.left.screen.erase()
if len(self.tasks) > 0:
self.left.content_height = len(self.tasks)
self.left.resize()
for idx, task in enumerate(self.tasks):
indent = 2 * task.depth
color = self.styles.get(task.state, 0)
attr = color | curses.A_BOLD if idx == self.selected else color
name = task.task_spec.bpmn_name or task.task_spec.name
lane = f'({task.task_spec.lane}) ' if task.task_spec.lane is not None else ''
state = TaskState.get_name(task.state)
task_info = f'{lane}{name} [{state}]'
if self.task_view == 'list':
self.left.screen.addstr(idx, 0, task_info, attr)
else:
self.left.screen.addstr(idx, 0, ' ' * indent + task_info, attr)
if self.info_view == 'task':
self.show_task()
self.left.screen.move(self.selected, 0)
else:
self.info_view = 'workflow'
self.left.content_height = self.left.region.height - 1
self.left.resize()
self.left.screen.addstr(0, 0, 'No tasks available')
self.left.screen.noutrefresh(self.left.first_visible, 0, *self.left.region.box)
def update_info(self):
if self.info_view == 'task' and len(self.tasks) > 0:
self.show_task()
else:
self.show_workflow()
def show_task(self):
task = self.tasks[self.selected]
info = {
'Name': task.task_spec.name,
'Bpmn ID': task.task_spec.bpmn_id or '',
'Bpmn name': task.task_spec.bpmn_name or '',
'Description': task.task_spec.description,
'Last state change': datetime.fromtimestamp(task.last_state_change),
}
self._show_details(info, task.data)
def show_workflow(self):
info = {
'Spec': self.workflow.spec.name,
'Ready tasks': len(self.workflow.get_tasks(state=TaskState.READY)),
'Waiting tasks': len(self.workflow.get_tasks(state=TaskState.WAITING)),
'Finished tasks': len(self.workflow.get_tasks(state=TaskState.FINISHED_MASK)),
'Total tasks': len(self.workflow.get_tasks()),
'Waiting subprocesses': len([sp for sp in self.workflow.subprocesses.values() if not sp.is_completed()]),
'Total subprocesses': len(self.workflow.subprocesses)
}
self._show_details(info, self.workflow.data)
def _show_details(self, info, data=None):
self.right.screen.erase()
lines = len(info)
if data is not None:
lines += 2
serialized = {}
for key, value in data.items():
serialized[key] = json.dumps(value, indent=2)
lines += len(serialized[key].split('\n'))
self.right.content_height = lines + 1
for name, value in info.items():
self.right.screen.addstr(f'{name}: ', curses.A_BOLD)
self.right.screen.addstr(f'{value}\n')
if data is not None:
self.right.screen.addstr('\nData\n', curses.A_BOLD)
for name, value in serialized.items():
self.right.screen.addstr(f'{name}: ', curses.A_ITALIC)
self.right.screen.addstr(f'{value}\n')
self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box)
def show_filters(self):
values = self.current_filter
fields = [
Field('state', 'State', TaskState.get_name, TaskState.get_value, values['state']),
Field('spec_name', 'Task spec', lambda v: v or '', lambda v: None if v == '' else v, values['spec_name']),
Field(
'updated_ts',
'Updated on or after',
lambda v: datetime.fromtimestamp(v).isoformat(),
lambda v: datetime.fromisoformat(v).timestamp(),
values['updated_ts'],
),
]
self._show_filters(fields)
def handle_key(self, ch, y, x):
if chr(ch).lower() == 'l':
self.task_view = 'tree' if self.task_view == 'list' else 'list'
self.update_task_tree()
elif chr(ch).lower() == 'w':
self.info_view = 'workflow' if self.info_view == 'task' else 'task'
self.update_info()
elif chr(ch).lower() == 'f':
self.show_filters()
elif chr(ch).lower() == 'r':
if self.step is False:
self.engine.run_ready_events(self.workflow)
else:
self.workflow.refresh_waiting_tasks()
self.update_task_tree()
elif chr(ch).lower() == 's':
self.engine.update_workflow(self.workflow, self.workflow_id)
elif ch == curses.ascii.TAB:
if self.scroll == 'right':
self.scroll = 'left'
self.screen = self.left.screen
curses.curs_set(0)
else:
self.scroll = 'right'
self.screen = self.right.screen
self.right.screen.move(0, 0)
curses.curs_set(1)
elif ch == curses.KEY_DOWN:
if self.scroll == 'left' and self.selected < len(self.tasks) - 1:
self.selected += 1
self.left.scroll_down(y)
self.update_task_tree()
else:
self.right.scroll_down(y)
self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box)
elif ch == curses.KEY_UP:
if self.scroll == 'left' and self.selected > 0:
self.selected -= 1
self.left.scroll_up(y)
self.update_task_tree()
elif self.scroll == 'right':
self.right.scroll_up(y)
self.right.screen.noutrefresh(self.right.first_visible, 0, *self.right.region.box)
elif ch == curses.ascii.NL:
if self.scroll == 'left' and len(self.tasks) > 0:
task = self.tasks[self.selected]
if task.state == TaskState.READY:
self.complete_task(task)
self.draw()
def resize(self):
self.left.resize()
self.right.resize()

View File

@ -0,0 +1 @@
from .engine import BpmnEngine

View File

@ -0,0 +1,100 @@
import curses
import logging
from SpiffWorkflow.bpmn.parser.ValidationException import ValidationException
from SpiffWorkflow.bpmn.specs.mixins.events.event_types import CatchingEvent
from SpiffWorkflow.bpmn import BpmnWorkflow
from SpiffWorkflow.bpmn.script_engine import PythonScriptEngine
from SpiffWorkflow import TaskState
logger = logging.getLogger('spiff_engine')
class BpmnEngine:
def __init__(self, parser, serializer, handlers=None, script_engine=None):
self.parser = parser
self.serializer = serializer
self._script_engine = script_engine or PythonScriptEngine()
self._handlers = handlers or {}
def handler(self, task):
handler = self._handlers.get(task.task_spec.__class__)
if handler is not None:
return handler(task)
def add_spec(self, process_id, bpmn_files, dmn_files):
self.add_files(bpmn_files, dmn_files)
try:
spec = self.parser.get_spec(process_id)
dependencies = self.parser.get_subprocess_specs(process_id)
except ValidationException as exc:
# Clear the process parsers so the files can be re-added
# There's probably plenty of other stuff that should be here
# However, our parser makes me mad so not investigating further at this time
self.parser.process_parsers = {}
raise exc
spec_id = self.serializer.create_workflow_spec(spec, dependencies)
logger.info(f'Added {process_id} with id {spec_id}')
return spec_id
def add_collaboration(self, collaboration_id, bpmn_files, dmn_files=None):
self.add_files(bpmn_files, dmn_files)
try:
spec, dependencies = self.parser.get_collaboration(collaboration_id)
except ValidationException as exc:
self.parser.process_parsers = {}
raise exc
spec_id = self.serializer.create_workflow_spec(spec, dependencies)
logger.info(f'Added {collaboration_id} with id {spec_id}')
return spec_id
def add_files(self, bpmn_files, dmn_files):
self.parser.add_bpmn_files(bpmn_files)
if dmn_files is not None:
self.parser.add_dmn_files(dmn_files)
def list_specs(self):
return self.serializer.list_specs()
def delete_workflow_spec(self, spec_id):
self.serializer.delete_workflow_spec(spec_id)
logger.info(f'Deleted workflow spec with id {spec_id}')
def start_workflow(self, spec_id):
spec, sp_specs = self.serializer.get_workflow_spec(spec_id)
wf = BpmnWorkflow(spec, sp_specs, script_engine=self._script_engine)
wf_id = self.serializer.create_workflow(wf, spec_id)
logger.info(f'Created workflow with id {wf_id}')
return wf_id
def get_workflow(self, wf_id):
wf = self.serializer.get_workflow(wf_id)
wf.script_engine = self._script_engine
return wf
def update_workflow(self, workflow, wf_id):
logger.info(f'Saved workflow {wf_id}')
self.serializer.update_workflow(workflow, wf_id)
def list_workflows(self, include_completed=False):
return self.serializer.list_workflows(include_completed)
def delete_workflow(self, wf_id):
self.serializer.delete_workflow(wf_id)
logger.info(f'Deleted workflow with id {wf_id}')
def run_until_user_input_required(self, workflow):
task = workflow.get_next_task(state=TaskState.READY, manual=False)
while task is not None:
task.run()
self.run_ready_events(workflow)
task = workflow.get_next_task(state=TaskState.READY, manual=False)
def run_ready_events(self, workflow):
workflow.refresh_waiting_tasks()
task = workflow.get_next_task(state=TaskState.READY, spec_class=CatchingEvent)
while task is not None:
task.run()
task = workflow.get_next_task(state=TaskState.READY, spec_class=CatchingEvent)

View File

View File

@ -0,0 +1 @@
from .serializer import FileSerializer

View File

@ -0,0 +1,117 @@
import json, re
import os
import logging
from uuid import uuid4, UUID
from datetime import datetime
from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer
from SpiffWorkflow.bpmn.serializer.default.workflow import BpmnWorkflowConverter, BpmnSubWorkflowConverter
from SpiffWorkflow.bpmn.serializer.default.process_spec import BpmnProcessSpecConverter
logger = logging.getLogger(__name__)
class FileSerializer(BpmnWorkflowSerializer):
@staticmethod
def initialize(dirname):
try:
os.makedirs(dirname, exist_ok=True)
os.mkdir(os.path.join(dirname, 'spec'))
os.mkdir(os.path.join(dirname, 'instance'))
except FileExistsError:
pass
def __init__(self, dirname, **kwargs):
super().__init__(**kwargs)
self.dirname = dirname
self.fmt = {'indent': 2, 'separators': [', ', ': ']}
def create_workflow_spec(self, spec, dependencies):
spec_dir = os.path.join(self.dirname, 'spec')
if spec.file is not None:
dirname = os.path.join(spec_dir, os.path.dirname(spec.file), spec.name)
else:
dirname = os.path.join(spec_dir, spec.name)
filename = os.path.join(dirname, f'{spec.name}.json')
try:
os.makedirs(dirname, exist_ok=True)
with open(filename, 'x') as fh:
fh.write(json.dumps(self.to_dict(spec), **self.fmt))
if len(dependencies) > 0:
os.mkdir(os.path.join(dirname, 'dependencies'))
for name, sp in dependencies.items():
with open(os.path.join(dirname, 'dependencies', f'{name}.json'), 'w') as fh:
fh.write(json.dumps(self.to_dict(sp), **self.fmt))
except FileExistsError:
pass
return filename
def delete_workflow_spec(self, filename):
try:
os.remove(filename)
except FileNotFoundError:
pass
def get_workflow_spec(self, filename, **kwargs):
dirname = os.path.dirname(filename)
with open(filename) as fh:
spec = self.from_dict(json.loads(fh.read()))
subprocess_specs = {}
depdir = os.path.join(os.path.dirname(filename), 'dependencies')
if os.path.exists(depdir):
for f in os.listdir(depdir):
name = re.sub('\.json$', '', os.path.basename(f))
with open(os.path.join(depdir, f)) as fh:
subprocess_specs[name] = self.from_dict(json.loads(fh.read()))
return spec, subprocess_specs
def list_specs(self):
library = []
for root, dirs, files in os.walk(os.path.join(self.dirname, 'spec')):
if 'dependencies' not in root:
for f in files:
filename = os.path.join(root, f)
name = re.sub('\.json$', '', os.path.basename(f))
path = re.sub(os.path.join(self.dirname, 'spec'), '', filename).lstrip('/')
library.append((filename, name, path))
return library
def create_workflow(self, workflow, spec_id):
name = re.sub('\.json$', '', os.path.basename(spec_id))
dirname = os.path.join(self.dirname, 'instance', name)
os.makedirs(dirname, exist_ok=True)
wf_id = uuid4()
with open(os.path.join(dirname, f'{wf_id}.json'), 'w') as fh:
fh.write(json.dumps(self.to_dict(workflow), **self.fmt))
return os.path.join(dirname, f'{wf_id}.json')
def get_workflow(self, filename, **kwargs):
with open(filename) as fh:
return self.from_dict(json.loads(fh.read()))
def update_workflow(self, workflow, filename):
with open(filename, 'w') as fh:
fh.write(json.dumps(self.to_dict(workflow), **self.fmt))
def delete_workflow(self, filename):
try:
os.remove(filename)
except FileNotFoundError:
pass
def list_workflows(self, include_completed):
instances = []
for root, dirs, files in os.walk(os.path.join(self.dirname, 'instance')):
for f in files:
filename = os.path.join(root, f)
name = os.path.split(os.path.dirname(filename))[-1]
stat = os.lstat(filename)
created = datetime.fromtimestamp(stat.st_ctime).strftime('%Y-%^m-%d %H:%M:%S')
updated = datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%^m-%d %H:%M:%S')
# '?' is active tasks -- we can't know this unless we reydrate the workflow
# We also have to lose the ability to filter out completed workflows
instances.append((filename, name, '-', created, updated, '-'))
return instances

View File

@ -0,0 +1,6 @@
from .serializer import (
SqliteSerializer,
WorkflowConverter,
SubworkflowConverter,
WorkflowSpecConverter,
)

View File

@ -0,0 +1,221 @@
create table if not exists _workflow_spec (
id uuid primary key,
serialization json
);
create table if not exists _task_spec (
workflow_spec_id uuid references _workflow_spec (id) on delete cascade,
name text generated always as (serialization->>'name') stored,
serialization json
);
create index if not exists task_spec_workflow on _task_spec (workflow_spec_id);
create index if not exists task_spec_name on _task_spec (name);
create view if not exists workflow_spec as
with
ts as (select workflow_spec_id, json_group_object(name, json(serialization)) task_specs from _task_spec group by workflow_spec_id)
select
id, json_insert(serialization, '$.task_specs', json(task_specs)) serialization
from (
select _workflow_spec.id id, serialization, task_specs from _workflow_spec join ts on _workflow_spec.id=ts.workflow_spec_id
);
create trigger if not exists insert_workflow_spec instead of insert on workflow_spec
begin
insert into _workflow_spec (id, serialization) values (new.id, json_remove(new.serialization, '$.task_specs'));
insert into _task_spec (workflow_spec_id, serialization) select new.id, value from json_each(new.serialization->>'task_specs');
end;
create trigger if not exists delete_workflow_spec instead of delete on workflow_spec
begin
delete from _workflow_spec where id=old.id;
delete from _task_spec where workflow_spec_id=old.id;
end;
create table if not exists _spec_dependency (
parent_id uuid references _workflow_spec (id) on delete cascade,
child_id uuid references _workflow_spec (id) on delete cascade
);
create index if not exists spec_parent on _spec_dependency (parent_id);
create index if not exists spec_child on _spec_dependency (child_id);
create view if not exists spec_dependency as
with recursive
dependency(root, descendant, depth) as (
select parent_id, child_id, 0 from _spec_dependency
union
select root, child_id, depth + 1 from _spec_dependency, dependency where parent_id=dependency.descendant
)
select root, descendant, depth, serialization from dependency join workflow_spec on dependency.descendant=workflow_spec.id;
create table if not exists _workflow (
id uuid primary key,
workflow_spec_id uuid references _workflow_spec (id),
serialization json
);
create table if not exists _task (
workflow_id references _workflow (id) on delete cascade,
id uuid generated always as (serialization->>'id') stored unique,
serialization json
);
create index if not exists task_id on _task (id);
create index if not exists task_workflow_id on _task (workflow_id);
create table if not exists _task_data (
workflow_id uuid references _workflow (id) on delete cascade,
task_id uuid,
name text,
value json,
last_updated timestamp default current_timestamp,
unique (task_id, name)
);
create index if not exists task_data_id on _task_data (task_id);
create index if not exists task_data_name on _task_data (name);
create view if not exists task as
with data as (
select task_id, json_group_object(name, iif(json_valid(value), json(value), value)) task_data
from _task_data group by task_id
)
select _task.workflow_id, _task.id, json_insert(serialization, '$.data', json(ifnull(task_data, '{}'))) serialization
from _task left join data on _task.id=data.task_id;
create trigger if not exists insert_task instead of insert on task
begin
insert into _task (workflow_id, serialization) values (new.workflow_id, json_remove(new.serialization, '$.data'));
insert into _task_data (workflow_id, task_id, name, value)
select new.workflow_id, new.serialization->>'id', key, value from json_each(new.serialization->>'data') where true
on conflict (task_id, name) do nothing;
end;
create trigger if not exists update_task instead of update on task
begin
update _task set serialization=json_remove(new.serialization, '$.data') where _task.id=new.serialization->>'id';
delete from _task_data where task_id=new.id and name not in (select key from json_each(new.serialization->>'data'));
insert into _task_data (workflow_id, task_id, name, value)
select new.workflow_id, new.serialization->>'id', key, value from json_each(new.serialization->>'data') where true
on conflict (task_id, name) do update set value=excluded.value;
end;
create trigger if not exists delete_task instead of delete on task
begin
delete from _task where id=old.id;
delete from _task_data where task_id=old.id;
end;
create table if not exists _workflow_data (
workflow_id uuid references _workflow (id) on delete cascade,
name text,
value json,
last_updated timestamp default current_timestamp,
unique (workflow_id, name)
);
create index if not exists workflow_data_id on _workflow_data (workflow_id);
create index if not exists wokflow_data_name on _workflow_data (name);
create view if not exists workflow as
with
tasks as (select workflow_id, json_group_object(id, json(serialization)) tasks from task group by workflow_id),
data as (select workflow_id, json_group_object(name, iif(json_valid(value), json(value), value)) data from _workflow_data group by workflow_id)
select
_workflow.id,
_workflow.workflow_spec_id,
json_insert(
json_insert(
json_insert(
_workflow.serialization,
'$.data',
json(ifnull(data, '{}'))
),
'$.tasks',
json(tasks)
),
'$.spec',
json(workflow_spec.serialization)
) serialization
from _workflow
left join data on _workflow.id=data.workflow_id
join tasks on _workflow.id=tasks.workflow_id
join workflow_spec on _workflow.workflow_spec_id=workflow_spec.id;
create trigger if not exists insert_workflow instead of insert on workflow
begin
insert into _workflow (id, workflow_spec_id, serialization)
values (
new.id,
new.workflow_spec_id,
json_remove(json_remove(new.serialization, '$.tasks'), '$.data')
);
insert into task (workflow_id, serialization) select new.id, value from json_each(new.serialization->>'tasks');
insert into _workflow_data (workflow_id, name, value) select new.id, key, value from json_each(new.serialization->>'data');
end;
create trigger if not exists update_workflow instead of update on workflow
begin
update _workflow set serialization=json_remove(json_remove(new.serialization, '$.tasks'), '$.data') where _workflow.id=new.id;
delete from task where workflow_id=new.id and id not in (select value->>'id' from json_each(new.serialization->>'tasks'));
update task set serialization=value from (
select value, serialization from json_each(new.serialization->>'tasks') t join _task on value->>'id'=_task.id
) t
where value->>'id'=task.id and value->>'last_state_change' > t.serialization->>'last_state_change';
insert into task (workflow_id, serialization) select new.id, value from json_each(new.serialization->>'tasks')
where value->>'id' not in (select id from _task where workflow_id=new.id);
delete from _workflow_data where workflow_id=new.id and name not in (select key from json_each(new.serialization->>'data'));
insert into _workflow_data (workflow_id, name, value) select new.id, key, value from json_each(new.serialization->>'$.data') where true
on conflict (workflow_id, name) do update set value=excluded.value;
end;
create trigger if not exists delete_workflow instead of delete on workflow
begin
delete from _workflow where id=old.id;
delete from _task where workflow_id=old.id;
delete from _workflow_data where workflow_id=old.id;
end;
create view if not exists workflow_dependency as
with recursive
subworkflow as (select workflow_id, id from _task where id in (select id from _workflow)),
dependency(root, descendant, depth) as (
select workflow_id, id, 1 from subworkflow
union
select workflow_id, dependency.descendant, depth + 1 from subworkflow, dependency where subworkflow.id=dependency.root
)
select root, descendant, depth, serialization from dependency join workflow on dependency.descendant=workflow.id;
create view if not exists spec_library as
select id, serialization->>'name' name, serialization->>'file' filename from workflow_spec
where id not in (select distinct child_id from _spec_dependency);
create table if not exists instance (
id uuid primary key references _workflow (id) on delete cascade,
bullshit text,
spec_name text,
active_tasks int,
started timestamp,
updated timestamp,
ended timestamp
);
create trigger if not exists create_instance instead of insert on workflow
begin
insert into instance (id, spec_name, active_tasks, started) select * from (
select new.id, name, count(value), current_timestamp
from (select name from spec_library where id=new.workflow_spec_id), json_each(new.serialization->>'tasks')
where value->>'state' between 8 and 32
) where new.serialization->>'typename'='BpmnWorkflow';
end;
create trigger if not exists update_instance instead of update on workflow
begin
update instance set updated=current_timestamp, ended=t.ended from (
select iif(count(value)=0, current_timestamp, null) ended
from json_each(new.serialization->>'tasks') where value->>'state' < 64
) t where id=new.id;
end;
create trigger if not exists delete_instance instead of delete on workflow
begin
delete from instance where id=old.id;
end;

View File

@ -0,0 +1,221 @@
import sqlite3, json
import os
import logging
from uuid import uuid4, UUID
from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer
from SpiffWorkflow.bpmn.serializer.default.workflow import BpmnWorkflowConverter, BpmnSubWorkflowConverter
from SpiffWorkflow.bpmn.serializer.default.process_spec import BpmnProcessSpecConverter
from SpiffWorkflow.bpmn.specs.mixins.subworkflow_task import SubWorkflowTask
logger = logging.getLogger(__name__)
class WorkflowConverter(BpmnWorkflowConverter):
def to_dict(self, workflow):
dct = super(BpmnWorkflowConverter, self).to_dict(workflow)
dct['bpmn_events'] = self.registry.convert(workflow.bpmn_events)
dct['subprocesses'] = {}
dct['tasks'] = list(dct['tasks'].values())
return dct
class SubworkflowConverter(BpmnSubWorkflowConverter):
def to_dict(self, workflow):
dct = super().to_dict(workflow)
dct['tasks'] = list(dct['tasks'].values())
return dct
class WorkflowSpecConverter(BpmnProcessSpecConverter):
def to_dict(self, spec):
dct = super().to_dict(spec)
dct['task_specs'] = list(dct['task_specs'].values())
return dct
class SqliteSerializer(BpmnWorkflowSerializer):
@staticmethod
def initialize(db):
with open(os.path.join(os.path.dirname(__file__), 'schema-sqlite.sql')) as fh:
db.executescript(fh.read())
db.commit()
def __init__(self, dbname, **kwargs):
super().__init__(**kwargs)
self.dbname = dbname
def create_workflow_spec(self, spec, dependencies):
spec_id, new = self.execute(self._create_workflow_spec, spec)
if new and len(dependencies) > 0:
pairs = self.get_spec_dependencies(spec_id, spec, dependencies)
# This handles the case where the participant requires an event to be kicked off
added = list(map(lambda p: p[1], pairs))
for name, child in dependencies.items():
child_id, new_child = self.execute(self._create_workflow_spec, child)
if new_child:
pairs |= self.get_spec_dependencies(child_id, child, dependencies)
pairs.add((spec_id, child_id))
self.execute(self._set_spec_dependencies, pairs)
return spec_id
def get_spec_dependencies(self, parent_id, parent, dependencies):
# There ought to be an option in the parser to do this
pairs = set()
for task_spec in filter(lambda ts: isinstance(ts, SubWorkflowTask), parent.task_specs.values()):
child = dependencies.get(task_spec.spec)
child_id, new = self.execute(self._create_workflow_spec, child)
pairs.add((parent_id, child_id))
if new:
pairs |= self.get_spec_dependencies(child_id, child, dependencies)
return pairs
def get_workflow_spec(self, spec_id, include_dependencies=True):
return self.execute(self._get_workflow_spec, spec_id, include_dependencies)
def list_specs(self):
return self.execute(self._list_specs)
def delete_workflow_spec(self, spec_id):
return self.execute(self._delete_workflow_spec, spec_id)
def create_workflow(self, workflow, spec_id):
return self.execute(self._create_workflow, workflow, spec_id)
def get_workflow(self, wf_id, include_dependencies=True):
return self.execute(self._get_workflow, wf_id, include_dependencies)
def update_workflow(self, workflow, wf_id):
return self.execute(self._update_workflow, workflow, wf_id)
def list_workflows(self, include_completed=False):
return self.execute(self._list_workflows, include_completed)
def delete_workflow(self, wf_id):
return self.execute(self._delete_workflow, wf_id)
def _create_workflow_spec(self, cursor, spec):
cursor.execute(
"select id, false from workflow_spec where serialization->>'file'=? and serialization->>'name'=?",
(spec.file, spec.name)
)
row = cursor.fetchone()
if row is None:
dct = self.to_dict(spec)
spec_id = uuid4()
cursor.execute("insert into workflow_spec (id, serialization) values (?, ?)", (spec_id, dct))
return spec_id, True
else:
return row
def _set_spec_dependencies(self, cursor, values):
cursor.executemany("insert into _spec_dependency (parent_id, child_id) values (?, ?)", values)
def _get_workflow_spec(self, cursor, spec_id, include_dependencies):
cursor.execute("select serialization as 'serialization [json]' from workflow_spec where id=?", (spec_id, ))
spec = self.from_dict(cursor.fetchone()[0])
subprocess_specs = {}
if include_dependencies:
subprocess_specs = self._get_subprocess_specs(cursor, spec_id)
return spec, subprocess_specs
def _get_subprocess_specs(self, cursor, spec_id):
subprocess_specs = {}
cursor.execute(
"select serialization->>'name', serialization as 'serialization [json]' from spec_dependency where root=?",
(spec_id, )
)
for name, serialization in cursor:
subprocess_specs[name] = self.from_dict(serialization)
return subprocess_specs
def _list_specs(self, cursor):
cursor.execute("select id, name, filename from spec_library")
return cursor.fetchall()
def _delete_workflow_spec(self, cursor, spec_id):
try:
cursor.execute("delete from workflow_spec where id=?", (spec_id, ))
except sqlite3.IntegrityError:
logger.warning(f'Unable to delete spec {spec_id} because it is used by existing workflows')
def _create_workflow(self, cursor, workflow, spec_id):
dct = super().to_dict(workflow)
wf_id = uuid4()
stmt = "insert into workflow (id, workflow_spec_id, serialization) values (?, ?, ?)"
cursor.execute(stmt, (wf_id, spec_id, dct))
if len(workflow.subprocesses) > 0:
cursor.execute("select serialization->>'name', descendant from spec_dependency where root=?", (spec_id, ))
dependencies = dict((name, id) for name, id in cursor)
for sp_id, sp in workflow.subprocesses.items():
cursor.execute(stmt, (sp_id, dependencies[sp.spec.name], self.to_dict(sp)))
return wf_id
def _get_workflow(self, cursor, wf_id, include_dependencies):
cursor.execute("select workflow_spec_id, serialization as 'serialization [json]' from workflow where id=?", (wf_id, ))
row = cursor.fetchone()
spec_id, workflow = row[0], self.from_dict(row[1])
if include_dependencies:
workflow.subprocess_specs = self._get_subprocess_specs(cursor, spec_id)
cursor.execute(
"select descendant as 'id [uuid]', serialization as 'serialization [json]' from workflow_dependency where root=? order by depth",
(wf_id, )
)
for sp_id, sp in cursor:
task = workflow.get_task_from_id(sp_id)
workflow.subprocesses[sp_id] = self.from_dict(sp, task=task, top_workflow=workflow)
return workflow
def _update_workflow(self, cursor, workflow, wf_id):
dct = self.to_dict(workflow)
cursor.execute("select descendant as 'id [uuid]' from workflow_dependency where root=?", (wf_id, ))
dependencies = [row[0] for row in cursor]
cursor.execute(
"select serialization->>'name', descendant as 'id [uuid]' from spec_dependency where root=(select workflow_spec_id from _workflow where id=?)",
(wf_id, )
)
spec_dependencies = dict((name, spec_id) for name, spec_id in cursor)
stmt = "update workflow set serialization=? where id=?"
cursor.execute(stmt, (dct, wf_id))
for sp_id, sp in workflow.subprocesses.items():
sp_dct = self.to_dict(sp)
if sp_id in dependencies:
cursor.execute(stmt, (sp_dct, sp_id))
else:
cursor.execute(
"insert into workflow (id, workflow_spec_id, serialization) values (?, ?, ?)",
(sp_id, spec_dependencies[sp.spec.name], sp_dct)
)
def _list_workflows(self, cursor, include_completed):
if include_completed:
query = "select id, spec_name, active_tasks, started, updated, ended from instance"
else:
query = "select id, spec_name, active_tasks, started, updated, ended from instance where ended is null"
cursor.execute(query)
return cursor.fetchall()
def _delete_workflow(self, cursor, wf_id):
cursor.execute("delete from workflow where id=?", (wf_id, ))
def execute(self, func, *args, **kwargs):
conn = sqlite3.connect(self.dbname, detect_types=sqlite3.PARSE_DECLTYPES|sqlite3.PARSE_COLNAMES)
conn.execute("pragma foreign_keys=on")
sqlite3.register_adapter(UUID, lambda v: str(v))
sqlite3.register_converter("uuid", lambda s: UUID(s.decode('utf-8')))
sqlite3.register_adapter(dict, lambda v: json.dumps(v))
sqlite3.register_converter("json", lambda s: json.loads(s))
cursor = conn.cursor()
try:
rv = func(cursor, *args, **kwargs)
conn.commit()
except Exception as exc:
logger.error(str(exc), exc_info=True)
conn.rollback()
finally:
cursor.close()
conn.close()
return rv

View File

View File

@ -0,0 +1,63 @@
import os, json
import logging
from jinja2 import Template
from ..curses_ui.user_input import Field
forms_dir = 'bpmn/tutorial/forms'
class TaskHandler:
def __init__(self, task):
self.task = task
def get_instructions(self):
instructions = f'{self.task.task_spec.bpmn_name}\n\n'
text = self.task.task_spec.extensions.get('instructionsForEndUser')
if text is not None:
template = Template(text)
instructions += template.render(self.task.data)
instructions += '\n\n'
return instructions
def on_complete(self, results):
self.task.run()
class ManualTaskHandler(TaskHandler):
def get_configuration(self):
return self.get_instructions(), []
class UserTaskHandler(TaskHandler):
def get_configuration(self):
return self.get_instructions(), self.get_fields()
def create_field(self, name, config):
if 'oneOf' in config:
option_map = dict([ (v['title'], v['const']) for v in config['oneOf'] ])
label = f'{config["title"]} ' + '(' + ', '.join(option_map) + ')'
def validate(value):
if value not in option_map:
raise Exception(f'Invalid option: {value}')
else:
return option_map[value]
field = Field(name, label, lambda v: v, validate, '')
elif config['type'] == 'integer':
field = Field(name, config['title'], lambda v: str(v) if v is not None else '', int, None)
else:
field = Field(name, config['title'], str, str, '')
return field
def get_fields(self):
filename = self.task.task_spec.extensions['properties']['formJsonSchemaFilename']
schema = json.load(open(os.path.join(forms_dir, filename)))
return [self.create_field(name, config) for name, config in schema['properties'].items()]
def on_complete(self, results):
self.task.set_data(**results)
super().on_complete(results)

View File

@ -0,0 +1,53 @@
import logging
import datetime
from SpiffWorkflow.spiff.parser.process import SpiffBpmnParser
from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask
from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG
from SpiffWorkflow.bpmn.workflow import BpmnWorkflow, BpmnSubWorkflow
from SpiffWorkflow.bpmn.specs.bpmn_process_spec import BpmnProcessSpec
from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask
from SpiffWorkflow.bpmn.script_engine import PythonScriptEngine, TaskDataEnvironment
from ..serializer.file import FileSerializer
from ..engine import BpmnEngine
from .curses_handlers import UserTaskHandler, ManualTaskHandler
from .product_info import (
ProductInfo,
product_info_to_dict,
product_info_from_dict,
lookup_product_info,
lookup_shipping_cost,
)
logger = logging.getLogger('spiff_engine')
logger.setLevel(logging.INFO)
spiff_logger = logging.getLogger('spiff')
spiff_logger.setLevel(logging.INFO)
dirname = 'wfdata'
FileSerializer.initialize(dirname)
registry = FileSerializer.configure(SPIFF_CONFIG)
registry.register(ProductInfo, product_info_to_dict, product_info_from_dict)
serializer = FileSerializer(dirname, registry=registry)
parser = SpiffBpmnParser()
handlers = {
UserTask: UserTaskHandler,
ManualTask: ManualTaskHandler,
NoneTask: ManualTaskHandler,
}
script_env = TaskDataEnvironment({
'datetime': datetime,
'lookup_product_info': lookup_product_info,
'lookup_shipping_cost': lookup_shipping_cost,
})
script_engine = PythonScriptEngine(script_env)
engine = BpmnEngine(parser, serializer, handlers, script_engine)

View File

@ -1,17 +1,18 @@
from SpiffWorkflow.bpmn.specs.event_definitions import TimerEventDefinition, NoneEventDefinition
from SpiffWorkflow.bpmn.specs.mixins.events.start_event import StartEvent
from SpiffWorkflow.spiff.specs.spiff_task import SpiffBpmnTask
from SpiffWorkflow.bpmn.specs.event_definitions import NoneEventDefinition
from SpiffWorkflow.bpmn.specs.event_definitions.timer import TimerEventDefinition
from SpiffWorkflow.bpmn.specs.mixins import StartEventMixin
from SpiffWorkflow.spiff.specs import SpiffBpmnTask
from SpiffWorkflow.bpmn.serializer.workflow import BpmnWorkflowSerializer
from SpiffWorkflow.bpmn.serializer.task_spec import StartEventConverter
from SpiffWorkflow.bpmn.serializer import BpmnWorkflowSerializer
from SpiffWorkflow.bpmn.serializer.default import EventConverter
from SpiffWorkflow.spiff.serializer.task_spec import SpiffBpmnTaskConverter
from SpiffWorkflow.spiff.serializer.config import SPIFF_SPEC_CONFIG
from SpiffWorkflow.spiff.serializer import DEFAULT_CONFIG
from SpiffWorkflow.spiff.parser import SpiffBpmnParser
from SpiffWorkflow.spiff.parser.event_parsers import StartEventParser
from SpiffWorkflow.spiff.parser.process import SpiffBpmnParser
from SpiffWorkflow.bpmn.parser.util import full_tag
class CustomStartEvent(StartEvent, SpiffBpmnTask):
class CustomStartEvent(StartEventMixin, SpiffBpmnTask):
def __init__(self, wf_spec, bpmn_id, event_definition, **kwargs):
@ -22,7 +23,6 @@ class CustomStartEvent(StartEvent, SpiffBpmnTask):
super().__init__(wf_spec, bpmn_id, event_definition, **kwargs)
self.timer_event = None
class CustomStartEventConverter(SpiffBpmnTaskConverter):
def __init__(self, registry):
@ -36,14 +36,10 @@ class CustomStartEventConverter(SpiffBpmnTaskConverter):
dct['event_definition'] = self.registry.convert(spec.event_definition)
return dct
SPIFF_SPEC_CONFIG['task_specs'].remove(StartEventConverter)
SPIFF_SPEC_CONFIG['task_specs'].append(CustomStartEventConverter)
wf_spec_converter = BpmnWorkflowSerializer.configure_workflow_spec_converter(SPIFF_SPEC_CONFIG)
serializer = BpmnWorkflowSerializer(wf_spec_converter)
DEFAULT_CONFIG['task_specs'].remove(StartEventConverter)
DEFAULT_CONFIG['task_specs'].append(CustomStartEventConverter)
registry = BpmnWorkflowSerializer.configure(DEFAULT_CONFIG)
serializer = BpmnWorkflowSerializer(registry)
parser = SpiffBpmnParser()
parser.OVERRIDE_PARSER_CLASSES[full_tag('startEvent')] = (StartEventParser, CustomStartEvent)

View File

@ -0,0 +1,39 @@
import logging
import datetime
from SpiffWorkflow.spiff.parser import SpiffBpmnParser
from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask
from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG
from SpiffWorkflow.bpmn.specs.bpmn_process_spec import BpmnProcessSpec
from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask
from SpiffWorkflow.bpmn.script_engine import PythonScriptEngine, TaskDataEnvironment
from ..serializer.file import FileSerializer
from ..engine import BpmnEngine
from .curses_handlers import UserTaskHandler, ManualTaskHandler
logger = logging.getLogger('spiff_engine')
logger.setLevel(logging.INFO)
spiff_logger = logging.getLogger('spiff')
spiff_logger.setLevel(logging.INFO)
dirname = 'wfdata'
FileSerializer.initialize(dirname)
registry = FileSerializer.configure(SPIFF_CONFIG)
serializer = FileSerializer(dirname, registry=registry)
parser = SpiffBpmnParser()
handlers = {
UserTask: UserTaskHandler,
ManualTask: ManualTaskHandler,
NoneTask: ManualTaskHandler,
}
script_env = TaskDataEnvironment({'datetime': datetime })
script_engine = PythonScriptEngine(script_env)
engine = BpmnEngine(parser, serializer, handlers, script_engine)

View File

@ -1,10 +1,6 @@
import json
from collections import namedtuple
from SpiffWorkflow.bpmn.serializer.helpers.dictionary import DictionaryConverter
ProductInfo = namedtuple('ProductInfo', ['color', 'size', 'style', 'price'])
INVENTORY = {
'product_a': ProductInfo(False, False, False, 15.00),
'product_b': ProductInfo(False, False, False, 15.00),
@ -32,13 +28,4 @@ def product_info_to_dict(obj):
def product_info_from_dict(dct):
return ProductInfo(**dct)
registry = DictionaryConverter()
registry.register(ProductInfo, product_info_to_dict, product_info_from_dict)
def dumps(obj):
dct = registry.convert(obj)
return json.dumps(dct)
def loads(s):
dct = json.loads(s)
return registry.restore(dct)

View File

@ -0,0 +1,40 @@
import logging
import datetime
from RestrictedPython import safe_globals
from SpiffWorkflow.spiff.parser import SpiffBpmnParser
from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask
from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG
from SpiffWorkflow.bpmn.specs.bpmn_process_spec import BpmnProcessSpec
from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask
from SpiffWorkflow.bpmn.script_engine import PythonScriptEngine, TaskDataEnvironment
from ..serializer.file import FileSerializer
from ..engine import BpmnEngine
from .curses_handlers import UserTaskHandler, ManualTaskHandler
logger = logging.getLogger('spiff_engine')
logger.setLevel(logging.INFO)
spiff_logger = logging.getLogger('spiff')
spiff_logger.setLevel(logging.INFO)
dirname = 'wfdata'
FileSerializer.initialize(dirname)
registry = FileSerializer.configure(SPIFF_CONFIG)
serializer = FileSerializer(dirname, registry=registry)
parser = SpiffBpmnParser()
handlers = {
UserTask: UserTaskHandler,
ManualTask: ManualTaskHandler,
NoneTask: ManualTaskHandler,
}
script_env = TaskDataEnvironment(safe_globals)
script_engine = PythonScriptEngine(script_env)
engine = BpmnEngine(parser, serializer, handlers, script_engine)

View File

@ -0,0 +1,66 @@
import json
import logging
import datetime
from SpiffWorkflow.spiff.parser import SpiffBpmnParser
from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask
from SpiffWorkflow.spiff.serializer.config import SPIFF_CONFIG
from SpiffWorkflow.bpmn.specs.bpmn_process_spec import BpmnProcessSpec
from SpiffWorkflow.bpmn.specs.mixins.none_task import NoneTask
from SpiffWorkflow.bpmn.script_engine import PythonScriptEngine, TaskDataEnvironment
from ..serializer.file import FileSerializer
from ..engine import BpmnEngine
from .curses_handlers import UserTaskHandler, ManualTaskHandler
from .product_info import (
ProductInfo,
product_info_to_dict,
product_info_from_dict,
lookup_product_info,
lookup_shipping_cost,
)
logger = logging.getLogger('spiff_engine')
logger.setLevel(logging.INFO)
spiff_logger = logging.getLogger('spiff_engine')
spiff_logger.setLevel(logging.INFO)
dirname = 'wfdata'
FileSerializer.initialize(dirname)
registry = FileSerializer.configure(SPIFF_CONFIG)
registry.register(ProductInfo, product_info_to_dict, product_info_from_dict)
serializer = FileSerializer(dirname, registry=registry)
parser = SpiffBpmnParser()
handlers = {
UserTask: UserTaskHandler,
ManualTask: ManualTaskHandler,
NoneTask: ManualTaskHandler,
}
service_task_env = TaskDataEnvironment({
'product_info_from_dict': product_info_from_dict,
'datetime': datetime,
})
class ServiceTaskEngine(PythonScriptEngine):
def __init__(self):
super().__init__(environment=service_task_env)
def call_service(self, operation_name, operation_params, task_data):
if operation_name == 'lookup_product_info':
product_info = lookup_product_info(operation_params['product_name']['value'])
result = product_info_to_dict(product_info)
elif operation_name == 'lookup_shipping_cost':
result = lookup_shipping_cost(operation_params['shipping_method']['value'])
else:
raise Exception("Unknown Service!")
return json.dumps(result)
script_engine = ServiceTaskEngine()
engine = BpmnEngine(parser, serializer, handlers, script_engine)

View File

@ -0,0 +1,52 @@
import sqlite3
import logging
import datetime
from SpiffWorkflow.spiff.parser import SpiffBpmnParser
from SpiffWorkflow.spiff.specs.defaults import UserTask, ManualTask
from SpiffWorkflow.spiff.serializer import DEFAULT_CONFIG
from SpiffWorkflow.bpmn import BpmnWorkflow
from SpiffWorkflow.bpmn.util.subworkflow import BpmnSubWorkflow
from SpiffWorkflow.bpmn.specs import BpmnProcessSpec
from SpiffWorkflow.bpmn.specs.mixins import NoneTaskMixin as NoneTask
from SpiffWorkflow.bpmn.script_engine import TaskDataEnvironment, PythonScriptEngine
from ..serializer.sqlite import (
SqliteSerializer,
WorkflowConverter,
SubworkflowConverter,
WorkflowSpecConverter
)
from ..engine import BpmnEngine
from .curses_handlers import UserTaskHandler, ManualTaskHandler
logger = logging.getLogger('spiff_engine')
logger.setLevel(logging.INFO)
spiff_logger = logging.getLogger('spiff')
spiff_logger.setLevel(logging.INFO)
DEFAULT_CONFIG[BpmnWorkflow] = WorkflowConverter
DEFAULT_CONFIG[BpmnSubWorkflow] = SubworkflowConverter
DEFAULT_CONFIG[BpmnProcessSpec] = WorkflowSpecConverter
dbname = 'spiff.db'
with sqlite3.connect(dbname) as db:
SqliteSerializer.initialize(db)
registry = SqliteSerializer.configure(DEFAULT_CONFIG)
serializer = SqliteSerializer(dbname, registry=registry)
parser = SpiffBpmnParser()
handlers = {
UserTask: UserTaskHandler,
ManualTask: ManualTaskHandler,
NoneTask: ManualTaskHandler,
}
script_env = TaskDataEnvironment({'datetime': datetime })
script_engine = PythonScriptEngine(script_env)
engine = BpmnEngine(parser, serializer, handlers, script_engine)