backoffice: add checkboxes to run global actions on many items at once (#7865)

This commit is contained in:
Frédéric Péters 2019-08-31 10:38:14 +02:00
parent a3c3ec6b21
commit 281b020996
9 changed files with 394 additions and 13 deletions

View File

@ -985,6 +985,115 @@ def test_backoffice_statistics(pub):
assert '<h2>Filters</h2>' in resp.body
assert 'End: 2013-01-01' in resp.body
def test_backoffice_multi_actions(pub):
create_superuser(pub)
create_environment(pub)
formdef = FormDef.get_by_urlname('form-title')
app = login(get_app(pub))
resp = app.get('/backoffice/management/form-title/')
assert not 'id="multi-actions"' in resp.body
workflow = Workflow.get_default_workflow()
workflow.id = '2'
action = workflow.add_global_action('FOOBAR')
jump = action.append_item('jump')
jump.status = 'finished'
trigger = action.triggers[0]
trigger.roles = ['whatever']
workflow.store()
formdef.workflow_id = workflow.id
formdef.store()
resp = app.get('/backoffice/management/form-title/')
assert not 'id="multi-actions"' in resp.body
trigger.roles = [x.id for x in Role.select() if x.name == 'foobar']
workflow.store()
resp = app.get('/backoffice/management/form-title/')
assert 'id="multi-actions"' in resp.body
ids = []
for checkbox in resp.forms[0].fields['select[]'][1:6]:
ids.append(checkbox._value)
checkbox.checked = True
resp = resp.forms[0].submit('button-action-1')
assert '?job=' in resp.location
resp = resp.follow()
assert 'Executing task &quot;FOOBAR&quot; on forms' in resp.body
assert '>completed<' in resp.body
for id in ids:
assert formdef.data_class().get(id).status == 'wf-finished'
draft_ids = [x.id for x in formdef.data_class().select() if x.status == 'draft']
resp = app.get('/backoffice/management/form-title/')
assert resp.forms[0].fields['select[]'][0]._value == '_all'
resp.forms[0].fields['select[]'][0].checked = True
resp = resp.forms[0].submit('button-action-1')
for formdata in formdef.data_class().select():
if formdata.id in draft_ids:
assert formdata.status == 'draft'
else:
assert formdata.status == 'wf-finished'
for formdata in formdef.data_class().select():
if formdata.status != 'draft':
formdata.jump_status('new')
formdata.store()
# action for other role
action2 = workflow.add_global_action('OTHER ACTION')
jump = action2.append_item('jump')
jump.status = 'accepted'
trigger = action2.triggers[0]
trigger.roles = ['whatever']
workflow.store()
resp = app.get('/backoffice/management/form-title/')
assert 'id="multi-actions"' in resp.body
assert not 'OTHER ACTION' in resp.body
# action for function
trigger.roles = ['_foobar']
workflow.store()
resp = app.get('/backoffice/management/form-title/')
assert 'id="multi-actions"' in resp.body
assert 'OTHER ACTION' not in resp.body
workflow.roles['_foobar'] = 'Foobar'
workflow.store()
resp = app.get('/backoffice/management/form-title/')
assert 'id="multi-actions"' in resp.body
assert 'OTHER ACTION' in resp.body
# alter some formdata to simulate dispatch action
stable_ids = []
for checkbox in resp.forms[0].fields['select[]'][1:6]:
formdata = formdef.data_class().get(checkbox._value)
formdata.workflow_roles = {'_foobar': formdef.workflow_roles['_receiver']}
formdata.store()
stable_ids.append(formdata.id)
resp = app.get('/backoffice/management/form-title/')
assert 'OTHER ACTION' in resp.body
resp.forms[0].fields['select[]'][0].checked = True # _all
resp = resp.forms[0].submit('button-action-2')
assert '?job=' in resp.location
resp = resp.follow()
assert 'Executing task &quot;OTHER ACTION&quot; on forms' in resp.body
# check only dispatched formdata have been moved by global action executed
# on all formdatas
for formdata in formdef.data_class().select():
if formdata.id in draft_ids:
assert formdata.status == 'draft'
elif formdata.id in stable_ids:
assert formdata.status == 'wf-accepted'
else:
assert formdata.status != 'wf-accepted'
def test_backoffice_statistics_status_filter(pub):
create_superuser(pub)
create_environment(pub)

View File

@ -1330,10 +1330,23 @@ class FormPage(Directory):
def listing_top_actions(self):
return ''
def get_multi_actions(self, user):
global_actions = self.formdef.workflow.get_global_manual_actions()
workflow_roles = self.formdef.workflow.roles or {}
mass_actions = []
for action_dict in global_actions:
action_dict['roles'] = [x for x in user.get_roles() if x in action_dict.get('roles') or []]
if action_dict['functions'] or action_dict['roles']:
mass_actions.append(action_dict)
return mass_actions
def _q_index(self):
self.check_access()
get_logger().info('backoffice - form %s - listing' % self.formdef.name)
if 'job' in get_request().form:
return self.job_multi()
fields = self.get_fields_from_query()
selected_filter = self.get_filter_from_query()
criterias = self.get_criterias_from_query()
@ -1354,10 +1367,31 @@ class FormPage(Directory):
if get_request().get_query():
qs = '?' + get_request().get_query()
multi_actions = self.get_multi_actions(get_request().user)
if not get_request().form.get('ajax') == 'true':
multi_form = Form(id='multi-actions')
for action in multi_actions:
attrs = {}
if action.get('functions'):
for function in action.get('functions'):
attrs['data-visible_for_%s' % function] = 'true'
else:
attrs['data-visible_for_all'] = 'true'
multi_form.add_submit('button-action-%s' % action['action'].id, action['action'].name, attrs=attrs)
if multi_form.is_submitted() and get_request().form.get('select[]'):
for action in multi_actions:
if multi_form.get_submit() == 'button-action-%s' % action['action'].id:
return self.submit_multi(
action,
selected_filter=selected_filter,
query=query,
criterias=criterias)
table = FormDefUI(self.formdef).listing(fields=fields,
selected_filter=selected_filter,
limit=int(limit), offset=int(offset), query=query,
order_by=order_by, criterias=criterias)
order_by=order_by, criterias=criterias,
include_checkboxes=bool(multi_actions))
if get_response().status_code == 302:
# catch early redirect
return table
@ -1373,7 +1407,11 @@ class FormPage(Directory):
r += get_session().display_message()
r += self.listing_top_actions()
r += htmltext('</div>')
r += table
if multi_actions:
multi_form.widgets.append(HtmlWidget(table))
r += multi_form.render()
else:
r += table
get_response().filter['sidebar'] = self.get_formdata_sidebar(qs) + \
self.get_fields_sidebar(selected_filter, fields, limit=limit,
@ -1382,6 +1420,64 @@ class FormPage(Directory):
return r.getvalue()
def submit_multi(self, action, selected_filter, query, criterias):
class ActionJob(object):
def __init__(self, formdef, query_string, action, item_ids):
self.formdef = formdef
self.query_string = query_string
self.action = action
self.item_ids = item_ids
self.user = get_request().user
def execute(self, job=None):
formdatas = self.formdef.data_class().get_ids(self.item_ids)
publisher = get_publisher()
for formdata in formdatas:
publisher.substitutions.reset()
publisher.substitutions.feed(publisher)
publisher.substitutions.feed(self.formdef)
publisher.substitutions.feed(formdata)
formdata.perform_global_action(self.action['action'].id, self.user)
item_ids = get_request().form['select[]']
if '_all' in item_ids:
item_ids = FormDefUI(self.formdef).get_listing_item_ids(
selected_filter, user=get_request().user, query=query,
criterias=criterias)
action_job = ActionJob(self.formdef, get_request().get_query(), action, item_ids)
job = get_response().add_after_job(
N_('Executing task "%s" on forms') % action['action'].name,
action_job.execute)
job.query_string = get_request().get_query()
job.store()
return redirect('./?job=%s' % job.id)
def job_multi(self):
try:
job = AfterJob.get(get_request().form.get('job'))
except KeyError:
return redirect('.')
html_top('management', title=_('Executing Task'))
r = TemplateIO(html=True)
r += get_session().display_message()
get_response().add_javascript(['jquery.js', 'afterjob.js'])
r += htmltext('<dl class="job-status">')
r += htmltext('<dt>')
r += _(job.label)
r += htmltext('</dt>')
r += htmltext('<dd>')
r += htmltext('<span class="afterjob" id="%s">') % job.id
r += _(job.status)
r += htmltext('</span>')
r += htmltext('</dd>')
r += htmltext('</dl>')
r += htmltext('<div class="done">')
r += htmltext('<a data-redirect-auto="true" href="./?%s">%s</a>') % (job.query_string, _('Back to Listing'))
r += htmltext('</div>')
return r.getvalue()
def csv_tuple_heading(self, fields):
heading_fields = [] # '#id', _('time'), _('userlabel'), _('status')]
for field in fields:

View File

@ -116,11 +116,15 @@ class CompatHTTPRequest(HTTPRequest):
params['charset'] = site_charset
if not self.form:
self.form = {}
for k, v in self.django_request.POST.items():
for k in self.django_request.POST:
if isinstance(k, unicode):
k = k.encode(site_charset)
if isinstance(v, unicode):
v = v.encode(site_charset)
if k.endswith('[]'):
v = [x.encode(site_charset) for x in self.django_request.POST.getlist(k)]
else:
v = self.django_request.POST[k]
if isinstance(v, unicode):
v = v.encode(site_charset)
self.form[k] = v
for k, upload_file in self.django_request.FILES.items():

View File

@ -480,6 +480,14 @@ class FormData(StorableObject):
url = perform_items(wf_status.items, self)
return url
def perform_global_action(self, action_id, user):
from wcs.workflows import perform_items
for action in self.formdef.workflow.get_global_actions_for_user(formdata=self, user=user):
if action.id != action_id:
continue
perform_items(action.items, self)
break
def get_workflow_messages(self, position='top'):
wf_status = self.get_status()
if not wf_status:

View File

@ -31,7 +31,8 @@ class FormDefUI(object):
def listing(self, fields, selected_filter='all', url_action=None,
items=None, offset=0, limit=0,
query=None, order_by=None, criterias=None):
query=None, order_by=None, criterias=None,
include_checkboxes=False):
partial_display = False
@ -68,6 +69,8 @@ class FormDefUI(object):
r += htmltext('<table id="listing" class="main compact">')
r += htmltext('<colgroup>')
if include_checkboxes:
r += htmltext('<col/>') # checkbox
r += htmltext('<col/>') # lock
r += htmltext('<col/>')
r += htmltext('<col/>')
@ -78,6 +81,10 @@ class FormDefUI(object):
using_postgresql = get_publisher().is_using_postgresql()
r += htmltext('<thead><tr>')
if include_checkboxes:
r += htmltext('<th class="select"><input type="checkbox" name="select[]" value="_all"/>')
r += htmltext(' <span id="info-all-rows">%s</span></th>') % _(
'Do note the selected action will run on all pages when this is checked.')
if self.formdef.workflow.criticality_levels and using_postgresql:
r += htmltext('<th style="width: 4ex;" data-field-sort-key="criticality_level"><span></span></th>')
else:
@ -105,7 +112,8 @@ class FormDefUI(object):
r += htmltext('</th>')
r += htmltext('</tr></thead>')
r += htmltext('<tbody>')
r += htmltext(self.tbody(fields, items, url_action))
r += htmltext(self.tbody(fields, items, url_action,
include_checkboxes=include_checkboxes))
r += htmltext('</tbody>')
r += htmltext('</table>')
@ -115,9 +123,7 @@ class FormDefUI(object):
return r.getvalue()
def get_listing_items(self, selected_filter='all', offset=None,
limit=None, query=None, order_by=None, user=None, criterias=None, anonymise=False):
user = user or get_request().user
def get_listing_item_ids(self, selected_filter='all', query=None, order_by=None, user=None, criterias=None, anonymise=False):
formdata_class = self.formdef.data_class()
if selected_filter == 'all':
item_ids = formdata_class.keys()
@ -161,6 +167,20 @@ class FormDefUI(object):
'concerned_roles', str(role)))
item_ids = list(set(item_ids).intersection(concerned_ids))
return item_ids
def get_listing_items(self, selected_filter='all', offset=None,
limit=None, query=None, order_by=None, user=None, criterias=None, anonymise=False):
user = user or get_request().user
formdata_class = self.formdef.data_class()
item_ids = self.get_listing_item_ids(
selected_filter=selected_filter,
query=query,
user=user,
criterias=criterias,
anonymise=anonymise)
if order_by and not hasattr(formdata_class, 'get_sorted_ids'):
# get_sorted_ids is only implemented in the SQL backend
order_by = None
@ -186,7 +206,7 @@ class FormDefUI(object):
return (items, total_count)
def tbody(self, fields=None, items=None, url_action=None):
def tbody(self, fields=None, items=None, url_action=None, include_checkboxes=False):
r = TemplateIO(html=True)
if url_action:
pass
@ -194,7 +214,8 @@ class FormDefUI(object):
else:
url_action = ''
root_url = get_publisher().get_root_url()
visited_objects = get_publisher().get_visited_objects(exclude_user=get_session().user)
user = get_request().user
visited_objects = get_publisher().get_visited_objects(exclude_user=user.id)
include_criticality_level = bool(self.formdef.workflow.criticality_levels)
for i, filled in enumerate(items):
classes = ['status-%s-%s' % (filled.formdef.workflow.id, filled.status)]
@ -224,6 +245,17 @@ class FormDefUI(object):
if filled.anonymised:
data += ' data-anonymised="true"'
r += htmltext('<tr class="%s"%s>' % (' '.join(classes), data))
if include_checkboxes:
r += htmltext('<td class="select"><input type="checkbox" name="select[]" ')
r += htmltext('value="%s"') % filled.id
workflow_roles = {}
workflow_roles.update(self.formdef.workflow_roles)
if filled.workflow_roles:
workflow_roles.update(filled.workflow_roles)
for function_key, function_value in workflow_roles.items():
if function_value in user.get_roles():
r += htmltext(' data-is-%s="true" ' % function_key)
r += htmltext('/></td>')
if include_criticality_level:
r += htmltext('<td %s></td>' % style) # criticality_level
else:

View File

@ -390,14 +390,41 @@ ul.biglist, table#listing {
transition: opacity 500ms ease-out;
}
table#listing {
position: relative;
}
#listing.activity ul.biglist,
table#listing.activity {
opacity: 0.5;
}
#listing tbody tr td {
cursor: pointer;
}
#listing tbody tr td.select {
cursor: default;
}
#listing thead tr th.select span {
display: none;
position: absolute;
left: 0px;
top: -17px;
background: white;
padding: 5px 1rem;
border: 1px solid #666;
border-radius: 5px;
cursor: default;
}
#listing thead tr th.select input:checked + span {
display: block;
}
#listing tbody tr:hover td {
background: #dde;
cursor: pointer;
}
#listing.main th,
@ -413,6 +440,15 @@ table.main th.nosort {
cursor: inherit;
}
table#listing .select {
width: 1rem;
text-align: center;
}
table#listing .select input {
margin: 2px 0;
}
table.main th[data-field-sort-key] span:after {
padding-left: 1ex;
content: "\f0dc"; /* sort */
@ -1807,3 +1843,15 @@ a.button.button-paragraph p:last-child {
a.button.button-paragraph:hover p {
color: white;
}
#main-content form#multi-actions {
padding: 0;
}
#main-content form#multi-actions div.buttons {
padding: 0 0.5rem;
}
table#listing tr.checked td {
background: #ddf;
}

View File

@ -8,6 +8,9 @@ function updateStatus()
function () {
var current_text = $(this).text();
if (current_text.indexOf('completed') == 0) {
if ($('div.done a[data-redirect-auto]').length) {
window.location = $('div.done a[data-redirect-auto]').attr('href');
}
$(this).text(current_text.substr(10, current_text.length));
$(this).addClass('activity-done');
$('.afterjob-running').hide();

View File

@ -12,6 +12,14 @@ function prepare_row_links() {
event.stopPropagation();
});
$('#listing tbody tr').on('mouseup', function(event) {
var $target = $(event.target);
if ($target.is('input[type=checkbox]')) {
return false;
}
if ($target.is('td.select')) {
$target.find('input').click();
return false;
}
var data_link = $(this).data('link');
if (data_link) {
if (data_link.indexOf('http://') == -1 && data_link.indexOf('https://') == -1) {
@ -25,6 +33,60 @@ function prepare_row_links() {
return false;
}
});
if ($('#listing tbody input[type=checkbox]:checked').length == 0) {
$('form#multi-actions div.buttons').hide();
} else {
$('form#multi-actions div.buttons').show();
}
$('#listing tbody input[type=checkbox]').each(function() {
if ($(this).is(':checked')) {
$(this).parents('tr').addClass('checked');
} else {
$(this).parents('tr').removeClass('checked');
}
});
$('#listing input[type=checkbox]').on('change', function() {
if ($(this).is(':checked')) {
if ($(this).is('[value=_all]')) {
$(this).parents('table').find('tbody td.select input').prop('checked', true);
$(this).parents('table').find('tbody tr').addClass('checked');
} else {
$(this).parents('tr').addClass('checked');
}
} else {
if ($(this).is('[value=_all]')) {
$(this).parents('table').find('tbody td.select input').prop('checked', false);
$(this).parents('table').find('tbody tr').removeClass('checked');
} else {
$(this).parents('tr').removeClass('checked');
$('#listing input[type=checkbox][value=_all]').prop('checked', false);
}
}
if ($('#listing tbody input[type=checkbox]:checked').length == 0) {
$('form#multi-actions div.buttons').hide();
return;
} else {
$('form#multi-actions div.buttons button').each(function(idx, elem) {
var visible = false;
for (var key in $(elem).first().data()) {
if (key == 'visible_for_all') {
visible = true;
break;
}
if ($('input[type=checkbox][data-is-' + key.substr(12) + ']:checked').length) {
visible = true;
break;
}
}
if (visible) {
$(elem).parents('div.widget').show();
} else {
$(elem).parents('div.widget').hide();
}
});
$('form#multi-actions div.buttons').show();
}
});
}
function prepare_column_headers() {
@ -61,6 +123,10 @@ function prepare_column_headers() {
}
function autorefresh_table() {
if ($('#multi-actions input:checked').length) {
// disable autorefresh when multiselection is enabled
return;
}
$(document).trigger('backoffice-filter-change',
{qs: $('form#listing-settings').serialize(), auto: true});
}

View File

@ -413,6 +413,21 @@ class Workflow(StorableObject):
self.global_actions.append(action)
return action
def get_global_manual_actions(self):
actions = []
for action in self.global_actions or []:
roles = []
for trigger in action.triggers or []:
if not isinstance(trigger, WorkflowGlobalActionManualTrigger):
continue
roles.extend(trigger.roles or [])
functions = [x for x in roles if x in self.roles]
roles = [x for x in roles if x not in self.roles]
if functions or roles:
actions.append({'action': action, 'roles': roles, 'functions': functions})
return actions
def get_global_actions_for_user(self, formdata, user):
if not user:
return []