def test_get_completed_courses(self, mock_get_certs_for_user): """ Ensure the function correctly calls to and handles results from the certificates API """ student = UserFactory(username='******') mock_get_certs_for_user.return_value = [ self.make_cert_result(status='downloadable', type='verified', course_key='downloadable-course'), self.make_cert_result(status='generating', type='professional', course_key='generating-course'), self.make_cert_result(status='unknown', type='honor', course_key='unknown-course'), ] result = utils.get_completed_courses(student) self.assertEqual(mock_get_certs_for_user.call_args[0], (student.username, )) self.assertEqual(result, [ { 'course_id': 'downloadable-course', 'mode': 'verified' }, { 'course_id': 'generating-course', 'mode': 'professional' }, ])
def test_get_completed_courses(self, mock_get_certs_for_user): """ Ensure the function correctly calls to and handles results from the certificates API """ student = UserFactory(username='******') mock_get_certs_for_user.return_value = [ self.make_cert_result(status='downloadable', type='verified', course_key='downloadable-course'), self.make_cert_result(status='generating', type='professional', course_key='generating-course'), self.make_cert_result(status='unknown', type='honor', course_key='unknown-course'), ] result = utils.get_completed_courses(student) self.assertEqual(mock_get_certs_for_user.call_args[0], (student.username, )) self.assertEqual(result, [ {'course_id': 'downloadable-course', 'mode': 'verified'}, {'course_id': 'generating-course', 'mode': 'professional'}, ])
def test_get_completed_courses(self, mock_get_certs_for_user): """ Ensure the function correctly calls to and handles results from the certificates API """ student = UserFactory(username="******") mock_get_certs_for_user.return_value = [ self.make_cert_result(status="downloadable", type="verified", course_key="downloadable-course"), self.make_cert_result(status="generating", type="professional", course_key="generating-course"), self.make_cert_result(status="unknown", type="honor", course_key="unknown-course"), ] result = utils.get_completed_courses(student) self.assertEqual(mock_get_certs_for_user.call_args[0], (student.username,)) self.assertEqual( result, [ {"course_id": "downloadable-course", "mode": "verified"}, {"course_id": "generating-course", "mode": "professional"}, ], )
def award_program_certificates(self, username): """ This task is designed to be called whenever a student's completion status changes with respect to one or more courses (primarily, when a course certificate is awarded). It will consult with a variety of APIs to determine whether or not the specified user should be awarded a certificate in one or more programs, and use the credentials service to create said certificates if so. This task may also be invoked independently of any course completion status change - for example, to backpopulate missing program credentials for a student. Args: username: The username of the student Returns: None """ LOGGER.info('Running task award_program_certificates for username %s', username) config = ProgramsApiConfig.current() countdown = 2 ** self.request.retries # If either programs or credentials config models are 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 config.is_certification_enabled: LOGGER.warning( 'Task award_program_certificates cannot be executed when program certification is disabled in API config', ) raise self.retry(countdown=countdown, max_retries=config.max_retries) if not CredentialsApiConfig.current().is_learner_issuance_enabled: LOGGER.warning( 'Task award_program_certificates cannot be executed when credentials issuance is disabled in API config', ) raise self.retry(countdown=countdown, max_retries=config.max_retries) try: try: student = User.objects.get(username=username) except User.DoesNotExist: LOGGER.exception('Task award_program_certificates was called with invalid username %s', username) # Don't retry for this case - just conclude the task. return # Fetch the set of all course runs for which the user has earned a # certificate. course_certs = get_completed_courses(student) if not course_certs: # Highly unlikely, since at present the only trigger for this task # is the earning of a new course certificate. However, it could be # that the transaction in which a course certificate was awarded # was subsequently rolled back, which could lead to an empty result # here, so we'll at least log that this happened before exiting. # # If this task is ever updated to support revocation of program # certs, this branch should be removed, since it could make sense # in that case to call this task for a user without any (valid) # course certs. LOGGER.warning('Task award_program_certificates was called for user %s with no completed courses', username) return # Invoke the Programs API completion check endpoint to identify any # programs that are satisfied by these course completions. programs_client = get_api_client(config, student) program_ids = get_completed_programs(programs_client, course_certs) if not program_ids: # Again, no reason to continue beyond this point unless/until this # task gets updated to support revocation of program certs. return # Determine which program certificates the user has already been # awarded, if any. existing_program_ids = get_awarded_certificate_programs(student) except Exception as exc: # pylint: disable=broad-except LOGGER.exception('Failed to determine program certificates to be awarded for user %s', username) raise self.retry(exc=exc, countdown=countdown, max_retries=config.max_retries) # For each completed program for which the student doesn't already have a # certificate, award one now. # # This logic is important, because we will retry the whole task if awarding any particular program cert fails. # # N.B. the list is sorted to facilitate deterministic ordering, e.g. for tests. new_program_ids = sorted(list(set(program_ids) - set(existing_program_ids))) if new_program_ids: try: credentials_client = get_api_client( CredentialsApiConfig.current(), User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME) # pylint: disable=no-member ) except Exception as exc: # pylint: disable=broad-except LOGGER.exception('Failed to create a credentials API client to award program certificates') # Retry because a misconfiguration could be fixed raise self.retry(exc=exc, countdown=countdown, max_retries=config.max_retries) retry = False for program_id in new_program_ids: try: award_program_certificate(credentials_client, username, program_id) LOGGER.info('Awarded certificate for program %s to user %s', program_id, username) except Exception: # pylint: disable=broad-except # keep trying to award other certs, but retry the whole task to fix any missing entries LOGGER.exception('Failed to award certificate for program %s to user %s', program_id, username) retry = True if retry: # N.B. This logic assumes that this task is idempotent LOGGER.info('Retrying task to award failed certificates to user %s', username) raise self.retry(countdown=countdown, max_retries=config.max_retries) LOGGER.info('Successfully completed the task award_program_certificates for username %s', username)
def award_program_certificates(self, username): """ This task is designed to be called whenever a student's completion status changes with respect to one or more courses (primarily, when a course certificate is awarded). It will consult with a variety of APIs to determine whether or not the specified user should be awarded a certificate in one or more programs, and use the credentials service to create said certificates if so. This task may also be invoked independently of any course completion status change - for example, to backpopulate missing program credentials for a student. Args: username: The username of the student Returns: None """ LOGGER.info('Running task award_program_certificates for username %s', username) config = ProgramsApiConfig.current() countdown = 2**self.request.retries # If either programs or credentials config models are 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 config.is_certification_enabled: LOGGER.warning( 'Task award_program_certificates cannot be executed when program certification is disabled in API config', ) raise self.retry(countdown=countdown, max_retries=config.max_retries) if not CredentialsApiConfig.current().is_learner_issuance_enabled: LOGGER.warning( 'Task award_program_certificates cannot be executed when credentials issuance is disabled in API config', ) raise self.retry(countdown=countdown, max_retries=config.max_retries) try: try: student = User.objects.get(username=username) except User.DoesNotExist: LOGGER.exception( 'Task award_program_certificates was called with invalid username %s', username) # Don't retry for this case - just conclude the task. return # Fetch the set of all course runs for which the user has earned a # certificate. course_certs = get_completed_courses(student) if not course_certs: # Highly unlikely, since at present the only trigger for this task # is the earning of a new course certificate. However, it could be # that the transaction in which a course certificate was awarded # was subsequently rolled back, which could lead to an empty result # here, so we'll at least log that this happened before exiting. # # If this task is ever updated to support revocation of program # certs, this branch should be removed, since it could make sense # in that case to call this task for a user without any (valid) # course certs. LOGGER.warning( 'Task award_program_certificates was called for user %s with no completed courses', username) return # Invoke the Programs API completion check endpoint to identify any # programs that are satisfied by these course completions. programs_client = get_api_client(config, student) program_ids = get_completed_programs(programs_client, course_certs) if not program_ids: # Again, no reason to continue beyond this point unless/until this # task gets updated to support revocation of program certs. LOGGER.info( 'Task award_program_certificates was called for user %s with no completed programs', username) return # Determine which program certificates the user has already been # awarded, if any. existing_program_ids = get_awarded_certificate_programs(student) except Exception as exc: # pylint: disable=broad-except LOGGER.exception( 'Failed to determine program certificates to be awarded for user %s', username) raise self.retry(exc=exc, countdown=countdown, max_retries=config.max_retries) # For each completed program for which the student doesn't already have a # certificate, award one now. # # This logic is important, because we will retry the whole task if awarding any particular program cert fails. # # N.B. the list is sorted to facilitate deterministic ordering, e.g. for tests. new_program_ids = sorted( list(set(program_ids) - set(existing_program_ids))) if new_program_ids: try: credentials_client = get_api_client( CredentialsApiConfig.current(), User.objects.get( username=settings.CREDENTIALS_SERVICE_USERNAME) # pylint: disable=no-member ) except Exception as exc: # pylint: disable=broad-except LOGGER.exception( 'Failed to create a credentials API client to award program certificates' ) # Retry because a misconfiguration could be fixed raise self.retry(exc=exc, countdown=countdown, max_retries=config.max_retries) retry = False for program_id in new_program_ids: try: award_program_certificate(credentials_client, username, program_id) LOGGER.info('Awarded certificate for program %s to user %s', program_id, username) except Exception: # pylint: disable=broad-except # keep trying to award other certs, but retry the whole task to fix any missing entries LOGGER.exception( 'Failed to award certificate for program %s to user %s', program_id, username) retry = True if retry: # N.B. This logic assumes that this task is idempotent LOGGER.info( 'Retrying task to award failed certificates to user %s', username) raise self.retry(countdown=countdown, max_retries=config.max_retries) else: 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)