From b8e04f30a13206aaff8821fbeafb1b67fe6d1089 Mon Sep 17 00:00:00 2001 From: Awais Date: Mon, 22 Jun 2015 14:38:19 +0500 Subject: [PATCH] ECOM-1597 adding signals to update min-grade requirement. --- .../tests/test_field_override_performance.py | 48 ++++----- lms/djangoapps/courseware/grades.py | 16 ++- .../tests/test_submitting_problems.py | 42 ++++++++ lms/djangoapps/courseware/tests/test_views.py | 1 + openedx/core/djangoapps/credit/signals.py | 42 ++++++++ .../djangoapps/credit/tests/test_signals.py | 100 ++++++++++++++++++ openedx/core/djangoapps/signals/__init__.py | 0 openedx/core/djangoapps/signals/signals.py | 9 ++ 8 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 openedx/core/djangoapps/credit/tests/test_signals.py create mode 100644 openedx/core/djangoapps/signals/__init__.py create mode 100644 openedx/core/djangoapps/signals/signals.py diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index 3b98b3b46b..afb013e3c1 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -173,18 +173,18 @@ class TestFieldOverrideMongoPerformance(FieldOverridePerformanceTestCase): TEST_DATA = { # (providers, course_width, enable_ccx): # of sql queries, # of mongo queries, # of xblocks - ('no_overrides', 1, True): (26, 7, 14), - ('no_overrides', 2, True): (134, 7, 85), - ('no_overrides', 3, True): (594, 7, 336), - ('ccx', 1, True): (26, 7, 14), - ('ccx', 2, True): (134, 7, 85), - ('ccx', 3, True): (594, 7, 336), - ('no_overrides', 1, False): (26, 7, 14), - ('no_overrides', 2, False): (134, 7, 85), - ('no_overrides', 3, False): (594, 7, 336), - ('ccx', 1, False): (26, 7, 14), - ('ccx', 2, False): (134, 7, 85), - ('ccx', 3, False): (594, 7, 336), + ('no_overrides', 1, True): (27, 7, 14), + ('no_overrides', 2, True): (135, 7, 85), + ('no_overrides', 3, True): (595, 7, 336), + ('ccx', 1, True): (27, 7, 14), + ('ccx', 2, True): (135, 7, 85), + ('ccx', 3, True): (595, 7, 336), + ('no_overrides', 1, False): (27, 7, 14), + ('no_overrides', 2, False): (135, 7, 85), + ('no_overrides', 3, False): (595, 7, 336), + ('ccx', 1, False): (27, 7, 14), + ('ccx', 2, False): (135, 7, 85), + ('ccx', 3, False): (595, 7, 336), } @@ -196,16 +196,16 @@ class TestFieldOverrideSplitPerformance(FieldOverridePerformanceTestCase): __test__ = True TEST_DATA = { - ('no_overrides', 1, True): (26, 4, 9), - ('no_overrides', 2, True): (134, 19, 54), - ('no_overrides', 3, True): (594, 84, 215), - ('ccx', 1, True): (26, 4, 9), - ('ccx', 2, True): (134, 19, 54), - ('ccx', 3, True): (594, 84, 215), - ('no_overrides', 1, False): (26, 4, 9), - ('no_overrides', 2, False): (134, 19, 54), - ('no_overrides', 3, False): (594, 84, 215), - ('ccx', 1, False): (26, 4, 9), - ('ccx', 2, False): (134, 19, 54), - ('ccx', 3, False): (594, 84, 215), + ('no_overrides', 1, True): (27, 4, 9), + ('no_overrides', 2, True): (135, 19, 54), + ('no_overrides', 3, True): (595, 84, 215), + ('ccx', 1, True): (27, 4, 9), + ('ccx', 2, True): (135, 19, 54), + ('ccx', 3, True): (595, 84, 215), + ('no_overrides', 1, False): (27, 4, 9), + ('no_overrides', 2, False): (135, 19, 54), + ('no_overrides', 3, False): (595, 84, 215), + ('ccx', 1, False): (27, 4, 9), + ('ccx', 2, False): (135, 19, 54), + ('ccx', 3, False): (595, 84, 215), } diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 755a05a762..566f8a3ea0 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -26,6 +26,7 @@ from submissions import api as sub_api # installed from the edx-submissions rep from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.signals.signals import GRADES_UPDATED log = logging.getLogger("edx.courseware") @@ -126,9 +127,22 @@ def grade(student, request, course, keep_raw_scores=False): """ Wraps "_grade" with the manual_transaction context manager just in case there are unanticipated errors. + Send a signal to update the minimum grade requirement status. """ with manual_transaction(): - return _grade(student, request, course, keep_raw_scores) + grade_summary = _grade(student, request, course, keep_raw_scores) + responses = GRADES_UPDATED.send_robust( + sender=None, + username=request.user.username, + grade_summary=grade_summary, + course_key=course.id, + deadline=course.end + ) + + for receiver, response in responses: + log.info('Signal fired when student grade is calculated. Receiver: %s. Response: %s', receiver, response) + + return grade_summary def _grade(student, request, course, keep_raw_scores): diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 34fa3d8161..bdb0d86c4c 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -26,6 +26,10 @@ from student.models import anonymous_id_for_user from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.partitions.partitions import Group, UserPartition +from openedx.core.djangoapps.credit.api import ( + set_credit_requirements, get_credit_requirement_status +) +from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory @@ -238,6 +242,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase): reverse('progress', kwargs={'course_id': self.course.id.to_deprecated_string()}) ) + fake_request.user = self.student_user return grades.grade(self.student_user, fake_request, self.course) def get_progress_summary(self): @@ -594,6 +599,43 @@ class TestCourseGrader(TestSubmittingProblems): self.assertEqual(self.earned_hw_scores(), [1.0, 2.0, 2.0]) # Order matters self.assertEqual(self.score_for_hw('homework3'), [1.0, 1.0]) + def test_min_grade_credit_requirements_status(self): + """ + Test for credit course. If user passes minimum grade requirement then + status will be updated as satisfied in requirement status table. + """ + self.basic_setup() + self.submit_question_answer('p1', {'2_1': 'Correct'}) + self.submit_question_answer('p2', {'2_1': 'Correct'}) + + # Enable the course for credit + credit_course = CreditCourse.objects.create( + course_key=self.course.id, + enabled=True, + ) + + # Configure a credit provider for the course + credit_provider = CreditProvider.objects.create( + provider_id="ASU", + enable_integration=True, + provider_url="https://credit.example.com/request", + ) + credit_course.providers.add(credit_provider) + credit_course.save() + + requirements = [{ + "namespace": "grade", + "name": "grade", + "display_name": "Grade", + "criteria": {"min_grade": 0.52} + }] + # Add a single credit requirement (final grade) + set_credit_requirements(self.course.id, requirements) + + self.get_grade_summary() + req_status = get_credit_requirement_status(self.course.id, self.student_user.username, 'grade', 'grade') + self.assertEqual(req_status[0]["status"], 'satisfied') + @attr('shard_1') class ProblemWithUploadedFilesTest(TestSubmittingProblems): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 217da31f53..e5b69c0952 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -946,6 +946,7 @@ class IsCoursePassedTests(ModuleStoreTestCase): grade_cutoffs={'cutoff': 0.75, 'Pass': self.SUCCESS_CUTOFF} ) self.request = RequestFactory() + self.request.user = self.student def test_user_fails_if_not_clear_exam(self): # If user has not grade then false will return diff --git a/openedx/core/djangoapps/credit/signals.py b/openedx/core/djangoapps/credit/signals.py index f0efdb0a89..043cb940bd 100644 --- a/openedx/core/djangoapps/credit/signals.py +++ b/openedx/core/djangoapps/credit/signals.py @@ -3,8 +3,11 @@ This file contains receivers of course publication signals. """ from django.dispatch import receiver +from django.utils import timezone +from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import SignalHandler +from openedx.core.djangoapps.signals.signals import GRADES_UPDATED @receiver(SignalHandler.course_published) @@ -18,3 +21,42 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable= from .tasks import update_credit_course_requirements update_credit_course_requirements.delay(unicode(course_key)) + + +@receiver(GRADES_UPDATED) +def listen_for_grade_calculation(sender, username, grade_summary, course_key, deadline, **kwargs): # pylint: disable=unused-argument + """Receive 'MIN_GRADE_REQUIREMENT_STATUS' signal and update minimum grade + requirement status. + + Args: + sender: None + username(string): user name + grade_summary(dict): Dict containing output from the course grader + course_key(CourseKey): The key for the course + deadline(datetime): Course end date or None + + Kwargs: + kwargs : None + + """ + from openedx.core.djangoapps.credit.api import ( + is_credit_course, get_credit_requirement, set_credit_requirement_status + ) + + course_id = CourseKey.from_string(unicode(course_key)) + is_credit = is_credit_course(course_id) + if is_credit: + requirement = get_credit_requirement(course_id, 'grade', 'grade') + if requirement: + criteria = requirement.get('criteria') + if criteria: + min_grade = criteria.get('min_grade') + if grade_summary['percent'] >= min_grade: + reason_dict = {'final_grade': grade_summary['percent']} + set_credit_requirement_status( + username, course_id, 'grade', 'grade', status="satisfied", reason=reason_dict + ) + elif deadline and deadline < timezone.now(): + set_credit_requirement_status( + username, course_id, 'grade', 'grade', status="failed", reason={} + ) diff --git a/openedx/core/djangoapps/credit/tests/test_signals.py b/openedx/core/djangoapps/credit/tests/test_signals.py new file mode 100644 index 0000000000..c5e2b143e9 --- /dev/null +++ b/openedx/core/djangoapps/credit/tests/test_signals.py @@ -0,0 +1,100 @@ +""" +Tests for minimum grade requirement status +""" + +import pytz +import ddt +from datetime import timedelta, datetime + +from django.test.client import RequestFactory + +from openedx.core.djangoapps.credit.api import ( + set_credit_requirements, get_credit_requirement_status +) + +from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider +from openedx.core.djangoapps.credit.signals import listen_for_grade_calculation +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@ddt.ddt +class TestMinGradedRequirementStatus(ModuleStoreTestCase): + """Test cases to check the minimum grade requirement status updated. + If user grade is above or equal to min-grade then status will be + satisfied. But if student grade is less than and deadline is passed then + user will be marked as failed. + """ + VALID_DUE_DATE = datetime.now(pytz.UTC) + timedelta(days=20) + EXPIRED_DUE_DATE = datetime.now(pytz.UTC) - timedelta(days=20) + + def setUp(self): + super(TestMinGradedRequirementStatus, self).setUp() + self.course = CourseFactory.create( + org='Robot', number='999', display_name='Test Course' + ) + + self.user = UserFactory() + self.request = RequestFactory().get('/') + self.request.user = self.user + self.client.login(username=self.user.username, password=self.user.password) + + # Enable the course for credit + credit_course = CreditCourse.objects.create( + course_key=self.course.id, + enabled=True, + ) + + # Configure a credit provider for the course + credit_provider = CreditProvider.objects.create( + provider_id="ASU", + enable_integration=True, + provider_url="https://credit.example.com/request", + ) + credit_course.providers.add(credit_provider) + credit_course.save() + + requirements = [{ + "namespace": "grade", + "name": "grade", + "display_name": "Grade", + "criteria": {"min_grade": 0.52} + }] + # Add a single credit requirement (final grade) + set_credit_requirements(self.course.id, requirements) + + @ddt.data( + (0.6, VALID_DUE_DATE), + (0.52, VALID_DUE_DATE), + (0.70, EXPIRED_DUE_DATE), + ) + @ddt.unpack + def test_min_grade_requirement_with_valid_grade(self, grade_achieved, due_date): + """Test with valid grades. Deadline date does not effect in case + of valid grade. + """ + + listen_for_grade_calculation(None, self.user.username, {'percent': grade_achieved}, self.course.id, due_date) + req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade') + self.assertEqual(req_status[0]["status"], 'satisfied') + + @ddt.data( + (0.50, None), + (0.51, None), + (0.40, VALID_DUE_DATE), + ) + @ddt.unpack + def test_min_grade_requirement_failed_grade_valid_deadline(self, grade_achieved, due_date): + """Test with failed grades and deadline is still open or not defined.""" + + listen_for_grade_calculation(None, self.user.username, {'percent': grade_achieved}, self.course.id, due_date) + req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade') + self.assertEqual(req_status[0]["status"], None) + + def test_min_grade_requirement_failed_grade_expired_deadline(self): + """Test with failed grades and deadline expire""" + + listen_for_grade_calculation(None, self.user.username, {'percent': 0.22}, self.course.id, self.EXPIRED_DUE_DATE) + req_status = get_credit_requirement_status(self.course.id, self.request.user.username, 'grade', 'grade') + self.assertEqual(req_status[0]["status"], 'failed') diff --git a/openedx/core/djangoapps/signals/__init__.py b/openedx/core/djangoapps/signals/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py new file mode 100644 index 0000000000..57200789f8 --- /dev/null +++ b/openedx/core/djangoapps/signals/signals.py @@ -0,0 +1,9 @@ +""" +This module contains all signals. +""" + +from django.dispatch import Signal + + +# Signal that fires when a user is graded (in lms/courseware/grades.py) +GRADES_UPDATED = Signal(providing_args=["username", "grade_summary", "course_key", "deadline"])