diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9f8ba7cdf64..79c67dd3f11 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -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. diff --git a/edx_proctoring/__init__.py b/edx_proctoring/__init__.py index e75ce94b16b..133ec1ee68a 100644 --- a/edx_proctoring/__init__.py +++ b/edx_proctoring/__init__.py @@ -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 diff --git a/edx_proctoring/static/proctoring/js/views/proctored_exam_allowance_view.js b/edx_proctoring/static/proctoring/js/views/proctored_exam_allowance_view.js index fd53dd045c6..73be7d293c4 100644 --- a/edx_proctoring/static/proctoring/js/views/proctored_exam_allowance_view.js +++ b/edx_proctoring/static/proctoring/js/views/proctored_exam_allowance_view.js @@ -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'; @@ -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; @@ -75,7 +83,6 @@ edx = edx || {}; }, success: function() { // fetch the allowances again. - self.collection.url = self.initial_url + self.course_id + '/allowance'; self.hydrate(); } } @@ -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(); @@ -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, @@ -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 = diff --git a/edx_proctoring/static/proctoring/spec/proctored_exam_bulk_allowance_spec.js b/edx_proctoring/static/proctoring/spec/proctored_exam_bulk_allowance_spec.js new file mode 100644 index 00000000000..4d2f217cdcb --- /dev/null +++ b/edx_proctoring/static/proctoring/spec/proctored_exam_bulk_allowance_spec.js @@ -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: 'testuser1@test.com' + } + }]} + ]; + + beforeEach(function() { + // eslint-disable-next-line max-len + html = ' <%- gettext("Allowances") %>\n \n + <%- gettext("Add Allowance") %>\n \n\n<% var is_allowances = proctored_exam_allowances.length !== 0 %>\n<% if (is_allowances) { %>\n\n
\n
\n <% _.each(proctored_exam_allowances, function(student){ %>\n \n \n \n \n \n \n \n \n \n \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 \n \n \n <% }else{ %>\n \n \n <% } %>\n \n \n \n \n <% }); %>\n \n \n <% }); %>\n
\n
\n<% } %>\n'; + this.server = sinon.fakeServer.create(); + this.server.autoRespond = true; + setFixtures('
'); + + // 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('testuser1@test.com'); + 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('testuser1@test.com'); + 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'); + }); +}); diff --git a/edx_proctoring/static/proctoring/templates/course_grouped_allowances.underscore b/edx_proctoring/static/proctoring/templates/course_grouped_allowances.underscore new file mode 100644 index 00000000000..d0333ba9cb0 --- /dev/null +++ b/edx_proctoring/static/proctoring/templates/course_grouped_allowances.underscore @@ -0,0 +1,64 @@ + <%- gettext("Allowances") %> + + + <%- gettext("Add Allowance") %> + + +<% var is_allowances = proctored_exam_allowances.length !== 0 %> +<% if (is_allowances) { %> + +
+
+ <% _.each(proctored_exam_allowances, function(student){ %> + + + + + + + + + + + <% _.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;} %> + + + + <% }else{ %> + + + <% } %> + + + + + <% }); %> + + + <% }); %> +
+
+<% } %> diff --git a/edx_proctoring/tests/test_views.py b/edx_proctoring/tests/test_views.py index b23c1113402..16fc6e119dd 100644 --- a/edx_proctoring/tests/test_views.py +++ b/edx_proctoring/tests/test_views.py @@ -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')) @@ -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): """ @@ -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): diff --git a/edx_proctoring/urls.py b/edx_proctoring/urls.py index 24e7e7ba09a..175cf1f294a 100644 --- a/edx_proctoring/urls.py +++ b/edx_proctoring/urls.py @@ -91,7 +91,7 @@ name='proctored_exam.bulk_allowance' ), url( - r'edx_proctoring/v1/proctored_exam/allowance/grouped/course_id/{}$'.format(settings.COURSE_ID_PATTERN), + r'edx_proctoring/v1/proctored_exam/{}/grouped/allowance$'.format(settings.COURSE_ID_PATTERN), views.GroupedExamAllowancesByStudent.as_view(), name='proctored_exam.allowance.grouped.course' ), diff --git a/edx_proctoring/views.py b/edx_proctoring/views.py index 997c8d90184..3692db478e3 100644 --- a/edx_proctoring/views.py +++ b/edx_proctoring/views.py @@ -1587,17 +1587,17 @@ class GroupedExamAllowancesByStudent(ProctoredAPIView): HTTP GET: The response will contain a dictionary with the allowances of a course grouped by student. For example: - {'grouped_allowances':{'4': - [{'id': 4, 'created': '2021-06-21T14:47:17.847221Z', - 'modified': '2021-06-21T14:47:17.847221Z', - 'user': {'id': 4, 'username': 'student1', - 'email': 'student1@test.com'}, - 'key': 'additional_time_granted', 'value': '30', - 'proctored_exam': {'id': 2, 'course_id': 'a/b/c', 'content_id': 'test_content2', - 'external_id': None, 'exam_name': 'Test Exam2', 'time_limit_mins': 90, - 'is_proctored': False, 'is_practice_exam': False, - 'is_active': True, 'due_date': None, - 'hide_after_due': False, 'backend': None}}} + {{'student1': + [{'id': 4, 'created': '2021-06-21T14:47:17.847221Z', + 'modified': '2021-06-21T14:47:17.847221Z', + 'user': {'id': 4, 'username': 'student1', + 'email': 'student1@test.com'}, + 'key': 'additional_time_granted', 'value': '30', + 'proctored_exam': {'id': 2, 'course_id': 'a/b/c', 'content_id': 'test_content2', + 'external_id': None, 'exam_name': 'Test Exam2', 'time_limit_mins': 90, + 'is_proctored': False, 'is_practice_exam': False, + 'is_active': True, 'due_date': None, + 'hide_after_due': False, 'backend': None}}} **Exceptions** HTTP GET: @@ -1615,13 +1615,13 @@ def get(self, request, course_id): grouped_allowances = {} - # Process allowances so they are grouped by user id + # Process allowances so they are grouped by username for allowance in all_allowances: serialied_allowance = ProctoredExamStudentAllowanceSerializer(allowance).data - user_id = serialied_allowance['user']['id'] - grouped_allowances.setdefault(user_id, []).append(serialied_allowance) + username = serialied_allowance['user']['username'] + grouped_allowances.setdefault(username, []).append(serialied_allowance) - response_data = {'grouped_allowances': grouped_allowances} + response_data = grouped_allowances return Response(response_data) diff --git a/package.json b/package.json index e0ab1a6cd76..48027fe82db 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@edx/edx-proctoring", "//": "Note that the version format is slightly different than that of the Python version when using prereleases.", - "version": "3.20.6", + "version": "3.21.0", "main": "edx_proctoring/static/index.js", "scripts": { "test": "gulp test"