Commit 9530021b by Will Daly

ECOM-1139: Example certificates.

Add the ability to generate "example" certificates to
test that certificate generation is working correctly for a course.

Add the ability to enable/disable self-generated certificates
on a per-course basis.
parent 57f051b5
"""
Certificates API
"""
"""Certificates API
This is a Python API for generating certificates asynchronously.
Other Django apps should use the API functions defined in this module
rather than importing Django models directly.
"""
import logging
from certificates.models import CertificateStatuses as cert_status, certificate_status_for_student
from certificates.models import (
CertificateStatuses as cert_status,
certificate_status_for_student,
CertificateGenerationCourseSetting,
CertificateGenerationConfiguration,
ExampleCertificateSet
)
from certificates.queue import XQueueCertInterface
log = logging.getLogger("edx.certificate")
......@@ -63,3 +72,124 @@ def certificate_downloadable_status(student, course_key):
response_data['download_url'] = current_status['download_url']
return response_data
def set_cert_generation_enabled(course_key, is_enabled):
"""Enable or disable self-generated certificates for a course.
There are two "switches" that control whether self-generated certificates
are enabled for a course:
1) Whether the self-generated certificates feature is enabled.
2) Whether self-generated certificates have been enabled for this particular course.
The second flag should be enabled *only* when someone has successfully
generated example certificates for the course. This helps avoid
configuration errors (for example, not having a template configured
for the course installed on the workers). The UI for the instructor
dashboard enforces this constraint.
Arguments:
course_key (CourseKey): The course identifier.
Keyword Arguments:
is_enabled (boolean): If provided, enable/disable self-generated
certificates for this course.
"""
CertificateGenerationCourseSetting.set_enabled_for_course(course_key, is_enabled)
if is_enabled:
log.info(u"Enabled self-generated certificates for course '%s'.", unicode(course_key))
else:
log.info(u"Disabled self-generated certificates for course '%s'.", unicode(course_key))
def cert_generation_enabled(course_key):
"""Check whether certificate generation is enabled for a course.
There are two "switches" that control whether self-generated certificates
are enabled for a course:
1) Whether the self-generated certificates feature is enabled.
2) Whether self-generated certificates have been enabled for this particular course.
Certificates are enabled for a course only when both switches
are set to True.
Arguments:
course_key (CourseKey): The course identifier.
Returns:
boolean: Whether self-generated certificates are enabled
for the course.
"""
return (
CertificateGenerationConfiguration.current().enabled and
CertificateGenerationCourseSetting.is_enabled_for_course(course_key)
)
def generate_example_certificates(course_key):
"""Generate example certificates for a course.
Example certificates are used to validate that certificates
are configured correctly for the course. Staff members can
view the example certificates before enabling
the self-generated certificates button for students.
Several example certificates may be generated for a course.
For example, if a course offers both verified and honor certificates,
examples of both types of certificate will be generated.
If an error occurs while starting the certificate generation
job, the errors will be recorded in the database and
can be retrieved using `example_certificate_status()`.
Arguments:
course_key (CourseKey): The course identifier.
Returns:
None
"""
xqueue = XQueueCertInterface()
for cert in ExampleCertificateSet.create_example_set(course_key):
xqueue.add_example_cert(cert)
log.info(u"Started generated example certificates for course '%s'.", course_key)
def example_certificates_status(course_key):
"""Check the status of example certificates for a course.
This will check the *latest* example certificate task.
This is generally what we care about in terms of enabling/disabling
self-generated certificates for a course.
Arguments:
course_key (CourseKey): The course identifier.
Returns:
list
Example Usage:
>>> from certificates import api as certs_api
>>> certs_api.example_certificate_status(course_key)
[
{
'description': 'honor',
'status': 'success',
'download_url': 'http://www.example.com/abcd/honor_cert.pdf'
},
{
'description': 'verified',
'status': 'error',
'error_reason': 'No template found!'
}
]
"""
return ExampleCertificateSet.latest_status(course_key)
from certificates.models import GeneratedCertificate
from certificates.models import certificate_status_for_student
from certificates.models import CertificateStatuses as status
from certificates.models import CertificateWhitelist
"""Interface for adding certificate generation tasks to the XQueue. """
import json
import random
import logging
import lxml.html
from lxml.etree import XMLSyntaxError, ParserError # pylint:disable=no-name-in-module
from courseware import grades, courses
from django.test.client import RequestFactory
from capa.xqueue_interface import XQueueInterface
from capa.xqueue_interface import make_xheader, make_hashkey
from django.conf import settings
from django.core.urlresolvers import reverse
from requests.auth import HTTPBasicAuth
from courseware import grades
from xmodule.modulestore.django import modulestore
from capa.xqueue_interface import XQueueInterface
from capa.xqueue_interface import make_xheader, make_hashkey
from student.models import UserProfile, CourseEnrollment
from verify_student.models import SoftwareSecurePhotoVerification
import json
import random
import logging
import lxml.html
from lxml.etree import XMLSyntaxError, ParserError
from certificates.models import (
GeneratedCertificate,
certificate_status_for_student,
CertificateStatuses as status,
CertificateWhitelist,
ExampleCertificate
)
LOGGER = logging.getLogger(__name__)
class XQueueAddToQueueError(Exception):
"""An error occurred when adding a certificate task to the queue. """
def __init__(self, error_code, error_msg):
self.error_code = error_code
self.error_msg = error_msg
super(XQueueAddToQueueError, self).__init__(unicode(self))
def __unicode__(self):
return (
u"Could not add certificate to the XQueue. "
u"The error code was '{code}' and the message was '{msg}'."
).format(
code=self.error_code,
msg=self.error_msg
)
class XQueueCertInterface(object):
"""
XQueueCertificateInterface provides an
......@@ -205,7 +230,7 @@ class XQueueCertInterface(object):
# re-use the course passed in optionally so we don't have to re-fetch everything
# for every student
if course is None:
course = courses.get_course_by_id(course_id)
course = modulestore().get_course(course_id, depth=0)
profile = UserProfile.objects.get(user=student)
profile_name = profile.name
......@@ -213,7 +238,7 @@ class XQueueCertInterface(object):
self.request.user = student
self.request.session = {}
course_name = course.display_name or course_id.to_deprecated_string()
course_name = course.display_name or unicode(course_id)
is_whitelisted = self.whitelist.filter(user=student, course_id=course_id, whitelist=True).exists()
grade = grades.grade(student, self.request, course)
enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(student, course_id)
......@@ -297,7 +322,7 @@ class XQueueCertInterface(object):
contents = {
'action': 'create',
'username': student.username,
'course_id': course_id.to_deprecated_string(),
'course_id': unicode(course_id),
'course_name': course_name,
'name': profile_name,
'grade': grade_contents,
......@@ -337,20 +362,106 @@ class XQueueCertInterface(object):
return new_status
def _send_to_xqueue(self, contents, key):
"""Create a new task on the XQueue. """
def add_example_cert(self, example_cert):
"""Add a task to create an example certificate.
if self.use_https:
proto = "https"
else:
proto = "http"
Unlike other certificates, an example certificate is
not associated with any particular user and is never
shown to students.
If an error occurs when adding the example certificate
to the queue, the example certificate status
will be set to "error".
Arguments:
example_cert (ExampleCertificate)
"""
contents = {
'action': 'create',
'course_id': unicode(example_cert.course_key),
'name': example_cert.full_name,
'template_pdf': example_cert.template,
# Example certificates are not associated with a particular user.
# However, we still need to find the example certificate when
# we receive a response from the queue. For this reason,
# we use the example certificate's unique identifier as a username.
# Note that the username is *not* displayed on the certificate;
# it is used only to identify the certificate task in the queue.
'username': example_cert.uuid,
# We send this extra parameter to differentiate
# example certificates from other certificates.
# This is not used by the certificates workers or XQueue.
'example_certificate': True,
}
# The callback for example certificates is different than the callback
# for other certificates. Although both tasks use the same queue,
# we can distinguish whether the certificate was an example cert based
# on which end-point XQueue uses once the task completes.
callback_url_path = reverse('certificates.views.update_example_certificate')
try:
self._send_to_xqueue(
contents,
example_cert.access_key,
task_identifier=example_cert.uuid,
callback_url_path=callback_url_path
)
except XQueueAddToQueueError as exc:
example_cert.update_status(
ExampleCertificate.STATUS_ERROR,
error_reason=unicode(exc)
)
def _send_to_xqueue(self, contents, key, task_identifier=None, callback_url_path='update_certificate'):
"""Create a new task on the XQueue.
Arguments:
contents (dict): The contents of the XQueue task.
key (str): An access key for the task. This will be sent
to the callback end-point once the task completes,
so that we can validate that the sender is the same
entity that received the task.
Keyword Arguments:
callback_url_path (str): The path of the callback URL.
If not provided, use the default end-point for student-generated
certificates.
"""
callback_url = u'{protocol}://{base_url}{path}'.format(
protocol=("https" if self.use_https else "http"),
base_url=settings.SITE_NAME,
path=callback_url_path
)
# Append the key to the URL
# This is necessary because XQueue assumes that only one
# submission is active for a particular URL.
# If it receives a second submission with the same callback URL,
# it "retires" any other submission with the same URL.
# This was a hack that depended on the URL containing the user ID
# and courseware location; an assumption that does not apply
# to certificate generation.
# XQueue also truncates the callback URL to 128 characters,
# but since our key lengths are shorter than that, this should
# not affect us.
callback_url += "?key={key}".format(
key=(
task_identifier
if task_identifier is not None
else key
)
)
xheader = make_xheader(
'{0}://{1}/update_certificate?{2}'.format(
proto, settings.SITE_NAME, key), key, settings.CERT_QUEUE)
xheader = make_xheader(callback_url, key, settings.CERT_QUEUE)
(error, msg) = self.xqueue_interface.send_to_queue(
header=xheader, body=json.dumps(contents))
if error:
LOGGER.critical(u'Unable to add a request to the queue: %s %s', unicode(error), msg)
raise Exception('Unable to send queue message')
exc = XQueueAddToQueueError(error, msg)
LOGGER.critical(unicode(exc))
raise exc
"""Tests for the certificates Python API. """
from contextlib import contextmanager
import ddt
from django.test import TestCase, RequestFactory
from django.test.utils import override_settings
from mock import patch, Mock
from opaque_keys.edx.locator import CourseLocator
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from course_modes.tests.factories import CourseModeFactory
from config_models.models import cache
from certificates import api as certs_api
from certificates.models import (
CertificateStatuses,
CertificateGenerationConfiguration,
ExampleCertificate
)
from certificates.queue import XQueueCertInterface
from certificates.tests.factories import GeneratedCertificateFactory
class CertificateDownloadableStatusTests(ModuleStoreTestCase):
"""Tests for the `certificate_downloadable_status` helper function. """
def setUp(self):
super(CertificateDownloadableStatusTests, self).setUp()
self.student = UserFactory()
self.student_no_cert = UserFactory()
self.course = CourseFactory.create(
org='edx',
number='verified',
display_name='Verified Course'
)
self.request_factory = RequestFactory()
def test_user_cert_status_with_generating(self):
GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course.id,
status=CertificateStatuses.generating,
mode='verified'
)
self.assertEqual(
certs_api.certificate_downloadable_status(self.student, self.course.id),
{
'is_downloadable': False,
'is_generating': True,
'download_url': None
}
)
def test_user_cert_status_with_error(self):
GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course.id,
status=CertificateStatuses.error,
mode='verified'
)
self.assertEqual(
certs_api.certificate_downloadable_status(self.student, self.course.id),
{
'is_downloadable': False,
'is_generating': True,
'download_url': None
}
)
def test_user_with_out_cert(self):
self.assertEqual(
certs_api.certificate_downloadable_status(self.student_no_cert, self.course.id),
{
'is_downloadable': False,
'is_generating': False,
'download_url': None
}
)
def test_user_with_downloadable_cert(self):
GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='verified',
download_url='www.google.com'
)
self.assertEqual(
certs_api.certificate_downloadable_status(self.student, self.course.id),
{
'is_downloadable': True,
'is_generating': False,
'download_url': 'www.google.com'
}
)
class GenerateUserCertificatesTest(ModuleStoreTestCase):
"""Tests for the `generate_user_certificates` helper function. """
def setUp(self):
super(GenerateUserCertificatesTest, self).setUp()
self.student = UserFactory()
self.student_no_cert = UserFactory()
self.course = CourseFactory.create(
org='edx',
number='verified',
display_name='Verified Course',
grade_cutoffs={'cutoff': 0.75, 'Pass': 0.5}
)
self.enrollment = CourseEnrollment.enroll(self.student, self.course.id, mode='honor')
self.request_factory = RequestFactory()
@override_settings(CERT_QUEUE='certificates')
@patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75}))
def test_new_cert_requests_into_xqueue_returns_generating(self):
# Mock `grade.grade` and return a summary with passing score.
# New requests save into xqueue and return the status
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_send_to_queue:
mock_send_to_queue.return_value = (0, "Successfully queued")
result = certs_api.generate_user_certificates(self.student, self.course)
self.assertEqual(result, 'generating')
@ddt.ddt
class CertificateGenerationEnabledTest(TestCase):
"""Test enabling/disabling self-generated certificates for a course. """
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
def setUp(self):
super(CertificateGenerationEnabledTest, self).setUp()
# Since model-based configuration is cached, we need
# to clear the cache before each test.
cache.clear()
@ddt.data(
(None, None, False),
(False, None, False),
(False, True, False),
(True, None, False),
(True, False, False),
(True, True, True)
)
@ddt.unpack
def test_cert_generation_enabled(self, is_feature_enabled, is_course_enabled, expect_enabled):
if is_feature_enabled is not None:
CertificateGenerationConfiguration.objects.create(enabled=is_feature_enabled)
if is_course_enabled is not None:
certs_api.set_cert_generation_enabled(self.COURSE_KEY, is_course_enabled)
self._assert_enabled_for_course(self.COURSE_KEY, expect_enabled)
def test_latest_setting_used(self):
# Enable the feature
CertificateGenerationConfiguration.objects.create(enabled=True)
# Enable for the course
certs_api.set_cert_generation_enabled(self.COURSE_KEY, True)
self._assert_enabled_for_course(self.COURSE_KEY, True)
# Disable for the course
certs_api.set_cert_generation_enabled(self.COURSE_KEY, False)
self._assert_enabled_for_course(self.COURSE_KEY, False)
def test_setting_is_course_specific(self):
# Enable the feature
CertificateGenerationConfiguration.objects.create(enabled=True)
# Enable for one course
certs_api.set_cert_generation_enabled(self.COURSE_KEY, True)
self._assert_enabled_for_course(self.COURSE_KEY, True)
# Should be disabled for another course
other_course = CourseLocator(org='other', course='other', run='other')
self._assert_enabled_for_course(other_course, False)
def _assert_enabled_for_course(self, course_key, expect_enabled):
"""Check that self-generated certificates are enabled or disabled for the course. """
actual_enabled = certs_api.cert_generation_enabled(course_key)
self.assertEqual(expect_enabled, actual_enabled)
class GenerateExampleCertificatesTest(TestCase):
"""Test generation of example certificates. """
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
def setUp(self):
super(GenerateExampleCertificatesTest, self).setUp()
def test_generate_example_certs(self):
# Generate certificates for the course
with self._mock_xqueue() as mock_queue:
certs_api.generate_example_certificates(self.COURSE_KEY)
# Verify that the appropriate certs were added to the queue
self._assert_certs_in_queue(mock_queue, 1)
# Verify that the certificate status is "started"
self._assert_cert_status({
'description': 'honor',
'status': 'started'
})
def test_generate_example_certs_with_verified_mode(self):
# Create verified and honor modes for the course
CourseModeFactory(course_id=self.COURSE_KEY, mode_slug='honor')
CourseModeFactory(course_id=self.COURSE_KEY, mode_slug='verified')
# Generate certificates for the course
with self._mock_xqueue() as mock_queue:
certs_api.generate_example_certificates(self.COURSE_KEY)
# Verify that the appropriate certs were added to the queue
self._assert_certs_in_queue(mock_queue, 2)
# Verify that the certificate status is "started"
self._assert_cert_status(
{
'description': 'verified',
'status': 'started'
},
{
'description': 'honor',
'status': 'started'
}
)
@contextmanager
def _mock_xqueue(self):
"""Mock the XQueue method for adding a task to the queue. """
with patch.object(XQueueCertInterface, 'add_example_cert') as mock_queue:
yield mock_queue
def _assert_certs_in_queue(self, mock_queue, expected_num):
"""Check that the certificate generation task was added to the queue. """
certs_in_queue = [call_args[0] for (call_args, __) in mock_queue.call_args_list]
self.assertEqual(len(certs_in_queue), expected_num)
for cert in certs_in_queue:
self.assertTrue(isinstance(cert, ExampleCertificate))
def _assert_cert_status(self, *expected_statuses):
"""Check the example certificate status. """
actual_status = certs_api.example_certificates_status(self.COURSE_KEY)
self.assertEqual(list(expected_statuses), actual_status)
"""Tests for certificate Django models. """
from django.test import TestCase
from opaque_keys.edx.locator import CourseLocator
from certificates.models import (
ExampleCertificate,
ExampleCertificateSet
)
class ExampleCertificateTest(TestCase):
"""Tests for the ExampleCertificate model. """
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
DESCRIPTION = 'test'
TEMPLATE = 'test.pdf'
DOWNLOAD_URL = 'http://www.example.com'
ERROR_REASON = 'Kaboom!'
def setUp(self):
super(ExampleCertificateTest, self).setUp()
self.cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY)
self.cert = ExampleCertificate.objects.create(
example_cert_set=self.cert_set,
description=self.DESCRIPTION,
template=self.TEMPLATE
)
def test_update_status_success(self):
self.cert.update_status(
ExampleCertificate.STATUS_SUCCESS,
download_url=self.DOWNLOAD_URL
)
self.assertEqual(
self.cert.status_dict,
{
'description': self.DESCRIPTION,
'status': ExampleCertificate.STATUS_SUCCESS,
'download_url': self.DOWNLOAD_URL
}
)
def test_update_status_error(self):
self.cert.update_status(
ExampleCertificate.STATUS_ERROR,
error_reason=self.ERROR_REASON
)
self.assertEqual(
self.cert.status_dict,
{
'description': self.DESCRIPTION,
'status': ExampleCertificate.STATUS_ERROR,
'error_reason': self.ERROR_REASON
}
)
def test_update_status_invalid(self):
with self.assertRaisesRegexp(ValueError, 'status'):
self.cert.update_status('invalid')
def test_latest_status_unavailable(self):
# Delete any existing statuses
ExampleCertificateSet.objects.all().delete()
# Verify that the "latest" status is None
result = ExampleCertificateSet.latest_status(self.COURSE_KEY)
self.assertIs(result, None)
def test_latest_status_is_course_specific(self):
other_course = CourseLocator(org='other', course='other', run='other')
result = ExampleCertificateSet.latest_status(other_course)
self.assertIs(result, None)
# -*- coding: utf-8 -*-
"""Tests for the XQueue certificates interface. """
from contextlib import contextmanager
import json
from mock import patch
from django.test import TestCase
from django.test.utils import override_settings
from opaque_keys.edx.locator import CourseLocator
# It is really unfortunate that we are using the XQueue client
# code from the capa library. In the future, we should move this
# into a shared library. We import it here so we can mock it
# and verify that items are being correctly added to the queue
# in our `XQueueCertInterface` implementation.
from capa.xqueue_interface import XQueueInterface
from certificates.queue import XQueueCertInterface
from certificates.models import ExampleCertificateSet, ExampleCertificate
@override_settings(CERT_QUEUE='certificates')
class XQueueCertInterfaceTest(TestCase):
"""Tests for the XQueue interface for certificate generation. """
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
TEMPLATE = 'test.pdf'
DESCRIPTION = 'test'
ERROR_MSG = 'Kaboom!'
def setUp(self):
super(XQueueCertInterfaceTest, self).setUp()
self.xqueue = XQueueCertInterface()
def test_add_example_cert(self):
cert = self._create_example_cert()
with self._mock_xqueue() as mock_send:
self.xqueue.add_example_cert(cert)
# Verify that the correct payload was sent to the XQueue
self._assert_queue_task(mock_send, cert)
# Verify the certificate status
self.assertEqual(cert.status, ExampleCertificate.STATUS_STARTED)
def test_add_example_cert_error(self):
cert = self._create_example_cert()
with self._mock_xqueue(success=False):
self.xqueue.add_example_cert(cert)
# Verify the error status of the certificate
self.assertEqual(cert.status, ExampleCertificate.STATUS_ERROR)
self.assertIn(self.ERROR_MSG, cert.error_reason)
def _create_example_cert(self):
"""Create an example certificate. """
cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY)
return ExampleCertificate.objects.create(
example_cert_set=cert_set,
description=self.DESCRIPTION,
template=self.TEMPLATE
)
@contextmanager
def _mock_xqueue(self, success=True):
"""Mock the XQueue method for sending a task to the queue. """
with patch.object(XQueueInterface, 'send_to_queue') as mock_send:
mock_send.return_value = (0, None) if success else (1, self.ERROR_MSG)
yield mock_send
def _assert_queue_task(self, mock_send, cert):
"""Check that the task was added to the queue. """
expected_header = {
'lms_key': cert.access_key,
'lms_callback_url': 'https://edx.org/update_example_certificate?key={key}'.format(key=cert.uuid),
'queue_name': 'certificates'
}
expected_body = {
'action': 'create',
'username': cert.uuid,
'name': u'John Doë',
'course_id': unicode(self.COURSE_KEY),
'template_pdf': 'test.pdf',
'example_certificate': True
}
self.assertTrue(mock_send.called)
__, kwargs = mock_send.call_args_list[0]
actual_header = json.loads(kwargs['header'])
actual_body = json.loads(kwargs['body'])
self.assertEqual(expected_header, actual_header)
self.assertEqual(expected_body, actual_body)
"""Tests for certificates views. """
import json
import ddt
from django.test import TestCase
from django.core.urlresolvers import reverse
from django.core.cache import cache
from opaque_keys.edx.locator import CourseLocator
from certificates.models import ExampleCertificateSet, ExampleCertificate
@ddt.ddt
class UpdateExampleCertificateViewTest(TestCase):
"""Tests for the XQueue callback that updates example certificates. """
COURSE_KEY = CourseLocator(org='test', course='test', run='test')
DESCRIPTION = 'test'
TEMPLATE = 'test.pdf'
DOWNLOAD_URL = 'http://www.example.com'
ERROR_REASON = 'Kaboom!'
def setUp(self):
super(UpdateExampleCertificateViewTest, self).setUp()
self.cert_set = ExampleCertificateSet.objects.create(course_key=self.COURSE_KEY)
self.cert = ExampleCertificate.objects.create(
example_cert_set=self.cert_set,
description=self.DESCRIPTION,
template=self.TEMPLATE,
)
self.url = reverse('certificates.views.update_example_certificate')
# Since rate limit counts are cached, we need to clear
# this before each test.
cache.clear()
def test_update_example_certificate_success(self):
response = self._post_to_view(self.cert, download_url=self.DOWNLOAD_URL)
self._assert_response(response)
self.cert = ExampleCertificate.objects.get()
self.assertEqual(self.cert.status, ExampleCertificate.STATUS_SUCCESS)
self.assertEqual(self.cert.download_url, self.DOWNLOAD_URL)
def test_update_example_certificate_invalid_key(self):
payload = {
'xqueue_header': json.dumps({
'lms_key': 'invalid'
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid,
'url': self.DOWNLOAD_URL
})
}
response = self.client.post(self.url, data=payload)
self.assertEqual(response.status_code, 404)
def test_update_example_certificate_error(self):
response = self._post_to_view(self.cert, error_reason=self.ERROR_REASON)
self._assert_response(response)
self.cert = ExampleCertificate.objects.get()
self.assertEqual(self.cert.status, ExampleCertificate.STATUS_ERROR)
self.assertEqual(self.cert.error_reason, self.ERROR_REASON)
@ddt.data('xqueue_header', 'xqueue_body')
def test_update_example_certificate_invalid_params(self, missing_param):
payload = {
'xqueue_header': json.dumps({
'lms_key': self.cert.access_key
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid,
'url': self.DOWNLOAD_URL
})
}
del payload[missing_param]
response = self.client.post(self.url, data=payload)
self.assertEqual(response.status_code, 400)
def test_update_example_certificate_missing_download_url(self):
payload = {
'xqueue_header': json.dumps({
'lms_key': self.cert.access_key
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid
})
}
response = self.client.post(self.url, data=payload)
self.assertEqual(response.status_code, 400)
def test_update_example_cetificate_non_json_param(self):
payload = {
'xqueue_header': '{/invalid',
'xqueue_body': '{/invalid'
}
response = self.client.post(self.url, data=payload)
self.assertEqual(response.status_code, 400)
def test_unsupported_http_method(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_bad_request_rate_limiting(self):
payload = {
'xqueue_header': json.dumps({
'lms_key': 'invalid'
}),
'xqueue_body': json.dumps({
'username': self.cert.uuid,
'url': self.DOWNLOAD_URL
})
}
# Exceed the rate limit for invalid requests
# (simulate a DDOS with invalid keys)
for _ in range(100):
response = self.client.post(self.url, data=payload)
if response.status_code == 403:
break
# The final status code should indicate that the rate
# limit was exceeded.
self.assertEqual(response.status_code, 403)
def _post_to_view(self, cert, download_url=None, error_reason=None):
"""Simulate a callback from the XQueue to the example certificate end-point. """
header = {'lms_key': cert.access_key}
body = {'username': cert.uuid}
if download_url is not None:
body['url'] = download_url
if error_reason is not None:
body['error'] = 'error'
body['error_reason'] = self.ERROR_REASON
payload = {
'xqueue_header': json.dumps(header),
'xqueue_body': json.dumps(body)
}
return self.client.post(self.url, data=payload)
def _assert_response(self, response):
"""Check the response from the callback end-point. """
content = json.loads(response.content)
self.assertEqual(response.status_code, 200)
self.assertEqual(content['return_code'], 0)
"""
Tests for the certificates api and helper function.
"""
from django.test import RequestFactory
from django.test.utils import override_settings
from mock import patch, Mock
from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from certificates.api import certificate_downloadable_status, generate_user_certificates
from student.models import CourseEnrollment
from student.tests.factories import UserFactory
from certificates.models import CertificateStatuses
from certificates.tests.factories import GeneratedCertificateFactory
class CertificateDownloadableStatusTests(ModuleStoreTestCase):
"""
Tests for the certificate_downloadable_status helper function
"""
def setUp(self):
super(CertificateDownloadableStatusTests, self).setUp()
self.student = UserFactory()
self.student_no_cert = UserFactory()
self.course = CourseFactory.create(
org='edx',
number='verified',
display_name='Verified Course'
)
self.request_factory = RequestFactory()
def test_user_cert_status_with_generating(self):
"""
in case of certificate with error means means is_generating is True and is_downloadable is False
"""
GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course.id,
status=CertificateStatuses.generating,
mode='verified'
)
self.assertEqual(
certificate_downloadable_status(self.student, self.course.id),
{
'is_downloadable': False,
'is_generating': True,
'download_url': None
}
)
def test_user_cert_status_with_error(self):
"""
in case of certificate with error means means is_generating is True and is_downloadable is False
"""
GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course.id,
status=CertificateStatuses.error,
mode='verified'
)
self.assertEqual(
certificate_downloadable_status(self.student, self.course.id),
{
'is_downloadable': False,
'is_generating': True,
'download_url': None
}
)
def test_user_with_out_cert(self):
"""
in case of no certificate means is_generating is False and is_downloadable is False
"""
self.assertEqual(
certificate_downloadable_status(self.student_no_cert, self.course.id),
{
'is_downloadable': False,
'is_generating': False,
'download_url': None
}
)
def test_user_with_downloadable_cert(self):
"""
in case of downloadable certificate means is_generating is False and is_downloadable is True
download_url has cert link
"""
GeneratedCertificateFactory.create(
user=self.student,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='verified',
download_url='www.google.com'
)
self.assertEqual(
certificate_downloadable_status(self.student, self.course.id),
{
'is_downloadable': True,
'is_generating': False,
'download_url': 'www.google.com'
}
)
class GenerateUserCertificatesTest(ModuleStoreTestCase):
"""
Tests for the generate_user_certificates helper function
"""
def setUp(self):
super(GenerateUserCertificatesTest, self).setUp()
self.student = UserFactory()
self.student_no_cert = UserFactory()
self.course = CourseFactory.create(
org='edx',
number='verified',
display_name='Verified Course',
grade_cutoffs={'cutoff': 0.75, 'Pass': 0.5}
)
self.enrollment = CourseEnrollment.enroll(self.student, self.course.id, mode='honor')
self.request_factory = RequestFactory()
@override_settings(CERT_QUEUE='certificates')
@patch('courseware.grades.grade', Mock(return_value={'grade': 'Pass', 'percent': 0.75}))
def test_new_cert_requests_into_xqueue_returns_generating(self):
"""
mocking grade.grade and returns a summary with passing score.
new requests saves into xqueue and returns the status
"""
with patch('capa.xqueue_interface.XQueueInterface.send_to_queue') as mock_send_to_queue:
mock_send_to_queue.return_value = (0, "Successfully queued")
self.assertEqual(generate_user_certificates(self.student, self.course), 'generating')
......@@ -4,14 +4,21 @@ import json
import logging
from django.contrib.auth.models import User
from django.http import HttpResponse
from django.http import HttpResponse, Http404, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from capa.xqueue_interface import XQUEUE_METRIC_NAME
from certificates.models import certificate_status_for_student, CertificateStatuses, GeneratedCertificate
from certificates.models import (
certificate_status_for_student,
CertificateStatuses,
GeneratedCertificate,
ExampleCertificate
)
from certificates.queue import XQueueCertInterface
from xmodule.course_module import CourseDescriptor
from xmodule.modulestore.django import modulestore
from util.json_request import JsonResponse, JsonResponseBadRequest
from util.bad_request_rate_limiter import BadRequestRateLimiter
from opaque_keys.edx.locations import SlashSeparatedCourseKey
logger = logging.getLogger(__name__)
......@@ -121,3 +128,99 @@ def update_certificate(request):
cert.save()
return HttpResponse(json.dumps({'return_code': 0}),
mimetype='application/json')
@csrf_exempt
@require_POST
def update_example_certificate(request):
"""Callback from the XQueue that updates example certificates.
Example certificates are used to verify that certificate
generation is configured correctly for a course.
Unlike other certificates, example certificates
are not associated with a particular user or displayed
to students.
For this reason, we need a different end-point to update
the status of generated example certificates.
Arguments:
request (HttpRequest)
Returns:
HttpResponse (200): Status was updated successfully.
HttpResponse (400): Invalid parameters.
HttpResponse (403): Rate limit exceeded for bad requests.
HttpResponse (404): Invalid certificate identifier or access key.
"""
logger.info(u"Received response for example certificate from XQueue.")
rate_limiter = BadRequestRateLimiter()
# Check the parameters and rate limits
# If these are invalid, return an error response.
if rate_limiter.is_rate_limit_exceeded(request):
logger.info(u"Bad request rate limit exceeded for update example certificate end-point.")
return HttpResponseForbidden("Rate limit exceeded")
if 'xqueue_body' not in request.POST:
logger.info(u"Missing parameter 'xqueue_body' for update example certificate end-point")
rate_limiter.tick_bad_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_body' is required.")
if 'xqueue_header' not in request.POST:
logger.info(u"Missing parameter 'xqueue_header' for update example certificate end-point")
rate_limiter.tick_bad_request_counter(request)
return JsonResponseBadRequest("Parameter 'xqueue_header' is required.")
try:
xqueue_body = json.loads(request.POST['xqueue_body'])
xqueue_header = json.loads(request.POST['xqueue_header'])
except (ValueError, TypeError):
logger.info(u"Could not decode params to example certificate end-point as JSON.")
rate_limiter.tick_bad_request_counter(request)
return JsonResponseBadRequest("Parameters must be JSON-serialized.")
# Attempt to retrieve the example certificate record
# so we can update the status.
try:
uuid = xqueue_body.get('username')
access_key = xqueue_header.get('lms_key')
cert = ExampleCertificate.objects.get(uuid=uuid, access_key=access_key)
except ExampleCertificate.DoesNotExist:
# If we are unable to retrieve the record, it means the uuid or access key
# were not valid. This most likely means that the request is NOT coming
# from the XQueue. Return a 404 and increase the bad request counter
# to protect against a DDOS attack.
logger.info(u"Could not find example certificate with uuid '%s' and access key '%s'", uuid, access_key)
rate_limiter.tick_bad_request_counter(request)
raise Http404
if 'error' in xqueue_body:
# If an error occurs, save the error message so we can fix the issue.
error_reason = xqueue_body.get('error_reason')
cert.update_status(ExampleCertificate.STATUS_ERROR, error_reason=error_reason)
logger.warning(
(
u"Error occurred during example certificate generation for uuid '%s'. "
u"The error response was '%s'."
), uuid, error_reason
)
else:
# If the certificate generated successfully, save the download URL
# so we can display the example certificate.
download_url = xqueue_body.get('url')
if download_url is None:
rate_limiter.tick_bad_request_counter(request)
logger.warning(u"No download URL provided for example certificate with uuid '%s'.", uuid)
return JsonResponseBadRequest(
"Parameter 'download_url' is required for successfully generated certificates."
)
else:
cert.update_status(ExampleCertificate.STATUS_SUCCESS, download_url=download_url)
logger.info("Successfully updated example certificate with uuid '%s'.", uuid)
# Let the XQueue know that we handled the response
return JsonResponse({'return_code': 0})
......@@ -17,7 +17,9 @@ urlpatterns = (
# certificate view
url(r'^update_certificate$', 'certificates.views.update_certificate'),
url(r'^update_example_certificate$', 'certificates.views.update_example_certificate'),
url(r'^request_certificate$', 'certificates.views.request_certificate'),
url(r'^$', 'branding.views.index', name="root"), # Main marketing page, or redirect to courseware
url(r'^dashboard$', 'student.views.dashboard', name="dashboard"),
url(r'^login_ajax$', 'student.views.login_user', name="login"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment