* feat: revert Fix certificate generation without persistent grades * feat: Remove Persistent Course Grades Flags * test: update tests
459 lines
18 KiB
Python
459 lines
18 KiB
Python
"""
|
|
Course certificate generation handler.
|
|
|
|
These methods check to see if a certificate can be generated (created if it does not already exist, or updated if it
|
|
exists but its state can be altered). If so, a celery task is launched to do the generation. If the certificate
|
|
cannot be generated, a message is logged and no further action is taken.
|
|
"""
|
|
|
|
import logging
|
|
from django.conf import settings
|
|
from openedx_filters.learning.filters import CertificateCreationRequested
|
|
|
|
from common.djangoapps.course_modes import api as modes_api
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.student.models import CourseEnrollment
|
|
from lms.djangoapps.certificates.data import CertificateStatuses
|
|
from lms.djangoapps.certificates.models import (
|
|
CertificateAllowlist,
|
|
CertificateInvalidation,
|
|
GeneratedCertificate
|
|
)
|
|
from lms.djangoapps.certificates.tasks import CERTIFICATE_DELAY_SECONDS, generate_certificate
|
|
from lms.djangoapps.certificates.utils import has_html_certificates_enabled
|
|
from lms.djangoapps.grades.api import CourseGradeFactory
|
|
from lms.djangoapps.instructor.access import is_beta_tester
|
|
from lms.djangoapps.verify_student.services import IDVerificationService
|
|
from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class GeneratedCertificateException(Exception):
|
|
pass
|
|
|
|
|
|
class CertificateGenerationNotAllowed(GeneratedCertificateException):
|
|
pass
|
|
|
|
|
|
def generate_certificate_task(user, course_key, generation_mode=None, delay_seconds=CERTIFICATE_DELAY_SECONDS):
|
|
"""
|
|
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_on_certificate_allowlist(user, course_key):
|
|
log.info(f'User {user.id} is on the allowlist for {course_key}. Attempt will be made to generate an allowlist '
|
|
f'certificate.')
|
|
return generate_allowlist_certificate_task(user, course_key, generation_mode=generation_mode,
|
|
delay_seconds=delay_seconds)
|
|
|
|
log.info(f'Attempt will be made to generate course certificate for user {user.id} : {course_key}')
|
|
return _generate_regular_certificate_task(user, course_key, generation_mode=generation_mode,
|
|
delay_seconds=delay_seconds)
|
|
|
|
|
|
def generate_allowlist_certificate_task(user, course_key, generation_mode=None,
|
|
delay_seconds=CERTIFICATE_DELAY_SECONDS):
|
|
"""
|
|
Create a task to generate an allowlist certificate for this user in this course run.
|
|
"""
|
|
enrollment_mode = _get_enrollment_mode(user, course_key)
|
|
course_grade = _get_course_grade(user, course_key)
|
|
if _can_generate_allowlist_certificate(user, course_key, enrollment_mode):
|
|
try:
|
|
return _generate_certificate_task(
|
|
user=user, course_key=course_key, enrollment_mode=enrollment_mode, course_grade=course_grade,
|
|
generation_mode=generation_mode, delay_seconds=delay_seconds,
|
|
)
|
|
except CertificateGenerationNotAllowed:
|
|
# Catch exception to contain error message in console.
|
|
log.error(
|
|
"Certificate generation not allowed for user %s in course %s",
|
|
user.id,
|
|
course_key,
|
|
)
|
|
return False
|
|
|
|
status = _set_allowlist_cert_status(user, course_key, enrollment_mode, course_grade)
|
|
if status is not None:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _generate_regular_certificate_task(user, course_key, generation_mode=None, delay_seconds=CERTIFICATE_DELAY_SECONDS):
|
|
"""
|
|
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.
|
|
"""
|
|
enrollment_mode = _get_enrollment_mode(user, course_key)
|
|
course_grade = _get_course_grade(user, course_key)
|
|
if _can_generate_regular_certificate(user, course_key, enrollment_mode, course_grade):
|
|
return _generate_certificate_task(user=user, course_key=course_key, enrollment_mode=enrollment_mode,
|
|
course_grade=course_grade, generation_mode=generation_mode,
|
|
delay_seconds=delay_seconds)
|
|
|
|
status = _set_regular_cert_status(user, course_key, enrollment_mode, course_grade)
|
|
if status is not None:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _generate_certificate_task(user, course_key, enrollment_mode, course_grade, status=None, generation_mode=None,
|
|
delay_seconds=CERTIFICATE_DELAY_SECONDS):
|
|
"""
|
|
Create a task to generate a certificate
|
|
"""
|
|
log.info(f'About to create a regular certificate task for {user.id} : {course_key}')
|
|
|
|
course_grade_val = _get_grade_value(course_grade)
|
|
|
|
try:
|
|
# .. filter_implemented_name: CertificateCreationRequested
|
|
# .. filter_type: org.openedx.learning.certificate.creation.requested.v1
|
|
user, course_key, enrollment_mode, status, course_grade, generation_mode = CertificateCreationRequested.run_filter( # pylint: disable=line-too-long
|
|
user=user,
|
|
course_key=course_key,
|
|
mode=enrollment_mode,
|
|
status=status,
|
|
grade=course_grade,
|
|
generation_mode=generation_mode,
|
|
)
|
|
except CertificateCreationRequested.PreventCertificateCreation as exc:
|
|
raise CertificateGenerationNotAllowed(str(exc)) from exc
|
|
|
|
kwargs = {
|
|
'student': str(user.id),
|
|
'course_key': str(course_key),
|
|
'enrollment_mode': str(enrollment_mode),
|
|
'course_grade': str(course_grade_val)
|
|
}
|
|
if status is not None:
|
|
kwargs['status'] = status
|
|
if generation_mode is not None:
|
|
kwargs['generation_mode'] = generation_mode
|
|
|
|
generate_certificate.apply_async(countdown=delay_seconds, kwargs=kwargs)
|
|
return True
|
|
|
|
|
|
def _can_generate_allowlist_certificate(user, course_key, enrollment_mode):
|
|
"""
|
|
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_on_certificate_allowlist(user, course_key):
|
|
log.info(f'{user.id} : {course_key} is not on the certificate allowlist. Allowlist certificate cannot be '
|
|
f'generated.')
|
|
return False
|
|
|
|
log.info(f'{user.id} : {course_key} is on the certificate allowlist')
|
|
|
|
if not _can_generate_certificate_common(user, course_key, enrollment_mode):
|
|
log.info(f'One of the common checks failed. Allowlist certificate cannot be generated for {user.id} : '
|
|
f'{course_key}.')
|
|
return False
|
|
|
|
log.info(f'Allowlist certificate can be generated for {user.id} : {course_key}')
|
|
return True
|
|
|
|
|
|
def _can_generate_regular_certificate(user, course_key, enrollment_mode, course_grade):
|
|
"""
|
|
Check if a regular (non-allowlist) 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 _is_ccx_course(course_key):
|
|
log.info(f'{course_key} is a CCX course. Certificate cannot be generated for {user.id}.')
|
|
return False
|
|
|
|
if is_beta_tester(user, course_key):
|
|
log.info(f'{user.id} is a beta tester in {course_key}. Certificate cannot be generated.')
|
|
return False
|
|
|
|
if not _is_passing_grade(course_grade):
|
|
log.info(f'{user.id} does not have a passing grade in {course_key}. Certificate cannot be generated.')
|
|
return False
|
|
|
|
if not _can_generate_certificate_common(user, course_key, enrollment_mode):
|
|
log.info(f'One of the common checks failed. Certificate cannot be generated for {user.id} : {course_key}.')
|
|
return False
|
|
|
|
log.info(f'Regular certificate can be generated for {user.id} : {course_key}')
|
|
return True
|
|
|
|
|
|
def _can_generate_certificate_common(user, course_key, enrollment_mode):
|
|
"""
|
|
Check if a 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.
|
|
|
|
This method contains checks that are common to both allowlist and regular course certificates.
|
|
"""
|
|
if CertificateInvalidation.has_certificate_invalidation(user, course_key):
|
|
# The invalidation list prevents certificate generation
|
|
log.info(f'{user.id} : {course_key} is on the certificate invalidation list. Certificate cannot be generated.')
|
|
return False
|
|
|
|
if enrollment_mode is None:
|
|
log.info(f'{user.id} : {course_key} does not have an enrollment. Certificate cannot be generated.')
|
|
return False
|
|
|
|
is_eligible_for_cert = modes_api.is_eligible_for_certificate(enrollment_mode)
|
|
if not is_eligible_for_cert:
|
|
log.info(f'{user.id} : {course_key} has an enrollment mode of {enrollment_mode}, which is not eligible for a '
|
|
f'certificate. Certificate cannot be generated.')
|
|
return False
|
|
|
|
# If the IDV check fails we then check if the course-run requires ID verification. Honor and Professional-No-ID
|
|
# modes do not require IDV for certificate generation.
|
|
if _id_verification_enforced_and_missing(user):
|
|
if enrollment_mode not in CourseMode.NON_VERIFIED_MODES:
|
|
log.info(f'{user.id} does not have a verified id. Certificate cannot be generated for {course_key}.')
|
|
return False
|
|
|
|
log.info(f'{user.id} : {course_key} is eligible for a certificate without requiring a verified ID. '
|
|
'Skipping results of the ID verification check.')
|
|
|
|
if not _can_generate_certificate_for_status(user, course_key, enrollment_mode):
|
|
return False
|
|
|
|
course_overview = get_course_overview_or_none(course_key)
|
|
if not course_overview:
|
|
log.info(f'{course_key} does not a course overview. Certificate cannot be generated for {user.id}.')
|
|
return False
|
|
|
|
if not has_html_certificates_enabled(course_overview):
|
|
log.info(f'{course_key} does not have HTML certificates enabled. Certificate cannot be generated for '
|
|
f'{user.id}.')
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _set_allowlist_cert_status(user, course_key, enrollment_mode, course_grade):
|
|
"""
|
|
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, enrollment_mode):
|
|
return None
|
|
|
|
cert = GeneratedCertificate.certificate_for_student(user, course_key)
|
|
return _get_cert_status_common(user, course_key, enrollment_mode, course_grade, cert)
|
|
|
|
|
|
def _set_regular_cert_status(user, course_key, enrollment_mode, course_grade):
|
|
"""
|
|
Determine the regular (non-allowlist) 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_regular_cert_status(user, course_key, enrollment_mode):
|
|
return None
|
|
|
|
cert = GeneratedCertificate.certificate_for_student(user, course_key)
|
|
status = _get_cert_status_common(user, course_key, enrollment_mode, course_grade, cert)
|
|
if status is not None:
|
|
return status
|
|
|
|
if not _id_verification_enforced_and_missing(user) \
|
|
and not _is_passing_grade(course_grade) \
|
|
and cert is not None:
|
|
if cert.status != CertificateStatuses.notpassing:
|
|
course_grade_val = _get_grade_value(course_grade)
|
|
cert.mark_notpassing(mode=enrollment_mode, grade=course_grade_val, source='certificate_generation')
|
|
return CertificateStatuses.notpassing
|
|
|
|
return None
|
|
|
|
|
|
def _get_cert_status_common(user, course_key, enrollment_mode, course_grade, 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) and cert is not None:
|
|
if cert.status != CertificateStatuses.unavailable:
|
|
cert.invalidate(mode=enrollment_mode, source='certificate_generation')
|
|
return CertificateStatuses.unavailable
|
|
|
|
if _id_verification_enforced_and_missing(user) and _has_passing_grade_or_is_allowlisted(
|
|
user, course_key, course_grade
|
|
):
|
|
if cert is None:
|
|
_generate_certificate_task(user=user, course_key=course_key, enrollment_mode=enrollment_mode,
|
|
course_grade=course_grade, status=CertificateStatuses.unverified,
|
|
generation_mode='batch')
|
|
elif cert.status != CertificateStatuses.unverified:
|
|
cert.mark_unverified(mode=enrollment_mode, source='certificate_generation')
|
|
return CertificateStatuses.unverified
|
|
|
|
return None
|
|
|
|
|
|
def _can_set_allowlist_cert_status(user, course_key, enrollment_mode):
|
|
"""
|
|
Determine whether we can set a custom (non-downloadable) cert status for an allowlist certificate
|
|
"""
|
|
if not is_on_certificate_allowlist(user, course_key):
|
|
return False
|
|
|
|
return _can_set_cert_status_common(user, course_key, enrollment_mode)
|
|
|
|
|
|
def _can_set_regular_cert_status(user, course_key, enrollment_mode):
|
|
"""
|
|
Determine whether we can set a custom (non-downloadable) cert status for a regular (non-allowlist) certificate
|
|
"""
|
|
if _is_ccx_course(course_key):
|
|
return False
|
|
|
|
if is_beta_tester(user, course_key):
|
|
return False
|
|
|
|
return _can_set_cert_status_common(user, course_key, enrollment_mode)
|
|
|
|
|
|
def _can_set_cert_status_common(user, course_key, enrollment_mode):
|
|
"""
|
|
Determine whether we can set a custom (non-downloadable) cert status
|
|
"""
|
|
if _is_cert_downloadable(user, course_key):
|
|
return False
|
|
|
|
if enrollment_mode is None:
|
|
return False
|
|
|
|
if not modes_api.is_eligible_for_certificate(enrollment_mode):
|
|
return False
|
|
|
|
course_overview = get_course_overview_or_none(course_key)
|
|
if not course_overview:
|
|
return False
|
|
|
|
if not has_html_certificates_enabled(course_overview):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def is_on_certificate_allowlist(user, course_key):
|
|
"""
|
|
Check if the user is on the allowlist, and is enabled for the allowlist, for this course run
|
|
"""
|
|
return CertificateAllowlist.objects.filter(user=user, course_id=course_key, allowlist=True).exists()
|
|
|
|
|
|
def _can_generate_certificate_for_status(user, course_key, enrollment_mode):
|
|
"""
|
|
Check if the user's certificate status can handle regular (non-allowlist) certificate generation
|
|
"""
|
|
cert = GeneratedCertificate.certificate_for_student(user, course_key)
|
|
if cert is None:
|
|
return True
|
|
|
|
if cert.status == CertificateStatuses.downloadable:
|
|
if not _is_mode_now_eligible(enrollment_mode, cert):
|
|
log.info(f'Certificate with status {cert.status} already exists for {user.id} : {course_key}, and is not '
|
|
f'eligible for generation. Certificate cannot be generated as it is already in a final state. The '
|
|
f'current enrollment mode is {enrollment_mode} and the existing cert mode is {cert.mode}')
|
|
return False
|
|
|
|
log.info(f'Certificate with status {cert.status} already exists for {user.id} : {course_key}, and is eligible for '
|
|
f'generation. The current enrollment mode is {enrollment_mode} and the existing cert mode is {cert.mode}')
|
|
return True
|
|
|
|
|
|
def _is_ccx_course(course_key):
|
|
"""
|
|
Check if the course is a CCX (custom edX course)
|
|
"""
|
|
return hasattr(course_key, 'ccx')
|
|
|
|
|
|
def _has_passing_grade_or_is_allowlisted(user, course_key, course_grade):
|
|
"""
|
|
Check if the user has a passing grade in this course run, or is on the allowlist and so is exempt from needing
|
|
a passing grade.
|
|
"""
|
|
if is_on_certificate_allowlist(user, course_key):
|
|
return True
|
|
|
|
return _is_passing_grade(course_grade)
|
|
|
|
|
|
def _is_passing_grade(course_grade):
|
|
"""
|
|
Check if the grade is a passing grade
|
|
"""
|
|
if course_grade:
|
|
return course_grade.passed
|
|
return False
|
|
|
|
|
|
def _get_grade_value(course_grade):
|
|
"""
|
|
Get the user's course grade as a percent, or an empty string if there is no grade
|
|
"""
|
|
if course_grade:
|
|
return course_grade.percent
|
|
return ''
|
|
|
|
|
|
def _get_course_grade(user, course_key):
|
|
"""
|
|
Get the user's course grade in this course run. Note that this may be None.
|
|
"""
|
|
return CourseGradeFactory().read(user, course_key=course_key)
|
|
|
|
|
|
def _get_enrollment_mode(user, course_key):
|
|
"""
|
|
Get the user's enrollment mode for this course run. Note that this may be None.
|
|
"""
|
|
enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(user, course_key)
|
|
return enrollment_mode
|
|
|
|
|
|
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 _is_mode_now_eligible(enrollment_mode, cert):
|
|
"""
|
|
Check if the current enrollment mode is now eligible, while the enrollment mode on the cert is NOT eligible
|
|
"""
|
|
if modes_api.is_eligible_for_certificate(enrollment_mode) and not modes_api.is_eligible_for_certificate(cert.mode):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _id_verification_enforced_and_missing(user):
|
|
"""
|
|
Return true if IDV is required for this course and the user does not have it
|
|
"""
|
|
return settings.FEATURES.get(
|
|
'ENABLE_CERTIFICATES_IDV_REQUIREMENT') and not IDVerificationService.user_is_verified(user)
|