cr-connect-workflow/crc/api/workflow.py

424 lines
18 KiB
Python

import uuid
from flask import g
from crc import session
from crc.api.common import ApiError, ApiErrorSchema
from crc.models.api_models import WorkflowApiSchema
from crc.models.study import StudyModel, WorkflowMetadata, StudyStatus
from crc.models.task_event import TaskEventModel, TaskEvent, TaskEventSchema, TaskAction
from crc.models.workflow import WorkflowModel, WorkflowSpecInfoSchema, WorkflowSpecCategorySchema
from crc.services.error_service import ValidationErrorService
from crc.services.lookup_service import LookupService
from crc.services.study_service import StudyService
from crc.services.user_service import UserService
from crc.services.workflow_processor import WorkflowProcessor
from crc.services.workflow_service import WorkflowService
from crc.services.workflow_spec_service import WorkflowSpecService
def all_specifications(libraries=False,standalone=False):
spec_service = WorkflowSpecService()
if libraries and standalone:
raise ApiError('inconceivable!', 'You should specify libraries or standalone, but not both')
if libraries:
workflows = spec_service.get_libraries()
return WorkflowSpecInfoSchema(many=True).dump(workflows)
if standalone:
workflows = spec_service.get_standalones()
return WorkflowSpecInfoSchema(many=True).dump(workflows)
# return standard workflows (not library, not standalone)
specs = spec_service.get_specs()
master_spec = spec_service.get_master_spec()
if master_spec:
specs.append(spec_service.get_master_spec())
return WorkflowSpecInfoSchema(many=True).dump(specs)
def add_workflow_specification(body):
spec = WorkflowSpecInfoSchema().load(body)
spec_service = WorkflowSpecService()
category = spec_service.get_category(spec.category_id)
spec.category = category
workflows = spec_service.cleanup_workflow_spec_display_order(category)
size = len(workflows)
spec.display_order = size
spec_service.add_spec(spec)
return WorkflowSpecInfoSchema().dump(spec)
def get_workflow_specification(spec_id):
spec_service = WorkflowSpecService()
if spec_id is None:
raise ApiError('unknown_spec', 'Please provide a valid Workflow Specification ID.')
spec = spec_service.get_spec(spec_id)
if spec is None:
raise ApiError('unknown_spec', 'The Workflow Specification "' + spec_id + '" is not recognized.')
return WorkflowSpecInfoSchema().dump(spec)
def validate_spec_and_library(spec_id,library_id):
spec_service = WorkflowSpecService()
if spec_id is None:
raise ApiError('unknown_spec', 'Please provide a valid Workflow Specification ID.')
if library_id is None:
raise ApiError('unknown_spec', 'Please provide a valid Library Specification ID.')
spec = spec_service.get_spec(spec_id)
library = spec_service.get_spec(library_id);
if spec is None:
raise ApiError('unknown_spec', 'The Workflow Specification "' + spec_id + '" is not recognized.')
if library is None:
raise ApiError('unknown_spec', 'The Library Specification "' + library_id + '" is not recognized.')
if not library.library:
raise ApiError('unknown_spec', 'Linked workflow spec is not a library.')
def add_workflow_spec_library(spec_id, library_id):
validate_spec_and_library(spec_id, library_id)
spec_service = WorkflowSpecService()
spec = spec_service.get_spec(spec_id)
if library_id in spec.libraries:
raise ApiError('invalid_request', 'The Library Specification "' + library_id + '" is already attached.')
spec.libraries.append(library_id)
spec_service.update_spec(spec)
return WorkflowSpecInfoSchema().dump(spec)
def drop_workflow_spec_library(spec_id, library_id):
validate_spec_and_library(spec_id, library_id)
spec_service = WorkflowSpecService()
spec = spec_service.get_spec(spec_id)
if library_id in spec.libraries:
spec.libraries.remove(library_id)
spec_service.update_spec(spec)
return WorkflowSpecInfoSchema().dump(spec)
def validate_workflow_specification(spec_id, study_id=None, test_until=None):
try:
master_spec = WorkflowSpecService().master_spec
if study_id is not None:
study_model = session.query(StudyModel).filter(StudyModel.id == study_id).first()
if study_model is None:
raise ApiError(code='invalid_study',
message=f"Unable to validate Workflow because the"
f" provided study id ({study_id}) does not exist.")
statuses = WorkflowProcessor.run_master_spec(master_spec, study_model)
if spec_id in statuses and statuses[spec_id]['status'] == 'disabled':
raise ApiError(code='disabled_workflow',
message=f"This workflow is disabled. {statuses[spec_id]['message']}")
WorkflowService.test_spec(spec_id, study_id, test_until)
WorkflowService.test_spec(spec_id, study_id, test_until, required_only=True)
except ApiError as ae:
error = ae
error = ValidationErrorService.interpret_validation_error(error)
return ApiErrorSchema(many=True).dump([error])
return []
def update_workflow_specification(spec_id, body):
spec_service = WorkflowSpecService()
if spec_id is None:
raise ApiError('unknown_spec', 'Please provide a valid Workflow Spec ID.')
spec = spec_service.get_spec(spec_id)
if spec is None:
raise ApiError('unknown_study', 'The spec "' + spec_id + '" is not recognized.')
# Make sure they don't try to change the display_order
# There is a separate endpoint for this
body['display_order'] = spec.display_order
# Libraries and standalone workflows don't get a category_id
if body['library'] is True or body['standalone'] is True:
body['category_id'] = None
spec = WorkflowSpecInfoSchema().load(body)
spec_service.update_spec(spec)
return WorkflowSpecInfoSchema().dump(spec)
def delete_workflow_specification(spec_id):
if spec_id is None:
raise ApiError('unknown_spec', 'Please provide a valid Workflow Specification ID.')
spec_service = WorkflowSpecService()
spec = spec_service.get_spec(spec_id)
if spec is None:
raise ApiError('unknown_spec', 'The Workflow Specification "' + spec_id + '" is not recognized.')
spec_service.delete_spec(spec_id)
category = spec_service.get_category(spec.category_id) # Reload the category, or cleanup may re-create the spec.
spec_service.cleanup_workflow_spec_display_order(category)
def reorder_workflow_specification(spec_id, direction):
if direction not in ('up', 'down'):
raise ApiError(code='bad_direction',
message='The direction must be `up` or `down`.')
spec_service = WorkflowSpecService()
spec = spec_service.get_spec(spec_id)
if spec:
ordered_specs = spec_service.reorder_spec(spec, direction)
else:
raise ApiError(code='bad_spec_id',
message=f'The spec_id {spec_id} did not return a specification. Please check that it is valid.')
return WorkflowSpecInfoSchema(many=True).dump(ordered_specs)
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 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. """
workflow_model: WorkflowModel = session.query(WorkflowModel).filter_by(id=workflow_id).first()
processor = WorkflowProcessor(workflow_model)
if do_engine_steps:
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 restart_workflow(workflow_id, clear_data=False, delete_files=False):
"""Restart a workflow with the latest spec.
Clear data allows user to restart the workflow without previous data."""
# fixme: remove delete_files arg, clear_data is the only one respected.
workflow_model: WorkflowModel = session.query(WorkflowModel).filter_by(id=workflow_id).first()
WorkflowProcessor.reset(workflow_model, clear_data=clear_data)
processor = WorkflowProcessor(workflow_model)
processor.do_engine_steps()
processor.save()
WorkflowService.update_task_assignments(processor)
workflow_api_model = WorkflowService.processor_to_workflow_api(processor)
api_model = WorkflowApiSchema().dump(workflow_api_model)
return api_model
def get_task_events(action = None, workflow = None, study = None):
"""Provides a way to see a history of what has happened, or get a list of tasks that need your attention."""
user = UserService.current_user(allow_admin_impersonate=True)
studies = session.query(StudyModel).filter(StudyModel.user_uid==user.uid)
studyids = [s.id for s in studies]
query = session.query(TaskEventModel).filter((TaskEventModel.study_id.in_(studyids)) | \
(TaskEventModel.user_uid==user.uid))
if action:
query = query.filter(TaskEventModel.action == action)
if workflow:
query = query.filter(TaskEventModel.workflow_id == workflow)
if study:
query = query.filter(TaskEventModel.study_id == study)
events = query.all()
# Turn the database records into something a little richer for the UI to use.
task_events = []
for event in events:
study = session.query(StudyModel).filter(StudyModel.id == event.study_id).first()
workflow = session.query(WorkflowModel).filter(WorkflowModel.id == event.workflow_id).first()
spec = WorkflowSpecService().get_spec(workflow.workflow_spec_id)
if spec is not None:
workflow_meta = WorkflowMetadata.from_workflow(workflow, spec)
if study and study.status in [StudyStatus.open_for_enrollment, StudyStatus.in_progress]:
task_events.append(TaskEvent(event, study, workflow_meta))
return TaskEventSchema(many=True).dump(task_events)
def delete_workflow(workflow_id):
StudyService.delete_workflow(workflow_id)
def set_current_task(workflow_id, task_id):
workflow_model = session.query(WorkflowModel).filter_by(id=workflow_id).first()
processor = WorkflowProcessor(workflow_model)
task_id = uuid.UUID(task_id)
spiff_task = processor.bpmn_workflow.get_task(task_id)
cancel_notify = (spiff_task.state == spiff_task.COMPLETED and
spiff_task.task_spec.__class__.__name__ != 'EndEvent')
if not spiff_task:
# An invalid task_id was requested.
raise ApiError("invalid_task", "The Task you requested no longer exists as a part of this workflow.")
_verify_user_and_role(processor, spiff_task)
user_uid = UserService.current_user(allow_admin_impersonate=True).uid
if spiff_task.state != spiff_task.COMPLETED and spiff_task.state != spiff_task.READY:
raise ApiError("invalid_state", "You may not move the token to a task who's state is not "
"currently set to COMPLETE or READY.")
# Only reset the token if the task doesn't already have it.
if cancel_notify:
processor.cancel_notify()
spiff_task.reset_token({}, reset_data=True) # Don't try to copy the existing data back into this task.
processor.save()
WorkflowService.log_task_action(user_uid, processor, spiff_task, TaskAction.TOKEN_RESET.value)
WorkflowService.update_task_assignments(processor)
workflow_api_model = WorkflowService.processor_to_workflow_api(processor, spiff_task)
return WorkflowApiSchema().dump(workflow_api_model)
def update_task(workflow_id, task_id, body, terminate_loop=None, update_all=False):
workflow_model = session.query(WorkflowModel).filter_by(id=workflow_id).first()
if workflow_model is None:
raise ApiError("invalid_workflow_id", "The given workflow id is not valid.", status_code=404)
processor = WorkflowProcessor(workflow_model)
task_id = uuid.UUID(task_id)
spiff_task = processor.bpmn_workflow.get_task(task_id)
_verify_user_and_role(processor, spiff_task)
user = UserService.current_user(allow_admin_impersonate=False) # Always log as the real user.
if not spiff_task:
raise ApiError("empty_task", "Processor failed to obtain task.", status_code=404)
if spiff_task.state != spiff_task.READY:
raise ApiError("invalid_state", "You may not update a task unless it is in the READY state. "
"Consider calling a token reset to make this task Ready.")
if terminate_loop and spiff_task.is_looping():
spiff_task.terminate_loop()
# Extract the details specific to the form submitted
form_data = WorkflowService().extract_form_data(body, spiff_task)
# Update the task
__update_task(processor, spiff_task, form_data, user)
# If we need to update all tasks, then get the next ready task and if it a multi-instance with the same
# task spec, complete that form as well.
if update_all:
last_index = spiff_task.task_info()["mi_index"]
next_task = processor.next_task()
while next_task and next_task.task_info()["mi_index"] > last_index:
__update_task(processor, next_task, form_data, user)
last_index = next_task.task_info()["mi_index"]
next_task = processor.next_task()
WorkflowService.update_task_assignments(processor)
workflow_api_model = WorkflowService.processor_to_workflow_api(processor)
return WorkflowApiSchema().dump(workflow_api_model)
def __update_task(processor, task, data, user):
"""All the things that need to happen when we complete a form, abstracted
here because we need to do it multiple times when completing all tasks in
a multi-instance task"""
task.update_data(data)
WorkflowService.post_process_form(task) # some properties may update the data store.
processor.complete_task(task)
# Log the action before doing the engine steps, as doing so could effect the state of the task
# the workflow could wrap around in the ngine steps, and the task could jump from being completed to
# another state. What we are logging here is the completion.
WorkflowService.log_task_action(user.uid, processor, task, TaskAction.COMPLETE.value)
processor.do_engine_steps()
processor.save()
def list_workflow_spec_categories():
spec_service = WorkflowSpecService()
categories = spec_service.get_categories()
return WorkflowSpecCategorySchema(many=True).dump(categories)
def get_workflow_spec_category(cat_id):
spec_service = WorkflowSpecService()
category = spec_service.get_category(cat_id)
return WorkflowSpecCategorySchema().dump(category)
def add_workflow_spec_category(body):
spec_service = WorkflowSpecService()
category = WorkflowSpecCategorySchema().load(body)
spec_service.add_category(category)
return WorkflowSpecCategorySchema().dump(category)
def update_workflow_spec_category(cat_id, body):
if cat_id is None:
raise ApiError('unknown_category', 'Please provide a valid Workflow Spec Category ID.')
spec_service = WorkflowSpecService()
category = spec_service.get_category(cat_id)
if category is None:
raise ApiError('unknown_category', 'The category "' + cat_id + '" is not recognized.')
# Make sure they don't try to change the display_order
# There is a separate endpoint for that
body['display_order'] = category.display_order
category = WorkflowSpecCategorySchema().load(body)
spec_service.update_category(category)
return WorkflowSpecCategorySchema().dump(category)
def delete_workflow_spec_category(cat_id):
spec_service = WorkflowSpecService()
spec_service.delete_category(cat_id)
def reorder_workflow_spec_category(cat_id, direction):
if direction not in ('up', 'down'):
raise ApiError(code='bad_direction',
message='The direction must be `up` or `down`.')
spec_service = WorkflowSpecService()
spec_service.cleanup_category_display_order()
category = spec_service.get_category(cat_id)
if category:
ordered_categories = spec_service.reorder_workflow_spec_category(category, direction)
return WorkflowSpecCategorySchema(many=True).dump(ordered_categories)
else:
raise ApiError(code='bad_category_id',
message=f'The category id {cat_id} did not return a Workflow Spec Category. Make sure it is a valid ID.')
def lookup(workflow_id, task_spec_name, field_id, query=None, value=None, limit=10):
"""
given a field in a task, attempts to find the lookup table or function associated
with that field and runs a full-text query against it to locate the values and
labels that would be returned to a type-ahead box.
Tries to be fast, but first runs will be very slow.
"""
workflow = session.query(WorkflowModel).filter(WorkflowModel.id == workflow_id).first()
lookup_data = LookupService.lookup(workflow, task_spec_name, field_id, query, value, limit)
# Just return the data
return lookup_data
def lookup_ldap(query=None, limit=10):
"""
perform a lookup against the LDAP server without needing a provided workflow.
"""
value = None
lookup_data = LookupService._run_ldap_query(query, value, limit)
return lookup_data
def _verify_user_and_role(processor, spiff_task):
"""Assures the currently logged in user can access the given workflow and task, or
raises an error. """
user = UserService.current_user(allow_admin_impersonate=True)
allowed_users = WorkflowService.get_users_assigned_to_task(processor, spiff_task)
if user.uid not in allowed_users:
raise ApiError.from_task("permission_denied",
f"This task must be completed by '{allowed_users}', "
f"but you are {user.uid}", spiff_task)