diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 95af81a9b9..9c39c7b549 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -675,6 +675,9 @@ class CourseFields(object): scope=Scope.settings, ) + # Note: Although users enter the entrance exam minimum score + # as a percentage value, it is internally converted and stored + # as a decimal value less than 1. entrance_exam_minimum_score_pct = Float( display_name=_("Entrance Exam Minimum Score (%)"), help=_( diff --git a/lms/djangoapps/courseware/entrance_exams.py b/lms/djangoapps/courseware/entrance_exams.py index 3968229fd1..7716d462df 100644 --- a/lms/djangoapps/courseware/entrance_exams.py +++ b/lms/djangoapps/courseware/entrance_exams.py @@ -43,118 +43,16 @@ def user_can_skip_entrance_exam(user, course): return False -def user_has_passed_entrance_exam(request, course): +def user_has_passed_entrance_exam(user, course): """ Checks to see if the user has attained a sufficient score to pass the exam Begin by short-circuiting if the course does not have an entrance exam """ if not course_has_entrance_exam(course): return True - if not request.user.is_authenticated(): + if not user.is_authenticated(): return False - entrance_exam_score = get_entrance_exam_score(request, course) - if entrance_exam_score >= course.entrance_exam_minimum_score_pct: - return True - return False - - -# pylint: disable=invalid-name -def user_must_complete_entrance_exam(request, user, course): - """ - Some courses can be gated on an Entrance Exam, which is a specially-configured chapter module which - presents users with a problem set which they must complete. This particular workflow determines - whether or not the user is allowed to clear the Entrance Exam gate and access the rest of the course. - """ - # First, let's see if the user is allowed to skip - if user_can_skip_entrance_exam(user, course): - return False - # If they can't actually skip the exam, we'll need to see if they've already passed it - if user_has_passed_entrance_exam(request, course): - return False - # Can't skip, haven't passed, must take the exam - return True - - -def _calculate_entrance_exam_score(user, course_descriptor, exam_modules): - """ - Calculates the score (percent) of the entrance exam using the provided modules - """ - student_module_dict = {} - scores_client = ScoresClient(course_descriptor.id, user.id) - # removing branch and version from exam modules locator - # otherwise student module would not return scores since module usage keys would not match - locations = [ - BlockUsageLocator( - course_key=course_descriptor.id, - block_type=exam_module.location.block_type, - block_id=exam_module.location.block_id - ) - if isinstance(exam_module.location, BlockUsageLocator) and exam_module.location.version - else exam_module.location - for exam_module in exam_modules - ] - scores_client.fetch_scores(locations) - - # Iterate over all of the exam modules to get score of user for each of them - for index, exam_module in enumerate(exam_modules): - exam_module_score = scores_client.get(locations[index]) - if exam_module_score: - student_module_dict[unicode(locations[index])] = { - 'grade': exam_module_score.correct, - 'max_grade': exam_module_score.total - } - exam_percentage = 0 - module_percentages = [] - ignore_categories = ['course', 'chapter', 'sequential', 'vertical'] - - for index, module in enumerate(exam_modules): - if module.graded and module.category not in ignore_categories: - module_percentage = 0 - module_location = unicode(locations[index]) - if module_location in student_module_dict and student_module_dict[module_location]['max_grade']: - student_module = student_module_dict[module_location] - module_percentage = student_module['grade'] / student_module['max_grade'] - - module_percentages.append(module_percentage) - if module_percentages: - exam_percentage = sum(module_percentages) / float(len(module_percentages)) - return exam_percentage - - -def get_entrance_exam_score(request, course): - """ - Gather the set of modules which comprise the entrance exam - Note that 'request' may not actually be a genuine request, due to the - circular nature of module_render calling entrance_exams and get_module_for_descriptor - being used here. In some use cases, the caller is actually mocking a request, although - in these scenarios the 'user' child object can be trusted and used as expected. - It's a much larger refactoring job to break this legacy mess apart, unfortunately. - """ - exam_key = UsageKey.from_string(course.entrance_exam_id) - exam_descriptor = modulestore().get_item(exam_key) - - def inner_get_module(descriptor): - """ - Delegate to get_module_for_descriptor (imported here to avoid circular reference) - """ - from courseware.module_render import get_module_for_descriptor - field_data_cache = FieldDataCache([descriptor], course.id, request.user) - return get_module_for_descriptor( - request.user, - request, - descriptor, - field_data_cache, - course.id, - course=course - ) - - exam_module_generators = yield_dynamic_descriptor_descendants( - exam_descriptor, - request.user.id, - inner_get_module - ) - exam_modules = [module for module in exam_module_generators] - return _calculate_entrance_exam_score(request.user, course, exam_modules) + return get_entrance_exam_content(user, course) is None def get_entrance_exam_content(user, course): diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index d541be7dca..a4287935b7 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -33,7 +33,7 @@ from xblock.reference.plugins import FSService import static_replace from courseware.access import has_access, get_user_role from courseware.entrance_exams import ( - user_must_complete_entrance_exam, + user_can_skip_entrance_exam, user_has_passed_entrance_exam ) from courseware.masquerade import ( @@ -164,7 +164,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ required_content = milestones_helpers.get_required_content(course, user) # The user may not actually have to complete the entrance exam, if one is required - if not user_must_complete_entrance_exam(request, user, course): + if user_can_skip_entrance_exam(user, course): required_content = [content for content in required_content if not content == course.entrance_exam_id] previous_of_active_section, next_of_active_section = None, None @@ -990,7 +990,7 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course and course \ and getattr(course, 'entrance_exam_enabled', False) \ and getattr(instance, 'in_entrance_exam', False): - ee_data = {'entrance_exam_passed': user_has_passed_entrance_exam(request, course)} + ee_data = {'entrance_exam_passed': user_has_passed_entrance_exam(request.user, course)} resp = append_data_to_webob_response(resp, ee_data) except NoSuchHandlerError: diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index cae742e0f4..2da310d4f3 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -6,7 +6,7 @@ from django.conf import settings from django.utils.translation import ugettext as _, ugettext_noop from courseware.access import has_access -from courseware.entrance_exams import user_must_complete_entrance_exam +from courseware.entrance_exams import user_can_skip_entrance_exam from openedx.core.lib.course_tabs import CourseTabPluginManager from student.models import CourseEnrollment from xmodule.tabs import CourseTab, CourseTabList, key_checker @@ -294,7 +294,7 @@ def get_course_tab_list(request, course): # If the user has to take an entrance exam, we'll need to hide away all but the # "Courseware" tab. The tab is then renamed as "Entrance Exam". course_tab_list = [] - must_complete_ee = user_must_complete_entrance_exam(request, user, course) + must_complete_ee = not user_can_skip_entrance_exam(user, course) for tab in xmodule_tab_list: if must_complete_ee: # Hide all of the tabs except for 'Courseware' diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index 8282c37e2f..bac392115b 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -17,7 +17,6 @@ from courseware.tests.helpers import ( from courseware.entrance_exams import ( course_has_entrance_exam, get_entrance_exam_content, - get_entrance_exam_score, user_can_skip_entrance_exam, user_has_passed_entrance_exam, ) @@ -281,32 +280,14 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest """ exam_chapter = get_entrance_exam_content(self.request.user, self.course) self.assertEqual(exam_chapter.url_name, self.entrance_exam.url_name) - self.assertFalse(user_has_passed_entrance_exam(self.request, self.course)) + self.assertFalse(user_has_passed_entrance_exam(self.request.user, self.course)) answer_entrance_exam_problem(self.course, self.request, self.problem_1) answer_entrance_exam_problem(self.course, self.request, self.problem_2) exam_chapter = get_entrance_exam_content(self.request.user, self.course) self.assertEqual(exam_chapter, None) - self.assertTrue(user_has_passed_entrance_exam(self.request, self.course)) - - def test_entrance_exam_score(self): - """ - test entrance exam score. we will hit the method get_entrance_exam_score to verify exam score. - """ - # One query is for getting the list of disabled XBlocks (which is - # then stored in the request). - with self.assertNumQueries(1): - exam_score = get_entrance_exam_score(self.request, self.course) - self.assertEqual(exam_score, 0) - - answer_entrance_exam_problem(self.course, self.request, self.problem_1) - answer_entrance_exam_problem(self.course, self.request, self.problem_2) - - with self.assertNumQueries(1): - exam_score = get_entrance_exam_score(self.request, self.course) - # 50 percent exam score should be achieved. - self.assertGreater(exam_score * 100, 50) + self.assertTrue(user_has_passed_entrance_exam(self.request.user, self.course)) def test_entrance_exam_requirement_message(self): """ @@ -332,6 +313,10 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest minimum_score_pct = 29 self.course.entrance_exam_minimum_score_pct = float(minimum_score_pct) / 100 modulestore().update_item(self.course, self.request.user.id) # pylint: disable=no-member + + # answer the problem so it results in only 20% correct. + answer_entrance_exam_problem(self.course, self.request, self.problem_1, value=1, max_value=5) + url = reverse( 'courseware_section', kwargs={ @@ -342,9 +327,11 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest ) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertIn('To access course materials, you must score {required_score}% or higher'.format( - required_score=minimum_score_pct - ), resp.content) + self.assertIn( + 'To access course materials, you must score {}% or higher'.format(minimum_score_pct), + resp.content + ) + self.assertIn('Your current score is 20%.', resp.content) def test_entrance_exam_requirement_message_hidden(self): """ @@ -388,7 +375,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest resp = self.client.get(url) self.assertNotIn('To access course materials, you must score', resp.content) - self.assertIn('You have passed the entrance exam.', resp.content) + self.assertIn('Your score is 100%. You have passed the entrance exam.', resp.content) self.assertIn('Lesson 1', resp.content) def test_entrance_exam_gating(self): @@ -450,7 +437,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest for toc_section in self.expected_unlocked_toc: self.assertIn(toc_section, unlocked_toc) - @patch('courseware.entrance_exams.user_has_passed_entrance_exam', Mock(return_value=False)) def test_courseware_page_access_without_passing_entrance_exam(self): """ Test courseware access page without passing entrance exam @@ -468,7 +454,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest }) self.assertRedirects(response, expected_url, status_code=302, target_status_code=200) - @patch('courseware.entrance_exams.user_has_passed_entrance_exam', Mock(return_value=False)) def test_courseinfo_page_access_without_passing_entrance_exam(self): """ Test courseware access page without passing entrance exam @@ -481,12 +466,11 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest exam_url = response.get('Location') self.assertRedirects(response, exam_url) - @patch('courseware.entrance_exams.user_has_passed_entrance_exam', Mock(return_value=True)) + @patch('courseware.entrance_exams.get_entrance_exam_content', Mock(return_value=None)) def test_courseware_page_access_after_passing_entrance_exam(self): """ Test courseware access page after passing entrance exam """ - # Mocking get_required_content with empty list to assume user has passed entrance exam self._assert_chapter_loaded(self.course, self.chapter) @patch('util.milestones_helpers.get_required_content', Mock(return_value=['a value'])) @@ -528,7 +512,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest Test has_passed_entrance_exam method with anonymous user """ self.request.user = self.anonymous_user - self.assertFalse(user_has_passed_entrance_exam(self.request, self.course)) + self.assertFalse(user_has_passed_entrance_exam(self.request.user, self.course)) def test_course_has_entrance_exam_missing_exam_id(self): course = CourseFactory.create( @@ -541,7 +525,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest def test_user_has_passed_entrance_exam_short_circuit_missing_exam(self): course = CourseFactory.create( ) - self.assertTrue(user_has_passed_entrance_exam(self.request, course)) + self.assertTrue(user_has_passed_entrance_exam(self.request.user, course)) @patch.dict("django.conf.settings.FEATURES", {'ENABLE_MASQUERADE': False}) def test_entrance_exam_xblock_response(self): @@ -599,7 +583,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest return toc['chapters'] -def answer_entrance_exam_problem(course, request, problem, user=None): +def answer_entrance_exam_problem(course, request, problem, user=None, value=1, max_value=1): """ Takes a required milestone `problem` in a `course` and fulfills it. @@ -608,11 +592,13 @@ def answer_entrance_exam_problem(course, request, problem, user=None): request (Request): request Object problem (xblock): xblock object, the problem to be fulfilled user (User): User object in case it is different from request.user + value (int): raw_earned value of the problem + max_value (int): raw_possible value of the problem """ if not user: user = request.user - grade_dict = {'value': 1, 'max_value': 1, 'user_id': user.id} + grade_dict = {'value': value, 'max_value': max_value, 'user_id': user.id} field_data_cache = FieldDataCache.cache_for_descriptor_descendents( course.id, user, diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index c0e2602ed8..a9bafbd7d3 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -296,13 +296,11 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase, Probl """ Returns SubsectionGrade for given url. """ - # list of grade summaries for each section - sections_list = [] - for chapter in self.get_course_grade().chapter_grades: - sections_list.extend(chapter['sections']) - - # get the first section that matches the url (there should only be one) - return next(section for section in sections_list if section.url_name == hw_url_name) + for chapter in self.get_course_grade().chapter_grades.itervalues(): + for section in chapter['sections']: + if section.url_name == hw_url_name: + return section + return None def score_for_hw(self, hw_url_name): """ diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 40a36cee54..fc9cce8552 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -22,7 +22,8 @@ import logging import newrelic.agent import urllib -from xblock.fragment import Fragment +from lms.djangoapps.gating.api import get_entrance_exam_score_ratio, get_entrance_exam_usage_key +from lms.djangoapps.grades.new.course_grade import CourseGradeFactory from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.user_api.preferences.api import get_user_preference @@ -31,11 +32,12 @@ from shoppingcart.models import CourseRegistrationCode from student.models import CourseEnrollment from student.views import is_course_blocked from student.roles import GlobalStaff +from survey.utils import must_answer_survey from util.enterprise_helpers import get_enterprise_consent_url from util.views import ensure_valid_course_key +from xblock.fragment import Fragment from xmodule.modulestore.django import modulestore from xmodule.x_module import STUDENT_VIEW -from survey.utils import must_answer_survey from ..access import has_access, _adjust_start_date_for_beta_testers from ..access_utils import in_preview_mode @@ -43,9 +45,8 @@ from ..courses import get_studio_url, get_course_with_access from ..entrance_exams import ( course_has_entrance_exam, get_entrance_exam_content, - get_entrance_exam_score, user_has_passed_entrance_exam, - user_must_complete_entrance_exam, + user_can_skip_entrance_exam, ) from ..exceptions import Redirect from ..masquerade import setup_masquerade @@ -276,10 +277,7 @@ class CoursewareIndex(View): """ Check to see if an Entrance Exam is required for the user. """ - if ( - course_has_entrance_exam(self.course) and - user_must_complete_entrance_exam(self.request, self.effective_user, self.course) - ): + if not user_can_skip_entrance_exam(self.effective_user, self.course): exam_chapter = get_entrance_exam_content(self.effective_user, self.course) if exam_chapter and exam_chapter.get_children(): exam_section = exam_chapter.get_children()[0] @@ -428,10 +426,7 @@ class CoursewareIndex(View): ) # entrance exam data - if course_has_entrance_exam(self.course): - if getattr(self.chapter, 'is_entrance_exam', False): - courseware_context['entrance_exam_current_score'] = get_entrance_exam_score(self.request, self.course) - courseware_context['entrance_exam_passed'] = user_has_passed_entrance_exam(self.request, self.course) + self._add_entrance_exam_to_context(courseware_context) # staff masquerading data now = datetime.now(UTC()) @@ -469,6 +464,17 @@ class CoursewareIndex(View): return courseware_context + def _add_entrance_exam_to_context(self, courseware_context): + """ + Adds entrance exam related information to the given context. + """ + if course_has_entrance_exam(self.course) and getattr(self.chapter, 'is_entrance_exam', False): + courseware_context['entrance_exam_passed'] = user_has_passed_entrance_exam(self.effective_user, self.course) + courseware_context['entrance_exam_current_score'] = get_entrance_exam_score_ratio( + CourseGradeFactory().create(self.effective_user, self.course), + get_entrance_exam_usage_key(self.course), + ) + def _create_section_context(self, previous_of_active_section, next_of_active_section): """ Returns and creates the rendering context for the section. diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 6a01baf548..482b6dddd4 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -101,7 +101,7 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from xmodule.tabs import CourseTabList from xmodule.x_module import STUDENT_VIEW -from ..entrance_exams import user_must_complete_entrance_exam +from ..entrance_exams import user_can_skip_entrance_exam from ..module_render import get_module_for_descriptor, get_module, get_module_by_usage_id from web_fragments.fragment import Fragment @@ -336,7 +336,7 @@ def course_info(request, course_id): # If the user needs to take an entrance exam to access this course, then we'll need # to send them to that specific course module before allowing them into other areas - if user_must_complete_entrance_exam(request, user, course): + if not user_can_skip_entrance_exam(user, course): return redirect(reverse('courseware', args=[unicode(course.id)])) # check to see if there is a required survey that must be taken before @@ -857,7 +857,7 @@ def _progress(request, course_key, student_id): student = User.objects.prefetch_related("groups").get(id=student.id) course_grade = CourseGradeFactory().create(student, course) - courseware_summary = course_grade.chapter_grades + courseware_summary = course_grade.chapter_grades.values() grade_summary = course_grade.summary studio_url = get_studio_url(course, 'settings/grading') diff --git a/lms/djangoapps/gating/api.py b/lms/djangoapps/gating/api.py index 14bf7677d2..baf6888c08 100644 --- a/lms/djangoapps/gating/api.py +++ b/lms/djangoapps/gating/api.py @@ -2,14 +2,12 @@ API for the gating djangoapp """ from collections import defaultdict -from django.test.client import RequestFactory import json import logging -from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_score +from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_content from openedx.core.lib.gating import api as gating_api from opaque_keys.edx.keys import UsageKey -from xmodule.modulestore.django import modulestore from util import milestones_helpers @@ -53,7 +51,7 @@ def _get_minimum_required_percentage(milestone): min_score = int(requirements.get('min_score')) except (ValueError, TypeError): log.warning( - 'Failed to find minimum score for gating milestone %s, defaulting to 100', + u'Gating: Failed to find minimum score for gating milestone %s, defaulting to 100', json.dumps(milestone) ) return min_score @@ -63,35 +61,56 @@ def _get_subsection_percentage(subsection_grade): """ Returns the percentage value of the given subsection_grade. """ - if subsection_grade.graded_total.possible: - return float(subsection_grade.graded_total.earned) / float(subsection_grade.graded_total.possible) * 100.0 - else: - return 0 + return _calculate_ratio(subsection_grade.graded_total.earned, subsection_grade.graded_total.possible) * 100.0 -def evaluate_entrance_exam(course, subsection_grade, user): +def _calculate_ratio(earned, possible): + """ + Returns the percentage of the given earned and possible values. + """ + return float(earned) / float(possible) if possible else 0.0 + + +def evaluate_entrance_exam(course_grade, user): """ Evaluates any entrance exam milestone relationships attached - to the given subsection. If the subsection_grade meets the - minimum score required, the dependent milestone will be marked + to the given course. If the course_grade meets the + minimum score required, the dependent milestones will be marked fulfilled for the user. """ + course = course_grade.course if milestones_helpers.is_entrance_exams_enabled() and getattr(course, 'entrance_exam_enabled', False): - subsection = modulestore().get_item(subsection_grade.location) - in_entrance_exam = getattr(subsection, 'in_entrance_exam', False) - if in_entrance_exam: - # We don't have access to the true request object in this context, but we can use a mock - request = RequestFactory().request() - request.user = user - exam_pct = get_entrance_exam_score(request, course) - if exam_pct >= course.entrance_exam_minimum_score_pct: - exam_key = UsageKey.from_string(course.entrance_exam_id) + if get_entrance_exam_content(user, course): + exam_chapter_key = get_entrance_exam_usage_key(course) + exam_score_ratio = get_entrance_exam_score_ratio(course_grade, exam_chapter_key) + if exam_score_ratio >= course.entrance_exam_minimum_score_pct: relationship_types = milestones_helpers.get_milestone_relationship_types() content_milestones = milestones_helpers.get_course_content_milestones( course.id, - exam_key, + exam_chapter_key, relationship=relationship_types['FULFILLS'] ) - # Mark each milestone dependent on the entrance exam as fulfilled by the user. + # Mark each entrance exam dependent milestone as fulfilled by the user. for milestone in content_milestones: - milestones_helpers.add_user_milestone({'id': request.user.id}, milestone) + milestones_helpers.add_user_milestone({'id': user.id}, milestone) + + +def get_entrance_exam_usage_key(course): + """ + Returns the UsageKey of the entrance exam for the course. + """ + return UsageKey.from_string(course.entrance_exam_id).replace(course_key=course.id) + + +def get_entrance_exam_score_ratio(course_grade, exam_chapter_key): + """ + Returns the score for the given chapter as a ratio of the + aggregated earned over the possible points, resulting in a + decimal value less than 1. + """ + try: + earned, possible = course_grade.score_for_chapter(exam_chapter_key) + except KeyError: + earned, possible = 0.0, 0.0 + log.warning(u'Gating: Unexpectedly failed to find chapter_grade for %s.', exam_chapter_key) + return _calculate_ratio(earned, possible) diff --git a/lms/djangoapps/gating/signals.py b/lms/djangoapps/gating/signals.py index 64a29dedfa..f05dd33b2d 100644 --- a/lms/djangoapps/gating/signals.py +++ b/lms/djangoapps/gating/signals.py @@ -5,6 +5,7 @@ from django.dispatch import receiver from gating import api as gating_api from lms.djangoapps.grades.signals.signals import SUBSECTION_SCORE_CHANGED +from openedx.core.djangoapps.signals.signals import COURSE_GRADE_CHANGED @receiver(SUBSECTION_SCORE_CHANGED) @@ -21,4 +22,18 @@ def evaluate_subsection_gated_milestones(**kwargs): """ subsection_grade = kwargs['subsection_grade'] gating_api.evaluate_prerequisite(kwargs['course'], subsection_grade, kwargs.get('user')) - gating_api.evaluate_entrance_exam(kwargs['course'], subsection_grade, kwargs.get('user')) + + +@receiver(COURSE_GRADE_CHANGED) +def evaluate_course_gated_milestones(**kwargs): + """ + Receives the COURSE_GRADE_CHANGED signal and triggers the + evaluation of any milestone relationships which are attached + to the course grade. + + Arguments: + kwargs (dict): Contains user, course_grade + Returns: + None + """ + gating_api.evaluate_entrance_exam(kwargs['course_grade'], kwargs.get('user')) diff --git a/lms/djangoapps/grades/new/course_grade.py b/lms/djangoapps/grades/new/course_grade.py index 25b597139b..c80a8a656b 100644 --- a/lms/djangoapps/grades/new/course_grade.py +++ b/lms/djangoapps/grades/new/course_grade.py @@ -49,7 +49,7 @@ class CourseGrade(object): a dict keyed by subsection format types. """ subsections_by_format = defaultdict(OrderedDict) - for chapter in self.chapter_grades: + for chapter in self.chapter_grades.itervalues(): for subsection_grade in chapter['sections']: if subsection_grade.graded: graded_total = subsection_grade.graded_total @@ -63,7 +63,7 @@ class CourseGrade(object): Returns a dict of problem scores keyed by their locations. """ locations_to_scores = {} - for chapter in self.chapter_grades: + for chapter in self.chapter_grades.itervalues(): for subsection_grade in chapter['sections']: locations_to_scores.update(subsection_grade.locations_to_scores) return locations_to_scores @@ -88,10 +88,12 @@ class CourseGrade(object): @lazy def chapter_grades(self): """ - Returns a list of chapters, each containing its subsection grades, - display name, and url name. + Returns a dictionary of dictionaries. + The primary dictionary is keyed by the chapter's usage_key. + The secondary dictionary contains the chapter's + subsection grades, display name, and url name. """ - chapter_grades = [] + chapter_grades = OrderedDict() for chapter_key in self.course_structure.get_children(self.course.location): chapter = self.course_structure[chapter_key] chapter_subsection_grades = [] @@ -101,11 +103,11 @@ class CourseGrade(object): self._subsection_grade_factory.create(self.course_structure[subsection_key], read_only=True) ) - chapter_grades.append({ + chapter_grades[chapter_key] = { 'display_name': block_metadata_utils.display_name_with_default_escaped(chapter), 'url_name': block_metadata_utils.url_name_for_block(chapter), 'sections': chapter_subsection_grades - }) + } return chapter_grades @property @@ -152,7 +154,7 @@ class CourseGrade(object): If read_only is True, doesn't save any updates to the grades. """ - subsections_total = sum(len(chapter['sections']) for chapter in self.chapter_grades) + subsections_total = sum(len(chapter['sections']) for chapter in self.chapter_grades.itervalues()) total_graded_subsections = sum(len(x) for x in self.graded_subsections_by_format.itervalues()) subsections_created = len(self._subsection_grade_factory._unsaved_subsection_grades) # pylint: disable=protected-access @@ -187,6 +189,19 @@ class CourseGrade(object): ) ) + def score_for_chapter(self, chapter_key): + """ + Returns the aggregate weighted score for the given chapter. + Raises: + KeyError if the chapter is not found. + """ + earned, possible = 0.0, 0.0 + chapter_grade = self.chapter_grades[chapter_key] + for section in chapter_grade['sections']: + earned += section.graded_total.earned + possible += section.graded_total.possible + return earned, possible + def score_for_module(self, location): """ Calculate the aggregate weighted score for any location in the course. @@ -201,8 +216,7 @@ class CourseGrade(object): score = self.locations_to_scores[location] return score.earned, score.possible children = self.course_structure.get_children(location) - earned = 0.0 - possible = 0.0 + earned, possible = 0.0, 0.0 for child in children: child_earned, child_possible = self.score_for_module(child) earned += child_earned