Files
edx-platform/lms/djangoapps/instructor/tests/test_certificates.py
Justin Hynes 32685a7999 MICROBA-1025 | Update cert_whitelist.py management command
[MICROBA-1025]
- Update management command to use the same logic that the Instructor Dashboard uses
- Fix bug in management command where processing stopped when encountering a user that did not exist
- Add more logging
- Add and update tests where needed
2021-03-09 14:45:44 -05:00

1338 lines
53 KiB
Python

"""Tests for the certificates panel of the instructor dash. """
import contextlib
import io
import json
from datetime import datetime, timedelta
from unittest import mock
import ddt
import pytest
import pytz
from config_models.models import cache
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test.utils import override_settings
from django.urls import reverse
from capa.xqueue_interface import XQueueInterface
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment
from lms.djangoapps.certificates import api as certs_api
from lms.djangoapps.certificates.models import (
CertificateGenerationConfiguration,
CertificateInvalidation,
CertificateStatuses,
CertificateWhitelist,
GeneratedCertificate
)
from lms.djangoapps.certificates.tests.factories import (
CertificateInvalidationFactory,
CertificateWhitelistFactory,
GeneratedCertificateFactory
)
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory
from lms.djangoapps.grades.tests.utils import mock_passing_grade
from lms.djangoapps.verify_student.services import IDVerificationService
from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
@ddt.ddt
class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
"""Tests for the certificate panel of the instructor dash. """
ERROR_REASON = "An error occurred!"
DOWNLOAD_URL = "http://www.example.com/abcd123/cert.pdf"
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
cls.url = reverse(
'instructor_dashboard',
kwargs={'course_id': str(cls.course.id)}
)
def setUp(self):
super().setUp()
self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id)
# Need to clear the cache for model-based configuration
cache.clear()
# Enable the certificate generation feature
CertificateGenerationConfiguration.objects.create(enabled=True)
def test_visible_only_to_global_staff(self):
# Instructors don't see the certificates section
self.client.login(username=self.instructor.username, password="test")
self._assert_certificates_visible(False)
# Global staff can see the certificates section
self.client.login(username=self.global_staff.username, password="test")
self._assert_certificates_visible(True)
def test_visible_only_when_feature_flag_enabled(self):
# Disable the feature flag
CertificateGenerationConfiguration.objects.create(enabled=False)
cache.clear()
# Now even global staff can't see the certificates section
self.client.login(username=self.global_staff.username, password="test")
self._assert_certificates_visible(False)
@ddt.data("started", "error", "success")
def test_show_certificate_status(self, status):
self.client.login(username=self.global_staff.username, password="test")
with self._certificate_status("honor", status):
self._assert_certificate_status("honor", status)
def test_show_enabled_button(self):
self.client.login(username=self.global_staff.username, password="test")
# Initially, no example certs are generated, so
# the enable button should be disabled
self._assert_enable_certs_button_is_disabled()
with self._certificate_status("honor", "success"):
# Certs are disabled for the course, so the enable button should be shown
self._assert_enable_certs_button(True)
# Enable certificates for the course
certs_api.set_cert_generation_enabled(self.course.id, True)
# Now the "disable" button should be shown
self._assert_enable_certs_button(False)
def test_can_disable_even_after_failure(self):
self.client.login(username=self.global_staff.username, password="test")
with self._certificate_status("honor", "error"):
# When certs are disabled for a course, then don't allow them
# to be enabled if certificate generation doesn't complete successfully
certs_api.set_cert_generation_enabled(self.course.id, False)
self._assert_enable_certs_button_is_disabled()
# However, if certificates are already enabled, allow them
# to be disabled even if an error has occurred
certs_api.set_cert_generation_enabled(self.course.id, True)
self._assert_enable_certs_button(False)
@mock.patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True})
def test_show_enabled_button_for_html_certs(self):
"""
Tests `Enable Student-Generated Certificates` button is enabled
and `Generate Example Certificates` button is not available if
course has Web/HTML certificates view enabled.
"""
self.course.cert_html_view_enabled = True
self.course.save()
self.store.update_item(self.course, self.global_staff.id)
self.client.login(username=self.global_staff.username, password="test")
response = self.client.get(self.url)
self.assertContains(response, 'Enable Student-Generated Certificates')
self.assertContains(response, 'enable-certificates-submit')
self.assertNotContains(response, 'Generate Example Certificates')
@mock.patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True})
def test_buttons_for_html_certs_in_self_paced_course(self):
"""
Tests `Enable Student-Generated Certificates` button is enabled
and `Generate Certificates` button is not available if
course has Web/HTML certificates view enabled on a self paced course.
"""
self.course.cert_html_view_enabled = True
self.course.save()
self.store.update_item(self.course, self.global_staff.id)
self.client.login(username=self.global_staff.username, password="test")
response = self.client.get(self.url)
self.assertContains(response, 'Enable Student-Generated Certificates')
self.assertContains(response, 'enable-certificates-submit')
self.assertNotContains(response, 'Generate Certificates')
self.assertNotContains(response, 'btn-start-generating-certificates')
def _assert_certificates_visible(self, is_visible):
"""Check that the certificates section is visible on the instructor dash. """
response = self.client.get(self.url)
if is_visible:
self.assertContains(response, "Student-Generated Certificates")
else:
self.assertNotContains(response, "Student-Generated Certificates")
@contextlib.contextmanager
def _certificate_status(self, description, status):
"""Configure the certificate status by mocking the certificates API. """
patched = 'lms.djangoapps.instructor.views.instructor_dashboard.certs_api.example_certificates_status'
with mock.patch(patched) as certs_api_status:
cert_status = [{
'description': description,
'status': status
}]
if status == 'error':
cert_status[0]['error_reason'] = self.ERROR_REASON
if status == 'success':
cert_status[0]['download_url'] = self.DOWNLOAD_URL
certs_api_status.return_value = cert_status
yield
def _assert_certificate_status(self, cert_name, expected_status):
"""Check the certificate status display on the instructor dash. """
response = self.client.get(self.url)
if expected_status == 'started':
expected = f'Generating example {cert_name} certificate'
self.assertContains(response, expected)
elif expected_status == 'error':
expected = self.ERROR_REASON
self.assertContains(response, expected)
elif expected_status == 'success':
expected = self.DOWNLOAD_URL
self.assertContains(response, expected)
else:
self.fail(f"Invalid certificate status: {expected_status}")
def _assert_enable_certs_button_is_disabled(self):
"""Check that the "enable student-generated certificates" button is disabled. """
response = self.client.get(self.url)
expected_html = '<button class="is-disabled" disabled>Enable Student-Generated Certificates</button>'
self.assertContains(response, expected_html)
def _assert_enable_certs_button(self, is_enabled):
"""Check whether the button says "enable" or "disable" cert generation. """
response = self.client.get(self.url)
expected_html = (
'Enable Student-Generated Certificates' if is_enabled
else 'Disable Student-Generated Certificates'
)
self.assertContains(response, expected_html)
@override_settings(CERT_QUEUE='certificates')
@ddt.ddt
class CertificatesInstructorApiTest(SharedModuleStoreTestCase):
"""Tests for the certificates end-points in the instructor dash API. """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super().setUp()
self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id)
self.user = UserFactory()
CourseEnrollment.enroll(self.user, self.course.id)
# Enable certificate generation
cache.clear()
CertificateGenerationConfiguration.objects.create(enabled=True)
@ddt.data('generate_example_certificates', 'enable_certificate_generation')
def test_allow_only_global_staff(self, url_name):
url = reverse(url_name, kwargs={'course_id': self.course.id})
# Instructors do not have access
self.client.login(username=self.instructor.username, password='test')
response = self.client.post(url)
assert response.status_code == 403
# Global staff have access
self.client.login(username=self.global_staff.username, password='test')
response = self.client.post(url)
assert response.status_code == 302
def test_generate_example_certificates(self):
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'generate_example_certificates',
kwargs={'course_id': str(self.course.id)}
)
response = self.client.post(url)
# Expect a redirect back to the instructor dashboard
self._assert_redirects_to_instructor_dash(response)
# Expect that certificate generation started
# Cert generation will fail here because XQueue isn't configured,
# but the status should at least not be None.
status = certs_api.example_certificates_status(self.course.id)
assert status is not None
@ddt.data(True, False)
def test_enable_certificate_generation(self, is_enabled):
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'enable_certificate_generation',
kwargs={'course_id': str(self.course.id)}
)
params = {'certificates-enabled': 'true' if is_enabled else 'false'}
response = self.client.post(url, data=params)
# Expect a redirect back to the instructor dashboard
self._assert_redirects_to_instructor_dash(response)
# Expect that certificate generation is now enabled for the course
actual_enabled = certs_api.cert_generation_enabled(self.course.id)
assert is_enabled == actual_enabled
def _assert_redirects_to_instructor_dash(self, response):
"""Check that the response redirects to the certificates section. """
expected_redirect = reverse(
'instructor_dashboard',
kwargs={'course_id': str(self.course.id)}
)
expected_redirect += '#view-certificates'
self.assertRedirects(response, expected_redirect)
def test_certificate_generation_api_without_global_staff(self):
"""
Test certificates generation api endpoint returns permission denied if
user who made the request is not member of global staff.
"""
user = UserFactory.create()
self.client.login(username=user.username, password='test')
url = reverse(
'start_certificate_generation',
kwargs={'course_id': str(self.course.id)}
)
response = self.client.post(url)
assert response.status_code == 403
self.client.login(username=self.instructor.username, password='test')
response = self.client.post(url)
assert response.status_code == 403
def test_certificate_generation_api_with_global_staff(self):
"""
Test certificates generation api endpoint returns success status when called with
valid course key
"""
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'start_certificate_generation',
kwargs={'course_id': str(self.course.id)}
)
response = self.client.post(url)
assert response.status_code == 200
res_json = json.loads(response.content.decode('utf-8'))
assert res_json['message'] is not None
assert res_json['task_id'] is not None
def test_certificate_regeneration_success(self):
"""
Test certificate regeneration is successful when accessed with 'certificate_statuses'
present in GeneratedCertificate table.
"""
# Create a generated Certificate of some user with status 'downloadable'
GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor'
)
# Login the client and access the url with 'certificate_statuses'
self.client.login(username=self.global_staff.username, password='test')
url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.downloadable]})
# Assert 200 status code in response
assert response.status_code == 200
res_json = json.loads(response.content.decode('utf-8'))
# Assert request is successful
assert res_json['success']
# Assert success message
assert res_json['message'] ==\
'Certificate regeneration task has been started.' \
' You can view the status of the generation task in the "Pending Tasks" section.'
@override_settings(AUDIT_CERT_CUTOFF_DATE=datetime.now(pytz.UTC) - timedelta(days=1))
@ddt.data(
(CertificateStatuses.generating, 'ID Verified', 'approved'),
(CertificateStatuses.unverified, 'Not ID Verified', 'denied'),
)
@ddt.unpack
def test_verified_users_with_audit_certs(self, expected_cert_status, verification_output, id_verification_status):
"""
Test certificate regeneration for verified users with audit certificates.
Scenario:
Enroll user in a course in audit mode,
User passed the course and now he has `audit_passing` certificate status,
User switched to verified mode and is ID verified,
Regenerate certificate for it,
Modified certificate status is `generating` if user is ID verified otherwise `unverified`.
"""
# Check that user is enrolled in audit mode.
enrollment = CourseEnrollment.get_enrollment(self.user, self.course.id)
assert enrollment.mode == CourseMode.AUDIT
with mock_passing_grade():
# Generate certificate for user and check that user has a audit passing certificate.
cert_status = certs_api.generate_user_certificates(
student=self.user,
course_key=self.course.id,
course=self.course,
)
# Check that certificate status is 'audit_passing'.
assert cert_status == CertificateStatuses.audit_passing
# Update user enrollment mode to verified mode.
enrollment.update_enrollment(mode=CourseMode.VERIFIED)
assert enrollment.mode == CourseMode.VERIFIED
# Create and assert user's ID verification record.
SoftwareSecurePhotoVerificationFactory.create(user=self.user, status=id_verification_status)
actual_verification_status = IDVerificationService.verification_status_for_user(
self.user,
enrollment.mode
)
assert actual_verification_status == verification_output
# Login the client and access the url with 'audit_passing' status.
self.client.login(username=self.global_staff.username, password='test')
url = reverse(
'start_certificate_regeneration',
kwargs={'course_id': str(self.course.id)}
)
with mock.patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None)
response = self.client.post(
url,
{'certificate_statuses': [CertificateStatuses.audit_passing]}
)
# Assert 200 status code in response
assert response.status_code == 200
res_json = json.loads(response.content.decode('utf-8'))
# Assert request is successful
assert res_json['success']
# Assert success message
assert res_json['message'] ==\
'Certificate regeneration task has been started.' \
' You can view the status of the generation task in the "Pending Tasks" section.'
# Now, check whether user has audit certificate.
cert = certs_api.get_certificate_for_user(self.user.username, self.course.id)
assert cert['status'] != CertificateStatuses.audit_passing
assert cert['status'] == expected_cert_status
def test_certificate_regeneration_error(self):
"""
Test certificate regeneration errors out when accessed with either empty list of 'certificate_statuses' or
the 'certificate_statuses' that are not present in GeneratedCertificate table.
"""
# Create a dummy course and GeneratedCertificate with the same status as the one we will use to access
# 'start_certificate_regeneration' but their error message should be displayed as GeneratedCertificate
# belongs to a different course
dummy_course = CourseFactory.create()
GeneratedCertificateFactory.create(
user=self.user,
course_id=dummy_course.id,
status=CertificateStatuses.generating,
mode='honor'
)
# Login the client and access the url without 'certificate_statuses'
self.client.login(username=self.global_staff.username, password='test')
url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url)
# Assert 400 status code in response
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] ==\
'Please select one or more certificate statuses that require certificate regeneration.'
# Access the url passing 'certificate_statuses' that are not present in db
url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.generating]})
# Assert 400 status code in response
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] == 'Please select certificate statuses from the list only.'
@override_settings(CERT_QUEUE='certificates')
@ddt.ddt
class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase):
"""Tests for the generate certificates end-points in the instructor dash API. """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super().setUp()
self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id)
self.user = UserFactory()
self.user2 = UserFactory()
CourseEnrollment.enroll(self.user, self.course.id)
CourseEnrollment.enroll(self.user2, self.course.id)
self.url = reverse('certificate_exception_view', kwargs={'course_id': str(self.course.id)})
certificate_white_list_item = CertificateWhitelistFactory.create(
user=self.user2,
course_id=self.course.id,
)
self.certificate_exception = dict(
created="",
notes="Test Notes for Test Certificate Exception",
user_email='',
user_id='',
user_name=str(self.user.username)
)
self.certificate_exception_in_db = dict(
id=certificate_white_list_item.id,
user_name=certificate_white_list_item.user.username,
notes=certificate_white_list_item.notes,
user_email=certificate_white_list_item.user.email,
user_id=certificate_white_list_item.user.id,
)
# Enable certificate generation
cache.clear()
CertificateGenerationConfiguration.objects.create(enabled=True)
self.client.login(username=self.global_staff.username, password='test')
def test_certificate_exception_added_successfully(self):
"""
Test certificates exception addition api endpoint returns success status and updated certificate exception data
when called with valid course key and certificate exception data
"""
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception),
content_type='application/json'
)
# Assert successful request processing
assert response.status_code == 200
certificate_exception = json.loads(response.content.decode('utf-8'))
# Assert Certificate Exception Updated data
assert certificate_exception['user_email'] == self.user.email
assert certificate_exception['user_name'] == self.user.username
assert certificate_exception['user_id'] == self.user.id
def test_certificate_exception_invalid_username_error(self):
"""
Test certificates exception addition api endpoint returns failure when called with
invalid username.
"""
invalid_user = 'test_invalid_user_name'
self.certificate_exception.update({'user_name': invalid_user})
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception),
content_type='application/json'
)
# Assert 400 status code in response
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request not successful
assert not res_json['success']
# Assert Error Message
assert res_json['message'] == f'{invalid_user} does not exist in the LMS. Please check your spelling and retry.'
def test_certificate_exception_missing_username_and_email_error(self):
"""
Test certificates exception addition api endpoint returns failure when called with
missing username/email.
"""
self.certificate_exception.update({'user_name': '', 'user_email': ''})
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception),
content_type='application/json'
)
# Assert 400 status code in response
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request not successful
assert not res_json['success']
# Assert Error Message
assert res_json['message'] ==\
'Student username/email field is required and can not be empty.' \
' Kindly fill in username/email and then press "Add to Exception List" button.'
def test_certificate_exception_duplicate_user_error(self):
"""
Test certificates exception addition api endpoint returns failure when called with
username/email that already exists in 'CertificateWhitelist' table.
"""
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception_in_db),
content_type='application/json'
)
# Assert 400 status code in response
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request not successful
assert not res_json['success']
user = self.certificate_exception_in_db['user_name']
# Assert Error Message
assert res_json['message'] == f'Student (username/email={user}) already in certificate exception list.'
def test_certificate_exception_same_user_in_two_different_courses(self):
"""
Test certificates exception addition api endpoint in scenario when same
student is added to two different courses.
"""
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception),
content_type='application/json'
)
assert response.status_code == 200
certificate_exception = json.loads(response.content.decode('utf-8'))
# Assert Certificate Exception Updated data
assert certificate_exception['user_email'] == self.user.email
assert certificate_exception['user_name'] == self.user.username
assert certificate_exception['user_id'] == self.user.id
course2 = CourseFactory.create()
url_course2 = reverse(
'certificate_exception_view',
kwargs={'course_id': str(course2.id)}
)
# add certificate exception for same user in a different course
self.client.post(
url_course2,
data=json.dumps(self.certificate_exception),
content_type='application/json'
)
assert response.status_code == 200
certificate_exception = json.loads(response.content.decode('utf-8'))
# Assert Certificate Exception Updated data
assert certificate_exception['user_email'] == self.user.email
assert certificate_exception['user_name'] == self.user.username
assert certificate_exception['user_id'] == self.user.id
def test_certificate_exception_user_not_enrolled_error(self):
"""
Test certificates exception addition api endpoint returns failure when called with
username/email that is not enrolled in the given course.
"""
# Un-enroll student from the course
CourseEnrollment.unenroll(self.user, self.course.id)
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception),
content_type='application/json'
)
# Assert 400 status code in response
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request not successful
assert not res_json['success']
# Assert Error Message
assert res_json['message'] == (
f"Student {self.user.username} is not enrolled in this course. Please check your spelling and retry."
)
def test_certificate_exception_removed_successfully(self):
"""
Test certificates exception removal api endpoint returns success status
when called with valid course key and certificate exception id
"""
GeneratedCertificateFactory.create(
user=self.user2,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
grade='1.0'
)
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception_in_db),
content_type='application/json',
REQUEST_METHOD='DELETE'
)
# Assert successful request processing
assert response.status_code == 204
# Verify that certificate exception successfully removed from CertificateWhitelist and GeneratedCertificate
with pytest.raises(ObjectDoesNotExist):
CertificateWhitelist.objects.get(user=self.user2, course_id=self.course.id)
GeneratedCertificate.eligible_certificates.get(
user=self.user2, course_id=self.course.id, status__not=CertificateStatuses.unavailable
)
def test_remove_certificate_exception_invalid_request_error(self):
"""
Test certificates exception removal api endpoint returns error
when called without certificate exception id
"""
# Try to delete certificate exception without passing valid data
response = self.client.post(
self.url,
data='Test Invalid data',
content_type='application/json',
REQUEST_METHOD='DELETE'
)
# Assert error on request
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request not successful
assert not res_json['success']
# Assert Error Message
assert res_json['message'] ==\
'The record is not in the correct format. Please add a valid username or email address.'
def test_remove_certificate_exception_non_existing_error(self):
"""
Test certificates exception removal api endpoint returns error
when called with non existing certificate exception id
"""
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception),
content_type='application/json',
REQUEST_METHOD='DELETE'
)
# Assert error on request
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request not successful
assert not res_json['success']
# Assert Error Message
assert res_json['message'] == (
f"Error occurred removing the allowlist entry for student {self.user.username}. Please refresh the page "
"and try again"
)
def test_certificate_invalidation_already_exists(self):
"""
Test to confirm an error message is raised when generating a certificate exception for a learner that already
has an active certificate invalidation.
"""
# generate a certificate for the test learner in our course
generated_certificate = GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor',
)
# create a certificate invalidation tied to the generated certificate
CertificateInvalidationFactory.create(
generated_certificate=generated_certificate,
invalidated_by=self.global_staff,
)
# attempt to add learner to the allowlist, expect an error
response = self.client.post(
self.url,
data=json.dumps(self.certificate_exception),
content_type='application/json',
REQUEST_METHOD='POST'
)
res_json = json.loads(response.content.decode('utf-8'))
assert response.status_code == 400
assert res_json['message'] == (
f"Student {self.user.username} is already on the certificate invalidation list and cannot be added to "
"the certificate exception list."
)
@override_settings(CERT_QUEUE='certificates')
@ddt.ddt
class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase):
"""Tests for the generate certificates end-points in the instructor dash API. """
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super().setUp()
self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id)
self.user = UserFactory()
CourseEnrollment.enroll(self.user, self.course.id)
certificate_exception = CertificateWhitelistFactory.create(
user=self.user,
course_id=self.course.id,
)
self.certificate_exception = dict(
id=certificate_exception.id,
user_name=certificate_exception.user.username,
notes=certificate_exception.notes,
user_email=certificate_exception.user.email,
user_id=certificate_exception.user.id,
)
# Enable certificate generation
cache.clear()
CertificateGenerationConfiguration.objects.create(enabled=True)
self.client.login(username=self.global_staff.username, password='test')
def test_generate_certificate_exceptions_all_students(self):
"""
Test generate certificates exceptions api endpoint returns success
when called with existing certificate exception
"""
url = reverse(
'generate_certificate_exceptions',
kwargs={'course_id': str(self.course.id), 'generate_for': 'all'}
)
response = self.client.post(
url,
content_type='application/json'
)
# Assert Success
assert response.status_code == 200
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request is successful
assert res_json['success']
# Assert Message
assert res_json['message'] == 'Certificate generation started for white listed students.'
def test_generate_certificate_exceptions_whitelist_not_generated(self):
"""
Test generate certificates exceptions api endpoint returns success
when calling with new certificate exception.
"""
url = reverse(
'generate_certificate_exceptions',
kwargs={'course_id': str(self.course.id), 'generate_for': 'new'}
)
response = self.client.post(
url,
content_type='application/json'
)
# Assert Success
assert response.status_code == 200
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request is successful
assert res_json['success']
# Assert Message
assert res_json['message'] == 'Certificate generation started for white listed students.'
def test_generate_certificate_exceptions_generate_for_incorrect_value(self):
"""
Test generate certificates exceptions api endpoint returns error
when calling with generate_for without 'new' or 'all' value.
"""
url = reverse(
'generate_certificate_exceptions',
kwargs={'course_id': str(self.course.id), 'generate_for': ''}
)
response = self.client.post(
url,
content_type='application/json'
)
# Assert Failure
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Request is not successful
assert not res_json['success']
# Assert Message
assert res_json['message'] == 'Invalid data, generate_for must be "new" or "all".'
@ddt.ddt
class TestCertificatesInstructorApiBulkWhiteListExceptions(SharedModuleStoreTestCase):
"""
Test Bulk certificates white list exceptions from csv file
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course = CourseFactory.create()
cls.url = reverse('generate_bulk_certificate_exceptions',
kwargs={'course_id': cls.course.id})
def setUp(self):
super().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)
# Global staff can see the certificates section
self.client.login(username=self.global_staff.username, password="test")
def test_create_white_list_exception_record(self):
"""
Happy path test to create a single new white listed record
"""
csv_content = b"test_student1@example.com,dummy_notes\n" \
b"test_student2@example.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
assert len(data['general_errors']) == 0
assert len(data['row_errors']['data_format_error']) == 0
assert len(data['row_errors']['user_not_exist']) == 0
assert len(data['row_errors']['user_already_white_listed']) == 0
assert len(data['row_errors']['user_not_enrolled']) == 0
assert len(data['success']) == 2
assert len(CertificateWhitelist.objects.all()) == 2
def test_invalid_data_format_in_csv(self):
"""
Try uploading a CSV file with invalid data formats and verify the errors.
"""
csv_content = b"test_student1@example.com,test,1,USA\n" \
b"test_student2@example.com,test,1"
data = self.upload_file(csv_content=csv_content)
assert len(data['row_errors']['data_format_error']) == 2
assert len(data['general_errors']) == 0
assert len(data['success']) == 0
assert len(CertificateWhitelist.objects.all()) == 0
def test_file_upload_type_not_csv(self):
"""
Try uploading some non-CSV file e.g. .JPG file and verify that it is rejected
"""
uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read())
response = self.client.post(self.url, {'students_list': uploaded_file})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert len(data['general_errors']) != 0
assert data['general_errors'][0] ==\
'Make sure that the file you upload is in CSV format with no extraneous characters or rows.'
def test_bad_file_upload_type(self):
"""
Try uploading CSV file with invalid binary data and verify that it is rejected
"""
uploaded_file = SimpleUploadedFile("temp.csv", io.BytesIO(b"some initial binary data: \x00\x01").read())
response = self.client.post(self.url, {'students_list': uploaded_file})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert len(data['general_errors']) != 0
assert data['general_errors'][0] == 'Could not read uploaded file.'
def test_invalid_email_in_csv(self):
"""
Test failure case of a poorly formatted email field
"""
csv_content = b"test_student.example.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
assert len(data['row_errors']['user_not_exist']) == 1
assert len(data['success']) == 0
assert len(CertificateWhitelist.objects.all()) == 0
def test_csv_user_not_enrolled(self):
"""
If the user is not enrolled in the course then there should be a user_not_enrolled error.
"""
csv_content = b"nonenrolled@test.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
assert len(data['row_errors']['user_not_enrolled']) == 1
assert len(data['general_errors']) == 0
assert len(data['success']) == 0
def test_certificate_exception_already_exist(self):
"""
Test error if existing user is already in certificates exception list.
"""
CertificateWhitelist.objects.create(
user=self.enrolled_user_1,
course_id=self.course.id,
whitelist=True,
notes=''
)
csv_content = b"test_student1@example.com,dummy_notes"
data = self.upload_file(csv_content=csv_content)
assert len(data['row_errors']['user_already_white_listed']) == 1
assert len(data['general_errors']) == 0
assert len(data['success']) == 0
assert len(CertificateWhitelist.objects.all()) == 1
def test_csv_file_not_attached(self):
"""
Test when the user does not attach a file
"""
csv_content = b"test_student1@example.com,dummy_notes\n" \
b"test_student2@example.com,dummy_notes"
uploaded_file = SimpleUploadedFile("temp.csv", csv_content)
response = self.client.post(self.url, {'file_not_found': uploaded_file})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
assert len(data['general_errors']) == 1
assert len(data['success']) == 0
def test_certificate_invalidation_already_exists(self):
"""
Test to confirm an error message is raised when generating a certificate exception for a learner appears in the
CSV file who has an active certificate invalidation.
"""
# generate a certificate for the test learner in our course
generated_certificate = GeneratedCertificateFactory.create(
user=self.enrolled_user_1,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor',
)
CertificateInvalidationFactory.create(
generated_certificate=generated_certificate,
invalidated_by=self.global_staff,
)
# attempt to add learner to the allowlist, expect an error
csv_content = b"test_student1@example.com,notes"
data = self.upload_file(csv_content=csv_content)
assert len(data['row_errors']['user_on_certificate_invalidation_list']) == 1
assert data['row_errors']['user_on_certificate_invalidation_list'][0] == 'user "TestStudent1" in row# 1'
def upload_file(self, csv_content):
"""
Upload a csv file.
:return json data
"""
uploaded_file = SimpleUploadedFile("temp.csv", csv_content)
response = self.client.post(self.url, {'students_list': uploaded_file})
assert response.status_code == 200
data = json.loads(response.content.decode('utf-8'))
return data
@ddt.ddt
class CertificateInvalidationViewTests(SharedModuleStoreTestCase):
"""
Test certificate invalidation view.
"""
@classmethod
def setUpClass(cls):
super().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().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
assert response.status_code == 200
result = json.loads(response.content.decode('utf-8'))
# Assert Certificate Exception Updated data
assert result['user'] == self.enrolled_user_1.username
assert result['invalidated_by'] == self.global_staff.username
assert 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.eligible_certificates.get(
user=self.enrolled_user_1,
course_id=self.course.id,
)
assert not 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
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] == \
'Student username/email field is required and can not be empty.' \
' 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
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] == f'{invalid_user} does not exist in the LMS. Please check your spelling and retry.'
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
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] == '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=self.enrolled_user_2.username, course=self.course.number) # pylint: disable=line-too-long
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
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] == 'Certificate for student {user} is already invalid, kindly verify that certificate was generated for this student and then proceed.'.format(user=self.enrolled_user_1.username) # pylint: disable=line-too-long
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
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] == 'Certificate of {user} has already been invalidated. Please check your spelling and retry.'.format(user=self.enrolled_user_1.username) # pylint: disable=line-too-long
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
assert response.status_code == 204
# Verify that certificate invalidation successfully removed from database
with pytest.raises(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
assert response.status_code == 400
res_json = json.loads(response.content.decode('utf-8'))
# Assert Error Message
assert res_json['message'] == 'Certificate Invalidation does not exist, Please refresh the page and try again.'
def test_learner_already_on_certificate_exception_list(self):
"""
Test to make sure we don't allow a single to learner to appear on both the certificate exception and
invalidation lists.
"""
# add test learner to the allowlist
CertificateWhitelistFactory.create(user=self.enrolled_user_1, course_id=self.course.id)
# now try and add them to the invalidation list, expect an error
response = self.client.post(
self.url,
data=json.dumps(self.certificate_invalidation_data),
content_type='application/json',
)
res_json = json.loads(response.content.decode('utf-8'))
assert response.status_code == 400
assert res_json['message'] == (
f"The student {self.enrolled_user_1.username} appears on the Certificate Exception list in course "
f"{self.course.id}. Please remove them from the Certificate Exception list before attempting to "
"invalidate their certificate."
)