diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index ff4a038ee5..a06771bb03 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -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 diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index 35229015de..c475140344 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -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 + ) diff --git a/lms/djangoapps/grades/new/course_grade_factory.py b/lms/djangoapps/grades/new/course_grade_factory.py index d3603cc819..dd92bac55a 100644 --- a/lms/djangoapps/grades/new/course_grade_factory.py +++ b/lms/djangoapps/grades/new/course_grade_factory.py @@ -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( diff --git a/lms/djangoapps/grades/tests/test_new.py b/lms/djangoapps/grades/tests/test_new.py index d1dbbac161..7dfb42c7df 100644 --- a/lms/djangoapps/grades/tests/test_new.py +++ b/lms/djangoapps/grades/tests/test_new.py @@ -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): diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 4c36e6d6da..07fa5634c8 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -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, diff --git a/openedx/core/djangoapps/signals/apps.py b/openedx/core/djangoapps/signals/apps.py new file mode 100644 index 0000000000..35965fc881 --- /dev/null +++ b/openedx/core/djangoapps/signals/apps.py @@ -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 diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index b38a6b565b..5d862bc90f 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -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'])