Merge branch 'MDL-60474-master' of git://github.com/damyon/moodle

This commit is contained in:
Jun Pataleta 2018-12-19 10:14:13 +08:00
commit e63084ec1b
16 changed files with 525 additions and 157 deletions

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
define(["jquery","core/notification","core/ajax","core/templates"],function(a,b,c,d){var e=function(b){this._regionSelector=b,this._region=a(b),this._userCache={},a(document).on("user-changed",this._refreshUserInfo.bind(this))};return e.prototype._regionSelector=null,e.prototype._userCache=null,e.prototype._region=null,e.prototype._lastUserId=0,e.prototype._getAssignmentId=function(){return this._region.attr("data-assignmentid")},e.prototype._refreshUserInfo=function(e,f){var g=a.Deferred();this._lastUserId!=f&&(this._lastUserId=f,d.render("mod_assign/loading",{}).done(function(e,h){if(this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,e,h),this._region.fadeIn("fast")}.bind(this)),f<0)return void d.render("mod_assign/grading_navigation_no_users",{}).done(function(a,b){this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,a,b),this._region.fadeIn("fast")}.bind(this))}.bind(this)).fail(b.exception);if("undefined"!=typeof this._userCache[f])g.resolve(this._userCache[f]);else{var i=this._getAssignmentId(),j=c.call([{methodname:"mod_assign_get_participant",args:{userid:f,assignid:i,embeduser:!0}}]);j[0].done(function(a){a.hasOwnProperty("id")?(this._userCache[f]=a,g.resolve(this._userCache[f])):g.reject("No users")}.bind(this)).fail(b.exception)}g.done(function(c){var e=a("[data-showuseridentity]").data("showuseridentity").split(","),f=[];c.courseid=a('[data-region="grading-navigation-panel"]').attr("data-courseid"),c.user&&(a.each(e,function(a,b){"undefined"!=typeof c.user[b]&&""!==c.user[b]&&(c.hasidentity=!0,f.push(c.user[b]))}),c.identity=f.join(", "),c.user.profileimageurl&&(c.profileimageurl=c.user.profileimageurl)),d.render("mod_assign/grading_navigation_user_summary",c).done(function(a,b){this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,a,b),this._region.fadeIn("fast")}.bind(this))}.bind(this)).fail(b.exception)}.bind(this)).fail(function(){d.render("mod_assign/grading_navigation_no_users",{}).done(function(a,b){this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,a,b),this._region.fadeIn("fast")}.bind(this))}.bind(this)).fail(b.exception)}.bind(this))}.bind(this)).fail(b.exception))},e});
define(["jquery","core/notification","core/ajax","core/templates"],function(a,b,c,d){var e=function(b){this._regionSelector=b,this._region=a(b),this._userCache={},a(document).on("user-changed",this._refreshUserInfo.bind(this))};return e.prototype._regionSelector=null,e.prototype._userCache=null,e.prototype._region=null,e.prototype._lastUserId=0,e.prototype._getAssignmentId=function(){return this._region.attr("data-assignmentid")},e.prototype._refreshUserInfo=function(e,f){var g=a.Deferred();this._lastUserId!=f&&(this._lastUserId=f,d.render("mod_assign/loading",{}).done(function(e,h){if(this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,e,h),this._region.fadeIn("fast")}.bind(this)),f<0)return void d.render("mod_assign/grading_navigation_no_users",{}).done(function(a,b){f==this._lastUserId&&this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,a,b),this._region.fadeIn("fast")}.bind(this))}.bind(this)).fail(b.exception);if("undefined"!=typeof this._userCache[f])g.resolve(this._userCache[f]);else{var i=this._getAssignmentId(),j=c.call([{methodname:"mod_assign_get_participant",args:{userid:f,assignid:i,embeduser:!0}}]);j[0].done(function(a){a.hasOwnProperty("id")?(this._userCache[f]=a,g.resolve(this._userCache[f])):g.reject("No users")}.bind(this)).fail(b.exception)}g.done(function(c){var e=a("[data-showuseridentity]").data("showuseridentity").split(","),g=[];c.courseid=a('[data-region="grading-navigation-panel"]').attr("data-courseid"),c.user&&(a.each(e,function(a,b){"undefined"!=typeof c.user[b]&&""!==c.user[b]&&(c.hasidentity=!0,g.push(c.user[b]))}),c.identity=g.join(", "),c.user.profileimageurl&&(c.profileimageurl=c.user.profileimageurl)),d.render("mod_assign/grading_navigation_user_summary",c).done(function(a,b){f==this._lastUserId&&this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,a,b),this._region.fadeIn("fast")}.bind(this))}.bind(this)).fail(b.exception)}.bind(this)).fail(function(){d.render("mod_assign/grading_navigation_no_users",{}).done(function(a,b){this._region.fadeOut("fast",function(){d.replaceNodeContents(this._region,a,b),this._region.fadeIn("fast")}.bind(this))}.bind(this)).fail(b.exception)}.bind(this))}.bind(this)).fail(b.exception))},e});

View File

@ -1 +1 @@
define(["core/ajax","jquery","core/templates"],function(a,b,c){return{processResults:function(a,b){return b},transport:function(d,e,f,g){var h=b(d).attr("data-assignmentid"),i=b(d).attr("data-groupid"),j=b('[data-region="configure-filters"] input[type="checkbox"]'),k=[];j.each(function(a,c){k[b(c).attr("name")]=b(c).prop("checked")}),a.call([{methodname:"mod_assign_list_participants",args:{assignid:h,groupid:i,filter:e,limit:30,includeenrolments:!1}}])[0].then(function(a){var d=[],e=b("[data-showuseridentity]").data("showuseridentity").split(",");return b.each(a,function(a,f){var g=f,h=[],i=!0;k.filter_submitted&&!f.submitted&&(i=!1),k.filter_notsubmitted&&f.submitted&&(i=!1),k.filter_requiregrading&&!f.requiregrading&&(i=!1),k.filter_grantedextension&&!f.grantedextension&&(i=!1),i&&(b.each(e,function(a,b){"undefined"!=typeof f[b]&&""!==f[b]&&(g.hasidentity=!0,h.push(f[b]))}),g.identity=h.join(", "),d.push(c.render("mod_assign/list_participant_user_summary",g).then(function(a){return{value:f.id,label:a}})))}),b.when.apply(b,d)}).then(function(){var a=[];arguments[0]&&(a=Array.prototype.slice.call(arguments)),f(a)})["catch"](g)}}});
define(["core/ajax","jquery","core/templates"],function(a,b,c){return{processResults:function(a,b){return b},transport:function(d,e,f,g){var h=b(d).attr("data-assignmentid"),i=b(d).attr("data-groupid"),j=b('[data-region="configure-filters"] input[type="checkbox"]'),k=[];j.each(function(a,c){k[b(c).attr("name")]=b(c).prop("checked")}),a.call([{methodname:"mod_assign_list_participants",args:{assignid:h,groupid:i,filter:e,limit:30,includeenrolments:!1,tablesort:!0}}])[0].then(function(a){var d=[],e=b("[data-showuseridentity]").data("showuseridentity").split(",");return b.each(a,function(a,f){var g=f,h=[],i=!0;k.filter_submitted&&!f.submitted&&(i=!1),k.filter_notsubmitted&&f.submitted&&(i=!1),k.filter_requiregrading&&!f.requiregrading&&(i=!1),k.filter_grantedextension&&!f.grantedextension&&(i=!1),i&&(b.each(e,function(a,b){"undefined"!=typeof f[b]&&""!==f[b]&&(g.hasidentity=!0,h.push(f[b]))}),g.identity=h.join(", "),d.push(c.render("mod_assign/list_participant_user_summary",g).then(function(a){return{value:f.id,label:a}})))}),b.when.apply(b,d)}).then(function(){var a=[];arguments[0]&&(a=Array.prototype.slice.call(arguments)),f(a)})["catch"](g)}}});

View File

@ -38,10 +38,13 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
this._filters = [];
this._users = [];
this._filteredUsers = [];
this._lastXofYUpdate = 0;
this._firstLoadUsers = true;
// Get the current user list from a webservice.
this._loadAllUsers();
// We do not allow navigation while ajax requests are pending.
// Attach listeners to the select and arrow buttons.
this._region.find('[data-action="previous-user"]').on('click', this._handlePreviousUser.bind(this));
@ -56,7 +59,7 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
var toggleLink = this._region.find('[data-region="user-filters"]');
var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
configPanel.on('change', '[type="checkbox"]', this._filterChanged.bind(this));
configPanel.on('change', 'select', this._filterChanged.bind(this));
var userid = $('[data-region="grading-navigation-panel"]').data('first-userid');
if (userid) {
@ -68,8 +71,6 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
}
).fail(notification.exception);
// We do not allow navigation while ajax requests are pending.
$(document).bind("start-loading-user", function() {
this._isLoading = true;
}.bind(this));
@ -93,23 +94,44 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
/** @type {JQuery} JQuery node for the page region containing the user navigation. */
GradingNavigation.prototype._region = null;
/** @type {String} Last active filters */
GradingNavigation.prototype._lastFilters = '';
/**
* Load the list of all users for this assignment.
*
* @private
* @method _loadAllUsers
* @return {Boolean} True if the user list was fetched.
*/
GradingNavigation.prototype._loadAllUsers = function() {
var select = this._region.find('[data-action=change-user]');
var assignmentid = select.attr('data-assignmentid');
var groupid = select.attr('data-groupid');
var filterPanel = this._region.find('[data-region="configure-filters"]');
var filter = filterPanel.find('select[name="filter"]').val();
var workflowFilter = filterPanel.find('select[name="workflowfilter"]');
if (workflowFilter) {
filter += ',' + workflowFilter.val();
}
var markerFilter = filterPanel.find('select[name="markerfilter"]');
if (markerFilter) {
filter += ',' + markerFilter.val();
}
if (this._lastFilters == filter) {
return false;
}
this._lastFilters = filter;
ajax.call([{
methodname: 'mod_assign_list_participants',
args: {assignid: assignmentid, groupid: groupid, filter: '', onlyids: true},
args: {assignid: assignmentid, groupid: groupid, filter: '', onlyids: true, tablesort: true},
done: this._usersLoaded.bind(this),
fail: notification.exception
}]);
return true;
};
/**
@ -120,13 +142,14 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
* @param {Array} users
*/
GradingNavigation.prototype._usersLoaded = function(users) {
this._firstLoadUsers = false;
this._filteredUsers = this._users = users;
if (this._users.length) {
// Position the configure filters panel under the link that expands it.
var toggleLink = this._region.find('[data-region="user-filters"]');
var configPanel = $(document.getElementById(toggleLink.attr('aria-controls')));
configPanel.find('[type="checkbox"]').trigger('change');
configPanel.find('select[name="filter"]').trigger('change');
} else {
this._selectNoUser();
}
@ -153,6 +176,48 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
}
};
/**
* Close the configure filters panel if a click is detected outside of it.
*
* @private
* @method _updateFilterPreference
* @param {Number} userId The current user id.
* @param {Array} filterList The list of current filter values.
* @param {Array} preferenceNames The names of the preferences to update
* @return {Promise} Resolved when all the preferences are updated.
*/
GradingNavigation.prototype._updateFilterPreferences = function(userId, filterList, preferenceNames) {
var preferences = [],
i = 0;
if (filterList.length == 0 || this._firstLoadUsers) {
// Nothing to update.
var deferred = $.Deferred();
deferred.resolve();
return deferred;
}
// General filter.
// Set the user preferences to the current filters.
for (i = 0; i < filterList.length; i++) {
var newValue = filterList[i];
if (newValue == 'none') {
newValue = '';
}
preferences.push({
userid: userId,
name: preferenceNames[i],
value: newValue
});
}
return ajax.call([{
methodname: 'core_user_set_user_preferences',
args: {
preferences: preferences
}
}])[0];
};
/**
* Turn a filter on or off.
*
@ -160,28 +225,20 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
* @method _filterChanged
* @param {Event} event
*/
GradingNavigation.prototype._filterChanged = function(event) {
var name = $(event.target).attr('name');
var key = name.split('_').pop();
var enabled = $(event.target).prop('checked');
GradingNavigation.prototype._filterChanged = function() {
// There are 3 types of filter right now.
var filterPanel = this._region.find('[data-region="configure-filters"]');
var filters = filterPanel.find('select');
if (enabled) {
if (this._filters.indexOf(key) == -1) {
this._filters[this._filters.length] = key;
}
} else {
var index = this._filters.indexOf(key);
if (index != -1) {
this._filters.splice(index, 1);
}
}
this._filters = [];
filters.each(function(idx, ele) {
this._filters.push($(ele).val());
}.bind(this));
// Update the active filter string.
var filterlist = [];
this._region.find('[data-region="configure-filters"]').find('[type="checkbox"]').each(function(idx, ele) {
if ($(ele).prop('checked')) {
filterlist[filterlist.length] = $(ele).closest('label').text();
}
filterPanel.find('option:checked').each(function(idx, ele) {
filterlist[filterlist.length] = $(ele).text();
});
if (filterlist.length) {
this._region.find('[data-region="user-filters"] span').text(filterlist.join(', '));
@ -191,50 +248,30 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
}.bind(this)).fail(notification.exception);
}
// Filter the options in the select box that do not match the current filters.
var select = this._region.find('[data-action=change-user]');
var userid = select.attr('data-selected');
var foundIndex = 0;
var currentUserID = select.data('currentuserid');
var preferenceNames = ['assign_filter', 'assign_workflowfilter', 'assign_markerfilter'];
this._updateFilterPreferences(currentUserID, this._filters, preferenceNames).done(function() {
// Reload the list of users to apply the new filters.
if (!this._loadAllUsers()) {
var userid = parseInt(select.attr('data-selected'));
var foundIndex = 0;
// Search the returned users for the current selection.
$.each(this._filteredUsers, function(index, user) {
if (userid == user.id) {
foundIndex = index;
}
});
this._filteredUsers = [];
$.each(this._users, function(index, user) {
var show = true;
$.each(this._filters, function(filterindex, filter) {
if (filter == "submitted") {
if (user.submitted == "0") {
show = false;
}
} else if (filter == "notsubmitted") {
if (user.submitted == "1") {
show = false;
}
} else if (filter == "requiregrading") {
if (user.requiregrading == "0") {
show = false;
}
} else if (filter == "grantedextension") {
if (user.grantedextension == "0") {
show = false;
}
if (this._filteredUsers.length) {
this._selectUserById(this._filteredUsers[foundIndex].id);
} else {
this._selectNoUser();
}
});
if (show) {
this._filteredUsers[this._filteredUsers.length] = user;
if (userid == user.id) {
foundIndex = (this._filteredUsers.length - 1);
}
}
}.bind(this));
if (this._filteredUsers.length) {
this._selectUserById(this._filteredUsers[foundIndex].id);
} else {
this._selectNoUser();
}
this._triggerNextUserEvent();
}.bind(this)).fail(notification.exception);
this._refreshCount();
};
/**
@ -396,6 +433,28 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
}
};
/**
* Set count string. This method only sets the value for the last time it was ever called to deal
* with promises that return in a non-predictable order.
*
* @private
* @method _setCountString
* @param {Number} x
* @param {Number} y
*/
GradingNavigation.prototype._setCountString = function(x, y) {
var updateNumber = 0;
this._lastXofYUpdate++;
updateNumber = this._lastXofYUpdate;
var param = {x: x, y: y};
str.get_string('xofy', 'mod_assign', param).done(function(s) {
if (updateNumber == this._lastXofYUpdate) {
this._region.find('[data-region="user-count-summary"]').text(s);
}
}.bind(this)).fail(notification.exception);
};
/**
* Rebuild the x of y string.
*
@ -423,11 +482,19 @@ define(['jquery', 'core/notification', 'core/str', 'core/form-autocomplete',
if (count) {
currentIndex += 1;
}
var param = {x: currentIndex, y: count};
str.get_string('xofy', 'mod_assign', param).done(function(s) {
this._region.find('[data-region="user-count-summary"]').text(s);
}.bind(this)).fail(notification.exception);
this._setCountString(currentIndex, count);
// Update window URL
if (currentIndex > 0) {
var url = new URL(window.location);
if (parseInt(url.searchParams.get('blindid')) > 0) {
var newid = this._filteredUsers[currentIndex - 1].recordid;
url.searchParams.set('blindid', newid);
} else {
url.searchParams.set('userid', userid);
}
// We do this so a browser refresh will return to the same user.
window.history.replaceState({}, "", url);
}
}
};

View File

@ -48,7 +48,7 @@ define(['jquery', 'core/notification', 'core/ajax', 'core/templates'], function(
/** @type {JQuery} JQuery node for the page region containing the user navigation. */
UserInfo.prototype._region = null;
/** @type {Integer} Remember the last user id to prevent unnessecary reloads. */
/** @type {Integer} Remember the last user id to prevent unnecessary reloads. */
UserInfo.prototype._lastUserId = 0;
/**
@ -90,11 +90,13 @@ define(['jquery', 'core/notification', 'core/ajax', 'core/templates'], function(
if (userid < 0) {
// Render the template.
templates.render('mod_assign/grading_navigation_no_users', {}).done(function(html, js) {
// Update the page.
this._region.fadeOut("fast", function() {
templates.replaceNodeContents(this._region, html, js);
this._region.fadeIn("fast");
}.bind(this));
if (userid == this._lastUserId) {
// Update the page.
this._region.fadeOut("fast", function() {
templates.replaceNodeContents(this._region, html, js);
this._region.fadeIn("fast");
}.bind(this));
}
}.bind(this)).fail(notification.exception);
return;
}
@ -147,10 +149,12 @@ define(['jquery', 'core/notification', 'core/ajax', 'core/templates'], function(
templates.render('mod_assign/grading_navigation_user_summary', context).done(function(html, js) {
// Update the page.
this._region.fadeOut("fast", function() {
templates.replaceNodeContents(this._region, html, js);
this._region.fadeIn("fast");
}.bind(this));
if (userid == this._lastUserId) {
this._region.fadeOut("fast", function() {
templates.replaceNodeContents(this._region, html, js);
this._region.fadeIn("fast");
}.bind(this));
}
}.bind(this)).fail(notification.exception);
}.bind(this)).fail(function() {
// Render the template.

View File

@ -59,7 +59,14 @@ define(['core/ajax', 'jquery', 'core/templates'], function(ajax, $, templates) {
ajax.call([{
methodname: 'mod_assign_list_participants',
args: {assignid: assignmentid, groupid: groupid, filter: query, limit: 30, includeenrolments: false}
args: {
assignid: assignmentid,
groupid: groupid,
filter: query,
limit: 30,
includeenrolments: false,
tablesort: true
}
}])[0].then(function(results) {
var promises = [];
var identityfields = $('[data-showuseridentity]').data('showuseridentity').split(',');

View File

@ -67,6 +67,9 @@ class grading_app implements templatable, renderable {
$this->userid = $userid;
$this->groupid = $groupid;
$this->assignment = $assignment;
user_preference_allow_ajax_update('assign_filter', PARAM_ALPHA);
user_preference_allow_ajax_update('assign_workflowfilter', PARAM_ALPHA);
user_preference_allow_ajax_update('assign_markerfilter', PARAM_ALPHANUMEXT);
$this->participants = $assignment->list_participants_with_filter_status_and_group($groupid);
if (!$this->userid && count($this->participants)) {
$this->userid = reset($this->participants)->id;
@ -80,7 +83,7 @@ class grading_app implements templatable, renderable {
* @return stdClass - Flat list of exported data.
*/
public function export_for_template(renderer_base $output) {
global $CFG;
global $CFG, $USER;
$export = new stdClass();
$export->userid = $this->userid;
@ -91,6 +94,12 @@ class grading_app implements templatable, renderable {
$export->name = $this->assignment->get_context()->get_context_name();
$export->courseid = $this->assignment->get_course()->id;
$export->participants = array();
$export->filters = $this->assignment->get_filters();
$export->markingworkflowfilters = $this->assignment->get_marking_workflow_filters(true);
$export->hasmarkingworkflow = count($export->markingworkflowfilters) > 0;
$export->markingallocationfilters = $this->assignment->get_marking_allocation_filters(true);
$export->hasmarkingallocation = count($export->markingallocationfilters) > 0;
$num = 1;
foreach ($this->participants as $idx => $record) {
$user = new stdClass();
@ -160,6 +169,7 @@ class grading_app implements templatable, renderable {
$export->larrow = $output->larrow();
// List of identity fields to display (the user info will not contain any fields the user cannot view anyway).
$export->showuseridentity = $CFG->showuseridentity;
$export->currentuserid = $USER->id;
return $export;
}

View File

@ -2560,7 +2560,9 @@ class mod_assign_external extends external_api {
'limit' => new external_value(PARAM_INT, 'maximum number of records to return', VALUE_DEFAULT, 0),
'onlyids' => new external_value(PARAM_BOOL, 'Do not return all user fields', VALUE_DEFAULT, false),
'includeenrolments' => new external_value(PARAM_BOOL, 'Do return courses where the user is enrolled',
VALUE_DEFAULT, true)
VALUE_DEFAULT, true),
'tablesort' => new external_value(PARAM_BOOL, 'Apply current user table sorting preferences.',
VALUE_DEFAULT, false)
)
);
}
@ -2575,11 +2577,13 @@ class mod_assign_external extends external_api {
* @param int $limit Maximum number of records to return
* @param bool $onlyids Only return user ids.
* @param bool $includeenrolments Return courses where the user is enrolled.
* @param bool $tablesort Apply current user table sorting params from the grading table.
* @return array of warnings and status result
* @since Moodle 3.1
* @throws moodle_exception
*/
public static function list_participants($assignid, $groupid, $filter, $skip, $limit, $onlyids, $includeenrolments) {
public static function list_participants($assignid, $groupid, $filter, $skip,
$limit, $onlyids, $includeenrolments, $tablesort) {
global $DB, $CFG;
require_once($CFG->dirroot . "/mod/assign/locallib.php");
require_once($CFG->dirroot . "/user/lib.php");
@ -2592,7 +2596,8 @@ class mod_assign_external extends external_api {
'skip' => $skip,
'limit' => $limit,
'onlyids' => $onlyids,
'includeenrolments' => $includeenrolments
'includeenrolments' => $includeenrolments,
'tablesort' => $tablesort
));
$warnings = array();
@ -2604,7 +2609,7 @@ class mod_assign_external extends external_api {
$participants = array();
if (groups_group_visible($params['groupid'], $course, $cm)) {
$participants = $assign->list_participants_with_filter_status_and_group($params['groupid']);
$participants = $assign->list_participants_with_filter_status_and_group($params['groupid'], $params['tablesort']);
}
$userfields = user_get_default_fields();
@ -2658,6 +2663,11 @@ class mod_assign_external extends external_api {
if (!empty($record->groupname)) {
$userdetails['groupname'] = $record->groupname;
}
// Unique id is required for blind marking.
$userdetails['recordid'] = -1;
if (!empty($record->recordid)) {
$userdetails['recordid'] = $record->recordid;
}
$result[] = $userdetails;
}
@ -2689,6 +2699,7 @@ class mod_assign_external extends external_api {
$userdesc->keys['profileimageurl']->required = VALUE_OPTIONAL;
$userdesc->keys['email']->desc = 'Email address';
$userdesc->keys['idnumber']->desc = 'The idnumber of the user';
$userdesc->keys['recordid'] = new external_value(PARAM_INT, 'record id');
// Define other keys.
$otherkeys = [

View File

@ -2052,3 +2052,29 @@ function mod_assign_core_calendar_event_timestart_updated(\calendar_event $event
$event->trigger();
}
}
/**
* Return a list of all the user preferences used by mod_assign.
*
* @return array
*/
function mod_assign_user_preferences() {
$preferences = array();
$preferences['assign_filter'] = array(
'type' => PARAM_ALPHA,
'null' => NULL_NOT_ALLOWED,
'default' => ''
);
$preferences['assign_workflowfilter'] = array(
'type' => PARAM_ALPHA,
'null' => NULL_NOT_ALLOWED,
'default' => ''
);
$preferences['assign_markerfilter'] = array(
'type' => PARAM_ALPHANUMEXT,
'null' => NULL_NOT_ALLOWED,
'default' => ''
);
return $preferences;
}

View File

@ -33,11 +33,12 @@ define('ASSIGN_SUBMISSION_STATUS_DRAFT', 'draft');
define('ASSIGN_SUBMISSION_STATUS_SUBMITTED', 'submitted');
// Search filters for grading page.
define('ASSIGN_FILTER_NONE', 'none');
define('ASSIGN_FILTER_SUBMITTED', 'submitted');
define('ASSIGN_FILTER_NOT_SUBMITTED', 'notsubmitted');
define('ASSIGN_FILTER_SINGLE_USER', 'singleuser');
define('ASSIGN_FILTER_REQUIRE_GRADING', 'require_grading');
define('ASSIGN_FILTER_GRANTED_EXTENSION', 'granted_extension');
define('ASSIGN_FILTER_REQUIRE_GRADING', 'requiregrading');
define('ASSIGN_FILTER_GRANTED_EXTENSION', 'grantedextension');
// Marker filter for grading page.
define('ASSIGN_MARKER_FILTER_NO_MARKER', -1);
@ -1954,11 +1955,12 @@ class assign {
* If this is a group assignment, group info is also returned.
*
* @param int $currentgroup
* @param boolean $tablesort Apply current user table sorting preferences.
* @return array List of user records with extra fields 'submitted', 'notsubmitted', 'requiregrading', 'grantedextension',
* 'groupid', 'groupname'
*/
public function list_participants_with_filter_status_and_group($currentgroup) {
$participants = $this->list_participants($currentgroup, false);
public function list_participants_with_filter_status_and_group($currentgroup, $tablesort = false) {
$participants = $this->list_participants($currentgroup, false, $tablesort);
if (empty($participants)) {
return $participants;
@ -1967,17 +1969,56 @@ class assign {
}
}
/**
* Return a valid order by segment for list_participants that matches
* the sorting of the current grading table. Not every field is supported,
* we are only concerned with a list of users so we can't search on anything
* that is not part of the user information (like grading statud or last modified stuff).
*
* @return string Order by clause for list_participants
*/
private function get_grading_sort_sql() {
$usersort = flexible_table::get_sort_for_table('mod_assign_grading');
$extrauserfields = get_extra_user_fields($this->get_context());
$userfields = explode(',', user_picture::fields('', $extrauserfields));
$orderfields = explode(',', $usersort);
$validlist = [];
foreach ($orderfields as $orderfield) {
$orderfield = trim($orderfield);
foreach ($userfields as $field) {
$parts = explode(' ', $orderfield);
if ($parts[0] == $field) {
// Prepend the user table prefix and count this as a valid order field.
array_push($validlist, 'u.' . $orderfield);
}
}
}
// Produce a final list.
$result = implode(',', $validlist);
if (empty($result)) {
// Fall back ordering when none has been set.
$result = 'u.lastname, u.firstname, u.id';
}
return $result;
}
/**
* Load a list of users enrolled in the current course with the specified permission and group.
* 0 for no group.
* Apply any current sort filters from the grading table.
*
* @param int $currentgroup
* @param bool $idsonly
* @return array List of user records
*/
public function list_participants($currentgroup, $idsonly) {
public function list_participants($currentgroup, $idsonly, $tablesort = false) {
global $DB, $USER;
// Get the last known sort order for the grading table.
if (empty($currentgroup)) {
$currentgroup = 0;
}
@ -1989,6 +2030,7 @@ class assign {
$fields = 'u.*';
$orderby = 'u.lastname, u.firstname, u.id';
$additionaljoins = '';
$additionalfilters = '';
$instance = $this->get_instance();
@ -2009,7 +2051,9 @@ class assign {
// Note, different DBs have different ordering of NULL values.
// Therefore we coalesce the current time into the timecreated field, and the max possible integer into
// the ID field.
$orderby = "COALESCE(s.timecreated, " . time() . ") ASC, COALESCE(s.id, " . PHP_INT_MAX . ") ASC, um.id ASC";
if (empty($tablesort)) {
$orderby = "COALESCE(s.timecreated, " . time() . ") ASC, COALESCE(s.id, " . PHP_INT_MAX . ") ASC, um.id ASC";
}
}
if ($instance->markingworkflow &&
@ -2044,6 +2088,19 @@ class assign {
$this->participants[$key] = $users;
}
if ($tablesort) {
// Resort the user list according to the grading table sort and filter settings.
$sortedfiltereduserids = $this->get_grading_userid_list(true, '');
$sortedfilteredusers = [];
foreach ($sortedfiltereduserids as $nextid) {
$nextid = intval($nextid);
if (isset($this->participants[$key][$nextid])) {
$sortedfilteredusers[$nextid] = $this->participants[$key][$nextid];
}
}
$this->participants[$key] = $sortedfilteredusers;
}
if ($idsonly) {
$idslist = array();
foreach ($this->participants[$key] as $id => $user) {
@ -2338,9 +2395,21 @@ class assign {
* Utility function to get the userid for every row in the grading table
* so the order can be frozen while we iterate it.
*
* @param boolean $cached If true, the cached list from the session could be returned.
* @param string $useridlistid String value used for caching the participant list.
* @return array An array of userids
*/
protected function get_grading_userid_list() {
protected function get_grading_userid_list($cached = false, $useridlistid = '') {
if ($cached) {
if (empty($useridlistid)) {
$useridlistid = $this->get_useridlist_key_id();
}
$useridlistkey = $this->get_useridlist_key($useridlistid);
if (empty($SESSION->mod_assign_useridlist[$useridlistkey])) {
$SESSION->mod_assign_useridlist[$useridlistkey] = $this->get_grading_userid_list(false, '');
}
return $SESSION->mod_assign_useridlist[$useridlistkey];
}
$filter = get_user_preferences('assign_filter', '');
$table = new assign_grading_table($this, 0, $filter, 0, false);
@ -3962,11 +4031,7 @@ class assign {
$attemptnumber = optional_param('attemptnumber', -1, PARAM_INT);
if (!$userid) {
$useridlistkey = $this->get_useridlist_key($useridlistid);
if (empty($SESSION->mod_assign_useridlist[$useridlistkey])) {
$SESSION->mod_assign_useridlist[$useridlistkey] = $this->get_grading_userid_list();
}
$useridlist = $SESSION->mod_assign_useridlist[$useridlistkey];
$useridlist = $this->get_grading_userid_list(true, $useridlistid);
} else {
$rownum = 0;
$useridlistid = 0;
@ -4260,13 +4325,7 @@ class assign {
$markingworkflow = $this->get_instance()->markingworkflow;
// Get marking states to show in form.
$markingworkflowoptions = array();
if ($markingworkflow) {
$notmarked = get_string('markingworkflowstatenotmarked', 'assign');
$markingworkflowoptions[''] = get_string('filternone', 'assign');
$markingworkflowoptions[ASSIGN_MARKING_WORKFLOW_STATE_NOTMARKED] = $notmarked;
$markingworkflowoptions = array_merge($markingworkflowoptions, $this->get_marking_workflow_states_for_current_user());
}
$markingworkflowoptions = $this->get_marking_workflow_filters();
// Print options for changing the filter and changing the number of results per page.
$gradingoptionsformparams = array('cm'=>$cmid,
@ -6818,13 +6877,7 @@ class assign {
}
// Get marking states to show in form.
$markingworkflowoptions = array();
if ($this->get_instance()->markingworkflow) {
$notmarked = get_string('markingworkflowstatenotmarked', 'assign');
$markingworkflowoptions[''] = get_string('filternone', 'assign');
$markingworkflowoptions[ASSIGN_MARKING_WORKFLOW_STATE_NOTMARKED] = $notmarked;
$markingworkflowoptions = array_merge($markingworkflowoptions, $this->get_marking_workflow_states_for_current_user());
}
$markingworkflowoptions = $this->get_marking_workflow_filters();
$gradingoptionsparams = array('cm'=>$this->get_course_module()->id,
'contextid'=>$this->context->id,
@ -7316,11 +7369,7 @@ class assign {
$bothids = ($userid && $useridlistid);
if (!$userid || $bothids) {
$useridlistkey = $this->get_useridlist_key($useridlistid);
if (empty($SESSION->mod_assign_useridlist[$useridlistkey])) {
$SESSION->mod_assign_useridlist[$useridlistkey] = $this->get_grading_userid_list();
}
$useridlist = $SESSION->mod_assign_useridlist[$useridlistkey];
$useridlist = $this->get_grading_userid_list(true, $useridlistid);
} else {
$useridlist = array($userid);
$rownum = 0;
@ -8908,6 +8957,108 @@ class assign {
public function set_most_recent_team_submission($submission) {
$this->mostrecentteamsubmission = $submission;
}
/**
* Return array of valid grading allocation filters for the grading interface.
*
* @param boolean $export Export the list of filters for a template.
* @return array
*/
public function get_marking_allocation_filters($export = false) {
$markingallocation = $this->get_instance()->markingworkflow &&
$this->get_instance()->markingallocation &&
has_capability('mod/assign:manageallocations', $this->context);
// Get markers to use in drop lists.
$markingallocationoptions = array();
if ($markingallocation) {
list($sort, $params) = users_order_by_sql('u');
// Only enrolled users could be assigned as potential markers.
$markers = get_enrolled_users($this->context, 'mod/assign:grade', 0, 'u.*', $sort);
$markingallocationoptions[''] = get_string('filternone', 'assign');
$markingallocationoptions[ASSIGN_MARKER_FILTER_NO_MARKER] = get_string('markerfilternomarker', 'assign');
$viewfullnames = has_capability('moodle/site:viewfullnames', $this->context);
foreach ($markers as $marker) {
$markingallocationoptions[$marker->id] = fullname($marker, $viewfullnames);
}
}
if ($export) {
$allocationfilter = get_user_preferences('assign_markerfilter', '');
$result = [];
foreach ($markingallocationoptions as $option => $label) {
array_push($result, [
'key' => $option,
'name' => $label,
'active' => ($allocationfilter == $option),
]);
}
return $result;
}
return $markingworkflowoptions;
}
/**
* Return array of valid grading workflow filters for the grading interface.
*
* @param boolean $export Export the list of filters for a template.
* @return array
*/
public function get_marking_workflow_filters($export = false) {
$markingworkflow = $this->get_instance()->markingworkflow;
// Get marking states to show in form.
$markingworkflowoptions = array();
if ($markingworkflow) {
$notmarked = get_string('markingworkflowstatenotmarked', 'assign');
$markingworkflowoptions[''] = get_string('filternone', 'assign');
$markingworkflowoptions[ASSIGN_MARKING_WORKFLOW_STATE_NOTMARKED] = $notmarked;
$markingworkflowoptions = array_merge($markingworkflowoptions, $this->get_marking_workflow_states_for_current_user());
}
if ($export) {
$workflowfilter = get_user_preferences('assign_workflowfilter', '');
$result = [];
foreach ($markingworkflowoptions as $option => $label) {
array_push($result, [
'key' => $option,
'name' => $label,
'active' => ($workflowfilter == $option),
]);
}
return $result;
}
return $markingworkflowoptions;
}
/**
* Return array of valid search filters for the grading interface.
*
* @return array
*/
public function get_filters() {
$filterkeys = [
ASSIGN_FILTER_SUBMITTED,
ASSIGN_FILTER_NOT_SUBMITTED,
ASSIGN_FILTER_REQUIRE_GRADING,
ASSIGN_FILTER_GRANTED_EXTENSION
];
$current = get_user_preferences('assign_filter', '');
$filters = [];
// First is always "no filter" option.
array_push($filters, [
'key' => 'none',
'name' => get_string('filternone', 'assign'),
'active' => ($current == '')
]);
foreach ($filterkeys as $key) {
array_push($filters, [
'key' => $key,
'name' => get_string('filter' . $key, 'assign'),
'active' => ($current == $key)
]);
}
return $filters;
}
}
/**

View File

@ -354,14 +354,14 @@
.path-mod-assign [data-region="configure-filters"] {
display: none;
text-align: left;
width: auto;
width: 480px;
background-color: #fff;
background-clip: padding-box;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2);
border-radius: 6px;
position: absolute;
margin-top: 28px;
margin-left: -140px;
margin-left: -452px;
padding: 10px 0;
z-index: 1;
}
@ -391,11 +391,6 @@
border-bottom-color: #fff;
}
.path-mod-assign [data-region="configure-filters"] label {
display: block;
padding: 3px 20px;
}
.path-mod-assign .alignment [data-region="configure-filters"] input {
margin-bottom: 0;
}

View File

@ -23,7 +23,7 @@
* none
Data attributes required for JS:
* data-action, data-assignmentid, data-groupid, data-region
* data-action, data-assignmentid, data-groupid, data-region, data-currentuserid
Context variables required for this template:
* see mod/assign/classes/output/grading_app.php
@ -31,37 +31,73 @@
This template uses ajax functionality, so it cannot be shown in the template library.
}}
<a href="#previous" data-action="previous-user">{{{larrow}}}</a>
<span>
<select data-action="change-user" data-assignmentid="{{assignmentid}}" data-groupid="{{groupid}}">
</select>
</span>
<a href="#next" data-action="next-user">{{{rarrow}}}</a>
<span>
<select data-action="change-user" data-currentuserid="{{currentuserid}}" data-assignmentid="{{assignmentid}}" data-groupid="{{groupid}}">
</select>
</span>
<a href="#next" data-action="next-user">{{{rarrow}}}</a>
<br>
<span data-region="user-count">
<small>
<span data-region="user-count-summary">{{#str}}xofy, mod_assign, { "x": "{{index}}", "y": "{{count}}" }{{/str}}</span>
</small>
<small>
<span data-region="user-count-summary">{{#str}}xofy, mod_assign, { "x": "{{index}}", "y": "{{count}}" }{{/str}}</span>
</small>
</span>
<span data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="well well-small">
<form>
<label><input type="checkbox" name="filter_submitted">{{#str}}filtersubmitted, mod_assign{{/str}}</label>
<label><input type="checkbox" name="filter_notsubmitted">{{#str}}filternotsubmitted, mod_assign{{/str}}</label>
<label><input type="checkbox" name="filter_requiregrading">{{#str}}filterrequiregrading, mod_assign{{/str}}</label>
<label><input type="checkbox" name="filter_grantedextension">{{#str}}filtergrantedextension, mod_assign{{/str}}</label>
</form>
<span data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="well well-large p-2">
<form class="container-fluid">
<div class="row-fluid">
<label class="span4 text-right" for="filter-general-{{uniqid}}">
{{#str}}filter, mod_assign{{/str}}
</label>
<div class="span8">
<select name="filter" class="custom-select span8" id="filter-general-{{uniqid}}">
{{#filters}}
<option value="{{key}}" {{#active}}selected="selected"{{/active}}> {{name}} </option>
{{/filters}}
</select>
</div>
</div>
{{#hasmarkingallocation}}
<div class="row-fluid">
<label class="span4 text-right" for="filter-marker-{{uniqid}}">
{{#str}}markerfilter, mod_assign{{/str}}
</label>
<div class="span8">
<select name="markerfilter" class="custom-select span8" id="filter-marker-{{uniqid}}">
{{#markingallocationfilters}}
<option value="{{key}}" {{#active}}selected="selected"{{/active}} > {{name}} </option>
{{/markingallocationfilters}}
</select>
</div>
</div>
{{/hasmarkingallocation}}
{{#hasmarkingworkflow}}
<div class="row-fluid">
<label class="span4 text-right" for="filter-workflow-{{uniqid}}">
{{#str}}workflowfilter, mod_assign{{/str}}
</label>
<div class="span8">
<select name="workflowfilter" class="custom-select span8" id="filter-workflow-{{uniqid}}">
{{#markingworkflowfilters}}
<option value="{{key}}" {{#active}}selected="selected"{{/active}} > {{name}} </option>
{{/markingworkflowfilters}}
</select>
</div>
</div>
{{/hasmarkingworkflow}}
</form>
</span>
<a href="#" data-region="user-filters" title="{{#str}}changefilters, mod_assign{{/str}}" aria-expanded="false" aria-controls="filter-configuration-{{uniqid}}">
<span class="accesshide">
{{#filters}}
{{filtername}}
{{/filters}}
{{^filters}}
{{#str}}nofilters, mod_assign{{/str}}
{{/filters}}
</span>
{{#pix}}i/filter{{/pix}}
<span class="accesshide">
{{#filters}}
{{filtername}}
{{/filters}}
{{^filters}}
{{#str}}nofilters, mod_assign{{/str}}
{{/filters}}
</span>
{{#pix}}i/filter{{/pix}}
</a>

View File

@ -12,10 +12,12 @@ Feature: View the grading status of an assignment
| username | firstname | lastname | email |
| teacher1 | Teacher | 1 | teacher1@example.com |
| student1 | Student | 1 | student1@example.com |
| student2 | Student | 2 | student2@example.com |
And the following "course enrolments" exist:
| user | course | role |
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
| student2 | C1 | student |
@javascript
Scenario: View the grading status for an assignment with marking workflow enabled
@ -46,6 +48,10 @@ Feature: View the grading status of an assignment
And I navigate to "View all submissions" in current page administration
And I should see "Not marked" in the "Student 1" "table_row"
And I click on "Grade" "link" in the "Student 1" "table_row"
And I should see "1 of 2"
And I click on "Change filters" "link"
And I set the field "Filter" to "submitted"
And I should see "1 of 1"
And I set the field "Grade out of 100" to "50"
And I set the field "Marking workflow state" to "In review"
And I set the field "Feedback comments" to "Great job! Lol, not really."
@ -71,6 +77,7 @@ Feature: View the grading status of an assignment
And I navigate to "View all submissions" in current page administration
And I should see "In review" in the "Student 1" "table_row"
And I click on "Grade" "link" in the "Student 1" "table_row"
And I should see "1 of 1"
And I set the field "Marking workflow state" to "Released"
And I press "Save changes"
And I press "Ok"
@ -93,6 +100,7 @@ Feature: View the grading status of an assignment
And I navigate to "View all submissions" in current page administration
And I should see "Released" in the "Student 1" "table_row"
And I click on "Grade" "link" in the "Student 1" "table_row"
And I should see "1 of 1"
And I set the field "Marking workflow state" to "In marking"
And I set the field "Notify students" to "0"
And I press "Save changes"
@ -104,6 +112,11 @@ Feature: View the grading status of an assignment
# The grade should also remain displayed as it's stored in the assign DB tables, but the final grade should be empty.
And "Student 1" row "Grade" column of "generaltable" table should contain "50.00"
And "Student 1" row "Final grade" column of "generaltable" table should contain "-"
And I click on "Grade" "link" in the "Student 1" "table_row"
And I click on "Change filters" "link"
And I set the field "Workflow filter" to "In review"
And I should see "0 of 0"
And I follow "Test assignment name"
And I log out
@javascript
@ -134,6 +147,10 @@ Feature: View the grading status of an assignment
And I navigate to "View all submissions" in current page administration
And I should not see "Graded" in the "Student 1" "table_row"
And I click on "Grade" "link" in the "Student 1" "table_row"
And I should see "1 of 2"
And I click on "Change filters" "link"
And I set the field "Filter" to "submitted"
And I should see "1 of 1"
And I set the field "Grade out of 100" to "50"
And I set the field "Feedback comments" to "Great job! Lol, not really."
And I press "Save changes"
@ -167,6 +184,7 @@ Feature: View the grading status of an assignment
And I should see "Graded - follow up submission received" in the "Student 1" "table_row"
And I wait "10" seconds
And I click on "Grade" "link" in the "Student 1" "table_row"
And I should see "1 of 1"
And I set the field "Grade out of 100" to "99.99"
And I set the field "Feedback comments" to "Even better job! Really."
And I press "Save changes"

View File

@ -2425,7 +2425,7 @@ class mod_assign_external_testcase extends externallib_advanced_testcase {
$DB->update_record('user', $student);
$this->setUser($teacher);
$participants = mod_assign_external::list_participants($assignment->id, 0, '', 0, 0, false, true);
$participants = mod_assign_external::list_participants($assignment->id, 0, '', 0, 0, false, true, true);
$participants = external_api::clean_returnvalue(mod_assign_external::list_participants_returns(), $participants);
$this->assertCount(1, $participants);
@ -2443,7 +2443,7 @@ class mod_assign_external_testcase extends externallib_advanced_testcase {
$this->assertEquals($student->institution, $participant['institution']);
$this->assertArrayHasKey('enrolledcourses', $participant);
$participants = mod_assign_external::list_participants($assignment->id, 0, '', 0, 0, false, false);
$participants = mod_assign_external::list_participants($assignment->id, 0, '', 0, 0, false, false, true);
$participants = external_api::clean_returnvalue(mod_assign_external::list_participants_returns(), $participants);
// Check that the list of courses the participant is enrolled is not returned.
$participant = $participants[0];

View File

@ -3987,4 +3987,17 @@ Anchor link 2:<a title=\"bananas\" href=\"../logo-240x60.gif\">Link text</a>
// Check that submissionstatus_marked 'Graded' message does appear for student.
$this->assertContains(get_string('submissionstatus_marked', 'assign'), $output2);
}
/**
* Test the result of get_filters is consistent.
*/
public function test_get_filters() {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
$assign = $this->create_instance($course);
$valid = $assign->get_filters();
$this->assertEquals(count($valid), 5);
}
}

View File

@ -32,7 +32,7 @@
}}
<a href="#previous" data-action="previous-user">{{{larrow}}}</a>
<span data-region="input-field">
<select data-action="change-user" data-assignmentid="{{assignmentid}}" data-groupid="{{groupid}}"></select>
<select data-action="change-user" data-currentuserid="{{currentuserid}}" data-assignmentid="{{assignmentid}}" data-groupid="{{groupid}}"></select>
</span>
<a href="#next" data-action="next-user">{{{rarrow}}}</a>
@ -44,12 +44,42 @@
</small>
</span>
<span data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-small">
<span data-region="configure-filters" id="filter-configuration-{{uniqid}}" class="card card-large p-2">
<form>
<label><input type="checkbox" name="filter_submitted">{{#str}}filtersubmitted, mod_assign{{/str}}</label>
<label><input type="checkbox" name="filter_notsubmitted">{{#str}}filternotsubmitted, mod_assign{{/str}}</label>
<label><input type="checkbox" name="filter_requiregrading">{{#str}}filterrequiregrading, mod_assign{{/str}}</label>
<label><input type="checkbox" name="filter_grantedextension">{{#str}}filtergrantedextension, mod_assign{{/str}}</label>
<span class="row px-3 py-1">
<label class="text-right w-25 p-2 m-0" for="filter-general-{{uniqid}}">
{{#str}}filter, mod_assign{{/str}}
</label>
<select name="filter" class="custom-select w-50" id="filter-general-{{uniqid}}">
{{#filters}}
<option value="{{key}}" {{#active}}selected="selected"{{/active}} > {{name}} </option>
{{/filters}}
</select>
</span>
{{#hasmarkingallocation}}
<span class="row px-3 py-1">
<label class="text-right w-25 p-2 m-0" for="filter-marker-{{uniqid}}">
{{#str}}markerfilter, mod_assign{{/str}}
</label>
<select name="markerfilter" class="custom-select w-50" id="filter-marker-{{uniqid}}">
{{#markingallocationfilters}}
<option value="{{key}}" {{#active}}selected="selected"{{/active}} > {{name}} </option>
{{/markingallocationfilters}}
</select>
</span>
{{/hasmarkingallocation}}
{{#hasmarkingworkflow}}
<span class="row px-3 py-1">
<label class="text-right w-25 p-2 m-0" for="filter-workflow-{{uniqid}}">
{{#str}}workflowfilter, mod_assign{{/str}}
</label>
<select name="workflowfilter" class="custom-select w-50" id="filter-workflow-{{uniqid}}">
{{#markingworkflowfilters}}
<option value="{{key}}" {{#active}}selected="selected"{{/active}} > {{name}} </option>
{{/markingworkflowfilters}}
</select>
</span>
{{/hasmarkingworkflow}}
</form>
</span>