Commit 16f06bf6 by Gregory Martin

Generate eligible certificates on learner track change

parent 32618fa0
...@@ -15,8 +15,10 @@ from certificates.models import \ ...@@ -15,8 +15,10 @@ from certificates.models import \
GeneratedCertificate GeneratedCertificate
from certificates.tasks import generate_certificate from certificates.tasks import generate_certificate
from courseware import courses 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.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__) log = logging.getLogger(__name__)
...@@ -76,7 +78,6 @@ def _listen_for_passing_grade(sender, user, course_id, **kwargs): # pylint: dis ...@@ -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, Listen for a learner passing a course, send cert generation task,
downstream signal from COURSE_GRADE_CHANGED downstream signal from COURSE_GRADE_CHANGED
""" """
# No flags enabled # No flags enabled
if ( if (
not waffle.waffle().is_enabled(waffle.SELF_PACED_ONLY) and 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 ...@@ -86,19 +87,55 @@ def _listen_for_passing_grade(sender, user, course_id, **kwargs): # pylint: dis
# Only SELF_PACED_ONLY flag enabled # Only SELF_PACED_ONLY flag enabled
if waffle.waffle().is_enabled(waffle.SELF_PACED_ONLY): 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 return
# Only INSTRUCTOR_PACED_ONLY flag enabled # Only INSTRUCTOR_PACED_ONLY flag enabled
elif waffle.waffle().is_enabled(waffle.INSTRUCTOR_PACED_ONLY): if waffle.waffle().is_enabled(waffle.INSTRUCTOR_PACED_ONLY):
if courses.get_course_by_id(course_key, depth=0).self_paced: if courses.get_course_by_id(course_id, depth=0).self_paced:
return return
if GeneratedCertificate.certificate_for_student(self.user, self.course_id) is None: if fire_ungenerated_certificate_task(
generate_certificate.apply_async( user=user,
student=user, course_id=course_id
course_key=course_id, ):
)
log.info(u'Certificate generation task initiated for {user} : {course} via passing grade'.format( log.info(u'Certificate generation task initiated for {user} : {course} via passing grade'.format(
user=user.id, user=user.id,
course=course_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 ...@@ -6,10 +6,15 @@ import mock
from certificates import api as certs_api from certificates import api as certs_api
from certificates.config import waffle 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 certificates.signals import _listen_for_course_pacing_changed
from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory 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 openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration
from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
...@@ -72,10 +77,7 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase): ...@@ -72,10 +77,7 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase):
user=self.user, user=self.user,
course_id=self.course.id course_id=self.course.id
) )
mock_generate_certificate_apply_async.assert_not_called( mock_generate_certificate_apply_async.assert_not_called()
student=self.user,
course_key=self.course.id
)
with waffle.waffle().override(waffle.SELF_PACED_ONLY, active=True): with waffle.waffle().override(waffle.SELF_PACED_ONLY, active=True):
CertificateWhitelist.objects.create( CertificateWhitelist.objects.create(
user=self.user, user=self.user,
...@@ -100,10 +102,7 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase): ...@@ -100,10 +102,7 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase):
user=self.user, user=self.user,
course_id=self.ip_course.id course_id=self.ip_course.id
) )
mock_generate_certificate_apply_async.assert_not_called( mock_generate_certificate_apply_async.assert_not_called()
student=self.user,
course_key=self.ip_course.id
)
with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=True): with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=True):
CertificateWhitelist.objects.create( CertificateWhitelist.objects.create(
user=self.user, user=self.user,
...@@ -121,7 +120,9 @@ class PassingGradeCertsTest(ModuleStoreTestCase): ...@@ -121,7 +120,9 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
""" """
def setUp(self): def setUp(self):
super(PassingGradeCertsTest, self).setUp() super(PassingGradeCertsTest, self).setUp()
self.course = CourseFactory.create(self_paced=True) self.course = CourseFactory.create(
self_paced=True,
)
self.user = UserFactory.create() self.user = UserFactory.create()
self.enrollment = CourseEnrollmentFactory( self.enrollment = CourseEnrollmentFactory(
user=self.user, user=self.user,
...@@ -130,6 +131,12 @@ class PassingGradeCertsTest(ModuleStoreTestCase): ...@@ -130,6 +131,12 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
mode="verified", mode="verified",
) )
self.ip_course = CourseFactory.create(self_paced=False) 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): def test_cert_generation_on_passing_self_paced(self):
with mock.patch( with mock.patch(
...@@ -138,22 +145,13 @@ class PassingGradeCertsTest(ModuleStoreTestCase): ...@@ -138,22 +145,13 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
) as mock_generate_certificate_apply_async: ) as mock_generate_certificate_apply_async:
with waffle.waffle().override(waffle.SELF_PACED_ONLY, active=True): with waffle.waffle().override(waffle.SELF_PACED_ONLY, active=True):
grade_factory = CourseGradeFactory() grade_factory = CourseGradeFactory()
with mock_get_score(0, 2): # Not passing
grade_factory.update(self.user, self.course) grade_factory.update(self.user, self.course)
mock_generate_certificate_apply_async.assert_not_called( mock_generate_certificate_apply_async.assert_not_called()
student=self.user, # Certs fired after passing
course_key=self.course.id with mock_passing_grade():
)
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) 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, student=self.user,
course_key=self.course.id course_key=self.course.id
) )
...@@ -165,22 +163,96 @@ class PassingGradeCertsTest(ModuleStoreTestCase): ...@@ -165,22 +163,96 @@ class PassingGradeCertsTest(ModuleStoreTestCase):
) as mock_generate_certificate_apply_async: ) as mock_generate_certificate_apply_async:
with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=True): with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=True):
grade_factory = CourseGradeFactory() grade_factory = CourseGradeFactory()
with mock_get_score(0, 2): # Not passing
grade_factory.update(self.user, self.ip_course) grade_factory.update(self.user, self.ip_course)
mock_generate_certificate_apply_async.assert_not_called( mock_generate_certificate_apply_async.assert_not_called()
student=self.user, # Certs fired after passing
course_key=self.ip_course.id with mock_passing_grade():
)
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) 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, student=self.user,
course_key=self.ip_course.id 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): ...@@ -159,6 +159,7 @@ class CourseGradeFactory(object):
persistent_grade.letter_grade, persistent_grade.letter_grade,
persistent_grade.passed_timestamp is not None, persistent_grade.passed_timestamp is not None,
) )
log.info(u'Grades: Read, %s, User: %s, %s', unicode(course_data), user.id, persistent_grade) log.info(u'Grades: Read, %s, User: %s, %s', unicode(course_data), user.id, persistent_grade)
return course_grade, persistent_grade.grading_policy_hash return course_grade, persistent_grade.grading_policy_hash
...@@ -199,11 +200,11 @@ class CourseGradeFactory(object): ...@@ -199,11 +200,11 @@ class CourseGradeFactory(object):
course_key=course_data.course_key, course_key=course_data.course_key,
deadline=course_data.course.end, deadline=course_data.course.end,
) )
if course_grade.passed is True: if course_grade.passed:
COURSE_GRADE_NOW_PASSED.send_robust( COURSE_GRADE_NOW_PASSED.send(
sender=CourseGradeFactory, sender=CourseGradeFactory,
user=user, user=user,
course_key=course_data.course_key, course_id=course_data.course_key,
) )
log.info( log.info(
......
...@@ -186,7 +186,7 @@ class TestCourseGradeFactory(GradeTestBase): ...@@ -186,7 +186,7 @@ class TestCourseGradeFactory(GradeTestBase):
self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None) self.assertEqual(course_grade.letter_grade, u'Pass' if expected_pass else None)
self.assertEqual(course_grade.percent, 0.5) 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) _assert_create(expected_pass=True)
with self.assertNumQueries(13), mock_get_score(1, 2): with self.assertNumQueries(13), mock_get_score(1, 2):
......
...@@ -41,11 +41,13 @@ from lms.djangoapps.verify_student.ssencrypt import ( ...@@ -41,11 +41,13 @@ from lms.djangoapps.verify_student.ssencrypt import (
random_aes_key, random_aes_key,
rsa_encrypt 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.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.xmodule_django.models import CourseKeyField from openedx.core.djangoapps.xmodule_django.models import CourseKeyField
from openedx.core.djangolib.model_mixins import DeprecatedModelMixin from openedx.core.djangolib.model_mixins import DeprecatedModelMixin
from openedx.core.storage import get_storage from openedx.core.storage import get_storage
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -516,6 +518,11 @@ class PhotoVerification(StatusModel): ...@@ -516,6 +518,11 @@ class PhotoVerification(StatusModel):
self.reviewing_service = service self.reviewing_service = service
self.status = "approved" self.status = "approved"
self.save() 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") @status_before_must_be("must_retry", "submitted", "approved", "denied")
def deny(self, def deny(self,
......
"""
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 ...@@ -16,6 +16,9 @@ COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "stat
COURSE_GRADE_NOW_PASSED = Signal( COURSE_GRADE_NOW_PASSED = Signal(
providing_args=[ providing_args=[
'user', # user object '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'])
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment