diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 9a762336bf..8ebdb94c4d 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -74,7 +74,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, COURSE_CERT_CHANGED +from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled @@ -369,8 +369,14 @@ class GeneratedCertificate(models.Model): self.download_url = '' self.grade = '' self.status = CertificateStatuses.unavailable - self.save() + COURSE_CERT_REVOKED.send_robust( + sender=self.__class__, + user=self.user, + course_key=self.course_id, + mode=self.mode, + status=self.status, + ) def mark_notpassing(self, grade): """ diff --git a/openedx/core/djangoapps/programs/signals.py b/openedx/core/djangoapps/programs/signals.py index aea84fec29..2b01d01916 100644 --- a/openedx/core/djangoapps/programs/signals.py +++ b/openedx/core/djangoapps/programs/signals.py @@ -7,7 +7,7 @@ import logging from django.dispatch import receiver -from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED +from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED from openedx.core.djangoapps.site_configuration import helpers LOGGER = logging.getLogger(__name__) @@ -131,3 +131,46 @@ def handle_course_cert_changed(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_course_certificate award_course_certificate.delay(user.username, str(course_key)) + + +@receiver(COURSE_CERT_REVOKED) +def handle_course_cert_revoked(sender, user, course_key, mode, status, **kwargs): + """ + If programs is enabled and a learner's course certificate is revoked, + schedule a celery task to revoke any related program certificates. + + 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: + revoked + + 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( + u'handling COURSE_CERT_REVOKED: 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 revoke_program_certificates + revoke_program_certificates.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 6b12824d9f..6560c614cc 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -52,6 +52,22 @@ def get_completed_programs(site, student): return meter.completed_programs_with_available_dates +def get_inverted_programs(site, student): + """ + Given a set of completed courses, determine which programs are completed. + + Args: + site (Site): Site for which data should be retrieved. + student (User): Representing the student whose completed programs to check for. + + Returns: + dict of {program_UUIDs: visible_dates} + + """ + meter = ProgramProgressMeter(site, student) + return meter.invert_programs() + + def get_certified_programs(student): """ Find the UUIDs of all the programs for which the student has already been awarded @@ -331,3 +347,155 @@ def award_course_certificate(self, username, course_run_key): except Exception as exc: LOGGER.exception(u'Failed to determine course certificates to be awarded for user %s', username) raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES) + + +def revoke_program_certificate(client, username, program_uuid): + """ + Revoke a certificate of the given student for the given program. + + Args: + client: credentials API client (EdxRestApiClient) + username: The username of the student + program_uuid: uuid of the program + + Returns: + None + + """ + client.credentials.post({ + 'username': username, + 'status': 'revoked', + 'credential': { + 'type': PROGRAM_CERTIFICATE, + 'program_uuid': program_uuid + } + }) + + +@task(bind=True, ignore_result=True, routing_key=PROGRAM_CERTIFICATES_ROUTING_KEY) +def revoke_program_certificates(self, username, course_key): + """ + This task is designed to be called whenever a student's course certificate is + revoked. + + It will consult with a variety of APIs to determine whether or not the + specified user's certificate should be revoked in one or more programs, and + use the credentials service to revoke the said certificates if so. + + Args: + username (str): The username of the student + course_key (str|CourseKey): The course identifier + + Returns: + None + + """ + 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 revoke_program_certificates cannot be executed when credentials issuance is disabled in API config', + ) + raise self.retry(countdown=countdown, max_retries=MAX_RETRIES) + + try: + try: + student = User.objects.get(username=username) + except User.DoesNotExist: + LOGGER.exception(u'Task revoke_program_certificates was called with invalid username %s', username) + # Don't retry for this case - just conclude the task. + return + inverted_programs = {} + for site in Site.objects.all(): + inverted_programs.update(get_inverted_programs(site, student)) + course_specific_programs = inverted_programs.get(str(course_key)) + import pdb; pdb.set_trace() + if not course_specific_programs: + # No reason to continue beyond this point + LOGGER.info( + u'Task revoke_program_certificates was called for user %s and course %s with no engaged programs', + username, + course_key + ) + return + + # Determine which program certificates the user has already been awarded, if any. + existing_program_uuids = get_certified_programs(student) + program_uuids_to_revoke = [] + for program in course_specific_programs: + if program['uuid'] in existing_program_uuids: + program_uuids_to_revoke.append(program['uuid']) + except Exception as exc: + LOGGER.exception( + u'Failed to determine program certificates to be revoked for user %s with course %s', + username, + course_key + ) + raise self.retry(exc=exc, countdown=countdown, max_retries=MAX_RETRIES) + + if program_uuids_to_revoke: + try: + credentials_client = get_credentials_api_client( + User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME), + ) + except Exception as exc: + 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=MAX_RETRIES) + + failed_program_certificate_revoke_attempts = [] + for program_uuid in program_uuids_to_revoke: + try: + revoke_program_certificate(credentials_client, username, program_uuid) + LOGGER.info(u'Revoked certificate for program %s for user %s', program_uuid, username) + except exceptions.HttpNotFoundError: + LOGGER.exception( + u"""Certificate for program {uuid} could not be found. Unable to revoke certificate for user + {username}.""".format(uuid=program_uuid, username=username) + ) + except exceptions.HttpClientError as exc: + # Grab the status code from the client error, because our API + # client handles all 4XX errors the same way. In the future, + # we may want to fork slumber, add 429 handling, and use that + # in edx_rest_api_client. + if exc.response.status_code == 429: # pylint: disable=no-member + rate_limit_countdown = 60 + LOGGER.info( + u"""Rate limited. Retrying task to revoke certificates for user {username} in {countdown} + seconds""".format(username=username, countdown=rate_limit_countdown) + ) + # Retry after 60 seconds, when we should be in a new throttling window + raise self.retry(exc=exc, countdown=rate_limit_countdown, max_retries=MAX_RETRIES) + else: + LOGGER.exception( + u"Unable to revoke certificate for user {username} for program {uuid}.".format( + username=username, uuid=program_uuid + ) + ) + except Exception: # pylint: disable=broad-except + # keep trying to revoke other certs, but retry the whole task to fix any missing entries + LOGGER.warning(u'Failed to revoke certificate for program {uuid} of user {username}.'.format( + uuid=program_uuid, username=username)) + failed_program_certificate_revoke_attempts.append(program_uuid) + + if failed_program_certificate_revoke_attempts: + # N.B. This logic assumes that this task is idempotent + LOGGER.info(u'Retrying task to revoke failed certificates to user %s', username) + # The error message may change on each reattempt but will never be raised until + # the max number of retries have been exceeded. It is unlikely that this list + # will change by the time it reaches its maximimum number of attempts. + exception = MaxRetriesExceededError( + u"Failed to revoke certificate for user {} for programs {}".format( + username, failed_program_certificate_revoke_attempts)) + raise self.retry( + exc=exception, + countdown=countdown, + max_retries=MAX_RETRIES) + else: + LOGGER.info(u'There is no program certificates for user %s to revoke', username) + + LOGGER.info(u'Successfully completed the task revoke_program_certificates for username %s', username) diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index 06ef324aaf..fd4d1765c3 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -13,6 +13,7 @@ COURSE_GRADE_CHANGED = Signal(providing_args=["user", "course_grade", "course_ke # 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"]) +COURSE_CERT_REVOKED = Signal(providing_args=["user", "course_key", "mode", "status"]) # Signal that indicates that a user has passed a course. COURSE_GRADE_NOW_PASSED = Signal(