Commit a4cc266c by Matt Drayer

Merge pull request #10126 from edx/asadiqbal08/SOL-1288

asadiqbal08/SOL-1288 Add view of Certs generated to the Instructor Dashboard
parents d7c8cb80 74b08249
......@@ -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
......@@ -3856,6 +3858,118 @@ class TestDueDateExtensions(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
@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):
"""
......
......@@ -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):
......
......@@ -1092,6 +1092,45 @@ def re_validate_invoice(obj_invoice):
@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')
def get_students_features(request, course_id, csv=False): # pylint: disable=redefined-outer-name
"""
Respond with json which contains a summary of all enrolled students profile information.
......
......@@ -23,6 +23,8 @@ urlpatterns = patterns(
'instructor.views.api.get_grading_config', name="get_grading_config"),
url(r'^get_students_features(?P<csv>/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$',
......
......@@ -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)}
),
......
......@@ -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.
......
......@@ -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 = $ '<div/>', 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'
......
<div class="issued_certificates">
<p>${_("Click to list certificates that are issued for this course:")}</p>
<span>
<input type="button" name="issued-certificates-list" value="View Certificates Issued" >
<input type="button" name="issued-certificates-csv" value="Download CSV of Certificates Issued" >
</span>
<div class="data-display-table certificate-data-display-table" id="data-issued-certificates-table"></div>
<div class="issued-certificates-error request-response-error msg msg-error copy"></div>
</div>
\ No newline at end of file
/*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');
});
});
});
......@@ -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': {
......
......@@ -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:
......
......@@ -52,11 +52,21 @@
<input type="button" name="list-problem-responses-csv" value="${_("Download a CSV of problem responses")}" data-endpoint="${ section_data['get_problem_responses_url'] }" data-csv="true">
</p>
<div class="issued_certificates">
<p>${_("Click to list certificates that are issued for this course:")}</p>
<span>
<input type="button" name="issued-certificates-list" value="${_("View Certificates Issued")}" data-csv="false" data-endpoint="${ section_data['get_issued_certificates_url'] }">
<input type="button" name="issued-certificates-csv" value="${_("Download CSV of Certificates Issued")}" data-csv="true" data-endpoint="${ section_data['get_issued_certificates_url'] }">
</span>
<div class="data-display-table certificate-data-display-table" id="data-issued-certificates-table"></div>
<div class="issued-certificates-error request-response-error msg msg-error copy"></div>
</div>
% if not disable_buttons:
<p>${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}</p>
<p><input type="button" name="list-profiles" value="${_("List enrolled students' profile information")}" data-endpoint="${ section_data['get_students_features_url'] }"></p>
%endif
<div class="data-display-table" id="data-student-profiles-table"></div>
<div class="data-display-table profile-data-display-table" id="data-student-profiles-table"></div>
%if settings.FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']:
<p>${_("Click to generate a CSV grade report for all currently enrolled students.")}</p>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment