Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
E
edx-platform
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
edx
edx-platform
Commits
9aa0a01c
Commit
9aa0a01c
authored
Dec 22, 2015
by
Saleem Latif
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Allow PMs to Invalidate Certificates
parent
5e972b2a
Show whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1396 additions
and
22 deletions
+1396
-22
common/test/acceptance/pages/lms/instructor_dashboard.py
+61
-1
common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
+199
-0
lms/djangoapps/certificates/migrations/0007_certificateinvalidation.py
+30
-0
lms/djangoapps/certificates/models.py
+59
-0
lms/djangoapps/certificates/tests/factories.py
+10
-1
lms/djangoapps/instructor/tests/test_certificates.py
+299
-2
lms/djangoapps/instructor/views/api.py
+177
-11
lms/djangoapps/instructor/views/api_urls.py
+4
-0
lms/djangoapps/instructor/views/instructor_dashboard.py
+11
-1
lms/static/js/certificates/collections/certificate_invalidation_collection.js
+17
-0
lms/static/js/certificates/factories/certificate_invalidation_factory.js
+32
-0
lms/static/js/certificates/models/certificate_invalidation.js
+36
-0
lms/static/js/certificates/views/certificate_invalidation_view.js
+122
-0
lms/static/js/spec/instructor_dashboard/certificates_invalidation_spec.js
+268
-0
lms/static/js/spec/main.js
+1
-0
lms/static/sass/course/instructor/_instructor_2.scss
+6
-4
lms/templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore
+43
-0
lms/templates/instructor/instructor_dashboard_2/certificates.html
+20
-1
lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html
+1
-1
No files found.
common/test/acceptance/pages/lms/instructor_dashboard.py
View file @
9aa0a01c
...
@@ -1006,6 +1006,16 @@ class CertificatesPage(PageObject):
...
@@ -1006,6 +1006,16 @@ class CertificatesPage(PageObject):
)
)
self
.
wait_for_element_visibility
(
'#add-exception'
,
'Add Exception button is visible'
)
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
):
def
refresh
(
self
):
"""
"""
Refresh Certificates Page and wait for the page to load completely.
Refresh Certificates Page and wait for the page to load completely.
...
@@ -1064,6 +1074,42 @@ class CertificatesPage(PageObject):
...
@@ -1064,6 +1074,42 @@ class CertificatesPage(PageObject):
"""
"""
self
.
get_selector
(
'#add-exception'
)
.
click
()
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
@property
def
generate_certificates_button
(
self
):
def
generate_certificates_button
(
self
):
"""
"""
...
@@ -1111,4 +1157,18 @@ class CertificatesPage(PageObject):
...
@@ -1111,4 +1157,18 @@ class CertificatesPage(PageObject):
"""
"""
Returns the Message (error/success) in "Certificate Exceptions" section.
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'
)
common/test/acceptance/tests/lms/test_lms_instructor_dashboard.py
View file @
9aa0a01c
...
@@ -845,3 +845,202 @@ class CertificatesTest(BaseInstructorDashboardTest):
...
@@ -845,3 +845,202 @@ class CertificatesTest(BaseInstructorDashboardTest):
' below to send the certificate.'
,
' below to send the certificate.'
,
self
.
certificates_section
.
message
.
text
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
)
lms/djangoapps/certificates/migrations/0007_certificateinvalidation.py
0 → 100644
View file @
9aa0a01c
# -*- 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
)),
],
),
]
lms/djangoapps/certificates/models.py
View file @
9aa0a01c
...
@@ -253,6 +253,12 @@ class GeneratedCertificate(models.Model):
...
@@ -253,6 +253,12 @@ class GeneratedCertificate(models.Model):
self
.
save
()
self
.
save
()
def
is_valid
(
self
):
"""
Return True if certificate is valid else return False.
"""
return
self
.
status
==
CertificateStatuses
.
downloadable
class
CertificateGenerationHistory
(
TimeStampedModel
):
class
CertificateGenerationHistory
(
TimeStampedModel
):
"""
"""
...
@@ -309,6 +315,59 @@ 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
)
(
"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
)
@receiver
(
post_save
,
sender
=
GeneratedCertificate
)
def
handle_post_cert_generated
(
sender
,
instance
,
**
kwargs
):
# pylint: disable=unused-argument
def
handle_post_cert_generated
(
sender
,
instance
,
**
kwargs
):
# pylint: disable=unused-argument
"""
"""
...
...
lms/djangoapps/certificates/tests/factories.py
View file @
9aa0a01c
...
@@ -9,7 +9,7 @@ from student.models import LinkedInAddToProfileConfiguration
...
@@ -9,7 +9,7 @@ from student.models import LinkedInAddToProfileConfiguration
from
certificates.models
import
(
from
certificates.models
import
(
GeneratedCertificate
,
CertificateStatuses
,
CertificateHtmlViewConfiguration
,
CertificateWhitelist
,
BadgeAssertion
,
GeneratedCertificate
,
CertificateStatuses
,
CertificateHtmlViewConfiguration
,
CertificateWhitelist
,
BadgeAssertion
,
BadgeImageConfiguration
,
BadgeImageConfiguration
,
CertificateInvalidation
,
)
)
...
@@ -35,6 +35,15 @@ class CertificateWhitelistFactory(DjangoModelFactory):
...
@@ -35,6 +35,15 @@ class CertificateWhitelistFactory(DjangoModelFactory):
notes
=
'Test Notes'
notes
=
'Test Notes'
class
CertificateInvalidationFactory
(
DjangoModelFactory
):
class
Meta
(
object
):
model
=
CertificateInvalidation
notes
=
'Test Notes'
active
=
True
class
BadgeAssertionFactory
(
DjangoModelFactory
):
class
BadgeAssertionFactory
(
DjangoModelFactory
):
class
Meta
(
object
):
class
Meta
(
object
):
model
=
BadgeAssertion
model
=
BadgeAssertion
...
...
lms/djangoapps/instructor/tests/test_certificates.py
View file @
9aa0a01c
...
@@ -13,9 +13,10 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
...
@@ -13,9 +13,10 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from
xmodule.modulestore.tests.factories
import
CourseFactory
from
xmodule.modulestore.tests.factories
import
CourseFactory
from
config_models.models
import
cache
from
config_models.models
import
cache
from
courseware.tests.factories
import
GlobalStaffFactory
,
InstructorFactory
,
UserFactory
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
,
\
from
certificates.models
import
CertificateGenerationConfiguration
,
CertificateStatuses
,
CertificateWhitelist
,
\
GeneratedCertificate
GeneratedCertificate
,
CertificateInvalidation
from
certificates
import
api
as
certs_api
from
certificates
import
api
as
certs_api
from
student.models
import
CourseEnrollment
from
student.models
import
CourseEnrollment
from
django.core.files.uploadedfile
import
SimpleUploadedFile
from
django.core.files.uploadedfile
import
SimpleUploadedFile
...
@@ -923,3 +924,299 @@ class TestCertificatesInstructorApiBulkWhiteListExceptions(SharedModuleStoreTest
...
@@ -923,3 +924,299 @@ class TestCertificatesInstructorApiBulkWhiteListExceptions(SharedModuleStoreTest
self
.
assertEqual
(
response
.
status_code
,
200
)
self
.
assertEqual
(
response
.
status_code
,
200
)
data
=
json
.
loads
(
response
.
content
)
data
=
json
.
loads
(
response
.
content
)
return
data
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."
,
)
lms/djangoapps/instructor/views/api.py
View file @
9aa0a01c
...
@@ -89,7 +89,7 @@ from instructor.views import INVOICE_KEY
...
@@ -89,7 +89,7 @@ from instructor.views import INVOICE_KEY
from
submissions
import
api
as
sub_api
# installed from the edx-submissions repository
from
submissions
import
api
as
sub_api
# installed from the edx-submissions repository
from
certificates
import
api
as
certs_api
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
bulk_email.models
import
CourseEmail
from
student.models
import
get_user_by_username_or_email
from
student.models
import
get_user_by_username_or_email
...
@@ -2839,26 +2839,53 @@ def parse_request_data_and_get_user(request, course_key):
...
@@ -2839,26 +2839,53 @@ def parse_request_data_and_get_user(request, course_key):
:param course_key: Course Identifier of the course for whom to process certificate exception
: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
:return: key-value pairs containing certificate exception data and User object
"""
"""
try
:
certificate_exception
=
parse_request_data
(
request
)
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.'
))
user
=
certificate_exception
.
get
(
'user_name'
,
''
)
or
certificate_exception
.
get
(
'user_email'
,
''
)
user
=
certificate_exception
.
get
(
'user_name'
,
''
)
or
certificate_exception
.
get
(
'user_email'
,
''
)
if
not
user
:
if
not
user
:
raise
ValueError
(
_
(
'Student username/email field is required and can not be empty. '
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.'
))
'Kindly fill in username/email and then press "Add to Exception List" button.'
))
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
:
try
:
db_user
=
get_user_by_username_or_email
(
user
)
student
=
get_user_by_username_or_email
(
username_or_email
)
except
ObjectDoesNotExist
:
except
ObjectDoesNotExist
:
raise
ValueError
(
_
(
"{user} does not exist in the LMS. Please check your spelling and retry."
)
.
format
(
user
=
user
))
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
# Make Sure the given student is enrolled in the course
if
not
CourseEnrollment
.
is_enrolled
(
db_user
,
course_key
):
if
not
CourseEnrollment
.
is_enrolled
(
student
,
course_key
):
raise
ValueError
(
_
(
"{user} is not enrolled in this course. Please check your spelling and retry."
)
raise
ValueError
(
_
(
"{user} is not enrolled in this course. Please check your spelling and retry."
)
.
format
(
user
=
user
))
.
format
(
user
=
username_or_email
))
return
student
return
certificate_exception
,
db_user
@transaction.non_atomic_requests
@transaction.non_atomic_requests
...
@@ -3014,3 +3041,142 @@ def generate_bulk_certificate_exceptions(request, course_id): # pylint: disable
...
@@ -3014,3 +3041,142 @@ def generate_bulk_certificate_exceptions(request, course_id): # pylint: disable
}
}
return
JsonResponse
(
results
)
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
lms/djangoapps/instructor/views/api_urls.py
View file @
9aa0a01c
...
@@ -161,4 +161,8 @@ urlpatterns = patterns(
...
@@ -161,4 +161,8 @@ urlpatterns = patterns(
url
(
r'^generate_bulk_certificate_exceptions'
,
url
(
r'^generate_bulk_certificate_exceptions'
,
'instructor.views.api.generate_bulk_certificate_exceptions'
,
'instructor.views.api.generate_bulk_certificate_exceptions'
,
name
=
'generate_bulk_certificate_exceptions'
),
name
=
'generate_bulk_certificate_exceptions'
),
url
(
r'^certificate_invalidation_view/$'
,
'instructor.views.api.certificate_invalidation_view'
,
name
=
'certificate_invalidation_view'
),
)
)
lms/djangoapps/instructor/views/instructor_dashboard.py
View file @
9aa0a01c
...
@@ -43,6 +43,7 @@ from certificates.models import (
...
@@ -43,6 +43,7 @@ from certificates.models import (
GeneratedCertificate
,
GeneratedCertificate
,
CertificateStatuses
,
CertificateStatuses
,
CertificateGenerationHistory
,
CertificateGenerationHistory
,
CertificateInvalidation
,
)
)
from
certificates
import
api
as
certs_api
from
certificates
import
api
as
certs_api
from
util.date_utils
import
get_default_time_display
from
util.date_utils
import
get_default_time_display
...
@@ -184,6 +185,13 @@ def instructor_dashboard_2(request, course_id):
...
@@ -184,6 +185,13 @@ def instructor_dashboard_2(request, course_id):
kwargs
=
{
'course_id'
:
unicode
(
course_key
)}
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
=
{
context
=
{
'course'
:
course
,
'course'
:
course
,
'studio_url'
:
get_studio_url
(
course
,
'course'
),
'studio_url'
:
get_studio_url
(
course
,
'course'
),
...
@@ -191,9 +199,11 @@ def instructor_dashboard_2(request, course_id):
...
@@ -191,9 +199,11 @@ def instructor_dashboard_2(request, course_id):
'disable_buttons'
:
disable_buttons
,
'disable_buttons'
:
disable_buttons
,
'analytics_dashboard_message'
:
analytics_dashboard_message
,
'analytics_dashboard_message'
:
analytics_dashboard_message
,
'certificate_white_list'
:
certificate_white_list
,
'certificate_white_list'
:
certificate_white_list
,
'certificate_invalidations'
:
certificate_invalidations
,
'generate_certificate_exceptions_url'
:
generate_certificate_exceptions_url
,
'generate_certificate_exceptions_url'
:
generate_certificate_exceptions_url
,
'generate_bulk_certificate_exceptions_url'
:
generate_bulk_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
)
return
render_to_response
(
'instructor/instructor_dashboard_2/instructor_dashboard_2.html'
,
context
)
...
...
lms/static/js/certificates/collections/certificate_invalidation_collection.js
0 → 100644
View file @
9aa0a01c
// 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
);
\ No newline at end of file
lms/static/js/certificates/factories/certificate_invalidation_factory.js
0 → 100644
View file @
9aa0a01c
// 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
);
\ No newline at end of file
lms/static/js/certificates/models/certificate_invalidation.js
0 → 100644
View file @
9aa0a01c
// 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
);
\ No newline at end of file
lms/static/js/certificates/views/certificate_invalidation_view.js
0 → 100644
View file @
9aa0a01c
// 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
);
\ No newline at end of file
lms/static/js/spec/instructor_dashboard/certificates_invalidation_spec.js
0 → 100644
View file @
9aa0a01c
/*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
);
});
});
}
);
lms/static/js/spec/main.js
View file @
9aa0a01c
...
@@ -655,6 +655,7 @@
...
@@ -655,6 +655,7 @@
'lms/include/js/spec/instructor_dashboard/ecommerce_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/student_admin_spec.js'
,
'lms/include/js/spec/instructor_dashboard/certificates_exception_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_bulk_exception_spec.js'
,
'lms/include/js/spec/instructor_dashboard/certificates_spec.js'
,
'lms/include/js/spec/instructor_dashboard/certificates_spec.js'
,
'lms/include/js/spec/student_account/account_spec.js'
,
'lms/include/js/spec/student_account/account_spec.js'
,
...
...
lms/static/sass/course/instructor/_instructor_2.scss
View file @
9aa0a01c
...
@@ -2159,9 +2159,6 @@ input[name="subject"] {
...
@@ -2159,9 +2159,6 @@ input[name="subject"] {
}
}
}
}
#certificate-white-list-editor
{
padding-top
:
5px
;
.certificate-exception-inputs
{
.student-username-or-email
{
.student-username-or-email
{
width
:
300px
;
width
:
300px
;
margin-bottom
:
10px
;
margin-bottom
:
10px
;
...
@@ -2169,6 +2166,11 @@ input[name="subject"] {
...
@@ -2169,6 +2166,11 @@ input[name="subject"] {
.notes-field
{
.notes-field
{
width
:
400px
;
width
:
400px
;
}
}
#certificate-white-list-editor
{
padding-top
:
5px
;
.certificate-exception-inputs
{
p
+
p
{
p
+
p
{
margin-top
:
5px
;
margin-top
:
5px
;
}
}
...
@@ -2178,7 +2180,7 @@ input[name="subject"] {
...
@@ -2178,7 +2180,7 @@ input[name="subject"] {
}
}
}
}
.white-listed-students
{
.white-listed-students
,
.invalidation-history
{
margin-top
:
10px
;
margin-top
:
10px
;
padding-top
:
5px
;
padding-top
:
5px
;
table
{
table
{
...
...
lms/templates/instructor/instructor_dashboard_2/certificate-invalidation.underscore
0 → 100644
View file @
9aa0a01c
<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>
lms/templates/instructor/instructor_dashboard_2/certificates.html
View file @
9aa0a01c
...
@@ -8,6 +8,10 @@ import json
...
@@ -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}");
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
>
<
%
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"
/>
<
%
page
args=
"section_data"
/>
<div
class=
"certificates-wrapper"
>
<div
class=
"certificates-wrapper"
>
...
@@ -165,10 +169,25 @@ import json
...
@@ -165,10 +169,25 @@ import json
<div
class=
"certificate-exception-section"
>
<div
class=
"certificate-exception-section"
>
<div
id=
"certificate-white-list-editor"
></div>
<div
id=
"certificate-white-list-editor"
></div>
<div
class=
"bulk-white-list-exception"
></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/>
<br/>
</div>
</div>
<div
class=
"no-pending-tasks-message"
></div>
<div
class=
"no-pending-tasks-message"
></div>
</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>
</div>
lms/templates/instructor/instructor_dashboard_2/instructor_dashboard_2.html
View file @
9aa0a01c
...
@@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse
...
@@ -66,7 +66,7 @@ from django.core.urlresolvers import reverse
## Include Underscore templates
## Include Underscore templates
<
%
block
name=
"header_extras"
>
<
%
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"
>
<script
type=
"text/template"
id=
"${template_name}-tpl"
>
<%
static
:
include
path
=
"instructor/instructor_dashboard_2/${template_name}.underscore"
/>
<%
static
:
include
path
=
"instructor/instructor_dashboard_2/${template_name}.underscore"
/>
</script>
</script>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment