Merge pull request #27723 from edx/jhynes/microba-1075_events
feat: add `edx.certificate.revoked` event
This commit is contained in:
@@ -647,7 +647,7 @@ def remove_allowlist_entry(user, course_key):
|
||||
certificate = get_certificate_for_user(user.username, course_key, False)
|
||||
if certificate:
|
||||
log.info(f"Invalidating certificate for student {user.id} in course {course_key} before allowlist removal.")
|
||||
certificate.invalidate()
|
||||
certificate.invalidate(source='allowlist_removal')
|
||||
|
||||
log.info(f"Removing student {user.id} from the allowlist in course {course_key}.")
|
||||
allowlist_entry.delete()
|
||||
|
||||
@@ -258,7 +258,7 @@ def _set_v2_cert_status(user, course_key):
|
||||
if cert is None:
|
||||
cert = GeneratedCertificate.objects.create(user=user, course_id=course_key)
|
||||
if cert.status != CertificateStatuses.notpassing:
|
||||
cert.mark_notpassing(course_grade.percent)
|
||||
cert.mark_notpassing(course_grade.percent, source='certificate_generation')
|
||||
return CertificateStatuses.notpassing
|
||||
|
||||
return None
|
||||
@@ -275,14 +275,14 @@ def _get_cert_status_common(user, course_key, cert):
|
||||
if cert is None:
|
||||
cert = GeneratedCertificate.objects.create(user=user, course_id=course_key)
|
||||
if cert.status != CertificateStatuses.unavailable:
|
||||
cert.invalidate()
|
||||
cert.invalidate(source='certificate_generation')
|
||||
return CertificateStatuses.unavailable
|
||||
|
||||
if not IDVerificationService.user_is_verified(user):
|
||||
if cert is None:
|
||||
cert = GeneratedCertificate.objects.create(user=user, course_id=course_key)
|
||||
if cert.status != CertificateStatuses.unverified:
|
||||
cert.mark_unverified()
|
||||
cert.mark_unverified(source='certificate_generation')
|
||||
return CertificateStatuses.unverified
|
||||
|
||||
return None
|
||||
|
||||
@@ -358,24 +358,66 @@ class GeneratedCertificate(models.Model):
|
||||
user=self.user
|
||||
)
|
||||
|
||||
def invalidate(self):
|
||||
def invalidate(self, source=None):
|
||||
"""
|
||||
Invalidate Generated Certificate by marking it 'unavailable'. This will prevent the learner from being able to
|
||||
access their certificate in the associated Course. In addition, we remove any errors and grade information
|
||||
associated with the certificate record.
|
||||
Invalidate Generated Certificate by marking it 'unavailable'. For additional information see the
|
||||
`_revoke_certificate()` function.
|
||||
|
||||
Args:
|
||||
source (String) - source requesting invalidation of the certificate for tracking purposes
|
||||
"""
|
||||
log.info(f'Marking certificate as unavailable for {self.user.id} : {self.course_id}')
|
||||
self._revoke_certificate(CertificateStatuses.unavailable, source=source)
|
||||
|
||||
def mark_notpassing(self, grade, source=None):
|
||||
"""
|
||||
Invalidates a Generated Certificate by marking it as 'notpassing'. For additional information see the
|
||||
`_revoke_certificate()` function.
|
||||
|
||||
Args:
|
||||
grade (float) - snapshot of the learner's current grade as a decimal
|
||||
source (String) - source requesting invalidation of the certificate for tracking purposes
|
||||
"""
|
||||
log.info(f'Marking certificate as notpassing for {self.user.id} : {self.course_id}')
|
||||
self._revoke_certificate(CertificateStatuses.notpassing, grade=grade, source=source)
|
||||
|
||||
def mark_unverified(self, source=None):
|
||||
"""
|
||||
Invalidates a Generated Certificate by marking it as 'unverified'. For additional information see the
|
||||
`_revoke_certificate()` function.
|
||||
|
||||
Args:
|
||||
source (String) - source requesting invalidation of the certificate for tracking purposes
|
||||
"""
|
||||
log.info(f'Marking certificate as unverified for {self.user.id} : {self.course_id}')
|
||||
self._revoke_certificate(CertificateStatuses.unverified, source=source)
|
||||
|
||||
def _revoke_certificate(self, status, grade=None, source=None):
|
||||
"""
|
||||
Revokes a course certificate from a learner, updating the certificate's status as specified by the value of the
|
||||
`status` argument. This will prevent the learner from being able to access their certiticate in the associated
|
||||
course run.
|
||||
|
||||
We remove the `download_uuid` and the `download_url` as well, but this is only important to PDF certificates.
|
||||
|
||||
Invalidating a certificate fires the `COURSE_CERT_REVOKED` signal. This kicks off a task to determine if there
|
||||
are any program certificates that need to be revoked from the learner.
|
||||
are any program certificates that also need to be revoked from the learner.
|
||||
|
||||
If the certificate had a status of `downloadable` before being revoked then we will also emit an
|
||||
`edx.certificate.revoked` event for tracking purposes.
|
||||
|
||||
Args:
|
||||
status (CertificateStatus) - certificate status to set for the `GeneratedCertificate` record
|
||||
grade (float) - snapshot of the learner's current grade as a decimal
|
||||
source (String) - source requesting invalidation of the certificate for tracking purposes
|
||||
"""
|
||||
log.info(f'Marking certificate as unavailable for {self.user.id} : {self.course_id}')
|
||||
previous_certificate_status = self.status
|
||||
|
||||
self.error_reason = ''
|
||||
self.download_uuid = ''
|
||||
self.download_url = ''
|
||||
self.grade = ''
|
||||
self.status = CertificateStatuses.unavailable
|
||||
self.grade = grade or ''
|
||||
self.status = status
|
||||
self.save()
|
||||
|
||||
COURSE_CERT_REVOKED.send_robust(
|
||||
@@ -386,49 +428,18 @@ class GeneratedCertificate(models.Model):
|
||||
status=self.status,
|
||||
)
|
||||
|
||||
def mark_notpassing(self, grade):
|
||||
"""
|
||||
Invalidates a Generated Certificate by marking it as 'notpassing'. For additional information, please see the
|
||||
comments of the `invalidate` function above as they also apply here.
|
||||
"""
|
||||
log.info(f'Marking certificate as notpassing for {self.user.id} : {self.course_id}')
|
||||
if previous_certificate_status == CertificateStatuses.downloadable:
|
||||
# imported here to avoid a circular import issue
|
||||
from lms.djangoapps.certificates.utils import emit_certificate_event
|
||||
|
||||
self.error_reason = ''
|
||||
self.download_uuid = ''
|
||||
self.download_url = ''
|
||||
self.grade = grade
|
||||
self.status = CertificateStatuses.notpassing
|
||||
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_unverified(self):
|
||||
"""
|
||||
Invalidates a Generated Certificate by marking it as 'unverified'. For additional information, please see the
|
||||
comments of the `invalidate` function above as they also apply here.
|
||||
"""
|
||||
log.info(f'Marking certificate as unverified for {self.user.id} : {self.course_id}')
|
||||
|
||||
self.error_reason = ''
|
||||
self.download_uuid = ''
|
||||
self.download_url = ''
|
||||
self.grade = ''
|
||||
self.status = CertificateStatuses.unverified
|
||||
self.save()
|
||||
|
||||
COURSE_CERT_REVOKED.send_robust(
|
||||
sender=self.__class__,
|
||||
user=self.user,
|
||||
course_key=self.course_id,
|
||||
mode=self.mode,
|
||||
status=self.status,
|
||||
)
|
||||
event_data = {
|
||||
'user_id': self.user.id,
|
||||
'course_id': str(self.course_id),
|
||||
'certificate_id': self.verify_uuid,
|
||||
'enrollment_mode': self.mode,
|
||||
'source': source or '',
|
||||
}
|
||||
emit_certificate_event('revoked', self.user, str(self.course_id), event_data=event_data)
|
||||
|
||||
def is_valid(self):
|
||||
"""
|
||||
|
||||
@@ -140,7 +140,7 @@ class XQueueCertInterface:
|
||||
)
|
||||
return None
|
||||
|
||||
certificate.invalidate()
|
||||
certificate.invalidate(source='certificate_regeneration')
|
||||
|
||||
LOGGER.info(
|
||||
f"The certificate status for student {student.id} in course '{course_id} has been changed to "
|
||||
|
||||
@@ -35,7 +35,7 @@ class CertificateService:
|
||||
user=user_id,
|
||||
course_id=course_key
|
||||
)
|
||||
generated_certificate.invalidate()
|
||||
generated_certificate.invalidate(source='certificate_service')
|
||||
except ObjectDoesNotExist:
|
||||
log.warning(
|
||||
'Invalidation failed because a certificate for user %d in course %s does not exist.',
|
||||
|
||||
@@ -114,7 +114,7 @@ def _listen_for_failing_grade(sender, user, course_id, grade, **kwargs): # pyli
|
||||
cert = GeneratedCertificate.certificate_for_student(user, course_id)
|
||||
if cert is not None:
|
||||
if CertificateStatuses.is_passing_status(cert.status):
|
||||
cert.mark_notpassing(grade.percent)
|
||||
cert.mark_notpassing(grade.percent, source='notpassing_signal')
|
||||
log.info('Certificate marked not passing for {user} : {course} via failing grade: {grade}'.format(
|
||||
user=user.id,
|
||||
course=course_id,
|
||||
|
||||
@@ -67,6 +67,7 @@ from lms.djangoapps.certificates.tests.factories import (
|
||||
CertificateInvalidationFactory
|
||||
)
|
||||
from lms.djangoapps.grades.tests.utils import mock_passing_grade
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
|
||||
|
||||
FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
|
||||
@@ -253,6 +254,9 @@ class CertificateIsInvalid(WebCertificateTestMixin, ModuleStoreTestCase):
|
||||
number='verified',
|
||||
display_name='Verified Course'
|
||||
)
|
||||
self.course_overview = CourseOverviewFactory.create(
|
||||
id=self.course.id
|
||||
)
|
||||
self.global_staff = GlobalStaffFactory()
|
||||
self.request_factory = RequestFactory()
|
||||
|
||||
|
||||
@@ -301,6 +301,9 @@ class CertificateInvalidationTest(SharedModuleStoreTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.course = CourseFactory()
|
||||
self.course_overview = CourseOverviewFactory.create(
|
||||
id=self.course.id
|
||||
)
|
||||
self.user = UserFactory()
|
||||
self.course_id = self.course.id # pylint: disable=no-member
|
||||
self.certificate = GeneratedCertificateFactory.create(
|
||||
@@ -358,7 +361,18 @@ class GeneratedCertificateTest(SharedModuleStoreTestCase):
|
||||
self.course = CourseOverviewFactory()
|
||||
self.course_key = self.course.id
|
||||
|
||||
def test_invalidate(self):
|
||||
def _assert_event_data(self, mocked_function_call, expected_event_data):
|
||||
"""Utility function that verifies the mocked function was called with the expected arguments."""
|
||||
|
||||
mocked_function_call.assert_called_with(
|
||||
'revoked',
|
||||
self.user,
|
||||
str(self.course_key),
|
||||
event_data=expected_event_data
|
||||
)
|
||||
|
||||
@patch('lms.djangoapps.certificates.utils.emit_certificate_event')
|
||||
def test_invalidate(self, mock_emit_certificate_event):
|
||||
"""
|
||||
Test the invalidate method
|
||||
"""
|
||||
@@ -367,12 +381,24 @@ class GeneratedCertificateTest(SharedModuleStoreTestCase):
|
||||
user=self.user,
|
||||
course_id=self.course_key
|
||||
)
|
||||
cert.invalidate()
|
||||
source = 'invalidated_test'
|
||||
cert.invalidate(source=source)
|
||||
|
||||
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
|
||||
assert cert.status == CertificateStatuses.unavailable
|
||||
|
||||
def test_notpassing(self):
|
||||
expected_event_data = {
|
||||
'user_id': self.user.id,
|
||||
'course_id': str(self.course_key),
|
||||
'certificate_id': cert.verify_uuid,
|
||||
'enrollment_mode': cert.mode,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
self._assert_event_data(mock_emit_certificate_event, expected_event_data)
|
||||
|
||||
@patch('lms.djangoapps.certificates.utils.emit_certificate_event')
|
||||
def test_notpassing(self, mock_emit_certificate_event):
|
||||
"""
|
||||
Test the notpassing method
|
||||
"""
|
||||
@@ -382,13 +408,25 @@ class GeneratedCertificateTest(SharedModuleStoreTestCase):
|
||||
course_id=self.course_key
|
||||
)
|
||||
grade = '.3'
|
||||
cert.mark_notpassing(grade)
|
||||
source = "notpassing_test"
|
||||
cert.mark_notpassing(grade, source=source)
|
||||
|
||||
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
|
||||
assert cert.status == CertificateStatuses.notpassing
|
||||
assert cert.grade == grade
|
||||
|
||||
def test_unverified(self):
|
||||
expected_event_data = {
|
||||
'user_id': self.user.id,
|
||||
'course_id': str(self.course_key),
|
||||
'certificate_id': cert.verify_uuid,
|
||||
'enrollment_mode': cert.mode,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
self._assert_event_data(mock_emit_certificate_event, expected_event_data)
|
||||
|
||||
@patch('lms.djangoapps.certificates.utils.emit_certificate_event')
|
||||
def test_unverified(self, mock_emit_certificate_event):
|
||||
"""
|
||||
Test the unverified method
|
||||
"""
|
||||
@@ -397,7 +435,18 @@ class GeneratedCertificateTest(SharedModuleStoreTestCase):
|
||||
user=self.user,
|
||||
course_id=self.course_key
|
||||
)
|
||||
cert.mark_unverified()
|
||||
source = "unverified_test"
|
||||
cert.mark_unverified(source=source)
|
||||
|
||||
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
|
||||
assert cert.status == CertificateStatuses.unverified
|
||||
|
||||
expected_event_data = {
|
||||
'user_id': self.user.id,
|
||||
'course_id': str(self.course_key),
|
||||
'certificate_id': cert.verify_uuid,
|
||||
'enrollment_mode': cert.mode,
|
||||
'source': source,
|
||||
}
|
||||
|
||||
self._assert_event_data(mock_emit_certificate_event, expected_event_data)
|
||||
|
||||
@@ -7,6 +7,7 @@ from common.djangoapps.student.tests.factories import UserFactory
|
||||
from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate
|
||||
from lms.djangoapps.certificates.services import CertificateService
|
||||
from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
@@ -20,6 +21,9 @@ class CertificateServiceTests(ModuleStoreTestCase):
|
||||
super().setUp()
|
||||
self.service = CertificateService()
|
||||
self.course = CourseFactory()
|
||||
self.course_overview = CourseOverviewFactory.create(
|
||||
id=self.course.id
|
||||
)
|
||||
self.user = UserFactory()
|
||||
self.user_id = self.user.id
|
||||
self.course_id = self.course.id # pylint: disable=no-member
|
||||
|
||||
@@ -3332,7 +3332,7 @@ def invalidate_certificate(request, generated_certificate, certificate_invalidat
|
||||
)
|
||||
|
||||
# Invalidate the certificate
|
||||
generated_certificate.invalidate()
|
||||
generated_certificate.invalidate(source='certificate_invalidation_list')
|
||||
|
||||
return {
|
||||
'id': certificate_invalidation.id,
|
||||
|
||||
@@ -157,4 +157,4 @@ def _invalidate_generated_certificates(course_id, enrolled_students, certificate
|
||||
f'for course {course_id}')
|
||||
else:
|
||||
log.info(f'About to invalidate certificate for user {c.user.id} in course {course_id}')
|
||||
c.invalidate()
|
||||
c.invalidate(source='bulk_certificate_regeneration')
|
||||
|
||||
Reference in New Issue
Block a user