Allow PMs to Invalidate Certificates
This commit is contained in:
@@ -1006,6 +1006,16 @@ class CertificatesPage(PageObject):
|
||||
)
|
||||
self.wait_for_element_visibility('#add-exception', 'Add Exception button is visible')
|
||||
|
||||
def wait_for_certificate_invalidations_section(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Wait for certificate invalidations section to be rendered on page
|
||||
"""
|
||||
self.wait_for_element_visibility(
|
||||
'div.certificate-invalidation-container',
|
||||
'Certificate invalidations section is visible.'
|
||||
)
|
||||
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
Refresh Certificates Page and wait for the page to load completely.
|
||||
@@ -1064,6 +1074,42 @@ class CertificatesPage(PageObject):
|
||||
"""
|
||||
self.get_selector('#add-exception').click()
|
||||
|
||||
def add_certificate_invalidation(self, student, notes):
|
||||
"""
|
||||
Add certificate invalidation for 'student'.
|
||||
"""
|
||||
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
|
||||
|
||||
self.get_selector('#certificate-invalidation-user').fill(student)
|
||||
self.get_selector('#certificate-invalidation-notes').fill(notes)
|
||||
self.get_selector('#invalidate-certificate').click()
|
||||
|
||||
self.wait_for_ajax()
|
||||
self.wait_for(
|
||||
lambda: student in self.get_selector('div.invalidation-history table tr:last-child td').text,
|
||||
description='Certificate invalidation added to list.'
|
||||
)
|
||||
|
||||
def remove_first_certificate_invalidation(self):
|
||||
"""
|
||||
Remove certificate invalidation from the invalidation list.
|
||||
"""
|
||||
self.wait_for_element_visibility('#invalidate-certificate', 'Invalidate Certificate button is visible')
|
||||
self.get_selector('div.invalidation-history table tr td .re-validate-certificate').first.click()
|
||||
self.wait_for_ajax()
|
||||
|
||||
def fill_certificate_invalidation_user_name_field(self, student): # pylint: disable=invalid-name
|
||||
"""
|
||||
Fill username/email field with given text
|
||||
"""
|
||||
self.get_selector('#certificate-invalidation-user').fill(student)
|
||||
|
||||
def click_invalidate_certificate_button(self):
|
||||
"""
|
||||
Click 'Invalidate Certificate' button in 'certificates invalidations' section
|
||||
"""
|
||||
self.get_selector('#invalidate-certificate').click()
|
||||
|
||||
@property
|
||||
def generate_certificates_button(self):
|
||||
"""
|
||||
@@ -1111,4 +1157,18 @@ class CertificatesPage(PageObject):
|
||||
"""
|
||||
Returns the Message (error/success) in "Certificate Exceptions" section.
|
||||
"""
|
||||
return self.get_selector('div.message')
|
||||
return self.get_selector('.certificate-exception-container div.message')
|
||||
|
||||
@property
|
||||
def last_certificate_invalidation(self):
|
||||
"""
|
||||
Returns last certificate invalidation from "Certificate Invalidations" section.
|
||||
"""
|
||||
return self.get_selector('div.certificate-invalidation-container table tr:last-child td')
|
||||
|
||||
@property
|
||||
def certificate_invalidation_message(self): # pylint: disable=invalid-name
|
||||
"""
|
||||
Returns the message (error/success) in "Certificate Invalidation" section.
|
||||
"""
|
||||
return self.get_selector('.certificate-invalidation-container div.message')
|
||||
|
||||
@@ -845,3 +845,202 @@ class CertificatesTest(BaseInstructorDashboardTest):
|
||||
' below to send the certificate.',
|
||||
self.certificates_section.message.text
|
||||
)
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
class CertificateInvalidationTest(BaseInstructorDashboardTest):
|
||||
"""
|
||||
Tests for Certificates functionality on instructor dashboard.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CertificateInvalidationTest, cls).setUpClass()
|
||||
|
||||
# Create course fixture once each test run
|
||||
CourseFixture(
|
||||
org='test_org',
|
||||
number='335535897951379478207964576572017930000',
|
||||
run='test_run',
|
||||
display_name='Test Course 335535897951379478207964576572017930000',
|
||||
).install()
|
||||
|
||||
def setUp(self):
|
||||
super(CertificateInvalidationTest, self).setUp()
|
||||
# set same course number as we have in fixture json
|
||||
self.course_info['number'] = "335535897951379478207964576572017930000"
|
||||
|
||||
# we have created a user with this id in fixture, and created a generated certificate for it.
|
||||
self.student_id = "99"
|
||||
self.student_name = "testcert"
|
||||
self.student_email = "cert@example.com"
|
||||
|
||||
# Enroll above test user in the course
|
||||
AutoAuthPage(
|
||||
self.browser,
|
||||
username=self.student_name,
|
||||
email=self.student_email,
|
||||
course_id=self.course_id,
|
||||
).visit()
|
||||
|
||||
self.test_certificate_config = {
|
||||
'id': 1,
|
||||
'name': 'Certificate name',
|
||||
'description': 'Certificate description',
|
||||
'course_title': 'Course title override',
|
||||
'signatories': [],
|
||||
'version': 1,
|
||||
'is_active': True
|
||||
}
|
||||
|
||||
self.cert_fixture = CertificateConfigFixture(self.course_id, self.test_certificate_config)
|
||||
self.cert_fixture.install()
|
||||
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_instructor_can_invalidate_certificate(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can add a certificate
|
||||
invalidation to invalidation list.
|
||||
|
||||
Given that I am on the Certificates tab on the Instructor Dashboard
|
||||
When I fill in student username and notes fields and click 'Add Exception' button
|
||||
Then new certificate exception should be visible in certificate exceptions list
|
||||
"""
|
||||
notes = 'Test Notes'
|
||||
# Add a student to certificate invalidation list
|
||||
self.certificates_section.add_certificate_invalidation(self.student_name, notes)
|
||||
self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
# Validate success message
|
||||
self.assertIn(
|
||||
"Certificate has been successfully invalidated for {user}.".format(user=self.student_name),
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
# Verify that added invalidations are also synced with backend
|
||||
# Revisit Page
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# wait for the certificate invalidations section to render
|
||||
self.certificates_section.wait_for_certificate_invalidations_section()
|
||||
|
||||
# validate certificate invalidation is visible in certificate invalidation list
|
||||
self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
def test_instructor_can_re_validate_certificate(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor can re-validate certificate.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
AND there is a certificate invalidation in certificate invalidation table
|
||||
When I click "Remove from Invalidation Table" button
|
||||
Then certificate is re-validated and removed from certificate invalidation table.
|
||||
"""
|
||||
notes = 'Test Notes'
|
||||
# Add a student to certificate invalidation list
|
||||
self.certificates_section.add_certificate_invalidation(self.student_name, notes)
|
||||
self.assertIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
# Verify that added invalidations are also synced with backend
|
||||
# Revisit Page
|
||||
self.certificates_section.refresh()
|
||||
|
||||
# wait for the certificate invalidations section to render
|
||||
self.certificates_section.wait_for_certificate_invalidations_section()
|
||||
|
||||
# click "Remove from Invalidation Table" button next to certificate invalidation
|
||||
self.certificates_section.remove_first_certificate_invalidation()
|
||||
|
||||
# validate certificate invalidation is removed from the list
|
||||
self.assertNotIn(self.student_name, self.certificates_section.last_certificate_invalidation.text)
|
||||
self.assertNotIn(notes, self.certificates_section.last_certificate_invalidation.text)
|
||||
|
||||
self.assertIn(
|
||||
"The certificate for this learner has been re-validated and the system is "
|
||||
"re-running the grade for this learner.",
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
def test_error_on_empty_user_name_or_email(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if he clicks
|
||||
"Invalidate Certificate" button without entering student username or email.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
When I click "Invalidate Certificate" button without entering student username/email.
|
||||
Then I see following error message
|
||||
"Student username/email field is required and can not be empty."
|
||||
"Kindly fill in username/email and then press "Invalidate Certificate" button."
|
||||
"""
|
||||
# Click "Invalidate Certificate" with empty student username/email field
|
||||
self.certificates_section.fill_certificate_invalidation_user_name_field("")
|
||||
self.certificates_section.click_invalidate_certificate_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u'Student username/email field is required and can not be empty. '
|
||||
u'Kindly fill in username/email and then press "Invalidate Certificate" button.',
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
def test_error_on_invalid_user(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if
|
||||
the student entered for certificate invalidation does not exist.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
When I click "Invalidate Certificate"
|
||||
AND the username entered does not exist in the system
|
||||
Then I see following error message
|
||||
"Student username/email field is required and can not be empty."
|
||||
"Kindly fill in username/email and then press "Invalidate Certificate" button."
|
||||
"""
|
||||
invalid_user = "invalid_test_user"
|
||||
# Click "Invalidate Certificate" with invalid student username/email
|
||||
self.certificates_section.fill_certificate_invalidation_user_name_field(invalid_user)
|
||||
self.certificates_section.click_invalidate_certificate_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u"{user} does not exist in the LMS. Please check your spelling and retry.".format(user=invalid_user),
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
def test_user_not_enrolled_error(self):
|
||||
"""
|
||||
Scenario: On the Certificates tab of the Instructor Dashboard, Instructor should see error message if
|
||||
the student entered for certificate invalidation is not enrolled in the course.
|
||||
|
||||
Given that I am on the certificates tab on the Instructor Dashboard
|
||||
When I click "Invalidate Certificate"
|
||||
AND the username entered is not enrolled in the current course
|
||||
Then I see following error message
|
||||
"{user} is not enrolled in this course. Please check your spelling and retry."
|
||||
"""
|
||||
new_user = 'test_user_{uuid}'.format(uuid=self.unique_id[6:12])
|
||||
new_email = 'test_user_{uuid}@example.com'.format(uuid=self.unique_id[6:12])
|
||||
# Create a new user who is not enrolled in the course
|
||||
AutoAuthPage(self.browser, username=new_user, email=new_email).visit()
|
||||
# Login as instructor and visit Certificate Section of Instructor Dashboard
|
||||
self.user_name, self.user_id = self.log_in_as_instructor()
|
||||
self.instructor_dashboard_page.visit()
|
||||
self.certificates_section = self.instructor_dashboard_page.select_certificates()
|
||||
|
||||
# Click 'Invalidate Certificate' button with not enrolled student
|
||||
self.certificates_section.wait_for_certificate_invalidations_section()
|
||||
|
||||
self.certificates_section.fill_certificate_invalidation_user_name_field(new_user)
|
||||
self.certificates_section.click_invalidate_certificate_button()
|
||||
self.certificates_section.wait_for_ajax()
|
||||
|
||||
self.assertIn(
|
||||
u"{user} is not enrolled in this course. Please check your spelling and retry.".format(user=new_user),
|
||||
self.certificates_section.certificate_invalidation_message.text
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
import model_utils.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('certificates', '0006_certificatetemplateasset_asset_slug'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CertificateInvalidation',
|
||||
fields=[
|
||||
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
|
||||
('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, verbose_name='created', editable=False)),
|
||||
('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, verbose_name='modified', editable=False)),
|
||||
('notes', models.TextField(default=None, null=True)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('generated_certificate', models.ForeignKey(to='certificates.GeneratedCertificate')),
|
||||
('invalidated_by', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -253,6 +253,12 @@ class GeneratedCertificate(models.Model):
|
||||
|
||||
self.save()
|
||||
|
||||
def is_valid(self):
|
||||
"""
|
||||
Return True if certificate is valid else return False.
|
||||
"""
|
||||
return self.status == CertificateStatuses.downloadable
|
||||
|
||||
|
||||
class CertificateGenerationHistory(TimeStampedModel):
|
||||
"""
|
||||
@@ -309,6 +315,59 @@ class CertificateGenerationHistory(TimeStampedModel):
|
||||
("regenerated" if self.is_regeneration else "generated", self.generated_by, self.created, self.course_id)
|
||||
|
||||
|
||||
class CertificateInvalidation(TimeStampedModel):
|
||||
"""
|
||||
Model for storing Certificate Invalidation.
|
||||
"""
|
||||
generated_certificate = models.ForeignKey(GeneratedCertificate)
|
||||
invalidated_by = models.ForeignKey(User)
|
||||
notes = models.TextField(default=None, null=True)
|
||||
active = models.BooleanField(default=True)
|
||||
|
||||
class Meta(object):
|
||||
app_label = "certificates"
|
||||
|
||||
def __unicode__(self):
|
||||
return u"Certificate %s, invalidated by %s on %s." % \
|
||||
(self.generated_certificate, self.invalidated_by, self.created)
|
||||
|
||||
def deactivate(self):
|
||||
"""
|
||||
Deactivate certificate invalidation by setting active to False.
|
||||
"""
|
||||
self.active = False
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def get_certificate_invalidations(cls, course_key, student=None):
|
||||
"""
|
||||
Return certificate invalidations filtered based on the provided course and student (if provided),
|
||||
|
||||
Returned value is JSON serializable list of dicts, dict element would have the following key-value pairs.
|
||||
1. id: certificate invalidation id (primary key)
|
||||
2. user: username of the student to whom certificate belongs
|
||||
3. invalidated_by: user id of the instructor/support user who invalidated the certificate
|
||||
4. created: string containing date of invalidation in the following format "December 29, 2015"
|
||||
5. notes: string containing notes regarding certificate invalidation.
|
||||
"""
|
||||
certificate_invalidations = cls.objects.filter(
|
||||
generated_certificate__course_id=course_key,
|
||||
active=True,
|
||||
)
|
||||
if student:
|
||||
certificate_invalidations = certificate_invalidations.filter(generated_certificate__user=student)
|
||||
data = []
|
||||
for certificate_invalidation in certificate_invalidations:
|
||||
data.append({
|
||||
'id': certificate_invalidation.id,
|
||||
'user': certificate_invalidation.generated_certificate.user.username,
|
||||
'invalidated_by': certificate_invalidation.invalidated_by.username,
|
||||
'created': certificate_invalidation.created.strftime("%B %d, %Y"),
|
||||
'notes': certificate_invalidation.notes,
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
@receiver(post_save, sender=GeneratedCertificate)
|
||||
def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
|
||||
@@ -9,7 +9,7 @@ from student.models import LinkedInAddToProfileConfiguration
|
||||
|
||||
from certificates.models import (
|
||||
GeneratedCertificate, CertificateStatuses, CertificateHtmlViewConfiguration, CertificateWhitelist, BadgeAssertion,
|
||||
BadgeImageConfiguration,
|
||||
BadgeImageConfiguration, CertificateInvalidation,
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,15 @@ class CertificateWhitelistFactory(DjangoModelFactory):
|
||||
notes = 'Test Notes'
|
||||
|
||||
|
||||
class CertificateInvalidationFactory(DjangoModelFactory):
|
||||
|
||||
class Meta(object):
|
||||
model = CertificateInvalidation
|
||||
|
||||
notes = 'Test Notes'
|
||||
active = True
|
||||
|
||||
|
||||
class BadgeAssertionFactory(DjangoModelFactory):
|
||||
class Meta(object):
|
||||
model = BadgeAssertion
|
||||
|
||||
@@ -13,9 +13,10 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from config_models.models import cache
|
||||
from courseware.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
|
||||
from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory
|
||||
from certificates.tests.factories import GeneratedCertificateFactory, CertificateWhitelistFactory, \
|
||||
CertificateInvalidationFactory
|
||||
from certificates.models import CertificateGenerationConfiguration, CertificateStatuses, CertificateWhitelist, \
|
||||
GeneratedCertificate
|
||||
GeneratedCertificate, CertificateInvalidation
|
||||
from certificates import api as certs_api
|
||||
from student.models import CourseEnrollment
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
@@ -923,3 +924,299 @@ class TestCertificatesInstructorApiBulkWhiteListExceptions(SharedModuleStoreTest
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content)
|
||||
return data
|
||||
|
||||
|
||||
@attr('shard_1')
|
||||
@ddt.ddt
|
||||
class CertificateInvalidationViewTests(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test certificate invalidation view.
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CertificateInvalidationViewTests, cls).setUpClass()
|
||||
cls.course = CourseFactory.create()
|
||||
cls.url = reverse('certificate_invalidation_view',
|
||||
kwargs={'course_id': cls.course.id})
|
||||
cls.notes = "Test notes."
|
||||
|
||||
def setUp(self):
|
||||
super(CertificateInvalidationViewTests, self).setUp()
|
||||
self.global_staff = GlobalStaffFactory()
|
||||
self.enrolled_user_1 = UserFactory(
|
||||
username='TestStudent1',
|
||||
email='test_student1@example.com',
|
||||
first_name='Enrolled',
|
||||
last_name='Student',
|
||||
)
|
||||
self.enrolled_user_2 = UserFactory(
|
||||
username='TestStudent2',
|
||||
email='test_student2@example.com',
|
||||
first_name='Enrolled',
|
||||
last_name='Student',
|
||||
)
|
||||
|
||||
self.not_enrolled_student = UserFactory(
|
||||
username='NotEnrolledStudent',
|
||||
email='nonenrolled@test.com',
|
||||
first_name='NotEnrolled',
|
||||
last_name='Student',
|
||||
)
|
||||
CourseEnrollment.enroll(self.enrolled_user_1, self.course.id)
|
||||
CourseEnrollment.enroll(self.enrolled_user_2, self.course.id)
|
||||
|
||||
self.generated_certificate = GeneratedCertificateFactory.create(
|
||||
user=self.enrolled_user_1,
|
||||
course_id=self.course.id,
|
||||
status=CertificateStatuses.downloadable,
|
||||
mode='honor',
|
||||
)
|
||||
|
||||
self.certificate_invalidation_data = dict(
|
||||
user=self.enrolled_user_1.username,
|
||||
notes=self.notes,
|
||||
)
|
||||
|
||||
# Global staff can see the certificates section
|
||||
self.client.login(username=self.global_staff.username, password="test")
|
||||
|
||||
def test_invalidate_certificate(self):
|
||||
"""
|
||||
Test user can invalidate a generated certificate.
|
||||
"""
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
# Assert successful request processing
|
||||
self.assertEqual(response.status_code, 200)
|
||||
result = json.loads(response.content)
|
||||
|
||||
# Assert Certificate Exception Updated data
|
||||
self.assertEqual(result['user'], self.enrolled_user_1.username)
|
||||
self.assertEqual(result['invalidated_by'], self.global_staff.username)
|
||||
self.assertEqual(result['notes'], self.notes)
|
||||
|
||||
# Verify that CertificateInvalidation record has been created in the database i.e. no DoesNotExist error
|
||||
try:
|
||||
CertificateInvalidation.objects.get(
|
||||
generated_certificate=self.generated_certificate,
|
||||
invalidated_by=self.global_staff,
|
||||
notes=self.notes,
|
||||
active=True,
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
self.fail("The certificate is not invalidated.")
|
||||
|
||||
# Validate generated certificate was invalidated
|
||||
generated_certificate = GeneratedCertificate.objects.get(
|
||||
user=self.enrolled_user_1,
|
||||
course_id=self.course.id,
|
||||
)
|
||||
self.assertFalse(generated_certificate.is_valid())
|
||||
|
||||
def test_missing_username_and_email_error(self):
|
||||
"""
|
||||
Test error message if user name or email is missing.
|
||||
"""
|
||||
self.certificate_invalidation_data.update({'user': ''})
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Assert 400 status code in response
|
||||
self.assertEqual(response.status_code, 400)
|
||||
res_json = json.loads(response.content)
|
||||
|
||||
# Assert Error Message
|
||||
self.assertEqual(
|
||||
res_json['message'],
|
||||
u'Student username/email field is required and can not be empty. '
|
||||
u'Kindly fill in username/email and then press "Invalidate Certificate" button.',
|
||||
)
|
||||
|
||||
def test_invalid_user_name_error(self):
|
||||
"""
|
||||
Test error message if invalid user name is given.
|
||||
"""
|
||||
invalid_user = "test_invalid_user_name"
|
||||
|
||||
self.certificate_invalidation_data.update({"user": invalid_user})
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Assert 400 status code in response
|
||||
self.assertEqual(response.status_code, 400)
|
||||
res_json = json.loads(response.content)
|
||||
|
||||
# Assert Error Message
|
||||
self.assertEqual(
|
||||
res_json['message'],
|
||||
u"{user} does not exist in the LMS. Please check your spelling and retry.".format(user=invalid_user),
|
||||
)
|
||||
|
||||
def test_user_not_enrolled_error(self):
|
||||
"""
|
||||
Test error message if user is not enrolled in the course.
|
||||
"""
|
||||
self.certificate_invalidation_data.update({"user": self.not_enrolled_student.username})
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Assert 400 status code in response
|
||||
self.assertEqual(response.status_code, 400)
|
||||
res_json = json.loads(response.content)
|
||||
|
||||
# Assert Error Message
|
||||
self.assertEqual(
|
||||
res_json['message'],
|
||||
u"{user} is not enrolled in this course. Please check your spelling and retry.".format(
|
||||
user=self.not_enrolled_student.username,
|
||||
),
|
||||
)
|
||||
|
||||
def test_no_generated_certificate_error(self):
|
||||
"""
|
||||
Test error message if there is no generated certificate for the student.
|
||||
"""
|
||||
self.certificate_invalidation_data.update({"user": self.enrolled_user_2.username})
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Assert 400 status code in response
|
||||
self.assertEqual(response.status_code, 400)
|
||||
res_json = json.loads(response.content)
|
||||
|
||||
# Assert Error Message
|
||||
self.assertEqual(
|
||||
res_json['message'],
|
||||
u"The student {student} does not have certificate for the course {course}. "
|
||||
u"Kindly verify student username/email and the selected course are correct and try again.".format(
|
||||
student=self.enrolled_user_2.username,
|
||||
course=self.course.number,
|
||||
),
|
||||
)
|
||||
|
||||
def test_certificate_already_invalid_error(self):
|
||||
"""
|
||||
Test error message if certificate for the student is already invalid.
|
||||
"""
|
||||
# Invalidate user certificate
|
||||
self.generated_certificate.invalidate()
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Assert 400 status code in response
|
||||
self.assertEqual(response.status_code, 400)
|
||||
res_json = json.loads(response.content)
|
||||
|
||||
# Assert Error Message
|
||||
self.assertEqual(
|
||||
res_json['message'],
|
||||
u"Certificate for student {user} is already invalid, kindly verify that certificate "
|
||||
u"was generated for this student and then proceed.".format(
|
||||
user=self.enrolled_user_1.username,
|
||||
),
|
||||
)
|
||||
|
||||
def test_duplicate_certificate_invalidation_error(self):
|
||||
"""
|
||||
Test error message if certificate invalidation for the student is already present.
|
||||
"""
|
||||
CertificateInvalidationFactory.create(
|
||||
generated_certificate=self.generated_certificate,
|
||||
invalidated_by=self.global_staff,
|
||||
)
|
||||
# Invalidate user certificate
|
||||
self.generated_certificate.invalidate()
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
# Assert 400 status code in response
|
||||
self.assertEqual(response.status_code, 400)
|
||||
res_json = json.loads(response.content)
|
||||
|
||||
# Assert Error Message
|
||||
self.assertEqual(
|
||||
res_json['message'],
|
||||
u"Certificate of {user} has already been invalidated. Please check your spelling and retry.".format(
|
||||
user=self.enrolled_user_1.username,
|
||||
),
|
||||
)
|
||||
|
||||
def test_remove_certificate_invalidation(self):
|
||||
"""
|
||||
Test that user can remove certificate invalidation.
|
||||
"""
|
||||
# Invalidate user certificate
|
||||
self.generated_certificate.invalidate()
|
||||
|
||||
CertificateInvalidationFactory.create(
|
||||
generated_certificate=self.generated_certificate,
|
||||
invalidated_by=self.global_staff,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
REQUEST_METHOD='DELETE'
|
||||
)
|
||||
|
||||
# Assert 204 status code in response
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
# Verify that certificate invalidation successfully removed from database
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
CertificateInvalidation.objects.get(
|
||||
generated_certificate=self.generated_certificate,
|
||||
invalidated_by=self.global_staff,
|
||||
active=True,
|
||||
)
|
||||
|
||||
def test_remove_certificate_invalidation_error(self):
|
||||
"""
|
||||
Test error message if certificate invalidation does not exists.
|
||||
"""
|
||||
# Invalidate user certificate
|
||||
self.generated_certificate.invalidate()
|
||||
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(self.certificate_invalidation_data),
|
||||
content_type='application/json',
|
||||
REQUEST_METHOD='DELETE'
|
||||
)
|
||||
|
||||
# Assert 400 status code in response
|
||||
self.assertEqual(response.status_code, 400)
|
||||
res_json = json.loads(response.content)
|
||||
|
||||
# Assert Error Message
|
||||
self.assertEqual(
|
||||
res_json['message'],
|
||||
u"Certificate Invalidation does not exist, Please refresh the page and try again.",
|
||||
)
|
||||
|
||||
@@ -89,7 +89,7 @@ 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, GeneratedCertificate, CertificateStatuses
|
||||
from certificates.models import CertificateWhitelist, GeneratedCertificate, CertificateStatuses, CertificateInvalidation
|
||||
|
||||
from bulk_email.models import CourseEmail
|
||||
from student.models import get_user_by_username_or_email
|
||||
@@ -2839,28 +2839,55 @@ def parse_request_data_and_get_user(request, course_key):
|
||||
:param course_key: Course Identifier of the course for whom to process certificate exception
|
||||
:return: key-value pairs containing certificate exception data and User object
|
||||
"""
|
||||
try:
|
||||
certificate_exception = json.loads(request.body or '{}')
|
||||
except ValueError:
|
||||
raise ValueError(_('The record is not in the correct format. Please add a valid username or email address.'))
|
||||
certificate_exception = parse_request_data(request)
|
||||
|
||||
user = certificate_exception.get('user_name', '') or certificate_exception.get('user_email', '')
|
||||
if not user:
|
||||
raise ValueError(_('Student username/email field is required and can not be empty. '
|
||||
'Kindly fill in username/email and then press "Add to Exception List" button.'))
|
||||
try:
|
||||
db_user = get_user_by_username_or_email(user)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(_("{user} does not exist in the LMS. Please check your spelling and retry.").format(user=user))
|
||||
|
||||
# Make Sure the given student is enrolled in the course
|
||||
if not CourseEnrollment.is_enrolled(db_user, course_key):
|
||||
raise ValueError(_("{user} is not enrolled in this course. Please check your spelling and retry.")
|
||||
.format(user=user))
|
||||
db_user = get_student(user, course_key)
|
||||
|
||||
return certificate_exception, db_user
|
||||
|
||||
|
||||
def parse_request_data(request):
|
||||
"""
|
||||
Parse and return request data, raise ValueError in case of invalid JSON data.
|
||||
|
||||
:param request: HttpRequest request object.
|
||||
:return: dict object containing parsed json data.
|
||||
"""
|
||||
try:
|
||||
data = json.loads(request.body or '{}')
|
||||
except ValueError:
|
||||
raise ValueError(_('The record is not in the correct format. Please add a valid username or email address.'))
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_student(username_or_email, course_key):
|
||||
"""
|
||||
Retrieve and return User object from db, raise ValueError
|
||||
if user is does not exists or is not enrolled in the given course.
|
||||
|
||||
:param username_or_email: String containing either user name or email of the student.
|
||||
:param course_key: CourseKey object identifying the current course.
|
||||
:return: User object
|
||||
"""
|
||||
try:
|
||||
student = get_user_by_username_or_email(username_or_email)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(_("{user} does not exist in the LMS. Please check your spelling and retry.").format(
|
||||
user=username_or_email
|
||||
))
|
||||
|
||||
# Make Sure the given student is enrolled in the course
|
||||
if not CourseEnrollment.is_enrolled(student, course_key):
|
||||
raise ValueError(_("{user} is not enrolled in this course. Please check your spelling and retry.")
|
||||
.format(user=username_or_email))
|
||||
return student
|
||||
|
||||
|
||||
@transaction.non_atomic_requests
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@@ -3014,3 +3041,142 @@ def generate_bulk_certificate_exceptions(request, course_id): # pylint: disable
|
||||
}
|
||||
|
||||
return JsonResponse(results)
|
||||
|
||||
|
||||
@transaction.non_atomic_requests
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@require_global_staff
|
||||
@require_http_methods(['POST', 'DELETE'])
|
||||
def certificate_invalidation_view(request, course_id):
|
||||
"""
|
||||
Invalidate/Re-Validate students to/from certificate.
|
||||
|
||||
:param request: HttpRequest object
|
||||
:param course_id: course identifier of the course for whom to add/remove certificates exception.
|
||||
:return: JsonResponse object with success/error message or certificate invalidation data.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
# Validate request data and return error response in case of invalid data
|
||||
try:
|
||||
certificate_invalidation_data = parse_request_data(request)
|
||||
certificate = validate_request_data_and_get_certificate(certificate_invalidation_data, course_key)
|
||||
except ValueError as error:
|
||||
return JsonResponse({'message': error.message}, status=400)
|
||||
|
||||
# Invalidate certificate of the given student for the course course
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
certificate_invalidation = invalidate_certificate(request, certificate, certificate_invalidation_data)
|
||||
except ValueError as error:
|
||||
return JsonResponse({'message': error.message}, status=400)
|
||||
return JsonResponse(certificate_invalidation)
|
||||
|
||||
# Re-Validate student certificate for the course course
|
||||
elif request.method == 'DELETE':
|
||||
try:
|
||||
re_validate_certificate(request, course_key, certificate)
|
||||
except ValueError as error:
|
||||
return JsonResponse({'message': error.message}, status=400)
|
||||
|
||||
return JsonResponse({}, status=204)
|
||||
|
||||
|
||||
def invalidate_certificate(request, generated_certificate, certificate_invalidation_data):
|
||||
"""
|
||||
Invalidate given GeneratedCertificate and add CertificateInvalidation record for future reference or re-validation.
|
||||
|
||||
:param request: HttpRequest object
|
||||
:param generated_certificate: GeneratedCertificate object, the certificate we want to invalidate
|
||||
:param certificate_invalidation_data: dict object containing data for CertificateInvalidation.
|
||||
:return: dict object containing updated certificate invalidation data.
|
||||
"""
|
||||
if len(CertificateInvalidation.get_certificate_invalidations(
|
||||
generated_certificate.course_id,
|
||||
generated_certificate.user,
|
||||
)) > 0:
|
||||
raise ValueError(
|
||||
_("Certificate of {user} has already been invalidated. Please check your spelling and retry.").format(
|
||||
user=generated_certificate.user.username,
|
||||
)
|
||||
)
|
||||
|
||||
# Verify that certificate user wants to invalidate is a valid one.
|
||||
if not generated_certificate.is_valid():
|
||||
raise ValueError(
|
||||
_("Certificate for student {user} is already invalid, kindly verify that certificate was generated "
|
||||
"for this student and then proceed.").format(user=generated_certificate.user.username)
|
||||
)
|
||||
|
||||
# Add CertificateInvalidation record for future reference or re-validation
|
||||
certificate_invalidation, __ = CertificateInvalidation.objects.update_or_create(
|
||||
generated_certificate=generated_certificate,
|
||||
defaults={
|
||||
'invalidated_by': request.user,
|
||||
'notes': certificate_invalidation_data.get("notes", ""),
|
||||
'active': True,
|
||||
}
|
||||
)
|
||||
|
||||
# Invalidate GeneratedCertificate
|
||||
generated_certificate.invalidate()
|
||||
return {
|
||||
'id': certificate_invalidation.id,
|
||||
'user': certificate_invalidation.generated_certificate.user.username,
|
||||
'invalidated_by': certificate_invalidation.invalidated_by.username,
|
||||
'created': certificate_invalidation.created.strftime("%B %d, %Y"),
|
||||
'notes': certificate_invalidation.notes,
|
||||
}
|
||||
|
||||
|
||||
def re_validate_certificate(request, course_key, generated_certificate):
|
||||
"""
|
||||
Remove certificate invalidation from db and start certificate generation task for this student.
|
||||
Raises ValueError if certificate invalidation is present.
|
||||
|
||||
:param request: HttpRequest object
|
||||
:param course_key: CourseKey object identifying the current course.
|
||||
:param generated_certificate: GeneratedCertificate object of the student for the given course
|
||||
"""
|
||||
try:
|
||||
# Fetch CertificateInvalidation object
|
||||
certificate_invalidation = CertificateInvalidation.objects.get(generated_certificate=generated_certificate)
|
||||
except ObjectDoesNotExist:
|
||||
raise ValueError(_("Certificate Invalidation does not exist, Please refresh the page and try again."))
|
||||
else:
|
||||
# Deactivate certificate invalidation if it was fetched successfully.
|
||||
certificate_invalidation.deactivate()
|
||||
|
||||
# We need to generate certificate only for a single student here
|
||||
students = [certificate_invalidation.generated_certificate.user]
|
||||
instructor_task.api.generate_certificates_for_students(request, course_key, students=students)
|
||||
|
||||
|
||||
def validate_request_data_and_get_certificate(certificate_invalidation, course_key):
|
||||
"""
|
||||
Fetch and return GeneratedCertificate of the student passed in request data for the given course.
|
||||
|
||||
Raises ValueError in case of missing student username/email or
|
||||
if student does not have certificate for the given course.
|
||||
|
||||
:param certificate_invalidation: dict containing certificate invalidation data
|
||||
:param course_key: CourseKey object identifying the current course.
|
||||
:return: GeneratedCertificate object of the student for the given course
|
||||
"""
|
||||
user = certificate_invalidation.get("user")
|
||||
|
||||
if not user:
|
||||
raise ValueError(
|
||||
_('Student username/email field is required and can not be empty. '
|
||||
'Kindly fill in username/email and then press "Invalidate Certificate" button.')
|
||||
)
|
||||
|
||||
student = get_student(user, course_key)
|
||||
|
||||
certificate = GeneratedCertificate.certificate_for_student(student, course_key)
|
||||
if not certificate:
|
||||
raise ValueError(_(
|
||||
"The student {student} does not have certificate for the course {course}. Kindly verify student "
|
||||
"username/email and the selected course are correct and try again."
|
||||
).format(student=student.username, course=course_key.course))
|
||||
return certificate
|
||||
|
||||
@@ -161,4 +161,8 @@ urlpatterns = patterns(
|
||||
url(r'^generate_bulk_certificate_exceptions',
|
||||
'instructor.views.api.generate_bulk_certificate_exceptions',
|
||||
name='generate_bulk_certificate_exceptions'),
|
||||
|
||||
url(r'^certificate_invalidation_view/$',
|
||||
'instructor.views.api.certificate_invalidation_view',
|
||||
name='certificate_invalidation_view'),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ from certificates.models import (
|
||||
GeneratedCertificate,
|
||||
CertificateStatuses,
|
||||
CertificateGenerationHistory,
|
||||
CertificateInvalidation,
|
||||
)
|
||||
from certificates import api as certs_api
|
||||
from util.date_utils import get_default_time_display
|
||||
@@ -184,6 +185,13 @@ def instructor_dashboard_2(request, course_id):
|
||||
kwargs={'course_id': unicode(course_key)}
|
||||
)
|
||||
|
||||
certificate_invalidation_view_url = reverse( # pylint: disable=invalid-name
|
||||
'certificate_invalidation_view',
|
||||
kwargs={'course_id': unicode(course_key)}
|
||||
)
|
||||
|
||||
certificate_invalidations = CertificateInvalidation.get_certificate_invalidations(course_key)
|
||||
|
||||
context = {
|
||||
'course': course,
|
||||
'studio_url': get_studio_url(course, 'course'),
|
||||
@@ -191,9 +199,11 @@ def instructor_dashboard_2(request, course_id):
|
||||
'disable_buttons': disable_buttons,
|
||||
'analytics_dashboard_message': analytics_dashboard_message,
|
||||
'certificate_white_list': certificate_white_list,
|
||||
'certificate_invalidations': certificate_invalidations,
|
||||
'generate_certificate_exceptions_url': generate_certificate_exceptions_url,
|
||||
'generate_bulk_certificate_exceptions_url': generate_bulk_certificate_exceptions_url,
|
||||
'certificate_exception_view_url': certificate_exception_view_url
|
||||
'certificate_exception_view_url': certificate_exception_view_url,
|
||||
'certificate_invalidation_view_url': certificate_invalidation_view_url,
|
||||
}
|
||||
|
||||
return render_to_response('instructor/instructor_dashboard_2/instructor_dashboard_2.html', context)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Backbone.js Application Collection: CertificateInvalidationCollection
|
||||
/*global define, RequireJS */
|
||||
|
||||
;(function(define) {
|
||||
'use strict';
|
||||
|
||||
define(
|
||||
['backbone', 'js/certificates/models/certificate_invalidation'],
|
||||
|
||||
function(Backbone, CertificateInvalidation) {
|
||||
return Backbone.Collection.extend({
|
||||
model: CertificateInvalidation
|
||||
});
|
||||
}
|
||||
);
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -0,0 +1,31 @@
|
||||
// Backbone.js Page Object Factory: Certificate Invalidation Factory
|
||||
/*global define, RequireJS */
|
||||
|
||||
;(function(define) {
|
||||
'use strict';
|
||||
define(
|
||||
[
|
||||
'js/certificates/views/certificate_invalidation_view',
|
||||
'js/certificates/collections/certificate_invalidation_collection'
|
||||
],
|
||||
function(CertificateInvalidationView, CertificateInvalidationCollection) {
|
||||
|
||||
return function(certificate_invalidation_collection_json, certificate_invalidation_url) {
|
||||
var certificate_invalidation_collection = new CertificateInvalidationCollection(
|
||||
JSON.parse(certificate_invalidation_collection_json), {
|
||||
parse: true,
|
||||
canBeEmpty: true,
|
||||
url: certificate_invalidation_url
|
||||
}
|
||||
);
|
||||
|
||||
var certificate_invalidation_view = new CertificateInvalidationView({
|
||||
collection: certificate_invalidation_collection
|
||||
});
|
||||
|
||||
certificate_invalidation_view.render();
|
||||
};
|
||||
|
||||
}
|
||||
);
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -0,0 +1,35 @@
|
||||
// Backbone.js Application Model: CertificateInvalidation
|
||||
/*global define, RequireJS */
|
||||
|
||||
;(function(define) {
|
||||
'use strict';
|
||||
|
||||
define(
|
||||
['underscore', 'underscore.string', 'gettext', 'backbone'],
|
||||
|
||||
function(_, str, gettext, Backbone) {
|
||||
return Backbone.Model.extend({
|
||||
idAttribute: 'id',
|
||||
|
||||
defaults: {
|
||||
user: '',
|
||||
invalidated_by: '',
|
||||
created: '',
|
||||
notes: ''
|
||||
},
|
||||
|
||||
url: function() {
|
||||
return this.get('url');
|
||||
},
|
||||
|
||||
validate: function(attrs) {
|
||||
if (!_.str.trim(attrs.user)) {
|
||||
// A username or email must be provided for certificate invalidation
|
||||
return gettext('Student username/email field is required and can not be empty. ' +
|
||||
'Kindly fill in username/email and then press "Invalidate Certificate" button.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -0,0 +1,121 @@
|
||||
// Backbone Application View: CertificateInvalidationView
|
||||
/*global define, RequireJS */
|
||||
|
||||
;(function(define) {
|
||||
'use strict';
|
||||
define(
|
||||
['jquery', 'underscore', 'gettext', 'backbone', 'js/certificates/models/certificate_invalidation'],
|
||||
|
||||
function($, _, gettext, Backbone, CertificateInvalidationModel) {
|
||||
return Backbone.View.extend({
|
||||
el: "#certificate-invalidation",
|
||||
messages: "div.message",
|
||||
events: {
|
||||
'click #invalidate-certificate': 'invalidateCertificate',
|
||||
'click .re-validate-certificate': 'reValidateCertificate'
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
this.listenTo(this.collection, 'change add remove', this.render);
|
||||
},
|
||||
|
||||
render: function() {
|
||||
var template = this.loadTemplate('certificate-invalidation');
|
||||
this.$el.html(template({certificate_invalidations: this.collection.models}));
|
||||
},
|
||||
|
||||
loadTemplate: function(name) {
|
||||
var templateSelector = "#" + name + "-tpl",
|
||||
templateText = $(templateSelector).text();
|
||||
return _.template(templateText);
|
||||
},
|
||||
|
||||
invalidateCertificate: function() {
|
||||
var user = this.$("#certificate-invalidation-user").val();
|
||||
var notes = this.$("#certificate-invalidation-notes").val();
|
||||
|
||||
var certificate_invalidation = new CertificateInvalidationModel({
|
||||
url: this.collection.url,
|
||||
user: user,
|
||||
notes: notes
|
||||
});
|
||||
|
||||
if (this.collection.findWhere({user: user})) {
|
||||
this.showMessage(
|
||||
gettext("Certificate of ") + user +
|
||||
gettext(" has already been invalidated. Please check your spelling and retry."
|
||||
));
|
||||
}
|
||||
else if (certificate_invalidation.isValid()) {
|
||||
var self = this;
|
||||
certificate_invalidation.save(null, {
|
||||
wait: true,
|
||||
|
||||
success: function(model) {
|
||||
self.collection.add(model);
|
||||
self.showMessage(
|
||||
gettext('Certificate has been successfully invalidated for ') + user + '.'
|
||||
);
|
||||
},
|
||||
|
||||
error: function(model, response) {
|
||||
try {
|
||||
var response_data = JSON.parse(response.responseText);
|
||||
self.showMessage(response_data.message);
|
||||
}
|
||||
catch(exception) {
|
||||
self.showMessage(gettext("Server Error, Please refresh the page and try again."));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
else {
|
||||
this.showMessage(certificate_invalidation.validationError);
|
||||
}
|
||||
},
|
||||
|
||||
reValidateCertificate: function(event) {
|
||||
var certificate_invalidation = $(event.target).data();
|
||||
var model = this.collection.get(certificate_invalidation),
|
||||
self = this;
|
||||
|
||||
if (model) {
|
||||
model.destroy({
|
||||
success: function() {
|
||||
self.showMessage(gettext('The certificate for this learner has been re-validated and ' +
|
||||
'the system is re-running the grade for this learner.'));
|
||||
},
|
||||
error: function(model, response) {
|
||||
try {
|
||||
var response_data = JSON.parse(response.responseText);
|
||||
self.showMessage(response_data.message);
|
||||
}
|
||||
catch(exception) {
|
||||
self.showMessage(gettext("Server Error, Please refresh the page and try again."));
|
||||
}
|
||||
},
|
||||
wait: true,
|
||||
data: JSON.stringify(model.attributes)
|
||||
});
|
||||
}
|
||||
else {
|
||||
self.showMessage(gettext('Could not find Certificate Invalidation in the list. ' +
|
||||
'Please refresh the page and try again'));
|
||||
}
|
||||
},
|
||||
|
||||
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) {
|
||||
$(this.messages + ">p" ).remove();
|
||||
this.$(this.messages).removeClass('hidden').append("<p>"+ gettext(message) + "</p>");
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
}).call(this, define || RequireJS.define);
|
||||
@@ -0,0 +1,268 @@
|
||||
/*global define */
|
||||
define([
|
||||
'jquery',
|
||||
'common/js/spec_helpers/ajax_helpers',
|
||||
'js/certificates/models/certificate_invalidation',
|
||||
'js/certificates/views/certificate_invalidation_view',
|
||||
'js/certificates/collections/certificate_invalidation_collection'
|
||||
],
|
||||
function($, AjaxHelpers, CertificateInvalidationModel, CertificateInvalidationView,
|
||||
CertificateInvalidationCollection) {
|
||||
'use strict';
|
||||
describe("Field validation of invalidation model.", function() {
|
||||
var certificate_invalidation = null;
|
||||
var assertValid = function(fields, isValid, expectedErrors) {
|
||||
certificate_invalidation.set(fields);
|
||||
var errors = certificate_invalidation.validate(certificate_invalidation.attributes);
|
||||
|
||||
if (isValid) {
|
||||
expect(errors).toBe(undefined);
|
||||
} else {
|
||||
expect(errors).toEqual(expectedErrors);
|
||||
}
|
||||
};
|
||||
|
||||
var EXPECTED_ERRORS = {
|
||||
user_name_or_email_required: 'Student username/email field is required and can not be empty. ' +
|
||||
'Kindly fill in username/email and then press "Invalidate Certificate" button.'
|
||||
};
|
||||
|
||||
beforeEach(function() {
|
||||
|
||||
certificate_invalidation = new CertificateInvalidationModel({user: 'test_user'});
|
||||
certificate_invalidation.set({
|
||||
notes: "Test notes"
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts valid email addresses", function() {
|
||||
assertValid({user: "bob@example.com"}, true);
|
||||
assertValid({user: "bob+smith@example.com"}, true);
|
||||
assertValid({user: "bob+smith@example.com"}, true);
|
||||
assertValid({user: "bob+smith@example.com"}, true);
|
||||
assertValid({user: "bob@test.example.com"}, true);
|
||||
assertValid({user: "bob@test-example.com"}, true);
|
||||
});
|
||||
|
||||
it("displays username or email required error", function() {
|
||||
assertValid({user: ""}, false, EXPECTED_ERRORS.user_name_or_email_required);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Certificate invalidation collection initialization and updates.",
|
||||
function() {
|
||||
var certificate_invalidations = null,
|
||||
certificate_invalidation_url = 'test/url/';
|
||||
var certificate_invalidations_json = [
|
||||
{
|
||||
id: 1,
|
||||
user: "test1",
|
||||
invalidated_by: 2,
|
||||
created: "Thursday, October 29, 2015",
|
||||
notes: "test notes for test certificate invalidation"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: "test2",
|
||||
invalidated_by: 2,
|
||||
created: "Thursday, October 29, 2015",
|
||||
notes: "test notes for test certificate invalidation"
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(function() {
|
||||
certificate_invalidations = new CertificateInvalidationCollection(certificate_invalidations_json, {
|
||||
parse: true,
|
||||
canBeEmpty: true,
|
||||
url: certificate_invalidation_url
|
||||
});
|
||||
});
|
||||
|
||||
it("has 2 models in the collection after initialization", function() {
|
||||
expect(certificate_invalidations.models.length).toEqual(2);
|
||||
});
|
||||
|
||||
it("model is removed from collection on destroy", function() {
|
||||
var model = certificate_invalidations.get({id: 2});
|
||||
model.destroy();
|
||||
expect(certificate_invalidations.models.length).toEqual(1);
|
||||
expect(certificate_invalidations.get({id: 2})).toBe(undefined);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
describe("Certificate invalidation success/error messages on add/remove invalidations.", function() {
|
||||
var view = null,
|
||||
certificate_invalidation_url = 'test/url/',
|
||||
user_name_field = null,
|
||||
notes_field = null,
|
||||
invalidate_button=null,
|
||||
duplicate_user='test2',
|
||||
new_user='test4@test.com',
|
||||
requests=null;
|
||||
|
||||
var messages = {
|
||||
error: {
|
||||
empty_user_name_email: 'Student username/email field is required and can not be empty. ' +
|
||||
'Kindly fill in username/email and then press "Invalidate Certificate" button.',
|
||||
duplicate_user: "Certificate of " + (duplicate_user) + " has already been invalidated. " +
|
||||
"Please check your spelling and retry.",
|
||||
server_error: "Server Error, Please refresh the page and try again.",
|
||||
from_server: "Test Message from server"
|
||||
},
|
||||
success: {
|
||||
saved: "Certificate has been successfully invalidated for " + new_user + '.',
|
||||
re_validated: 'The certificate for this learner has been re-validated and ' +
|
||||
'the system is re-running the grade for this learner.'
|
||||
}
|
||||
};
|
||||
|
||||
var certificate_invalidations_json = [
|
||||
{
|
||||
id: 1,
|
||||
user: "test1",
|
||||
invalidated_by: 2,
|
||||
created: "Thursday, October 29, 2015",
|
||||
notes: "test notes for test certificate invalidation"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user: "test2",
|
||||
invalidated_by: 2,
|
||||
created: "Thursday, October 29, 2015",
|
||||
notes: "test notes for test certificate invalidation"
|
||||
}
|
||||
];
|
||||
|
||||
beforeEach(function() {
|
||||
setFixtures();
|
||||
var fixture =readFixtures(
|
||||
"templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore"
|
||||
);
|
||||
|
||||
setFixtures(
|
||||
"<div class='certificate-invalidation-container'>" +
|
||||
" <h2>Invalidate Certificates</h2> " +
|
||||
" <div id='certificate-invalidation'></div>" +
|
||||
"</div>" +
|
||||
"<script type='text/template' id='certificate-invalidation-tpl'>" + fixture + "</script>"
|
||||
);
|
||||
|
||||
var certificate_invalidations = new CertificateInvalidationCollection(certificate_invalidations_json, {
|
||||
parse: true,
|
||||
canBeEmpty: true,
|
||||
url: certificate_invalidation_url,
|
||||
generate_certificates_url: certificate_invalidation_url
|
||||
|
||||
});
|
||||
|
||||
view = new CertificateInvalidationView({collection: certificate_invalidations});
|
||||
view.render();
|
||||
|
||||
user_name_field = $("#certificate-invalidation-user");
|
||||
notes_field = $("#certificate-invalidation-notes");
|
||||
invalidate_button = $("#invalidate-certificate");
|
||||
|
||||
requests = AjaxHelpers.requests(this);
|
||||
});
|
||||
|
||||
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/remove to collection", function() {
|
||||
var user = 'test3',
|
||||
notes = 'test3 notes',
|
||||
model = new CertificateInvalidationModel({user: user, notes: notes});
|
||||
|
||||
// Add another model in collection and verify it is rendered
|
||||
view.collection.add(model);
|
||||
expect(view.$el.find('table tbody tr').length).toBe(3);
|
||||
|
||||
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(user);
|
||||
|
||||
// Remove a model from collection
|
||||
var collection_model = view.collection.get({id: 2});
|
||||
collection_model.destroy();
|
||||
|
||||
// Verify view is updated
|
||||
expect(view.$el.find('table tbody tr').length).toBe(2);
|
||||
|
||||
|
||||
});
|
||||
|
||||
it("verifies view error message on duplicate certificate validation.", function() {
|
||||
$(user_name_field).val(duplicate_user);
|
||||
$(invalidate_button).click();
|
||||
|
||||
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.duplicate_user);
|
||||
});
|
||||
|
||||
it("verifies view error message on empty username/email field.", function() {
|
||||
$(user_name_field).val("");
|
||||
$(invalidate_button).click();
|
||||
|
||||
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.empty_user_name_email);
|
||||
});
|
||||
|
||||
it("verifies view success message on certificate invalidation.", function() {
|
||||
$(user_name_field).val(new_user);
|
||||
$(notes_field).val("test notes for user test4");
|
||||
$(invalidate_button).click();
|
||||
|
||||
AjaxHelpers.respondWithJson(
|
||||
requests,
|
||||
{
|
||||
id: 4,
|
||||
user: 'test4',
|
||||
validated_by: 5,
|
||||
created: "Thursday, December 29, 2015",
|
||||
notes: "test notes for user test4"
|
||||
}
|
||||
);
|
||||
expect($("#certificate-invalidation div.message").text()).toEqual(messages.success.saved);
|
||||
});
|
||||
|
||||
it("verifies view server error if server returns unknown response.", function() {
|
||||
$(user_name_field).val(new_user);
|
||||
$(notes_field).val("test notes for user test4");
|
||||
$(invalidate_button).click();
|
||||
|
||||
// Response with empty body
|
||||
AjaxHelpers.respondWithTextError(requests, 400, "");
|
||||
|
||||
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.server_error);
|
||||
});
|
||||
|
||||
it("verifies certificate re-validation request and success message.", function() {
|
||||
var user = 'test1',
|
||||
re_validate_certificate = "div.certificate-invalidation-container table tr:contains('" +
|
||||
user + "') td .re-validate-certificate";
|
||||
|
||||
$(re_validate_certificate).click();
|
||||
AjaxHelpers.respondWithJson(requests, {});
|
||||
|
||||
expect($("#certificate-invalidation div.message").text()).toEqual(messages.success.re_validated);
|
||||
});
|
||||
|
||||
it("verifies error message from server is displayed.", function() {
|
||||
var user = 'test1',
|
||||
re_validate_certificate = "div.certificate-invalidation-container table tr:contains('" +
|
||||
user + "') td .re-validate-certificate";
|
||||
|
||||
$(re_validate_certificate).click();
|
||||
AjaxHelpers.respondWithError(requests, 400, {
|
||||
success: false,
|
||||
message: messages.error.from_server
|
||||
});
|
||||
|
||||
expect($("#certificate-invalidation div.message").text()).toEqual(messages.error.from_server);
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -655,6 +655,7 @@
|
||||
'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/instructor_dashboard/certificates_invalidation_spec.js',
|
||||
'lms/include/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js',
|
||||
'lms/include/js/spec/instructor_dashboard/certificates_spec.js',
|
||||
'lms/include/js/spec/student_account/account_spec.js',
|
||||
|
||||
@@ -2159,16 +2159,18 @@ input[name="subject"] {
|
||||
}
|
||||
}
|
||||
|
||||
.student-username-or-email {
|
||||
width: 300px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.notes-field {
|
||||
width: 400px;
|
||||
}
|
||||
|
||||
|
||||
#certificate-white-list-editor {
|
||||
padding-top: 5px;
|
||||
.certificate-exception-inputs {
|
||||
.student-username-or-email {
|
||||
width: 300px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.notes-field {
|
||||
width: 400px;
|
||||
}
|
||||
p + p {
|
||||
margin-top: 5px;
|
||||
}
|
||||
@@ -2178,7 +2180,7 @@ input[name="subject"] {
|
||||
}
|
||||
}
|
||||
|
||||
.white-listed-students {
|
||||
.white-listed-students, .invalidation-history {
|
||||
margin-top: 10px;
|
||||
padding-top: 5px;
|
||||
table {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<p class="under-heading info">
|
||||
<%= gettext("To invalidate a certificate for a particular learner, add the username or email address below.") %>
|
||||
</p>
|
||||
|
||||
<div class="add-certificate-invalidation">
|
||||
<input class='student-username-or-email' id="certificate-invalidation-user" type="text" placeholder="<%= gettext('Username or email address') %>" aria-describedby='student-user-name-or-email-tip'>
|
||||
<textarea class='notes-field' id="certificate-invalidation-notes" rows="10" placeholder="<%= gettext('Add notes about this learner') %>" aria-describedby='notes-field-tip'></textarea>
|
||||
<br/>
|
||||
<button type="button" class="btn-blue" id="invalidate-certificate"><%= gettext('Invalidate Certificate') %></button>
|
||||
</div>
|
||||
|
||||
<div class="message hidden"></div>
|
||||
|
||||
<div class="invalidation-history">
|
||||
<% if (certificate_invalidations.length === 0) { %>
|
||||
<p><%- gettext("No results") %></p>
|
||||
<% } else { %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class='user-name'><%= gettext('Student') %></th>
|
||||
<th class='user-name'><%= gettext('Invalidated By') %></th>
|
||||
<th class='date'><%= gettext('Invalidated') %></th>
|
||||
<th class='notes'><%= gettext('Notes') %></th>
|
||||
<th class='action'><%= gettext('Action') %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (var i = 0; i < certificate_invalidations.length; i++) {
|
||||
var certificate_invalidation = certificate_invalidations[i];
|
||||
%>
|
||||
<tr>
|
||||
<td><%- certificate_invalidation.get("user") %></td>
|
||||
<td><%- certificate_invalidation.get("invalidated_by") %></td>
|
||||
<td><%- certificate_invalidation.get("created") %></td>
|
||||
<td><%- certificate_invalidation.get("notes") %></td>
|
||||
<td><button class='re-validate-certificate' data-cid='<%- certificate_invalidation.cid %>'><%- gettext("Remove from Invalidation Table") %></button></td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -8,6 +8,10 @@ import json
|
||||
CertificateWhitelistFactory('${json.dumps(certificate_white_list)}', "${generate_certificate_exceptions_url}", "${certificate_exception_view_url}", "${generate_bulk_certificate_exceptions_url}");
|
||||
</%static:require_module>
|
||||
|
||||
<%static:require_module module_name="js/certificates/factories/certificate_invalidation_factory" class_name="CertificateInvalidationFactory">
|
||||
CertificateInvalidationFactory('${json.dumps(certificate_invalidations)}', '${certificate_invalidation_view_url}');
|
||||
</%static:require_module>
|
||||
|
||||
<%page args="section_data"/>
|
||||
<div class="certificates-wrapper">
|
||||
|
||||
@@ -165,10 +169,25 @@ import json
|
||||
<div class="certificate-exception-section">
|
||||
<div id="certificate-white-list-editor"></div>
|
||||
<div class="bulk-white-list-exception"></div>
|
||||
<div class="white-listed-students" id="white-listed-students"></div>
|
||||
<div class="white-listed-students" id="white-listed-students">
|
||||
<div class="ui-loading">
|
||||
<span class="spin"><i class="icon fa fa-refresh" aria-hidden="true"></i></span> <span class="copy">${_('Loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<br/>
|
||||
</div>
|
||||
<div class="no-pending-tasks-message"></div>
|
||||
</div>
|
||||
|
||||
<hr class="section-divider" />
|
||||
|
||||
<div class="certificate-invalidation-container">
|
||||
<h2> ${_("Invalidate Certificates")} </h2>
|
||||
<div id="certificate-invalidation">
|
||||
<div class="ui-loading">
|
||||
<span class="spin"><i class="icon fa fa-refresh" aria-hidden="true"></i></span> <span class="copy">${_('Loading')}</span>
|
||||
</div>
|
||||
</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","certificate-white-list","certificate-white-list-editor","certificate-bulk-white-list"]:
|
||||
% 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", "certificate-bulk-white-list", "certificate-invalidation"]:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="instructor/instructor_dashboard_2/${template_name}.underscore" />
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user