Skip to content

Commit

Permalink
Merge pull request #911 from edx/mohtamba/group_allowances_by_learner
Browse files Browse the repository at this point in the history
Added grouped allowance feature, where allowances are now grouped in separate dropdowns based on users.
  • Loading branch information
mohtamba authored Jul 23, 2021
2 parents 1cece68 + 4bed05d commit 1f59cc8
Show file tree
Hide file tree
Showing 9 changed files with 249 additions and 50 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ Change Log
Unreleased
~~~~~~~~~~

[3.21.0] - 2021-07-23
~~~~~~~~~~~~~~~~~~~~~
* Added feature behind the bulk allowance waffle flag that groups allowances by users.
* Updated the UI so allowances are under dropdown for each user

[3.20.6] - 2021-07-22
~~~~~~~~~~~~~~~~~~~~~
* Removed use of name field in proctored exam attempt admin.
Expand Down
2 changes: 1 addition & 1 deletion edx_proctoring/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"""

# Be sure to update the version number in edx_proctoring/package.json
__version__ = '3.20.6'
__version__ = '3.21.0'

default_app_config = 'edx_proctoring.apps.EdxProctoringConfig' # pylint: disable=invalid-name
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,17 @@ edx = edx || {};
/* unfortunately we have to make some assumptions about what is being set up in HTML */
this.setElement($('.special-allowance-container'));
this.course_id = this.$el.data('course-id');

/* we need to check if the bulk allowance waffle flag is enabled */
this.enableBulkAllowance =
this.$el.data('enable-bulk-allowance');
this.enableBulkAllowance = this.enableBulkAllowance &&
this.enableBulkAllowance.toLowerCase() === 'true';
/* this should be moved to a 'data' attribute in HTML */
this.template_url = '/static/proctoring/templates/course_allowances.underscore';
if (this.enableBulkAllowance) {
this.template_url = '/static/proctoring/templates/course_grouped_allowances.underscore';
} else {
this.template_url = '/static/proctoring/templates/course_allowances.underscore';
}
this.template = null;
this.initial_url = this.collection.url;
this.allowance_url = this.initial_url + 'allowance';
Expand All @@ -32,11 +40,11 @@ edx = edx || {};
this.loadTemplateData();

this.proctoredExamCollection.url = this.proctoredExamCollection.url + this.course_id;
this.collection.url = this.initial_url + this.course_id + '/allowance';
},
events: {
'click #add-allowance': 'showAddModal',
'click .remove_allowance': 'removeAllowance'
'click .remove_allowance': 'removeAllowance',
'click .accordion-trigger': 'toggleAllowanceAccordion'
},
getCSRFToken: function() {
var cookieValue = null;
Expand Down Expand Up @@ -75,7 +83,6 @@ edx = edx || {};
},
success: function() {
// fetch the allowances again.
self.collection.url = self.initial_url + self.course_id + '/allowance';
self.hydrate();
}
}
Expand Down Expand Up @@ -113,6 +120,11 @@ edx = edx || {};
/* we might - at some point - add a visual element to the */
/* loading, like a spinner */
var self = this;
if (self.enableBulkAllowance) {
self.collection.url = self.initial_url + self.course_id + '/grouped/allowance';
} else {
self.collection.url = self.initial_url + self.course_id + '/allowance';
}
self.collection.fetch({
success: function() {
self.render();
Expand All @@ -126,31 +138,32 @@ edx = edx || {};
var self = this;
var key, i, html;
if (this.template !== null) {
this.collection.each(function(item) {
key = item.get('key');
for (i = 0; i < self.allowance_types.length; i += 1) {
if (key === self.allowance_types[i][0]) {
item.set('key_display_name', self.allowance_types[i][1]);
break;
if (!this.enableBulkAllowance) {
this.collection.each(function(item) {
key = item.get('key');
for (i = 0; i < self.allowance_types.length; i += 1) {
if (key === self.allowance_types[i][0]) {
item.set('key_display_name', self.allowance_types[i][1]);
break;
}
}
}
if (!item.has('key_display_name')) {
item.set('key_display_name', key);
}
});
html = this.template({proctored_exam_allowances: this.collection.toJSON()});
if (!item.has('key_display_name')) {
item.set('key_display_name', key);
}
});
html = this.template({proctored_exam_allowances: this.collection.toJSON()});
} else {
html = this.template({proctored_exam_allowances: this.collection.toJSON()[0],
allowance_types: self.allowance_types});
}
this.$el.html(html);
}
},
showAddModal: function(event) {
var self = this;
var enableBulkAllowance =
self.$el.data('enable-bulk-allowance');
enableBulkAllowance = enableBulkAllowance &&
enableBulkAllowance.toLowerCase() === 'true';
self.proctoredExamCollection.fetch({
success: function() {
if (!enableBulkAllowance) {
if (!self.enableBulkAllowance) {
// eslint-disable-next-line no-new
new edx.instructor_dashboard.proctoring.AddAllowanceView({
course_id: self.course_id,
Expand All @@ -171,6 +184,28 @@ edx = edx || {};
});
event.stopPropagation();
event.preventDefault();
},
toggleAllowanceAccordion: function(event) {
// based on code from openedx/features/course_experience/static/course_experience/js/CourseOutline.js
// but modified to better fit this feature's needs
var accordionRow, isExpanded, $toggleChevron, $contentPanel;
accordionRow = event.currentTarget;
if (accordionRow.classList.contains('accordion-trigger')) {
isExpanded = accordionRow.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
$toggleChevron = $(accordionRow).find('.fa-chevron-right');
$contentPanel = $('#' + accordionRow.innerText.trim());
$contentPanel.show();
$toggleChevron.addClass('fa-rotate-90');
accordionRow.setAttribute('aria-expanded', 'true');
} else {
$toggleChevron = $(accordionRow).find('.fa-chevron-right');
$contentPanel = $('#' + accordionRow.innerText.trim());
$contentPanel.hide();
$toggleChevron.removeClass('fa-rotate-90');
accordionRow.setAttribute('aria-expanded', 'false');
}
}
}
});
this.edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
describe('ProctoredExamAllowanceView', function() {
'use strict';

var html = '';
var expectedProctoredAllowanceJson = [{
student: [{
created: '2015-08-10T09:15:45Z',
id: 1,
modified: '2015-08-10T09:15:45Z',
key: 'Additional time (minutes)',
value: '1',
proctored_exam: {
content_id: 'i4x://edX/DemoX/sequential/9f5e9b018a244ea38e5d157e0019e60c',
course_id: 'edX/DemoX/Demo_Course',
exam_name: 'Test Exam',
external_id: null,
id: 17,
is_active: true,
is_practice_exam: false,
is_proctored: true,
time_limit_mins: 1
},
user: {
username: 'testuser1',
email: '[email protected]'
}
}]}
];

beforeEach(function() {
// eslint-disable-next-line max-len
html = '<span class="tip"> <%- gettext("Allowances") %>\n <span>\n <a id="add-allowance" href="#" class="add blue-button">+ <%- gettext("Add Allowance") %></a>\n </span>\n</span>\n<% var is_allowances = proctored_exam_allowances.length !== 0 %>\n<% if (is_allowances) { %>\n\n<div class="wrapper-content wrapper">\n <section class="content exam-allowances-content">\n <% _.each(proctored_exam_allowances, function(student){ %>\n <div class="accordion-trigger" aria-expanded="false" style="font-size:20px;">\n <span class="fa fa-chevron-right" aria-hidden="true"></span>\n <%=student[0].user.username %>\n </div>\n <table class="allowance-table" id="<%=student[0].user.username %>" style="display:none;">\n <tbody>\n <tr class="allowance-headings">\n <th class="exam-name"><%- gettext("Exam Name") %></th>\n <th class="email"><%- gettext("Email") %></th>\n <th class="allowance-name"><%- gettext("Allowance Type") %> </th>\n <th class="allowance-value"><%- gettext("Allowance Value") %></th>\n <th class="c_action"><%- gettext("Actions") %> </th>\n </tr>\n <% _.each(student, function(proctored_exam_allowance){ %>\n <% var key = proctored_exam_allowance.key; %>\n <% for (i = 0; i < allowance_types.length; i += 1) { %>\n <% if (key === allowance_types[i][0]) { %>\n <% proctored_exam_allowance.key_display_name = allowance_types[i][1]; %>\n <% break; %>\n <% }} %>\n <% if (!proctored_exam_allowance.key_display_name) { %>\n <% proctored_exam_allowance.key_display_name = key;} %>\n <tr class="allowance-items">\n <td>\n <%- proctored_exam_allowance.proctored_exam.exam_name %>\n </td>\n <td>\n <% if (proctored_exam_allowance.user){ %>\n <%= proctored_exam_allowance.user.email %>\n </td>\n <% }else{ %>\n <td>N/A</td>\n <td>N/A</td>\n <% } %>\n <td>\n <%= proctored_exam_allowance.key_display_name %>\n </td>\n <td>\n <%- proctored_exam_allowance.value %></td>\n <td>\n <a data-exam-id="<%= proctored_exam_allowance.proctored_exam.id %>"\n data-key-name="<%= proctored_exam_allowance.key %>"\n data-user-id="<%= proctored_exam_allowance.user.id %>"\n class="remove_allowance" href="#">[x]</a>\n </td>\n </tr>\n <% }); %>\n </tbody>\n </table>\n <% }); %>\n </section>\n</div>\n<% } %>\n';
this.server = sinon.fakeServer.create();
this.server.autoRespond = true;
setFixtures('<div class="special-allowance-container" data-course-id="test_course_id"' +
'data-enable-bulk-allowance="True"></div>');

// load the underscore template response before calling the proctored exam allowance view.
this.server.respondWith('GET', '/static/proctoring/templates/course_grouped_allowances.underscore',
[
200,
{'Content-Type': 'text/html'},
html
]
);
});

afterEach(function() {
this.server.restore();
});
it('should render the proctored exam allowance view properly', function() {
this.server.respondWith('GET', '/api/edx_proctoring/v1/proctored_exam/test_course_id/grouped/allowance',
[
200,
{
'Content-Type': 'application/json'
},
JSON.stringify(expectedProctoredAllowanceJson)
]
);

this.proctored_exam_allowance = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView();
this.server.respond();
this.server.respond();
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('testuser1');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('[email protected]');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('Additional time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('Test Exam');
});
//
it('should remove the proctored exam allowance', function() {
this.server.respondWith('GET', '/api/edx_proctoring/v1/proctored_exam/test_course_id/grouped/allowance',
[
200,
{
'Content-Type': 'application/json'
},
JSON.stringify(expectedProctoredAllowanceJson)
]
);

this.proctored_exam_allowance = new edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView();

this.server.respond();
this.server.respond();

expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('testuser1');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('[email protected]');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('Additional time (minutes)');
expect(this.proctored_exam_allowance.$el.find('tr.allowance-items').html())
.toContain('Test Exam');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<span class="tip"> <%- gettext("Allowances") %>
<span>
<a id="add-allowance" href="#" class="add blue-button">+ <%- gettext("Add Allowance") %></a>
</span>
</span>
<% var is_allowances = proctored_exam_allowances.length !== 0 %>
<% if (is_allowances) { %>

<div class="wrapper-content wrapper">
<section class="content exam-allowances-content">
<% _.each(proctored_exam_allowances, function(student){ %>
<div class="accordion-trigger" aria-expanded="false" style="font-size:20px;">
<span class="fa fa-chevron-right" aria-hidden="true"></span>
<%=student[0].user.username %>
</div>
<table class="allowance-table" id="<%=student[0].user.username %>" style="display:none;">
<tbody>
<tr class="allowance-headings">
<th class="exam-name"><%- gettext("Exam Name") %></th>
<th class="email"><%- gettext("Email") %></th>
<th class="allowance-name"><%- gettext("Allowance Type") %> </th>
<th class="allowance-value"><%- gettext("Allowance Value") %></th>
<th class="c_action"><%- gettext("Actions") %> </th>
</tr>
<% _.each(student, function(proctored_exam_allowance){ %>
<% var key = proctored_exam_allowance.key; %>
<% for (i = 0; i < allowance_types.length; i += 1) { %>
<% if (key === allowance_types[i][0]) { %>
<% proctored_exam_allowance.key_display_name = allowance_types[i][1]; %>
<% break; %>
<% }} %>
<% if (!proctored_exam_allowance.key_display_name) { %>
<% proctored_exam_allowance.key_display_name = key;} %>
<tr class="allowance-items">
<td>
<%- proctored_exam_allowance.proctored_exam.exam_name %>
</td>
<td>
<% if (proctored_exam_allowance.user){ %>
<%= proctored_exam_allowance.user.email %>
</td>
<% }else{ %>
<td>N/A</td>
<td>N/A</td>
<% } %>
<td>
<%= proctored_exam_allowance.key_display_name %>
</td>
<td>
<%- proctored_exam_allowance.value %></td>
<td>
<a data-exam-id="<%= proctored_exam_allowance.proctored_exam.id %>"
data-key-name="<%= proctored_exam_allowance.key %>"
data-user-id="<%= proctored_exam_allowance.user.id %>"
class="remove_allowance" href="#">[x]</a>
</td>
</tr>
<% }); %>
</tbody>
</table>
<% }); %>
</section>
</div>
<% } %>
15 changes: 5 additions & 10 deletions edx_proctoring/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5305,10 +5305,9 @@ def test_get_grouped_allowances(self):
'additional_time_granted')
third_serialized_allowance = ProctoredExamStudentAllowanceSerializer(third_user_allowance).data
expected_response = {
'grouped_allowances': {str(first_user): [first_serialized_allowance],
str(second_user): [second_serialized_allowance],
str(third_user): [third_serialized_allowance]}
}
user_list[0].username: [first_serialized_allowance],
user_list[1].username: [second_serialized_allowance],
user_list[2].username: [third_serialized_allowance]}
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
Expand Down Expand Up @@ -5371,9 +5370,7 @@ def test_get_grouped_allowances_course_no_allowances(self):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(len(response_data), 1)
grouped_allowances = response_data['grouped_allowances']
self.assertEqual(len(grouped_allowances), 0)
self.assertEqual(len(response_data), 0)

def test_get_grouped_allowances_non_global_staff(self):
"""
Expand Down Expand Up @@ -5418,9 +5415,7 @@ def test_get_grouped_allowances_non_global_staff(self):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.content.decode('utf-8'))
self.assertEqual(len(response_data), 1)
grouped_allowances = response_data['grouped_allowances']
self.assertEqual(len(grouped_allowances), 3)
self.assertEqual(len(response_data), 3)


class TestActiveExamsForUserView(LoggedInTestCase):
Expand Down
Loading

0 comments on commit 1f59cc8

Please sign in to comment.