Generate eligible certificates on learner track change
This commit is contained in:
@@ -15,8 +15,10 @@ from certificates.models import \
|
||||
GeneratedCertificate
|
||||
from certificates.tasks import generate_certificate
|
||||
from courseware import courses
|
||||
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
|
||||
from openedx.core.djangoapps.models.course_details import COURSE_PACING_CHANGE
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_NOW_PASSED
|
||||
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_NOW_PASSED, LEARNER_NOW_VERIFIED
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -76,7 +78,6 @@ def _listen_for_passing_grade(sender, user, course_id, **kwargs): # pylint: dis
|
||||
Listen for a learner passing a course, send cert generation task,
|
||||
downstream signal from COURSE_GRADE_CHANGED
|
||||
"""
|
||||
|
||||
# No flags enabled
|
||||
if (
|
||||
not waffle.waffle().is_enabled(waffle.SELF_PACED_ONLY) and
|
||||
@@ -86,19 +87,55 @@ def _listen_for_passing_grade(sender, user, course_id, **kwargs): # pylint: dis
|
||||
|
||||
# Only SELF_PACED_ONLY flag enabled
|
||||
if waffle.waffle().is_enabled(waffle.SELF_PACED_ONLY):
|
||||
if not courses.get_course_by_id(course_key, depth=0).self_paced:
|
||||
if not courses.get_course_by_id(course_id, depth=0).self_paced:
|
||||
return
|
||||
|
||||
# Only INSTRUCTOR_PACED_ONLY flag enabled
|
||||
elif waffle.waffle().is_enabled(waffle.INSTRUCTOR_PACED_ONLY):
|
||||
if courses.get_course_by_id(course_key, depth=0).self_paced:
|
||||
if waffle.waffle().is_enabled(waffle.INSTRUCTOR_PACED_ONLY):
|
||||
if courses.get_course_by_id(course_id, depth=0).self_paced:
|
||||
return
|
||||
if GeneratedCertificate.certificate_for_student(self.user, self.course_id) is None:
|
||||
generate_certificate.apply_async(
|
||||
student=user,
|
||||
course_key=course_id,
|
||||
)
|
||||
if fire_ungenerated_certificate_task(
|
||||
user=user,
|
||||
course_id=course_id
|
||||
):
|
||||
log.info(u'Certificate generation task initiated for {user} : {course} via passing grade'.format(
|
||||
user=user.id,
|
||||
course=course_id
|
||||
))
|
||||
|
||||
|
||||
@receiver(LEARNER_NOW_VERIFIED, dispatch_uid="learner_track_changed")
|
||||
def _listen_for_track_change(sender, user, **kwargs): # pylint: disable=unused-argument
|
||||
"""
|
||||
Catches a track change signal, determines user status,
|
||||
calls fire_ungenerated_certificate_task for passing grades
|
||||
"""
|
||||
if (
|
||||
not waffle.waffle().is_enabled(waffle.SELF_PACED_ONLY) and
|
||||
not waffle.waffle().is_enabled(waffle.INSTRUCTOR_PACED_ONLY)
|
||||
):
|
||||
return
|
||||
user_enrollments = CourseEnrollment.enrollments_for_user(user=user)
|
||||
grade_factory = CourseGradeFactory()
|
||||
for enrollment in user_enrollments:
|
||||
if grade_factory.read(user=user, course=enrollment.course).passed:
|
||||
if fire_ungenerated_certificate_task(
|
||||
user=user,
|
||||
course_id=enrollment.course.id
|
||||
):
|
||||
log.info(u'Certificate generation task initiated for {user} : {course} via track change'.format(
|
||||
user=user.id,
|
||||
course=enrollment.course.id
|
||||
))
|
||||
|
||||
|
||||
def fire_ungenerated_certificate_task(user, course_id):
|
||||
"""
|
||||
Helper function to fire un-generated certificate tasks
|
||||
"""
|
||||
if GeneratedCertificate.certificate_for_student(user, course_id) is None:
|
||||
generate_certificate.apply_async(
|
||||
student=user,
|
||||
course_key=course_id
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -6,10 +6,15 @@ import mock
|
||||
|
||||
from certificates import api as certs_api
|
||||
from certificates.config import waffle
|
||||
from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist
|
||||
from certificates.models import \
|
||||
CertificateGenerationConfiguration, \
|
||||
CertificateWhitelist, \
|
||||
GeneratedCertificate, \
|
||||
CertificateStatuses
|
||||
from certificates.signals import _listen_for_course_pacing_changed
|
||||
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory
|
||||
from lms.djangoapps.grades.tests.utils import mock_get_score
|
||||
from lms.djangoapps.grades.tests.utils import mock_passing_grade
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
|
||||
from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
@@ -72,10 +77,7 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase):
|
||||
user=self.user,
|
||||
course_id=self.course.id
|
||||
)
|
||||
mock_generate_certificate_apply_async.assert_not_called(
|
||||
student=self.user,
|
||||
course_key=self.course.id
|
||||
)
|
||||
mock_generate_certificate_apply_async.assert_not_called()
|
||||
with waffle.waffle().override(waffle.SELF_PACED_ONLY, active=True):
|
||||
CertificateWhitelist.objects.create(
|
||||
user=self.user,
|
||||
@@ -100,10 +102,7 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase):
|
||||
user=self.user,
|
||||
course_id=self.ip_course.id
|
||||
)
|
||||
mock_generate_certificate_apply_async.assert_not_called(
|
||||
student=self.user,
|
||||
course_key=self.ip_course.id
|
||||
)
|
||||
mock_generate_certificate_apply_async.assert_not_called()
|
||||
with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=True):
|
||||
CertificateWhitelist.objects.create(
|
||||
user=self.user,
|
||||
@@ -121,7 +120,9 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
|
||||
"""
|
||||
def setUp(self):
|
||||
super(PassingGradeCertsTest, self).setUp()
|
||||
self.course = CourseFactory.create(self_paced=True)
|
||||
self.course = CourseFactory.create(
|
||||
self_paced=True,
|
||||
)
|
||||
self.user = UserFactory.create()
|
||||
self.enrollment = CourseEnrollmentFactory(
|
||||
user=self.user,
|
||||
@@ -130,6 +131,12 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
|
||||
mode="verified",
|
||||
)
|
||||
self.ip_course = CourseFactory.create(self_paced=False)
|
||||
self.ip_enrollment = CourseEnrollmentFactory(
|
||||
user=self.user,
|
||||
course_id=self.ip_course.id,
|
||||
is_active=True,
|
||||
mode="verified",
|
||||
)
|
||||
|
||||
def test_cert_generation_on_passing_self_paced(self):
|
||||
with mock.patch(
|
||||
@@ -138,22 +145,13 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
|
||||
) as mock_generate_certificate_apply_async:
|
||||
with waffle.waffle().override(waffle.SELF_PACED_ONLY, active=True):
|
||||
grade_factory = CourseGradeFactory()
|
||||
with mock_get_score(0, 2):
|
||||
# Not passing
|
||||
grade_factory.update(self.user, self.course)
|
||||
mock_generate_certificate_apply_async.assert_not_called()
|
||||
# Certs fired after passing
|
||||
with mock_passing_grade():
|
||||
grade_factory.update(self.user, self.course)
|
||||
mock_generate_certificate_apply_async.assert_not_called(
|
||||
student=self.user,
|
||||
course_key=self.course.id
|
||||
)
|
||||
with mock_get_score(1, 2):
|
||||
grade_factory.update(self.user, self.course)
|
||||
mock_generate_certificate_apply_async.assert_called(
|
||||
student=self.user,
|
||||
course_key=self.course.id
|
||||
)
|
||||
# Certs are not re-fired after passing
|
||||
with mock_get_score(2, 2):
|
||||
grade_factory.update(self.user, self.course)
|
||||
mock_generate_certificate_apply_async.assert_not_called(
|
||||
mock_generate_certificate_apply_async.assert_called_with(
|
||||
student=self.user,
|
||||
course_key=self.course.id
|
||||
)
|
||||
@@ -165,22 +163,96 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
|
||||
) as mock_generate_certificate_apply_async:
|
||||
with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=True):
|
||||
grade_factory = CourseGradeFactory()
|
||||
with mock_get_score(0, 2):
|
||||
# Not passing
|
||||
grade_factory.update(self.user, self.ip_course)
|
||||
mock_generate_certificate_apply_async.assert_not_called()
|
||||
# Certs fired after passing
|
||||
with mock_passing_grade():
|
||||
grade_factory.update(self.user, self.ip_course)
|
||||
mock_generate_certificate_apply_async.assert_not_called(
|
||||
student=self.user,
|
||||
course_key=self.ip_course.id
|
||||
)
|
||||
with mock_get_score(1, 2):
|
||||
grade_factory.update(self.user, self.ip_course)
|
||||
mock_generate_certificate_apply_async.assert_called(
|
||||
student=self.user,
|
||||
course_key=self.ip_course.id
|
||||
)
|
||||
# Certs are not re-fired after passing
|
||||
with mock_get_score(2, 2):
|
||||
grade_factory.update(self.user, self.ip_course)
|
||||
mock_generate_certificate_apply_async.assert_not_called(
|
||||
mock_generate_certificate_apply_async.assert_called_with(
|
||||
student=self.user,
|
||||
course_key=self.ip_course.id
|
||||
)
|
||||
|
||||
def test_cert_already_generated(self):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.certificates.signals.generate_certificate.apply_async',
|
||||
return_value=None
|
||||
) as mock_generate_certificate_apply_async:
|
||||
grade_factory = CourseGradeFactory()
|
||||
# Create the certificate
|
||||
GeneratedCertificate.eligible_certificates.create(
|
||||
user=self.user,
|
||||
course_id=self.course.id,
|
||||
status=CertificateStatuses.downloadable
|
||||
)
|
||||
# Certs are not re-fired after passing
|
||||
with mock_passing_grade():
|
||||
grade_factory.update(self.user, self.course)
|
||||
mock_generate_certificate_apply_async.assert_not_called()
|
||||
|
||||
|
||||
class LearnerTrackChangeCertsTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for certificate generation task firing on learner verification
|
||||
"""
|
||||
def setUp(self):
|
||||
super(LearnerTrackChangeCertsTest, self).setUp()
|
||||
self.course_one = CourseFactory.create(self_paced=True)
|
||||
self.user_one = UserFactory.create()
|
||||
self.enrollment_one = CourseEnrollmentFactory(
|
||||
user=self.user_one,
|
||||
course_id=self.course_one.id,
|
||||
is_active=True,
|
||||
mode='honor',
|
||||
)
|
||||
self.user_two = UserFactory.create()
|
||||
self.course_two = CourseFactory.create(self_paced=False)
|
||||
self.enrollment_two = CourseEnrollmentFactory(
|
||||
user=self.user_two,
|
||||
course_id=self.course_two.id,
|
||||
is_active=True,
|
||||
mode='honor'
|
||||
)
|
||||
|
||||
def test_cert_generation_on_photo_verification_self_paced(self):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.certificates.signals.generate_certificate.apply_async',
|
||||
return_value=None
|
||||
) as mock_generate_certificate_apply_async:
|
||||
with mock_passing_grade():
|
||||
grade_factory = CourseGradeFactory()
|
||||
grade_factory.update(self.user_one, self.course_one)
|
||||
|
||||
with waffle.waffle().override(waffle.SELF_PACED_ONLY, active=True):
|
||||
mock_generate_certificate_apply_async.assert_not_called()
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(
|
||||
user=self.user_one,
|
||||
status='submitted'
|
||||
)
|
||||
attempt.approve()
|
||||
mock_generate_certificate_apply_async.assert_called_with(
|
||||
student=self.user_one,
|
||||
course_key=self.course_one.id
|
||||
)
|
||||
|
||||
def test_cert_generation_on_photo_verification_instructor_paced(self):
|
||||
with mock.patch(
|
||||
'lms.djangoapps.certificates.signals.generate_certificate.apply_async',
|
||||
return_value=None
|
||||
) as mock_generate_certificate_apply_async:
|
||||
with mock_passing_grade():
|
||||
grade_factory = CourseGradeFactory()
|
||||
grade_factory.update(self.user_two, self.course_two)
|
||||
|
||||
with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=True):
|
||||
mock_generate_certificate_apply_async.assert_not_called()
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(
|
||||
user=self.user_two,
|
||||
status='submitted'
|
||||
)
|
||||
attempt.approve()
|
||||
mock_generate_certificate_apply_async.assert_called_with(
|
||||
student=self.user_two,
|
||||
course_key=self.course_two.id
|
||||
)
|
||||
|
||||
@@ -159,6 +159,7 @@ class CourseGradeFactory(object):
|
||||
persistent_grade.letter_grade,
|
||||
persistent_grade.passed_timestamp is not None,
|
||||
)
|
||||
|
||||
log.info(u'Grades: Read, %s, User: %s, %s', unicode(course_data), user.id, persistent_grade)
|
||||
|
||||
return course_grade, persistent_grade.grading_policy_hash
|
||||
@@ -199,11 +200,11 @@ class CourseGradeFactory(object):
|
||||
course_key=course_data.course_key,
|
||||
deadline=course_data.course.end,
|
||||
)
|
||||
if course_grade.passed is True:
|
||||
COURSE_GRADE_NOW_PASSED.send_robust(
|
||||
if course_grade.passed:
|
||||
COURSE_GRADE_NOW_PASSED.send(
|
||||
sender=CourseGradeFactory,
|
||||
user=user,
|
||||
course_key=course_data.course_key,
|
||||
course_id=course_data.course_key,
|
||||
)
|
||||
|
||||
log.info(
|
||||
|
||||
@@ -186,7 +186,7 @@ class TestCourseGradeFactory(GradeTestBase):
|
||||
self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None)
|
||||
self.assertEqual(course_grade.percent, 0.5)
|
||||
|
||||
with self.assertNumQueries(11), mock_get_score(1, 2):
|
||||
with self.assertNumQueries(13), mock_get_score(1, 2):
|
||||
_assert_create(expected_pass=True)
|
||||
|
||||
with self.assertNumQueries(13), mock_get_score(1, 2):
|
||||
|
||||
@@ -41,11 +41,13 @@ from lms.djangoapps.verify_student.ssencrypt import (
|
||||
random_aes_key,
|
||||
rsa_encrypt
|
||||
)
|
||||
from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
|
||||
from openedx.core.djangolib.model_mixins import DeprecatedModelMixin
|
||||
from openedx.core.storage import get_storage
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -516,6 +518,11 @@ class PhotoVerification(StatusModel):
|
||||
self.reviewing_service = service
|
||||
self.status = "approved"
|
||||
self.save()
|
||||
# Emit signal to find and generate eligible certificates
|
||||
LEARNER_NOW_VERIFIED.send_robust(
|
||||
sender=PhotoVerification,
|
||||
user=self.user
|
||||
)
|
||||
|
||||
@status_before_must_be("must_retry", "submitted", "approved", "denied")
|
||||
def deny(self,
|
||||
|
||||
20
openedx/core/djangoapps/signals/apps.py
Normal file
20
openedx/core/djangoapps/signals/apps.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Signal handlers are registered at startup here.
|
||||
"""
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SignalConfig(AppConfig):
|
||||
"""
|
||||
Application Configuration for Signals.
|
||||
"""
|
||||
name = u'openedx.core.djangoapps.signals'
|
||||
|
||||
def ready(self):
|
||||
"""
|
||||
Connect handlers.
|
||||
"""
|
||||
# Can't import models at module level in AppConfigs, and models get
|
||||
# included from the signal handlers
|
||||
from .signals import handlers # pylint: disable=unused-variable
|
||||
@@ -16,6 +16,9 @@ COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "stat
|
||||
COURSE_GRADE_NOW_PASSED = Signal(
|
||||
providing_args=[
|
||||
'user', # user object
|
||||
'course_key', # course.id
|
||||
'course_id', # course.id
|
||||
]
|
||||
)
|
||||
|
||||
# Signal that indicates that a user has become verified
|
||||
LEARNER_NOW_VERIFIED = Signal(providing_args=['user'])
|
||||
|
||||
Reference in New Issue
Block a user