Add revoke program certificate task.
Upon invalidating course certificate, revoke related program certificates as well. PROD-1271
This commit is contained in:
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user