Commit 940f8922 by Will Daly

ECOM-1140: Instructor dashboard example certificates

Allow global staff to generate example certificates
on the instructor dashboard.

Allow global staff to enable/disable self-generated
certificates for a course.
parent 1e52687f
......@@ -15,6 +15,7 @@ from django.http import Http404, HttpResponseBadRequest
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
from certificates import api as certs_api
from certificates.models import CertificateStatuses, CertificateGenerationConfiguration
from certificates.tests.factories import GeneratedCertificateFactory
from edxmako.middleware import MakoMiddleware
......@@ -677,10 +678,19 @@ class ProgressPageTests(ModuleStoreTestCase):
resp = views.progress(self.request, course_id=self.course.id.to_deprecated_string())
self.assertEqual(resp.status_code, 200)
def test_resp_with_generate_cert_config_enabled(self):
def test_generate_cert_config(self):
resp = views.progress(self.request, course_id=unicode(self.course.id))
self.assertNotContains(resp, 'Create Your Certificate')
# Enable the feature, but do not enable it for this course
CertificateGenerationConfiguration(enabled=True).save()
resp = views.progress(self.request, course_id=unicode(self.course.id))
self.assertEqual(resp.status_code, 200)
self.assertNotContains(resp, 'Create Your Certificate')
# Enable certificate generation for this course
certs_api.set_cert_generation_enabled(self.course.id, True)
resp = views.progress(self.request, course_id=unicode(self.course.id))
self.assertContains(resp, 'Create Your Certificate')
class VerifyCourseKeyDecoratorTests(TestCase):
......
......@@ -23,8 +23,7 @@ from django.utils.timezone import UTC
from django.views.decorators.http import require_GET, require_POST
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.shortcuts import redirect
from certificates.api import certificate_downloadable_status, generate_user_certificates
from certificates.models import CertificateGenerationConfiguration
from certificates import api as certs_api
from edxmako.shortcuts import render_to_response, render_to_string, marketing_link
from django_future.csrf import ensure_csrf_cookie
from django.views.decorators.cache import cache_control
......@@ -1013,7 +1012,7 @@ def _progress(request, course_key, student_id):
raise Http404
# checking certificate generation configuration
show_generate_cert_btn = CertificateGenerationConfiguration.current().enabled
show_generate_cert_btn = certs_api.cert_generation_enabled(course_key)
context = {
'course': course,
......@@ -1023,12 +1022,12 @@ def _progress(request, course_key, student_id):
'staff_access': staff_access,
'student': student,
'reverifications': fetch_reverify_banner_info(request, course_key),
'passed': is_course_passed(course, grade_summary) if show_generate_cert_btn else False,
'passed': is_course_passed(course, grade_summary),
'show_generate_cert_btn': show_generate_cert_btn
}
if show_generate_cert_btn:
context.update(certificate_downloadable_status(student, course_key))
context.update(certs_api.certificate_downloadable_status(student, course_key))
with grades.manual_transaction():
response = render_to_response('courseware/progress.html', context)
......@@ -1301,10 +1300,10 @@ def generate_user_cert(request, course_id):
if not is_course_passed(course, None, student, request):
return HttpResponseBadRequest(_("Your certificate will be available when you pass the course."))
certificate_status = certificate_downloadable_status(student, course.id)
certificate_status = certs_api.certificate_downloadable_status(student, course.id)
if not certificate_status["is_downloadable"] and not certificate_status["is_generating"]:
generate_user_certificates(student, course.id, course=course)
certs_api.generate_user_certificates(student, course.id)
_track_successful_certificate_generation(student.id, course.id)
return HttpResponse(_("Creating certificate"))
......
"""Tests for the certificates panel of the instructor dash. """
import contextlib
import ddt
import mock
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from config_models.models import cache
from courseware.tests.factories import GlobalStaffFactory, InstructorFactory
from certificates.models import CertificateGenerationConfiguration
from certificates import api as certs_api
@ddt.ddt
class CertificatesInstructorDashTest(ModuleStoreTestCase):
"""Tests for the certificate panel of the instructor dash. """
ERROR_REASON = "An error occurred!"
DOWNLOAD_URL = "http://www.example.com/abcd123/cert.pdf"
def setUp(self):
super(CertificatesInstructorDashTest, self).setUp()
self.course = CourseFactory.create()
self.url = reverse(
'instructor_dashboard',
kwargs={'course_id': unicode(self.course.id)}
)
self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id)
# Need to clear the cache for model-based configuration
cache.clear()
# Enable the certificate generation feature
CertificateGenerationConfiguration.objects.create(enabled=True)
def test_visible_only_to_global_staff(self):
# Instructors don't see the certificates section
self.client.login(username=self.instructor.username, password="test")
self._assert_certificates_visible(False)
# Global staff can see the certificates section
self.client.login(username=self.global_staff.username, password="test")
self._assert_certificates_visible(True)
def test_visible_only_when_feature_flag_enabled(self):
# Disable the feature flag
CertificateGenerationConfiguration.objects.create(enabled=False)
cache.clear()
# Now even global staff can't see the certificates section
self.client.login(username=self.global_staff.username, password="test")
self._assert_certificates_visible(False)
@ddt.data("started", "error", "success")
def test_show_certificate_status(self, status):
self.client.login(username=self.global_staff.username, password="test")
with self._certificate_status("honor", status):
self._assert_certificate_status("honor", status)
def test_show_enabled_button(self):
self.client.login(username=self.global_staff.username, password="test")
# Initially, no example certs are generated, so
# the enable button should be disabled
self._assert_enable_certs_button_is_disabled()
with self._certificate_status("honor", "success"):
# Certs are disabled for the course, so the enable button should be shown
self._assert_enable_certs_button(True)
# Enable certificates for the course
certs_api.set_cert_generation_enabled(self.course.id, True)
# Now the "disable" button should be shown
self._assert_enable_certs_button(False)
def test_can_disable_even_after_failure(self):
self.client.login(username=self.global_staff.username, password="test")
with self._certificate_status("honor", "error"):
# When certs are disabled for a course, then don't allow them
# to be enabled if certificate generation doesn't complete successfully
certs_api.set_cert_generation_enabled(self.course.id, False)
self._assert_enable_certs_button_is_disabled()
# However, if certificates are already enabled, allow them
# to be disabled even if an error has occurred
certs_api.set_cert_generation_enabled(self.course.id, True)
self._assert_enable_certs_button(False)
def _assert_certificates_visible(self, is_visible):
"""Check that the certificates section is visible on the instructor dash. """
response = self.client.get(self.url)
if is_visible:
self.assertContains(response, "Certificates")
else:
self.assertNotContains(response, "Certificates")
@contextlib.contextmanager
def _certificate_status(self, description, status):
"""Configure the certificate status by mocking the certificates API. """
patched = 'instructor.views.instructor_dashboard.certs_api.example_certificates_status'
with mock.patch(patched) as certs_api_status:
cert_status = [{
'description': description,
'status': status
}]
if status == 'error':
cert_status[0]['error_reason'] = self.ERROR_REASON
if status == 'success':
cert_status[0]['download_url'] = self.DOWNLOAD_URL
certs_api_status.return_value = cert_status
yield
def _assert_certificate_status(self, cert_name, expected_status):
"""Check the certificate status display on the instructor dash. """
response = self.client.get(self.url)
if expected_status == 'started':
expected = 'Generating example {name} certificate'.format(name=cert_name)
self.assertContains(response, expected)
elif expected_status == 'error':
expected = self.ERROR_REASON
self.assertContains(response, expected)
elif expected_status == 'success':
expected = self.DOWNLOAD_URL
self.assertContains(response, expected)
else:
self.fail("Invalid certificate status: {status}".format(status=expected_status))
def _assert_enable_certs_button_is_disabled(self):
"""Check that the "enable student-generated certificates" button is disabled. """
response = self.client.get(self.url)
expected_html = '<button class="is-disabled" disabled>Enable Student-Generated Certificates</button>'
self.assertContains(response, expected_html)
def _assert_enable_certs_button(self, is_enabled):
"""Check whether the button says "enable" or "disable" cert generation. """
response = self.client.get(self.url)
expected_html = (
'Enable Student-Generated Certificates' if is_enabled
else 'Disable Student-Generated Certificates'
)
self.assertContains(response, expected_html)
@override_settings(CERT_QUEUE='certificates')
@ddt.ddt
class CertificatesInstructorApiTest(ModuleStoreTestCase):
"""Tests for the certificates end-points in the instructor dash API. """
def setUp(self):
super(CertificatesInstructorApiTest, self).setUp()
self.course = CourseFactory.create()
self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id)
# Enable certificate generation
cache.clear()
CertificateGenerationConfiguration.objects.create(enabled=True)
@ddt.data('generate_example_certificates', 'enable_certificate_generation')
def test_allow_only_global_staff(self, url_name):
url = reverse(url_name, kwargs={'course_id': self.course.id})
# Instructors do not have access
self.client.login(username=self.instructor.username, password='test')
response = self.client.post(url)
self.assertEqual(response.status_code, 403)
# Global staff have access
self.client.login(username=self.global_staff.username, password='test')
response = self.client.post(url)
self.assertEqual(response.status_code, 302)
def test_generate_example_certificates(self):
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'generate_example_certificates',
kwargs={'course_id': unicode(self.course.id)}
)
response = self.client.post(url)
# Expect a redirect back to the instructor dashboard
self._assert_redirects_to_instructor_dash(response)
# Expect that certificate generation started
# Cert generation will fail here because XQueue isn't configured,
# but the status should at least not be None.
status = certs_api.example_certificates_status(self.course.id)
self.assertIsNot(status, None)
@ddt.data(True, False)
def test_enable_certificate_generation(self, is_enabled):
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'enable_certificate_generation',
kwargs={'course_id': unicode(self.course.id)}
)
params = {'certificates-enabled': 'true' if is_enabled else 'false'}
response = self.client.post(url, data=params)
# Expect a redirect back to the instructor dashboard
self._assert_redirects_to_instructor_dash(response)
# Expect that certificate generation is now enabled for the course
actual_enabled = certs_api.cert_generation_enabled(self.course.id)
self.assertEqual(is_enabled, actual_enabled)
def _assert_redirects_to_instructor_dash(self, response):
"""Check that the response redirects to the certificates section. """
expected_redirect = reverse(
'instructor_dashboard',
kwargs={'course_id': unicode(self.course.id)}
)
expected_redirect += '#view-certificates'
self.assertRedirects(response, expected_redirect)
......@@ -23,16 +23,15 @@ from django.core.validators import validate_email
from django.utils.translation import ugettext as _
from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound
from django.utils.html import strip_tags
from django.shortcuts import redirect
import string # pylint: disable=deprecated-module
import random
import unicodecsv
import urllib
import decimal
from student import auth
from student.roles import CourseSalesAdminRole
from student.roles import GlobalStaff, CourseSalesAdminRole
from util.file import store_uploaded_file, course_and_time_based_filename_generator, FileValidationException, UniversalNewlineIterator
import datetime
import pytz
from util.json_request import JsonResponse
from instructor.views.instructor_task_helpers import extract_email_features, extract_task_features
......@@ -58,7 +57,10 @@ from shoppingcart.models import (
CourseMode,
CourseRegistrationCodeInvoiceItem,
)
from student.models import CourseEnrollment, unique_id_for_user, anonymous_id_for_user, EntranceExamConfiguration
from student.models import (
CourseEnrollment, unique_id_for_user, anonymous_id_for_user,
UserProfile, Registration, EntranceExamConfiguration
)
import instructor_task.api
from instructor_task.api_helper import AlreadyRunningError
from instructor_task.models import ReportStore
......@@ -82,6 +84,8 @@ from instructor.views import INVOICE_KEY
from submissions import api as sub_api # installed from the edx-submissions repository
from certificates import api as certs_api
from bulk_email.models import CourseEmail
from .tools import (
......@@ -100,7 +104,6 @@ from .tools import (
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from opaque_keys import InvalidKeyError
from student.models import UserProfile, Registration
log = logging.getLogger(__name__)
......@@ -234,6 +237,20 @@ def require_level(level):
return decorator
def require_global_staff(func):
"""View decorator that requires that the user have global staff permissions. """
def wrapped(request, *args, **kwargs): # pylint: disable=missing-docstring
if GlobalStaff().has_user(request.user):
return func(request, *args, **kwargs)
else:
return HttpResponseForbidden(
u"Must be {platform_name} staff to perform this action.".format(
platform_name=settings.PLATFORM_NAME
)
)
return wrapped
def require_sales_admin(func):
"""
Decorator for checking sales administrator access before executing an HTTP endpoint. This decorator
......@@ -2284,6 +2301,60 @@ def _split_input_list(str_list):
return new_list
def _instructor_dash_url(course_key, section=None):
"""Return the URL for a section in the instructor dashboard.
Arguments:
course_key (CourseKey)
Keyword Arguments:
section (str): The name of the section to load.
Returns:
unicode: The URL of a section in the instructor dashboard.
"""
url = reverse('instructor_dashboard', kwargs={'course_id': unicode(course_key)})
if section is not None:
url += u'#view-{section}'.format(section=section)
return url
@require_global_staff
@require_POST
def generate_example_certificates(request, course_id=None): # pylint: disable=unused-argument
"""Start generating a set of example certificates.
Example certificates are used to verify that certificates have
been configured correctly for the course.
Redirects back to the intructor dashboard once certificate
generation has begun.
"""
course_key = CourseKey.from_string(course_id)
certs_api.generate_example_certificates(course_key)
return redirect(_instructor_dash_url(course_key, section='certificates'))
@require_global_staff
@require_POST
def enable_certificate_generation(request, course_id=None):
"""Enable/disable self-generated certificates for a course.
Once self-generated certificates have been enabled, students
who have passed the course will be able to generate certificates.
Redirects back to the intructor dashboard once the
setting has been updated.
"""
course_key = CourseKey.from_string(course_id)
is_enabled = (request.POST.get('certificates-enabled', 'false') == 'true')
certs_api.set_cert_generation_enabled(course_key, is_enabled)
return redirect(_instructor_dash_url(course_key, section='certificates'))
#---- Gradebook (shown to small courses only) ----
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_level('staff')
......
......@@ -109,4 +109,13 @@ urlpatterns = patterns(
# Cohort management
url(r'add_users_to_cohorts$',
'instructor.views.api.add_users_to_cohorts', name="add_users_to_cohorts"),
# Certificates
url(r'^generate_example_certificates$',
'instructor.views.api.generate_example_certificates',
name='generate_example_certificates'),
url(r'^enable_certificate_generation$',
'instructor.views.api.enable_certificate_generation',
name='enable_certificate_generation'),
)
......@@ -36,6 +36,8 @@ from student.models import CourseEnrollment
from shoppingcart.models import Coupon, PaidCourseRegistration
from course_modes.models import CourseMode, CourseModesArchive
from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole
from certificates.models import CertificateGenerationConfiguration
from certificates import api as certs_api
from class_dashboard.dashboard_data import get_section_display_name, get_array_section_has_problem
from .tools import get_units_with_due_date, title_or_url, bulk_email_is_enabled_for_course
......@@ -108,6 +110,13 @@ def instructor_dashboard_2(request, course_id):
if course_mode_has_price and (access['finance_admin'] or access['sales_admin']):
sections.append(_section_e_commerce(course, access, paid_modes[0], is_white_label))
# Certificates panel
# This is used to generate example certificates
# and enable self-generated certificates for a course.
certs_enabled = CertificateGenerationConfiguration.current().enabled
if certs_enabled and access['admin']:
sections.append(_section_certificates(course))
disable_buttons = not _is_small_course(course_key)
analytics_dashboard_message = None
......@@ -182,6 +191,53 @@ def _section_e_commerce(course, access, paid_mode, coupons_enabled):
return section_data
def _section_certificates(course):
"""Section information for the certificates panel.
The certificates panel allows global staff to generate
example certificates and enable self-generated certificates
for a course.
Arguments:
course (Course)
Returns:
dict
"""
example_cert_status = certs_api.example_certificates_status(course.id)
# Allow the user to enable self-generated certificates for students
# *only* once a set of example certificates has been successfully generated.
# If certificates have been misconfigured for the course (for example, if
# the PDF template hasn't been uploaded yet), then we don't want
# to turn on self-generated certificates for students!
can_enable_for_course = (
example_cert_status is not None and
all(
cert_status['status'] == 'success'
for cert_status in example_cert_status
)
)
return {
'section_key': 'certificates',
'section_display_name': _('Certificates'),
'example_certificate_status': example_cert_status,
'can_enable_for_course': can_enable_for_course,
'enabled_for_course': certs_api.cert_generation_enabled(course.id),
'urls': {
'generate_example_certificates': reverse(
'generate_example_certificates',
kwargs={'course_id': course.id}
),
'enable_certificate_generation': reverse(
'enable_certificate_generation',
kwargs={'course_id': course.id}
)
}
}
@ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
@require_POST
......
......@@ -1097,7 +1097,10 @@ rwd_header_footer_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/common_he
staff_grading_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/staff_grading/**/*.js'))
open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_ended/**/*.js'))
notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.js'))
instructor_dash_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/instructor_dashboard/**/*.js'))
instructor_dash_js = (
sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/instructor_dashboard/**/*.js')) +
sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/instructor_dashboard/**/*.js'))
)
# JavaScript used by the student account and profile pages
# These are not courseware, so they do not need many of the courseware-specific
......
var edx = edx || {};
(function( $, gettext ) {
'use strict';
edx.instructor_dashboard = edx.instructor_dashboard || {};
edx.instructor_dashboard.certificates = {};
$(function() {
/**
* Show a confirmation message before letting staff members
* enable/disable self-generated certificates for a course.
*/
$('#enable-certificates-form').on('submit', function( event ) {
var isEnabled = $('#certificates-enabled').val() === 'true',
confirmMessage = '';
if ( isEnabled ) {
confirmMessage = gettext('Allow students to generate certificates for this course?');
} else {
confirmMessage = gettext('Prevent students from generating certificates in this course?');
}
if ( !confirm( confirmMessage ) ) {
event.preventDefault();
}
});
/**
* Refresh the status for example certificate generation
* by reloading the instructor dashboard.
*/
$('#refresh-example-certificate-status').on('click', function() {
window.location.reload();
});
});
})( $, gettext );
......@@ -1773,6 +1773,17 @@ input[name="subject"] {
}
}
.certificates-wrapper {
.generate-example-certificates-wrapper {
margin-bottom: 30px;
}
.example-certificate-status-wrapper {
width: 75%;
}
}
.profile-distribution-widget {
margin-bottom: ($baseline * 2);
......
<%! from django.utils.translation import ugettext as _ %>
<%page args="section_data"/>
<div class="certificates-wrapper">
<div class="example-certificates">
<h2>${_('Example Certificates')}</h2>
<div class="generate-example-certificates-wrapper">
<p>${_('Generate example certificates for the course.')}</p>
<form id="generate-example-certificates-form" method="post" action="${section_data['urls']['generate_example_certificates']}">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
<input type="submit" id="generate-example-certificates-submit" value="${_('Generate Example Certificates')}"/>
</form>
</div>
% if section_data['example_certificate_status'] is not None:
<div class="example-certificate-status-wrapper">
<p>${_("Status:")}</p>
<ul>
% for cert_status in section_data['example_certificate_status']:
% if cert_status['status'] == 'started':
<li>${_('Generating example {name} certificate').format(name=cert_status['description'])}</li>
% elif cert_status['status'] == 'error':
<li>${_('Error generating example {name} certificate: {error}').format(name=cert_status['description'], error=cert_status['error_reason'])}</li>
% elif cert_status['status'] == 'success':
<li><a href="${cert_status['download_url']}">${_('View {name} certificate').format(name=cert_status['description'])}</a></li>
</li>
% endif
% endfor
</ul>
<button id="refresh-example-certificate-status">${_("Refresh Status")}</button>
</div>
% endif
</div>
<hr />
<div class="enable-certificates">
<h2>${_("Student-Generated Certificates")}</h2>
% if section_data['enabled_for_course']:
<form id="enable-certificates-form" method="post" action="${section_data['urls']['enable_certificate_generation']}">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
<input type="hidden" id="certificates-enabled" name="certificates-enabled" value="false" />
<input type="submit" id="disable-certificates-submit" value="${_('Disable Student-Generated Certificates')}"/>
</form>
% elif section_data['can_enable_for_course']:
<form id="enable-certificates-form" method="post" action="${section_data['urls']['enable_certificate_generation']}">
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
<input type="hidden" id="certificates-enabled" name="certificates-enabled" value="true" />
<input type="submit" id="enable-certificates-submit" value="${_('Enable Student-Generated Certificates')}"/>
</form>
% else:
<p>${_("You must successfully generate example certificates before you enable student-generated certificates.")}</p>
<button class="is-disabled" disabled>${_('Enable Student-Generated Certificates')}</button>
% endif
</div>
</div>
......@@ -53,7 +53,6 @@
<%static:js group='application'/>
## Backbone classes declared explicitly until RequireJS is supported
<script type="text/javascript" src="${static.url('js/instructor_dashboard/ecommerce.js')}"></script>
<script type="text/javascript" src="${static.url('js/models/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/notification.js')}"></script>
<script type="text/javascript" src="${static.url('js/views/file_uploader.js')}"></script>
......
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