diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 2657869ccc..08ba906556 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -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__, diff --git a/openedx/core/djangoapps/credentials/models.py b/openedx/core/djangoapps/credentials/models.py index bab4f94079..cad7b8a27f 100644 --- a/openedx/core/djangoapps/credentials/models.py +++ b/openedx/core/djangoapps/credentials/models.py @@ -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): """ diff --git a/openedx/core/djangoapps/credentials/tests/test_models.py b/openedx/core/djangoapps/credentials/tests/test_models.py index ed146585f7..143076c659 100644 --- a/openedx/core/djangoapps/credentials/tests/test_models.py +++ b/openedx/core/djangoapps/credentials/tests/test_models.py @@ -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) diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py index 59cf199b9c..68246ba5c2 100644 --- a/openedx/core/djangoapps/credentials/utils.py +++ b/openedx/core/djangoapps/credentials/utils.py @@ -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): diff --git a/openedx/core/djangoapps/programs/signals.py b/openedx/core/djangoapps/programs/signals.py index da63c9b9c2..25e3c4cf03 100644 --- a/openedx/core/djangoapps/programs/signals.py +++ b/openedx/core/djangoapps/programs/signals.py @@ -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) diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py index f8581f9852..67cee1283f 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -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) diff --git a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py index 52d433125e..728db00f2c 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tests/test_tasks.py @@ -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) diff --git a/openedx/core/djangoapps/programs/tests/test_signals.py b/openedx/core/djangoapps/programs/tests/test_signals.py index 2f8f79c814..d187e4b0e9 100644 --- a/openedx/core/djangoapps/programs/tests/test_signals.py +++ b/openedx/core/djangoapps/programs/tests/test_signals.py @@ -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)) diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index 3d1a11ce46..90b9fa08a6 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -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.