Merge branch 'dev' into 310-task-event-timezone
This commit is contained in:
commit
d6054a9846
34
crc/api.yml
34
crc/api.yml
|
@ -425,7 +425,7 @@ paths:
|
|||
- name: spec_id
|
||||
in: path
|
||||
required: true
|
||||
description: The unique id of an existing workflow specification to modify.
|
||||
description: The unique id of an existing workflow specification.
|
||||
schema:
|
||||
type: string
|
||||
get:
|
||||
|
@ -440,6 +440,18 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WorkflowSpec"
|
||||
post:
|
||||
operationId: crc.api.workflow.get_workflow_from_spec
|
||||
summary: Creates a workflow from a workflow spec and returns the workflow
|
||||
tags:
|
||||
- Workflow Specifications
|
||||
responses:
|
||||
'200':
|
||||
description: Workflow generated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Workflow"
|
||||
put:
|
||||
operationId: crc.api.workflow.update_workflow_specification
|
||||
security:
|
||||
|
@ -469,6 +481,21 @@ paths:
|
|||
responses:
|
||||
'204':
|
||||
description: The workflow specification has been removed.
|
||||
/workflow-specification/standalone:
|
||||
get:
|
||||
operationId: crc.api.workflow.standalone_workflow_specs
|
||||
summary: Provides a list of workflow specifications that can be run outside a study.
|
||||
tags:
|
||||
- Workflow Specifications
|
||||
responses:
|
||||
'200':
|
||||
description: A list of workflow specifications
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/WorkflowSpec"
|
||||
/workflow-specification/{spec_id}/validate:
|
||||
parameters:
|
||||
- name: spec_id
|
||||
|
@ -1536,6 +1563,9 @@ components:
|
|||
category_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
standalone:
|
||||
type: boolean
|
||||
example: false
|
||||
workflow_spec_category:
|
||||
$ref: "#/components/schemas/WorkflowSpecCategory"
|
||||
is_status:
|
||||
|
@ -1608,6 +1638,8 @@ components:
|
|||
type: integer
|
||||
num_tasks_incomplete:
|
||||
type: integer
|
||||
study_id:
|
||||
type: integer
|
||||
|
||||
example:
|
||||
id: 291234
|
||||
|
|
|
@ -101,6 +101,24 @@ def delete_workflow_specification(spec_id):
|
|||
session.commit()
|
||||
|
||||
|
||||
def get_workflow_from_spec(spec_id):
|
||||
workflow_model = WorkflowService.get_workflow_from_spec(spec_id, g.user)
|
||||
processor = WorkflowProcessor(workflow_model)
|
||||
|
||||
processor.do_engine_steps()
|
||||
processor.save()
|
||||
WorkflowService.update_task_assignments(processor)
|
||||
|
||||
workflow_api_model = WorkflowService.processor_to_workflow_api(processor)
|
||||
return WorkflowApiSchema().dump(workflow_api_model)
|
||||
|
||||
|
||||
def standalone_workflow_specs():
|
||||
schema = WorkflowSpecModelSchema(many=True)
|
||||
specs = WorkflowService.get_standalone_workflow_specs()
|
||||
return schema.dump(specs)
|
||||
|
||||
|
||||
def get_workflow(workflow_id, do_engine_steps=True):
|
||||
"""Retrieve workflow based on workflow_id, and return it in the last saved State.
|
||||
If do_engine_steps is False, return the workflow without running any engine tasks or logging any events. """
|
||||
|
@ -185,9 +203,6 @@ def update_task(workflow_id, task_id, body, terminate_loop=None, update_all=Fals
|
|||
if workflow_model is None:
|
||||
raise ApiError("invalid_workflow_id", "The given workflow id is not valid.", status_code=404)
|
||||
|
||||
elif workflow_model.study is None:
|
||||
raise ApiError("invalid_study", "There is no study associated with the given workflow.", status_code=404)
|
||||
|
||||
processor = WorkflowProcessor(workflow_model)
|
||||
task_id = uuid.UUID(task_id)
|
||||
spiff_task = processor.bpmn_workflow.get_task(task_id)
|
||||
|
|
|
@ -191,7 +191,7 @@ class DocumentDirectory(object):
|
|||
class WorkflowApi(object):
|
||||
def __init__(self, id, status, next_task, navigation,
|
||||
spec_version, is_latest_spec, workflow_spec_id, total_tasks, completed_tasks,
|
||||
last_updated, is_review, title):
|
||||
last_updated, is_review, title, study_id):
|
||||
self.id = id
|
||||
self.status = status
|
||||
self.next_task = next_task # The next task that requires user input.
|
||||
|
@ -204,13 +204,14 @@ class WorkflowApi(object):
|
|||
self.last_updated = last_updated
|
||||
self.title = title
|
||||
self.is_review = is_review
|
||||
self.study_id = study_id or ''
|
||||
|
||||
class WorkflowApiSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = WorkflowApi
|
||||
fields = ["id", "status", "next_task", "navigation",
|
||||
"workflow_spec_id", "spec_version", "is_latest_spec", "total_tasks", "completed_tasks",
|
||||
"last_updated", "is_review", "title"]
|
||||
"last_updated", "is_review", "title", "study_id"]
|
||||
unknown = INCLUDE
|
||||
|
||||
status = EnumField(WorkflowStatus)
|
||||
|
@ -221,7 +222,7 @@ class WorkflowApiSchema(ma.Schema):
|
|||
def make_workflow(self, data, **kwargs):
|
||||
keys = ['id', 'status', 'next_task', 'navigation',
|
||||
'workflow_spec_id', 'spec_version', 'is_latest_spec', "total_tasks", "completed_tasks",
|
||||
"last_updated", "is_review", "title"]
|
||||
"last_updated", "is_review", "title", "study_id"]
|
||||
filtered_fields = {key: data[key] for key in keys}
|
||||
filtered_fields['next_task'] = TaskSchema().make_task(data['next_task'])
|
||||
return WorkflowApi(**filtered_fields)
|
||||
|
|
|
@ -11,7 +11,7 @@ from sqlalchemy import func
|
|||
class TaskEventModel(db.Model):
|
||||
__tablename__ = 'task_event'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
study_id = db.Column(db.Integer, db.ForeignKey('study.id'), nullable=False)
|
||||
study_id = db.Column(db.Integer, db.ForeignKey('study.id'))
|
||||
user_uid = db.Column(db.String, nullable=False) # In some cases the unique user id may not exist in the db yet.
|
||||
workflow_id = db.Column(db.Integer, db.ForeignKey('workflow.id'), nullable=False)
|
||||
workflow_spec_id = db.Column(db.String, db.ForeignKey('workflow_spec.id'))
|
||||
|
|
|
@ -34,6 +34,7 @@ class WorkflowSpecModel(db.Model):
|
|||
category_id = db.Column(db.Integer, db.ForeignKey('workflow_spec_category.id'), nullable=True)
|
||||
category = db.relationship("WorkflowSpecCategoryModel")
|
||||
is_master_spec = db.Column(db.Boolean, default=False)
|
||||
standalone = db.Column(db.Boolean, default=False)
|
||||
|
||||
|
||||
class WorkflowSpecModelSchema(SQLAlchemyAutoSchema):
|
||||
|
@ -89,6 +90,7 @@ class WorkflowModel(db.Model):
|
|||
total_tasks = db.Column(db.Integer, default=0)
|
||||
completed_tasks = db.Column(db.Integer, default=0)
|
||||
last_updated = db.Column(db.DateTime(timezone=True),default=func.now())
|
||||
user_id = db.Column(db.String, default=None)
|
||||
# Order By is important or generating hashes on reviews.
|
||||
dependencies = db.relationship(WorkflowSpecDependencyFile, cascade="all, delete, delete-orphan",
|
||||
order_by="WorkflowSpecDependencyFile.file_data_id")
|
||||
|
|
|
@ -10,7 +10,9 @@ class FileDataGet(Script, DataStoreBase):
|
|||
return """Gets user data from the data store - takes only two keyword arguments arguments: 'file_id' and 'key' """
|
||||
|
||||
def do_task_validate_only(self, task, study_id, workflow_id, *args, **kwargs):
|
||||
self.do_task(task, study_id, workflow_id, *args, **kwargs)
|
||||
if self.validate_kw_args(**kwargs):
|
||||
myargs = [kwargs['key']]
|
||||
return True
|
||||
|
||||
def validate_kw_args(self,**kwargs):
|
||||
if kwargs.get('key',None) is None:
|
||||
|
|
|
@ -10,7 +10,11 @@ class FileDataSet(Script, DataStoreBase):
|
|||
return """Sets data the data store - takes three keyword arguments arguments: 'file_id' and 'key' and 'value'"""
|
||||
|
||||
def do_task_validate_only(self, task, study_id, workflow_id, *args, **kwargs):
|
||||
self.do_task(task, study_id, workflow_id, *args, **kwargs)
|
||||
if self.validate_kw_args(**kwargs):
|
||||
myargs = [kwargs['key'],kwargs['value']]
|
||||
fileid = kwargs['file_id']
|
||||
del(kwargs['file_id'])
|
||||
return True
|
||||
|
||||
def validate_kw_args(self,**kwargs):
|
||||
if kwargs.get('key',None) is None:
|
||||
|
|
|
@ -290,6 +290,7 @@ class StudyService(object):
|
|||
doc['files'] = []
|
||||
for file in doc_files:
|
||||
doc['files'].append({'file_id': file.id,
|
||||
'name': file.name,
|
||||
'workflow_id': file.workflow_id})
|
||||
|
||||
# update the document status to match the status of the workflow it is in.
|
||||
|
@ -495,6 +496,7 @@ class StudyService(object):
|
|||
def _create_workflow_model(study: StudyModel, spec):
|
||||
workflow_model = WorkflowModel(status=WorkflowStatus.not_started,
|
||||
study=study,
|
||||
user_id=None,
|
||||
workflow_spec_id=spec.id,
|
||||
last_updated=datetime.utcnow())
|
||||
session.add(workflow_model)
|
||||
|
|
|
@ -408,7 +408,8 @@ class WorkflowService(object):
|
|||
completed_tasks=processor.workflow_model.completed_tasks,
|
||||
last_updated=processor.workflow_model.last_updated,
|
||||
is_review=is_review,
|
||||
title=spec.display_name
|
||||
title=spec.display_name,
|
||||
study_id=processor.workflow_model.study_id or None
|
||||
)
|
||||
if not next_task: # The Next Task can be requested to be a certain task, useful for parallel tasks.
|
||||
# This may or may not work, sometimes there is no next task to complete.
|
||||
|
@ -667,30 +668,39 @@ class WorkflowService(object):
|
|||
|
||||
@staticmethod
|
||||
def get_users_assigned_to_task(processor, spiff_task) -> List[str]:
|
||||
if not hasattr(spiff_task.task_spec, 'lane') or spiff_task.task_spec.lane is None:
|
||||
associated = StudyService.get_study_associates(processor.workflow_model.study.id)
|
||||
return [user['uid'] for user in associated if user['access']]
|
||||
if spiff_task.task_spec.lane not in spiff_task.data:
|
||||
return [] # No users are assignable to the task at this moment
|
||||
lane_users = spiff_task.data[spiff_task.task_spec.lane]
|
||||
if not isinstance(lane_users, list):
|
||||
lane_users = [lane_users]
|
||||
if processor.workflow_model.study_id is None and processor.workflow_model.user_id is None:
|
||||
raise ApiError.from_task(code='invalid_workflow',
|
||||
message='A workflow must have either a study_id or a user_id.',
|
||||
task=spiff_task)
|
||||
# Standalone workflow - we only care about the current user
|
||||
elif processor.workflow_model.study_id is None and processor.workflow_model.user_id is not None:
|
||||
return [processor.workflow_model.user_id]
|
||||
# Workflow associated with a study - get all the users
|
||||
else:
|
||||
if not hasattr(spiff_task.task_spec, 'lane') or spiff_task.task_spec.lane is None:
|
||||
associated = StudyService.get_study_associates(processor.workflow_model.study.id)
|
||||
return [user['uid'] for user in associated if user['access']]
|
||||
if spiff_task.task_spec.lane not in spiff_task.data:
|
||||
return [] # No users are assignable to the task at this moment
|
||||
lane_users = spiff_task.data[spiff_task.task_spec.lane]
|
||||
if not isinstance(lane_users, list):
|
||||
lane_users = [lane_users]
|
||||
|
||||
lane_uids = []
|
||||
for user in lane_users:
|
||||
if isinstance(user, dict):
|
||||
if 'value' in user and user['value'] is not None:
|
||||
lane_uids.append(user['value'])
|
||||
lane_uids = []
|
||||
for user in lane_users:
|
||||
if isinstance(user, dict):
|
||||
if 'value' in user and user['value'] is not None:
|
||||
lane_uids.append(user['value'])
|
||||
else:
|
||||
raise ApiError.from_task(code="task_lane_user_error", message="Spiff Task %s lane user dict must have a key called 'value' with the user's uid in it." %
|
||||
spiff_task.task_spec.name, task=spiff_task)
|
||||
elif isinstance(user, str):
|
||||
lane_uids.append(user)
|
||||
else:
|
||||
raise ApiError.from_task(code="task_lane_user_error", message="Spiff Task %s lane user dict must have a key called 'value' with the user's uid in it." %
|
||||
spiff_task.task_spec.name, task=spiff_task)
|
||||
elif isinstance(user, str):
|
||||
lane_uids.append(user)
|
||||
else:
|
||||
raise ApiError.from_task(code="task_lane_user_error", message="Spiff Task %s lane user is not a string or dict" %
|
||||
spiff_task.task_spec.name, task=spiff_task)
|
||||
raise ApiError.from_task(code="task_lane_user_error", message="Spiff Task %s lane user is not a string or dict" %
|
||||
spiff_task.task_spec.name, task=spiff_task)
|
||||
|
||||
return lane_uids
|
||||
return lane_uids
|
||||
|
||||
@staticmethod
|
||||
def log_task_action(user_uid, processor, spiff_task, action):
|
||||
|
@ -783,3 +793,19 @@ class WorkflowService(object):
|
|||
for workflow in workflows:
|
||||
if workflow.status == WorkflowStatus.user_input_required or workflow.status == WorkflowStatus.waiting:
|
||||
WorkflowProcessor.reset(workflow, clear_data=False)
|
||||
|
||||
@staticmethod
|
||||
def get_workflow_from_spec(workflow_spec_id, user):
|
||||
workflow_model = WorkflowModel(status=WorkflowStatus.not_started,
|
||||
study=None,
|
||||
user_id=user.uid,
|
||||
workflow_spec_id=workflow_spec_id,
|
||||
last_updated=datetime.now())
|
||||
db.session.add(workflow_model)
|
||||
db.session.commit()
|
||||
return workflow_model
|
||||
|
||||
@staticmethod
|
||||
def get_standalone_workflow_specs():
|
||||
specs = db.session.query(WorkflowSpecModel).filter_by(standalone=True).all()
|
||||
return specs
|
||||
|
|
|
@ -266,7 +266,7 @@ class ExampleDataLoader:
|
|||
from_tests=True)
|
||||
|
||||
def create_spec(self, id, name, display_name="", description="", filepath=None, master_spec=False,
|
||||
category_id=None, display_order=None, from_tests=False):
|
||||
category_id=None, display_order=None, from_tests=False, standalone=False):
|
||||
"""Assumes that a directory exists in static/bpmn with the same name as the given id.
|
||||
further assumes that the [id].bpmn is the primary file for the workflow.
|
||||
returns an array of data models to be added to the database."""
|
||||
|
@ -278,7 +278,8 @@ class ExampleDataLoader:
|
|||
description=description,
|
||||
is_master_spec=master_spec,
|
||||
category_id=category_id,
|
||||
display_order=display_order)
|
||||
display_order=display_order,
|
||||
standalone=standalone)
|
||||
db.session.add(spec)
|
||||
db.session.commit()
|
||||
if not filepath and not from_tests:
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 8b976945a54e
|
||||
Revises: c872232ebdcb
|
||||
Create Date: 2021-04-18 11:42:41.894378
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '8b976945a54e'
|
||||
down_revision = 'c872232ebdcb'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('workflow', sa.Column('user_id', sa.String(), nullable=True))
|
||||
op.add_column('workflow_spec', sa.Column('standalone', sa.Boolean(), default=False))
|
||||
op.execute("UPDATE workflow_spec SET standalone=False WHERE standalone is null;")
|
||||
op.execute("ALTER TABLE task_event ALTER COLUMN study_id DROP NOT NULL")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.execute("UPDATE workflow SET user_id=NULL WHERE user_id is not NULL")
|
||||
op.drop_column('workflow', 'user_id')
|
||||
op.drop_column('workflow_spec', 'standalone')
|
||||
op.execute("ALTER TABLE task_event ALTER COLUMN study_id SET NOT NULL ")
|
|
@ -175,11 +175,6 @@ class BaseTest(unittest.TestCase):
|
|||
specs = session.query(WorkflowSpecModel).all()
|
||||
self.assertIsNotNone(specs)
|
||||
|
||||
for spec in specs:
|
||||
files = session.query(FileModel).filter_by(workflow_spec_id=spec.id).all()
|
||||
self.assertIsNotNone(files)
|
||||
self.assertGreater(len(files), 0)
|
||||
|
||||
for spec in specs:
|
||||
files = session.query(FileModel).filter_by(workflow_spec_id=spec.id).all()
|
||||
self.assertIsNotNone(files)
|
||||
|
@ -379,6 +374,10 @@ class BaseTest(unittest.TestCase):
|
|||
|
||||
def complete_form(self, workflow_in, task_in, dict_data, update_all=False, error_code=None, terminate_loop=None,
|
||||
user_uid="dhf8r"):
|
||||
# workflow_in should be a workflow, not a workflow_api
|
||||
# we were passing in workflow_api in many of our tests, and
|
||||
# this caused problems testing standalone workflows
|
||||
standalone = getattr(workflow_in.workflow_spec, 'standalone', False)
|
||||
prev_completed_task_count = workflow_in.completed_tasks
|
||||
if isinstance(task_in, dict):
|
||||
task_id = task_in["id"]
|
||||
|
@ -421,7 +420,8 @@ class BaseTest(unittest.TestCase):
|
|||
.order_by(TaskEventModel.date.desc()).all()
|
||||
self.assertGreater(len(task_events), 0)
|
||||
event = task_events[0]
|
||||
self.assertIsNotNone(event.study_id)
|
||||
if not standalone:
|
||||
self.assertIsNotNone(event.study_id)
|
||||
self.assertEqual(user_uid, event.user_uid)
|
||||
self.assertEqual(workflow.id, event.workflow_id)
|
||||
self.assertEqual(workflow.workflow_spec_id, event.workflow_spec_id)
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
<?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:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:camunda="http://camunda.org/schema/1.0/bpmn" id="Definitions_0ixyfs0" targetNamespace="http://bpmn.io/schema/bpmn" exporter="Camunda Modeler" exporterVersion="3.5.0">
|
||||
<bpmn:process id="Process_HelloWorld" name="Hello World Process" isExecutable="true">
|
||||
<bpmn:documentation>This workflow asks for a name and says hello</bpmn:documentation>
|
||||
<bpmn:startEvent id="StartEvent_1">
|
||||
<bpmn:outgoing>SequenceFlow_0qyd2b7</bpmn:outgoing>
|
||||
</bpmn:startEvent>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_0qyd2b7" sourceRef="StartEvent_1" targetRef="Task_GetName" />
|
||||
<bpmn:userTask id="Task_GetName" name="Get Name" camunda:formKey="Name">
|
||||
<bpmn:documentation>Hello</bpmn:documentation>
|
||||
<bpmn:extensionElements>
|
||||
<camunda:formData>
|
||||
<camunda:formField id="name" label="Name" type="string" defaultValue="World" />
|
||||
</camunda:formData>
|
||||
</bpmn:extensionElements>
|
||||
<bpmn:incoming>SequenceFlow_0qyd2b7</bpmn:incoming>
|
||||
<bpmn:outgoing>SequenceFlow_1h46b40</bpmn:outgoing>
|
||||
</bpmn:userTask>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_1h46b40" sourceRef="Task_GetName" targetRef="Task_SayHello" />
|
||||
<bpmn:manualTask id="Task_SayHello" name="Say Hello">
|
||||
<bpmn:documentation>Hello {{name}}</bpmn:documentation>
|
||||
<bpmn:incoming>SequenceFlow_1h46b40</bpmn:incoming>
|
||||
<bpmn:outgoing>SequenceFlow_0lqrc6e</bpmn:outgoing>
|
||||
</bpmn:manualTask>
|
||||
<bpmn:endEvent id="EndEvent_1l03lqw">
|
||||
<bpmn:incoming>SequenceFlow_0lqrc6e</bpmn:incoming>
|
||||
</bpmn:endEvent>
|
||||
<bpmn:sequenceFlow id="SequenceFlow_0lqrc6e" sourceRef="Task_SayHello" targetRef="EndEvent_1l03lqw" />
|
||||
</bpmn:process>
|
||||
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
|
||||
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="Process_HelloWorld">
|
||||
<bpmndi:BPMNShape id="_BPMNShape_StartEvent_2" bpmnElement="StartEvent_1">
|
||||
<dc:Bounds x="179" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_0qyd2b7_di" bpmnElement="SequenceFlow_0qyd2b7">
|
||||
<di:waypoint x="215" y="117" />
|
||||
<di:waypoint x="270" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNShape id="UserTask_0fbucz7_di" bpmnElement="Task_GetName">
|
||||
<dc:Bounds x="270" y="77" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_1h46b40_di" bpmnElement="SequenceFlow_1h46b40">
|
||||
<di:waypoint x="370" y="117" />
|
||||
<di:waypoint x="430" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
<bpmndi:BPMNShape id="ManualTask_1tia2zr_di" bpmnElement="Task_SayHello">
|
||||
<dc:Bounds x="430" y="77" width="100" height="80" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNShape id="EndEvent_1l03lqw_di" bpmnElement="EndEvent_1l03lqw">
|
||||
<dc:Bounds x="592" y="99" width="36" height="36" />
|
||||
</bpmndi:BPMNShape>
|
||||
<bpmndi:BPMNEdge id="SequenceFlow_0lqrc6e_di" bpmnElement="SequenceFlow_0lqrc6e">
|
||||
<di:waypoint x="530" y="117" />
|
||||
<di:waypoint x="592" y="117" />
|
||||
</bpmndi:BPMNEdge>
|
||||
</bpmndi:BPMNPlane>
|
||||
</bpmndi:BPMNDiagram>
|
||||
</bpmn:definitions>
|
|
@ -72,7 +72,7 @@ class TestStudyCancellations(BaseTest):
|
|||
workflow, study_id = self.load_workflow()
|
||||
workflow_api, first_task = self.get_first_task(workflow)
|
||||
|
||||
self.complete_form(workflow_api, first_task, {})
|
||||
self.complete_form(workflow, first_task, {})
|
||||
|
||||
study_result = self.put_study_on_hold(study_id)
|
||||
self.assertEqual('New Title', study_result.title)
|
||||
|
@ -82,10 +82,10 @@ class TestStudyCancellations(BaseTest):
|
|||
workflow, study_id = self.load_workflow()
|
||||
workflow_api, first_task = self.get_first_task(workflow)
|
||||
|
||||
self.complete_form(workflow_api, first_task, {})
|
||||
self.complete_form(workflow, first_task, {})
|
||||
|
||||
workflow_api, next_task = self.get_second_task(workflow)
|
||||
self.complete_form(workflow_api, next_task, {'how_many': 3})
|
||||
self.complete_form(workflow, next_task, {'how_many': 3})
|
||||
|
||||
study_result = self.put_study_on_hold(study_id)
|
||||
self.assertEqual('Second Title', study_result.title)
|
||||
|
@ -95,13 +95,13 @@ class TestStudyCancellations(BaseTest):
|
|||
workflow, study_id = self.load_workflow()
|
||||
workflow_api, first_task = self.get_first_task(workflow)
|
||||
|
||||
self.complete_form(workflow_api, first_task, {})
|
||||
self.complete_form(workflow, first_task, {})
|
||||
|
||||
workflow_api, second_task = self.get_second_task(workflow)
|
||||
self.complete_form(workflow_api, second_task, {'how_many': 3})
|
||||
self.complete_form(workflow, second_task, {'how_many': 3})
|
||||
|
||||
workflow_api, third_task = self.get_third_task(workflow)
|
||||
self.complete_form(workflow_api, third_task, {})
|
||||
self.complete_form(workflow, third_task, {})
|
||||
|
||||
study_result = self.put_study_on_hold(study_id)
|
||||
self.assertEqual('Beer consumption in the bipedal software engineer', study_result.title)
|
||||
|
|
|
@ -13,7 +13,8 @@ class TestAutoSetPrimaryBPMN(BaseTest):
|
|||
category_id = session.query(WorkflowSpecCategoryModel).first().id
|
||||
# Add a workflow spec
|
||||
spec = WorkflowSpecModel(id='make_cookies', name='make_cookies', display_name='Cooooookies',
|
||||
description='Om nom nom delicious cookies', category_id=category_id)
|
||||
description='Om nom nom delicious cookies', category_id=category_id,
|
||||
standalone=False)
|
||||
rv = self.app.post('/v1.0/workflow-specification',
|
||||
headers=self.logged_in_headers(),
|
||||
content_type="application/json",
|
||||
|
|
|
@ -23,7 +23,6 @@ class TestEmailScript(BaseTest):
|
|||
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
self.complete_form(workflow, first_task, {'subject': 'My Email Subject', 'recipients': 'test@example.com'})
|
||||
|
||||
self.assertEqual(1, len(outbox))
|
||||
|
@ -49,7 +48,6 @@ class TestEmailScript(BaseTest):
|
|||
def test_bad_email_address_1(self):
|
||||
workflow = self.create_workflow('email_script')
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
self.complete_form(workflow, first_task, {'recipients': 'test@example'})
|
||||
|
@ -57,7 +55,6 @@ class TestEmailScript(BaseTest):
|
|||
def test_bad_email_address_2(self):
|
||||
workflow = self.create_workflow('email_script')
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
self.complete_form(workflow, first_task, {'recipients': 'test'})
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
from tests.base_test import BaseTest
|
||||
|
||||
from crc import session
|
||||
from crc.models.user import UserModel
|
||||
from crc.services.workflow_service import WorkflowService
|
||||
|
||||
from example_data import ExampleDataLoader
|
||||
|
||||
|
||||
class TestNoStudyWorkflow(BaseTest):
|
||||
|
||||
def test_no_study_workflow(self):
|
||||
self.load_example_data()
|
||||
spec = ExampleDataLoader().create_spec('hello_world', 'Hello World', standalone=True, from_tests=True)
|
||||
user = session.query(UserModel).first()
|
||||
self.assertIsNotNone(user)
|
||||
workflow_model = WorkflowService.get_workflow_from_spec(spec.id, user)
|
||||
workflow_api = self.get_workflow_api(workflow_model)
|
||||
first_task = workflow_api.next_task
|
||||
self.complete_form(workflow_model, first_task, {'name': 'Big Guy'})
|
||||
workflow_api = self.get_workflow_api(workflow_model)
|
||||
second_task = workflow_api.next_task
|
||||
self.assertEqual(second_task.documentation, 'Hello Big Guy')
|
|
@ -13,10 +13,10 @@ class TestMessageEvent(BaseTest):
|
|||
# Start the workflow.
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
|
||||
self.complete_form(workflow, first_task, {'formdata': 'asdf'})
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Activity_HowMany', workflow.next_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Activity_HowMany', workflow_api.next_task.name)
|
||||
|
||||
# reset the workflow
|
||||
# this ultimately calls crc.api.workflow.set_current_task
|
||||
|
|
|
@ -67,14 +67,14 @@ class TestMultiinstanceTasksApi(BaseTest):
|
|||
content_type="application/json")
|
||||
self.assert_success(rv)
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
workflow = WorkflowApiSchema().load(json_data)
|
||||
data = workflow.next_task.data
|
||||
workflow_api = WorkflowApiSchema().load(json_data)
|
||||
data = workflow_api.next_task.data
|
||||
data['investigator']['email'] = "dhf8r@virginia.edu"
|
||||
self.complete_form(workflow, workflow.next_task, data)
|
||||
self.complete_form(workflow, workflow_api.next_task, data)
|
||||
#tasks = self.get_workflow_api(workflow).user_tasks
|
||||
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
self.assertEqual(WorkflowStatus.complete, workflow.status)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual(WorkflowStatus.complete, workflow_api.status)
|
||||
|
||||
|
||||
@patch('crc.services.protocol_builder.requests.get')
|
||||
|
|
|
@ -386,15 +386,15 @@ class TestTasksApi(BaseTest):
|
|||
# Start the workflow.
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
self.complete_form(workflow, first_task, {"has_bananas": True})
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Task_Num_Bananas', workflow.next_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Task_Num_Bananas', workflow_api.next_task.name)
|
||||
|
||||
# Trying to re-submit the initial task, and answer differently, should result in an error.
|
||||
self.complete_form(workflow, first_task, {"has_bananas": False}, error_code="invalid_state")
|
||||
|
||||
# Go ahead and set the number of bananas.
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
task = workflow.next_task
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
task = workflow_api.next_task
|
||||
|
||||
self.complete_form(workflow, task, {"num_bananas": 4})
|
||||
# We are now at the end of the workflow.
|
||||
|
@ -405,19 +405,19 @@ class TestTasksApi(BaseTest):
|
|||
content_type="application/json")
|
||||
self.assert_success(rv)
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
workflow = WorkflowApiSchema().load(json_data)
|
||||
workflow_api = WorkflowApiSchema().load(json_data)
|
||||
|
||||
# Assure the Next Task is the one we just reset the token to be on.
|
||||
self.assertEqual("Task_Has_Bananas", workflow.next_task.name)
|
||||
self.assertEqual("Task_Has_Bananas", workflow_api.next_task.name)
|
||||
|
||||
# Go ahead and get that workflow one more time, it should still be right.
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
|
||||
# Assure the Next Task is the one we just reset the token to be on.
|
||||
self.assertEqual("Task_Has_Bananas", workflow.next_task.name)
|
||||
self.assertEqual("Task_Has_Bananas", workflow_api.next_task.name)
|
||||
|
||||
# The next task should be a different value.
|
||||
self.complete_form(workflow, workflow.next_task, {"has_bananas": False})
|
||||
workflow = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Task_Why_No_Bananas', workflow.next_task.name)
|
||||
self.complete_form(workflow, workflow_api.next_task, {"has_bananas": False})
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Task_Why_No_Bananas', workflow_api.next_task.name)
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ class TestBooleanDefault(BaseTest):
|
|||
workflow = self.create_workflow('boolean_default_value')
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
set_default_task = workflow_api.next_task
|
||||
result = self.complete_form(workflow_api, set_default_task, {'yes_no': yes_no})
|
||||
result = self.complete_form(workflow, set_default_task, {'yes_no': yes_no})
|
||||
return result
|
||||
|
||||
def test_boolean_true_string(self):
|
||||
|
|
|
@ -7,35 +7,35 @@ class TestWorkflowEnumDefault(BaseTest):
|
|||
def test_enum_default_from_value_expression(self):
|
||||
workflow = self.create_workflow('enum_value_expression')
|
||||
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
self.assertEqual('Activity_UserInput', first_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
first_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_UserInput', first_task.name)
|
||||
|
||||
result = self.complete_form(workflow_api, first_task, {'user_input': True})
|
||||
result = self.complete_form(workflow, first_task, {'user_input': True})
|
||||
self.assertIn('user_input', result.next_task.data)
|
||||
self.assertEqual(True, result.next_task.data['user_input'])
|
||||
self.assertIn('lookup_output', result.next_task.data)
|
||||
self.assertEqual('black', result.next_task.data['lookup_output'])
|
||||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Activity_PickColor', self.get_workflow_api(workflow_api).next_task.name)
|
||||
self.assertEqual('Activity_PickColor', workflow_api.next_task.name)
|
||||
self.assertEqual({'value': 'black', 'label': 'Black'}, workflow_api.next_task.data['color_select'])
|
||||
|
||||
#
|
||||
workflow = self.create_workflow('enum_value_expression')
|
||||
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
self.assertEqual('Activity_UserInput', first_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
first_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_UserInput', first_task.name)
|
||||
|
||||
result = self.complete_form(workflow_api, first_task, {'user_input': False})
|
||||
result = self.complete_form(workflow, first_task, {'user_input': False})
|
||||
self.assertIn('user_input', result.next_task.data)
|
||||
self.assertEqual(False, result.next_task.data['user_input'])
|
||||
self.assertIn('lookup_output', result.next_task.data)
|
||||
self.assertEqual('white', result.next_task.data['lookup_output'])
|
||||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Activity_PickColor', self.get_workflow_api(workflow_api).next_task.name)
|
||||
self.assertEqual('Activity_PickColor', workflow_api.next_task.name)
|
||||
self.assertEqual({'value': 'white', 'label': 'White'}, workflow_api.next_task.data['color_select'])
|
||||
|
||||
def test_enum_value_expression_and_default(self):
|
||||
|
|
|
@ -18,7 +18,7 @@ class TestFormFieldName(BaseTest):
|
|||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
first_task = workflow_api.next_task
|
||||
self.complete_form(workflow_api, first_task, {})
|
||||
self.complete_form(workflow, first_task, {})
|
||||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
second_task = workflow_api.next_task
|
||||
|
|
|
@ -34,14 +34,13 @@ class TestWorkflowHiddenRequiredField(BaseTest):
|
|||
|
||||
first_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_Hello', first_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
|
||||
self.complete_form(workflow_api, first_task, {})
|
||||
self.complete_form(workflow, first_task, {})
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
|
||||
second_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_HiddenField', second_task.name)
|
||||
self.complete_form(workflow_api, second_task, {})
|
||||
self.complete_form(workflow, second_task, {})
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
|
||||
# The color field is hidden and required. Make sure we use the default value
|
||||
|
|
|
@ -12,20 +12,20 @@ class TestWorkflowRestart(BaseTest):
|
|||
|
||||
workflow = self.create_workflow('message_event')
|
||||
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
first_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
|
||||
result = self.complete_form(workflow_api, first_task, {'formdata': 'asdf'})
|
||||
result = self.complete_form(workflow, first_task, {'formdata': 'asdf'})
|
||||
self.assertIn('formdata', result.next_task.data)
|
||||
self.assertEqual('asdf', result.next_task.data['formdata'])
|
||||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Activity_HowMany', self.get_workflow_api(workflow_api).next_task.name)
|
||||
self.assertEqual('Activity_HowMany', workflow_api.next_task.name)
|
||||
|
||||
# restart with data. should land at beginning with data
|
||||
workflow_api = self.restart_workflow_api(result)
|
||||
first_task = self.get_workflow_api(workflow_api).next_task
|
||||
first_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
self.assertIn('formdata', workflow_api.next_task.data)
|
||||
self.assertEqual('asdf', workflow_api.next_task.data['formdata'])
|
||||
|
@ -36,7 +36,6 @@ class TestWorkflowRestart(BaseTest):
|
|||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
self.assertNotIn('formdata', workflow_api.next_task.data)
|
||||
|
||||
|
||||
def test_workflow_restart_delete_files(self):
|
||||
self.load_example_data()
|
||||
irb_code = 'Study_Protocol_Document'
|
||||
|
@ -80,14 +79,14 @@ class TestWorkflowRestart(BaseTest):
|
|||
study_id = workflow.study_id
|
||||
|
||||
# Start the workflow.
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.complete_form(workflow_api, first_task, {'formdata': 'asdf'})
|
||||
first_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
self.complete_form(workflow, first_task, {'formdata': 'asdf'})
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.assertEqual('Activity_HowMany', workflow_api.next_task.name)
|
||||
|
||||
workflow_api = self.restart_workflow_api(workflow)
|
||||
self.restart_workflow_api(workflow)
|
||||
study_result = session.query(StudyModel).filter(StudyModel.id == study_id).first()
|
||||
self.assertEqual('New Title', study_result.title)
|
||||
|
||||
|
@ -106,17 +105,16 @@ class TestWorkflowRestart(BaseTest):
|
|||
study_id = workflow.study_id
|
||||
|
||||
# Start the workflow.
|
||||
first_task = self.get_workflow_api(workflow).next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
self.complete_form(workflow_api, first_task, {'formdata': 'asdf'})
|
||||
first_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_GetData', first_task.name)
|
||||
self.complete_form(workflow, first_task, {'formdata': 'asdf'})
|
||||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
next_task = workflow_api.next_task
|
||||
self.assertEqual('Activity_HowMany', next_task.name)
|
||||
self.complete_form(workflow_api, next_task, {'how_many': 3})
|
||||
self.complete_form(workflow, next_task, {'how_many': 3})
|
||||
|
||||
workflow_api = self.restart_workflow_api(workflow)
|
||||
study_result = session.query(StudyModel).filter(StudyModel.id == study_id).first()
|
||||
self.assertEqual('Beer consumption in the bipedal software engineer', study_result.title)
|
||||
|
||||
|
|
|
@ -3,7 +3,9 @@ import json
|
|||
from tests.base_test import BaseTest
|
||||
from crc import session
|
||||
from crc.models.file import FileModel
|
||||
from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel, WorkflowSpecCategoryModel
|
||||
from crc.models.workflow import WorkflowSpecModel, WorkflowSpecModelSchema, WorkflowModel, WorkflowSpecCategoryModel, WorkflowSpecCategoryModelSchema
|
||||
|
||||
from example_data import ExampleDataLoader
|
||||
|
||||
|
||||
class TestWorkflowSpec(BaseTest):
|
||||
|
@ -28,7 +30,8 @@ class TestWorkflowSpec(BaseTest):
|
|||
category_id = session.query(WorkflowSpecCategoryModel).first().id
|
||||
category_count = session.query(WorkflowSpecModel).filter_by(category_id=category_id).count()
|
||||
spec = WorkflowSpecModel(id='make_cookies', name='make_cookies', display_name='Cooooookies',
|
||||
description='Om nom nom delicious cookies', category_id=category_id)
|
||||
description='Om nom nom delicious cookies', category_id=category_id,
|
||||
standalone=False)
|
||||
rv = self.app.post('/v1.0/workflow-specification',
|
||||
headers=self.logged_in_headers(),
|
||||
content_type="application/json",
|
||||
|
@ -101,3 +104,60 @@ class TestWorkflowSpec(BaseTest):
|
|||
num_workflows_after = session.query(WorkflowModel).filter_by(workflow_spec_id=spec_id).count()
|
||||
self.assertEqual(num_files_after + num_workflows_after, 0)
|
||||
|
||||
def test_get_standalone_workflow_specs(self):
|
||||
self.load_example_data()
|
||||
category = session.query(WorkflowSpecCategoryModel).first()
|
||||
ExampleDataLoader().create_spec('hello_world', 'Hello World', category_id=category.id,
|
||||
standalone=True, from_tests=True)
|
||||
rv = self.app.get('/v1.0/workflow-specification/standalone', headers=self.logged_in_headers())
|
||||
self.assertEqual(1, len(rv.json))
|
||||
|
||||
ExampleDataLoader().create_spec('email_script', 'Email Script', category_id=category.id,
|
||||
standalone=True, from_tests=True)
|
||||
|
||||
rv = self.app.get('/v1.0/workflow-specification/standalone', headers=self.logged_in_headers())
|
||||
self.assertEqual(2, len(rv.json))
|
||||
|
||||
def test_get_workflow_from_workflow_spec(self):
|
||||
self.load_example_data()
|
||||
spec = ExampleDataLoader().create_spec('hello_world', 'Hello World', standalone=True, from_tests=True)
|
||||
rv = self.app.post(f'/v1.0/workflow-specification/{spec.id}', headers=self.logged_in_headers())
|
||||
self.assert_success(rv)
|
||||
self.assertEqual('hello_world', rv.json['workflow_spec_id'])
|
||||
self.assertEqual('Task_GetName', rv.json['next_task']['name'])
|
||||
|
||||
def test_add_workflow_spec_category(self):
|
||||
self.load_example_data()
|
||||
count = session.query(WorkflowSpecCategoryModel).count()
|
||||
category = WorkflowSpecCategoryModel(
|
||||
id=count,
|
||||
name='another_test_category',
|
||||
display_name='Another Test Category',
|
||||
display_order=0
|
||||
)
|
||||
rv = self.app.post(f'/v1.0/workflow-specification-category',
|
||||
headers=self.logged_in_headers(),
|
||||
content_type="application/json",
|
||||
data=json.dumps(WorkflowSpecCategoryModelSchema().dump(category))
|
||||
)
|
||||
self.assert_success(rv)
|
||||
result = session.query(WorkflowSpecCategoryModel).filter(WorkflowSpecCategoryModel.name=='another_test_category').first()
|
||||
self.assertEqual('Another Test Category', result.display_name)
|
||||
self.assertEqual(count, result.id)
|
||||
|
||||
def test_update_workflow_spec_category(self):
|
||||
self.load_example_data()
|
||||
category = session.query(WorkflowSpecCategoryModel).first()
|
||||
category_name_before = category.name
|
||||
new_category_name = category_name_before + '_asdf'
|
||||
self.assertNotEqual(category_name_before, new_category_name)
|
||||
|
||||
category.name = new_category_name
|
||||
|
||||
rv = self.app.put(f'/v1.0/workflow-specification-category/{category.id}',
|
||||
content_type="application/json",
|
||||
headers=self.logged_in_headers(),
|
||||
data=json.dumps(WorkflowSpecCategoryModelSchema().dump(category)))
|
||||
self.assert_success(rv)
|
||||
json_data = json.loads(rv.get_data(as_text=True))
|
||||
self.assertEqual(new_category_name, json_data['name'])
|
||||
|
|
|
@ -9,7 +9,7 @@ class TestValueExpression(BaseTest):
|
|||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
first_task = workflow_api.next_task
|
||||
self.complete_form(workflow_api, first_task, {'value_expression_value': ''})
|
||||
self.complete_form(workflow, first_task, {'value_expression_value': ''})
|
||||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
second_task = workflow_api.next_task
|
||||
|
@ -26,7 +26,7 @@ class TestValueExpression(BaseTest):
|
|||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
first_task = workflow_api.next_task
|
||||
self.complete_form(workflow_api, first_task, {'value_expression_value': 'black'})
|
||||
self.complete_form(workflow, first_task, {'value_expression_value': 'black'})
|
||||
|
||||
workflow_api = self.get_workflow_api(workflow)
|
||||
second_task = workflow_api.next_task
|
||||
|
|
Loading…
Reference in New Issue