diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 8af9fbba65..a102083735 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -6,6 +6,7 @@ import json import copy import mock from mock import patch +import unittest from django.utils.timezone import UTC from django.test.utils import override_settings @@ -139,19 +140,9 @@ class CourseDetailsTestCase(CourseTestCase): self.assertNotContains(response, "Course Introduction Video") self.assertNotContains(response, "Requirements") - def _seed_milestone_relationship_types(self): - """ - Helper method to prepopulate MRTs so the tests can run - Note the settings check -- exams feature must be enabled for the tests to run correctly - """ - if settings.FEATURES.get('ENTRANCE_EXAMS', False): - from milestones.models import MilestoneRelationshipType - MilestoneRelationshipType.objects.create(name='requires') - MilestoneRelationshipType.objects.create(name='fulfills') - - @patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True}) + @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) def test_entrance_exam_created_updated_and_deleted_successfully(self): - self._seed_milestone_relationship_types() + seed_milestone_relationship_types() settings_details_url = get_url(self.course.id) data = { 'entrance_exam_enabled': 'true', @@ -196,13 +187,13 @@ class CourseDetailsTestCase(CourseTestCase): self.assertFalse(course.entrance_exam_enabled) self.assertEquals(course.entrance_exam_minimum_score_pct, None) - @patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True}) + @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) def test_entrance_exam_store_default_min_score(self): """ test that creating an entrance exam should store the default value, if key missing in json request or entrance_exam_minimum_score_pct is an empty string """ - self._seed_milestone_relationship_types() + seed_milestone_relationship_types() settings_details_url = get_url(self.course.id) test_data_1 = { 'entrance_exam_enabled': 'true', diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py index 08da658e76..4eb11a2314 100644 --- a/cms/djangoapps/contentstore/views/entrance_exam.py +++ b/cms/djangoapps/contentstore/views/entrance_exam.py @@ -2,22 +2,22 @@ Entrance Exams view module -- handles all requests related to entrance exam management via Studio Intended to be utilized as an AJAX callback handler, versus a proper view/screen """ +from functools import wraps import json import logging from django.contrib.auth.decorators import login_required from django_future.csrf import ensure_csrf_cookie -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseBadRequest from django.test import RequestFactory from contentstore.views.helpers import create_xblock from contentstore.views.item import delete_item -from milestones import api as milestones_api from models.settings.course_metadata import CourseMetadata from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys import InvalidKeyError from student.auth import has_course_author_access -from util.milestones_helpers import generate_milestone_namespace, NAMESPACE_CHOICES +from util import milestones_helpers from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from django.conf import settings @@ -40,8 +40,24 @@ def _get_default_entrance_exam_minimum_pct(): return entrance_exam_minimum_score_pct +# pylint: disable=missing-docstring +def check_feature_enabled(feature_name): + """ + Ensure the specified feature is turned on. Return an HTTP 400 code if not. + """ + def _check_feature_enabled(view_func): + def _decorator(request, *args, **kwargs): + # Deny access if the entrance exam feature is disabled + if not settings.FEATURES.get(feature_name, False): + return HttpResponseBadRequest() + return view_func(request, *args, **kwargs) + return wraps(view_func)(_decorator) + return _check_feature_enabled + + @login_required @ensure_csrf_cookie +@check_feature_enabled(feature_name='ENTRANCE_EXAMS') def entrance_exam(request, course_key_string): """ The restful handler for entrance exams. @@ -88,6 +104,7 @@ def entrance_exam(request, course_key_string): return HttpResponse(status=405) +@check_feature_enabled(feature_name='ENTRANCE_EXAMS') def create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct): """ api method to create an entrance exam. @@ -150,27 +167,28 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N ) # Add an entrance exam milestone if one does not already exist - milestone_namespace = generate_milestone_namespace( - NAMESPACE_CHOICES['ENTRANCE_EXAM'], + namespace_choices = milestones_helpers.get_namespace_choices() + milestone_namespace = milestones_helpers.generate_milestone_namespace( + namespace_choices.get('ENTRANCE_EXAM'), course_key ) - milestones = milestones_api.get_milestones(milestone_namespace) + milestones = milestones_helpers.get_milestones(milestone_namespace) if len(milestones): milestone = milestones[0] else: description = 'Autogenerated during {} entrance exam creation.'.format(unicode(course.id)) - milestone = milestones_api.add_milestone({ + milestone = milestones_helpers.add_milestone({ 'name': 'Completed Course Entrance Exam', 'namespace': milestone_namespace, 'description': description }) - relationship_types = milestones_api.get_milestone_relationship_types() - milestones_api.add_course_milestone( + relationship_types = milestones_helpers.get_milestone_relationship_types() + milestones_helpers.add_course_milestone( unicode(course.id), relationship_types['REQUIRES'], milestone ) - milestones_api.add_course_content_milestone( + milestones_helpers.add_course_content_milestone( unicode(course.id), unicode(created_block.location), relationship_types['FULFILLS'], @@ -202,6 +220,7 @@ def _get_entrance_exam(request, course_key): # pylint: disable=W0613 return HttpResponse(status=404) +@check_feature_enabled(feature_name='ENTRANCE_EXAMS') def update_entrance_exam(request, course_key, exam_data): """ Operation to update course fields pertaining to entrance exams @@ -215,6 +234,7 @@ def update_entrance_exam(request, course_key, exam_data): CourseMetadata.update_from_dict(metadata, course, request.user) +@check_feature_enabled(feature_name='ENTRANCE_EXAMS') def delete_entrance_exam(request, course_key): """ api method to delete an entrance exam @@ -238,7 +258,7 @@ def _delete_entrance_exam(request, course_key): for course_child in course_children: if course_child.is_entrance_exam: delete_item(request, course_child.scope_ids.usage_id) - milestones_api.remove_content_references(unicode(course_child.scope_ids.usage_id)) + milestones_helpers.remove_content_references(unicode(course_child.scope_ids.usage_id)) # Reset the entrance exam flags on the course # Reload the course so we have the latest state diff --git a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py index a2cf5c7043..0a720f98e8 100644 --- a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py +++ b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py @@ -2,6 +2,7 @@ Test module for Entrance Exams AJAX callback handler workflows """ import json +from mock import patch from django.conf import settings from django.contrib.auth.models import User @@ -9,18 +10,14 @@ from django.test.client import RequestFactory from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase from contentstore.utils import reverse_url -from contentstore.views.entrance_exam import create_entrance_exam +from contentstore.views.entrance_exam import create_entrance_exam, update_entrance_exam, delete_entrance_exam from models.settings.course_grading import CourseGradingModel from models.settings.course_metadata import CourseMetadata from opaque_keys.edx.keys import UsageKey from student.tests.factories import UserFactory +from util import milestones_helpers from xmodule.modulestore.django import modulestore -if settings.FEATURES.get('MILESTONES_APP', False): - from milestones import api as milestones_api - from milestones.models import MilestoneRelationshipType - from util.milestones_helpers import serialize_user - class EntranceExamHandlerTests(CourseTestCase): """ @@ -36,9 +33,8 @@ class EntranceExamHandlerTests(CourseTestCase): self.usage_key = self.course.location self.course_url = '/course/{}'.format(unicode(self.course.id)) self.exam_url = '/course/{}/entrance_exam/'.format(unicode(self.course.id)) - MilestoneRelationshipType.objects.create(name='requires', active=True) - MilestoneRelationshipType.objects.create(name='fulfills', active=True) - self.milestone_relationship_types = milestones_api.get_milestone_relationship_types() + milestones_helpers.seed_milestone_relationship_types() + self.milestone_relationship_types = milestones_helpers.get_milestone_relationship_types() def test_contentstore_views_entrance_exam_post(self): """ @@ -55,8 +51,8 @@ class EntranceExamHandlerTests(CourseTestCase): self.assertTrue(metadata['entrance_exam_enabled']) self.assertIsNotNone(metadata['entrance_exam_minimum_score_pct']) self.assertIsNotNone(metadata['entrance_exam_id']['value']) - self.assertTrue(len(milestones_api.get_course_milestones(unicode(self.course.id)))) - content_milestones = milestones_api.get_course_content_milestones( + self.assertTrue(len(milestones_helpers.get_course_milestones(unicode(self.course.id)))) + content_milestones = milestones_helpers.get_course_content_milestones( unicode(self.course.id), metadata['entrance_exam_id']['value'], self.milestone_relationship_types['FULFILLS'] @@ -123,12 +119,12 @@ class EntranceExamHandlerTests(CourseTestCase): ) user.set_password('test') user.save() - milestones = milestones_api.get_course_milestones(unicode(self.course_key)) + milestones = milestones_helpers.get_course_milestones(unicode(self.course_key)) self.assertEqual(len(milestones), 1) milestone_key = '{}.{}'.format(milestones[0]['namespace'], milestones[0]['name']) - paths = milestones_api.get_course_milestones_fulfillment_paths( + paths = milestones_helpers.get_course_milestones_fulfillment_paths( unicode(self.course_key), - serialize_user(user) + milestones_helpers.serialize_user(user) ) # What we have now is a course milestone requirement and no valid fulfillment @@ -250,3 +246,22 @@ class EntranceExamHandlerTests(CourseTestCase): resp = create_entrance_exam(request, self.course.id, None) self.assertEqual(resp.status_code, 201) + + @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': False}) + def test_entrance_exam_feature_flag_gating(self): + user = UserFactory() + user.is_staff = True + request = RequestFactory() + request.user = user + + resp = self.client.get(self.exam_url) + self.assertEqual(resp.status_code, 400) + + resp = create_entrance_exam(request, self.course.id, None) + self.assertEqual(resp.status_code, 400) + + resp = delete_entrance_exam(request, self.course.id) + self.assertEqual(resp.status_code, 400) + + # No return, so we'll just ensure no exception is thrown + update_entrance_exam(request, self.course.id, {}) diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py index 18e808a0c3..ebfa0ec1db 100644 --- a/common/djangoapps/util/milestones_helpers.py +++ b/common/djangoapps/util/milestones_helpers.py @@ -5,30 +5,24 @@ Utility library for working with the edx-milestones app from django.conf import settings from django.utils.translation import ugettext as _ -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey from courseware.models import StudentModule +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey from xmodule.modulestore.django import modulestore -from milestones.api import ( - get_course_milestones, - add_milestone, - add_course_milestone, - remove_course_milestone, - get_course_milestones_fulfillment_paths, - add_user_milestone, - get_user_milestones, -) -from milestones.models import MilestoneRelationshipType -from milestones.exceptions import InvalidMilestoneRelationshipTypeException -from opaque_keys.edx.keys import UsageKey - NAMESPACE_CHOICES = { 'ENTRANCE_EXAM': 'entrance_exams' } +def get_namespace_choices(): + """ + Return the enum to the caller + """ + return NAMESPACE_CHOICES + + def add_prerequisite_course(course_key, prerequisite_course_key): """ It would create a milestone, then it would set newly created @@ -36,18 +30,23 @@ def add_prerequisite_course(course_key, prerequisite_course_key): and it would set newly created milestone as fulfilment milestone for course referred by `prerequisite_course_key`. """ - if settings.FEATURES.get('MILESTONES_APP', False): - # create a milestone - milestone = add_milestone({ - 'name': _('Course {} requires {}'.format(unicode(course_key), unicode(prerequisite_course_key))), - 'namespace': unicode(prerequisite_course_key), - 'description': _('System defined milestone'), - }) - # add requirement course milestone - add_course_milestone(course_key, 'requires', milestone) + if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False): + return None + from milestones import api as milestones_api + milestone_name = _('Course {course_id} requires {prerequisite_course_id}').format( + course_id=unicode(course_key), + prerequisite_course_id=unicode(prerequisite_course_key) + ) + milestone = milestones_api.add_milestone({ + 'name': milestone_name, + 'namespace': unicode(prerequisite_course_key), + 'description': _('System defined milestone'), + }) + # add requirement course milestone + milestones_api.add_course_milestone(course_key, 'requires', milestone) - # add fulfillment course milestone - add_course_milestone(prerequisite_course_key, 'fulfills', milestone) + # add fulfillment course milestone + milestones_api.add_course_milestone(prerequisite_course_key, 'fulfills', milestone) def remove_prerequisite_course(course_key, milestone): @@ -55,11 +54,13 @@ def remove_prerequisite_course(course_key, milestone): It would remove pre-requisite course milestone for course referred by `course_key`. """ - if settings.FEATURES.get('MILESTONES_APP', False): - remove_course_milestone( - course_key, - milestone, - ) + if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False): + return None + from milestones import api as milestones_api + milestones_api.remove_course_milestone( + course_key, + milestone, + ) def set_prerequisite_courses(course_key, prerequisite_course_keys): @@ -69,18 +70,20 @@ def set_prerequisite_courses(course_key, prerequisite_course_keys): To only remove course milestones pass `course_key` and empty list or None as `prerequisite_course_keys` . """ - if settings.FEATURES.get('MILESTONES_APP', False): - #remove any existing requirement milestones with this pre-requisite course as requirement - course_milestones = get_course_milestones(course_key=course_key, relationship="requires") - if course_milestones: - for milestone in course_milestones: - remove_prerequisite_course(course_key, milestone) + if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False): + return None + from milestones import api as milestones_api + #remove any existing requirement milestones with this pre-requisite course as requirement + course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="requires") + if course_milestones: + for milestone in course_milestones: + remove_prerequisite_course(course_key, milestone) - # add milestones if pre-requisite course is selected - if prerequisite_course_keys: - for prerequisite_course_key_string in prerequisite_course_keys: - prerequisite_course_key = CourseKey.from_string(prerequisite_course_key_string) - add_prerequisite_course(course_key, prerequisite_course_key) + # add milestones if pre-requisite course is selected + if prerequisite_course_keys: + for prerequisite_course_key_string in prerequisite_course_keys: + prerequisite_course_key = CourseKey.from_string(prerequisite_course_key_string) + add_prerequisite_course(course_key, prerequisite_course_key) def get_pre_requisite_courses_not_completed(user, enrolled_courses): @@ -91,10 +94,11 @@ def get_pre_requisite_courses_not_completed(user, enrolled_courses): prerequisite courses yet to be completed. """ pre_requisite_courses = {} - if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES'): + if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False): + from milestones import api as milestones_api for course_key in enrolled_courses: required_courses = [] - fulfilment_paths = get_course_milestones_fulfillment_paths(course_key, {'id': user.id}) + fulfilment_paths = milestones_api.get_course_milestones_fulfillment_paths(course_key, {'id': user.id}) for milestone_key, milestone_value in fulfilment_paths.items(): # pylint: disable=unused-variable for key, value in milestone_value.items(): if key == 'courses' and value: @@ -146,10 +150,12 @@ def fulfill_course_milestone(course_key, user): Marks the course specified by the given course_key as complete for the given user. If any other courses require this course as a prerequisite, their milestones will be appropriately updated. """ - if settings.FEATURES.get('MILESTONES_APP', False): - course_milestones = get_course_milestones(course_key=course_key, relationship="fulfills") - for milestone in course_milestones: - add_user_milestone({'id': user.id}, milestone) + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + course_milestones = milestones_api.get_course_milestones(course_key=course_key, relationship="fulfills") + for milestone in course_milestones: + milestones_api.add_user_milestone({'id': user.id}, milestone) def get_required_content(course, user): @@ -159,9 +165,12 @@ 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 = get_course_milestones_fulfillment_paths( + milestone_paths = milestones_api.get_course_milestones_fulfillment_paths( unicode(course.id), serialize_user(user) ) @@ -221,8 +230,10 @@ def milestones_achieved_by_user(user, namespace): """ It would fetch list of milestones completed by user """ - if settings.FEATURES.get('MILESTONES_APP', False): - return get_user_milestones({'id': user.id}, namespace) + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + return milestones_api.get_user_milestones({'id': user.id}, namespace) def is_valid_course_key(key): @@ -240,9 +251,11 @@ def seed_milestone_relationship_types(): """ Helper method to pre-populate MRTs so the tests can run """ - if settings.FEATURES.get('MILESTONES_APP', False): - MilestoneRelationshipType.objects.create(name='requires') - MilestoneRelationshipType.objects.create(name='fulfills') + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones.models import MilestoneRelationshipType + MilestoneRelationshipType.objects.create(name='requires') + MilestoneRelationshipType.objects.create(name='fulfills') def generate_milestone_namespace(namespace, course_key=None): @@ -261,3 +274,106 @@ def serialize_user(user): return { 'id': user.id, } + + +def add_milestone(milestone_data): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + return milestones_api.add_milestone(milestone_data) + + +def get_milestones(namespace): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return [] + from milestones import api as milestones_api + return milestones_api.get_milestones(namespace) + + +def get_milestone_relationship_types(): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return {} + from milestones import api as milestones_api + return milestones_api.get_milestone_relationship_types() + + +def add_course_milestone(course_id, relationship, milestone): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + return milestones_api.add_course_milestone(course_id, relationship, milestone) + + +def get_course_milestones(course_id): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return [] + from milestones import api as milestones_api + return milestones_api.get_course_milestones(course_id) + + +def add_course_content_milestone(course_id, content_id, relationship, milestone): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + return milestones_api.add_course_content_milestone(course_id, content_id, relationship, milestone) + + +def get_course_content_milestones(course_id, content_id, relationship): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return [] + from milestones import api as milestones_api + return milestones_api.get_course_content_milestones(course_id, content_id, relationship) + + +def remove_content_references(content_id): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + return milestones_api.remove_content_references(content_id) + + +def get_course_milestones_fulfillment_paths(course_id, user_id): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + return milestones_api.get_course_milestones_fulfillment_paths( + course_id, + user_id + ) + + +def add_user_milestone(user, milestone): + """ + Client API operation adapter/wrapper + """ + if not settings.FEATURES.get('MILESTONES_APP', False): + return None + from milestones import api as milestones_api + return milestones_api.add_user_milestone(user, milestone) diff --git a/common/djangoapps/util/tests/test_milestones_helpers.py b/common/djangoapps/util/tests/test_milestones_helpers.py new file mode 100644 index 0000000000..69c669b54f --- /dev/null +++ b/common/djangoapps/util/tests/test_milestones_helpers.py @@ -0,0 +1,87 @@ +""" +Tests for the milestones helpers library, which is the integration point for the edx_milestones API +""" + +from mock import patch +from util import milestones_helpers +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@patch.dict('django.conf.settings.FEATURES', {'MILESTONES_APP': False}) +class MilestonesHelpersTestCase(ModuleStoreTestCase): + """ + Main test suite for Milestones API client library + """ + + def setUp(self): + """ + Test case scaffolding + """ + super(MilestonesHelpersTestCase, self).setUp(create_user=False) + self.course = CourseFactory.create( + metadata={ + 'entrance_exam_enabled': True, + } + ) + + self.user = {'id': '123'} + + self.milestone = { + 'name': 'Test Milestone', + 'namespace': 'doesnt.matter', + 'description': 'Testing Milestones Helpers Library', + } + + def test_add_milestone_returns_none_when_app_disabled(self): + response = milestones_helpers.add_milestone(milestone_data=self.milestone) + self.assertIsNone(response) + + def test_get_milestones_returns_none_when_app_disabled(self): + response = milestones_helpers.get_milestones(namespace="whatever") + self.assertEqual(len(response), 0) + + def test_get_milestone_relationship_types_returns_none_when_app_disabled(self): + response = milestones_helpers.get_milestone_relationship_types() + self.assertEqual(len(response), 0) + + def test_add_course_milestone_returns_none_when_app_disabled(self): + response = milestones_helpers.add_course_milestone(unicode(self.course.id), 'requires', self.milestone) + self.assertIsNone(response) + + def test_get_course_milestones_returns_none_when_app_disabled(self): + response = milestones_helpers.get_course_milestones(unicode(self.course.id)) + self.assertEqual(len(response), 0) + + def test_add_course_content_milestone_returns_none_when_app_disabled(self): + response = milestones_helpers.add_course_content_milestone( + unicode(self.course.id), + 'i4x://any/content/id', + 'requires', + self.milestone + ) + self.assertIsNone(response) + + def test_get_course_content_milestones_returns_none_when_app_disabled(self): + response = milestones_helpers.get_course_content_milestones( + unicode(self.course.id), + 'i4x://doesnt/matter/for/this/test', + 'requires' + ) + self.assertEqual(len(response), 0) + + def test_remove_content_references_returns_none_when_app_disabled(self): + response = milestones_helpers.remove_content_references("i4x://any/content/id/will/do") + self.assertIsNone(response) + + def test_get_namespace_choices_returns_values_when_app_disabled(self): + response = milestones_helpers.get_namespace_choices() + self.assertIn('ENTRANCE_EXAM', response) + + def test_get_course_milestones_fulfillment_paths_returns_none_when_app_disabled(self): + response = milestones_helpers.get_course_milestones_fulfillment_paths(unicode(self.course.id), self.user) + self.assertIsNone(response) + + def test_add_user_milestone_returns_none_when_app_disabled(self): + response = milestones_helpers.add_user_milestone(self.user, self.milestone) + self.assertIsNone(response) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index f6dd10ad7a..08ed6d9213 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -63,10 +63,8 @@ from xmodule.x_module import XModuleDescriptor from xblock_django.user_service import DjangoXBlockUserService from util.json_request import JsonResponse from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip -if settings.FEATURES.get('MILESTONES_APP', False): - from milestones import api as milestones_api - from util.milestones_helpers import calculate_entrance_exam_score, get_required_content - from util.module_utils import yield_dynamic_descriptor_descendents +from util import milestones_helpers +from util.module_utils import yield_dynamic_descriptor_descendents log = logging.getLogger(__name__) @@ -136,14 +134,14 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c return None # Check to see if the course is gated on milestone-required content (such as an Entrance Exam) - required_content = get_required_content(course, request.user) + required_content = milestones_helpers.get_required_content(course, request.user) chapters = list() for chapter in course_module.get_display_items(): # Only show required content, if there is required content # chapter.hide_from_toc is read-only (boo) local_hide_from_toc = False - if len(required_content): + if required_content: if unicode(chapter.location) not in required_content: local_hide_from_toc = True @@ -375,7 +373,7 @@ def get_module_system_for_user(user, field_data_cache, inner_get_module ) exam_modules = [module for module in exam_module_generators] - exam_score = calculate_entrance_exam_score(user, course_descriptor, exam_modules) + 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): @@ -394,8 +392,8 @@ def get_module_system_for_user(user, field_data_cache, exam_pct = _calculate_entrance_exam_score(user, course) if exam_pct >= course.entrance_exam_minimum_score_pct: exam_key = UsageKey.from_string(course.entrance_exam_id) - relationship_types = milestones_api.get_milestone_relationship_types() - content_milestones = milestones_api.get_course_content_milestones( + relationship_types = milestones_helpers.get_milestone_relationship_types() + content_milestones = milestones_helpers.get_course_content_milestones( course_key, exam_key, relationship=relationship_types['FULFILLS'] @@ -403,7 +401,7 @@ def get_module_system_for_user(user, field_data_cache, # Add each milestone to the user's set... user = {'id': user.id} for milestone in content_milestones: - milestones_api.add_user_milestone(user, milestone) + milestones_helpers.add_user_milestone(user, milestone) def handle_grade_event(block, event_type, event): # pylint: disable=unused-argument """ diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 2c732dc0ee..59261fcfd3 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -9,9 +9,7 @@ from courseware.access import has_access from student.models import CourseEnrollment, EntranceExamConfiguration from xmodule.tabs import CourseTabList -if settings.FEATURES.get('MILESTONES_APP', False): - from milestones.api import get_course_milestones_fulfillment_paths - from util.milestones_helpers import serialize_user +from util import milestones_helpers def get_course_tab_list(course, user): @@ -33,9 +31,9 @@ def get_course_tab_list(course, user): entrance_exam_mode = False if settings.FEATURES.get('ENTRANCE_EXAMS', False): if getattr(course, 'entrance_exam_enabled', False): - course_milestones_paths = get_course_milestones_fulfillment_paths( + course_milestones_paths = milestones_helpers.get_course_milestones_fulfillment_paths( unicode(course.id), - serialize_user(user) + milestones_helpers.serialize_user(user) ) for __, value in course_milestones_paths.iteritems(): if len(value.get('content', [])): diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index 1bdb0ca278..81fdb64315 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -1,6 +1,7 @@ """ 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 django.test.utils import override_settings from django.core.urlresolvers import reverse @@ -9,12 +10,11 @@ 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 milestones import api as milestones_api from milestones.models import MilestoneRelationshipType from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MOCK_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from util.milestones_helpers import generate_milestone_namespace, NAMESPACE_CHOICES +from util import milestones_helpers from student.models import CourseEnrollment from mock import patch import mock @@ -23,8 +23,10 @@ import mock class EntranceExamTestCases(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 """ - def setUp(self): """ Test case scaffolding @@ -109,30 +111,31 @@ class EntranceExamTestCases(ModuleStoreTestCase): category="problem", display_name="Exam Problem - Problem 3" ) - milestone_namespace = generate_milestone_namespace( - NAMESPACE_CHOICES['ENTRANCE_EXAM'], - self.course.id - ) - self.milestone = { - 'name': 'Test Milestone', - 'namespace': milestone_namespace, - 'description': 'Testing Courseware Entrance Exam Chapter', - } - MilestoneRelationshipType.objects.create(name='requires', active=True) - MilestoneRelationshipType.objects.create(name='fulfills', active=True) - self.milestone_relationship_types = milestones_api.get_milestone_relationship_types() - self.milestone = milestones_api.add_milestone(self.milestone) - milestones_api.add_course_milestone( - unicode(self.course.id), - self.milestone_relationship_types['REQUIRES'], - self.milestone - ) - milestones_api.add_course_content_milestone( - unicode(self.course.id), - unicode(self.entrance_exam.location), - self.milestone_relationship_types['FULFILLS'], - self.milestone - ) + if settings.FEATURES.get('ENTRANCE_EXAMS', False): + namespace_choices = milestones_helpers.get_namespace_choices() + milestone_namespace = milestones_helpers.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', + } + 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( + unicode(self.course.id), + self.milestone_relationship_types['REQUIRES'], + self.milestone + ) + milestones_helpers.add_course_content_milestone( + unicode(self.course.id), + unicode(self.entrance_exam.location), + self.milestone_relationship_types['FULFILLS'], + self.milestone + ) user = UserFactory() self.request = RequestFactory() self.request.user = user @@ -241,7 +244,8 @@ class EntranceExamTestCases(ModuleStoreTestCase): 'section': self.exam_1.location.name }) resp = self.client.get(url) - self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) + if settings.FEATURES.get('ENTRANCE_EXAMS', False): + 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): @@ -261,7 +265,6 @@ class EntranceExamTestCases(ModuleStoreTestCase): self.assertNotIn('Exam Problem - Problem 1', resp.content) self.assertNotIn('Exam Problem - Problem 2', resp.content) - @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True}) def test_entrance_exam_content_presence(self): """ Unit Test: If entrance exam is enabled then its content e.g. problems should be loaded and redirection will @@ -275,41 +278,44 @@ class EntranceExamTestCases(ModuleStoreTestCase): 'section': self.exam_1.location.name }) resp = self.client.get(url) - 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) + 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) def test_entrance_exam_content_info(self): """ test entrance exam content info method """ exam_chapter, is_exam_passed = get_entrance_exam_content_info(self.request, self.course) - self.assertEqual(exam_chapter.url_name, self.entrance_exam.url_name) - self.assertEqual(is_exam_passed, False) + if settings.FEATURES.get('ENTRANCE_EXAMS', False): + self.assertEqual(exam_chapter.url_name, self.entrance_exam.url_name) + self.assertEqual(is_exam_passed, False) - # 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) + # 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) - exam_chapter, is_exam_passed = get_entrance_exam_content_info(self.request, self.course) - self.assertEqual(exam_chapter, None) - self.assertEqual(is_exam_passed, True) + exam_chapter, is_exam_passed = get_entrance_exam_content_info(self.request, self.course) + self.assertEqual(exam_chapter, None) + self.assertEqual(is_exam_passed, True) + @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. @@ -352,8 +358,9 @@ class EntranceExamTestCases(ModuleStoreTestCase): } ) resp = self.client.get(url) - self.assertEqual(resp.status_code, 200) - self.assertIn('To access course materials, you must score', resp.content) + if settings.FEATURES.get('ENTRANCE_EXAMS', False): + 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): """ @@ -369,8 +376,9 @@ class EntranceExamTestCases(ModuleStoreTestCase): ) resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - self.assertNotIn('To access course materials, you must score', resp.content) - self.assertNotIn('You have passed the entrance exam.', resp.content) + 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) def test_entrance_exam_passed_message_and_course_content(self): """ @@ -404,10 +412,12 @@ class EntranceExamTestCases(ModuleStoreTestCase): module.system.publish(self.problem_1, 'grade', grade_dict) 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('Lesson 1', resp.content) + 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}) def test_entrance_exam_gating(self): """ Unit Test: test_entrance_exam_gating @@ -477,6 +487,7 @@ class EntranceExamTestCases(ModuleStoreTestCase): 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_exame_gating(self): """ Tests gating is disabled if skip entrance exam is set for a user. diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 3a83ea42cd..a4af387acb 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -15,16 +15,14 @@ from xmodule import tabs from xmodule.modulestore.tests.django_utils import ( TEST_DATA_MIXED_TOY_MODULESTORE, TEST_DATA_MIXED_CLOSED_MODULESTORE ) + +from courseware.tabs import get_course_tab_list from courseware.views import get_static_tab_contents, static_tab from student.tests.factories import UserFactory +from util import milestones_helpers from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -if settings.FEATURES.get('MILESTONES_APP', False): - from courseware.tabs import get_course_tab_list - from milestones import api as milestones_api - from milestones.models import MilestoneRelationshipType - class StaticTabDateTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): """Test cases for Static Tab Dates.""" @@ -140,9 +138,8 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): self.setup_user() self.enroll(self.course) self.user.is_staff = True - self.relationship_types = milestones_api.get_milestone_relationship_types() - MilestoneRelationshipType.objects.create(name='requires') - MilestoneRelationshipType.objects.create(name='fulfills') + self.relationship_types = milestones_helpers.get_milestone_relationship_types() + milestones_helpers.seed_milestone_relationship_types() def test_get_course_tabs_list_entrance_exam_enabled(self): """ @@ -160,13 +157,13 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): } self.course.entrance_exam_enabled = True self.course.entrance_exam_id = unicode(entrance_exam.location) - milestone = milestones_api.add_milestone(milestone) - milestones_api.add_course_milestone( + milestone = milestones_helpers.add_milestone(milestone) + milestones_helpers.add_course_milestone( unicode(self.course.id), self.relationship_types['REQUIRES'], milestone ) - milestones_api.add_course_content_milestone( + milestones_helpers.add_course_content_milestone( unicode(self.course.id), unicode(entrance_exam.location), self.relationship_types['FULFILLS'],