diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index ebfa0ec1db..71e32ea500 100644 --- a/common/djangoapps/util/milestones_helpers.py +++ b/common/djangoapps/util/milestones_helpers.py @@ -6,7 +6,6 @@ Utility library for working with the edx-milestones app from django.conf import settings from django.utils.translation import ugettext as _ -from courseware.models import StudentModule from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from xmodule.modulestore.django import modulestore @@ -165,12 +164,10 @@ def get_required_content(course, user): """ required_content = [] if settings.FEATURES.get('MILESTONES_APP', False): - from milestones import api as milestones_api from milestones.exceptions import InvalidMilestoneRelationshipTypeException - # Get all of the outstanding milestones for this course, for this user try: - milestone_paths = milestones_api.get_course_milestones_fulfillment_paths( + milestone_paths = get_course_milestones_fulfillment_paths( unicode(course.id), serialize_user(user) ) @@ -183,49 +180,9 @@ def get_required_content(course, user): if milestone_path.get('content') and len(milestone_path['content']): for content in milestone_path['content']: required_content.append(content) - - #local imports to avoid circular reference - from student.models import EntranceExamConfiguration - can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(user, course.id) - # check if required_content has any entrance exam and user is allowed to skip it - # then remove it from required content - if required_content and getattr(course, 'entrance_exam_enabled', False) and can_skip_entrance_exam: - descriptors = [modulestore().get_item(UsageKey.from_string(content)) for content in required_content] - entrance_exam_contents = [unicode(descriptor.location) - for descriptor in descriptors if descriptor.is_entrance_exam] - required_content = list(set(required_content) - set(entrance_exam_contents)) return required_content -def calculate_entrance_exam_score(user, course_descriptor, exam_modules): - """ - Calculates the score (percent) of the entrance exam using the provided modules - """ - exam_module_ids = [exam_module.location for exam_module in exam_modules] - student_modules = StudentModule.objects.filter( - student=user, - course_id=course_descriptor.id, - module_state_key__in=exam_module_ids, - ) - exam_pct = 0 - if student_modules: - module_pcts = [] - ignore_categories = ['course', 'chapter', 'sequential', 'vertical'] - for module in exam_modules: - if module.graded and module.category not in ignore_categories: - module_pct = 0 - try: - student_module = student_modules.get(module_state_key=module.location) - if student_module.max_grade: - module_pct = student_module.grade / student_module.max_grade - module_pcts.append(module_pct) - except StudentModule.DoesNotExist: - pass - if module_pcts: - exam_pct = sum(module_pcts) / float(len(module_pcts)) - return exam_pct - - def milestones_achieved_by_user(user, namespace): """ It would fetch list of milestones completed by user diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 12438d1cd0..488a255195 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -1037,7 +1037,7 @@ class EntranceExamTest(UniqueCourseTest): self.course_info['run'], self.course_info['display_name'] ).install() - self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.courseware_page = CoursewarePage(self.browser, self.course_id) self.settings_page = SettingsPage( self.browser, self.course_info['org'], @@ -1050,19 +1050,19 @@ class EntranceExamTest(UniqueCourseTest): def test_entrance_exam_section(self): """ - Scenario: Any course that is enabled for an entrance exam, should have entrance exam section at course info + Scenario: Any course that is enabled for an entrance exam, should have entrance exam chapter at courseware page. - Given that I am on the course info page - When I view the course info that has an entrance exam - Then there should be an "Entrance Exam" section.' + Given that I am on the courseware page + When I view the courseware that has an entrance exam + Then there should be an "Entrance Exam" chapter.' """ - - # visit course info page and make sure there is not entrance exam section. - self.course_info_page.visit() - self.course_info_page.wait_for_page() + entrance_exam_link_selector = 'div#accordion nav div h3 a' + # visit courseware page and make sure there is not entrance exam chapter. + self.courseware_page.visit() + self.courseware_page.wait_for_page() self.assertFalse(element_has_text( - page=self.course_info_page, - css_selector='div ol li a', + page=self.courseware_page, + css_selector=entrance_exam_link_selector, text='Entrance Exam' )) @@ -1082,10 +1082,10 @@ class EntranceExamTest(UniqueCourseTest): AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit() # visit course info page and make sure there is an "Entrance Exam" section. - self.course_info_page.visit() - self.course_info_page.wait_for_page() + self.courseware_page.visit() + self.courseware_page.wait_for_page() self.assertTrue(element_has_text( - page=self.course_info_page, - css_selector='div ol li a', + page=self.courseware_page, + css_selector=entrance_exam_link_selector, text='Entrance Exam' )) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index a2836dc504..759dff19cc 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -12,6 +12,7 @@ from django.contrib.auth.models import AnonymousUser from django.utils.timezone import UTC from opaque_keys.edx.keys import CourseKey, UsageKey + from xblock.core import XBlock from xmodule.course_module import ( @@ -25,12 +26,13 @@ from xmodule.partitions.partitions import NoSuchUserPartitionError, NoSuchUserPa from external_auth.models import ExternalAuthMap from courseware.masquerade import get_masquerade_role, is_masquerading_as_student from student import auth +from student.models import CourseEnrollment, CourseEnrollmentAllowed from student.roles import ( GlobalStaff, CourseStaffRole, CourseInstructorRole, OrgStaffRole, OrgInstructorRole, CourseBetaTesterRole ) -from student.models import CourseEnrollment, CourseEnrollmentAllowed from util.milestones_helpers import get_pre_requisite_courses_not_completed + import dogstats_wrapper as dog_stats_api DEBUG_ACCESS = False diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 35b0d7b4dc..28dbbf9b34 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -23,10 +23,8 @@ from courseware.model_data import FieldDataCache from courseware.module_render import get_module from student.models import CourseEnrollment import branding -from util.milestones_helpers import get_required_content, calculate_entrance_exam_score -from util.module_utils import yield_dynamic_descriptor_descendents + from opaque_keys.edx.keys import UsageKey -from .module_render import get_module_for_descriptor log = logging.getLogger(__name__) @@ -449,47 +447,3 @@ def get_problems_in_section(section): problem_descriptors[unicode(component.location)] = component return problem_descriptors - - -def get_entrance_exam_score(request, course): - """ - Get entrance exam score - """ - 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. - """ - field_data_cache = FieldDataCache([descriptor], course.id, request.user) - return get_module_for_descriptor(request.user, request, descriptor, field_data_cache, course.id) - - exam_module_generators = yield_dynamic_descriptor_descendents( - exam_descriptor, - inner_get_module - ) - exam_modules = [module for module in exam_module_generators] - return calculate_entrance_exam_score(request.user, course, exam_modules) - - -def get_entrance_exam_content_info(request, course): - """ - Get the entrance exam content information e.g. chapter, exam passing state. - return exam chapter and its passing state. - """ - required_content = get_required_content(course, request.user) - exam_chapter = None - is_exam_passed = True - # Iterating the list of required content of this course. - for content in required_content: - # database lookup to required content pointer - usage_key = course.id.make_usage_key_from_deprecated_string(content) - module_item = modulestore().get_item(usage_key) - if not module_item.hide_from_toc and module_item.is_entrance_exam: - # Here we are looking for entrance exam module/chapter in required_content. - # If module_item is an entrance exam chapter then set and return its info e.g. exam chapter, exam state. - exam_chapter = module_item - is_exam_passed = False - break - return exam_chapter, is_exam_passed diff --git a/lms/djangoapps/courseware/entrance_exams.py b/lms/djangoapps/courseware/entrance_exams.py new file mode 100644 index 0000000000..e7a0400ec1 --- /dev/null +++ b/lms/djangoapps/courseware/entrance_exams.py @@ -0,0 +1,171 @@ +""" +This file contains all entrance exam related utils/logic. +""" +from django.conf import settings + +from courseware.access import has_access +from courseware.model_data import FieldDataCache +from courseware.models import StudentModule +from opaque_keys.edx.keys import UsageKey +from student.models import EntranceExamConfiguration +from util.milestones_helpers import get_required_content +from util.module_utils import yield_dynamic_descriptor_descendents +from xmodule.modulestore.django import modulestore + + +def feature_is_enabled(): + """ + Checks to see if the Entrance Exams feature is enabled + Use this operation instead of checking the feature flag all over the place + """ + return settings.FEATURES.get('ENTRANCE_EXAMS', False) + + +def course_has_entrance_exam(course): + """ + Checks to see if a course is properly configured for an entrance exam + """ + if not feature_is_enabled(): + return False + if not course.entrance_exam_enabled: + return False + if not course.entrance_exam_id: + return False + return True + + +def user_can_skip_entrance_exam(request, user, course): + """ + Checks all of the various override conditions for a user to skip an entrance 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 user.is_authenticated(): + return False + if has_access(user, 'staff', course): + return True + if EntranceExamConfiguration.user_can_skip_entrance_exam(user, course.id): + return True + if not get_entrance_exam_content(request, course): + return True + return False + + +def user_has_passed_entrance_exam(request, 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(): + 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(request, 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 + """ + # All of the exam module ids + exam_module_ids = [exam_module.location for exam_module in exam_modules] + + # All of the corresponding student module records + student_modules = StudentModule.objects.filter( + student=user, + course_id=course_descriptor.id, + module_state_key__in=exam_module_ids, + ) + student_module_dict = {} + for student_module in student_modules: + student_module_dict[unicode(student_module.module_state_key)] = { + 'grade': student_module.grade, + 'max_grade': student_module.max_grade + } + exam_percentage = 0 + module_percentages = [] + ignore_categories = ['course', 'chapter', 'sequential', 'vertical'] + + for module in exam_modules: + if module.graded and module.category not in ignore_categories: + module_percentage = 0 + module_location = unicode(module.location) + 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 + ) + + exam_module_generators = yield_dynamic_descriptor_descendents( + exam_descriptor, + inner_get_module + ) + exam_modules = [module for module in exam_module_generators] + return _calculate_entrance_exam_score(request.user, course, exam_modules) + + +def get_entrance_exam_content(request, course): + """ + Get the entrance exam content information (ie, chapter module) + """ + required_content = get_required_content(course, request.user) + + exam_module = None + for content in required_content: + usage_key = course.id.make_usage_key_from_deprecated_string(content) + module_item = modulestore().get_item(usage_key) + if not module_item.hide_from_toc and module_item.is_entrance_exam: + exam_module = module_item + break + return exam_module diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index ef99331edc..b9040150ad 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -23,12 +23,17 @@ from django.core.context_processors import csrf from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import Http404, HttpResponse +from django.test.client import RequestFactory from django.views.decorators.csrf import csrf_exempt from capa.xqueue_interface import XQueueInterface from courseware.access import has_access, get_user_role from courseware.masquerade import setup_masquerade from courseware.model_data import FieldDataCache, DjangoKeyValueStore +from courseware.entrance_exams import ( + get_entrance_exam_score, + user_must_complete_entrance_exam +) from lms.djangoapps.lms_xblock.field_data import LmsFieldData from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig @@ -132,11 +137,17 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c if course_module is None: return None - # Check to see if the course is gated on milestone-required content (such as an Entrance Exam) + toc_chapters = list() + chapters = course_module.get_display_items() + + # See if the course is gated by one or more content milestones required_content = milestones_helpers.get_required_content(course, request.user) - chapters = list() - for chapter in course_module.get_display_items(): + # The user may not actually have to complete the entrance exam, if one is required + if not user_must_complete_entrance_exam(request, request.user, course): + required_content = [content for content in required_content if not content == course.entrance_exam_id] + + for chapter in chapters: # Only show required content, if there is required content # chapter.hide_from_toc is read-only (boo) local_hide_from_toc = False @@ -162,11 +173,13 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c 'active': active, 'graded': section.graded, }) - chapters.append({'display_name': chapter.display_name_with_default, - 'url_name': chapter.url_name, - 'sections': sections, - 'active': chapter.url_name == active_chapter}) - return chapters + toc_chapters.append({ + 'display_name': chapter.display_name_with_default, + 'url_name': chapter.url_name, + 'sections': sections, + 'active': chapter.url_name == active_chapter + }) + return toc_chapters def get_module(user, request, usage_key, field_data_cache, @@ -361,20 +374,6 @@ def get_module_system_for_user(user, field_data_cache, request_token=request_token, ) - def _calculate_entrance_exam_score(user, course_descriptor): - """ - Internal helper to calculate a user's score for a course's entrance exam - """ - exam_key = UsageKey.from_string(course_descriptor.entrance_exam_id) - exam_descriptor = modulestore().get_item(exam_key) - exam_module_generators = yield_dynamic_descriptor_descendents( - exam_descriptor, - inner_get_module - ) - exam_modules = [module for module in exam_module_generators] - exam_score = milestones_helpers.calculate_entrance_exam_score(user, course_descriptor, exam_modules) - return exam_score - def _fulfill_content_milestones(user, course_key, content_key): """ Internal helper to handle milestone fulfillments for the specified content module @@ -388,7 +387,10 @@ def get_module_system_for_user(user, field_data_cache, entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', False) in_entrance_exam = getattr(content, 'in_entrance_exam', False) if entrance_exam_enabled and in_entrance_exam: - exam_pct = _calculate_entrance_exam_score(user, course) + # 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) relationship_types = milestones_helpers.get_milestone_relationship_types() @@ -398,7 +400,7 @@ def get_module_system_for_user(user, field_data_cache, relationship=relationship_types['FULFILLS'] ) # Add each milestone to the user's set... - user = {'id': user.id} + user = {'id': request.user.id} for milestone in content_milestones: milestones_helpers.add_user_milestone(user, milestone) @@ -437,15 +439,13 @@ def get_module_system_for_user(user, field_data_cache, dog_stats_api.increment("lms.courseware.question_answered", tags=tags) - # If we're using the awesome edx-milestones app, we need to cycle - # through the fulfillment scenarios to see if any are now applicable + # Cycle through the milestone fulfillment scenarios to see if any are now applicable # thanks to the updated grading information that was just submitted - if settings.FEATURES.get('MILESTONES_APP', False): - _fulfill_content_milestones( - user, - course_id, - descriptor.location, - ) + _fulfill_content_milestones( + user, + course_id, + descriptor.location, + ) def publish(block, event_type, event): """A function that allows XModules to publish events.""" diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 59261fcfd3..cca02a73bb 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -3,9 +3,11 @@ This module is essentially a broker to xmodule/tabs.py -- it was originally intr perform some LMS-specific tab display gymnastics for the Entrance Exams feature """ from django.conf import settings +from django.test.client import RequestFactory from django.utils.translation import ugettext as _ from courseware.access import has_access +from courseware.entrance_exams import user_must_complete_entrance_exam from student.models import CourseEnrollment, EntranceExamConfiguration from xmodule.tabs import CourseTabList @@ -25,29 +27,15 @@ def get_course_tab_list(course, user): user_is_enrolled ) - # Entrance Exams Feature - # If the course has an entrance exam, we'll need to see if the user has not passed it - # If so, we'll need to hide away all of the tabs except for Courseware and Instructor - entrance_exam_mode = False - if settings.FEATURES.get('ENTRANCE_EXAMS', False): - if getattr(course, 'entrance_exam_enabled', False): - course_milestones_paths = milestones_helpers.get_course_milestones_fulfillment_paths( - unicode(course.id), - milestones_helpers.serialize_user(user) - ) - for __, value in course_milestones_paths.iteritems(): - if len(value.get('content', [])): - for content in value['content']: - if content == course.entrance_exam_id \ - and not EntranceExamConfiguration.user_can_skip_entrance_exam(user, course.id): - entrance_exam_mode = True - break - - # Now that we've loaded the tabs for this course, perform the Entrance Exam mode work - # Majority case is no entrance exam defined + # Now that we've loaded the tabs for this course, perform the Entrance Exam work + # If the user has to take an entrance exam, we'll need to hide away all of the tabs + # except for the Courseware and Instructor tabs (latter is only viewed if applicable) + # 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 course_tab_list = [] for tab in xmodule_tab_list: - if entrance_exam_mode: + if user_must_complete_entrance_exam(request, user, course): # Hide all of the tabs except for 'Courseware' and 'Instructor' # Rename 'Courseware' tab to 'Entrance Exam' if tab.type not in ['courseware', 'instructor']: diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index 192e167093..da8a20426f 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -7,18 +7,34 @@ from django.core.urlresolvers import reverse from courseware.model_data import FieldDataCache from courseware.module_render import get_module, toc_for_course -from courseware.tests.factories import UserFactory, InstructorFactory -from courseware.courses import get_entrance_exam_content_info, get_entrance_exam_score +from courseware.tests.factories import UserFactory, InstructorFactory, StaffFactory +from courseware.tests.helpers import LoginEnrollmentTestCase +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, +) from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from util import milestones_helpers +from util.milestones_helpers import ( + add_milestone, + add_course_milestone, + get_namespace_choices, + generate_milestone_namespace, + add_course_content_milestone, + get_milestone_relationship_types, + seed_milestone_relationship_types, +) from student.models import CourseEnrollment -from mock import patch +from student.tests.factories import CourseEnrollmentFactory, AnonymousUserFactory +from mock import patch, Mock import mock -class EntranceExamTestCases(ModuleStoreTestCase): +class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): """ Check that content is properly gated. Create a test course from scratch to mess with. We typically assume that the Entrance Exam feature flag is set to True in test.py @@ -110,8 +126,8 @@ class EntranceExamTestCases(ModuleStoreTestCase): display_name="Exam Problem - Problem 3" ) if settings.FEATURES.get('ENTRANCE_EXAMS', False): - namespace_choices = milestones_helpers.get_namespace_choices() - milestone_namespace = milestones_helpers.generate_milestone_namespace( + namespace_choices = get_namespace_choices() + milestone_namespace = generate_milestone_namespace( namespace_choices.get('ENTRANCE_EXAM'), self.course.id ) @@ -120,20 +136,21 @@ class EntranceExamTestCases(ModuleStoreTestCase): 'namespace': milestone_namespace, 'description': 'Testing Courseware Entrance Exam Chapter', } - milestones_helpers.seed_milestone_relationship_types() - self.milestone_relationship_types = milestones_helpers.get_milestone_relationship_types() - self.milestone = milestones_helpers.add_milestone(self.milestone) - milestones_helpers.add_course_milestone( + seed_milestone_relationship_types() + self.milestone_relationship_types = get_milestone_relationship_types() + self.milestone = add_milestone(self.milestone) + add_course_milestone( unicode(self.course.id), self.milestone_relationship_types['REQUIRES'], self.milestone ) - milestones_helpers.add_course_content_milestone( + add_course_content_milestone( unicode(self.course.id), unicode(self.entrance_exam.location), self.milestone_relationship_types['FULFILLS'], self.milestone ) + self.anonymous_user = AnonymousUserFactory() user = UserFactory() self.request = RequestFactory() self.request.user = user @@ -282,14 +299,14 @@ class EntranceExamTestCases(ModuleStoreTestCase): self.assertIn('Exam Problem - Problem 1', resp.content) self.assertIn('Exam Problem - Problem 2', resp.content) - def test_entrance_exam_content_info(self): + def test_get_entrance_exam_content(self): """ - test entrance exam content info method + test get entrance exam content method """ - exam_chapter, is_exam_passed = get_entrance_exam_content_info(self.request, self.course) + exam_chapter = get_entrance_exam_content(self.request, self.course) if settings.FEATURES.get('ENTRANCE_EXAMS', False): self.assertEqual(exam_chapter.url_name, self.entrance_exam.url_name) - self.assertEqual(is_exam_passed, False) + self.assertFalse(user_has_passed_entrance_exam(self.request, self.course)) # Pass the entrance exam # pylint: disable=maybe-no-member,no-member @@ -309,9 +326,18 @@ class EntranceExamTestCases(ModuleStoreTestCase): )._xmodule module.system.publish(self.problem_1, 'grade', grade_dict) - exam_chapter, is_exam_passed = get_entrance_exam_content_info(self.request, self.course) + # pylint: disable=protected-access + module = get_module( + self.request.user, + self.request, + self.problem_2.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_2, 'grade', grade_dict) + + exam_chapter = get_entrance_exam_content(self.request, self.course) self.assertEqual(exam_chapter, None) - self.assertEqual(is_exam_passed, True) + self.assertTrue(user_has_passed_entrance_exam(self.request, self.course)) @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True}) def test_entrance_exam_score(self): @@ -323,7 +349,7 @@ class EntranceExamTestCases(ModuleStoreTestCase): # Pass the entrance exam # pylint: disable=maybe-no-member,no-member - grade_dict = {'value': 1, 'max_value': 2, 'user_id': self.request.user.id} + grade_dict = {'value': 1, 'max_value': 1, 'user_id': self.request.user.id} field_data_cache = FieldDataCache.cache_for_descriptor_descendents( self.course.id, self.request.user, @@ -339,9 +365,18 @@ class EntranceExamTestCases(ModuleStoreTestCase): )._xmodule module.system.publish(self.problem_1, 'grade', grade_dict) + # pylint: disable=protected-access + module = get_module( + self.request.user, + self.request, + self.problem_2.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_2, 'grade', grade_dict) + exam_score = get_entrance_exam_score(self.request, self.course) # 50 percent exam score should be achieved. - self.assertEqual(exam_score * 100, 50) + self.assertGreater(exam_score * 100, 50) def test_entrance_exam_requirement_message(self): """ @@ -364,6 +399,12 @@ class EntranceExamTestCases(ModuleStoreTestCase): """ Unit Test: entrance exam message should not be present outside the context of entrance exam subsection. """ + # Login as staff to avoid redirect to entrance exam + self.client.logout() + staff_user = StaffFactory(course_key=self.course.id) + self.client.login(username=staff_user.username, password='test') + CourseEnrollment.enroll(staff_user, self.course.id) + url = reverse( 'courseware_section', kwargs={ @@ -409,13 +450,22 @@ class EntranceExamTestCases(ModuleStoreTestCase): )._xmodule module.system.publish(self.problem_1, 'grade', grade_dict) + # pylint: disable=protected-access + module = get_module( + self.request.user, + self.request, + self.problem_2.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(self.problem_2, 'grade', grade_dict) + resp = self.client.get(url) if settings.FEATURES.get('ENTRANCE_EXAMS', False): self.assertNotIn('To access course materials, you must score', resp.content) self.assertIn('You have passed the entrance exam.', resp.content) self.assertIn('Lesson 1', resp.content) - @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True}) + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) def test_entrance_exam_gating(self): """ Unit Test: test_entrance_exam_gating @@ -486,7 +536,7 @@ class EntranceExamTestCases(ModuleStoreTestCase): self.assertIn(toc_section, unlocked_toc) @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True}) - def test_skip_entrance_exame_gating(self): + def test_skip_entrance_exam_gating(self): """ Tests gating is disabled if skip entrance exam is set for a user. """ @@ -519,3 +569,137 @@ class EntranceExamTestCases(ModuleStoreTestCase): ) for toc_section in self.expected_unlocked_toc: self.assertIn(toc_section, unlocked_toc) + + def test_entrance_exam_gating_for_staff(self): + """ + Tests gating is disabled if user is member of staff. + """ + + # Login as member of staff + self.client.logout() + staff_user = StaffFactory(course_key=self.course.id) + staff_user.is_staff = True + self.client.login(username=staff_user.username, password='test') + + # assert staff has access to all toc + self.request.user = staff_user + unlocked_toc = toc_for_course( + self.request, + self.course, + self.entrance_exam.url_name, + self.exam_1.url_name, + self.field_data_cache + ) + for toc_section in self.expected_unlocked_toc: + self.assertIn(toc_section, unlocked_toc) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + @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 + """ + url = reverse( + 'courseware_chapter', + kwargs={'course_id': unicode(self.course.id), 'chapter': self.chapter.url_name} + ) + response = self.client.get(url) + redirect_url = reverse('courseware', args=[unicode(self.course.id)]) + self.assertRedirects(response, redirect_url, status_code=302, target_status_code=302) + response = self.client.get(redirect_url) + exam_url = response.get('Location') + self.assertRedirects(response, exam_url) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + @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 + """ + url = reverse('info', args=[unicode(self.course.id)]) + response = self.client.get(url) + redirect_url = reverse('courseware', args=[unicode(self.course.id)]) + self.assertRedirects(response, redirect_url, status_code=302, target_status_code=302) + response = self.client.get(redirect_url) + exam_url = response.get('Location') + self.assertRedirects(response, exam_url) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + @patch('courseware.entrance_exams.user_has_passed_entrance_exam', Mock(return_value=True)) + 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.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + @patch('util.milestones_helpers.get_required_content', Mock(return_value=['a value'])) + def test_courseware_page_access_with_staff_user_without_passing_entrance_exam(self): + """ + Test courseware access page without passing entrance exam but with staff user + """ + self.logout() + staff_user = StaffFactory.create(course_key=self.course.id) + self.login(staff_user.email, 'test') + CourseEnrollmentFactory(user=staff_user, course_id=self.course.id) + self._assert_chapter_loaded(self.course, self.chapter) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + def test_courseware_page_access_with_staff_user_after_passing_entrance_exam(self): + """ + Test courseware access page after passing entrance exam but with staff user + """ + self.logout() + staff_user = StaffFactory.create(course_key=self.course.id) + self.login(staff_user.email, 'test') + CourseEnrollmentFactory(user=staff_user, course_id=self.course.id) + self._assert_chapter_loaded(self.course, self.chapter) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': False}) + def test_courseware_page_access_when_entrance_exams_disabled(self): + """ + Test courseware page access when ENTRANCE_EXAMS feature is disabled + """ + self._assert_chapter_loaded(self.course, self.chapter) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + def test_can_skip_entrance_exam_with_anonymous_user(self): + """ + Test can_skip_entrance_exam method with anonymous user + """ + self.assertFalse(user_can_skip_entrance_exam(self.request, self.anonymous_user, self.course)) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + def test_has_passed_entrance_exam_with_anonymous_user(self): + """ + 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)) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + def test_course_has_entrance_exam_missing_exam_id(self): + course = CourseFactory.create( + metadata={ + 'entrance_exam_enabled': True, + } + ) + self.assertFalse(course_has_entrance_exam(course)) + + @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': True}) + 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)) + + def _assert_chapter_loaded(self, course, chapter): + """ + Asserts courseware chapter load successfully. + """ + url = reverse( + 'courseware_chapter', + kwargs={'course_id': unicode(course.id), 'chapter': chapter.url_name} + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index a4af387acb..eb083f442c 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -10,7 +10,7 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from courseware.courses import get_course_by_id from courseware.tests.helpers import get_request_for_user, LoginEnrollmentTestCase -from courseware.tests.factories import InstructorFactory +from courseware.tests.factories import InstructorFactory, StaffFactory from xmodule import tabs from xmodule.modulestore.tests.django_utils import ( TEST_DATA_MIXED_TOY_MODULESTORE, TEST_DATA_MIXED_CLOSED_MODULESTORE @@ -146,15 +146,18 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): Unit Test: test_get_course_tabs_list_entrance_exam_enabled """ entrance_exam = ItemFactory.create( - category="chapter", parent_location=self.course.location, - data="Exam Data", display_name="Entrance Exam" + category="chapter", + parent_location=self.course.location, + data="Exam Data", + display_name="Entrance Exam", + is_entrance_exam=True ) - entrance_exam.is_entrance_exam = True milestone = { 'name': 'Test Milestone', 'namespace': '{}.entrance_exams'.format(unicode(self.course.id)), 'description': 'Testing Courseware Tabs' } + self.user.is_staff = False self.course.entrance_exam_enabled = True self.course.entrance_exam_id = unicode(entrance_exam.location) milestone = milestones_helpers.add_milestone(milestone) @@ -170,10 +173,9 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): milestone ) course_tab_list = get_course_tab_list(self.course, self.user) - self.assertEqual(len(course_tab_list), 2) + self.assertEqual(len(course_tab_list), 1) self.assertEqual(course_tab_list[0]['tab_id'], 'courseware') self.assertEqual(course_tab_list[0]['name'], 'Entrance Exam') - self.assertEqual(course_tab_list[1]['tab_id'], 'instructor') def test_get_course_tabs_list_skipped_entrance_exam(self): """ @@ -198,6 +200,19 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): course_tab_list = get_course_tab_list(self.course, self.user) self.assertEqual(len(course_tab_list), 5) + def test_course_tabs_list_for_staff_members(self): + """ + Tests tab list is not limited if user is member of staff + and has not passed entrance exam. + """ + # Login as member of staff + self.client.logout() + staff_user = StaffFactory(course_key=self.course.id) + self.client.login(username=staff_user.username, password='test') + + course_tab_list = get_course_tab_list(self.course, staff_user) + self.assertEqual(len(course_tab_list), 5) + class TextBookTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): """ diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 7b19c24c51..d8882b6bd0 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -32,12 +32,22 @@ from markupsafe import escape from courseware import grades from courseware.access import has_access, _adjust_start_date_for_beta_testers -from courseware.courses import get_courses, get_course, get_studio_url, get_course_with_access, sort_by_announcement,\ - get_entrance_exam_content_info -from courseware.courses import sort_by_start_date, get_entrance_exam_score +from courseware.courses import ( + get_courses, get_course, + get_studio_url, get_course_with_access, + sort_by_announcement, + sort_by_start_date, +) from courseware.masquerade import setup_masquerade from courseware.model_data import FieldDataCache from .module_render import toc_for_course, get_module_for_descriptor, get_module +from .entrance_exams import ( + course_has_entrance_exam, + get_entrance_exam_content, + get_entrance_exam_score, + user_must_complete_entrance_exam, + user_has_passed_entrance_exam +) from courseware.models import StudentModule, StudentModuleHistory from course_modes.models import CourseMode @@ -366,6 +376,15 @@ def _index_bulk_op(request, course_key, chapter, section, position): user.id, unicode(course.id)) return redirect(reverse('dashboard')) + # Entrance Exam Check + # If the course has an entrance exam and the requested chapter is NOT the entrance exam, and + # the user hasn't yet met the criteria to bypass the entrance exam, redirect them to the exam. + if chapter and course_has_entrance_exam(course): + chapter_descriptor = course.get_child_by(lambda m: m.location.name == chapter) + if chapter_descriptor and not getattr(chapter_descriptor, 'is_entrance_exam', False) \ + and user_must_complete_entrance_exam(request, user, course): + log.info(u'User %d tried to view course %s without passing entrance exam', user.id, unicode(course.id)) + return redirect(reverse('courseware', args=[unicode(course.id)])) # check to see if there is a required survey that must be taken before # the user can access the course. if survey.utils.must_answer_survey(course, user): @@ -412,9 +431,9 @@ def _index_bulk_op(request, course_key, chapter, section, position): return render_to_response('courseware/courseware.html', context) elif chapter is None: # Check first to see if we should instead redirect the user to an Entrance Exam - if settings.FEATURES.get('ENTRANCE_EXAMS', False) and course.entrance_exam_enabled: - exam_chapter, __ = get_entrance_exam_content_info(request, course) - if exam_chapter is not None: + if course_has_entrance_exam(course): + exam_chapter = get_entrance_exam_content(request, course) + if exam_chapter: exam_section = None if exam_chapter.get_children(): exam_section = exam_chapter.get_children()[0] @@ -454,13 +473,12 @@ def _index_bulk_op(request, course_key, chapter, section, position): return redirect(reverse('courseware', args=[course.id.to_deprecated_string()])) raise Http404 - if settings.FEATURES.get('ENTRANCE_EXAMS', False) and course.entrance_exam_enabled: + if course_has_entrance_exam(course): # Message should not appear outside the context of entrance exam subsection. # if section is none then we don't need to show message on welcome back screen also. if getattr(chapter_module, 'is_entrance_exam', False) and section is not None: - __, is_exam_passed = get_entrance_exam_content_info(request, course) context['entrance_exam_current_score'] = get_entrance_exam_score(request, course) - context['entrance_exam_passed'] = is_exam_passed + context['entrance_exam_passed'] = user_has_passed_entrance_exam(request, course) if section is not None: section_descriptor = chapter_descriptor.get_child_by(lambda m: m.location.name == section) @@ -670,6 +688,11 @@ def course_info(request, course_id): with modulestore().bulk_operations(course_key): course = get_course_with_access(request.user, 'load', course_key) + # 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, request.user, course): + return redirect(reverse('courseware', args=[unicode(course.id)])) + # check to see if there is a required survey that must be taken before # the user can access the course. if request.user.is_authenticated() and survey.utils.must_answer_survey(course, request.user):