Skip to content
Snippets Groups Projects
Commit 6a7dc067 authored by Waheed Ahmed's avatar Waheed Ahmed
Browse files

Add revoke program certificate task.

Upon invalidating course certificate, revoke related program certificates
as well.

PROD-1271
parent 196dcf7f
No related branches found
No related tags found
No related merge requests found
......@@ -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(
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment