LEARNER-5050: Adds award course cert job to post to Credentials
This commit is contained in:
@@ -68,7 +68,7 @@ from badges.events.course_meta import completion_check, course_group_check
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.instructor_task.models import InstructorTask
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED
|
||||
from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager
|
||||
from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
|
||||
|
||||
@@ -340,8 +340,16 @@ class GeneratedCertificate(models.Model):
|
||||
"""
|
||||
After the base save() method finishes, fire the COURSE_CERT_AWARDED
|
||||
signal iff we are saving a record of a learner passing the course.
|
||||
As well as the COURSE_CERT_CHANGED for any save event.
|
||||
"""
|
||||
super(GeneratedCertificate, self).save(*args, **kwargs)
|
||||
COURSE_CERT_CHANGED.send_robust(
|
||||
sender=self.__class__,
|
||||
user=self.user,
|
||||
course_key=self.course_id,
|
||||
mode=self.mode,
|
||||
status=self.status,
|
||||
)
|
||||
if CertificateStatuses.is_passing_status(self.status):
|
||||
COURSE_CERT_AWARDED.send_robust(
|
||||
sender=self.__class__,
|
||||
|
||||
@@ -67,11 +67,20 @@ class CredentialsApiConfig(ConfigurationModel):
|
||||
@property
|
||||
def internal_api_url(self):
|
||||
"""
|
||||
Internally-accessible API URL root.
|
||||
Internally-accessible API URL root, looked up based on the current request.
|
||||
"""
|
||||
root = helpers.get_value('CREDENTIALS_INTERNAL_SERVICE_URL', settings.CREDENTIALS_INTERNAL_SERVICE_URL)
|
||||
return urljoin(root, '/api/{}/'.format(API_VERSION))
|
||||
|
||||
@staticmethod
|
||||
def get_internal_api_url_for_org(org):
|
||||
"""
|
||||
Internally-accessible API URL root, looked up by org rather than the current request.
|
||||
"""
|
||||
root = helpers.get_value_for_org(org, 'CREDENTIALS_INTERNAL_SERVICE_URL',
|
||||
settings.CREDENTIALS_INTERNAL_SERVICE_URL)
|
||||
return urljoin(root, '/api/{}/'.format(API_VERSION))
|
||||
|
||||
@property
|
||||
def public_api_url(self):
|
||||
"""
|
||||
|
||||
@@ -24,6 +24,9 @@ class TestCredentialsApiConfig(CredentialsApiConfigMixin, TestCase):
|
||||
"""Verify that URLs returned by the model are constructed correctly."""
|
||||
credentials_config = self.create_credentials_config()
|
||||
|
||||
expected = '{root}/api/{version}/'.format(root=CREDENTIALS_INTERNAL_SERVICE_URL.strip('/'), version=API_VERSION)
|
||||
self.assertEqual(credentials_config.get_internal_api_url_for_org('nope'), expected)
|
||||
|
||||
expected = '{root}/api/{version}/'.format(root=CREDENTIALS_INTERNAL_SERVICE_URL.strip('/'), version=API_VERSION)
|
||||
self.assertEqual(credentials_config.internal_api_url, expected)
|
||||
|
||||
|
||||
@@ -22,13 +22,25 @@ def get_credentials_records_url(program_uuid=None):
|
||||
return base_url
|
||||
|
||||
|
||||
def get_credentials_api_client(user):
|
||||
""" Returns an authenticated Credentials API client. """
|
||||
def get_credentials_api_client(user, org=None):
|
||||
"""
|
||||
Returns an authenticated Credentials API client.
|
||||
|
||||
Arguments:
|
||||
user (User): The user to authenticate as when requesting credentials.
|
||||
org (str): Optional organization to look up the site config for, rather than the current request
|
||||
|
||||
"""
|
||||
|
||||
scopes = ['email', 'profile']
|
||||
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
|
||||
jwt = JwtBuilder(user).build_token(scopes, expires_in)
|
||||
return EdxRestApiClient(CredentialsApiConfig.current().internal_api_url, jwt=jwt)
|
||||
|
||||
if org is None:
|
||||
url = CredentialsApiConfig.current().internal_api_url # by current request
|
||||
else:
|
||||
url = CredentialsApiConfig.get_internal_api_url_for_org(org) # by org
|
||||
return EdxRestApiClient(url, jwt=jwt)
|
||||
|
||||
|
||||
def get_credentials(user, program_uuid=None, credential_type=None):
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from django.dispatch import receiver
|
||||
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,3 +52,46 @@ def handle_course_cert_awarded(sender, user, course_key, mode, status, **kwargs)
|
||||
# import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
|
||||
from openedx.core.djangoapps.programs.tasks.v1.tasks import award_program_certificates
|
||||
award_program_certificates.delay(user.username)
|
||||
|
||||
|
||||
@receiver(COURSE_CERT_CHANGED)
|
||||
def handle_course_cert_changed(sender, user, course_key, mode, status, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
If a learner is awarded a course certificate,
|
||||
schedule a celery task to process that course certificate
|
||||
|
||||
Args:
|
||||
sender:
|
||||
class of the object instance that sent this signal
|
||||
user:
|
||||
django.contrib.auth.User - the user to whom a cert was awarded
|
||||
course_key:
|
||||
refers to the course run for which the cert was awarded
|
||||
mode:
|
||||
mode / certificate type, e.g. "verified"
|
||||
status:
|
||||
"downloadable"
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
# Import here instead of top of file since this module gets imported before
|
||||
# the credentials app is loaded, resulting in a Django deprecation warning.
|
||||
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
||||
|
||||
# Avoid scheduling new tasks if certification is disabled.
|
||||
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
||||
return
|
||||
|
||||
# schedule background task to process
|
||||
LOGGER.debug(
|
||||
'handling COURSE_CERT_CHANGED: username=%s, course_key=%s, mode=%s, status=%s',
|
||||
user,
|
||||
course_key,
|
||||
mode,
|
||||
status,
|
||||
)
|
||||
# import here, because signal is registered at startup, but items in tasks are not yet able to be loaded
|
||||
from openedx.core.djangoapps.programs.tasks.v1.tasks import award_course_certificate
|
||||
award_course_certificate.delay(user.username, course_key)
|
||||
|
||||
@@ -6,15 +6,15 @@ from celery.utils.log import get_task_logger # pylint: disable=no-name-in-modul
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from edx_rest_api_client import exceptions
|
||||
from edx_rest_api_client.client import EdxRestApiClient
|
||||
from provider.oauth2.models import Client
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from openedx.core.djangoapps.certificates.api import display_date_for_certificate
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
|
||||
from openedx.core.djangoapps.credentials.utils import get_credentials
|
||||
from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_api_client
|
||||
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
|
||||
from openedx.core.lib.token_utils import JwtBuilder
|
||||
|
||||
|
||||
LOGGER = get_task_logger(__name__)
|
||||
@@ -26,23 +26,8 @@ ROUTING_KEY = getattr(settings, 'CREDENTIALS_GENERATION_ROUTING_KEY', None)
|
||||
# unwanted behavior: infinite retries.
|
||||
MAX_RETRIES = 11
|
||||
|
||||
|
||||
def get_api_client(api_config, user):
|
||||
"""
|
||||
Create and configure an API client for authenticated HTTP requests.
|
||||
|
||||
Args:
|
||||
api_config: CredentialsApiConfig object
|
||||
user: User object as whom to authenticate to the API
|
||||
|
||||
Returns:
|
||||
EdxRestApiClient
|
||||
|
||||
"""
|
||||
scopes = ['email', 'profile']
|
||||
expires_in = settings.OAUTH_ID_TOKEN_EXPIRATION
|
||||
jwt = JwtBuilder(user).build_token(scopes, expires_in)
|
||||
return EdxRestApiClient(api_config.internal_api_url, jwt=jwt)
|
||||
PROGRAM_CERTIFICATE = 'program'
|
||||
COURSE_CERTIFICATE = 'course-run'
|
||||
|
||||
|
||||
def get_completed_programs(site, student):
|
||||
@@ -98,7 +83,10 @@ def award_program_certificate(client, username, program_uuid):
|
||||
"""
|
||||
client.credentials.post({
|
||||
'username': username,
|
||||
'credential': {'program_uuid': program_uuid},
|
||||
'credential': {
|
||||
'type': PROGRAM_CERTIFICATE,
|
||||
'program_uuid': program_uuid
|
||||
},
|
||||
'attributes': []
|
||||
})
|
||||
|
||||
@@ -172,9 +160,8 @@ def award_program_certificates(self, username):
|
||||
new_program_uuids = sorted(list(set(program_uuids) - set(existing_program_uuids)))
|
||||
if new_program_uuids:
|
||||
try:
|
||||
credentials_client = get_api_client(
|
||||
CredentialsApiConfig.current(),
|
||||
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME) # pylint: disable=no-member
|
||||
credentials_client = get_credentials_api_client(
|
||||
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME),
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
LOGGER.exception('Failed to create a credentials API client to award program certificates')
|
||||
@@ -205,3 +192,91 @@ def award_program_certificates(self, username):
|
||||
LOGGER.info('User %s is not eligible for any new program certificates', username)
|
||||
|
||||
LOGGER.info('Successfully completed the task award_program_certificates for username %s', username)
|
||||
|
||||
|
||||
def post_course_certificate(client, username, certificate, visible_date):
|
||||
"""
|
||||
POST a certificate that has been updated to Credentials
|
||||
"""
|
||||
client.credentials.post({
|
||||
'username': username,
|
||||
'status': 'awarded' if certificate.is_valid() else 'revoked', # Only need the two options at this time
|
||||
'credential': {
|
||||
'course_run_key': str(certificate.course_id),
|
||||
'mode': certificate.mode,
|
||||
'type': COURSE_CERTIFICATE,
|
||||
},
|
||||
'attributes': [
|
||||
{
|
||||
'name': 'visible_date',
|
||||
'value': visible_date.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@task(bind=True, ignore_result=True, routing_key=ROUTING_KEY)
|
||||
def award_course_certificate(self, username, course_run_key):
|
||||
"""
|
||||
This task is designed to be called whenever a student GeneratedCertificate is updated.
|
||||
It can be called independently for a username and a course_run, but is invoked on each GeneratedCertificate.save.
|
||||
"""
|
||||
|
||||
LOGGER.info('Running task award_course_certificate for username %s', username)
|
||||
|
||||
countdown = 2 ** self.request.retries
|
||||
|
||||
# If the credentials config model is disabled for this
|
||||
# feature, it may indicate a condition where processing of such tasks
|
||||
# has been temporarily disabled. Since this is a recoverable situation,
|
||||
# mark this task for retry instead of failing it altogether.
|
||||
|
||||
if not CredentialsApiConfig.current().is_learner_issuance_enabled:
|
||||
LOGGER.warning(
|
||||
'Task award_course_certificate cannot be executed when credentials issuance is disabled in API config',
|
||||
)
|
||||
raise self.retry(countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
try:
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
LOGGER.exception('Task award_course_certificate was called with invalid username %s', username)
|
||||
# Don't retry for this case - just conclude the task.
|
||||
return
|
||||
# Get the cert for the course key and username if it's both passing and available in professional/verified
|
||||
try:
|
||||
certificate = GeneratedCertificate.eligible_certificates.get(
|
||||
user=user.id,
|
||||
course_id=course_run_key
|
||||
)
|
||||
except GeneratedCertificate.DoesNotExist:
|
||||
LOGGER.exception(
|
||||
'Task award_course_certificate was called without Certificate found for %s to user %s',
|
||||
course_run_key,
|
||||
username
|
||||
)
|
||||
return
|
||||
if certificate.mode in CourseMode.VERIFIED_MODES + CourseMode.CREDIT_MODES:
|
||||
try:
|
||||
course_overview = CourseOverview.get_from_id(course_run_key)
|
||||
except (CourseOverview.DoesNotExist, IOError):
|
||||
LOGGER.exception(
|
||||
'Task award_course_certificate was called without course overview data for course %s',
|
||||
course_run_key
|
||||
)
|
||||
return
|
||||
credentials_client = get_credentials_api_client(User.objects.get(
|
||||
username=settings.CREDENTIALS_SERVICE_USERNAME),
|
||||
org=course_run_key.org,
|
||||
)
|
||||
# FIXME This may result in visible dates that do not update alongside the Course Overview if that changes
|
||||
# This is a known limitation of this implementation and was chosen to reduce the amount of replication,
|
||||
# endpoints, celery tasks, and jenkins jobs that needed to be written for this functionality
|
||||
visible_date = display_date_for_certificate(course_overview, certificate)
|
||||
post_course_certificate(credentials_client, username, certificate, visible_date)
|
||||
|
||||
LOGGER.info('Awarded certificate for course %s to user %s', course_run_key, username)
|
||||
except Exception as exc:
|
||||
LOGGER.exception('Failed to determine course certificates to be awarded for user %s', username)
|
||||
raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Tests for programs celery tasks.
|
||||
"""
|
||||
import json
|
||||
|
||||
from datetime import datetime
|
||||
import ddt
|
||||
import httpretty
|
||||
import mock
|
||||
@@ -13,40 +13,20 @@ from edx_oauth2_provider.tests.factories import ClientFactory
|
||||
from edx_rest_api_client import exceptions
|
||||
from edx_rest_api_client.client import EdxRestApiClient
|
||||
|
||||
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
|
||||
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
|
||||
from openedx.core.djangoapps.programs.tasks.v1 import tasks
|
||||
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
|
||||
CREDENTIALS_INTERNAL_SERVICE_URL = 'https://credentials.example.com'
|
||||
TASKS_MODULE = 'openedx.core.djangoapps.programs.tasks.v1.tasks'
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class GetApiClientTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Test the get_api_client function
|
||||
"""
|
||||
|
||||
@override_settings(CREDENTIALS_INTERNAL_SERVICE_URL=CREDENTIALS_INTERNAL_SERVICE_URL)
|
||||
@mock.patch(TASKS_MODULE + '.JwtBuilder.build_token')
|
||||
def test_get_api_client(self, mock_build_token):
|
||||
"""
|
||||
Ensure the function is making the right API calls based on inputs
|
||||
"""
|
||||
student = UserFactory()
|
||||
ClientFactory.create(name='credentials')
|
||||
api_config = self.create_credentials_config()
|
||||
mock_build_token.return_value = 'test-token'
|
||||
|
||||
api_client = tasks.get_api_client(api_config, student)
|
||||
expected = CREDENTIALS_INTERNAL_SERVICE_URL.strip('/') + '/api/v2/'
|
||||
self.assertEqual(api_client._store['base_url'], expected) # pylint: disable=protected-access
|
||||
self.assertEqual(api_client._store['session'].auth.token, 'test-token') # pylint: disable=protected-access
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class GetAwardedCertificateProgramsTestCase(TestCase):
|
||||
"""
|
||||
@@ -110,7 +90,10 @@ class AwardProgramCertificateTestCase(TestCase):
|
||||
|
||||
expected_body = {
|
||||
'username': test_username,
|
||||
'credential': {'program_uuid': 123},
|
||||
'credential': {
|
||||
'program_uuid': 123,
|
||||
'type': tasks.PROGRAM_CERTIFICATE,
|
||||
},
|
||||
'attributes': []
|
||||
}
|
||||
self.assertEqual(json.loads(httpretty.last_request().body), expected_body)
|
||||
@@ -324,3 +307,147 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
|
||||
tasks.award_program_certificates.delay(self.student.username).get()
|
||||
|
||||
self.assertEqual(mock_award_program_certificate.call_count, 2)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class PostCourseCertificateTestCase(TestCase):
|
||||
"""
|
||||
Test the award_program_certificate function
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.student = UserFactory.create(username='test-student')
|
||||
self.course = CourseOverviewFactory.create(
|
||||
self_paced=True # Any option to allow the certificate to be viewable for the course
|
||||
)
|
||||
self.certificate = GeneratedCertificateFactory(
|
||||
user=self.student,
|
||||
mode='verified',
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
)
|
||||
|
||||
@httpretty.activate
|
||||
def test_post_course_certificate(self):
|
||||
"""
|
||||
Ensure the correct API call gets made
|
||||
"""
|
||||
test_client = EdxRestApiClient('http://test-server', jwt='test-token')
|
||||
|
||||
httpretty.register_uri(
|
||||
httpretty.POST,
|
||||
'http://test-server/credentials/',
|
||||
)
|
||||
|
||||
visible_date = datetime.now()
|
||||
|
||||
tasks.post_course_certificate(test_client, self.student.username, self.certificate, visible_date)
|
||||
|
||||
expected_body = {
|
||||
'username': self.student.username,
|
||||
'status': 'awarded',
|
||||
'credential': {
|
||||
'course_run_key': str(self.certificate.course_id),
|
||||
'mode': self.certificate.mode,
|
||||
'type': tasks.COURSE_CERTIFICATE,
|
||||
},
|
||||
'attributes': [{
|
||||
'name': 'visible_date',
|
||||
'value': visible_date.strftime('%Y-%m-%dT%H:%M:%SZ') # text representation of date
|
||||
}]
|
||||
}
|
||||
self.assertEqual(json.loads(httpretty.last_request().body), expected_body)
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
@mock.patch(TASKS_MODULE + '.post_course_certificate')
|
||||
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
|
||||
class AwardCourseCertificatesTestCase(CredentialsApiConfigMixin, TestCase):
|
||||
"""
|
||||
Test the award_course_certificate celery task
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(AwardCourseCertificatesTestCase, self).setUp()
|
||||
|
||||
self.course = CourseOverviewFactory.create(
|
||||
self_paced=True # Any option to allow the certificate to be viewable for the course
|
||||
)
|
||||
self.student = UserFactory.create(username='test-student')
|
||||
# Instantiate the Certificate first so that the config doesn't execute issuance
|
||||
self.certificate = GeneratedCertificateFactory.create(
|
||||
user=self.student,
|
||||
mode='verified',
|
||||
course_id=self.course.id,
|
||||
status='downloadable'
|
||||
)
|
||||
|
||||
self.create_credentials_config()
|
||||
self.site = SiteFactory()
|
||||
|
||||
ClientFactory.create(name='credentials')
|
||||
UserFactory.create(username=settings.CREDENTIALS_SERVICE_USERNAME)
|
||||
|
||||
def test_award_course_certificates(self, mock_post_course_certificate):
|
||||
"""
|
||||
Tests the API POST method is called with appropriate params when configured properly
|
||||
"""
|
||||
tasks.award_course_certificate.delay(self.student.username, self.course.id).get()
|
||||
call_args, _ = mock_post_course_certificate.call_args
|
||||
self.assertEqual(call_args[1], self.student.username)
|
||||
self.assertEqual(call_args[2], self.certificate)
|
||||
|
||||
def test_award_course_cert_not_called_if_disabled(self, mock_post_course_certificate):
|
||||
"""
|
||||
Test that the post method is never called if the config is disabled
|
||||
"""
|
||||
self.create_credentials_config(enabled=False)
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.warning') as mock_warning:
|
||||
with self.assertRaises(MaxRetriesExceededError):
|
||||
tasks.award_course_certificate.delay(self.student.username, self.course.id).get()
|
||||
self.assertTrue(mock_warning.called)
|
||||
self.assertFalse(mock_post_course_certificate.called)
|
||||
|
||||
def test_award_course_cert_not_called_if_user_not_found(self, mock_post_course_certificate):
|
||||
"""
|
||||
Test that the post method is never called if the user isn't found by username
|
||||
"""
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
# Use a random username here since this user won't be found in the DB
|
||||
tasks.award_course_certificate.delay('random_username', self.course.id).get()
|
||||
self.assertTrue(mock_exception.called)
|
||||
self.assertFalse(mock_post_course_certificate.called)
|
||||
|
||||
def test_award_course_cert_not_called_if_certificate_not_found(self, mock_post_course_certificate):
|
||||
"""
|
||||
Test that the post method is never called if the certificate doesn't exist for the user and course
|
||||
"""
|
||||
self.certificate.delete()
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
tasks.award_course_certificate.delay(self.student.username, self.course.id).get()
|
||||
self.assertTrue(mock_exception.called)
|
||||
self.assertFalse(mock_post_course_certificate.called)
|
||||
|
||||
def test_award_course_cert_not_called_if_course_overview_not_found(self, mock_post_course_certificate):
|
||||
"""
|
||||
Test that the post method is never called if the CourseOverview isn't found
|
||||
"""
|
||||
self.course.delete()
|
||||
with mock.patch(TASKS_MODULE + '.LOGGER.exception') as mock_exception:
|
||||
# Use the certificate course id here since the course will be deleted
|
||||
tasks.award_course_certificate.delay(self.student.username, self.certificate.course_id).get()
|
||||
self.assertTrue(mock_exception.called)
|
||||
self.assertFalse(mock_post_course_certificate.called)
|
||||
|
||||
def test_award_course_cert_not_called_if_certificated_not_verified_mode(self, mock_post_course_certificate):
|
||||
"""
|
||||
Test that the post method is never called if the GeneratedCertificate is an 'audit' cert
|
||||
"""
|
||||
# Temporarily disable the config so the signal isn't handled from .save
|
||||
self.create_credentials_config(enabled=False)
|
||||
self.certificate.mode = 'audit'
|
||||
self.certificate.save()
|
||||
self.create_credentials_config()
|
||||
|
||||
tasks.award_course_certificate.delay(self.student.username, self.certificate.course_id).get()
|
||||
self.assertFalse(mock_post_course_certificate.called)
|
||||
|
||||
@@ -8,11 +8,12 @@ import mock
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED
|
||||
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED
|
||||
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded, handle_course_cert_changed
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
|
||||
TEST_USERNAME = 'test-user'
|
||||
TEST_COURSE_KEY = 'test-course'
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
@@ -37,7 +38,7 @@ class CertAwardedReceiverTest(TestCase):
|
||||
return dict(
|
||||
sender=self.__class__,
|
||||
user=UserFactory.create(username=TEST_USERNAME),
|
||||
course_key='test-course',
|
||||
course_key=TEST_COURSE_KEY,
|
||||
mode='test-mode',
|
||||
status='test-status',
|
||||
)
|
||||
@@ -75,3 +76,65 @@ class CertAwardedReceiverTest(TestCase):
|
||||
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
|
||||
self.assertEqual(mock_task.call_count, 1)
|
||||
self.assertEqual(mock_task.call_args[0], (TEST_USERNAME,))
|
||||
|
||||
|
||||
@attr(shard=2)
|
||||
# The credentials app isn't installed for the CMS.
|
||||
@skip_unless_lms
|
||||
@mock.patch('openedx.core.djangoapps.programs.tasks.v1.tasks.award_course_certificate.delay')
|
||||
@mock.patch(
|
||||
'openedx.core.djangoapps.credentials.models.CredentialsApiConfig.is_learner_issuance_enabled',
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value=False,
|
||||
)
|
||||
class CertChangedReceiverTest(TestCase):
|
||||
"""
|
||||
Tests for the `handle_course_cert_changed` signal handler function.
|
||||
"""
|
||||
|
||||
@property
|
||||
def signal_kwargs(self):
|
||||
"""
|
||||
DRY helper.
|
||||
"""
|
||||
return dict(
|
||||
sender=self.__class__,
|
||||
user=UserFactory.create(username=TEST_USERNAME),
|
||||
course_key='test-course',
|
||||
mode='test-mode',
|
||||
status='test-status',
|
||||
)
|
||||
|
||||
def test_signal_received(self, mock_is_learner_issuance_enabled, mock_task): # pylint: disable=unused-argument
|
||||
"""
|
||||
Ensures the receiver function is invoked when COURSE_CERT_CHANGED is
|
||||
sent.
|
||||
|
||||
Suboptimal: because we cannot mock the receiver function itself (due
|
||||
to the way django signals work), we mock a configuration call that is
|
||||
known to take place inside the function.
|
||||
"""
|
||||
COURSE_CERT_CHANGED.send(**self.signal_kwargs)
|
||||
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
|
||||
|
||||
def test_credentials_disabled(self, mock_is_learner_issuance_enabled, mock_task):
|
||||
"""
|
||||
Ensures that the receiver function does nothing when the credentials API
|
||||
configuration is not enabled.
|
||||
"""
|
||||
handle_course_cert_changed(**self.signal_kwargs)
|
||||
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
|
||||
self.assertEqual(mock_task.call_count, 0)
|
||||
|
||||
def test_credentials_enabled(self, mock_is_learner_issuance_enabled, mock_task):
|
||||
"""
|
||||
Ensures that the receiver function invokes the expected celery task
|
||||
when the credentials API configuration is enabled.
|
||||
"""
|
||||
mock_is_learner_issuance_enabled.return_value = True
|
||||
|
||||
handle_course_cert_changed(**self.signal_kwargs)
|
||||
|
||||
self.assertEqual(mock_is_learner_issuance_enabled.call_count, 1)
|
||||
self.assertEqual(mock_task.call_count, 1)
|
||||
self.assertEqual(mock_task.call_args[0], (TEST_USERNAME, TEST_COURSE_KEY))
|
||||
|
||||
@@ -10,6 +10,7 @@ COURSE_GRADE_CHANGED = Signal(providing_args=["user", "course_grade", "course_ke
|
||||
# Signal that fires when a user is awarded a certificate in a course (in the certificates django app)
|
||||
# TODO: runtime coupling between apps will be reduced if this event is changed to carry a username
|
||||
# rather than a User object; however, this will require changes to the milestones and badges APIs
|
||||
COURSE_CERT_CHANGED = Signal(providing_args=["user", "course_key", "mode", "status"])
|
||||
COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "status"])
|
||||
|
||||
# Signal that indicates that a user has passed a course.
|
||||
|
||||
Reference in New Issue
Block a user