From 99bd47e9a82ad763fa6c94b9d29df4dd612b8e03 Mon Sep 17 00:00:00 2001 From: Saleem Latif Date: Thu, 22 Oct 2015 14:31:21 +0500 Subject: [PATCH] Add Cert Exception UI on Instructor Dash --- .../pages/lms/instructor_dashboard.py | 65 ++++ .../lms/test_lms_instructor_dashboard.py | 117 +++++- ...elist_created__add_field_certificatewhi.py | 177 +++++++++ lms/djangoapps/certificates/models.py | 35 ++ .../certificates/tests/factories.py | 1 + .../instructor/tests/test_certificates.py | 146 ++++++++ lms/djangoapps/instructor/views/api.py | 100 +++++ lms/djangoapps/instructor/views/api_urls.py | 4 + .../instructor/views/instructor_dashboard.py | 12 +- lms/djangoapps/instructor_task/api.py | 21 ++ .../instructor_task/tasks_helper.py | 10 +- .../tests/test_tasks_helper.py | 9 +- .../collections/certificate_whitelist.js | 59 +++ .../certificate_whitelist_factory.js | 33 ++ .../models/certificate_exception.js | 36 ++ .../views/certificate_whitelist.js | 72 ++++ .../views/certificate_whitelist_editor.js | 80 ++++ .../certificates_exception_spec.js | 344 ++++++++++++++++++ lms/static/js/spec/main.js | 1 + .../sass/course/instructor/_instructor_2.scss | 81 +++++ .../certificate-white-list-editor.underscore | 9 + .../certificate-white-list.underscore | 39 ++ .../instructor_dashboard_2/certificates.html | 28 +- .../instructor_dashboard_2.html | 2 +- 24 files changed, 1471 insertions(+), 10 deletions(-) create mode 100644 lms/djangoapps/certificates/migrations/0025_auto__add_field_certificatewhitelist_created__add_field_certificatewhi.py create mode 100644 lms/static/js/certificates/collections/certificate_whitelist.js create mode 100644 lms/static/js/certificates/factories/certificate_whitelist_factory.js create mode 100644 lms/static/js/certificates/models/certificate_exception.js create mode 100644 lms/static/js/certificates/views/certificate_whitelist.js create mode 100644 lms/static/js/certificates/views/certificate_whitelist_editor.js create mode 100644 lms/static/js/spec/instructor_dashboard/certificates_exception_spec.js create mode 100644 lms/templates/instructor/instructor_dashboard_2/certificate-white-list-editor.underscore create mode 100644 lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore diff --git a/common/test/acceptance/pages/lms/instructor_dashboard.py b/common/test/acceptance/pages/lms/instructor_dashboard.py index 7826e3f2de..50659f279b 100644 --- a/common/test/acceptance/pages/lms/instructor_dashboard.py +++ b/common/test/acceptance/pages/lms/instructor_dashboard.py @@ -995,6 +995,23 @@ class CertificatesPage(PageObject): url = None PAGE_SELECTOR = 'section#certificates' + def wait_for_certificate_exceptions_section(self): + """ + Wait for Certificate Exceptions to be rendered on page + """ + self.wait_for_element_visibility( + 'div.certificate_exception-container', + 'Certificate Exception Section is visible' + ) + self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible') + + def refresh(self): + """ + Refresh Certificates Page and wait for the page to load completely. + """ + self.browser.refresh() + self.wait_for_page() + def is_browser_on_page(self): return self.q(css='a[data-section=certificates].active-section').present @@ -1004,6 +1021,33 @@ class CertificatesPage(PageObject): """ return self.q(css=' '.join([self.PAGE_SELECTOR, css_selector])) + def add_certificate_exception(self, student, free_text_note): + """ + Add Certificate Exception for 'student'. + """ + self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible') + + self.get_selector('#certificate-exception').fill(student) + self.get_selector('#notes').fill(free_text_note) + self.get_selector('#add-exception').click() + + self.wait_for( + lambda: student in self.get_selector('div.white-listed-students table tr:last-child td').text, + description='Certificate Exception added to list' + ) + + def click_generate_certificate_exceptions_button(self): # pylint: disable=invalid-name + """ + Click 'Generate Exception Certificates' button in 'Certificates Exceptions' section + """ + self.get_selector('#generate-exception-certificates').click() + + def click_add_exception_button(self): + """ + Click 'Add Exception' button in 'Certificates Exceptions' section + """ + self.get_selector('#add-exception').click() + @property def generate_certificates_button(self): """ @@ -1024,3 +1068,24 @@ class CertificatesPage(PageObject): Returns the "Pending Instructor Tasks" section. """ return self.get_selector('div.running-tasks-container') + + @property + def certificate_exceptions_section(self): + """ + Returns the "Certificate Exceptions" section. + """ + return self.get_selector('div.certificate_exception-container') + + @property + def last_certificate_exception(self): + """ + Returns the Last Certificate Exception in Certificate Exceptions list in "Certificate Exceptions" section. + """ + return self.get_selector('div.white-listed-students table tr:last-child td') + + @property + def message(self): + """ + Returns the Message (error/success) in "Certificate Exceptions" section. + """ + return self.get_selector('div.message') diff --git a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py index 8824d0dccc..9457488068 100644 --- a/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py +++ b/common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py @@ -21,6 +21,7 @@ from ...pages.lms.dashboard import DashboardPage from ...pages.lms.problem import ProblemPage from ...pages.lms.track_selection import TrackSelectionPage from ...pages.lms.pay_and_verify import PaymentAndVerificationFlow, FakePaymentPage +from common.test.acceptance.tests.helpers import disable_animations class BaseInstructorDashboardTest(EventsTestMixin, UniqueCourseTest): @@ -567,9 +568,10 @@ class CertificatesTest(BaseInstructorDashboardTest): def setUp(self): super(CertificatesTest, self).setUp() self.course_fixture = CourseFixture(**self.course_info).install() - self.log_in_as_instructor() - instructor_dashboard_page = self.visit_instructor_dashboard() - self.certificates_section = instructor_dashboard_page.select_certificates() + self.user_name, self.user_id = self.log_in_as_instructor() + self.instructor_dashboard_page = self.visit_instructor_dashboard() + self.certificates_section = self.instructor_dashboard_page.select_certificates() + disable_animations(self.certificates_section) def test_generate_certificates_buttons_is_visible(self): """ @@ -600,3 +602,112 @@ class CertificatesTest(BaseInstructorDashboardTest): Then I see 'Pending Instructor Tasks' section """ self.assertTrue(self.certificates_section.pending_tasks_section.visible) + + def test_certificate_exceptions_section_is_visible(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, Certificate Exceptions section is visible. + Given that I am on the Certificates tab on the Instructor Dashboard + Then I see 'CERTIFICATE EXCEPTIONS' section + """ + self.assertTrue(self.certificates_section.certificate_exceptions_section.visible) + + def test_instructor_can_add_certificate_exception(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can added new certificate + exception to list + + Given that I am on the Certificates tab on the Instructor Dashboard + When I fill in student username and click 'Add Exception' button + Then new certificate exception should be visible in certificate exceptions list + """ + # Add a student to Certificate exception list + self.certificates_section.add_certificate_exception(self.user_name, '') + self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text) + + def test_error_on_duplicate_certificate_exception(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, + Error message appears if student being added already exists in certificate exceptions list + + Given that I am on the Certificates tab on the Instructor Dashboard + When I fill in student username that already is in the list and click 'Add Exception' button + Then Error Message should say 'username/email already in exception list' + """ + # Add a student to Certificate exception list + self.certificates_section.add_certificate_exception(self.user_name, '') + + # Add duplicate student to Certificate exception list + self.certificates_section.add_certificate_exception(self.user_name, '') + + self.assertIn( + 'username/email already in exception list', + self.certificates_section.message.text + ) + + def test_error_on_empty_user_name(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, + Error message appears if no username/email is entered while clicking "Add Exception" button + + Given that I am on the Certificates tab on the Instructor Dashboard + When I click on 'Add Exception' button + AND student username/email field is empty + Then Error Message should say 'Student username/email is required.' + """ + # Click 'Add Exception' button without filling username/email field + self.certificates_section.wait_for_certificate_exceptions_section() + self.certificates_section.click_add_exception_button() + + self.assertIn( + 'Student username/email is required.', + self.certificates_section.message.text + ) + + def test_generate_certificate_exception(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, when user clicks + 'Generate Exception Certificates' newly added certificate exceptions should be synced on server + + Given that I am on the Certificates tab on the Instructor Dashboard + When I click 'Generate Exception Certificates' + Then newly added certificate exceptions should be synced on server + """ + # Add a student to Certificate exception list + self.certificates_section.add_certificate_exception(self.user_name, '') + + # Click 'Generate Exception Certificates' button + self.certificates_section.click_generate_certificate_exceptions_button() + self.certificates_section.wait_for_ajax() + + # Revisit Page + self.certificates_section.refresh() + + # wait for the certificate exception section to render + self.certificates_section.wait_for_certificate_exceptions_section() + + # validate certificate exception synced with server is visible in certificate exceptions list + self.assertIn(self.user_name, self.certificates_section.last_certificate_exception.text) + + def test_invalid_user_on_generate_certificate_exception(self): + """ + Scenario: On the Certificates tab of the Instructor Dashboard, when user clicks + 'Generate Exception Certificates' error message should appear if user does not exist + + Given that I am on the Certificates tab on the Instructor Dashboard + When I click 'Generate Exception Certificates' + AND the user specified by instructor does not exist + Then an error message "Student (username/email=test_user) does not exist" is displayed + """ + invalid_user = 'test_user_non_existent' + # Add a student to Certificate exception list + self.certificates_section.add_certificate_exception(invalid_user, '') + + # Click 'Generate Exception Certificates' button + self.certificates_section.click_generate_certificate_exceptions_button() + self.certificates_section.wait_for_ajax() + + # validate certificate exception synced with server is visible in certificate exceptions list + self.assertIn( + 'Student (username/email={}) does not exist'.format(invalid_user), + self.certificates_section.message.text + ) diff --git a/lms/djangoapps/certificates/migrations/0025_auto__add_field_certificatewhitelist_created__add_field_certificatewhi.py b/lms/djangoapps/certificates/migrations/0025_auto__add_field_certificatewhitelist_created__add_field_certificatewhi.py new file mode 100644 index 0000000000..cb1ec5b863 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0025_auto__add_field_certificatewhitelist_created__add_field_certificatewhi.py @@ -0,0 +1,177 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'CertificateWhitelist.created' + db.add_column('certificates_certificatewhitelist', 'created', + self.gf('django.db.models.fields.DateTimeField')(default=datetime.datetime.now, blank=True), + keep_default=False) + + # Adding field 'CertificateWhitelist.notes' + db.add_column('certificates_certificatewhitelist', 'notes', + self.gf('django.db.models.fields.TextField')(default=None, null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'CertificateWhitelist.created' + db.delete_column('certificates_certificatewhitelist', 'created') + + # Deleting field 'CertificateWhitelist.notes' + db.delete_column('certificates_certificatewhitelist', 'notes') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'certificates.badgeassertion': { + 'Meta': {'unique_together': "(('course_id', 'user', 'mode'),)", 'object_name': 'BadgeAssertion'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {'default': "'{}'"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'certificates.badgeimageconfiguration': { + 'Meta': {'object_name': 'BadgeImageConfiguration'}, + 'default': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'icon': ('django.db.models.fields.files.ImageField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '125'}) + }, + 'certificates.certificategenerationconfiguration': { + 'Meta': {'ordering': "('-change_date',)", 'object_name': 'CertificateGenerationConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'certificates.certificategenerationcoursesetting': { + 'Meta': {'object_name': 'CertificateGenerationCourseSetting'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.certificatehtmlviewconfiguration': { + 'Meta': {'ordering': "('-change_date',)", 'object_name': 'CertificateHtmlViewConfiguration'}, + 'change_date': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'changed_by': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True', 'on_delete': 'models.PROTECT'}), + 'configuration': ('django.db.models.fields.TextField', [], {}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'certificates.certificatetemplate': { + 'Meta': {'unique_together': "(('organization_id', 'course_key', 'mode'),)", 'object_name': 'CertificateTemplate'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '125', 'null': 'True', 'blank': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'organization_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'template': ('django.db.models.fields.TextField', [], {}) + }, + 'certificates.certificatetemplateasset': { + 'Meta': {'object_name': 'CertificateTemplateAsset'}, + 'asset': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.certificatewhitelist': { + 'Meta': {'object_name': 'CertificateWhitelist'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'notes': ('django.db.models.fields.TextField', [], {'default': 'None', 'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'whitelist': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'certificates.examplecertificate': { + 'Meta': {'object_name': 'ExampleCertificate'}, + 'access_key': ('django.db.models.fields.CharField', [], {'default': "'7c97c3537f944135b53a6d44ad8774b8'", 'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'description': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '255', 'null': 'True'}), + 'error_reason': ('django.db.models.fields.TextField', [], {'default': 'None', 'null': 'True'}), + 'example_cert_set': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['certificates.ExampleCertificateSet']"}), + 'full_name': ('django.db.models.fields.CharField', [], {'default': "u'John Do\\xeb'", 'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'started'", 'max_length': '255'}), + 'template': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'uuid': ('django.db.models.fields.CharField', [], {'default': "'82a6c6ad7a624746910b0dc584d950e0'", 'unique': 'True', 'max_length': '255', 'db_index': 'True'}) + }, + 'certificates.examplecertificateset': { + 'Meta': {'object_name': 'ExampleCertificateSet'}, + 'course_key': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}) + }, + 'certificates.generatedcertificate': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'GeneratedCertificate'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'default': 'None', 'max_length': '255', 'blank': 'True'}), + 'created_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now_add': 'True', 'blank': 'True'}), + 'distinction': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'download_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '128', 'blank': 'True'}), + 'download_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'error_reason': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '512', 'blank': 'True'}), + 'grade': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '5', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '32'}), + 'modified_date': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now', 'auto_now': 'True', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'unavailable'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'verify_uuid': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['certificates'] \ No newline at end of file diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index d38799e29b..7135671194 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -58,6 +58,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.conf import settings from django.utils.translation import ugettext_lazy as _ +from django_extensions.db.fields import CreationDateTimeField from django_extensions.db.fields.json import JSONField from model_utils import Choices from model_utils.models import TimeStampedModel @@ -108,6 +109,40 @@ class CertificateWhitelist(models.Model): user = models.ForeignKey(User) course_id = CourseKeyField(max_length=255, blank=True, default=None) whitelist = models.BooleanField(default=0) + created = CreationDateTimeField(_('created')) + notes = models.TextField(default=None, null=True) + + @classmethod + def get_certificate_white_list(cls, course_id): + """ + Return certificate white list for the given course as dict object, + returned dictionary will have the following key-value pairs + + [{ + id: 'id (pk) of CertificateWhitelist item' + user_id: 'User Id of the student' + user_name: 'name of the student' + user_email: 'email of the student' + course_id: 'Course key of the course to whom certificate exception belongs' + created: 'Creation date of the certificate exception' + notes: 'Additional notes for the certificate exception' + }, {...}, ...] + + """ + white_list = cls.objects.filter(course_id=course_id, whitelist=True) + result = [] + + for item in white_list: + result.append({ + 'id': item.id, + 'user_id': item.user.id, + 'user_name': unicode(item.user.username), + 'user_email': unicode(item.user.email), + 'course_id': unicode(item.course_id), + 'created': item.created.strftime("%A, %B %d, %Y"), + 'notes': unicode(item.notes or ''), + }) + return result class GeneratedCertificate(models.Model): diff --git a/lms/djangoapps/certificates/tests/factories.py b/lms/djangoapps/certificates/tests/factories.py index b9107b3688..599d97e5f5 100644 --- a/lms/djangoapps/certificates/tests/factories.py +++ b/lms/djangoapps/certificates/tests/factories.py @@ -30,6 +30,7 @@ class CertificateWhitelistFactory(DjangoModelFactory): course_id = None whitelist = True + notes = None class BadgeAssertionFactory(DjangoModelFactory): diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index cfb967bedd..13b580cc5a 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -204,8 +204,19 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): super(CertificatesInstructorApiTest, self).setUp() self.global_staff = GlobalStaffFactory() self.instructor = InstructorFactory(course_key=self.course.id) + self.user = UserFactory() # Enable certificate generation + self.certificate_exception_data = [ + dict( + created="Wednesday, October 28, 2015", + notes="Test Notes for Test Certificate Exception", + user_email='', + user_id='', + user_name=unicode(self.user.username) + ), + ] + cache.clear() CertificateGenerationConfiguration.objects.create(enabled=True) @@ -301,3 +312,138 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): res_json = json.loads(response.content) self.assertIsNotNone(res_json['message']) self.assertIsNotNone(res_json['task_id']) + + def test_certificate_exception_added_successfully(self): + """ + Test certificates exception addition api endpoint returns success status and updated certificate exception data + when called with valid course key and certificate exception data + """ + self.client.login(username=self.global_staff.username, password='test') + url = reverse( + 'create_certificate_exception', + kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''} + ) + + response = self.client.post( + url, + data=json.dumps(self.certificate_exception_data), + content_type='application/json' + ) + + # Assert successful request processing + self.assertEqual(response.status_code, 200) + res_json = json.loads(response.content) + + # Assert Request was successful + self.assertTrue(res_json['success']) + + # Assert Success Message + self.assertEqual(res_json['message'], u'Students added to Certificate white list successfully') + + # Assert Certificate Exception Updated data + certificate_exception = json.loads(res_json['data'])[0] + self.assertEqual(certificate_exception['user_email'], self.user.email) + self.assertEqual(certificate_exception['user_name'], self.user.username) + self.assertEqual(certificate_exception['user_id'], self.user.id) # pylint: disable=no-member + + def test_certificate_exception_invalid_username_error(self): + """ + Test certificates exception addition api endpoint returns failure when called with + invalid username. + """ + invalid_user = 'test_invalid_user_name' + self.certificate_exception_data[0].update({'user_name': invalid_user}) + + self.client.login(username=self.global_staff.username, password='test') + url = reverse( + 'create_certificate_exception', + kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''} + ) + + response = self.client.post( + url, + data=json.dumps(self.certificate_exception_data), + content_type='application/json') + + # Assert 400 status code in response + self.assertEqual(response.status_code, 400) + res_json = json.loads(response.content) + + # Assert Request not successful + self.assertFalse(res_json['success']) + + # Assert Error Message + self.assertEqual( + res_json['message'], + u'Student (username/email={user}) does not exist'.format(user=invalid_user) + ) + + def test_certificate_exception_missing_username_and_email_error(self): + """ + Test certificates exception addition api endpoint returns failure when called with + missing username/email. + """ + self.certificate_exception_data[0].update({'user_name': '', 'user_email': ''}) + + self.client.login(username=self.global_staff.username, password='test') + url = reverse( + 'create_certificate_exception', + kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''} + ) + + response = self.client.post( + url, + data=json.dumps(self.certificate_exception_data), + content_type='application/json') + + # Assert 400 status code in response + self.assertEqual(response.status_code, 400) + res_json = json.loads(response.content) + + # Assert Request not successful + self.assertFalse(res_json['success']) + + # Assert Error Message + self.assertEqual( + res_json['message'], + u'Student username/email is required.' + ) + + def test_certificate_exception_duplicate_user_error(self): + """ + Test certificates exception addition api endpoint returns failure when called with + username/email that already exists in 'CertificateWhitelist' table. + """ + + self.client.login(username=self.global_staff.username, password='test') + url = reverse( + 'create_certificate_exception', + kwargs={'course_id': unicode(self.course.id), 'white_list_student': ''} + ) + + self.client.post( + url, + data=json.dumps(self.certificate_exception_data), + content_type='application/json' + ) + + # Make some request again to simulate duplicate user scenario + response = self.client.post( + url, + data=json.dumps(self.certificate_exception_data), + content_type='application/json' + ) + + # Assert 400 status code in response + self.assertEqual(response.status_code, 400) + res_json = json.loads(response.content) + + # Assert Request not successful + self.assertFalse(res_json['success']) + + user = self.certificate_exception_data[0]['user_name'] + # Assert Error Message + self.assertEqual( + res_json['message'], + u"Student (username/email={user_id} already in certificate exception list)".format(user_id=user) + ) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index a6911eba1b..ff116b93c9 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -18,6 +18,7 @@ from django.views.decorators.cache import cache_control from django.core.exceptions import ValidationError, PermissionDenied from django.core.mail.message import EmailMessage from django.db import IntegrityError +from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned from django.core.urlresolvers import reverse from django.core.validators import validate_email from django.utils.translation import ugettext as _ @@ -91,8 +92,10 @@ 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 certificates.models import CertificateWhitelist from bulk_email.models import CourseEmail +from student.models import get_user_by_username_or_email from .tools import ( dump_student_extensions, @@ -2680,3 +2683,100 @@ def start_certificate_generation(request, course_id): 'task_id': task.task_id } return JsonResponse(response_payload) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_global_staff +@require_POST +def create_certificate_exception(request, course_id, white_list_student=None): + """ + Add Students to certificate white list. + """ + course_key = CourseKey.from_string(course_id) + + try: + certificate_white_list = json.loads(request.body) + except ValueError: + return JsonResponse({ + 'success': False, + 'message': _('Invalid Json data') + }, status=400) + try: + certificate_white_list, students = process_certificate_exceptions(certificate_white_list, course_key) + except ValueError as error: + return JsonResponse( + {'success': False, 'message': error.message, 'data': json.dumps(certificate_white_list)}, + status=400 + ) + + if white_list_student == 'all': + # Generate Certificates for all white listed students + students = User.objects.filter( + certificatewhitelist__course_id=course_key, + certificatewhitelist__whitelist=True + ) + + if students: + # generate certificates for students if 'students' list is not empty + instructor_task.api.generate_certificates_for_students(request, course_key, students=students) + + response_payload = { + 'success': True, + 'message': _('Students added to Certificate white list successfully'), + 'data': json.dumps(certificate_white_list) + } + + return JsonResponse(response_payload) + + +def process_certificate_exceptions(data_list, course_key): + """ + Validate user data for certificate exceptions, raise ValueError in case of invalid data and create + 'CertificateWhitelist' record for students in data_list. + + return updated data_list after creating 'CertificateWhitelist' records in db. + """ + students = [] + users = [data.get('user_name', False) or data.get('user_email', False) for data in data_list] + + if not all(users): + # Username and email can not both be empty + raise ValueError(_('Student username/email is required.')) + + if len(users) != len(set(users)): + # Duplicate Student username/email is not allowed + raise ValueError(_('Duplicate Student Username/password.')) + + for data in data_list: + user = data.get('user_name', '') or data.get('user_email', '') + try: + db_user = get_user_by_username_or_email(user) + except ObjectDoesNotExist: + raise ValueError(_('Student (username/email={user}) does not exist').format(user=user)) + except MultipleObjectsReturned: + raise ValueError(_('Multiple Students found with username/email={user}').format(user=user)) + + if CertificateWhitelist.objects.filter(user=db_user, whitelist=True).count() > 0: + raise ValueError( + _("Student (username/email={user_id} already in certificate exception list)").format(user_id=user) + ) + + certificate_white_list = CertificateWhitelist.objects.create( + user=db_user, + course_id=course_key, + whitelist=True, + notes=data.get('notes', '') + ) + + data.update({ + 'id': certificate_white_list.id, + 'user_email': db_user.email, + 'user_name': db_user.username, + 'user_id': db_user.id, + 'created': certificate_white_list.created.strftime("%A, %B %d, %Y"), + }) + + students.append(db_user) + + return data_list, students diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 5a94d78abb..c07192103d 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -140,4 +140,8 @@ urlpatterns = patterns( url(r'^start_certificate_generation', 'instructor.views.api.start_certificate_generation', name='start_certificate_generation'), + + url(r'^create_certificate_exception/(?P[^/]*)', + 'instructor.views.api.create_certificate_exception', + name='create_certificate_exception'), ) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 82d48c48bd..8bcfc7db88 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -37,7 +37,7 @@ from student.models import CourseEnrollment from shoppingcart.models import Coupon, PaidCourseRegistration, CourseRegCodeItem from course_modes.models import CourseMode, CourseModesArchive from student.roles import CourseFinanceAdminRole, CourseSalesAdminRole -from certificates.models import CertificateGenerationConfiguration +from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist from certificates import api as certs_api from util.date_utils import get_default_time_display @@ -161,13 +161,21 @@ def instructor_dashboard_2(request, course_id): disable_buttons = not _is_small_course(course_key) + certificate_white_list = CertificateWhitelist.get_certificate_white_list(course_key) + certificate_exception_url = reverse( + 'create_certificate_exception', + kwargs={'course_id': unicode(course_key), 'white_list_student': ''} + ) + context = { 'course': course, 'old_dashboard_url': reverse('instructor_dashboard_legacy', kwargs={'course_id': unicode(course_key)}), 'studio_url': get_studio_url(course, 'course'), 'sections': sections, 'disable_buttons': disable_buttons, - 'analytics_dashboard_message': analytics_dashboard_message + 'analytics_dashboard_message': analytics_dashboard_message, + 'certificate_white_list': certificate_white_list, + 'certificate_exception_url': certificate_exception_url } return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context) diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 5ad4cc07de..1b85b44a5a 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -476,3 +476,24 @@ def generate_certificates_for_all_students(request, course_key): # pylint: dis task_key = "" return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + +def generate_certificates_for_students(request, course_key, students=None): # pylint: disable=invalid-name + """ + Submits a task to generate certificates for given students enrolled in the course or + all students if argument 'students' is None + + Raises AlreadyRunningError if certificates are currently being generated. + """ + if students: + task_type = 'generate_certificates_certain_student' + students = [student.id for student in students] + task_input = {'students': students} + else: + task_type = 'generate_certificates_all_student' + task_input = {} + + task_class = generate_certificates + task_key = "" + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index e494c9a2b6..b7c4093116 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -1341,11 +1341,17 @@ def upload_proctored_exam_results_report(_xmodule_instance_args, _entry_id, cour def generate_students_certificates( _xmodule_instance_args, _entry_id, course_id, task_input, action_name): # pylint: disable=unused-argument """ - For a given `course_id`, generate certificates for all students - that are enrolled. + For a given `course_id`, generate certificates for only students present in 'students' key in task_input + json column, otherwise generate certificates for all enrolled students. """ start_time = time() enrolled_students = CourseEnrollment.objects.users_enrolled_in(course_id) + + students = task_input.get('students', None) + + if students is not None: + enrolled_students = enrolled_students.filter(id__in=students) + task_progress = TaskProgress(action_name, enrolled_students.count(), start_time) current_step = {'step': 'Calculating students already have certificates'} diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 9cefcb357f..9516c92782 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -9,6 +9,7 @@ Tests that CSV grade report generation works with unicode emails. import ddt from mock import Mock, patch import tempfile +import json from openedx.core.djangoapps.course_groups import cohorts import unicodecsv from django.core.urlresolvers import reverse @@ -1510,12 +1511,18 @@ class TestCertificateGeneration(InstructorTaskModuleTestCase): current_task = Mock() current_task.update_state = Mock() + instructor_task = Mock() + instructor_task.task_input = json.dumps({'students': None}) with self.assertNumQueries(125): with patch('instructor_task.tasks_helper._get_current_task') as mock_current_task: mock_current_task.return_value = current_task with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_queue: mock_queue.return_value = (0, "Successfully queued") - result = generate_students_certificates(None, None, self.course.id, None, 'certificates generated') + with patch('instructor_task.models.InstructorTask.objects.get') as instructor_task_object: + instructor_task_object.return_value = instructor_task + result = generate_students_certificates( + None, None, self.course.id, {}, 'certificates generated' + ) self.assertDictContainsSubset( { 'action_name': 'certificates generated', diff --git a/lms/static/js/certificates/collections/certificate_whitelist.js b/lms/static/js/certificates/collections/certificate_whitelist.js new file mode 100644 index 0000000000..1831be387b --- /dev/null +++ b/lms/static/js/certificates/collections/certificate_whitelist.js @@ -0,0 +1,59 @@ +// Backbone.js Application Collection: CertificateWhiteList +/*global define, RequireJS */ + +;(function(define){ + 'use strict'; + define([ + 'backbone', + 'gettext', + 'js/certificates/models/certificate_exception' + ], + + function(Backbone, gettext, CertificateExceptionModel){ + + var CertificateWhiteList = Backbone.Collection.extend({ + model: CertificateExceptionModel, + + initialize: function(attrs, options){ + this.url = options.url; + }, + + getModel: function(attrs){ + var model = this.findWhere({user_name: attrs.user_name}); + if(attrs.user_name && model){ + return model; + } + + model = this.findWhere({user_email: attrs.user_email}); + if(attrs.user_email && model){ + return model; + } + + return undefined; + }, + + sync: function(options, appended_url){ + var filtered = this.filter(function(model){ + return model.isNew(); + }); + + Backbone.sync( + 'create', + new CertificateWhiteList(filtered, {url: this.url + appended_url}), + options + ); + }, + + update: function(data){ + _.each(data, function(item){ + var certificate_exception_model = + this.getModel({user_name: item.user_name, user_email: item.user_email}); + certificate_exception_model.set(item); + }, this); + } + }); + + return CertificateWhiteList; + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/certificates/factories/certificate_whitelist_factory.js b/lms/static/js/certificates/factories/certificate_whitelist_factory.js new file mode 100644 index 0000000000..1eee6e3cf9 --- /dev/null +++ b/lms/static/js/certificates/factories/certificate_whitelist_factory.js @@ -0,0 +1,33 @@ +// Backbone.js Page Object Factory: Certificates +/*global define, RequireJS */ + +;(function(define){ + 'use strict'; + define([ + 'jquery', + 'js/certificates/views/certificate_whitelist', + 'js/certificates/models/certificate_exception', + 'js/certificates/views/certificate_whitelist_editor', + 'js/certificates/collections/certificate_whitelist' + ], + function($, CertificateWhiteListListView, CertificateExceptionModel, CertificateWhiteListEditorView , + CertificateWhiteListCollection){ + return function(certificate_white_list_json, certificate_exception_url){ + + var certificateWhiteList = new CertificateWhiteListCollection(JSON.parse(certificate_white_list_json), { + parse: true, + canBeEmpty: true, + url: certificate_exception_url + }); + + new CertificateWhiteListListView({ + collection: certificateWhiteList + }).render(); + + new CertificateWhiteListEditorView({ + collection: certificateWhiteList + }).render(); + }; + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/certificates/models/certificate_exception.js b/lms/static/js/certificates/models/certificate_exception.js new file mode 100644 index 0000000000..ecca48e213 --- /dev/null +++ b/lms/static/js/certificates/models/certificate_exception.js @@ -0,0 +1,36 @@ +// Backbone.js Application Model: CertificateWhitelist +/*global define, RequireJS */ + +;(function(define){ + 'use strict'; + + define([ + 'underscore', + 'underscore.string', + 'backbone', + 'gettext' + ], + + function(_, str, Backbone, gettext){ + + return Backbone.Model.extend({ + idAttribute: 'id', + + defaults: { + user_id: '', + user_name: '', + user_email: '', + created: '', + notes: '' + }, + + validate: function(attrs){ + if (!_.str.trim(attrs.user_name) && !_.str.trim(attrs.user_email)) { + return gettext('Student username/email is required.'); + } + + } + }); + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/certificates/views/certificate_whitelist.js b/lms/static/js/certificates/views/certificate_whitelist.js new file mode 100644 index 0000000000..c14e32d938 --- /dev/null +++ b/lms/static/js/certificates/views/certificate_whitelist.js @@ -0,0 +1,72 @@ +// Backbone Application View: CertificateWhitelist View +/*global define, RequireJS */ + +;(function(define){ + 'use strict'; + + define([ + 'jquery', + 'underscore', + 'gettext', + 'backbone' + ], + + function($, _, gettext, Backbone){ + return Backbone.View.extend({ + el: "#white-listed-students", + generate_exception_certificates_radio: + 'input:radio[name=generate-exception-certificates-radio]:checked', + + events: { + 'click #generate-exception-certificates': 'generateExceptionCertificates' + }, + + initialize: function(){ + // Re-render the view when an item is added to the collection + this.listenTo(this.collection, 'change add', this.render); + }, + + render: function(){ + var template = this.loadTemplate('certificate-white-list'); + this.$el.html(template({certificates: this.collection.models})); + + }, + + loadTemplate: function(name) { + var templateSelector = "#" + name + "-tpl", + templateText = $(templateSelector).text(); + return _.template(templateText); + }, + + generateExceptionCertificates: function(){ + this.collection.sync( + {success: this.showSuccess(this), error: this.showError(this)}, + $(this.generate_exception_certificates_radio).val() + ); + }, + + showSuccess: function(caller_object){ + return function(xhr){ + var response = xhr; + $(".message").text(response.message).removeClass('msg-error').addClass('msg-success').focus(); + caller_object.collection.update(JSON.parse(response.data)); + $('html, body').animate({ + scrollTop: $("#certificate-exception").offset().top - 10 + }, 1000); + }; + }, + + showError: function(caller_object){ + return function(xhr){ + var response = JSON.parse(xhr.responseText); + $(".message").text(response.message).removeClass('msg-success').addClass("msg-error").focus(); + caller_object.collection.update(JSON.parse(response.data)); + $('html, body').animate({ + scrollTop: $("#certificate-exception").offset().top - 10 + }, 1000); + }; + } + }); + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/certificates/views/certificate_whitelist_editor.js b/lms/static/js/certificates/views/certificate_whitelist_editor.js new file mode 100644 index 0000000000..d7b3b598b8 --- /dev/null +++ b/lms/static/js/certificates/views/certificate_whitelist_editor.js @@ -0,0 +1,80 @@ +// Backbone Application View: CertificateWhiteList Editor View +/*global define, RequireJS */ + +;(function(define){ + 'use strict'; + define([ + 'jquery', + 'underscore', + 'gettext', + 'backbone', + 'js/certificates/models/certificate_exception' + ], + function($, _, gettext, Backbone, CertificateExceptionModel){ + return Backbone.View.extend({ + el: "#certificate-white-list-editor", + message_div: '.message', + + events: { + 'click #add-exception': 'addException' + }, + + render: function(){ + var template = this.loadTemplate('certificate-white-list-editor'); + this.$el.html(template()); + }, + + loadTemplate: function(name) { + var templateSelector = "#" + name + "-tpl", + templateText = $(templateSelector).text(); + return _.template(templateText); + }, + + addException: function(){ + var value = this.$("#certificate-exception").val(); + var notes = this.$("#notes").val(); + var user_email = '', user_name='', model={}; + + if(this.isEmailAddress(value)){ + user_email = value; + model = {user_email: user_email}; + } + else{ + user_name = value; + model = {user_name: user_name}; + } + + var certificate_exception = new CertificateExceptionModel({ + user_name: user_name, + user_email: user_email, + notes: notes + }); + + if(this.collection.findWhere(model)){ + this.showMessage("username/email already in exception list", 'msg-error'); + } + else if(certificate_exception.isValid()){ + this.collection.add(certificate_exception, {validate: true}); + this.showMessage("Student Added to exception list", 'msg-success'); + } + else{ + this.showMessage(certificate_exception.validationError, 'msg-error'); + } + }, + + isEmailAddress: function validateEmail(email) { + var re = /^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$/i; + return re.test(email); + }, + + showMessage: function(message, messageClass){ + this.$(this.message_div).text(message). + removeClass('msg-error msg-success').addClass(messageClass).focus(); + $('html, body').animate({ + scrollTop: this.$el.offset().top - 20 + }, 1000); + } + }); + } + ); +}).call(this, define || RequireJS.define); \ No newline at end of file diff --git a/lms/static/js/spec/instructor_dashboard/certificates_exception_spec.js b/lms/static/js/spec/instructor_dashboard/certificates_exception_spec.js new file mode 100644 index 0000000000..39597395f4 --- /dev/null +++ b/lms/static/js/spec/instructor_dashboard/certificates_exception_spec.js @@ -0,0 +1,344 @@ +/*global define, sinon */ +define([ + 'jquery', + 'common/js/spec_helpers/ajax_helpers', + 'js/certificates/models/certificate_exception', + 'js/certificates/views/certificate_whitelist', + 'js/certificates/views/certificate_whitelist_editor', + 'js/certificates/collections/certificate_whitelist' + ], + function($, AjaxHelpers, CertificateExceptionModel, CertificateWhiteListView, CertificateWhiteListEditorView, + CertificateWhiteListCollection) { + 'use strict'; + describe("edx.certificates.models.certificates_exception.CertificateExceptionModel", function() { + var certificate_exception = null; + var assertValid = function(fields, isValid, expectedErrors) { + certificate_exception.set(fields); + var errors = certificate_exception.validate(certificate_exception.attributes); + + if (isValid) { + expect(errors).toBe(undefined); + } else { + expect(errors).toEqual(expectedErrors); + } + }; + + var EXPECTED_ERRORS = { + user_name_or_email_required: "Student username/email is required." + }; + + beforeEach(function() { + + certificate_exception = new CertificateExceptionModel({user_name: 'test_user'}); + certificate_exception.set({ + notes: "Test notes" + }); + }); + + it("accepts valid email addresses", function() { + assertValid({user_email: "bob@example.com"}, true); + assertValid({user_email: "bob+smith@example.com"}, true); + assertValid({user_email: "bob+smith@example.com"}, true); + assertValid({user_email: "bob+smith@example.com"}, true); + assertValid({user_email: "bob@test.example.com"}, true); + assertValid({user_email: "bob@test-example.com"}, true); + }); + + it("displays username or email required error", function() { + assertValid({user_name: ""}, false, EXPECTED_ERRORS.user_name_or_email_required); + }); + }); + + describe("edx.certificates.collections.certificate_whitelist.CertificateWhiteList", function() { + var certificate_white_list = null, + certificate_exception_url = 'test/url/'; + var certificates_exceptions_json = [ + { + id: "1", + user_id: "1", + user_name: "test1", + user_email: "test1@test.com", + course_id: "edX/test/course", + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate exception" + }, + { + id: "2", + user_id : "2", + user_name: "test2", + user_email : "test2@test.com", + course_id: "edX/test/course", + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate exception" + } + ]; + + beforeEach(function() { + certificate_white_list = new CertificateWhiteListCollection(certificates_exceptions_json, { + parse: true, + canBeEmpty: true, + url: certificate_exception_url + }); + }); + + it("has 2 models in the collection after initialization", function() { + expect(certificate_white_list.models.length).toEqual(2); + }); + + it("returns correct model on getModel call and 'undefined' if queried model is not present", function() { + expect(certificate_white_list.getModel({user_name: 'test1'})).not.toBe(undefined); + expect(certificate_white_list.getModel({user_name: 'test_invalid_user'})).toBe(undefined); + + expect(certificate_white_list.getModel({user_email: 'test1@test.com'})).not.toBe(undefined); + expect(certificate_white_list.getModel({user_email: 'test_invalid_user@test.com'})).toBe(undefined); + + expect(certificate_white_list.getModel({user_name: 'test1'}).attributes).toEqual( + { + id: '1', user_id: '1', user_name: 'test1', user_email: 'test1@test.com', + course_id: 'edX/test/course', created: "Thursday, October 29, 2015", + notes: 'test notes for test certificate exception' + } + ); + + expect(certificate_white_list.getModel({user_email: 'test2@test.com'}).attributes).toEqual( + { + id: '2', user_id: '2', user_name: 'test2', user_email: 'test2@test.com', + course_id: 'edX/test/course', created: "Thursday, October 29, 2015", + notes: 'test notes for test certificate exception' + } + ); + }); + + it('sends empty certificate exceptions list if no model is added', function(){ + var successCallback = sinon.spy(), + errorCallback = sinon.spy(), + requests = AjaxHelpers.requests(this), + add_students = 'all'; + var expected = { + url: certificate_exception_url + add_students, + postData : [] + }; + + certificate_white_list.sync({success: successCallback, error: errorCallback}, add_students); + AjaxHelpers.expectJsonRequest(requests, 'POST', expected.url, expected.postData); + }); + + it('syncs only newly added models with the server', function(){ + var successCallback = sinon.spy(), + errorCallback = sinon.spy(), + requests = AjaxHelpers.requests(this), + add_students = 'new'; + + certificate_white_list.add({user_name: 'test3', notes: 'test3 notes'}); + certificate_white_list.sync({success: successCallback, error: errorCallback}, add_students); + + var expected = { + url: certificate_exception_url + add_students, + postData : [ + {user_id: "", + user_name: "test3", + user_email: "", + created: "", + notes: "test3 notes"} + ] + }; + AjaxHelpers.expectJsonRequest(requests, 'POST', expected.url, expected.postData); + }); + }); + + describe("edx.certificates.views.certificate_whitelist.CertificateWhiteListView", function() { + var view = null, + certificate_exception_url = 'test/url/'; + + var certificates_exceptions_json = [ + { + id: "1", + user_id: "1", + user_name: "test1", + user_email: "test1@test.com", + course_id: "edX/test/course", + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate exception" + }, + { + id: "2", + user_id : "2", + user_name: "test2", + user_email : "test2@test.com", + course_id: "edX/test/course", + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate exception" + } + ]; + + beforeEach(function() { + setFixtures(); + var fixture = + readFixtures("templates/instructor/instructor_dashboard_2/certificate-white-list.underscore"); + setFixtures("" + + "
"); + + var certificate_white_list = new CertificateWhiteListCollection(certificates_exceptions_json, { + parse: true, + canBeEmpty: true, + url: certificate_exception_url + }); + + view = new CertificateWhiteListView({collection: certificate_white_list}); + view.render(); + }); + + it("verifies view is initialized and rendered successfully", function() { + expect(view).not.toBe(undefined); + expect(view.$el.find('table tbody tr').length).toBe(2); + }); + + it("verifies view is rendered on add/update to collection", function() { + var user = 'test1', + notes = 'test1 notes updates', + email='update_email@test.com'; + + // Add another model in collection and verify it is rendered + view.collection.add({user_name: 'test3', notes: 'test3 notes'}); + expect(view.$el.find('table tbody tr').length).toBe(3); + + // Update a model in collection and verify it is rendered + view.collection.update([ + {user_name: user, notes: notes, user_email: email} + ]); + + expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()). + toMatch(notes); + expect(view.$el.find('table tbody tr td:contains("' + user + '")').parent().html()). + toMatch(email); + }); + + it('verifies collection sync is called when "Generate Exception Certificates" is clicked', function(){ + var successCallback = sinon.spy(), + errorCallback = sinon.spy(); + + sinon.stub(view, "showSuccess").returns(successCallback); + sinon.stub(view, "showError").returns(errorCallback); + sinon.stub(view.collection, "sync"); + + view.$el.find("#generate-exception-certificates").click(); + + expect(view.collection.sync.called).toBe(true); + expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback})). + toBe(true); + }); + + it('verifies sync is called with "new/all" argument depending upon selected radio button', function(){ + var successCallback = sinon.spy(), + errorCallback = sinon.spy(); + + sinon.stub(view, "showSuccess").returns(successCallback); + sinon.stub(view, "showError").returns(errorCallback); + sinon.stub(view.collection, "sync"); + + view.$el.find("#generate-exception-certificates").click(); + + // By default 'Generate a Certificate for all New additions to the Exception list ' is selected + expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback}), 'new'). + toBe(true); + + // Select 'Generate a Certificate for all users on the Exception list ' option + view.$el.find("input:radio[name=generate-exception-certificates-radio][value=all]").click(); + view.$el.find("#generate-exception-certificates").click(); + expect(view.collection.sync.calledWith({success: successCallback, error: errorCallback}), 'all'). + toBe(true); + }); + }); + + describe("edx.certificates.views.certificate_whitelist_editor.CertificateWhiteListEditorView", function() { + var view = null, + certificate_exception_url = 'test/url/'; + var certificates_exceptions_json = [ + { + id: "1", + user_id: "1", + user_name: "test1", + user_email: "test1@test.com", + course_id: "edX/test/course", + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate exception" + }, + { + id: "2", + user_id : "2", + user_name: "test2", + user_email : "test2@test.com", + course_id: "edX/test/course", + created: "Thursday, October 29, 2015", + notes: "test notes for test certificate exception" + } + ]; + + beforeEach(function() { + setFixtures(); + + var fixture = readFixtures( + "templates/instructor/instructor_dashboard_2/certificate-white-list-editor.underscore" + ); + + setFixtures( + "" + + "
" + ); + + var certificate_white_list = new CertificateWhiteListCollection(certificates_exceptions_json, { + parse: true, + canBeEmpty: true, + url: certificate_exception_url + }); + + view = new CertificateWhiteListEditorView({collection: certificate_white_list}); + view.render(); + }); + + it("verifies view is initialized and rendered successfully", function() { + expect(view).not.toBe(undefined); + expect(view.$el.find('#certificate-exception').length).toBe(1); + expect(view.$el.find('#notes').length).toBe(1); + expect(view.$el.find('#add-exception').length).toBe(1); + }); + + it("verifies success and error messages", function() { + var message_selector='.message', + error_class = 'msg-error', + success_class = 'msg-success', + success_message = 'Student Added to exception list'; + + var error_messages = { + empty_user_name_email: 'Student username/email is required.', + duplicate_user: 'username/email already in exception list' + }; + + // click 'Add Exception' button with empty username/email field + view.$el.find('#add-exception').click(); + + // Verify error message for missing username/email + expect(view.$el.find(message_selector)).toHaveClass(error_class); + expect(view.$el.find(message_selector).html()).toMatch(error_messages.empty_user_name_email); + + // Add a new Exception to list + view.$el.find('#certificate-exception').val("test_user"); + view.$el.find('#notes').val("test user notes"); + view.$el.find('#add-exception').click(); + + // Verify success message + expect(view.$el.find(message_selector)).toHaveClass(success_class); + expect(view.$el.find(message_selector).html()).toMatch(success_message); + + // Add a duplicate Certificate Exception + view.$el.find('#certificate-exception').val("test_user"); + view.$el.find('#notes').val("test user notes"); + view.$el.find('#add-exception').click(); + + // Verify success message + expect(view.$el.find(message_selector)).toHaveClass(error_class); + expect(view.$el.find(message_selector).html()).toMatch(error_messages.duplicate_user); + }); + }); + } +); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index ae2cdb7e02..6b578fd4ef 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -642,6 +642,7 @@ 'lms/include/js/spec/shoppingcart/shoppingcart_spec.js', 'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js', 'lms/include/js/spec/instructor_dashboard/student_admin_spec.js', + 'lms/include/js/spec/instructor_dashboard/certificates_exception_spec.js', 'lms/include/js/spec/student_account/account_spec.js', 'lms/include/js/spec/student_account/access_spec.js', 'lms/include/js/spec/student_account/logistration_factory_spec.js', diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 0a29b01f2f..93f1ff82b3 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -2119,3 +2119,84 @@ input[name="subject"] { @include left(2em); @include right(auto); } + +#certificate-white-list-editor{ + .msg-success{ + border-top: 2px solid $confirm-color; + background: tint($confirm-color,95%); + color: $confirm-color; + } + + .certificate-exception-inputs{ + .student-username-or-email{ + width: 300px; + } + .notes-field{ + width: 400px; + } + p+p{ + margin-top: 5px; + } + .message{ + margin-top: 10px; + } + } +} + +.white-listed-students { + + table { + width: 100%; + word-wrap: break-word; + + th { + @extend %t-copy-sub2; + background-color: $gray-l5; + padding: ($baseline*0.75) ($baseline/2) ($baseline*0.75) ($baseline/2); + vertical-align: middle; + text-align: left; + color: $gray; + + &.date-column{ + width: 230px; + } + } + + td { + padding: ($baseline/2); + vertical-align: middle; + text-align: left; + } + + tbody { + box-shadow: 0 2px 2px $shadow-l1; + border: 1px solid $gray-l4; + background: $white; + + tr { + @include transition(all $tmg-f2 ease-in-out 0s); + border-top: 1px solid $gray-l4; + + &:first-child { + border-top: none; + } + + &:nth-child(odd) { + background-color: $gray-l6; + } + + a { + color: $gray-d1; + + &:hover { + color: $blue; + } + } + + &:hover { + background-color: $blue-l5; + } + } + } + } +} diff --git a/lms/templates/instructor/instructor_dashboard_2/certificate-white-list-editor.underscore b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list-editor.underscore new file mode 100644 index 0000000000..4d282a200d --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list-editor.underscore @@ -0,0 +1,9 @@ +
+ + + + +

<%- gettext("Specify either Student's username or email for whom to create certificate exception") %>

+

<%- gettext("Enter Notes associated with this certificate exception") %>

+
+
diff --git a/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore new file mode 100644 index 0000000000..4cd6cdaa20 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore @@ -0,0 +1,39 @@ +<% if (certificates.length === 0) { %> +

<%- gettext("No results") %>

+<% } else { %> + + + + + + + + + + <% for (var i = 0; i < certificates.length; i++) { + var cert = certificates[i]; + %> + + + + + + + + <% } %> + +
<%- gettext("Name") %><%- gettext("User ID") %><%- gettext("User Email") %><%- gettext("Date Exception Granted") %><%- gettext("Notes") %>
<%- cert.get("user_name") %><%- cert.get("user_id") %><%- cert.get("user_email") %><%- cert.get("created") %><%- cert.get("notes") %>
+<% } %> + +
+ +
+ +
+ diff --git a/lms/templates/instructor/instructor_dashboard_2/certificates.html b/lms/templates/instructor/instructor_dashboard_2/certificates.html index 4f56cbf729..897c5588a3 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificates.html +++ b/lms/templates/instructor/instructor_dashboard_2/certificates.html @@ -1,4 +1,13 @@ -<%! from django.utils.translation import ugettext as _ %> +<%namespace name='static' file='../../static_content.html'/> + +<%! from django.utils.translation import ugettext as _ +import json +%> + +<%static:require_module module_name="js/certificates/factories/certificate_whitelist_factory" class_name="CertificateWhitelistFactory"> + CertificateWhitelistFactory('${json.dumps(certificate_white_list)}', "${certificate_exception_url}"); + + <%page args="section_data"/>
@@ -82,4 +91,21 @@
%endif % endif + +
+
+

${_("Certificate Exceptions")}

+
+

${_("Use this to generate certificates for users who did not pass the course but have been given an exception by the Course Team to earn a certificate.")}

+
+
+
+
+
+
+
+
+
+
+ diff --git a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html index 5b47c98c12..6f0ccc7422 100644 --- a/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html +++ b/lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html @@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse ## Include Underscore templates <%block name="header_extras"> -% for template_name in ["cohorts", "enrollment-code-lookup-links", "cohort-editor", "cohort-group-header", "cohort-selector", "cohort-form", "notification", "cohort-state", "cohort-discussions-inline", "cohort-discussions-course-wide", "cohort-discussions-category","cohort-discussions-subcategory"]: +% for template_name in ["cohorts", "enrollment-code-lookup-links", "cohort-editor", "cohort-group-header", "cohort-selector", "cohort-form", "notification", "cohort-state", "cohort-discussions-inline", "cohort-discussions-course-wide", "cohort-discussions-category","cohort-discussions-subcategory","certificate-white-list","certificate-white-list-editor"]: