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.")}