diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index b2a9be1919..b7cdd755de 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -65,6 +65,8 @@ from instructor.views.api import require_finance_admin from instructor.tests.utils import FakeContentTask, FakeEmail, FakeEmailInfo from instructor.views.api import _split_input_list, common_exceptions_400, generate_unique_password from instructor_task.api_helper import AlreadyRunningError +from certificates.tests.factories import GeneratedCertificateFactory +from certificates.models import CertificateStatuses from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings @@ -3855,6 +3857,118 @@ class TestDueDateExtensions(SharedModuleStoreTestCase, LoginEnrollmentTestCase): self.user1.profile.name, self.user1.username)}) +@attr('shard_1') +class TestCourseIssuedCertificatesData(SharedModuleStoreTestCase): + """ + Test data dumps for issued certificates. + """ + @classmethod + def setUpClass(cls): + super(TestCourseIssuedCertificatesData, cls).setUpClass() + cls.course = CourseFactory.create() + + def setUp(self): + super(TestCourseIssuedCertificatesData, self).setUp() + self.instructor = InstructorFactory(course_key=self.course.id) + self.client.login(username=self.instructor.username, password='test') + + def generate_certificate(self, course_id, mode, status): + """ + Generate test certificate + """ + test_user = UserFactory() + GeneratedCertificateFactory.create( + user=test_user, + course_id=course_id, + mode=mode, + status=status + ) + + def test_certificates_features_against_status(self): + """ + Test certificates with status 'downloadable' should be in the response. + """ + url = reverse('get_issued_certificates', kwargs={'course_id': unicode(self.course.id)}) + # firstly generating downloadable certificates with 'honor' mode + certificate_count = 3 + for __ in xrange(certificate_count): + self.generate_certificate(course_id=self.course.id, mode='honor', status=CertificateStatuses.generating) + + response = self.client.get(url) + res_json = json.loads(response.content) + self.assertIn('certificates', res_json) + self.assertEqual(len(res_json['certificates']), 0) + + # Certificates with status 'downloadable' should be in response. + self.generate_certificate(course_id=self.course.id, mode='honor', status=CertificateStatuses.downloadable) + response = self.client.get(url) + res_json = json.loads(response.content) + self.assertIn('certificates', res_json) + self.assertEqual(len(res_json['certificates']), 1) + + def test_certificates_features_group_by_mode(self): + """ + Test for certificate csv features against mode. Certificates should be group by 'mode' in reponse. + """ + url = reverse('get_issued_certificates', kwargs={'course_id': unicode(self.course.id)}) + # firstly generating downloadable certificates with 'honor' mode + certificate_count = 3 + for __ in xrange(certificate_count): + self.generate_certificate(course_id=self.course.id, mode='honor', status=CertificateStatuses.downloadable) + + response = self.client.get(url) + res_json = json.loads(response.content) + self.assertIn('certificates', res_json) + self.assertEqual(len(res_json['certificates']), 1) + + # retrieve the first certificate from the list, there should be 3 certificates for 'honor' mode. + certificate = res_json['certificates'][0] + self.assertEqual(certificate.get('total_issued_certificate'), 3) + self.assertEqual(certificate.get('mode'), 'honor') + self.assertEqual(certificate.get('course_id'), str(self.course.id)) + + # Now generating downloadable certificates with 'verified' mode + for __ in xrange(certificate_count): + self.generate_certificate( + course_id=self.course.id, + mode='verified', + status=CertificateStatuses.downloadable + ) + + response = self.client.get(url) + res_json = json.loads(response.content) + self.assertIn('certificates', res_json) + + # total certificate count should be 2 for 'verified' mode. + self.assertEqual(len(res_json['certificates']), 2) + + # retrieve the second certificate from the list + certificate = res_json['certificates'][1] + self.assertEqual(certificate.get('total_issued_certificate'), 3) + self.assertEqual(certificate.get('mode'), 'verified') + + def test_certificates_features_csv(self): + """ + Test for certificate csv features. + """ + url = reverse('get_issued_certificates', kwargs={'course_id': unicode(self.course.id)}) + url += '?csv=true' + # firstly generating downloadable certificates with 'honor' mode + certificate_count = 3 + for __ in xrange(certificate_count): + self.generate_certificate(course_id=self.course.id, mode='honor', status=CertificateStatuses.downloadable) + + current_date = datetime.date.today().strftime("%B %d, %Y") + response = self.client.get(url) + self.assertEqual(response['Content-Type'], 'text/csv') + self.assertEqual(response['Content-Disposition'], 'attachment; filename={0}'.format('issued_certificates.csv')) + self.assertEqual( + response.content.strip(), + '"CourseID","Certificate Type","Total Certificates Issued","Date Report Run"\r\n"' + + str(self.course.id) + '","honor","3","' + current_date + '"' + ) + + @attr('shard_1') @override_settings(REGISTRATION_CODE_LENGTH=8) class TestCourseRegistrationCodes(SharedModuleStoreTestCase): diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index 027c15671d..cfb967bedd 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -136,9 +136,9 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): """Check that the certificates section is visible on the instructor dash. """ response = self.client.get(self.url) if is_visible: - self.assertContains(response, "Certificates") + self.assertContains(response, "Student-Generated Certificates") else: - self.assertNotContains(response, "Certificates") + self.assertNotContains(response, "Student-Generated Certificates") @contextlib.contextmanager def _certificate_status(self, description, status): diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 529dacc26a..a6911eba1b 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1089,6 +1089,45 @@ def re_validate_invoice(obj_invoice): return JsonResponse({'message': message}) +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_level('staff') +def get_issued_certificates(request, course_id): # pylint: disable=invalid-name + """ + Responds with JSON if CSV is not required. contains a list of issued certificates. + Arguments: + course_id + Returns: + {"certificates": [{course_id: xyz, mode: 'honor'}, ...]} + + """ + course_key = CourseKey.from_string(course_id) + csv_required = request.GET.get('csv', 'false') + + query_features = ['course_id', 'mode', 'total_issued_certificate', 'report_run_date'] + query_features_names = [ + ('course_id', _('CourseID')), + ('mode', _('Certificate Type')), + ('total_issued_certificate', _('Total Certificates Issued')), + ('report_run_date', _('Date Report Run')) + ] + certificates_data = instructor_analytics.basic.issued_certificates(course_key, query_features) + if csv_required.lower() == 'true': + __, data_rows = instructor_analytics.csvs.format_dictlist(certificates_data, query_features) + return instructor_analytics.csvs.create_csv_response( + 'issued_certificates.csv', + [col_header for __, col_header in query_features_names], + data_rows + ) + else: + response_payload = { + 'certificates': certificates_data, + 'queried_features': query_features, + 'feature_names': dict(query_features_names) + } + return JsonResponse(response_payload) + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index f24367a159..5a94d78abb 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -23,6 +23,8 @@ urlpatterns = patterns( 'instructor.views.api.get_grading_config', name="get_grading_config"), url(r'^get_students_features(?P/csv)?$', 'instructor.views.api.get_students_features', name="get_students_features"), + url(r'^get_issued_certificates/$', + 'instructor.views.api.get_issued_certificates', name="get_issued_certificates"), url(r'^get_students_who_may_enroll$', 'instructor.views.api.get_students_who_may_enroll', name="get_students_who_may_enroll"), url(r'^get_user_invoice_preference$', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 6ac0544c49..82d48c48bd 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -504,6 +504,9 @@ def _section_data_download(course, access): 'get_problem_responses_url': reverse('get_problem_responses', kwargs={'course_id': unicode(course_key)}), 'get_grading_config_url': reverse('get_grading_config', kwargs={'course_id': unicode(course_key)}), 'get_students_features_url': reverse('get_students_features', kwargs={'course_id': unicode(course_key)}), + 'get_issued_certificates_url': reverse( + 'get_issued_certificates', kwargs={'course_id': unicode(course_key)} + ), 'get_students_who_may_enroll_url': reverse( 'get_students_who_may_enroll', kwargs={'course_id': unicode(course_key)} ), diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index a91fdc9cde..10f22fff68 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -4,6 +4,7 @@ Student and course analytics. Serve miscellaneous course and student data """ import json +import datetime from shoppingcart.models import ( PaidCourseRegistration, CouponRedemption, CourseRegCodeItem, RegistrationCodeRedemption, CourseRegistrationCodeInvoiceItem @@ -19,6 +20,9 @@ from microsite_configuration import microsite from student.models import CourseEnrollmentAllowed from edx_proctoring.api import get_all_exam_attempts from courseware.models import StudentModule +from certificates.models import GeneratedCertificate +from django.db.models import Count +from certificates.models import CertificateStatuses STUDENT_FEATURES = ('id', 'username', 'first_name', 'last_name', 'is_staff', 'email') @@ -38,6 +42,7 @@ SALE_ORDER_FEATURES = ('id', 'company_name', 'company_contact_name', 'company_co AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at', 'is_valid') COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date', 'is_active') +CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason') UNAVAILABLE = "[unavailable]" @@ -162,6 +167,32 @@ def sale_record_features(course_id, features): return [sale_records_info(sale, features) for sale in sales] +def issued_certificates(course_key, features): + """ + Return list of issued certificates as dictionaries against the given course key. + + issued_certificates(course_key, features) + would return [ + {course_id: 'abc', 'total_issued_certificate': '5', 'mode': 'honor'} + {course_id: 'abc', 'total_issued_certificate': '10', 'mode': 'verified'} + {course_id: 'abc', 'total_issued_certificate': '15', 'mode': 'Professional Education'} + ] + """ + + report_run_date = datetime.date.today().strftime("%B %d, %Y") + certificate_features = [x for x in CERTIFICATE_FEATURES if x in features] + generated_certificates = list(GeneratedCertificate.objects.filter( + course_id=course_key, + status=CertificateStatuses.downloadable + ).values(*certificate_features).annotate(total_issued_certificate=Count('mode'))) + + # Report run date + for data in generated_certificates: + data['report_run_date'] = report_run_date + + return generated_certificates + + def enrolled_students_features(course_key, features): """ Return list of student features as dictionaries. diff --git a/lms/static/coffee/src/instructor_dashboard/data_download.coffee b/lms/static/coffee/src/instructor_dashboard/data_download.coffee index 55e5f40562..a4de311fce 100644 --- a/lms/static/coffee/src/instructor_dashboard/data_download.coffee +++ b/lms/static/coffee/src/instructor_dashboard/data_download.coffee @@ -11,12 +11,66 @@ std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, argum PendingInstructorTasks = -> window.InstructorDashboard.util.PendingInstructorTasks ReportDownloads = -> window.InstructorDashboard.util.ReportDownloads +# Data Download Certificate issued +class @DataDownload_Certificate + constructor: (@$container) -> + # gather elements + @$list_issued_certificate_table_btn = @$container.find("input[name='issued-certificates-list']'") + @$list_issued_certificate_csv_btn = @$container.find("input[name='issued-certificates-csv']'") + @$certificate_display_table = @$container.find '.certificate-data-display-table' + @$certificates_request_response_error = @$container.find '.issued-certificates-error.request-response-error' + + + @$list_issued_certificate_table_btn.click (e) => + url = @$list_issued_certificate_table_btn.data 'endpoint' + # Dynamically generate slickgrid table for displaying issued certificate information. + @clear_ui() + @$certificate_display_table.text gettext('Loading data...') + # fetch user list + $.ajax + type: 'POST' + url: url + error: (std_ajax_err) => + @clear_ui() + @$certificates_request_response_error.text gettext("Error getting issued certificates list.") + $(".issued_certificates .issued-certificates-error.msg-error").css({"display":"block"}) + success: (data) => + @clear_ui() + # display on a SlickGrid + options = + enableCellNavigation: true + enableColumnReorder: false + forceFitColumns: true + rowHeight: 35 + + columns = ({id: feature, field: feature, name: data.feature_names[feature]} for feature in data.queried_features) + grid_data = data.certificates + + $table_placeholder = $ '
', class: 'slickgrid' + @$certificate_display_table.append $table_placeholder + new Slick.Grid($table_placeholder, grid_data, columns, options) + + @$list_issued_certificate_csv_btn.click (e) => + @clear_ui() + url = @$list_issued_certificate_csv_btn.data 'endpoint' + location.href = url + '?csv=true' + + clear_ui: -> + # Clear any generated tables, warning messages, etc of certificates. + @$certificate_display_table.empty() + @$certificates_request_response_error.empty() + $(".issued-certificates-error.msg-error").css({"display":"none"}) + # Data Download Section class DataDownload constructor: (@$section) -> # attach self to html so that instructor_dashboard.coffee can find # this object to call event handlers like 'onClickTitle' @$section.data 'wrapper', @ + + # isolate # initialize DataDownload_Certificate subsection + new DataDownload_Certificate @$section.find '.issued_certificates' + # gather elements @$list_studs_btn = @$section.find("input[name='list-profiles']'") @$list_studs_csv_btn = @$section.find("input[name='list-profiles-csv']'") @@ -34,7 +88,7 @@ class DataDownload @$download_display_text = @$download.find '.data-display-text' @$download_request_response_error = @$download.find '.request-response-error' @$reports = @$section.find '.reports-download-container' - @$download_display_table = @$reports.find '.data-display-table' + @$download_display_table = @$reports.find '.profile-data-display-table' @$reports_request_response = @$reports.find '.request-response' @$reports_request_response_error = @$reports.find '.request-response-error' diff --git a/lms/static/js/fixtures/instructor_dashboard/data_download.html b/lms/static/js/fixtures/instructor_dashboard/data_download.html new file mode 100644 index 0000000000..ac50e51355 --- /dev/null +++ b/lms/static/js/fixtures/instructor_dashboard/data_download.html @@ -0,0 +1,9 @@ +
+

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

+ + + + +
+
+
\ No newline at end of file diff --git a/lms/static/js/spec/instructor_dashboard/data_download_spec.js b/lms/static/js/spec/instructor_dashboard/data_download_spec.js new file mode 100644 index 0000000000..5e5bb462fb --- /dev/null +++ b/lms/static/js/spec/instructor_dashboard/data_download_spec.js @@ -0,0 +1,67 @@ +/*global define */ +define(['jquery', 'coffee/src/instructor_dashboard/data_download', 'common/js/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; + + 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); + }); + + 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'} + }; + + data_download_certificate.$list_issued_certificate_table_btn.click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', url); + + // 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('show error on failure callback', function() { + // Spy on AJAX requests + var requests = AjaxHelpers.requests(this); + + 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(); + + // 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.'); + + // Simulate a success response from the server + data_download_certificate.$list_issued_certificate_table_btn.click(); + AjaxHelpers.expectJsonRequest(requests, 'POST', url); + + expect(data_download_certificate.$certificates_request_response_error.text()) + .not.toEqual('Error getting issued certificates list'); + }); + }); + }); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 661eb576c7..ae2cdb7e02 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -6,6 +6,7 @@ 'codemirror': 'xmodule_js/common_static/js/vendor/CodeMirror/codemirror', 'jquery': 'xmodule_js/common_static/js/vendor/jquery.min', 'jquery.ui': 'xmodule_js/common_static/js/vendor/jquery-ui.min', + 'jquery.eventDrag': 'xmodule_js/common_static/js/vendor/jquery.event.drag-2.2', 'jquery.flot': 'xmodule_js/common_static/js/vendor/flot/jquery.flot.min', 'jquery.form': 'xmodule_js/common_static/js/vendor/jquery.form', 'jquery.markitup': 'xmodule_js/common_static/js/vendor/markitup/jquery.markitup', @@ -89,7 +90,9 @@ 'annotator_1.2.9': 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min', // Common edx utils - 'common/js/utils/edx.utils.validate': 'xmodule_js/common_static/common/js/utils/edx.utils.validate' + 'common/js/utils/edx.utils.validate': 'xmodule_js/common_static/common/js/utils/edx.utils.validate', + 'slick.grid': 'xmodule_js/common_static/js/vendor/slick.grid', + 'slick.core': 'xmodule_js/common_static/js/vendor/slick.core' }, shim: { 'gettext': { diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 0605d739b7..a2ee74ff77 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -65,6 +65,9 @@ lib_paths: - xmodule_js/common_static/js/vendor/moment.min.js - xmodule_js/common_static/js/vendor/moment-with-locales.min.js - xmodule_js/common_static/common/js/utils/edx.utils.validate.js + - xmodule_js/common_static/js/vendor/slick.core.js + - xmodule_js/common_static/js/vendor/slick.grid.js + - xmodule_js/common_static/js/vendor/jquery.event.drag-2.2.js # Paths to source JavaScript files src_paths: diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index 36f897be16..07c43b3faf 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -52,11 +52,21 @@

+
+

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

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

${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}

%endif -
+
%if settings.FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']:

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