MICROBA-923 Add method to generate V2 course certificates (#26898)

This commit is contained in:
Christie Rice
2021-03-09 11:02:19 -05:00
committed by GitHub
parent 05dbd83290
commit 4aff07ad82
6 changed files with 200 additions and 90 deletions

View File

@@ -33,11 +33,11 @@ WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='certificates_revamp')
# .. toggle_name: certificates_revamp.use_allowlist
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag enable the course certificates allowlist (aka V2 of the certificate whitelist) on
# a per-course run basis.
# .. toggle_description: Waffle flag to enable the course certificates allowlist (aka V2 of the certificate whitelist)
# on a per-course run basis.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-01-27
# .. toggle_target_removal_date: 2022-01-027
# .. toggle_target_removal_date: 2022-01-27
# .. toggle_tickets: MICROBA-918
CERTIFICATES_USE_ALLOWLIST = CourseWaffleFlag(
waffle_namespace=WAFFLE_FLAG_NAMESPACE,
@@ -46,17 +46,69 @@ CERTIFICATES_USE_ALLOWLIST = CourseWaffleFlag(
)
# .. toggle_name: certificates_revamp.use_updated
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
# .. toggle_description: Waffle flag to enable the updated regular (non-allowlist) course certificate logic on a
# per-course run basis.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2021-03-05
# .. toggle_target_removal_date: 2022-03-05
# .. toggle_tickets: MICROBA-923
CERTIFICATES_USE_UPDATED = CourseWaffleFlag(
waffle_namespace=WAFFLE_FLAG_NAMESPACE,
flag_name='use_updated',
module_name=__name__,
)
def can_generate_certificate_task(user, course_key):
"""
Determine if we can create a task to generate a certificate for this user in this course run.
This will return True if either:
- the course run is using the allowlist and the user is on the allowlist, or
- the course run is using v2 course certificates
"""
if is_using_certificate_allowlist_and_is_on_allowlist(user, course_key):
return True
elif _is_using_v2_course_certificates(course_key):
return True
return False
def generate_certificate_task(user, course_key):
"""
Create a task to generate a certificate for this user in this course run, if the user is eligible and a certificate
can be generated.
If the allowlist is enabled for this course run and the user is on the allowlist, the allowlist logic will be used.
Otherwise, the regular course certificate generation logic will be used.
"""
if is_using_certificate_allowlist_and_is_on_allowlist(user, course_key):
log.info(f'{course_key} is using allowlist certificates, and the user {user.id} is on its allowlist. Attempt '
f'will be made to generate an allowlist certificate.')
return generate_allowlist_certificate_task(user, course_key)
elif _is_using_v2_course_certificates(course_key):
log.info(f'{course_key} is using v2 course certificates. Attempt will be made to generate a certificate for '
f'user {user.id}.')
return generate_regular_certificate_task(user, course_key)
log.info(f'Neither an allowlist nor a v2 course certificate can be generated for {user.id} : {course_key}.')
return False
def generate_allowlist_certificate_task(user, course_key):
"""
Create a task to generate an allowlist certificate for this user in this course run.
"""
if not can_generate_allowlist_certificate(user, course_key):
log.info(
f'Cannot generate an allowlist certificate for {user.id} : {course_key}')
if not _can_generate_allowlist_certificate(user, course_key):
log.info(f'Cannot generate an allowlist certificate for {user.id} : {course_key}')
return False
log.info(
f'About to create an allowlist certificate task for {user.id} : {course_key}')
log.info(f'About to create an allowlist certificate task for {user.id} : {course_key}')
kwargs = {
'student': str(user.id),
@@ -67,17 +119,34 @@ def generate_allowlist_certificate_task(user, course_key):
return True
def can_generate_allowlist_certificate(user, course_key):
def generate_regular_certificate_task(user, course_key):
"""
Create a task to generate a regular (non-allowlist) certificate for this user in this course run, if the user is
eligible and a certificate can be generated.
"""
if not _can_generate_v2_certificate(user, course_key):
log.info(f'Cannot generate a v2 course certificate for {user.id} : {course_key}')
return False
log.info(f'About to create a v2 course certificate task for {user.id} : {course_key}')
kwargs = {
'student': str(user.id),
'course_key': str(course_key),
'v2_certificate': True
}
generate_certificate.apply_async(countdown=CERTIFICATE_DELAY_SECONDS, kwargs=kwargs)
return True
def _can_generate_allowlist_certificate(user, course_key):
"""
Check if an allowlist certificate can be generated (created if it doesn't already exist, or updated if it does
exist) for this user, in this course run.
"""
if not is_using_certificate_allowlist(course_key):
# This course run is not using the allowlist feature
log.info(
'{course} is not using the certificate allowlist. Certificate cannot be generated.'.format(
course=course_key
))
log.info(f'{course_key} is not using the certificate allowlist. Certificate cannot be generated.')
return False
if not auto_certificate_generation_enabled():
@@ -87,19 +156,12 @@ def can_generate_allowlist_certificate(user, course_key):
if CertificateInvalidation.has_certificate_invalidation(user, course_key):
# The invalidation list overrides the allowlist
log.info(
'{user} : {course} is on the certificate invalidation list. Certificate cannot be generated.'.format(
user=user.id,
course=course_key
))
log.info(f'{user.id} : {course_key} is on the certificate invalidation list. Certificate cannot be generated.')
return False
enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(user, course_key)
if enrollment_mode is None:
log.info('{user} : {course} does not have an enrollment. Certificate cannot be generated.'.format(
user=user.id,
course=course_key
))
log.info(f'{user.id} : {course_key} does not have an enrollment. Certificate cannot be generated.')
return False
if not IDVerificationService.user_is_verified(user):
@@ -107,20 +169,29 @@ def can_generate_allowlist_certificate(user, course_key):
return False
if not _is_on_certificate_allowlist(user, course_key):
log.info('{user} : {course} is not on the certificate allowlist. Certificate cannot be generated.'.format(
user=user.id,
course=course_key
))
log.info(f'{user.id} : {course_key} is not on the certificate allowlist. Certificate cannot be generated.')
return False
log.info('{user} : {course} is on the certificate allowlist'.format(
user=user.id,
course=course_key
))
log.info(f'{user.id} : {course_key} is on the certificate allowlist')
cert = GeneratedCertificate.certificate_for_student(user, course_key)
return _can_generate_allowlist_certificate_for_status(cert)
def _can_generate_v2_certificate(user, course_key):
"""
Check if a v2 course certificate can be generated (created if it doesn't already exist, or updated if it does
exist) for this user, in this course run.
"""
if not _is_using_v2_course_certificates(course_key):
# This course run is not using the v2 course certificate feature
log.info(f'{course_key} is not using v2 course certificates. Certificate cannot be generated.')
return False
# TODO: Further implementation will be added in MICROBA-923
log.warning(f'Ignoring check on V2 course certificates for {user.id}: {course_key}')
return False
def is_using_certificate_allowlist_and_is_on_allowlist(user, course_key):
"""
Return True if both:
@@ -137,9 +208,16 @@ def is_using_certificate_allowlist(course_key):
return CERTIFICATES_USE_ALLOWLIST.is_enabled(course_key)
def _is_using_v2_course_certificates(course_key):
"""
Return True if the course run is using v2 course certificates
"""
return CERTIFICATES_USE_UPDATED.is_enabled(course_key)
def _is_on_certificate_allowlist(user, course_key):
"""
Check if the user is on the allowlist for this course run
Check if the user is on the allowlist, and is enabled for the allowlist, for this course run
"""
return CertificateWhitelist.objects.filter(user=user, course_id=course_key, whitelist=True).exists()

View File

@@ -1,48 +1,5 @@
"""
Certificates are created for a student and an offering of a course.
When a certificate is generated, a unique ID is generated so that
the certificate can be verified later. The ID is a UUID4, so that
it can't be easily guessed and so that it is unique.
Certificates are generated in batches by a cron job, when a
certificate is available for download the GeneratedCertificate
table is updated with information that will be displayed
on the course overview page.
State diagram:
[deleted,error,unavailable] [error,downloadable]
+ + +
| | |
| | |
add_cert regen_cert del_cert
| | |
v v v
[generating] [regenerating] [deleting]
+ + +
| | |
certificate certificate certificate
created removed,created deleted
+----------------+-------------+------->[error]
| | |
| | |
v v v
[downloadable] [downloadable] [deleted]
Eligibility:
Students are eligible for a certificate if they pass the course
with the following exceptions:
If the student has allow_certificate set to False in the student profile
he will never be issued a certificate.
If the user and course is present in the certificate whitelist table
then the student will be issued a certificate regardless of his grade,
unless he has allow_certificate set to False.
Course certificates are created for a student and an offering of a course (a course run).
"""

View File

@@ -59,8 +59,7 @@ class XQueueCertInterface:
Instantiating an object will create a new
connection to the queue server.
See models.py for valid state transitions,
summary of methods:
Summary of methods:
add_cert: Add a new certificate. Puts a single
request on the queue for the student/course.

View File

@@ -13,7 +13,7 @@ from opaque_keys.edx.keys import CourseKey
from lms.djangoapps.certificates.generation import generate_allowlist_certificate, generate_user_certificates
from lms.djangoapps.verify_student.services import IDVerificationService
logger = getLogger(__name__)
log = getLogger(__name__)
User = get_user_model()
CERTIFICATE_DELAY_SECONDS = 2
@@ -35,22 +35,29 @@ def generate_certificate(self, **kwargs):
has not yet updated with the user's new verification status.
- allowlist_certificate: A flag indicating whether to generate an allowlist certificate (which is V2 of
whitelisted certificates)
- v2_certificate: A flag indicating whether to generate a v2 course certificate
"""
original_kwargs = kwargs.copy()
student = User.objects.get(id=kwargs.pop('student'))
course_key = CourseKey.from_string(kwargs.pop('course_key'))
expected_verification_status = kwargs.pop('expected_verification_status', None)
allowlist_certificate = kwargs.pop('allowlist_certificate', False)
v2_certificate = kwargs.pop('v2_certificate', False)
if allowlist_certificate:
generate_allowlist_certificate(user=student, course_key=course_key)
return
if v2_certificate:
# TODO: will be implemented in MICROBA-923
log.warning(f'Ignoring v2 certificate task request for {student.id}: {course_key}')
return
if expected_verification_status:
actual_verification_status = IDVerificationService.user_status(student)
actual_verification_status = actual_verification_status['status']
if expected_verification_status != actual_verification_status:
logger.warning(
log.warning(
'Expected verification status {expected} '
'differs from actual verification status {actual} '
'for user {user} in course {course}'.format(

View File

@@ -13,12 +13,17 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.certificates.generation_handler import CERTIFICATES_USE_ALLOWLIST
from lms.djangoapps.certificates.generation_handler import CERTIFICATES_USE_ALLOWLIST, CERTIFICATES_USE_UPDATED
from lms.djangoapps.certificates.generation_handler import (
_can_generate_allowlist_certificate_for_status,
is_using_certificate_allowlist,
can_generate_allowlist_certificate,
_is_using_v2_course_certificates,
_can_generate_allowlist_certificate,
_can_generate_v2_certificate,
can_generate_certificate_task,
generate_allowlist_certificate_task,
generate_certificate_task,
generate_regular_certificate_task,
is_using_certificate_allowlist_and_is_on_allowlist
)
from lms.djangoapps.certificates.models import GeneratedCertificate, CertificateStatuses
@@ -147,29 +152,38 @@ class AllowlistTests(ModuleStoreTestCase):
"""
Test handling of an invalid user/course run combo
"""
assert not can_generate_allowlist_certificate(self.user, self.course_run_key)
assert not _can_generate_allowlist_certificate(self.user, self.course_run_key)
assert not generate_allowlist_certificate_task(self.user, self.course_run_key)
assert not can_generate_certificate_task(self.user, self.course_run_key)
assert not generate_certificate_task(self.user, self.course_run_key)
def test_handle_valid(self):
"""
Test handling of a valid user/course run combo
"""
assert can_generate_allowlist_certificate(self.user, self.course_run_key)
assert _can_generate_allowlist_certificate(self.user, self.course_run_key)
assert generate_allowlist_certificate_task(self.user, self.course_run_key)
def test_handle_valid_general_methods(self):
"""
Test handling of a valid user/course run combo for the general (non-allowlist) generation methods
"""
assert can_generate_certificate_task(self.user, self.course_run_key)
assert generate_certificate_task(self.user, self.course_run_key)
def test_can_generate_auto_disabled(self):
"""
Test handling when automatic generation is disabled
"""
with override_waffle_switch(AUTO_GENERATION_SWITCH, active=False):
assert not can_generate_allowlist_certificate(self.user, self.course_run_key)
assert not _can_generate_allowlist_certificate(self.user, self.course_run_key)
def test_can_generate_not_verified(self):
"""
Test handling when the user's id is not verified
"""
with mock.patch(ID_VERIFIED_METHOD, return_value=False):
assert not can_generate_allowlist_certificate(self.user, self.course_run_key)
assert not _can_generate_allowlist_certificate(self.user, self.course_run_key)
def test_can_generate_not_enrolled(self):
"""
@@ -179,7 +193,7 @@ class AllowlistTests(ModuleStoreTestCase):
cr = CourseFactory()
key = cr.id # pylint: disable=no-member
CertificateWhitelistFactory.create(course_id=key, user=u)
assert not can_generate_allowlist_certificate(u, key)
assert not _can_generate_allowlist_certificate(u, key)
def test_can_generate_not_whitelisted(self):
"""
@@ -194,7 +208,7 @@ class AllowlistTests(ModuleStoreTestCase):
is_active=True,
mode="verified",
)
assert not can_generate_allowlist_certificate(u, key)
assert not _can_generate_allowlist_certificate(u, key)
def test_can_generate_invalidated(self):
"""
@@ -222,4 +236,61 @@ class AllowlistTests(ModuleStoreTestCase):
active=True
)
assert not can_generate_allowlist_certificate(u, key)
assert not _can_generate_allowlist_certificate(u, key)
@override_switch(AUTO_GENERATION_SWITCH_NAME, active=True)
@override_waffle_flag(CERTIFICATES_USE_UPDATED, active=True)
@mock.patch(ID_VERIFIED_METHOD, mock.Mock(return_value=True))
class CertificateTests(ModuleStoreTestCase):
"""
Tests for handling course certificates
"""
def setUp(self):
super().setUp()
# Create user, a course run, and an enrollment
self.user = UserFactory()
self.course_run = CourseFactory()
self.course_run_key = self.course_run.id # pylint: disable=no-member
self.enrollment = CourseEnrollmentFactory(
user=self.user,
course_id=self.course_run_key,
is_active=True,
mode="verified",
)
def test_handle_valid(self):
"""
Test handling of a valid user/course run combo.
Note: these assertions are placeholders for now. They will be updated as the implementation is added.
"""
assert not _can_generate_v2_certificate(self.user, self.course_run_key)
assert can_generate_certificate_task(self.user, self.course_run_key)
assert not generate_certificate_task(self.user, self.course_run_key)
assert not generate_regular_certificate_task(self.user, self.course_run_key)
@override_waffle_flag(CERTIFICATES_USE_UPDATED, active=False)
def test_handle_invalid(self):
"""
Test handling of an invalid user/course run combo
"""
assert not _can_generate_v2_certificate(self.user, self.course_run_key)
assert not can_generate_certificate_task(self.user, self.course_run_key)
assert not generate_certificate_task(self.user, self.course_run_key)
assert not generate_regular_certificate_task(self.user, self.course_run_key)
def test_is_using_updated_true(self):
"""
Test the updated flag
"""
assert _is_using_v2_course_certificates(self.course_run_key)
@override_waffle_flag(CERTIFICATES_USE_UPDATED, active=False)
def test_is_using_updated_false(self):
"""
Test the updated flag without the override
"""
assert not _is_using_v2_course_certificates(self.course_run_key)

View File

@@ -69,8 +69,6 @@ def update_certificate(request):
Will update GeneratedCertificate for a new certificate or
modify an existing certificate entry.
See models.py for a state diagram of certificate states
This view should only ever be accessed by the xqueue server
"""