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
2c1680a9
Commit
2c1680a9
authored
Jan 04, 2016
by
Matt Drayer
Browse files
Options
Browse Files
Download
Plain Diff
Merge pull request #11080 from edx/saleem-latif/SOL-1529
SOL-1529: Allow PMs to Invalidate Certificates
parents
99b02996
9aa0a01c
Hide whitespace changes
Inline
Side-by-side
Showing
19 changed files
with
1400 additions
and
26 deletions
+1400
-26
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
+10
-8
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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -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 @
2c1680a9
# -*- 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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -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 @
2c1680a9
// 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 @
2c1680a9
// 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 @
2c1680a9
// 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 @
2c1680a9
// 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 @
2c1680a9
/*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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -2159,16 +2159,18 @@ input[name="subject"] {
...
@@ -2159,16 +2159,18 @@ input[name="subject"] {
}
}
}
}
.student-username-or-email
{
width
:
300px
;
margin-bottom
:
10px
;
}
.notes-field
{
width
:
400px
;
}
#certificate-white-list-editor
{
#certificate-white-list-editor
{
padding-top
:
5px
;
padding-top
:
5px
;
.certificate-exception-inputs
{
.certificate-exception-inputs
{
.student-username-or-email
{
width
:
300px
;
margin-bottom
:
10px
;
}
.notes-field
{
width
:
400px
;
}
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 @
2c1680a9
<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 @
2c1680a9
...
@@ -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 @
2c1680a9
...
@@ -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