Commit 99bd47e9 by Saleem Latif

Add Cert Exception UI on Instructor Dash

parent 15d1aa3a
......@@ -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')
......@@ -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
)
# -*- 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
......@@ -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):
......
......@@ -30,6 +30,7 @@ class CertificateWhitelistFactory(DjangoModelFactory):
course_id = None
whitelist = True
notes = None
class BadgeAssertionFactory(DjangoModelFactory):
......
......@@ -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)
)
......@@ -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
......@@ -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<white_list_student>[^/]*)',
'instructor.views.api.create_certificate_exception',
name='create_certificate_exception'),
)
......@@ -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)
......
......@@ -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)
......@@ -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'}
......
......@@ -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',
......
// 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
// 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
// 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
// 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
// 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
/*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("<script type='text/template' id='certificate-white-list-tpl'>" + fixture + "</script>" +
"<div class='white-listed-students' id='white-listed-students'></div>");
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(
"<script type='text/template' id='certificate-white-list-editor-tpl'>" + fixture + "</script>" +
"<div id='certificate-white-list-editor'></div>"
);
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);
});
});
}
);
......@@ -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',
......
......@@ -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;
}
}
}
}
}
<div class='certificate-exception-inputs'>
<input class='student-username-or-email' id="certificate-exception" type="text" placeholder="Student email or username" aria-describedby='student-user-name-or-email-tip'>
<textarea class='notes-field' id="notes" rows="10" placeholder="Free text notes" aria-describedby='notes-field-tip'></textarea>
<input type="button" id="add-exception" value="Add Exception">
<p id='student-user-name-or-email-tip'><%- gettext("Specify either Student's username or email for whom to create certificate exception") %></p>
<p id='notes-field-tip'><%- gettext("Enter Notes associated with this certificate exception") %></p>
<div class='message'></div>
</div>
<% if (certificates.length === 0) { %>
<p><%- gettext("No results") %></p>
<% } else { %>
<table>
<thead>
<th><%- gettext("Name") %></th>
<th><%- gettext("User ID") %></th>
<th><%- gettext("User Email") %></th>
<th class='date-column'><%- gettext("Date Exception Granted") %></th>
<th><%- gettext("Notes") %></th>
</thead>
<tbody>
<% for (var i = 0; i < certificates.length; i++) {
var cert = certificates[i];
%>
<tr>
<td><%- cert.get("user_name") %></td>
<td><%- cert.get("user_id") %></td>
<td><%- cert.get("user_email") %></td>
<td><%- cert.get("created") %></td>
<td><%- cert.get("notes") %></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<br/>
<label>
<input type='radio' name='generate-exception-certificates-radio' checked="checked" value='new' aria-describedby='generate-exception-certificates-radio-new-tip'>
<span id='generate-exception-certificates-radio-new-tip'><%- gettext('Generate a Certificate for all ') %><strong><%- gettext('New') %></strong> <%- gettext('additions to the Exception list') %></span>
</label>
<br/>
<label>
<input type='radio' name='generate-exception-certificates-radio' value='all' aria-describedby='generate-exception-certificates-radio-all-tip'>
<span id='generate-exception-certificates-radio-all-tip'><%- gettext('Generate a Certificate for all users on the Exception list') %></span>
</label>
<br/>
<input type="button" id="generate-exception-certificates" value="<%- gettext('Generate Exception Certificates') %>" />
<%! 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}");
</%static:require_module>
<%page args="section_data"/>
<div class="certificates-wrapper">
......@@ -82,4 +91,21 @@
</div>
%endif
% endif
<div class="certificate_exception-container">
<hr>
<h2> ${_("Certificate Exceptions")} </h2>
<div class="certificate-exception-section">
<p>${_("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.")} </p>
<br />
<div id="certificate-white-list-editor"></div>
<br/>
<br/>
<div class="white-listed-students" id="white-listed-students"></div>
<br/>
<br/>
</div>
<div class="no-pending-tasks-message"></div>
</div>
</div>
......@@ -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"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="instructor/instructor_dashboard_2/${template_name}.underscore" />
</script>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment