Merge pull request #14690 from edx/neem/fix-entrance-exam-grades
Fix grading for Entrance Exams
This commit is contained in:
@@ -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=_(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user