fix: Update mode in course certificate when marking cert as invalidated, not verified or not passing (#28330)

MICROBA-1410
This commit is contained in:
Christie Rice
2021-08-03 11:24:11 -04:00
committed by GitHub
parent af290ce6a9
commit cdeda7a313
7 changed files with 202 additions and 31 deletions

View File

@@ -50,7 +50,7 @@ def generate_course_certificate(user, course_key, status, enrollment_mode, cours
emit_certificate_event(event_name='created', user=user, course_id=course_key, event_data=event_data)
elif CertificateStatuses.unverified == cert.status:
cert.mark_unverified(source='certificate_generation')
cert.mark_unverified(mode=enrollment_mode, source='certificate_generation')
return cert

View File

@@ -220,7 +220,7 @@ def _set_regular_cert_status(user, course_key, enrollment_mode, course_grade):
if IDVerificationService.user_is_verified(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(course_grade_val, source='certificate_generation')
cert.mark_notpassing(mode=enrollment_mode, grade=course_grade_val, source='certificate_generation')
return CertificateStatuses.notpassing
return None
@@ -235,7 +235,7 @@ def _get_cert_status_common(user, course_key, enrollment_mode, course_grade, cer
"""
if CertificateInvalidation.has_certificate_invalidation(user, course_key) and cert is not None:
if cert.status != CertificateStatuses.unavailable:
cert.invalidate(source='certificate_generation')
cert.invalidate(mode=enrollment_mode, source='certificate_generation')
return CertificateStatuses.unavailable
if not IDVerificationService.user_is_verified(user) and _has_passing_grade_or_is_allowlisted(user, course_key,
@@ -245,7 +245,7 @@ def _get_cert_status_common(user, course_key, enrollment_mode, course_grade, cer
course_grade=course_grade, status=CertificateStatuses.unverified,
generation_mode='batch')
elif cert.status != CertificateStatuses.unverified:
cert.mark_unverified(source='certificate_generation')
cert.mark_unverified(mode=enrollment_mode, source='certificate_generation')
return CertificateStatuses.unverified
return None

View File

@@ -23,6 +23,8 @@ from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
from simple_history.models import HistoricalRecords
from common.djangoapps.student import models_api as student_api
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled
from lms.djangoapps.badges.events.course_complete import course_badge_check
from lms.djangoapps.badges.events.course_meta import completion_check, course_group_check
@@ -296,41 +298,51 @@ class GeneratedCertificate(models.Model):
user=self.user
)
def invalidate(self, source=None):
def invalidate(self, mode=None, source=None):
"""
Invalidate Generated Certificate by marking it 'unavailable'. For additional information see the
`_revoke_certificate()` function.
Args:
mode (String) - learner's current enrollment mode. May be none as the caller likely does not need to
evaluate the mode before deciding to invalidate the cert.
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)
if not mode:
mode, __ = CourseEnrollment.enrollment_mode_for_user(self.user, self.course_id)
def mark_notpassing(self, grade, source=None):
log.info(f'Marking certificate as unavailable for {self.user.id} : {self.course_id} with mode {mode} from '
f'source {source}')
self._revoke_certificate(status=CertificateStatuses.unavailable, mode=mode, source=source)
def mark_notpassing(self, mode, grade, source=None):
"""
Invalidates a Generated Certificate by marking it as 'notpassing'. For additional information see the
`_revoke_certificate()` function.
Args:
mode (String) - learner's current enrollment mode
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)
log.info(f'Marking certificate as notpassing for {self.user.id} : {self.course_id} with mode {mode} from '
f'source {source}')
self._revoke_certificate(status=CertificateStatuses.notpassing, mode=mode, grade=grade, source=source)
def mark_unverified(self, source=None):
def mark_unverified(self, mode, source=None):
"""
Invalidates a Generated Certificate by marking it as 'unverified'. For additional information see the
`_revoke_certificate()` function.
Args:
mode (String) - learner's current enrollment mode
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)
log.info(f'Marking certificate as unverified for {self.user.id} : {self.course_id} with mode {mode} from '
f'source {source}')
self._revoke_certificate(status=CertificateStatuses.unverified, mode=mode, source=source)
def _revoke_certificate(self, status, grade=None, source=None):
def _revoke_certificate(self, status, mode=None, 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 certificate in the associated
@@ -346,16 +358,29 @@ class GeneratedCertificate(models.Model):
Args:
status (CertificateStatus) - certificate status to set for the `GeneratedCertificate` record
mode (String) - learner's current enrollment mode
grade (float) - snapshot of the learner's current grade as a decimal
source (String) - source requesting invalidation of the certificate for tracking purposes
"""
previous_certificate_status = self.status
if not grade:
grade = ''
if not mode:
mode = self.mode
profile_name = student_api.get_name(self.user.id)
if not profile_name:
profile_name = ''
self.error_reason = ''
self.download_uuid = ''
self.download_url = ''
self.grade = grade or ''
self.grade = grade
self.status = status
self.mode = mode
self.name = profile_name
self.save()
COURSE_CERT_REVOKED.send_robust(

View File

@@ -97,12 +97,9 @@ 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, source='notpassing_signal')
log.info('Certificate marked not passing for {user} : {course} via failing grade: {grade}'.format(
user=user.id,
course=course_id,
grade=grade
))
enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(user, course_id)
cert.mark_notpassing(mode=enrollment_mode, grade=grade.percent, source='notpassing_signal')
log.info(f'Certificate marked not passing for {user.id} : {course_id} via failing grade')
@receiver(LEARNER_NOW_VERIFIED, dispatch_uid="learner_track_changed")

View File

@@ -596,6 +596,42 @@ class GenerateUserCertificatesTest(ModuleStoreTestCase):
assert cert.status == CertificateStatuses.downloadable
assert cert.mode == CourseMode.VERIFIED
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True})
def test_generation_unverified(self):
"""
Test that a cert is successfully generated with a status of unverified
"""
cert = get_certificate_for_user_id(self.user.id, self.course_run_key)
assert not cert
with mock.patch(PASSING_GRADE_METHOD, return_value=True):
with mock.patch(ID_VERIFIED_METHOD, return_value=False):
generate_certificate_task(self.user, self.course_run_key)
cert = get_certificate_for_user_id(self.user.id, self.course_run_key)
assert cert.status == CertificateStatuses.unverified
assert cert.mode == CourseMode.VERIFIED
@patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True})
def test_generation_notpassing(self):
"""
Test that a cert is successfully generated with a status of notpassing
"""
GeneratedCertificateFactory(
user=self.user,
course_id=self.course_run_key,
status=CertificateStatuses.unavailable,
mode=CourseMode.AUDIT
)
with mock.patch(PASSING_GRADE_METHOD, return_value=False):
with mock.patch(ID_VERIFIED_METHOD, return_value=True):
generate_certificate_task(self.user, self.course_run_key)
cert = get_certificate_for_user_id(self.user.id, self.course_run_key)
assert cert.status == CertificateStatuses.notpassing
assert cert.mode == CourseMode.VERIFIED
@ddt.ddt
class CertificateGenerationEnabledTest(EventTestMixin, TestCase):

View File

@@ -141,7 +141,7 @@ class CertificateTests(EventTestMixin, ModuleStoreTestCase):
assert generated_cert.status, CertificateStatuses.downloadable
assert generated_cert.verify_uuid, verify_uuid
generated_cert.mark_notpassing(50.00)
generated_cert.mark_notpassing(mode=generated_cert.mode, grade=50.00)
assert generated_cert.status, CertificateStatuses.notpassing
assert generated_cert.verify_uuid, verify_uuid

View File

@@ -3,6 +3,7 @@
import json
from unittest.mock import patch
from unittest import mock
import ddt
import pytest
@@ -14,6 +15,8 @@ from django.test.utils import override_settings
from opaque_keys.edx.locator import CourseKey, CourseLocator
from path import Path as path
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import UserProfile
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
from lms.djangoapps.certificates.models import (
CertificateAllowlist,
@@ -36,6 +39,9 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
ENROLLMENT_METHOD = 'common.djangoapps.student.models.CourseEnrollment.enrollment_mode_for_user'
PROFILE_METHOD = 'common.djangoapps.student.models_api.get_name'
FEATURES_INVALID_FILE_PATH = settings.FEATURES.copy()
FEATURES_INVALID_FILE_PATH['CERTS_HTML_VIEW_CONFIG_PATH'] = 'invalid/path/to/config.json'
@@ -384,24 +390,125 @@ class GeneratedCertificateTest(SharedModuleStoreTestCase):
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key
course_id=self.course_key,
mode=CourseMode.AUDIT,
name='Fuzzy Hippo'
)
mode = CourseMode.VERIFIED
source = 'invalidated_test'
cert.invalidate(source=source)
cert.invalidate(mode=mode, source=source)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
profile = UserProfile.objects.get(user=self.user)
assert cert.status == CertificateStatuses.unavailable
assert cert.mode == mode
assert cert.name == profile.name
expected_event_data = {
'user_id': self.user.id,
'course_id': str(self.course_key),
'certificate_id': cert.verify_uuid,
'enrollment_mode': cert.mode,
'enrollment_mode': mode,
'source': source,
}
self._assert_event_data(mock_emit_certificate_event, expected_event_data)
@patch('lms.djangoapps.certificates.utils.emit_certificate_event')
def test_invalidate_find_mode(self, mock_emit_certificate_event):
"""
Test the invalidate method when mode is retrieved from the enrollment
"""
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key,
mode=CourseMode.AUDIT
)
mode = CourseMode.MASTERS
source = 'invalidated_test'
with mock.patch(ENROLLMENT_METHOD, return_value=(mode, None)):
cert.invalidate(source=source)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
assert cert.status == CertificateStatuses.unavailable
assert cert.mode == mode
expected_event_data = {
'user_id': self.user.id,
'course_id': str(self.course_key),
'certificate_id': cert.verify_uuid,
'enrollment_mode': mode,
'source': source,
}
self._assert_event_data(mock_emit_certificate_event, expected_event_data)
@patch('lms.djangoapps.certificates.utils.emit_certificate_event')
def test_invalidate_no_mode(self, mock_emit_certificate_event):
"""
Test the invalidate method when there is no enrollment mode
"""
initial_mode = CourseMode.AUDIT
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key,
mode=initial_mode
)
source = 'invalidated_test'
with mock.patch(ENROLLMENT_METHOD, return_value=(None, None)):
cert.invalidate(source=source)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
assert cert.status == CertificateStatuses.unavailable
assert cert.mode == initial_mode
expected_event_data = {
'user_id': self.user.id,
'course_id': str(self.course_key),
'certificate_id': cert.verify_uuid,
'enrollment_mode': initial_mode,
'source': source,
}
self._assert_event_data(mock_emit_certificate_event, expected_event_data)
@patch('lms.djangoapps.certificates.utils.emit_certificate_event')
def test_invalidate_no_profile(self, mock_emit_certificate_event):
"""
Test the invalidate method when there is no user profile
"""
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key,
mode=CourseMode.AUDIT,
name='Squeaky Frog'
)
mode = CourseMode.VERIFIED
source = 'invalidated_test'
with mock.patch(PROFILE_METHOD, return_value=None):
cert.invalidate(mode=mode, source=source)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
assert cert.status == CertificateStatuses.unavailable
assert cert.mode == mode
assert cert.name == ''
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):
"""
@@ -410,21 +517,24 @@ class GeneratedCertificateTest(SharedModuleStoreTestCase):
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key
course_id=self.course_key,
mode=CourseMode.AUDIT
)
mode = CourseMode.VERIFIED
grade = '.3'
source = "notpassing_test"
cert.mark_notpassing(grade, source=source)
cert.mark_notpassing(mode=mode, grade=grade, source=source)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
assert cert.status == CertificateStatuses.notpassing
assert cert.mode == mode
assert cert.grade == grade
expected_event_data = {
'user_id': self.user.id,
'course_id': str(self.course_key),
'certificate_id': cert.verify_uuid,
'enrollment_mode': cert.mode,
'enrollment_mode': mode,
'source': source,
}
@@ -438,19 +548,22 @@ class GeneratedCertificateTest(SharedModuleStoreTestCase):
cert = GeneratedCertificateFactory.create(
status=CertificateStatuses.downloadable,
user=self.user,
course_id=self.course_key
course_id=self.course_key,
mode=CourseMode.AUDIT
)
mode = CourseMode.VERIFIED
source = "unverified_test"
cert.mark_unverified(source=source)
cert.mark_unverified(mode=mode, source=source)
cert = GeneratedCertificate.objects.get(user=self.user, course_id=self.course_key)
assert cert.status == CertificateStatuses.unverified
assert cert.mode == mode
expected_event_data = {
'user_id': self.user.id,
'course_id': str(self.course_key),
'certificate_id': cert.verify_uuid,
'enrollment_mode': cert.mode,
'enrollment_mode': mode,
'source': source,
}