diff --git a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.jsx b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.jsx index a9c9c5c61d..c919438665 100644 --- a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.jsx +++ b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.jsx @@ -1,15 +1,13 @@ /* global gettext */ -import { Button } from '@edx/paragon'; -import BlockBrowserContainer from 'BlockBrowser/components/BlockBrowser/BlockBrowserContainer'; +import { Button, Icon } from '@edx/paragon'; +import { BlockBrowser } from 'BlockBrowser'; import * as PropTypes from 'prop-types'; import * as React from 'react'; -import { ReportStatusContainer } from '../ReportStatus/ReportStatusContainer'; export default class Main extends React.Component { constructor(props) { super(props); this.handleToggleDropdown = this.handleToggleDropdown.bind(this); - this.initiateReportGeneration = this.initiateReportGeneration.bind(this); this.state = { showDropdown: false, }; @@ -24,39 +22,31 @@ export default class Main extends React.Component { this.setState({ showDropdown: false }); } - initiateReportGeneration() { - this.props.createProblemResponsesReportTask( - this.props.problemResponsesEndpoint, - this.props.taskStatusEndpoint, - this.props.selectedBlock, - ); - } - render() { const { selectedBlock, onSelectBlock } = this.props; return ( -
-
-
- +
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} + + {selectedBlock || 'Select a section or problem'} + + + + + + + {this.state.showDropdown && + { + this.hideDropdown(); + onSelectBlock(blockId); + }} + />}
); } @@ -64,17 +54,13 @@ export default class Main extends React.Component { Main.propTypes = { courseId: PropTypes.string.isRequired, - createProblemResponsesReportTask: PropTypes.func.isRequired, excludeBlockTypes: PropTypes.arrayOf(PropTypes.string), fetchCourseBlocks: PropTypes.func.isRequired, - problemResponsesEndpoint: PropTypes.string.isRequired, onSelectBlock: PropTypes.func.isRequired, selectedBlock: PropTypes.string, - taskStatusEndpoint: PropTypes.string.isRequired, }; Main.defaultProps = { excludeBlockTypes: null, - selectedBlock: '', - timeout: null, + selectedBlock: null, }; diff --git a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx index 85fb8c72d6..c25973868b 100644 --- a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx +++ b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx @@ -1,34 +1,25 @@ /* global jest,test,describe,expect */ import { Button } from '@edx/paragon'; -import BlockBrowserContainer from 'BlockBrowser/components/BlockBrowser/BlockBrowserContainer'; -import { Provider } from 'react-redux'; +import { BlockBrowser } from 'BlockBrowser'; import { shallow } from 'enzyme'; import React from 'react'; import renderer from 'react-test-renderer'; -import store from '../../data/store'; import Main from './Main'; describe('ProblemBrowser Main component', () => { const courseId = 'testcourse'; - const problemResponsesEndpoint = '/api/problem_responses/'; - const taskStatusEndpoint = '/api/task_status/'; const excludedBlockTypes = []; test('render with basic parameters', () => { const component = renderer.create( - -
- , +
, ); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); @@ -36,18 +27,13 @@ describe('ProblemBrowser Main component', () => { test('render with selected block', () => { const component = renderer.create( - -
- , +
, ); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); @@ -56,20 +42,15 @@ describe('ProblemBrowser Main component', () => { test('fetch course block on toggling dropdown', () => { const fetchCourseBlocksMock = jest.fn(); const component = renderer.create( - -
- , +
, ); - const instance = component.root.children[0].instance; + const instance = component.getInstance(); instance.handleToggleDropdown(); expect(fetchCourseBlocksMock.mock.calls.length).toBe(1); }); @@ -78,17 +59,13 @@ describe('ProblemBrowser Main component', () => { const component = shallow(
, ); - expect(component.find(BlockBrowserContainer).length).toBeFalsy(); - component.find(Button).find({ label: 'Select a section or problem' }).simulate('click'); - expect(component.find(BlockBrowserContainer).length).toBeTruthy(); + component.find('.problem-selector').simulate('click'); + expect(component.find(BlockBrowser)).toBeTruthy(); }); }); diff --git a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap index 115d583892..c6bbea41e6 100644 --- a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap +++ b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap @@ -2,80 +2,90 @@ exports[`ProblemBrowser Main component render with basic parameters 1`] = `
-
- - - -
-
+ +
+ + +
`; exports[`ProblemBrowser Main component render with selected block 1`] = `
-
- - - -
-
+ +
+ + +
`; diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py index 3a140a057b..b221f8b734 100644 --- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py +++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py @@ -26,6 +26,7 @@ from lms.djangoapps.courseware.tests.factories import StaffFactory, StudentModul from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase from lms.djangoapps.grades.config.waffle import WRITABLE_GRADEBOOK, waffle_flags from lms.djangoapps.instructor.views.gradebook_api import calculate_page_info +from lms.djangoapps.instructor.toggles import DATA_DOWNLOAD_V2 from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag from student.models import CourseEnrollment @@ -136,31 +137,39 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT self.assertTrue(has_instructor_tab(org_researcher, self.course)) @ddt.data( - ('staff', False), - ('instructor', False), - ('data_researcher', True), - ('global_staff', True), + ('staff', False, False), + ('instructor', False, False), + ('data_researcher', True, False), + ('global_staff', True, False), + ('staff', False, True), + ('instructor', False, True), + ('data_researcher', True, True), + ('global_staff', True, True), ) @ddt.unpack - def test_data_download(self, access_role, can_access): + def test_data_download(self, access_role, can_access, waffle_status): """ Verify that the Data Download tab only shows up for certain roles """ - download_section = '' - user = UserFactory.create(is_staff=access_role == 'global_staff') - CourseAccessRoleFactory( - course_id=self.course.id, - user=user, - role=access_role, - org=self.course.id.org - ) - self.client.login(username=user.username, password="test") - response = self.client.get(self.url) - if can_access: - self.assertContains(response, download_section) - else: - self.assertNotContains(response, download_section) + with override_waffle_flag(DATA_DOWNLOAD_V2, waffle_status): + download_section = '' + if waffle_status: + download_section = '' + user = UserFactory.create(is_staff=access_role == 'global_staff') + CourseAccessRoleFactory( + course_id=self.course.id, + user=user, + role=access_role, + org=self.course.id.org + ) + self.client.login(username=user.username, password="test") + response = self.client.get(self.url) + if can_access: + self.assertContains(response, download_section) + else: + self.assertNotContains(response, download_section) @override_settings(ANALYTICS_DASHBOARD_URL='http://example.com') @override_settings(ANALYTICS_DASHBOARD_NAME='Example') diff --git a/lms/djangoapps/instructor/toggles.py b/lms/djangoapps/instructor/toggles.py new file mode 100644 index 0000000000..3e4e58a423 --- /dev/null +++ b/lms/djangoapps/instructor/toggles.py @@ -0,0 +1,54 @@ +""" +Waffle flags for instructor dashboard. +""" + +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace, WaffleFlag + +WAFFLE_NAMESPACE = 'instructor' +# Namespace for instructor waffle flags. +WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name=WAFFLE_NAMESPACE) + +# Waffle flag enable new data download UI on specific course. +# .. toggle_name: instructor.enable_data_download_v2 +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: instructor +# .. toggle_category: Instructor dashboard +# .. toggle_use_cases: incremental_release, open_edx +# .. toggle_creation_date: 2020-07-8 +# .. toggle_expiration_date: ?? +# .. toggle_warnings: ?? +# .. toggle_tickets: PROD-1309 +# .. toggle_status: supported +DATA_DOWNLOAD_V2 = CourseWaffleFlag( + waffle_namespace=WaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix='instructor_dashboard: '), + flag_name='enable_data_download_v2', +) + +# Waffle flag to use optimised is_small_course. +# .. toggle_name: verify_student.optimised_is_small_course +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Supports staged rollout to improved is_small_course method. +# .. toggle_category: instructor +# .. toggle_use_cases: incremental_release, open_edx +# .. toggle_creation_date: 2020-07-02 +# .. toggle_expiration_date: n/a +# .. toggle_warnings: n/a +# .. toggle_tickets: PROD-1740 +# .. toggle_status: supported +OPTIMISED_IS_SMALL_COURSE = WaffleFlag( + waffle_namespace=WAFFLE_FLAG_NAMESPACE, + flag_name='optimised_is_small_course', +) + + +def data_download_v2_is_enabled(course_key): + """ + check if data download v2 is enabled. + """ + return DATA_DOWNLOAD_V2.is_enabled(course_key) + + +def use_optimised_is_small_course(): + return OPTIMISED_IS_SMALL_COURSE.is_enabled() diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 7b39156a8a..ac587233f3 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -65,6 +65,7 @@ from xmodule.tabs import CourseTab from .tools import get_units_with_due_date, title_or_url from .. import permissions +from ..toggles import data_download_v2_is_enabled log = logging.getLogger(__name__) @@ -600,9 +601,9 @@ def _section_data_download(course, access): settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False) and course.enable_proctored_exams ) - + section_key = 'data_download_2' if data_download_v2_is_enabled(course_key) else 'data_download' section_data = { - 'section_key': 'data_download', + 'section_key': section_key, 'section_display_name': _('Data Download'), 'access': access, 'show_generate_proctored_exam_report_button': show_proctored_report_button, diff --git a/lms/static/js/fixtures/instructor_dashboard/data_download.html b/lms/static/js/fixtures/instructor_dashboard/data_download.html index ac50e51355..500ba6d31a 100644 --- a/lms/static/js/fixtures/instructor_dashboard/data_download.html +++ b/lms/static/js/fixtures/instructor_dashboard/data_download.html @@ -1,9 +1,224 @@ -
-

${_("Click to list certificates that are issued for this course:")}

- - - - -
-
-
\ No newline at end of file +
+
    + + + + + + +
+
+
+ ${_("NOTE")}: + Please select the report type and then click Download Report button +
+
+ +
+ + +
+ +
+ +
+

${_("Click to display the grading configuration for the \ + course. The grading configuration is the breakdown of graded subsections of the course \ + (such as exams and problem sets), and can be changed on the 'Grading' \ + page (under 'Settings') in Studio.")}

+ + + + + + + + + + + + + +
+ +
+
+
+ ${_("NOTE")}: + Please select the report type and then click Download Report button +
+ + + +
+

${_("Click to list certificates that are issued for this course:")}

+
+
+
+
+ ${_("Select a problem to generate a CSV \ + file that lists all student answers to the problem. You also select a section or chapter to include \ + results of all problems in that section or chapter.")} +
+
+ ${static.renderReact( + component="ProblemBrowser", + id="react-block-listing", + props={ + "courseId": course.id, + "excludeBlockTypes": ['html', 'video', 'discussion'] + } + )} +
+ +

+ ${_("NOTE")}: + ${_("The generated report is limited to {max_entries} responses. If you expect more than {max_entries} " + "responses, try generating the report on a chapter-by-chapter, or problem-by-problem basis, or contact " + "your site administrator to increase the limit.").format(max_entries=max_entries)} +

+
+
+
+ ${_("NOTE")}: + Please select the report type and then click Download Report button +
+
+ +

Learner status

+ + + +
+

${_("Click to generate a CSV grade report for all currently enrolled students.")}

+
+
+
+
+ +
+ +
+
+
+
+
+ +

${_("Reports Available for Download")}

+

+ ${_("The reports listed below are available for download, identified by UTC date and time of generation.")} +

+ +

+ ${_("The answer distribution report listed below is generated periodically by an automated background process. \ + The report is cumulative, so answers submitted after the process starts are included in a subsequent report. \ + The report is generated several times per day.")} +

+

+ ${Text(_("{strong_start}Note{strong_end}: {ul_start}{li_start}To keep student data secure, you cannot save or \ + email these links for direct access. Copies of links expire within 5 minutes.{li_end}{li_start}Report files \ + are deleted 90 days after generation. If you will need access to old reports, download and store the files, \ + in accordance with your institution's data security policies.{li_end}{ul_end}")).format( + strong_start=HTML(""), + strong_end=HTML(""), + ul_start=HTML("

    "), + ul_end=HTML("
"), + li_start=HTML("
  • "), + li_end=HTML("
  • "), + )} +


    + +
    + +
    +
    +
    +

    ${_("Pending Tasks")}

    +
    +

    ${_("The status for any active tasks appears in a table below.")}

    +
    +
    +
    +
    +
    + diff --git a/lms/static/js/instructor_dashboard/data_download_2.js b/lms/static/js/instructor_dashboard/data_download_2.js new file mode 100644 index 0000000000..8416f090ba --- /dev/null +++ b/lms/static/js/instructor_dashboard/data_download_2.js @@ -0,0 +1,257 @@ +/* globals _, DataDownloadV2, PendingInstructorTasks, ReportDownloads */ + +(function() { + 'use strict'; + // eslint-disable-next-line no-unused-vars + var DataDownloadV2, PendingInstructorTasks, ReportDownloads, statusAjaxError; + + statusAjaxError = function() { + return window.InstructorDashboard.util.statusAjaxError.apply(this, arguments); + }; + + PendingInstructorTasks = function() { + return window.InstructorDashboard.util.PendingInstructorTasks; + }; + + ReportDownloads = function() { + return window.InstructorDashboard.util.ReportDownloads; + }; + + DataDownloadV2 = (function() { + function InstructorDashboardDataDownload($section) { + var dataDownloadObj = this; + this.$section = $section; + this.$section.data('wrapper', this); + this.$list_problem_responses_csv_input = this.$section.find("input[name='problem-location']"); + this.$download_display_text = $('.data-display-text'); + this.$download_request_response_error = $('.request-response-error'); + this.$download_display_table = $('.profile-data-display-table'); + this.$reports_request_response = $('.request-response'); + this.$reports_request_response_error = $('.request-response-error'); + this.report_downloads = new (ReportDownloads())(this.$section); + this.instructor_tasks = new (PendingInstructorTasks())(this.$section); + this.$download_report = $('.download-report'); + this.$gradeReportDownload = $('.grade-report-download'); + this.$report_type_selector = $('.report-type'); + this.$selection_informations = $('.selectionInfo'); + this.$data_display_table = $('.data-display-table-holder'); + this.$downloadProblemReport = $('#download-problem-report'); + this.$tabSwitch = $('.data-download-nav .btn-link'); + this.$selectedSection = $('#' + this.$tabSwitch.first().attr('data-section')); + this.$learnerStatus = $('.learner-status'); + + this.ERROR_MESSAGES = { + ORADataReport: gettext('Error generating ORA data report. Please try again.'), + problemGradeReport: gettext('Error generating problem grade report. Please try again.'), + profileInformation: gettext('Error generating student profile information. Please try again.'), + surveyResultReport: gettext('Error generating survey results. Please try again.'), + proctoredExamResults: gettext('Error generating proctored exam results. Please try again.'), + learnerWhoCanEnroll: gettext('Error generating list of students who may enroll. Please try again.'), + viewCertificates: gettext('Error getting issued certificates list.') + }; + + /** + * Removes text error/success messages and tables from UI + */ + this.clear_display = function() { + this.$download_display_text.empty(); + this.$download_display_table.empty(); + this.$download_request_response_error.empty(); + this.$reports_request_response.empty(); + this.$reports_request_response_error.empty(); + this.$data_display_table.empty(); + $('.msg-confirm').css({ + display: 'none' + }); + return $('.msg-error').css({ + display: 'none' + }); + }; + + this.clear_display(); + + /** + * Show and hide selected tab data + */ + this.$tabSwitch.click(function(event) { + var selectedSection = '#' + $(this).attr('data-section'); + event.preventDefault(); + $('.data-download-nav .btn-link').removeClass('active-section'); + $('section.tab-data').hide(); + $(selectedSection).show(); + $(this).addClass('active-section'); + + $(this).find('select').trigger('change'); + dataDownloadObj.$selectedSection = $(selectedSection); + + dataDownloadObj.clear_display(); + }); + + this.$tabSwitch.first().click(); + + /** + * on change of report select update show and hide related descriptions + */ + this.$report_type_selector.change(function() { + var selectedOption = dataDownloadObj.$report_type_selector.val(); + dataDownloadObj.$selection_informations.each(function(index, elem) { + if ($(elem).hasClass(selectedOption)) { + $(elem).show(); + } else { + $(elem).hide(); + } + }); + dataDownloadObj.clear_display(); + }); + + this.selectedOption = function() { + return dataDownloadObj.$selectedSection.find('select').find('option:selected'); + }; + + /** + * On click download button get selected option and pass it to handler function. + */ + this.downloadReportClickHandler = function() { + var selectedOption = dataDownloadObj.selectedOption(); + var errorMessage = dataDownloadObj.ERROR_MESSAGES[selectedOption.val()]; + + if (selectedOption.data('directdownload')) { + location.href = selectedOption.data('endpoint') + '?csv=true'; + } else if (selectedOption.data('datatable')) { + dataDownloadObj.renderDataTable(selectedOption); + } else { + dataDownloadObj.downloadCSV(selectedOption, errorMessage, false); + } + }; + this.$download_report.click(dataDownloadObj.downloadReportClickHandler); + + /** + * Call data endpoint and invoke buildDataTable to render Table UI. + * @param selected option element from report selector to get data-endpoint. + * @param errorMessage Error message in case endpoint call fail. + */ + this.renderDataTable = function(selected, errorMessage) { + var url = selected.data('endpoint'); + dataDownloadObj.clear_display(); + dataDownloadObj.$data_display_table.text(gettext('Loading data...')); + return $.ajax({ + type: 'POST', + url: url, + error: function(error) { + dataDownloadObj.OnError(error, errorMessage); + }, + success: function(data) { + dataDownloadObj.buildDataTable(data); + } + }); + }; + + + this.$downloadProblemReport.click(function() { + var data = {problem_location: dataDownloadObj.$list_problem_responses_csv_input.val()}; + dataDownloadObj.downloadCSV($(this), false, data); + }); + + this.$gradeReportDownload.click(function() { + var errorMessage = gettext('Error generating grades. Please try again.'); + var data = {verified_learners_only: dataDownloadObj.$learnerStatus.val()}; + dataDownloadObj.downloadCSV($(this), errorMessage, data); + }); + + /** + * Call data endpoint and render success/error message on dashboard UI. + */ + this.downloadCSV = function(selected, errorMessage, postData) { + var url = selected.data('endpoint'); + dataDownloadObj.clear_display(); + return $.ajax({ + type: 'POST', + dataType: 'json', + url: url, + data: postData, + error: function(error) { + dataDownloadObj.OnError(error, errorMessage); + }, + success: function(data) { + if (data.grading_config_summary) { + edx.HtmlUtils.setHtml( + dataDownloadObj.$download_display_text, edx.HtmlUtils.HTML(data.grading_config_summary)); + } else { + dataDownloadObj.$reports_request_response.text(data.status); + $('.msg-confirm').css({display: 'block'}); + } + } + }); + }; + + this.OnError = function(error, errorMessage) { + dataDownloadObj.clear_display(); + if (error.responseText) { + // eslint-disable-next-line no-param-reassign + errorMessage = JSON.parse(error.responseText); + } + dataDownloadObj.$download_request_response_error.text(errorMessage); + return dataDownloadObj.$download_request_response_error.css({ + display: 'block' + }); + }; + /** + * render data table on dashboard UI with given data. + */ + this.buildDataTable = function(data) { + var $tablePlaceholder, columns, feature, gridData, options; + dataDownloadObj.clear_display(); + options = { + enableCellNavigation: true, + enableColumnReorder: false, + forceFitColumns: true, + rowHeight: 35 + }; + columns = (function() { + var i, len, ref, results; + ref = data.queried_features; + results = []; + for (i = 0, len = ref.length; i < len; i++) { + feature = ref[i]; + results.push({ + id: feature, + field: feature, + name: data.feature_names[feature] + }); + } + return results; + }()); + gridData = data.hasOwnProperty('students') ? data.students : data.certificates; + $tablePlaceholder = $('
    ', { + class: 'slickgrid' + }); + dataDownloadObj.$download_display_table.append($tablePlaceholder); + return new window.Slick.Grid($tablePlaceholder, gridData, columns, options); + }; + } + + InstructorDashboardDataDownload.prototype.onClickTitle = function() { + this.clear_display(); + this.instructor_tasks.task_poller.start(); + return this.report_downloads.downloads_poller.start(); + }; + + InstructorDashboardDataDownload.prototype.onExit = function() { + this.instructor_tasks.task_poller.stop(); + return this.report_downloads.downloads_poller.stop(); + }; + return InstructorDashboardDataDownload; + }()); + + _.defaults(window, { + InstructorDashboard: {} + }); + + _.defaults(window.InstructorDashboard, { + sections: {} + }); + + _.defaults(window.InstructorDashboard.sections, { + DataDownloadV2: DataDownloadV2 + }); +}).call(this); diff --git a/lms/static/js/instructor_dashboard/instructor_dashboard.js b/lms/static/js/instructor_dashboard/instructor_dashboard.js index c58e7610e9..b617bb8144 100644 --- a/lms/static/js/instructor_dashboard/instructor_dashboard.js +++ b/lms/static/js/instructor_dashboard/instructor_dashboard.js @@ -163,6 +163,9 @@ such that the value can be defined later than this assignment (file load order). }, { constructor: window.InstructorDashboard.sections.DataDownload, $element: idashContent.find('.' + CSS_IDASH_SECTION + '#data_download') + }, { + constructor: window.InstructorDashboard.sections.DataDownloadV2, + $element: idashContent.find('.' + CSS_IDASH_SECTION + '#data_download_2') }, { constructor: window.InstructorDashboard.sections.ECommerce, $element: idashContent.find('.' + CSS_IDASH_SECTION + '#e-commerce') diff --git a/lms/static/js/spec/instructor_dashboard/data_download_spec.js b/lms/static/js/spec/instructor_dashboard/data_download_spec.js index 0e52e2c9b0..ad4c2bcbbf 100644 --- a/lms/static/js/spec/instructor_dashboard/data_download_spec.js +++ b/lms/static/js/spec/instructor_dashboard/data_download_spec.js @@ -1,70 +1,162 @@ -/* global define */ -define(['jquery', - 'js/instructor_dashboard/data_download', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'slick.grid'], - function($, DataDownload, AjaxHelpers) { - 'use strict'; - describe('edx.instructor_dashboard.data_download.DataDownload_Certificate', function() { - var url, data_download_certificate; +/* global define, DataDownload */ - beforeEach(function() { - loadFixtures('js/fixtures/instructor_dashboard/data_download.html'); - data_download_certificate = new window.DataDownload_Certificate($('.issued_certificates')); - url = '/courses/PU/FSc/2014_T4/instructor/api/get_issued_certificates'; - data_download_certificate.$list_issued_certificate_table_btn.data('endpoint', url); - }); +define([ + 'jquery', + 'js/instructor_dashboard/data_download_2', + 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers' +], + function($, id, AjaxHelper) { + 'use strict'; + describe('edx.instructor_dashboard.data_download', function() { + var requests, $selected, dataDownload, url, errorMessage; - it('show data on success callback', function() { - // Spy on AJAX requests - var requests = AjaxHelpers.requests(this); - var data = { - certificates: [{course_id: 'xyz_test', mode: 'honor'}], - queried_features: ['course_id', 'mode'], - feature_names: {course_id: 'Course ID', mode: ' Mode'} - }; + beforeEach(function() { + loadFixtures('js/fixtures/instructor_dashboard/data_download.html'); - data_download_certificate.$list_issued_certificate_table_btn.click(); - AjaxHelpers.expectJsonRequest(requests, 'POST', url); + dataDownload = window.InstructorDashboard.sections; + dataDownload.DataDownloadV2($('#data_download_2')); + window.InstructorDashboard.util.PendingInstructorTasks = function() { + return; + }; + requests = AjaxHelper.requests(this); + $selected = $(''); + url = $selected.data('endpoint'); + errorMessage = 'An Error is occurred with request'; + }); - // Simulate a success response from the server - AjaxHelpers.respondWithJson(requests, data); - expect(data_download_certificate.$certificate_display_table.html() - .indexOf('Course ID') !== -1).toBe(true); - expect(data_download_certificate.$certificate_display_table.html() - .indexOf('Mode') !== -1).toBe(true); - expect(data_download_certificate.$certificate_display_table.html() - .indexOf('xyz_test') !== -1).toBe(true); - expect(data_download_certificate.$certificate_display_table.html() - .indexOf('honor') !== -1).toBe(true); - }); + it('renders success message properly', function() { + dataDownload.downloadCSV($selected, errorMessage); - it('show error on failure callback', function() { - // Spy on AJAX requests - var requests = AjaxHelpers.requests(this); + AjaxHelper.expectRequest(requests, 'POST', url); + AjaxHelper.respondWithJson(requests, { + status: 'Request is succeeded' + }); + expect(dataDownload.$reports_request_response.text()).toContain('Request is succeeded'); + }); - data_download_certificate.$list_issued_certificate_table_btn.click(); - // Simulate a error response from the server - AjaxHelpers.respondWithError(requests); - expect(data_download_certificate.$certificates_request_response_error.text()) - .toEqual('Error getting issued certificates list.'); - }); - it('error should be clear from UI on success callback', function() { - var requests = AjaxHelpers.requests(this); - data_download_certificate.$list_issued_certificate_table_btn.click(); + it('renders grading config returned by the server in case of successful request ', function() { + dataDownload.downloadCSV($selected, errorMessage); - // Simulate a error response from the server - AjaxHelpers.respondWithError(requests); - expect(data_download_certificate.$certificates_request_response_error.text()) - .toEqual('Error getting issued certificates list.'); + AjaxHelper.expectRequest(requests, 'POST', url); + AjaxHelper.respondWithJson(requests, { + grading_config_summary: 'This is grading config' + }); + expect(dataDownload.$download_display_text.text()).toContain('This is grading config'); + }); - // Simulate a success response from the server - data_download_certificate.$list_issued_certificate_table_btn.click(); - AjaxHelpers.expectJsonRequest(requests, 'POST', url); + it('renders enrolled student list in case of successful request ', function() { + var data = { + available_features: [ + 'id', + 'username', + 'first_name', + 'last_name', + 'is_staff', + 'email', + 'date_joined', + 'last_login', + 'name', + 'language', + 'location', + 'year_of_birth', + 'gender', + 'level_of_education', + 'mailing_address', + 'goals', + 'meta', + 'city', + 'country' + ], + course_id: 'test_course_101', + feature_names: { + gender: 'Gender', + goals: 'Goals', + enrollment_mode: 'Enrollment Mode', + email: 'Email', + country: 'Country', + id: 'User ID', + mailing_address: 'Mailing Address', + last_login: 'Last Login', + date_joined: 'Date Joined', + location: 'Location', + city: 'City', + verification_status: 'Verification Status', + year_of_birth: 'Birth Year', + name: 'Name', + username: 'Username', + level_of_education: 'Level of Education', + language: 'Language' + }, + students: [ + { + gender: 'Male', + goals: 'Goal', + enrollment_mode: 'audit', + email: 'test@example.com', + country: 'PK', + year_of_birth: 'None', + id: '8', + mailing_address: 'None', + last_login: '2020-06-17T08:17:00.561Z', + date_joined: '2019-09-25T20:06:17.564Z', + location: 'None', + verification_status: 'N/A', + city: 'None', + name: 'None', + username: 'test', + level_of_education: 'None', + language: 'None' + } + ], + queried_features: [ + 'id', + 'username', + 'name', + 'email', + 'language', + 'location', + 'year_of_birth', + 'gender', + 'level_of_education', + 'mailing_address', + 'goals', + 'enrollment_mode', + 'verification_status', + 'last_login', + 'date_joined', + 'city', + 'country' + ], + students_count: 1 + }; + dataDownload.renderDataTable($selected, errorMessage); + AjaxHelper.expectRequest(requests, 'POST', url); + AjaxHelper.respondWithJson(requests, data); + // eslint-disable-next-line vars-on-top + var dataTable = dataDownload.$data_display_table.html(); + // eslint-disable-next-line vars-on-top + var existInHtml = function(value) { + expect(dataTable.indexOf(data.feature_names[value]) !== -1).toBe(false); + expect(dataTable.indexOf(data.students[0][value]) !== -1).toBe(false); + }; + data.queried_features.forEach(existInHtml); + }); - expect(data_download_certificate.$certificates_request_response_error.text()) - .not.toEqual('Error getting issued certificates list'); - }); - }); - }); + + it('calls renderDataTable function if data-datatable is true', function() { + $selected = $selected.attr('data-datatable', true); + spyOn(dataDownload, 'selectedOption').and.returnValue($selected); + spyOn(dataDownload, 'renderDataTable'); + dataDownload.downloadReportClickHandler(); + expect(dataDownload.renderDataTable).toHaveBeenCalled(); + }); + + it('calls downloadCSV function if no other data type is specified', function() { + spyOn(dataDownload, 'selectedOption').and.returnValue($selected); + spyOn(dataDownload, 'downloadCSV'); + dataDownload.downloadReportClickHandler(); + expect(dataDownload.downloadCSV).toHaveBeenCalled(); + }); + }); + }); diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 7c0cdb2c12..cb93e8bb0c 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -757,6 +757,7 @@ 'js/spec/financial-assistance/financial_assistance_form_view_spec.js', 'js/spec/groups/views/cohorts_spec.js', 'js/spec/groups/views/discussions_spec.js', + 'js/spec/instructor_dashboard/data_download_spec.js', 'js/spec/instructor_dashboard/certificates_bulk_exception_spec.js', 'js/spec/instructor_dashboard/certificates_exception_spec.js', 'js/spec/instructor_dashboard/certificates_invalidation_spec.js', diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 1a319abf9f..8c509acaae 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -313,28 +313,9 @@ } } } - - .report-generation-status { - .msg { - display: inherit; - - &.error { - color: $error-color; - } - - > div { - display: inline-block; - } - - a { - margin: 0 1rem; - - & > div { - display: inline-block; - } - } + .data-download-nav { + @extend .instructor-nav } - } } // elements - general @@ -1506,8 +1487,33 @@ // view - data download // -------------------- -.instructor-dashboard-wrapper-2 section.idash-section#data_download { - input { + + +.instructor-dashboard-wrapper-2 section.idash-section#data_download_2 { + .data-download-grid-container { + display:grid; + grid-template-columns:repeat(auto-fit, minmax(20rem, 1fr)); + grid-auto-rows: minmax(250px, auto); + div.card { + border-right: 2px solid grey; + border-bottom: 2px solid grey; + padding: 1em; + display: grid; + grid-template-rows: 1fr 3fr; + p.grid { + display: grid; + grid-template-columns: 1fr; + align-self: self-end; + } + .problem-browser { + display: grid; + grid-gap: 1em; + } + } + } + + + input { margin-bottom: 1em; line-height: 1.3em; } @@ -1531,8 +1537,79 @@ } } - #react-problem-report { - margin: $baseline 0; + .block-browser { + .header { + display: flex; + flex-direction: row; + align-items: center; + + .title { + margin: 0 0.5em; + } + } + + ul { + display: flex; + flex-direction: column; + padding: 0; + margin: 0; + + li { + display: flex; + flex-direction: row; + align-items: center; + margin: 0.25em 0; + + .block-name { + flex-grow: 1; + margin-right: 0.5em; + text-align: left; + } + } + } + } + + .problem-browser { + .block-browser { + position: absolute; + background: white; + padding: 5px; + box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23); + z-index: 2; + } + + input { + max-width: 800px; + width: 100%; + margin-bottom: 0; + } + } + +} + +.instructor-dashboard-wrapper-2 section.idash-section#data_download { + input { + margin-bottom: 1em; + line-height: 1.3em; + } + + .reports-download-container { + .data-display-table { + .slickgrid { + height: 400px; + } + } + + .report-downloads-table { + .slickgrid { + height: 300px; + padding: ($baseline/4); + } + // Disable horizontal scroll bar when grid only has 1 column. Remove this CSS class when more columns added. + .slick-viewport { + overflow-x: hidden !important; + } + } } .block-browser { @@ -2970,3 +3047,41 @@ div.staff_actions { color: theme-color("success"); } } + +.action-type-container p { + line-height: 2; +} + +.mb-15 { + margin-bottom: 20px; +} +.mb-20 { + margin-bottom: 20px; +} + +.download-report { + margin-left: 10px; +} + +.selector { + width: 315px; + height: 34px +} +.data-download-container .message { + border-radius: 1px; + padding: 10px 15px; + margin-bottom: 20px; + font-weight: 600; +} +.font-size-100 { + font-size: 100% +} + +.problem-selector{ + height: 34px; + min-width: 300px; + max-width: 780px; + border: 1px solid grey; + display: block; + padding: 8px; +} diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download_2.html b/lms/templates/instructor/instructor_dashboard_2/data_download_2.html new file mode 100644 index 0000000000..fe3e12cece --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/data_download_2.html @@ -0,0 +1,93 @@ +<%page args="section_data" expression_filter="h"/> +<%namespace name='static' file='/static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> + +
    +
      + + + %if settings.FEATURES.get('ENABLE_GRADE_DOWNLOADS'): + + + %if settings.FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']: + + %endif + %endif + +
    + <%include file="./data_download_2/reports.html" args="section_data=section_data, **context.kwargs" /> + + <%include file="./data_download_2/grading.html" args="section_data=section_data, **context.kwargs" /> + + %if settings.FEATURES.get('ENABLE_GRADE_DOWNLOADS'): + <%include file="./data_download_2/certificates.html" args="section_data=section_data, **context.kwargs" /> + <%include file="./data_download_2/problem_report.html" args="section_data=section_data, **context.kwargs" /> + %endif +
    +
    + +
    + +
    +
    +
    +
    +
    + +

    ${_("Reports Available for Download")}

    +

    + ${_("The reports listed below are available for download, identified by UTC date and time of generation.")} +

    + + %if settings.FEATURES.get('ENABLE_ASYNC_ANSWER_DISTRIBUTION'): +

    + ${_("The answer distribution report listed below is generated periodically by an automated background process. \ + The report is cumulative, so answers submitted after the process starts are included in a subsequent report. \ + The report is generated several times per day.")} +

    + %endif + + ## Translators: a table of URL links to report files appears after this sentence. +

    + ${Text(_("{strong_start}Note{strong_end}: {ul_start}{li_start}To keep student data secure, you cannot save or \ + email these links for direct access. Copies of links expire within 5 minutes.{li_end}{li_start}Report files \ + are deleted 90 days after generation. If you will need access to old reports, download and store the files, \ + in accordance with your institution's data security policies.{li_end}{ul_end}")).format( + strong_start=HTML(""), + strong_end=HTML(""), + ul_start=HTML("

      "), + ul_end=HTML("
    "), + li_start=HTML("
  • "), + li_end=HTML("
  • "), + )} +


    + +
    + +
    + +%if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): +
    +
    +

    ${_("Pending Tasks")}

    +
    +

    ${_("The status for any active tasks appears in a table below.")}

    +
    +
    +
    +
    +
    +%endif + diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/data_download_2/certificates.html new file mode 100644 index 0000000000..ea36196553 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/data_download_2/certificates.html @@ -0,0 +1,33 @@ +<%page args="section_data" expression_filter="h"/> +<%namespace name='static' file='/static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> + +
    +
    + ${_("Note")}: + Please certificate report type option and then click Download Report button. +
    + + + + +
    +

    ${_("Click to list certificates that are issued for this course:")}

    +
    +
    diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download_2/grading.html b/lms/templates/instructor/instructor_dashboard_2/data_download_2/grading.html new file mode 100644 index 0000000000..5cd84df4a6 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/data_download_2/grading.html @@ -0,0 +1,28 @@ +<%page args="section_data" expression_filter="h"/> +<%namespace name='static' file='/static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> + +%if settings.FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']: +
    +
    + ${_("Note")}: + Please select learner status and then click "Download Course Grade Report" button. +
    +

    Learner status

    + + + +
    +

    ${_("Click to generate a CSV grade report for all currently enrolled students.")}

    +
    +
    +%endif diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download_2/problem_report.html b/lms/templates/instructor/instructor_dashboard_2/data_download_2/problem_report.html new file mode 100644 index 0000000000..3299f6860c --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/data_download_2/problem_report.html @@ -0,0 +1,44 @@ +<%page args="section_data" expression_filter="h"/> +<%namespace name='static' file='/static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> + +
    +
    + ${_("Select a problem to generate a CSV \ + file that lists all student answers to the problem. You also select a section or chapter to include \ + results of all problems in that section or chapter.")} +
    +
    + ${static.renderReact( + component="ProblemBrowser", + id="react-block-listing", + props={ + "courseId": course.id, + "excludeBlockTypes": ['html', 'video', 'discussion'] + } + )} +
    + + + + + + + <% max_entries = settings.FEATURES.get('MAX_PROBLEM_RESPONSES_COUNT') %> + %if max_entries is not None: +

    + ${_("NOTE")}: + ${_("The generated report is limited to {max_entries} responses. If you expect more than {max_entries} " + "responses, try generating the report on a chapter-by-chapter, or problem-by-problem basis, or contact " + "your site administrator to increase the limit.").format(max_entries=max_entries)} +

    + %endif +
    diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download_2/reports.html b/lms/templates/instructor/instructor_dashboard_2/data_download_2/reports.html new file mode 100644 index 0000000000..71a3c2f45a --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/data_download_2/reports.html @@ -0,0 +1,109 @@ +<%page args="section_data" expression_filter="h"/> +<%namespace name='static' file='/static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> + +
    +
    + ${_("Note")}: + Please select the report type and then click "Download Report" button +
    +
    + +
    + + +
    + +
    + +
    +

    ${_("Click to display the grading configuration for the \ + course. The grading configuration is the breakdown of graded subsections of the course \ + (such as exams and problem sets), and can be changed on the 'Grading' \ + page (under 'Settings') in Studio.")}

    + + + + + + % if not disable_buttons: + + %endif + + + + + + + + + +
    + +