Commit a57f0d74 by Christopher Lee

Merge pull request #7794 from edx/clee/gated_content_mobile_api

MA-635 Block Mobile Content for unfulfilled milestones
parents 3e247cd6 5cef287c
...@@ -7,7 +7,8 @@ from django.conf import settings ...@@ -7,7 +7,8 @@ from django.conf import settings
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from opaque_keys import InvalidKeyError 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 from xmodule.modulestore.django import modulestore
NAMESPACE_CHOICES = { NAMESPACE_CHOICES = {
...@@ -26,7 +27,7 @@ def add_prerequisite_course(course_key, prerequisite_course_key): ...@@ -26,7 +27,7 @@ def add_prerequisite_course(course_key, prerequisite_course_key):
""" """
It would create a milestone, then it would set newly created It would create a milestone, then it would set newly created
milestones as requirement for course referred by `course_key` 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`. milestone for course referred by `prerequisite_course_key`.
""" """
if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False): if not settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False):
...@@ -313,6 +314,15 @@ def remove_content_references(content_id): ...@@ -313,6 +314,15 @@ def remove_content_references(content_id):
return milestones_api.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): def get_course_milestones_fulfillment_paths(course_id, user_id):
""" """
Client API operation adapter/wrapper Client API operation adapter/wrapper
......
...@@ -3,6 +3,8 @@ Tests for the milestones helpers library, which is the integration point for the ...@@ -3,6 +3,8 @@ Tests for the milestones helpers library, which is the integration point for the
""" """
from mock import patch from mock import patch
from milestones.exceptions import InvalidCourseKeyException, InvalidUserException
from util import milestones_helpers from util import milestones_helpers
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -85,3 +87,11 @@ class MilestonesHelpersTestCase(ModuleStoreTestCase): ...@@ -85,3 +87,11 @@ class MilestonesHelpersTestCase(ModuleStoreTestCase):
def test_add_user_milestone_returns_none_when_app_disabled(self): def test_add_user_milestone_returns_none_when_app_disabled(self):
response = milestones_helpers.add_user_milestone(self.user, self.milestone) response = milestones_helpers.add_user_milestone(self.user, self.milestone)
self.assertIsNone(response) 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)
...@@ -32,7 +32,10 @@ from student.roles import ( ...@@ -32,7 +32,10 @@ from student.roles import (
GlobalStaff, CourseStaffRole, CourseInstructorRole, GlobalStaff, CourseStaffRole, CourseInstructorRole,
OrgStaffRole, OrgInstructorRole, CourseBetaTesterRole 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 import dogstats_wrapper as dog_stats_api
...@@ -173,7 +176,12 @@ def _has_access_course_desc(user, action, course): ...@@ -173,7 +176,12 @@ def _has_access_course_desc(user, action, course):
# check start date # check start date
can_load() and can_load() and
# check mobile_available flag # 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(): def can_enroll():
......
...@@ -2,16 +2,14 @@ import json ...@@ -2,16 +2,14 @@ import json
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.client import RequestFactory from django.test.client import RequestFactory
from student.models import Registration from student.models import Registration
from django.test import TestCase
def get_request_for_user(user): def get_request_for_user(user):
"""Create a request object for user.""" """Create a request object for user."""
request = RequestFactory() request = RequestFactory()
request.user = user request.user = user
request.COOKIES = {} request.COOKIES = {}
......
"""
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)
...@@ -13,18 +13,21 @@ Test utilities for mobile API tests: ...@@ -13,18 +13,21 @@ Test utilities for mobile API tests:
# pylint: disable=no-member # pylint: disable=no-member
import ddt import ddt
from mock import patch from mock import patch
from rest_framework.test import APITestCase
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from courseware.tests.factories import UserFactory
from courseware.tests.factories import UserFactory
from student import auth from student import auth
from student.models import CourseEnrollment from student.models import CourseEnrollment
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from mobile_api.test_milestones import MobileAPIMilestonesMixin
class MobileAPITestCase(ModuleStoreTestCase, APITestCase): class MobileAPITestCase(ModuleStoreTestCase, APITestCase):
""" """
...@@ -124,7 +127,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin): ...@@ -124,7 +127,7 @@ class MobileAuthUserTestMixin(MobileAuthTestMixin):
@ddt.ddt @ddt.ddt
class MobileCourseAccessTestMixin(object): class MobileCourseAccessTestMixin(MobileAPIMilestonesMixin):
""" """
Test Mixin for testing APIs marked with mobile_course_access. Test Mixin for testing APIs marked with mobile_course_access.
(Use MobileEnrolledCourseAccessTestMixin when verify_enrolled is set to True.) (Use MobileEnrolledCourseAccessTestMixin when verify_enrolled is set to True.)
......
...@@ -49,6 +49,7 @@ class TestUserEnrollmentApi(MobileAPITestCase, MobileAuthUserTestMixin, MobileEn ...@@ -49,6 +49,7 @@ class TestUserEnrollmentApi(MobileAPITestCase, MobileAuthUserTestMixin, MobileEn
""" """
REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username']} REVERSE_INFO = {'name': 'courseenrollment-detail', 'params': ['username']}
ALLOW_ACCESS_TO_UNRELEASED_COURSE = True ALLOW_ACCESS_TO_UNRELEASED_COURSE = True
ALLOW_ACCESS_TO_MILESTONE_COURSE = True
def verify_success(self, response): def verify_success(self, response):
super(TestUserEnrollmentApi, self).verify_success(response) super(TestUserEnrollmentApi, self).verify_success(response)
......
...@@ -3,16 +3,21 @@ Common utility methods and decorators for Mobile APIs. ...@@ -3,16 +3,21 @@ Common utility methods and decorators for Mobile APIs.
""" """
import functools 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 opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
from courseware.courses import get_course_with_access from courseware.courses import get_course_with_access
from openedx.core.lib.api.permissions import IsUserInUrl
from openedx.core.lib.api.authentication import ( from openedx.core.lib.api.authentication import (
SessionAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser,
OAuth2AuthenticationAllowInactiveUser, 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): def mobile_course_access(depth=0, verify_enrolled=True):
...@@ -30,12 +35,25 @@ 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')) course_id = CourseKey.from_string(kwargs.pop('course_id'))
with modulestore().bulk_operations(course_id): with modulestore().bulk_operations(course_id):
try:
course = get_course_with_access( course = get_course_with_access(
request.user, request.user,
'load_mobile' if verify_enrolled else 'load_mobile_no_enrollment_check', 'load_mobile' if verify_enrolled else 'load_mobile_no_enrollment_check',
course_id, course_id,
depth=depth 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 func(self, request, course=course, *args, **kwargs)
return _wrapper return _wrapper
return _decorator return _decorator
......
...@@ -452,12 +452,6 @@ FEATURES['ENABLE_EDXNOTES'] = True ...@@ -452,12 +452,6 @@ FEATURES['ENABLE_EDXNOTES'] = True
# Add milestones to Installed apps for testing # Add milestones to Installed apps for testing
INSTALLED_APPS += ('milestones', ) INSTALLED_APPS += ('milestones', )
# MILESTONES
FEATURES['MILESTONES_APP'] = True
# ENTRANCE EXAMS
FEATURES['ENTRANCE_EXAMS'] = True
# Enable courseware search for tests # Enable courseware search for tests
FEATURES['ENABLE_COURSEWARE_SEARCH'] = True FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment