Skip to content
Snippets Groups Projects
Commit 16f06bf6 authored by Gregory Martin's avatar Gregory Martin
Browse files

Generate eligible certificates on learner track change

parent 32618fa0
No related merge requests found
...@@ -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'])
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment