From b9425c5b5cf17b16404b5c132097421567b3ca8d Mon Sep 17 00:00:00 2001 From: Gregory Martin Date: Fri, 23 Jun 2017 13:56:49 -0400 Subject: [PATCH] Implement signal/handler for learner passing grades --- lms/djangoapps/certificates/signals.py | 40 ++++++++- .../certificates/tests/test_signals.py | 90 ++++++++++++++++++- .../grades/new/course_grade_factory.py | 12 ++- openedx/core/djangoapps/signals/signals.py | 8 ++ 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index fa4854e352..ff4a038ee5 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -9,10 +9,14 @@ from django.dispatch import receiver from opaque_keys.edx.keys import CourseKey from .config import waffle -from certificates.models import CertificateGenerationCourseSetting, CertificateWhitelist +from certificates.models import \ + CertificateGenerationCourseSetting, \ + CertificateWhitelist, \ + GeneratedCertificate from certificates.tasks import generate_certificate from courseware import courses from openedx.core.djangoapps.models.course_details import COURSE_PACING_CHANGE +from openedx.core.djangoapps.signals.signals import COURSE_GRADE_NOW_PASSED log = logging.getLogger(__name__) @@ -64,3 +68,37 @@ def toggle_self_generated_certs(course_key, course_self_paced): """ course_key = CourseKey.from_string(course_key) CertificateGenerationCourseSetting.set_enabled_for_course(course_key, course_self_paced) + + +@receiver(COURSE_GRADE_NOW_PASSED, dispatch_uid="new_passing_learner") +def _listen_for_passing_grade(sender, user, course_id, **kwargs): # pylint: disable=unused-argument + """ + 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 + not waffle.waffle().is_enabled(waffle.INSTRUCTOR_PACED_ONLY) + ): + return + + # 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: + 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: + return + if GeneratedCertificate.certificate_for_student(self.user, self.course_id) is None: + generate_certificate.apply_async( + student=user, + course_key=course_id, + ) + log.info(u'Certificate generation task initiated for {user} : {course} via passing grade'.format( + user=user.id, + course=course_id + )) diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index ae6e241130..35229015de 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -8,8 +8,10 @@ from certificates import api as certs_api from certificates.config import waffle from certificates.models import CertificateGenerationConfiguration, CertificateWhitelist 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 openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration -from student.tests.factories import UserFactory +from student.tests.factories import CourseEnrollmentFactory, UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -56,9 +58,10 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase): self.user = UserFactory.create() self.ip_course = CourseFactory.create(self_paced=False) - def test_cert_generation_on_whitelist_append(self): + def test_cert_generation_on_whitelist_append_self_paced(self): """ - Verify that signal is sent, received, and fires task based on various flag configs + Verify that signal is sent, received, and fires task + based on 'SELF_PACED_ONLY' flag """ with mock.patch( 'lms.djangoapps.certificates.signals.generate_certificate.apply_async', @@ -82,6 +85,16 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase): student=self.user, course_key=self.course.id, ) + + def test_cert_generation_on_whitelist_append_instructor_paced(self): + """ + Verify that signal is sent, received, and fires task + based on 'INSTRUCTOR_PACED_ONLY' flag + """ + with mock.patch( + 'lms.djangoapps.certificates.signals.generate_certificate.apply_async', + return_value=None + ) as mock_generate_certificate_apply_async: with waffle.waffle().override(waffle.INSTRUCTOR_PACED_ONLY, active=False): CertificateWhitelist.objects.create( user=self.user, @@ -100,3 +113,74 @@ class WhitelistGeneratedCertificatesTest(ModuleStoreTestCase): student=self.user, course_key=self.ip_course.id ) + + +class PassingGradeCertsTest(ModuleStoreTestCase): + """ + Tests for certificate generation task firing on passing grade receipt + """ + def setUp(self): + super(PassingGradeCertsTest, self).setUp() + self.course = CourseFactory.create(self_paced=True) + self.user = UserFactory.create() + self.enrollment = CourseEnrollmentFactory( + user=self.user, + course_id=self.course.id, + is_active=True, + mode="verified", + ) + self.ip_course = CourseFactory.create(self_paced=False) + + def test_cert_generation_on_passing_self_paced(self): + with mock.patch( + 'lms.djangoapps.certificates.signals.generate_certificate.apply_async', + return_value=None + ) 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): + 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( + student=self.user, + course_key=self.course.id + ) + + def test_cert_generation_on_passing_instructor_paced(self): + with mock.patch( + 'lms.djangoapps.certificates.signals.generate_certificate.apply_async', + return_value=None + ) 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): + 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( + student=self.user, + course_key=self.ip_course.id + ) diff --git a/lms/djangoapps/grades/new/course_grade_factory.py b/lms/djangoapps/grades/new/course_grade_factory.py index 0f82ceca0f..d3603cc819 100644 --- a/lms/djangoapps/grades/new/course_grade_factory.py +++ b/lms/djangoapps/grades/new/course_grade_factory.py @@ -3,7 +3,8 @@ from contextlib import contextmanager from logging import getLogger import dogstats_wrapper as dog_stats_api -from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED + +from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED, COURSE_GRADE_NOW_PASSED from ..config import assume_zero_if_absent, should_persist_grades from ..config.waffle import WRITE_ONLY_IF_ENGAGED, waffle @@ -167,7 +168,8 @@ class CourseGradeFactory(object): """ Computes, saves, and returns a CourseGrade object for the given user and course. - Sends a COURSE_GRADE_CHANGED signal to listeners. + Sends a COURSE_GRADE_CHANGED signal to listeners and a + COURSE_GRADE_NOW_PASSED if learner has passed course. """ course_grade = CourseGrade(user, course_data) course_grade.update() @@ -197,6 +199,12 @@ 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( + sender=CourseGradeFactory, + user=user, + course_key=course_data.course_key, + ) log.info( u'Grades: Update, %s, User: %s, %s, persisted: %s', diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index c50a3efce3..b38a6b565b 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -11,3 +11,11 @@ COURSE_GRADE_CHANGED = Signal(providing_args=["user", "course_grade", "course_ke # TODO: runtime coupling between apps will be reduced if this event is changed to carry a username # rather than a User object; however, this will require changes to the milestones and badges APIs COURSE_CERT_AWARDED = Signal(providing_args=["user", "course_key", "mode", "status"]) + +# Signal that indicates that a user has passed a course. +COURSE_GRADE_NOW_PASSED = Signal( + providing_args=[ + 'user', # user object + 'course_key', # course.id + ] +)