From 53cd53cbc493f6b6770ae01979d1e3ce4d4731e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= Date: Sat, 12 Dec 2015 10:46:24 +0100 Subject: [PATCH] general: add support for global actions (#3659) --- tests/test_admin_pages.py | 90 +++++++ tests/test_backoffice_pages.py | 38 +++ tests/test_workflow_import.py | 26 ++ wcs/admin/workflows.py | 321 +++++++++++++++++++++-- wcs/backoffice/management.py | 2 +- wcs/formdata.py | 3 +- wcs/forms/common.py | 2 +- wcs/qommon/static/js/biglist.js | 5 +- wcs/wf/aggregation_email.py | 1 + wcs/wf/attachment.py | 1 + wcs/wf/export_to_model.py | 1 + wcs/wf/form.py | 1 + wcs/wf/timeout_jump.py | 1 + wcs/workflows.py | 556 +++++++++++++++++++++++++++------------- 14 files changed, 849 insertions(+), 199 deletions(-) diff --git a/tests/test_admin_pages.py b/tests/test_admin_pages.py index 6c690c0..75a2c16 100644 --- a/tests/test_admin_pages.py +++ b/tests/test_admin_pages.py @@ -1469,6 +1469,96 @@ def test_workflows_functions_vs_visibility(pub): assert set(Workflow.get(workflow.id).possible_status[2].visibility) == set( ['_receiver', '_other-function']) +def test_workflows_global_actions(pub): + create_superuser(pub) + create_role() + + Workflow.wipe() + workflow = Workflow(name='foo') + workflow.store() + + app = login(get_app(pub)) + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('add global action') + resp = resp.forms[0].submit('cancel') + assert not Workflow.get(workflow.id).global_actions + + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('add global action') + resp.forms[0]['name'] = 'Global Action' + resp = resp.forms[0].submit('submit') + assert Workflow.get(workflow.id).global_actions[0].name == 'Global Action' + + # test rename + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('Global Action') + resp = resp.click('Change Action Name') + resp.forms[0]['name'] = 'Renamed Action' + resp = resp.forms[0].submit('submit') + assert Workflow.get(workflow.id).global_actions[0].name == 'Renamed Action' + + # test removal + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('Renamed Action') + resp = resp.click('Delete') + resp = resp.forms[0].submit('delete') + assert not Workflow.get(workflow.id).global_actions + +def test_workflows_global_actions_edit(pub): + create_superuser(pub) + create_role() + + Workflow.wipe() + workflow = Workflow(name='foo') + workflow.store() + + app = login(get_app(pub)) + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('add global action') + resp.forms[0]['name'] = 'Global Action' + resp = resp.forms[0].submit('submit') + resp = resp.follow() + + resp = resp.click('Global Action') + + # test adding all actions + for action in [x[0] for x in resp.forms[0]['type'].options]: + resp.forms[0]['type'] = action + resp = resp.forms[0].submit() + resp = resp.follow() + + # test visiting + action_id = Workflow.get(workflow.id).global_actions[0].id + for item in Workflow.get(workflow.id).global_actions[0].items: + resp = app.get('/backoffice/workflows/%s/global-actions/%s/items/%s/' % ( + workflow.id, action_id, item.id)) + + # test modifying a trigger + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('Global Action') + assert len(Workflow.get(workflow.id).global_actions[0].triggers) == 1 + resp = resp.click(href='triggers/1/', index=0) + assert resp.form['roles$element0'].value == 'None' + resp.form['roles$element0'].value = '_receiver' + resp = resp.form.submit('submit') + assert Workflow.get(workflow.id).global_actions[0].triggers[0].roles == ['_receiver'] + + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('Global Action') + resp = resp.click(href='triggers/1/', index=0) + assert resp.form['roles$element0'].value == '_receiver' + resp.form['roles$element1'].value = '_submitter' + resp = resp.form.submit('submit') + assert Workflow.get(workflow.id).global_actions[0].triggers[0].roles == [ + '_receiver', '_submitter'] + + resp = app.get('/backoffice/workflows/%s/' % workflow.id) + resp = resp.click('Global Action') + resp = resp.click(href='triggers/1/', index=0) + resp.form['roles$element1'].value = 'None' + resp = resp.form.submit('submit') + assert Workflow.get(workflow.id).global_actions[0].triggers[0].roles == ['_receiver'] + def test_users(pub): create_superuser(pub) app = login(get_app(pub)) diff --git a/tests/test_backoffice_pages.py b/tests/test_backoffice_pages.py index c2437cd..54cb98c 100644 --- a/tests/test_backoffice_pages.py +++ b/tests/test_backoffice_pages.py @@ -18,6 +18,8 @@ from wcs.qommon.http_request import HTTPRequest from wcs.roles import Role from wcs.workflows import (Workflow, CommentableWorkflowStatusItem, ChoiceWorkflowStatusItem, EditableWorkflowStatusItem) +from wcs.wf.jump import JumpWorkflowStatusItem +from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem from wcs.wf.wscall import WebserviceCallStatusItem from wcs.categories import Category from wcs.formdef import FormDef @@ -477,6 +479,42 @@ def test_backoffice_handling(pub): assert FormDef.get_by_urlname('form-title').data_class().get(number31).status == 'wf-accepted' assert 'HELLO WORLD' in resp.body +def test_backoffice_handling_global_action(pub): + create_user(pub) + create_environment(pub) + + formdef = FormDef() + formdef.name = 'test global action' + formdef.fields = [] + + workflow = Workflow.get_default_workflow() + workflow.id = '2' + action = workflow.add_global_action('FOOBAR') + register_comment = action.append_item('register-comment') + register_comment.comment = 'HELLO WORLD GLOBAL ACTION' + jump = action.append_item('jump') + jump.status = 'finished' + trigger = action.triggers[0] + trigger.roles = [x.id for x in Role.select() if x.name == 'foobar'] + + workflow.store() + formdef.workflow_id = workflow.id + formdef.workflow_roles = {'_receiver': 1} + formdef.store() + + formdata = formdef.data_class()() + formdata.just_created() + formdata.store() + + app = login(get_app(pub)) + resp = app.get('/backoffice/management/%s/%s/' % (formdef.url_name, formdata.id)) + assert 'button-action-1' in resp.form.fields + resp = resp.form.submit('button-action-1') + + resp = app.get('/backoffice/management/%s/%s/' % (formdef.url_name, formdata.id)) + assert 'HELLO WORLD GLOBAL ACTION' in resp.body + assert formdef.data_class().get(formdata.id).status == 'wf-finished' + def test_backoffice_submission_context(pub): user = create_user(pub) create_environment(pub) diff --git a/tests/test_workflow_import.py b/tests/test_workflow_import.py index ef71247..063c05f 100644 --- a/tests/test_workflow_import.py +++ b/tests/test_workflow_import.py @@ -8,6 +8,7 @@ from wcs import publisher from wcs.workflows import Workflow, CommentableWorkflowStatusItem from wcs.wf.wscall import WebserviceCallStatusItem +from wcs.wf.register_comment import RegisterCommenterWorkflowStatusItem from wcs.roles import Role from wcs.fields import StringField @@ -316,3 +317,28 @@ def test_backoffice_info_text(): wf2 = assert_import_export_works(wf) assert wf2.possible_status[0].backoffice_info_text == '

Foo

' assert wf2.possible_status[0].items[0].backoffice_info_text == '

Bar

' + +def test_global_actions(): + role = Role() + role.id = '5' + role.name = 'Test Role' + role.store() + + wf = Workflow(name='global actions') + ac1 = wf.add_global_action('Action', 'ac1') + ac1.backoffice_info_text = '

Foo

' + + add_to_journal = RegisterCommenterWorkflowStatusItem() + add_to_journal.id = '_add_to_journal' + add_to_journal.comment = 'HELLO WORLD' + ac1.items.append(add_to_journal) + add_to_journal.parent = ac1 + + trigger = ac1.triggers[0] + assert trigger.key == 'manual' + trigger.roles = [role.id] + + wf2 = assert_import_export_works(wf) + assert wf2.global_actions[0].triggers[0].roles == [role.id] + + wf2 = assert_import_export_works(wf, True) diff --git a/wcs/admin/workflows.py b/wcs/admin/workflows.py index 4b7cb4a..a7437af 100644 --- a/wcs/admin/workflows.py +++ b/wcs/admin/workflows.py @@ -229,13 +229,13 @@ class WorkflowUI(object): class WorkflowItemPage(Directory): _q_exports = ['', 'delete'] - def __init__(self, workflow, status, component, html_top): + def __init__(self, workflow, parent, component, html_top): try: - self.item = [x for x in status.items if x.id == component][0] + self.item = [x for x in parent.items if x.id == component][0] except (IndexError, ValueError): raise errors.TraversalError() self.workflow = workflow - self.status = status + self.parent = parent self.html_top = html_top get_response().breadcrumb.append(('items/%s/' % component, _(self.item.description))) @@ -259,7 +259,7 @@ class WorkflowItemPage(Directory): if not form.get_submit() == 'submit' or form.has_errors(): self.html_top('%s - %s' % (_('Workflow'), self.workflow.name)) r = TemplateIO(html=True) - r += htmltext('

%s - %s

') % (self.workflow.name, self.status.name) + r += htmltext('

%s - %s

') % (self.workflow.name, self.parent.name) r += htmltext('

%s

') % _(self.item.description) r += form.render() if self.item.support_substitution_variables: @@ -286,20 +286,74 @@ class WorkflowItemPage(Directory): r += form.render() return r.getvalue() else: - del self.status.items[self.status.items.index(self.item)] + del self.parent.items[self.parent.items.index(self.item)] self.workflow.store() return redirect('../../') def _q_lookup(self, component): - t = self.item.q_admin_lookup(self.workflow, self.status, component, + t = self.item.q_admin_lookup(self.workflow, self.parent, component, self.html_top) if t: return t return Directory._q_lookup(self, component) -class WorkflowItemsDir(Directory): +class GlobalActionTriggerPage(Directory): + _q_exports = ['', 'delete'] + + def __init__(self, workflow, action, component, html_top): + try: + self.trigger = [x for x in action.triggers if x.id == component][0] + except (IndexError, ValueError): + raise errors.TraversalError() + self.workflow = workflow + self.action = action + self.status = action + self.html_top = html_top + get_response().breadcrumb.append(('triggers/%s/' % component, _('Trigger'))) + + def _q_index(self): + form = self.trigger.form(self.workflow) + form.add_submit('submit', _('Save')) + form.add_submit('cancel', _('Cancel')) + + if form.get_widget('cancel').parse(): + return redirect('..') + + if form.get_submit() == 'submit' and not form.has_errors(): + self.trigger.submit(form) + if not form.has_errors(): + self.workflow.store() + return redirect('../../') + + self.html_top('%s - %s' % (_('Workflow'), self.workflow.name)) + r = TemplateIO(html=True) + r += htmltext('

%s - %s

') % (self.workflow.name, self.action.name) + r += form.render() + return r.getvalue() + + def delete(self): + form = Form(enctype='multipart/form-data') + form.widgets.append(HtmlWidget('

%s

' % _('You are about to remove a trigger.'))) + form.add_submit('delete', _('Submit')) + form.add_submit('cancel', _('Cancel')) + if form.get_widget('cancel').parse(): + return redirect('../../') + if not form.is_submitted() or form.has_errors(): + get_response().breadcrumb.append(('delete', _('Delete'))) + self.html_top(title=_('Delete Trigger')) + r = TemplateIO(html=True) + r += htmltext('

%s

') % _('Deleting Trigger') + r += form.render() + return r.getvalue() + else: + del self.action.triggers[self.action.triggers.index(self.trigger)] + self.workflow.store() + return redirect('../../') + +class ToChildDirectory(Directory): _q_exports = [''] + klass = None def __init__(self, workflow, status, html_top): self.workflow = workflow @@ -307,12 +361,24 @@ class WorkflowItemsDir(Directory): self.html_top = html_top def _q_lookup(self, component): - return WorkflowItemPage(self.workflow, self.status, component, - self.html_top) + return self.klass(self.workflow, self.status, component, self.html_top) def _q_index(self): return redirect('..') + +class WorkflowItemsDir(ToChildDirectory): + klass = WorkflowItemPage + + +class GlobalActionTriggersDir(ToChildDirectory): + klass = GlobalActionTriggerPage + + +class GlobalActionItemsDir(ToChildDirectory): + klass = WorkflowItemPage + + class WorkflowStatusPage(Directory): _q_exports = ['', 'delete', 'newitem', ('items', 'items_dir'), 'update_order', 'edit', 'reassign', 'visibility', @@ -341,11 +407,6 @@ class WorkflowStatusPage(Directory): r += htmltext('%s - %s') % (self.workflow.name, self.status.name) r += get_session().display_message() - r += htmltext('
') - r += htmltext('

%s ') % _('Possible Status:') - r += htmltext('%s

') % self.status.name - r += htmltext('
') - if self.status.get_visibility_restricted_roles(): r += htmltext('
') r += _('This status is hidden from the user.') @@ -411,7 +472,6 @@ class WorkflowStatusPage(Directory): return r.getvalue() - def get_sidebar(self): get_response().add_javascript(['jquery.js', 'jquery-ui.js', 'popup.js', 'jquery.colourpicker.js']) @@ -437,9 +497,12 @@ class WorkflowStatusPage(Directory): r += htmltext('
') return r.getvalue() + def is_item_available(self, item): + return item.is_available() + def get_new_item_form(self): form = Form(enctype='multipart/form-data', action = 'newitem') - available_items = [x for x in item_classes if x.is_available()] + available_items = [x for x in item_classes if self.is_item_available(x)] def cmp_items(x, y): t = cmp(x.category and x.category[0], y.category and y.category[0]) if t: @@ -460,7 +523,6 @@ class WorkflowStatusPage(Directory): self.workflow.store() return 'ok' - def newitem(self): form = self.get_new_item_form() @@ -478,7 +540,6 @@ class WorkflowStatusPage(Directory): return redirect('.') - def delete(self): form = Form(enctype="multipart/form-data") form.widgets.append(HtmlWidget('

%s

' % _( @@ -888,10 +949,198 @@ class FunctionsDirectory(Directory): return redirect('..') +class GlobalActionPage(WorkflowStatusPage): + _q_exports = ['', 'new', 'delete', 'newitem', ('items', 'items_dir'), + 'edit', ('triggers', 'triggers_dir'), + ('backoffice-info-text', 'backoffice_info_text'),] + + def __init__(self, workflow, action_id, html_top): + self.html_top = html_top + self.workflow = workflow + try: + self.action = [x for x in self.workflow.global_actions if x.id == action_id][0] + except IndexError: + raise errors.TraversalError() + self.status = self.action + self.items_dir = GlobalActionItemsDir(workflow, self.action, html_top) + self.triggers_dir = GlobalActionTriggersDir(workflow, self.action, html_top) + + def _q_traverse(self, path): + get_response().breadcrumb.append( + ('global-actions/%s/' % self.action.id, _('Global Action: %s') % self.action.name)) + return Directory._q_traverse(self, path) + + def is_item_available(self, item): + return item.is_available() and item.ok_in_global_action + + def _q_index(self): + self.html_top('%s - %s' % (_('Workflow'), self.workflow.name)) + r = TemplateIO(html=True) + get_response().add_javascript(['jquery.js', 'jquery-ui.js', 'biglist.js', + 'ckeditor/ckeditor.js', 'qommon.wysiwyg.js', 'ckeditor/adapters/jquery.js']) + + r += htmltext('

%s - ') % _('Workflow') + r += htmltext('%s - %s

') % (self.workflow.name, self.action.name) + r += get_session().display_message() + + r += htmltext('
') + r += htmltext('

%s

') % _('Actions') + if not self.action.items: + r += htmltext('

%s

') % _('There are not yet any items in this action.') + else: + if str(self.workflow.id).startswith('_'): + r += htmltext('
') # bo-block + + r += htmltext('
') + r += htmltext('

%s

') % _('Triggers') + r += htmltext('') + r += htmltext('
') # bo-block + + r += htmltext('

%s

') % _('Back to workflow main page') + + get_response().filter['sidebar'] = self.get_sidebar() + + return r.getvalue() + + def delete(self): + form = Form(enctype='multipart/form-data') + form.widgets.append(HtmlWidget('

%s

' % _( + 'You are about to remove an action.'))) + form.add_submit('delete', _('Submit')) + form.add_submit('cancel', _('Cancel')) + if form.get_widget('cancel').parse(): + return redirect('../../') + if not form.is_submitted() or form.has_errors(): + get_response().breadcrumb.append(('delete', _('Delete'))) + self.html_top(title = _('Delete Action')) + r = TemplateIO(html=True) + r += htmltext('

%s %s

') % (_('Deleting Action:'), self.action.name) + r += form.render() + return r.getvalue() + + del self.workflow.global_actions[self.workflow.global_actions.index(self.action)] + self.workflow.store() + return redirect('../../') + + def edit(self): + form = Form(enctype = 'multipart/form-data') + form.add(StringWidget, 'name', title=_('Action Name'), required=True, + size=30, value=self.action.name) + form.add_submit('submit', _('Submit')) + form.add_submit('cancel', _('Cancel')) + if form.get_widget('cancel').parse(): + return redirect('..') + + if form.is_submitted() and not form.has_errors(): + new_name = str(form.get_widget('name').parse()) + if [x for x in self.workflow.global_actions if x.name == new_name]: + form.get_widget('name').set_error( + _('There is already an action with that name.')) + else: + self.action.name = new_name + self.workflow.store() + return redirect('.') + + self.html_top(title=_('Edit Action Name')) + get_response().breadcrumb.append( ('edit', _('Edit')) ) + r = TemplateIO(html=True) + r += htmltext('

%s

') % _('Edit Action Name') + r += form.render() + return r.getvalue() + + def get_sidebar(self): + get_response().add_javascript(['jquery.js', 'jquery-ui.js', 'popup.js', + 'jquery.colourpicker.js']) + r = TemplateIO(html=True) + if str(self.workflow.id).startswith('_'): + r += htmltext('

') + r += _('''This is the default workflow, you cannot edit it but you can + duplicate it to base your own workflow on it.''') + r += htmltext('

') + else: + r += htmltext('') + r += htmltext('
') + r += htmltext('

%s

') % _('New Item') + r += self.get_new_item_form().render() + r += htmltext('
') + return r.getvalue() + + +class GlobalActionsDirectory(Directory): + _q_exports = ['', 'new'] + + def __init__(self, workflow, html_top): + self.workflow = workflow + self.html_top = html_top + + def _q_lookup(self, component): + return GlobalActionPage(self.workflow, component, self.html_top) + + def _q_index(self): + return redirect('..') + + def new(self): + form = Form(enctype='multipart/form-data') + form.add(StringWidget, 'name', title=_('Name'), required=True, size=50) + form.add_submit('submit', _('Add')) + form.add_submit('cancel', _('Cancel')) + if form.get_widget('cancel').parse(): + return redirect('..') + + if form.is_submitted() and not form.has_errors(): + name = form.get_widget('name').parse() + action = self.workflow.add_global_action(name) + self.workflow.store() + return redirect('%s/' % action.id) + + get_response().breadcrumb.append(('new', _('New Global Action'))) + html_top('workflows', title=_('New Global Action')) + r = TemplateIO(html=True) + r += htmltext('

%s

') % _('New Global Action') + r += form.render() + return r.getvalue() + + class WorkflowPage(Directory): _q_exports = ['', 'edit', 'delete', 'newstatus', ('status', 'status_dir'), 'update_order', 'duplicate', 'export', 'svg', ('variables', 'variables_dir'), - ('functions', 'functions_dir')] + 'update_actions_order', + ('functions', 'functions_dir'), ('global-actions', 'global_actions_dir')] def __init__(self, component, html_top): try: @@ -903,6 +1152,7 @@ class WorkflowPage(Directory): self.status_dir = WorkflowStatusDirectory(self.workflow, html_top) self.variables_dir = VariablesDirectory(self.workflow) self.functions_dir = FunctionsDirectory(self.workflow) + self.global_actions_dir = GlobalActionsDirectory(self.workflow, html_top) get_response().breadcrumb.append((component + '/', self.workflow.name)) def _q_index(self): @@ -992,10 +1242,34 @@ class WorkflowPage(Directory): field.id, field.label) if not '*' in field.varname: r += htmltext(' (%s)') % field.varname - r += htmltext('') + r += htmltext('') r += htmltext('') r += htmltext('') + if not str(self.workflow.id).startswith('_') or self.workflow.global_actions: + r += htmltext('
') + r += htmltext('

%s') % _('Global Actions') + if not str(self.workflow.id).startswith('_'): + r += htmltext(' (%s)') % _('add global action') + r += htmltext('

') + + if not str(self.workflow.id).startswith('_'): + r += htmltext('
') + r += htmltext('') # .splitcontent-right r += htmltext('
') @@ -1073,6 +1347,13 @@ class WorkflowPage(Directory): self.workflow.store() return 'ok' + def update_actions_order(self): + request = get_request() + new_order = request.form['order'].strip(';').split(';') + self.workflow.global_actions = [ [x for x in self.workflow.global_actions if + x.id == y][0] for y in new_order] + self.workflow.store() + return 'ok' def newstatus(self): form = Form(enctype='multipart/form-data', action = 'newstatus') diff --git a/wcs/backoffice/management.py b/wcs/backoffice/management.py index 193fea4..8f03d18 100644 --- a/wcs/backoffice/management.py +++ b/wcs/backoffice/management.py @@ -1580,7 +1580,7 @@ class FormPage(Directory): class FormBackOfficeStatusPage(FormStatusPage): - _q_exports = ['', 'download', 'json', 'wfedit'] + _q_exports = ['', 'download', 'json', 'wfedit', 'action'] def html_top(self, title = None): return html_top('management', title) diff --git a/wcs/formdata.py b/wcs/formdata.py index 85c5000..9c30379 100644 --- a/wcs/formdata.py +++ b/wcs/formdata.py @@ -260,7 +260,8 @@ class FormData(StorableObject): url = None get_publisher().substitutions.feed(self) wf_status = self.get_status() - url = wf_status.perform_items(self) + from wcs.workflows import perform_items + url = perform_items(wf_status.items, self) return url def display_workflow_message(self): diff --git a/wcs/forms/common.py b/wcs/forms/common.py index 6d7dadc..a742eda 100644 --- a/wcs/forms/common.py +++ b/wcs/forms/common.py @@ -89,7 +89,7 @@ class FilesDirectory(Directory): class FormStatusPage(Directory): - _q_exports = ['', 'download', 'json'] + _q_exports = ['', 'download', 'json', 'action'] _q_extra_exports = [] def html_top(self, title = None): diff --git a/wcs/qommon/static/js/biglist.js b/wcs/qommon/static/js/biglist.js index a162e90..b982cdd 100644 --- a/wcs/qommon/static/js/biglist.js +++ b/wcs/qommon/static/js/biglist.js @@ -16,7 +16,7 @@ $(document).ready( update : function(event, ui) { result = ''; - items = $('ul.biglist li'); + items = $(ui.item).parent().find('li'); for (i=0; i < items.length; i++) { var item = items[i]; var item_id = item.id.substr(7, 50); @@ -24,7 +24,8 @@ $(document).ready( result += item_id + ';'; } } - $.post('update_order', {'order': result}); + var order_function = $(this).data('order-function') || 'update_order'; + $.post(order_function, {'order': result}); }, } ); diff --git a/wcs/wf/aggregation_email.py b/wcs/wf/aggregation_email.py index 406ef59..55d8608 100644 --- a/wcs/wf/aggregation_email.py +++ b/wcs/wf/aggregation_email.py @@ -28,6 +28,7 @@ from wcs.roles import Role class AggregationEmailWorkflowStatusItem(WorkflowStatusItem): description = N_('Aggregate to summary email') key = 'aggregationemail' + ok_in_global_action = False to = [] diff --git a/wcs/wf/attachment.py b/wcs/wf/attachment.py index 9f7e1f1..7ef825e 100644 --- a/wcs/wf/attachment.py +++ b/wcs/wf/attachment.py @@ -68,6 +68,7 @@ class AddAttachmentWorkflowStatusItem(WorkflowStatusItem): key = 'addattachment' endpoint = False waitpoint = True + ok_in_global_action = False title = None display_title = True diff --git a/wcs/wf/export_to_model.py b/wcs/wf/export_to_model.py index 317a9d5..f0ec887 100644 --- a/wcs/wf/export_to_model.py +++ b/wcs/wf/export_to_model.py @@ -131,6 +131,7 @@ class ExportToModel(WorkflowStatusItem): description = N_('Create Document') key = 'export_to_model' support_substitution_variables = True + ok_in_global_action = False endpoint = False waitpoint = True diff --git a/wcs/wf/form.py b/wcs/wf/form.py index af2f792..e552edb 100644 --- a/wcs/wf/form.py +++ b/wcs/wf/form.py @@ -63,6 +63,7 @@ class WorkflowFormFieldsDirectory(FieldsDirectory): class FormWorkflowStatusItem(WorkflowStatusItem): description = N_('Display a form') key = 'form' + ok_in_global_action = False by = [] formdef = None diff --git a/wcs/wf/timeout_jump.py b/wcs/wf/timeout_jump.py index f33883b..5011963 100644 --- a/wcs/wf/timeout_jump.py +++ b/wcs/wf/timeout_jump.py @@ -24,6 +24,7 @@ class TimeoutWorkflowStatusItem(WorkflowStatusJumpItem): description = N_('Change Status on Timeout') key = 'timeout' waitpoint = True + ok_in_global_action = False timeout = None diff --git a/wcs/workflows.py b/wcs/workflows.py index e0429c2..64d02ef 100644 --- a/wcs/workflows.py +++ b/wcs/workflows.py @@ -48,6 +48,32 @@ def lax_int(s): return -1 +def perform_items(items, formdata, depth=20): + if depth == 0: # prevents infinite loops + return + url = None + old_status = formdata.status + for item in items: + try: + url = item.perform(formdata) or url + except AbortActionException: + break + if formdata.status != old_status: + break + if formdata.status != old_status: + if not formdata.evolution: + formdata.evolution = [] + evo = Evolution() + evo.time = time.localtime() + evo.status = formdata.status + formdata.evolution.append(evo) + formdata.store() + # performs the items of the new status + wf_status = formdata.get_status() + url = perform_items(wf_status.items, formdata, depth=depth-1) or url + return url + + class WorkflowImportError(Exception): pass @@ -187,6 +213,10 @@ class AttachmentEvolutionPart: #pylint: disable=C1001 return {'attachments': AttachmentsSubstitutionProxy(formdata) } +class DuplicateGlobalActionNameError(Exception): + pass + + class DuplicateStatusNameError(Exception): pass @@ -220,6 +250,7 @@ class Workflow(StorableObject): possible_status = None roles = None variables_formdef = None + global_actions = None last_modification_time = None last_modification_user_id = None @@ -229,6 +260,7 @@ class Workflow(StorableObject): self.name = name self.possible_status = [] self.roles = {'_receiver': _('Recipient')} + self.global_actions = [] def migrate(self): changed = False @@ -240,6 +272,9 @@ class Workflow(StorableObject): for status in self.possible_status: changed |= status.migrate() + if not self.global_actions: + self.global_actions = [] + if changed: self.store() @@ -295,11 +330,43 @@ class Workflow(StorableObject): return status raise KeyError() + def add_global_action(self, name, id=None): + if [x for x in self.global_actions if x.name == name]: + raise DuplicateGlobalActionNameError() + action = WorkflowGlobalAction(name) + action.parent = self + action.append_trigger('manual') + + if id is None: + if self.global_actions: + action.id = str(max([lax_int(x.id) for x in self.global_actions]) + 1) + else: + action.id = '1' + else: + action.id = id + self.global_actions.append(action) + return action + + def get_global_actions_for_user(self, formdata, user): + if not user: + return [] + actions = [] + for action in self.global_actions or []: + for trigger in action.triggers or []: + if isinstance(trigger, WorkflowGlobalActionManualTrigger): + roles = [get_role_translation(formdata, x) + for x in trigger.roles] + if set(roles).intersection(user.roles or []): + actions.append(action) + break + return actions + def __setstate__(self, dict): self.__dict__.update(dict) - for s in self.possible_status: + for s in self.possible_status + (self.global_actions or []): s.parent = self - for i, item in enumerate(s.items): + triggers = getattr(s, 'triggers', None) or [] + for i, item in enumerate(s.items + triggers): item.parent = s if not item.id: item.id = '%d' % (i+1) @@ -379,6 +446,12 @@ class Workflow(StorableObject): possible_status.append(status.export_to_xml(charset=charset, include_id=include_id)) + if self.global_actions: + global_actions = ET.SubElement(root, 'global_actions') + for action in self.global_actions: + global_actions.append(action.export_to_xml(charset=charset, + include_id=include_id)) + if self.variables_formdef: variables = ET.SubElement(root, 'variables') formdef = ET.SubElement(variables, 'formdef') @@ -434,6 +507,15 @@ class Workflow(StorableObject): status_o.init_with_xml(status, charset, include_id=include_id) workflow.possible_status.append(status_o) + workflow.global_actions = [] + global_actions = tree.find('global_actions') + if global_actions is not None: + for action in global_actions: + action_o = WorkflowGlobalAction() + action_o.parent = workflow + action_o.init_with_xml(action, charset, include_id=include_id) + workflow.global_actions.append(action_o) + variables = tree.find('variables') if variables is not None: formdef = variables.find('formdef') @@ -456,12 +538,7 @@ class Workflow(StorableObject): return t def render_list_of_roles(self, roles): - t = [] - for r in roles: - role_label = get_role_translation_label(self, r) - if role_label: - t.append(role_label) - return ', '.join(t) + return render_list_of_roles(self, roles) def get_unknown_workflow(cls): workflow = Workflow(name=_('Unknown')) @@ -596,6 +673,271 @@ class Workflow(StorableObject): get_default_workflow = classmethod(get_default_workflow) +class XmlSerialisable(object): + node_name = None + + def export_to_xml(self, charset, include_id=False): + node = ET.Element(self.node_name) + node.attrib['type'] = self.key + for attribute in self.get_parameters(): + if hasattr(self, '%s_export_to_xml' % attribute): + getattr(self, '%s_export_to_xml' % attribute)(node, charset, + include_id=include_id) + continue + if hasattr(self, attribute) and getattr(self, attribute) is not None: + el = ET.SubElement(node, attribute) + val = getattr(self, attribute) + if type(val) is dict: + for k, v in val.items(): + ET.SubElement(el, k).text = unicode(v, charset, 'replace') + elif type(val) is list: + if attribute[-1] == 's': + atname = attribute[:-1] + else: + atname = 'item' + for v in val: + ET.SubElement(el, atname).text = unicode(str(v), charset, 'replace') + elif type(val) in (str, unicode): + if type(val) is unicode: + el.text = val + else: + el.text = unicode(val, charset, 'replace') + else: + el.text = str(val) + return node + + def init_with_xml(self, elem, charset, include_id=False): + for attribute in self.get_parameters(): + el = elem.find(attribute) + if hasattr(self, '%s_init_with_xml' % attribute): + getattr(self, '%s_init_with_xml' % attribute)(el, charset, + include_id=include_id) + continue + if el is None: + continue + if el.getchildren(): + if type(getattr(self, attribute)) is list: + v = [x.text.encode(charset) for x in el.getchildren()] + elif type(getattr(self, attribute)) is dict: + v = {} + for e in el.getchildren(): + v[e.tag] = e.text.encode(charset) + else: + # ??? + raise AssertionError + setattr(self, attribute, v) + else: + if el.text is None: + setattr(self, attribute, None) + elif el.text in ('False', 'True'): # bools + setattr(self, attribute, eval(el.text)) + elif type(getattr(self, attribute)) is int: + setattr(self, attribute, int(el.text.encode(charset))) + else: + setattr(self, attribute, el.text.encode(charset)) + + def _roles_export_to_xml(self, attribute, item, charset, include_id=False): + if not hasattr(self, attribute) or not getattr(self, attribute): + return + el = ET.SubElement(item, attribute) + for role_id in getattr(self, attribute): + if role_id is None: + continue + role_id = str(role_id) + if role_id.startswith('_') or role_id == 'logged-users': + role = unicode(role_id, charset) + else: + try: + role = unicode(Role.get(role_id).name, charset) + except KeyError: + role = unicode(role_id, charset) + sub = ET.SubElement(el, 'item') + sub.attrib['role_id'] = role_id + sub.text = role + + def _roles_init_with_xml(self, attribute, elem, charset, include_id=False): + if elem is None: + setattr(self, attribute, []) + else: + imported_roles = [] + for child in elem.getchildren(): + imported_roles.append(self._get_role_id_from_xml(child, + charset, include_id=include_id)) + setattr(self, attribute, imported_roles) + + def _role_export_to_xml(self, attribute, item, charset, include_id=False): + if not hasattr(self, attribute) or not getattr(self, attribute): + return + role_id = str(getattr(self, attribute)) + if role_id.startswith('_') or role_id == 'logged-users': + role = unicode(role_id, charset) + else: + try: + role = unicode(Role.get(role_id).name, charset) + except KeyError: + role = unicode(role_id, charset) + sub = ET.SubElement(item, attribute) + if include_id: + sub.attrib['role_id'] = role_id + sub.text = role + + def _get_role_id_from_xml(self, elem, charset, include_id=False): + if elem is None: + return None + + value = elem.text.encode(charset) + + # look for known static values + if value.startswith('_') or value == 'logged-users': + return value + + # if we import using id, only look at the role_id attribute + if include_id: + if not 'role_id' in elem.attrib: + return None + role_id = str(elem.attrib['role_id']) + if Role.has_key(role_id): + return role_id + else: + return None + + # if not using id, look up on the name + for role in Role.select(ignore_errors=True): + if role.name == value: + return role.id + + # and if there's no match, create a new role + role = Role() + role.name = value + role.store() + return role.id + + def _role_init_with_xml(self, attribute, elem, charset, include_id=False): + setattr(self, attribute, self._get_role_id_from_xml(elem, charset, + include_id=include_id)) + + +class WorkflowGlobalActionTrigger(XmlSerialisable): + node_name = 'trigger' + + +class WorkflowGlobalActionManualTrigger(WorkflowGlobalActionTrigger): + key = 'manual' + roles = None + + def get_parameters(self): + return ('roles',) + + def render_as_line(self): + if self.roles: + return _('Manual by %s') % render_list_of_roles( + self.parent.parent, self.roles) + else: + return _('Manual (not assigned)') + + def form(self, workflow): + form = Form(enctype='multipart/form-data') + options = [(None, '---', None)] + options += workflow.get_list_of_roles(include_logged_in_users=False) + form.add(WidgetList, 'roles', title=_('Roles'), + element_type=SingleSelectWidget, + value=self.roles, + add_element_label=_('Add Role'), + element_kwargs={'render_br': False, + 'options': options}) + return form + + def submit(self, form): + self.roles = form.get_widget('roles').parse() + + def roles_export_to_xml(self, item, charset, include_id=False): + self._roles_export_to_xml('roles', item, charset, include_id=include_id) + + def roles_init_with_xml(self, elem, charset, include_id=False): + self._roles_init_with_xml('roles', elem, charset, include_id=include_id) + + +class WorkflowGlobalAction(object): + id = None + name = None + items = None + triggers = None + backoffice_info_text = None + + def __init__(self, name=None): + self.name = name + self.items = [] + + def append_item(self, type): + for klass in item_classes: + if klass.key == type: + o = klass() + if self.items: + o.id = str(max([lax_int(x.id) for x in self.items]) + 1) + else: + o.id = '1' + self.items.append(o) + return o + else: + raise KeyError() + + def append_trigger(self, type): + trigger_types = { + 'manual': WorkflowGlobalActionManualTrigger + } + o = trigger_types.get(type)() + if not self.triggers: + self.triggers = [] + if self.triggers: + o.id = str(max([lax_int(x.id) for x in self.triggers]) + 1) + else: + o.id = '1' + self.triggers.append(o) + return o + + def export_to_xml(self, charset, include_id=False): + status = ET.Element('action') + ET.SubElement(status, 'id').text = unicode(self.id, charset) + ET.SubElement(status, 'name').text = unicode(self.name, charset) + + if self.backoffice_info_text: + ET.SubElement(status, 'backoffice_info_text').text = unicode( + self.backoffice_info_text, charset) + + items = ET.SubElement(status, 'items') + for item in self.items: + items.append(item.export_to_xml(charset=charset, + include_id=include_id)) + + triggers = ET.SubElement(status, 'triggers') + for trigger in self.triggers: + triggers.append(trigger.export_to_xml(charset=charset, + include_id=include_id)) + + return status + + def init_with_xml(self, elem, charset, include_id=False): + self.id = elem.find('id').text.encode(charset) + self.name = elem.find('name').text.encode(charset) + if elem.find('backoffice_info_text') is not None: + self.backoffice_info_text = elem.find('backoffice_info_text').text.encode(charset) + + self.items = [] + for item in elem.find('items'): + item_type = item.attrib['type'] + self.append_item(item_type) + item_o = self.items[-1] + item_o.parent = self + item_o.init_with_xml(item, charset, include_id=include_id) + + self.triggers = [] + for trigger in elem.find('triggers'): + trigger_type = trigger.attrib['type'] + self.append_trigger(trigger_type) + trigger_o = self.triggers[-1] + trigger_o.parent = self + trigger_o.init_with_xml(trigger, charset, include_id=include_id) + class WorkflowStatus(object): id = None @@ -645,31 +987,6 @@ class WorkflowStatus(object): return item raise KeyError() - def perform_items(self, formdata, depth=20): - if depth == 0: # prevents infinite loops - return - url = None - old_status = formdata.status - for item in self.items: - try: - url = item.perform(formdata) or url - except AbortActionException: - break - if formdata.status != old_status: - break - if formdata.status != old_status: - if not formdata.evolution: - formdata.evolution = [] - evo = Evolution() - evo.time = time.localtime() - evo.status = formdata.status - formdata.evolution.append(evo) - formdata.store() - # performs the items of the new status - wf_status = formdata.get_status() - url = wf_status.perform_items(formdata, depth=depth-1) or url - return url - def get_action_form(self, filled, user): form = Form(enctype='multipart/form-data', use_tokens = False) for item in self.items: @@ -677,12 +994,25 @@ class WorkflowStatus(object): continue item.fill_form(form, filled, user) + for action in filled.formdef.workflow.get_global_actions_for_user(filled, user): + form.add_submit('button-action-%s' % action.id, action.name) + if form.get_widget('button-action-%s' % action.id): + form.get_widget('button-action-%s' % action.id).backoffice_info_text = action.backoffice_info_text + if form.widgets or form.submit_widgets: return form else: return None def handle_form(self, form, filled, user): + # check for global actions + for action in filled.formdef.workflow.get_global_actions_for_user(filled, user): + if 'button-action-%s' % action.id in get_request().form: + url = perform_items(action.items, filled) + if url: + return redirect(url) + return + evo = Evolution() evo.time = time.localtime() if user: @@ -822,13 +1152,16 @@ class WorkflowStatus(object): item_o.parent = self item_o.init_with_xml(item, charset, include_id=include_id) -class WorkflowStatusItem(object): + +class WorkflowStatusItem(XmlSerialisable): + node_name = 'item' description = 'XX' category = None # (key, label) id = None endpoint = True # means it's not possible to interact, and/or cause a status change waitpoint = False # means it's possible to wait (user interaction, or other event) + ok_in_global_action = True # means it can be used in a global action directory_name = None directory_class = None support_substitution_variables = False @@ -937,146 +1270,6 @@ class WorkflowStatusItem(object): label = self.render_as_line() return label - def export_to_xml(self, charset, include_id=False): - item = ET.Element('item') - item.attrib['type'] = self.key - for attribute in self.get_parameters(): - if hasattr(self, '%s_export_to_xml' % attribute): - getattr(self, '%s_export_to_xml' % attribute)(item, charset, - include_id=include_id) - continue - if hasattr(self, attribute) and getattr(self, attribute) is not None: - el = ET.SubElement(item, attribute) - val = getattr(self, attribute) - if type(val) is dict: - for k, v in val.items(): - ET.SubElement(el, k).text = unicode(v, charset, 'replace') - elif type(val) is list: - if attribute[-1] == 's': - atname = attribute[:-1] - else: - atname = 'item' - for v in val: - ET.SubElement(el, atname).text = unicode(str(v), charset, 'replace') - elif type(val) in (str, unicode): - if type(val) is unicode: - el.text = val - else: - el.text = unicode(val, charset, 'replace') - else: - el.text = str(val) - return item - - def init_with_xml(self, elem, charset, include_id=False): - for attribute in self.get_parameters(): - el = elem.find(attribute) - if hasattr(self, '%s_init_with_xml' % attribute): - getattr(self, '%s_init_with_xml' % attribute)(el, charset, - include_id=include_id) - continue - if el is None: - continue - if el.getchildren(): - if type(getattr(self, attribute)) is list: - v = [x.text.encode(charset) for x in el.getchildren()] - elif type(getattr(self, attribute)) is dict: - v = {} - for e in el.getchildren(): - v[e.tag] = e.text.encode(charset) - else: - # ??? - raise AssertionError - setattr(self, attribute, v) - else: - if el.text is None: - setattr(self, attribute, None) - elif el.text in ('False', 'True'): # bools - setattr(self, attribute, eval(el.text)) - elif type(getattr(self, attribute)) is int: - setattr(self, attribute, int(el.text.encode(charset))) - else: - setattr(self, attribute, el.text.encode(charset)) - - def _roles_export_to_xml(self, attribute, item, charset, include_id=False): - if not hasattr(self, attribute) or not getattr(self, attribute): - return - el = ET.SubElement(item, attribute) - for role_id in getattr(self, attribute): - if role_id is None: - continue - role_id = str(role_id) - if role_id.startswith('_') or role_id == 'logged-users': - role = unicode(role_id, charset) - else: - try: - role = unicode(Role.get(role_id).name, charset) - except KeyError: - role = unicode(role_id, charset) - sub = ET.SubElement(el, 'item') - sub.attrib['role_id'] = role_id - sub.text = role - - def _roles_init_with_xml(self, attribute, elem, charset, include_id=False): - if elem is None: - setattr(self, attribute, []) - else: - imported_roles = [] - for child in elem.getchildren(): - imported_roles.append(self._get_role_id_from_xml(child, - charset, include_id=include_id)) - setattr(self, attribute, imported_roles) - - def _role_export_to_xml(self, attribute, item, charset, include_id=False): - if not hasattr(self, attribute) or not getattr(self, attribute): - return - role_id = str(getattr(self, attribute)) - if role_id.startswith('_') or role_id == 'logged-users': - role = unicode(role_id, charset) - else: - try: - role = unicode(Role.get(role_id).name, charset) - except KeyError: - role = unicode(role_id, charset) - sub = ET.SubElement(item, attribute) - if include_id: - sub.attrib['role_id'] = role_id - sub.text = role - - def _get_role_id_from_xml(self, elem, charset, include_id=False): - if elem is None: - return None - - value = elem.text.encode(charset) - - # look for known static values - if value.startswith('_') or value == 'logged-users': - return value - - # if we import using id, only look at the role_id attribute - if include_id: - if not 'role_id' in elem.attrib: - return None - role_id = str(elem.attrib['role_id']) - if Role.has_key(role_id): - return role_id - else: - return None - - # if not using id, look up on the name - for role in Role.select(ignore_errors=True): - if role.name == value: - return role.id - - # and if there's no match, create a new role - role = Role() - role.name = value - role.store() - return role.id - - def _role_init_with_xml(self, attribute, elem, charset, include_id=False): - setattr(self, attribute, self._get_role_id_from_xml(elem, charset, - include_id=include_id)) - def by_export_to_xml(self, item, charset, include_id=False): self._roles_export_to_xml('by', item, charset, include_id=include_id) @@ -1153,6 +1346,15 @@ def get_role_translation_label(workflow, role_id): except KeyError: return +def render_list_of_roles(workflow, roles): + t = [] + for r in roles: + role_label = get_role_translation_label(workflow, r) + if role_label: + t.append(role_label) + return ', '.join(t) + + item_classes = [] def register_item_class(klass): @@ -1166,6 +1368,7 @@ class CommentableWorkflowStatusItem(WorkflowStatusItem): key = 'commentable' endpoint = False waitpoint = True + ok_in_global_action = False varname = None label = None @@ -1281,6 +1484,7 @@ class ChoiceWorkflowStatusItem(WorkflowStatusJumpItem): key = 'choice' endpoint = False waitpoint = True + ok_in_global_action = False label = None by = [] @@ -1333,6 +1537,7 @@ register_item_class(ChoiceWorkflowStatusItem) class JumpOnSubmitWorkflowStatusItem(WorkflowStatusJumpItem): description = N_('Change Status on Submit') key = 'jumponsubmit' + ok_in_global_action = False def render_as_line(self): if self.status: @@ -1574,6 +1779,7 @@ class DisplayMessageWorkflowStatusItem(WorkflowStatusItem): description = N_('Display message') key = 'displaymsg' support_substitution_variables = True + ok_in_global_action = False message = None @@ -1611,6 +1817,7 @@ register_item_class(DisplayMessageWorkflowStatusItem) class RedirectToStatusWorkflowStatusItem(WorkflowStatusItem): description = N_('Redirect to Status Page') key = 'redirectstatus' + ok_in_global_action = False backoffice = False @@ -1635,6 +1842,7 @@ class EditableWorkflowStatusItem(WorkflowStatusItem): key = 'editable' endpoint = False waitpoint = True + ok_in_global_action = False by = [] status = None -- 2.6.4