feat: Generate more certificate status for V2 course certs (#27326)

MICROBA-1106
This commit is contained in:
Christie Rice
2021-04-27 09:40:06 -04:00
committed by GitHub
parent 7a3b53afec
commit 6e1f700cdc
7 changed files with 348 additions and 16 deletions

View File

@@ -10,10 +10,8 @@ These methods should be called from tasks.
"""
import logging
import random # lint-amnesty, pylint: disable=unused-import
from uuid import uuid4
from capa.xqueue_interface import make_hashkey # lint-amnesty, pylint: disable=unused-import
from common.djangoapps.student.models import CourseEnrollment, UserProfile
from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate
from lms.djangoapps.certificates.queue import XQueueCertInterface

View File

@@ -106,11 +106,14 @@ def generate_allowlist_certificate_task(user, course_key, generation_mode=None):
"""
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}')
return False
if _can_generate_allowlist_certificate(user, course_key):
return _generate_certificate_task(user, course_key, generation_mode)
return _generate_certificate_task(user, course_key, generation_mode)
status = _set_allowlist_cert_status(user, course_key)
if status is not None:
return True
return False
def generate_regular_certificate_task(user, course_key, generation_mode=None):
@@ -118,11 +121,14 @@ def generate_regular_certificate_task(user, course_key, generation_mode=None):
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
if _can_generate_v2_certificate(user, course_key):
return _generate_certificate_task(user, course_key, generation_mode)
return _generate_certificate_task(user, course_key, generation_mode)
status = _set_v2_cert_status(user, course_key)
if status is not None:
return True
return False
def _generate_certificate_task(user, course_key, generation_mode=None):
@@ -239,6 +245,122 @@ def _can_generate_certificate_common(user, course_key):
return True
def _set_allowlist_cert_status(user, course_key):
"""
Determine the allowlist certificate status for this user, in this course run and update the cert.
This is used when a downloadable cert cannot be generated, but we want to provide more info about why it cannot
be generated.
"""
if not _can_set_allowlist_cert_status(user, course_key):
return None
cert = GeneratedCertificate.certificate_for_student(user, course_key)
return _get_cert_status_common(user, course_key, cert)
def _set_v2_cert_status(user, course_key):
"""
Determine the V2 certificate status for this user, in this course run.
This is used when a downloadable cert cannot be generated, but we want to provide more info about why it cannot
be generated.
"""
if not _can_set_v2_cert_status(user, course_key):
return None
cert = GeneratedCertificate.certificate_for_student(user, course_key)
status = _get_cert_status_common(user, course_key, cert)
if status is not None:
return status
course = _get_course(course_key)
course_grade = _get_course_grade(user, course)
if not course_grade.passed:
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)
return CertificateStatuses.notpassing
return None
def _get_cert_status_common(user, course_key, cert):
"""
Determine the certificate status for this user, in this course run.
This is used when a downloadable cert cannot be generated, but we want to provide more info about why it cannot
be generated.
"""
if CertificateInvalidation.has_certificate_invalidation(user, course_key):
if cert is None:
cert = GeneratedCertificate.objects.create(user=user, course_id=course_key)
if cert.status != CertificateStatuses.unavailable:
cert.invalidate()
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()
return CertificateStatuses.unverified
return None
def _can_set_allowlist_cert_status(user, course_key):
"""
Determine whether we can set a custom (non-downloadable) cert status for an allowlist certificate
"""
if not is_using_certificate_allowlist(course_key):
return False
if not is_on_certificate_allowlist(user, course_key):
return False
course = _get_course(course_key)
return _can_set_cert_status_common(user, course_key, course)
def _can_set_v2_cert_status(user, course_key):
"""
Determine whether we can set a custom (non-downloadable) cert status for a V2 certificate
"""
if not is_using_v2_course_certificates(course_key):
return False
if _is_ccx_course(course_key):
return False
course = _get_course(course_key)
if _is_beta_tester(user, course):
return False
return _can_set_cert_status_common(user, course_key, course)
def _can_set_cert_status_common(user, course_key, course):
"""
Determine whether we can set a custom (non-downloadable) cert status
"""
if _is_cert_downloadable(user, course_key):
return False
enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(user, course_key)
if enrollment_mode is None:
return False
if not modes_api.is_eligible_for_certificate(enrollment_mode):
return False
if not has_html_certificates_enabled(course):
return False
return True
def is_using_certificate_allowlist_and_is_on_allowlist(user, course_key):
"""
Return True if both:
@@ -306,10 +428,32 @@ def _has_passing_grade(user, course):
"""
Check if the user has a passing grade in this course run
"""
course_grade = CourseGradeFactory().read(user, course)
course_grade = _get_course_grade(user, course)
return course_grade.passed
def _get_course_grade(user, course):
"""
Get the user's course grade in this course run
"""
return CourseGradeFactory().read(user, course)
def _is_cert_downloadable(user, course_key):
"""
Check if cert already exists, has a downloadable status, and has not been invalidated
"""
cert = GeneratedCertificate.certificate_for_student(user, course_key)
if cert is None:
return False
if cert.status != CertificateStatuses.downloadable:
return False
if CertificateInvalidation.has_certificate_invalidation(user, course_key):
return False
return True
def _get_course(course_key):
"""
Get the course from the course key

View File

@@ -6,7 +6,6 @@ from unittest import mock
import pytest
from django.core.management import CommandError, call_command
from waffle.testutils import override_switch # lint-amnesty, pylint: disable=unused-import
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from lms.djangoapps.certificates.tests.test_generation_handler import ID_VERIFIED_METHOD

View File

@@ -58,7 +58,13 @@ class CertificateStatuses:
restricted - The user is on the restricted list. This status was previously set if allow_certificate was
set to False in the userprofile table.
unavailable - Certificate has been invalidated.
unverified - The user is in verified track but does not have an approved, unexpired identity verification.
unverified - The user does not have an approved, unexpired identity verification.
The following statuses are set by V2 of course certificates:
downloadable - See generation.py
notpassing - See GeneratedCertificate.mark_notpassing()
unavailable - See GeneratedCertificate.invalidate()
unverified - See GeneratedCertificate.mark_unverified()
"""
deleted = 'deleted'
deleting = 'deleting'
@@ -389,6 +395,28 @@ class GeneratedCertificate(models.Model):
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,
)
def is_valid(self):
"""
Return True if certificate is valid else return False.

View File

@@ -11,8 +11,6 @@ from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, U
from lms.djangoapps.certificates.generation_handler import (
CERTIFICATES_USE_ALLOWLIST,
CERTIFICATES_USE_UPDATED,
is_using_certificate_allowlist,
is_using_v2_course_certificates,
_can_generate_allowlist_certificate,
_can_generate_certificate_for_status,
_can_generate_v2_certificate,
@@ -20,7 +18,11 @@ from lms.djangoapps.certificates.generation_handler import (
generate_allowlist_certificate_task,
generate_certificate_task,
generate_regular_certificate_task,
is_using_certificate_allowlist_and_is_on_allowlist
is_using_certificate_allowlist,
is_using_certificate_allowlist_and_is_on_allowlist,
is_using_v2_course_certificates,
_set_allowlist_cert_status,
_set_v2_cert_status
)
from lms.djangoapps.certificates.models import CertificateStatuses, GeneratedCertificate
from lms.djangoapps.certificates.tests.factories import (
@@ -153,6 +155,7 @@ class AllowlistTests(ModuleStoreTestCase):
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)
assert _set_allowlist_cert_status(self.user, self.course_run_key) is None
def test_handle_valid(self):
"""
@@ -174,6 +177,7 @@ class AllowlistTests(ModuleStoreTestCase):
"""
with mock.patch(ID_VERIFIED_METHOD, return_value=False):
assert not _can_generate_allowlist_certificate(self.user, self.course_run_key)
assert _set_allowlist_cert_status(self.user, self.course_run_key) == CertificateStatuses.unverified
def test_can_generate_not_enrolled(self):
"""
@@ -184,6 +188,7 @@ class AllowlistTests(ModuleStoreTestCase):
key = cr.id # pylint: disable=no-member
CertificateWhitelistFactory.create(course_id=key, user=u)
assert not _can_generate_allowlist_certificate(u, key)
assert _set_allowlist_cert_status(u, key) is None
def test_can_generate_audit(self):
"""
@@ -201,6 +206,7 @@ class AllowlistTests(ModuleStoreTestCase):
CertificateWhitelistFactory.create(course_id=key, user=u)
assert not _can_generate_allowlist_certificate(u, key)
assert _set_allowlist_cert_status(u, key) is None
def test_can_generate_not_whitelisted(self):
"""
@@ -216,6 +222,7 @@ class AllowlistTests(ModuleStoreTestCase):
mode="verified",
)
assert not _can_generate_allowlist_certificate(u, key)
assert _set_allowlist_cert_status(u, key) is None
def test_can_generate_invalidated(self):
"""
@@ -244,6 +251,7 @@ class AllowlistTests(ModuleStoreTestCase):
)
assert not _can_generate_allowlist_certificate(u, key)
assert _set_allowlist_cert_status(u, key) == CertificateStatuses.unavailable
def test_can_generate_web_cert_disabled(self):
"""
@@ -251,6 +259,29 @@ class AllowlistTests(ModuleStoreTestCase):
"""
with mock.patch(WEB_CERTS_METHOD, return_value=False):
assert not _can_generate_allowlist_certificate(self.user, self.course_run_key)
assert _set_allowlist_cert_status(self.user, self.course_run_key) is None
def test_cert_status_downloadable(self):
"""
Test cert status when status is already downloadable
"""
u = UserFactory()
cr = CourseFactory()
key = cr.id # pylint: disable=no-member
CourseEnrollmentFactory(
user=u,
course_id=key,
is_active=True,
mode="verified",
)
GeneratedCertificateFactory(
user=u,
course_id=key,
mode=GeneratedCertificate.MODES.verified,
status=CertificateStatuses.downloadable
)
assert _set_allowlist_cert_status(u, key) is None
@override_waffle_flag(CERTIFICATES_USE_UPDATED, active=True)
@@ -362,6 +393,7 @@ class CertificateTests(ModuleStoreTestCase):
"""
with mock.patch(ID_VERIFIED_METHOD, return_value=False):
assert not _can_generate_v2_certificate(self.user, self.course_run_key)
assert _set_v2_cert_status(self.user, self.course_run_key) == CertificateStatuses.unverified
def test_can_generate_ccx(self):
"""
@@ -369,6 +401,7 @@ class CertificateTests(ModuleStoreTestCase):
"""
with mock.patch(CCX_COURSE_METHOD, return_value=True):
assert not _can_generate_v2_certificate(self.user, self.course_run_key)
assert _set_v2_cert_status(self.user, self.course_run_key) is None
def test_can_generate_beta_tester(self):
"""
@@ -376,6 +409,7 @@ class CertificateTests(ModuleStoreTestCase):
"""
with mock.patch(BETA_TESTER_METHOD, return_value=True):
assert not _can_generate_v2_certificate(self.user, self.course_run_key)
assert _set_v2_cert_status(self.user, self.course_run_key) is None
def test_can_generate_failing_grade(self):
"""
@@ -383,6 +417,7 @@ class CertificateTests(ModuleStoreTestCase):
"""
with mock.patch(PASSING_GRADE_METHOD, return_value=False):
assert not _can_generate_v2_certificate(self.user, self.course_run_key)
assert _set_v2_cert_status(self.user, self.course_run_key) == CertificateStatuses.notpassing
def test_can_generate_not_enrolled(self):
"""
@@ -392,6 +427,7 @@ class CertificateTests(ModuleStoreTestCase):
cr = CourseFactory()
key = cr.id # pylint: disable=no-member
assert not _can_generate_v2_certificate(u, key)
assert _set_v2_cert_status(u, key) is None
def test_can_generate_audit(self):
"""
@@ -408,6 +444,7 @@ class CertificateTests(ModuleStoreTestCase):
)
assert not _can_generate_v2_certificate(u, key)
assert _set_v2_cert_status(u, key) is None
def test_can_generate_invalidated(self):
"""
@@ -435,6 +472,7 @@ class CertificateTests(ModuleStoreTestCase):
)
assert not _can_generate_v2_certificate(u, key)
assert _set_v2_cert_status(u, key) == CertificateStatuses.unavailable
def test_can_generate_web_cert_disabled(self):
"""
@@ -442,3 +480,71 @@ class CertificateTests(ModuleStoreTestCase):
"""
with mock.patch(WEB_CERTS_METHOD, return_value=False):
assert not _can_generate_v2_certificate(self.user, self.course_run_key)
assert _set_v2_cert_status(self.user, self.course_run_key) is None
@override_waffle_flag(CERTIFICATES_USE_UPDATED, active=False)
def test_cert_status_v1(self):
"""
Test cert status with V1 of course certs
"""
assert _set_v2_cert_status(self.user, self.course_run_key) is None
def test_cert_status_downloadable(self):
"""
Test cert status when status is already downloadable
"""
u = UserFactory()
cr = CourseFactory()
key = cr.id # pylint: disable=no-member
CourseEnrollmentFactory(
user=u,
course_id=key,
is_active=True,
mode="verified",
)
GeneratedCertificateFactory(
user=u,
course_id=key,
mode=GeneratedCertificate.MODES.verified,
status=CertificateStatuses.downloadable
)
assert _set_v2_cert_status(u, key) is None
def test_cert_status_none(self):
"""
Test cert status when the user has no cert
"""
u = UserFactory()
cr = CourseFactory()
key = cr.id # pylint: disable=no-member
CourseEnrollmentFactory(
user=u,
course_id=key,
is_active=True,
mode="verified",
)
assert _set_v2_cert_status(u, key) == CertificateStatuses.notpassing
def test_cert_status_generating(self):
"""
Test cert status when status is generating
"""
u = UserFactory()
cr = CourseFactory()
key = cr.id # pylint: disable=no-member
CourseEnrollmentFactory(
user=u,
course_id=key,
is_active=True,
mode="verified",
)
GeneratedCertificateFactory(
user=u,
course_id=key,
mode=GeneratedCertificate.MODES.verified,
status=CertificateStatuses.generating
)
assert _set_v2_cert_status(u, key) == CertificateStatuses.notpassing

View File

@@ -344,3 +344,60 @@ class CertificateInvalidationTest(SharedModuleStoreTestCase):
assert mock_revoke_task.call_count == 1
assert mock_revoke_task.call_args[0] == (self.user.username, str(self.course_id))
class GeneratedCertificateTest(SharedModuleStoreTestCase):
"""
Test GeneratedCertificates
"""
def setUp(self):
super().setUp()
self.user = UserFactory()
self.course = CourseOverviewFactory()
self.course_key = self.course.id
def test_invalidate(self):
"""
Test the invalidate method
"""
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key
)
cert.invalidate()
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
assert cert.status == CertificateStatuses.unavailable
def test_notpassing(self):
"""
Test the notpassing method
"""
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key
)
grade = '.3'
cert.mark_notpassing(grade)
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):
"""
Test the unverified method
"""
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key
)
cert.mark_unverified()
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
assert cert.status == CertificateStatuses.unverified