diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index e5b655e7ca..1447381d30 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -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): diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index b20372cc11..e960177285 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -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")) diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py new file mode 100644 index 0000000000..12509f4475 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -0,0 +1,221 @@ +"""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 = '' + 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) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 1dd904536d..ec3d2edf27 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -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') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 54bec7c582..fc6a05aaef 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -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'), ) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index c9955114e8..512283db94 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -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 diff --git a/lms/envs/common.py b/lms/envs/common.py index 9a72d221be..7747cf67ce 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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 diff --git a/lms/static/js/instructor_dashboard/certificates.js b/lms/static/js/instructor_dashboard/certificates.js new file mode 100644 index 0000000000..c962c91f84 --- /dev/null +++ b/lms/static/js/instructor_dashboard/certificates.js @@ -0,0 +1,37 @@ +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 ); diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 8fddfd6b00..89368b2e28 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -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); diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html new file mode 100644 index 0000000000..5de8ff6d89 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html @@ -0,0 +1,58 @@ +<%! from django.utils.translation import ugettext as _ %> +<%page args="section_data"/> +
${_('Generate example certificates for the course.')}
+ + +${_("Status:")}
+${_("You must successfully generate example certificates before you enable student-generated certificates.")}
+ + % endif +