From 5cef287c5dfb2db7aa90ff0478587f1f12e505fd Mon Sep 17 00:00:00 2001 From: christopher lee Date: Mon, 13 Apr 2015 16:33:01 -0400 Subject: [PATCH] MA-635 Block Mobile Content for unfulfilled milestones Previously, the mobile api did not check for pre-requisite courses or entrance exams. This change checks for these milestones and then returns course content accordingly. --- common/djangoapps/util/milestones_helpers.py | 14 +- .../util/tests/test_milestones_helpers.py | 10 + lms/djangoapps/courseware/access.py | 12 +- lms/djangoapps/courseware/tests/helpers.py | 4 +- .../courseware/tests/test_entrance_exam.py | 390 +++++++----------- lms/djangoapps/mobile_api/test_milestones.py | 137 ++++++ lms/djangoapps/mobile_api/testutils.py | 13 +- lms/djangoapps/mobile_api/users/tests.py | 1 + lms/djangoapps/mobile_api/utils.py | 36 +- lms/envs/test.py | 6 - 10 files changed, 349 insertions(+), 274 deletions(-) create mode 100644 lms/djangoapps/mobile_api/test_milestones.py diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index 71e32ea500..b0fe8b069c 100644 --- a/common/djangoapps/util/milestones_helpers.py +++ b/common/djangoapps/util/milestones_helpers.py @@ -7,7 +7,8 @@ from django.conf import settings from django.utils.translation import ugettext as _ from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import CourseKey + from xmodule.modulestore.django import modulestore NAMESPACE_CHOICES = { @@ -26,7 +27,7 @@ def add_prerequisite_course(course_key, prerequisite_course_key): """ It would create a milestone, then it would set newly created milestones as requirement for course referred by `course_key` - and it would set newly created milestone as fulfilment + and it would set newly created milestone as fulfillment milestone for course referred by `prerequisite_course_key`. """ if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False): @@ -313,6 +314,15 @@ def remove_content_references(content_id): return milestones_api.remove_content_references(content_id) +def any_unfulfilled_milestones(course_id, user_id): + """ Returns a boolean if user has any unfulfilled milestones """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return False + return bool( + get_course_milestones_fulfillment_paths(course_id, {"id": user_id}) + ) + + def get_course_milestones_fulfillment_paths(course_id, user_id): """ Client API operation adapter/wrapper diff --git a/common/djangoapps/util/tests/test_milestones_helpers.py b/common/djangoapps/util/tests/test_milestones_helpers.py index 69c669b54f..ceb8d82d0c 100644 --- a/common/djangoapps/util/tests/test_milestones_helpers.py +++ b/common/djangoapps/util/tests/test_milestones_helpers.py @@ -3,6 +3,8 @@ Tests for the milestones helpers library, which is the integration point for the """ from mock import patch + +from milestones.exceptions import InvalidCourseKeyException, InvalidUserException from util import milestones_helpers from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -85,3 +87,11 @@ class MilestonesHelpersTestCase(ModuleStoreTestCase): def test_add_user_milestone_returns_none_when_app_disabled(self): response = milestones_helpers.add_user_milestone(self.user, self.milestone) self.assertIsNone(response) + + @patch.dict('django.conf.settings.FEATURES', {'MILESTONES_APP': True}) + def test_any_unfulfilled_milestones(self): + """ Tests any_unfulfilled_milestones for invalid arguments """ + with self.assertRaises(InvalidCourseKeyException): + milestones_helpers.any_unfulfilled_milestones(None, self.user) + with self.assertRaises(InvalidUserException): + milestones_helpers.any_unfulfilled_milestones(self.course.id, None) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 2377335a52..46727f7923 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -32,7 +32,10 @@ from student.roles import ( GlobalStaff, CourseStaffRole, CourseInstructorRole, OrgStaffRole, OrgInstructorRole, CourseBetaTesterRole ) -from util.milestones_helpers import get_pre_requisite_courses_not_completed +from util.milestones_helpers import ( + get_pre_requisite_courses_not_completed, + any_unfulfilled_milestones, +) import dogstats_wrapper as dog_stats_api @@ -173,7 +176,12 @@ def _has_access_course_desc(user, action, course): # check start date can_load() and # check mobile_available flag - is_mobile_available_for_user(user, course) + is_mobile_available_for_user(user, course) and + # check staff access, if not then check for unfulfilled milestones + ( + _has_staff_access_to_descriptor(user, course, course.id) or + not any_unfulfilled_milestones(course.id, user.id) + ) ) def can_enroll(): diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 1aee349cd5..5cea78277b 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -2,16 +2,14 @@ import json from django.contrib.auth.models import User from django.core.urlresolvers import reverse +from django.test import TestCase from django.test.client import RequestFactory from student.models import Registration -from django.test import TestCase - def get_request_for_user(user): """Create a request object for user.""" - request = RequestFactory() request.user = user request.COOKIES = {} diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index f371d8d766..f249b1ef79 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -1,15 +1,18 @@ """ Tests use cases related to LMS Entrance Exam behavior, such as gated content access (TOC) """ -from django.conf import settings -from django.test.client import RequestFactory +from mock import patch, Mock + from django.core.urlresolvers import reverse from nose.plugins.attrib import attr from courseware.model_data import FieldDataCache -from courseware.module_render import get_module, toc_for_course +from courseware.module_render import toc_for_course, get_module from courseware.tests.factories import UserFactory, InstructorFactory, StaffFactory -from courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.helpers import ( + LoginEnrollmentTestCase, + get_request_for_user +) from courseware.entrance_exams import ( course_has_entrance_exam, get_entrance_exam_content, @@ -17,9 +20,8 @@ from courseware.entrance_exams import ( 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 student.models import CourseEnrollment +from student.tests.factories import CourseEnrollmentFactory, AnonymousUserFactory from util.milestones_helpers import ( add_milestone, add_course_milestone, @@ -29,20 +31,21 @@ from util.milestones_helpers import ( get_milestone_relationship_types, seed_milestone_relationship_types, ) -from student.models import CourseEnrollment -from student.tests.factories import CourseEnrollmentFactory, AnonymousUserFactory -from mock import patch, Mock -import mock +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @attr('shard_1') +@patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) 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 - However, the tests below are designed to execute workflows regardless of the setting - If set to False, we are essentially confirming that the workflows do not cause exceptions + Check that content is properly gated. + + Creates a test course from scratch. The tests below are designed to execute + workflows regardless of the feature flag settings. """ + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) def setUp(self): """ Test case scaffolding @@ -122,54 +125,17 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): category="problem", display_name="Exam Problem - Problem 2" ) - self.problem_3 = ItemFactory.create( - parent=subsection, - category="problem", - display_name="Exam Problem - Problem 3" - ) - if settings.FEATURES.get('ENTRANCE_EXAMS', False): - namespace_choices = get_namespace_choices() - milestone_namespace = generate_milestone_namespace( - namespace_choices.get('ENTRANCE_EXAM'), - self.course.id - ) - self.milestone = { - 'name': 'Test Milestone', - 'namespace': milestone_namespace, - 'description': 'Testing Courseware Entrance Exam Chapter', - } - 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 - ) - 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 - self.request.COOKIES = {} - self.request.META = {} - self.request.is_secure = lambda: True - self.request.get_host = lambda: "edx.org" - self.request.method = 'GET' - self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents( - self.course.id, - user, - self.entrance_exam - ) + + seed_milestone_relationship_types() + add_entrance_exam_milestone(self.course, self.entrance_exam) + self.course.entrance_exam_enabled = True self.course.entrance_exam_minimum_score_pct = 0.50 self.course.entrance_exam_id = unicode(self.entrance_exam.scope_ids.usage_id) - modulestore().update_item(self.course, user.id) # pylint: disable=no-member + + self.anonymous_user = AnonymousUserFactory() + self.request = get_request_for_user(UserFactory()) + modulestore().update_item(self.course, self.request.user.id) # pylint: disable=no-member self.client.login(username=self.request.user.username, password="test") CourseEnrollment.enroll(self.request.user, self.course.id) @@ -260,8 +226,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): 'section': self.exam_1.location.name }) resp = self.client.get(url) - if settings.FEATURES.get('ENTRANCE_EXAMS', False): - self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) + self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': False}) def test_entrance_exam_content_absence(self): @@ -294,53 +259,26 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): 'section': self.exam_1.location.name }) resp = self.client.get(url) - if settings.FEATURES.get('ENTRANCE_EXAMS', False): - self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) - resp = self.client.get(expected_url) - self.assertIn('Exam Problem - Problem 1', resp.content) - self.assertIn('Exam Problem - Problem 2', resp.content) + self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) + resp = self.client.get(expected_url) + self.assertIn('Exam Problem - Problem 1', resp.content) + self.assertIn('Exam Problem - Problem 2', resp.content) def test_get_entrance_exam_content(self): """ test get entrance exam content method """ 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.assertFalse(user_has_passed_entrance_exam(self.request, self.course)) + self.assertEqual(exam_chapter.url_name, self.entrance_exam.url_name) + self.assertFalse(user_has_passed_entrance_exam(self.request, self.course)) - # Pass the entrance exam - # pylint: disable=maybe-no-member,no-member - 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, - self.course, - depth=2 - ) - # pylint: disable=protected-access - module = get_module( - self.request.user, - self.request, - self.problem_1.scope_ids.usage_id, - field_data_cache, - )._xmodule - module.system.publish(self.problem_1, 'grade', grade_dict) + answer_entrance_exam_problem(self.course, self.request, self.problem_1) + answer_entrance_exam_problem(self.course, self.request, self.problem_2) - # 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.assertTrue(user_has_passed_entrance_exam(self.request, self.course)) - exam_chapter = get_entrance_exam_content(self.request, self.course) - self.assertEqual(exam_chapter, None) - 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): """ test entrance exam score. we will hit the method get_entrance_exam_score to verify exam score. @@ -348,32 +286,8 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): exam_score = get_entrance_exam_score(self.request, self.course) self.assertEqual(exam_score, 0) - # Pass the entrance exam - # pylint: disable=maybe-no-member,no-member - 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, - self.course, - depth=2 - ) - # pylint: disable=protected-access - module = get_module( - self.request.user, - self.request, - self.problem_1.scope_ids.usage_id, - field_data_cache, - )._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) + answer_entrance_exam_problem(self.course, self.request, self.problem_1) + answer_entrance_exam_problem(self.course, self.request, self.problem_2) exam_score = get_entrance_exam_score(self.request, self.course) # 50 percent exam score should be achieved. @@ -392,9 +306,8 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): } ) resp = self.client.get(url) - if settings.FEATURES.get('ENTRANCE_EXAMS', False): - self.assertEqual(resp.status_code, 200) - self.assertIn('To access course materials, you must score', resp.content) + self.assertEqual(resp.status_code, 200) + self.assertIn('To access course materials, you must score', resp.content) def test_entrance_exam_requirement_message_hidden(self): """ @@ -416,9 +329,8 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): ) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - if settings.FEATURES.get('ENTRANCE_EXAMS', False): - self.assertNotIn('To access course materials, you must score', resp.content) - self.assertNotIn('You have passed the entrance exam.', resp.content) + self.assertNotIn('To access course materials, you must score', resp.content) + self.assertNotIn('You have passed the entrance exam.', resp.content) def test_entrance_exam_passed_message_and_course_content(self): """ @@ -434,121 +346,40 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): } ) - # pylint: disable=maybe-no-member,no-member - 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, - self.course, - depth=2 - ) - # pylint: disable=protected-access - module = get_module( - self.request.user, - self.request, - self.problem_1.scope_ids.usage_id, - field_data_cache, - )._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) + answer_entrance_exam_problem(self.course, self.request, self.problem_1) + answer_entrance_exam_problem(self.course, self.request, self.problem_2) 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) + 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, 'MILESTONES_APP': True}) def test_entrance_exam_gating(self): """ Unit Test: test_entrance_exam_gating """ # This user helps to cover a discovered bug in the milestone fulfillment logic chaos_user = UserFactory() - locked_toc = toc_for_course( - self.request, - self.course, - self.entrance_exam.url_name, - self.exam_1.url_name, - self.field_data_cache - ) + locked_toc = self._return_table_of_contents() for toc_section in self.expected_locked_toc: self.assertIn(toc_section, locked_toc) # Set up the chaos user - # pylint: disable=maybe-no-member,no-member - grade_dict = {'value': 1, 'max_value': 1, 'user_id': chaos_user.id} - field_data_cache = FieldDataCache.cache_for_descriptor_descendents( - self.course.id, - chaos_user, - self.course, - depth=2 - ) - # pylint: disable=protected-access - module = get_module( - chaos_user, - self.request, - self.problem_1.scope_ids.usage_id, - field_data_cache, - )._xmodule - module.system.publish(self.problem_1, 'grade', grade_dict) + answer_entrance_exam_problem(self.course, self.request, self.problem_1, chaos_user) + answer_entrance_exam_problem(self.course, self.request, self.problem_1) + answer_entrance_exam_problem(self.course, self.request, self.problem_2) - # pylint: disable=maybe-no-member,no-member - 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, - self.course, - depth=2 - ) - # pylint: disable=protected-access - module = get_module( - self.request.user, - self.request, - self.problem_1.scope_ids.usage_id, - field_data_cache, - )._xmodule - module.system.publish(self.problem_1, 'grade', grade_dict) - - module = get_module( - self.request.user, - self.request, - self.problem_2.scope_ids.usage_id, - field_data_cache, - )._xmodule # pylint: disable=protected-access - module.system.publish(self.problem_2, 'grade', grade_dict) - unlocked_toc = toc_for_course( - self.request, - self.course, - self.entrance_exam.url_name, - self.exam_1.url_name, - self.field_data_cache - ) + unlocked_toc = self._return_table_of_contents() for toc_section in self.expected_unlocked_toc: self.assertIn(toc_section, unlocked_toc) - @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True}) def test_skip_entrance_exam_gating(self): """ Tests gating is disabled if skip entrance exam is set for a user. """ # make sure toc is locked before allowing user to skip entrance exam - locked_toc = toc_for_course( - self.request, - self.course, - self.entrance_exam.url_name, - self.exam_1.url_name, - self.field_data_cache - ) + locked_toc = self._return_table_of_contents() for toc_section in self.expected_locked_toc: self.assertIn(toc_section, locked_toc) @@ -561,13 +392,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): }) self.assertEqual(response.status_code, 200) - unlocked_toc = toc_for_course( - self.request, - self.course, - self.entrance_exam.url_name, - self.exam_1.url_name, - self.field_data_cache - ) + unlocked_toc = self._return_table_of_contents() for toc_section in self.expected_unlocked_toc: self.assertIn(toc_section, unlocked_toc) @@ -584,17 +409,10 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): # 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 - ) + unlocked_toc = self._return_table_of_contents() 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): """ @@ -611,7 +429,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): 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): """ @@ -625,7 +442,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): 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): """ @@ -634,7 +450,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): # 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): """ @@ -646,7 +461,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): 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 @@ -664,14 +478,12 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): """ 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 @@ -679,7 +491,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): 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={ @@ -688,7 +499,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): ) 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( ) @@ -704,3 +514,89 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase): ) response = self.client.get(url) self.assertEqual(response.status_code, 200) + + def _return_table_of_contents(self): + """ + Returns table of content for the entrance exam specific to this test + + Returns the table of contents for course self.course, for chapter + self.entrance_exam, and for section self.exam1 + """ + self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents( # pylint: disable=attribute-defined-outside-init + self.course.id, + self.request.user, + self.entrance_exam + ) + return toc_for_course( + self.request, + self.course, + self.entrance_exam.url_name, + self.exam_1.url_name, + self.field_data_cache + ) + + +def answer_entrance_exam_problem(course, request, problem, user=None): + """ + Takes a required milestone `problem` in a `course` and fulfills it. + + Args: + course (Course): Course object, the course the required problem is in + 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 + """ + if not user: + user = request.user + + # pylint: disable=maybe-no-member,no-member + grade_dict = {'value': 1, 'max_value': 1, 'user_id': user.id} + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + course.id, + user, + course, + depth=2 + ) + # pylint: disable=protected-access + module = get_module( + user, + request, + problem.scope_ids.usage_id, + field_data_cache, + )._xmodule + module.system.publish(problem, 'grade', grade_dict) + + +def add_entrance_exam_milestone(course, entrance_exam): + """ + Adds the milestone for given `entrance_exam` in `course` + + Args: + course (Course): Course object in which the extrance_exam is located + entrance_exam (xblock): the entrance exam to be added as a milestone + """ + namespace_choices = get_namespace_choices() + milestone_relationship_types = get_milestone_relationship_types() + + milestone_namespace = generate_milestone_namespace( + namespace_choices.get('ENTRANCE_EXAM'), + course.id + ) + milestone = add_milestone( + { + 'name': 'Test Milestone', + 'namespace': milestone_namespace, + 'description': 'Testing Courseware Entrance Exam Chapter', + } + ) + add_course_milestone( + unicode(course.id), + milestone_relationship_types['REQUIRES'], + milestone + ) + add_course_content_milestone( + unicode(course.id), + unicode(entrance_exam.location), + milestone_relationship_types['FULFILLS'], + milestone + ) diff --git a/lms/djangoapps/mobile_api/test_milestones.py b/lms/djangoapps/mobile_api/test_milestones.py new file mode 100644 index 0000000000..b4429579f6 --- /dev/null +++ b/lms/djangoapps/mobile_api/test_milestones.py @@ -0,0 +1,137 @@ +""" +Milestone related tests for the mobile_api +""" +from mock import patch + +from courseware.tests.helpers import get_request_for_user +from courseware.tests.test_entrance_exam import answer_entrance_exam_problem, add_entrance_exam_milestone +from util.milestones_helpers import ( + add_prerequisite_course, + fulfill_course_milestone, + seed_milestone_relationship_types, +) +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +class MobileAPIMilestonesMixin(object): + """ + Tests the Mobile API decorators for milestones. + + The two milestones currently supported in these tests are entrance exams and + pre-requisite courses. If either of these milestones are unfulfilled, + the mobile api will appropriately block content until the milestone is + fulfilled. + """ + MILESTONE_MESSAGE = { + 'developer_message': + 'Cannot access content with unfulfilled pre-requisites or unpassed entrance exam.' + } + + ALLOW_ACCESS_TO_MILESTONE_COURSE = False # pylint: disable=invalid-name + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) + def test_unfulfilled_prerequisite_course(self): + """ Tests the case for an unfulfilled pre-requisite course """ + self._add_prerequisite_course() + self.init_course_access() + self._verify_unfulfilled_milestone_response() + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) + def test_unfulfilled_prerequisite_course_for_staff(self): + self._add_prerequisite_course() + self.user.is_staff = True + self.user.save() + self.init_course_access() + self.api_response() + + @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True}) + def test_fulfilled_prerequisite_course(self): + """ + Tests the case when a user fulfills existing pre-requisite course + """ + self._add_prerequisite_course() + add_prerequisite_course(self.course.id, self.prereq_course.id) + fulfill_course_milestone(self.prereq_course.id, self.user) + self.init_course_access() + self.api_response() + + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) + def test_unpassed_entrance_exam(self): + """ + Tests the case where the user has not passed the entrance exam + """ + self._add_entrance_exam() + self.init_course_access() + self._verify_unfulfilled_milestone_response() + + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) + def test_unpassed_entrance_exam_for_staff(self): + self._add_entrance_exam() + self.user.is_staff = True + self.user.save() + self.init_course_access() + self.api_response() + + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True, 'MILESTONES_APP': True}) + def test_passed_entrance_exam(self): + """ + Tests access when user has passed the entrance exam + """ + self._add_entrance_exam() + self._pass_entrance_exam() + self.init_course_access() + self.api_response() + + def _add_entrance_exam(self): + """ Sets up entrance exam """ + seed_milestone_relationship_types() + self.course.entrance_exam_enabled = True + + self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=self.course, + category="chapter", + display_name="Entrance Exam Chapter", + is_entrance_exam=True, + in_entrance_exam=True + ) + self.problem_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=self.entrance_exam, + category='problem', + display_name="The Only Exam Problem", + graded=True, + in_entrance_exam=True + ) + + add_entrance_exam_milestone(self.course, self.entrance_exam) + + self.course.entrance_exam_minimum_score_pct = 0.50 + self.course.entrance_exam_id = unicode(self.entrance_exam.location) + modulestore().update_item(self.course, self.user.id) + + def _add_prerequisite_course(self): + """ Helper method to set up the prerequisite course """ + seed_milestone_relationship_types() + self.prereq_course = CourseFactory.create() # pylint: disable=attribute-defined-outside-init + add_prerequisite_course(self.course.id, self.prereq_course.id) + + def _pass_entrance_exam(self): + """ Helper function to pass the entrance exam """ + request = get_request_for_user(self.user) + answer_entrance_exam_problem(self.course, request, self.problem_1) + + def _verify_unfulfilled_milestone_response(self): + """ + Verifies the response depending on ALLOW_ACCESS_TO_MILESTONE_COURSE + + Since different endpoints will have different behaviours towards milestones, + setting ALLOW_ACCESS_TO_MILESTONE_COURSE (default is False) to True, will + not return a 204. For example, when getting a list of courses a user is + enrolled in, although a user may have unfulfilled milestones, the course + should still show up in the course enrollments list. + """ + if self.ALLOW_ACCESS_TO_MILESTONE_COURSE: + self.api_response() + else: + response = self.api_response(expected_response_code=204) + self.assertEqual(response.data, self.MILESTONE_MESSAGE) diff --git a/lms/djangoapps/mobile_api/testutils.py b/lms/djangoapps/mobile_api/testutils.py index 09e2cab339..57fd67a14f 100644 --- a/lms/djangoapps/mobile_api/testutils.py +++ b/lms/djangoapps/mobile_api/testutils.py @@ -13,18 +13,21 @@ Test utilities for mobile API tests: # pylint: disable=no-member import ddt from mock import patch -from rest_framework.test import APITestCase + from django.core.urlresolvers import reverse -from opaque_keys.edx.keys import CourseKey -from courseware.tests.factories import UserFactory +from rest_framework.test import APITestCase +from opaque_keys.edx.keys import CourseKey + +from courseware.tests.factories import UserFactory from student import auth from student.models import CourseEnrollment - from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from mobile_api.test_milestones import MobileAPIMilestonesMixin + class MobileAPITestCase(ModuleStoreTestCase, APITestCase): """ @@ -124,7 +127,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin): @ddt.ddt -class MobileCourseAccessTestMixin(object): +class MobileCourseAccessTestMixin(MobileAPIMilestonesMixin): """ Test Mixin for testing APIs marked with mobile_course_access. (Use MobileEnrolledCourseAccessTestMixin when verify_enrolled is set to True.) diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index c5774fd593..53ceb300d9 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -49,6 +49,7 @@ class TestUserEnrollmentApi(MobileAPITestCase, MobileAuthUserTestMixin, MobileEn """ REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username']} ALLOW_ACCESS_TO_UNRELEASED_COURSE = True + ALLOW_ACCESS_TO_MILESTONE_COURSE = True def verify_success(self, response): super(TestUserEnrollmentApi, self).verify_success(response) diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 57dc67c73c..5291693ce7 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -3,16 +3,21 @@ Common utility methods and decorators for Mobile APIs. """ import functools -from rest_framework import permissions + +from django.http import Http404 + +from rest_framework import permissions, status, response + from opaque_keys.edx.keys import CourseKey -from xmodule.modulestore.django import modulestore from courseware.courses import get_course_with_access +from openedx.core.lib.api.permissions import IsUserInUrl from openedx.core.lib.api.authentication import ( SessionAuthenticationAllowInactiveUser, OAuth2AuthenticationAllowInactiveUser, ) -from openedx.core.lib.api.permissions import IsUserInUrl +from util.milestones_helpers import any_unfulfilled_milestones +from xmodule.modulestore.django import modulestore def mobile_course_access(depth=0, verify_enrolled=True): @@ -30,12 +35,25 @@ def mobile_course_access(depth=0, verify_enrolled=True): """ course_id = CourseKey.from_string(kwargs.pop('course_id')) with modulestore().bulk_operations(course_id): - course = get_course_with_access( - request.user, - 'load_mobile' if verify_enrolled else 'load_mobile_no_enrollment_check', - course_id, - depth=depth - ) + try: + course = get_course_with_access( + request.user, + 'load_mobile' if verify_enrolled else 'load_mobile_no_enrollment_check', + course_id, + depth=depth + ) + except Http404: + # any_unfulfilled_milestones called a second time since get_course_with_access returns a bool + if any_unfulfilled_milestones(course_id, request.user.id): + message = { + "developer_message": "Cannot access content with unfulfilled pre-requisites or unpassed entrance exam." # pylint: disable=line-too-long + } + return response.Response( + data=message, + status=status.HTTP_204_NO_CONTENT + ) + else: + raise return func(self, request, course=course, *args, **kwargs) return _wrapper return _decorator diff --git a/lms/envs/test.py b/lms/envs/test.py index b545a562df..0d3c092d89 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -452,12 +452,6 @@ FEATURES['ENABLE_EDXNOTES'] = True # Add milestones to Installed apps for testing INSTALLED_APPS += ('milestones', ) -# MILESTONES -FEATURES['MILESTONES_APP'] = True - -# ENTRANCE EXAMS -FEATURES['ENTRANCE_EXAMS'] = True - # Enable courseware search for tests FEATURES['ENABLE_COURSEWARE_SEARCH'] = True