diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 5062450cf561..100eb647929f 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -243,6 +243,52 @@ def compare_date_fields(self, details, encoded, context, field):
elif field in encoded and encoded[field] is not None:
self.fail(field + " included in encoding but missing from details at " + context)
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
+ def test_pre_requisite_course_list_present(self):
+ settings_details_url = get_url(self.course.id)
+ response = self.client.get_html(settings_details_url)
+ self.assertContains(response, "Prerequisite Course")
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
+ def test_pre_requisite_course_update_and_fetch(self):
+ url = get_url(self.course.id)
+ resp = self.client.get_json(url)
+ course_detail_json = json.loads(resp.content)
+ # assert pre_requisite_courses is initialized
+ self.assertEqual([], course_detail_json['pre_requisite_courses'])
+
+ # update pre requisite courses with a new course keys
+ pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
+ pre_requisite_course2 = CourseFactory.create(org='edX', course='902', run='test_run')
+ pre_requisite_course_keys = [unicode(pre_requisite_course.id), unicode(pre_requisite_course2.id)]
+ course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
+ self.client.ajax_post(url, course_detail_json)
+
+ # fetch updated course to assert pre_requisite_courses has new values
+ resp = self.client.get_json(url)
+ course_detail_json = json.loads(resp.content)
+ self.assertEqual(pre_requisite_course_keys, course_detail_json['pre_requisite_courses'])
+
+ # remove pre requisite course
+ course_detail_json['pre_requisite_courses'] = []
+ self.client.ajax_post(url, course_detail_json)
+ resp = self.client.get_json(url)
+ course_detail_json = json.loads(resp.content)
+ self.assertEqual([], course_detail_json['pre_requisite_courses'])
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
+ def test_invalid_pre_requisite_course(self):
+ url = get_url(self.course.id)
+ resp = self.client.get_json(url)
+ course_detail_json = json.loads(resp.content)
+
+ # update pre requisite courses one valid and one invalid key
+ pre_requisite_course = CourseFactory.create(org='edX', course='900', run='test_run')
+ pre_requisite_course_keys = [unicode(pre_requisite_course.id), 'invalid_key']
+ course_detail_json['pre_requisite_courses'] = pre_requisite_course_keys
+ response = self.client.ajax_post(url, course_detail_json)
+ self.assertEqual(400, response.status_code)
+
@ddt.ddt
class CourseGradingTest(CourseTestCase):
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index cbc40b6f31e9..d33056ebedd5 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -67,6 +67,10 @@
from course_action_state.managers import CourseActionStateItemNotFoundError
from microsite_configuration import microsite
from xmodule.course_module import CourseFields
+from util.milestones_helpers import (
+ set_prerequisite_courses,
+ is_valid_course_key
+)
__all__ = ['course_info_handler', 'course_handler', 'course_info_update_handler',
@@ -346,34 +350,8 @@ def _accessible_courses_list_from_groups(request):
def course_listing(request):
"""
List all courses available to the logged in user
- Try to get all courses by first reversing django groups and fallback to old method if it fails
- Note: overhead of pymongo reads will increase if getting courses from django groups fails
"""
- if GlobalStaff().has_user(request.user):
- # user has global access so no need to get courses from django groups
- courses, in_process_course_actions = _accessible_courses_list(request)
- else:
- try:
- courses, in_process_course_actions = _accessible_courses_list_from_groups(request)
- except AccessListFallback:
- # user have some old groups or there was some error getting courses from django groups
- # so fallback to iterating through all courses
- courses, in_process_course_actions = _accessible_courses_list(request)
-
- def format_course_for_view(course):
- """
- Return a dict of the data which the view requires for each course
- """
- return {
- 'display_name': course.display_name,
- 'course_key': unicode(course.location.course_key),
- 'url': reverse_course_url('course_handler', course.id),
- 'lms_link': get_lms_link_for_item(course.location),
- 'rerun_link': _get_rerun_link_for_item(course.id),
- 'org': course.display_org_with_default,
- 'number': course.display_number_with_default,
- 'run': course.location.run
- }
+ courses, in_process_course_actions = get_courses_accessible_to_user(request)
def format_in_process_course_view(uca):
"""
@@ -396,14 +374,7 @@ def format_in_process_course_view(uca):
) if uca.state == CourseRerunUIStateManager.State.FAILED else ''
}
- # remove any courses in courses that are also in the in_process_course_actions list
- in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions]
- courses = [
- format_course_for_view(c)
- for c in courses
- if not isinstance(c, ErrorDescriptor) and (c.id not in in_process_action_course_keys)
- ]
-
+ courses = _remove_in_process_courses(courses, in_process_course_actions)
in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions]
return render_to_response('index.html', {
@@ -469,6 +440,52 @@ def course_index(request, course_key):
})
+def get_courses_accessible_to_user(request):
+ """
+ Try to get all courses by first reversing django groups and fallback to old method if it fails
+ Note: overhead of pymongo reads will increase if getting courses from django groups fails
+ """
+ if GlobalStaff().has_user(request.user):
+ # user has global access so no need to get courses from django groups
+ courses, in_process_course_actions = _accessible_courses_list(request)
+ else:
+ try:
+ courses, in_process_course_actions = _accessible_courses_list_from_groups(request)
+ except AccessListFallback:
+ # user have some old groups or there was some error getting courses from django groups
+ # so fallback to iterating through all courses
+ courses, in_process_course_actions = _accessible_courses_list(request)
+ return courses, in_process_course_actions
+
+
+def _remove_in_process_courses(courses, in_process_course_actions):
+ """
+ remove any courses in courses that are also in the in_process_course_actions list and formats them for view
+ """
+ def format_course_for_view(course):
+ """
+ Return a dict of the data which the view requires for each course
+ """
+ return {
+ 'display_name': course.display_name,
+ 'course_key': unicode(course.location.course_key),
+ 'url': reverse_course_url('course_handler', course.id),
+ 'lms_link': get_lms_link_for_item(course.location),
+ 'rerun_link': _get_rerun_link_for_item(course.id),
+ 'org': course.display_org_with_default,
+ 'number': course.display_number_with_default,
+ 'run': course.location.run
+ }
+
+ in_process_action_course_keys = [uca.course_key for uca in in_process_course_actions]
+ courses = [
+ format_course_for_view(c)
+ for c in courses
+ if not isinstance(c, ErrorDescriptor) and (c.id not in in_process_action_course_keys)
+ ]
+ return courses
+
+
def course_outline_initial_state(locator_to_show, course_structure):
"""
Returns the desired initial state for the course outline view. If the 'show' request parameter
@@ -744,6 +761,7 @@ def settings_handler(request, course_key_string):
json: update the Course and About xblocks through the CourseDetails model
"""
course_key = CourseKey.from_string(course_key_string)
+ prerequisite_course_enabled = settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False)
with modulestore().bulk_operations(course_key):
course_module = get_course_and_check_access(course_key, request.user)
if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET':
@@ -758,8 +776,7 @@ def settings_handler(request, course_key_string):
)
short_description_editable = settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True)
-
- return render_to_response('settings.html', {
+ settings_context = {
'context_course': course_module,
'course_locator': course_key,
'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_key),
@@ -768,15 +785,31 @@ def settings_handler(request, course_key_string):
'about_page_editable': about_page_editable,
'short_description_editable': short_description_editable,
'upload_asset_url': upload_asset_url
- })
+ }
+ if prerequisite_course_enabled:
+ courses, in_process_course_actions = get_courses_accessible_to_user(request)
+ # exclude current course from the list of available courses
+ courses = [course for course in courses if course.id != course_key]
+ if courses:
+ courses = _remove_in_process_courses(courses, in_process_course_actions)
+ settings_context.update({'pre_requisite_courses': courses})
+
+ return render_to_response('settings.html', settings_context)
elif 'application/json' in request.META.get('HTTP_ACCEPT', ''):
if request.method == 'GET':
+ course_details = CourseDetails.fetch(course_key)
return JsonResponse(
- CourseDetails.fetch(course_key),
+ course_details,
# encoder serializes dates, old locations, and instances
encoder=CourseSettingsEncoder
)
else: # post or put, doesn't matter.
+ # if pre-requisite course feature is enabled set pre-requisite course
+ if prerequisite_course_enabled:
+ prerequisite_course_keys = request.json.get('pre_requisite_courses', [])
+ if not all(is_valid_course_key(course_key) for course_key in prerequisite_course_keys):
+ return JsonResponseBadRequest({_("error"): _("Invalid prerequisite course key")})
+ set_prerequisite_courses(course_key, prerequisite_course_keys)
return JsonResponse(
CourseDetails.update_from_json(course_key, request.json, request.user),
encoder=CourseSettingsEncoder
diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py
index 21f344f706b4..509c2df6a26e 100644
--- a/cms/djangoapps/models/settings/course_details.py
+++ b/cms/djangoapps/models/settings/course_details.py
@@ -39,6 +39,7 @@ def __init__(self, org, course_id, run):
self.effort = None # int hours/week
self.course_image_name = ""
self.course_image_asset_path = "" # URL of the course image
+ self.pre_requisite_courses = [] # pre-requisite courses
@classmethod
def _fetch_about_attribute(cls, course_key, attribute):
@@ -64,6 +65,7 @@ def fetch(cls, course_key):
course_details.end_date = descriptor.end
course_details.enrollment_start = descriptor.enrollment_start
course_details.enrollment_end = descriptor.enrollment_end
+ course_details.pre_requisite_courses = descriptor.pre_requisite_courses
course_details.course_image_name = descriptor.course_image
course_details.course_image_asset_path = course_image_url(descriptor)
@@ -155,6 +157,11 @@ def update_from_json(cls, course_key, jsondict, user):
descriptor.course_image = jsondict['course_image_name']
dirty = True
+ if 'pre_requisite_courses' in jsondict \
+ and sorted(jsondict['pre_requisite_courses']) != sorted(descriptor.pre_requisite_courses):
+ descriptor.pre_requisite_courses = jsondict['pre_requisite_courses']
+ dirty = True
+
if dirty:
module_store.update_item(descriptor, user.id)
diff --git a/cms/envs/bok_choy.py b/cms/envs/bok_choy.py
index f62357153389..bf7c47965e77 100644
--- a/cms/envs/bok_choy.py
+++ b/cms/envs/bok_choy.py
@@ -53,6 +53,8 @@
# Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
+# Enable pre-requisite course
+FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 6b044d388d73..1584500ade2b 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -715,7 +715,8 @@
'openassessment.xblock',
# edxval
- 'edxval'
+ 'edxval',
+ 'milestones'
)
@@ -732,6 +733,10 @@
continue
INSTALLED_APPS += (app_name,)
+############## Pre-requisite Courses ##############
+# Turn of pre-requisite course by default. This feature depends on milestones app
+FEATURES['ENABLE_PREREQUISITE_COURSES'] = False
+
### ADVANCED_SECURITY_CONFIG
# Empty by default
ADVANCED_SECURITY_CONFIG = {}
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 2d16ded81a6c..43307b8d65ca 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -153,6 +153,9 @@
# Add external_auth to Installed apps for testing
INSTALLED_APPS += ('external_auth', )
+# Add milestones to Installed apps for testing
+INSTALLED_APPS += ('milestones', )
+
# hide ratelimit warnings while running tests
filterwarnings('ignore', message='No request passed to the backend, unable to rate-limit')
diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js
index 3957940b5dc2..58b58fb7af6f 100644
--- a/cms/static/js/models/settings/course_details.js
+++ b/cms/static/js/models/settings/course_details.js
@@ -15,7 +15,8 @@ var CourseDetails = Backbone.Model.extend({
intro_video: null,
effort: null, // an int or null,
course_image_name: '', // the filename
- course_image_asset_path: '' // the full URL (/c4x/org/course/num/asset/filename)
+ course_image_asset_path: '', // the full URL (/c4x/org/course/num/asset/filename)
+ pre_requisite_courses: []
},
validate: function(newattrs) {
diff --git a/cms/static/js/spec/views/settings/main_spec.js b/cms/static/js/spec/views/settings/main_spec.js
index c3066f4fa614..7fea35e84839 100644
--- a/cms/static/js/spec/views/settings/main_spec.js
+++ b/cms/static/js/spec/views/settings/main_spec.js
@@ -4,7 +4,7 @@ define([
], function($, CourseDetailsModel, MainView, AjaxHelpers) {
'use strict';
describe('Settings/Main', function () {
- var urlRoot = '/course-details',
+ var urlRoot = '/course/settings/org/DemoX/Demo_Course',
modelData = {
start_date: "2014-10-05T00:00:00Z",
end_date: "2014-11-05T20:00:00Z",
@@ -19,7 +19,8 @@ define([
intro_video : null,
effort : null,
course_image_name : '',
- course_image_asset_path : ''
+ course_image_asset_path : '',
+ pre_requisite_courses : []
},
mockSettingsPage = readFixtures('mock/mock-settings-page.underscore');
@@ -47,7 +48,6 @@ define([
// Expect to see changes just in `start_date` field.
start_date: "2014-10-05T22:00:00.000Z"
});
-
this.view.$el.find('#course-start-time')
.val('22:00')
.trigger('input');
@@ -56,8 +56,25 @@ define([
// It sends `POST` request, because the model doesn't have `id`. In
// this case, it is considered to be new according to Backbone documentation.
AjaxHelpers.expectJsonRequest(
- requests, 'POST', '/course-details', expectedJson
+ requests, 'POST', urlRoot, expectedJson
+ );
+ });
+
+ it('Selecting a course in pre-requisite drop down should save it as part of course details', function () {
+ var pre_requisite_courses = ['test/CSS101/2012_T1'];
+ var requests = AjaxHelpers.requests(this),
+ expectedJson = $.extend(true, {}, modelData, {
+ pre_requisite_courses: pre_requisite_courses
+ });
+ this.view.$el.find('#pre-requisite-course')
+ .val(pre_requisite_courses[0])
+ .trigger('change');
+
+ this.view.saveView();
+ AjaxHelpers.expectJsonRequest(
+ requests, 'POST', urlRoot, expectedJson
);
+ AjaxHelpers.respondWithJson(requests, expectedJson);
});
});
});
diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js
index 47f88d423470..999e80fad4b1 100644
--- a/cms/static/js/views/settings/main.js
+++ b/cms/static/js/views/settings/main.js
@@ -10,6 +10,7 @@ var DetailsView = ValidatingView.extend({
// Leaving change in as fallback for older browsers
"change input" : "updateModel",
"change textarea" : "updateModel",
+ "change select" : "updateModel",
'click .remove-course-introduction-video' : "removeVideo",
'focus #course-overview' : "codeMirrorize",
'mouseover .timezone' : "updateTime",
@@ -63,6 +64,9 @@ var DetailsView = ValidatingView.extend({
var imageURL = this.model.get('course_image_asset_path');
this.$el.find('#course-image-url').val(imageURL);
this.$el.find('#course-image').attr('src', imageURL);
+ var pre_requisite_courses = this.model.get('pre_requisite_courses');
+ pre_requisite_courses = pre_requisite_courses.length > 0 ? pre_requisite_courses : '';
+ this.$el.find('#' + this.fieldToSelectorMap['pre_requisite_courses']).val(pre_requisite_courses);
return this;
},
@@ -75,7 +79,8 @@ var DetailsView = ValidatingView.extend({
'short_description' : 'course-short-description',
'intro_video' : 'course-introduction-video',
'effort' : "course-effort",
- 'course_image_asset_path': 'course-image-url'
+ 'course_image_asset_path': 'course-image-url',
+ 'pre_requisite_courses': 'pre-requisite-course'
},
updateTime : function(e) {
@@ -154,6 +159,11 @@ var DetailsView = ValidatingView.extend({
case 'course-short-description':
this.setField(event);
break;
+ case 'pre-requisite-course':
+ var value = $(event.currentTarget).val();
+ value = value == "" ? [] : [value];
+ this.model.set('pre_requisite_courses', value);
+ break;
// Don't make the user reload the page to check the Youtube ID.
// Wait for a second to load the video, avoiding egregious AJAX calls.
case 'course-introduction-video':
diff --git a/cms/templates/js/mock/mock-settings-page.underscore b/cms/templates/js/mock/mock-settings-page.underscore
index 67835a9ee735..be45253da1f2 100644
--- a/cms/templates/js/mock/mock-settings-page.underscore
+++ b/cms/templates/js/mock/mock-settings-page.underscore
@@ -62,6 +62,19 @@
(UTC)
+
+
+ Prerequisite Course
+
+ None
+ [Test] Communicating for Impact
+ CohortAverageTesting
+ edX Demonstration Course
+
+ Course that students must complete before beginning this course
+ set pre-requisite course
+
+
diff --git a/cms/templates/settings.html b/cms/templates/settings.html
index 590d6d3e2caf..8d6f950a6736 100644
--- a/cms/templates/settings.html
+++ b/cms/templates/settings.html
@@ -306,6 +306,21 @@ ${_("Requirements")}
${_("Time spent on all course work")}
+ % if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES'):
+
+
+
+ % endif
% endif
diff --git a/common/djangoapps/student/tests/test_course_listing.py b/common/djangoapps/student/tests/test_course_listing.py
index b73067cbd082..b647147bdfdf 100644
--- a/common/djangoapps/student/tests/test_course_listing.py
+++ b/common/djangoapps/student/tests/test_course_listing.py
@@ -2,6 +2,7 @@
Unit tests for getting the list of courses for a user through iterating all courses and
by reversing group name formats.
"""
+import mock
from mock import patch, Mock
from student.tests.factories import UserFactory
@@ -15,6 +16,11 @@
from django.test.client import Client
from student.models import CourseEnrollment
from student.views import get_course_enrollment_pairs
+from opaque_keys.edx.keys import CourseKey
+from util.milestones_helpers import (
+ get_pre_requisite_courses_not_completed,
+ set_prerequisite_courses,
+)
import unittest
from django.conf import settings
@@ -35,14 +41,16 @@ def setUp(self):
self.client = Client()
self.client.login(username=self.teacher.username, password='test')
- def _create_course_with_access_groups(self, course_location):
+ def _create_course_with_access_groups(self, course_location, metadata=None):
"""
Create dummy course with 'CourseFactory' and enroll the student
"""
+ metadata = {} if not metadata else metadata
course = CourseFactory.create(
org=course_location.org,
number=course_location.course,
- run=course_location.run
+ run=course_location.run,
+ metadata=metadata
)
CourseEnrollment.enroll(self.student, course.id)
@@ -119,3 +127,32 @@ def test_course_listing_errored_deleted_courses(self):
courses_list = list(get_course_enrollment_pairs(self.student, None, []))
self.assertEqual(len(courses_list), 1, courses_list)
self.assertEqual(courses_list[0][0].id, good_location)
+
+ @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
+ def test_course_listing_has_pre_requisite_courses(self):
+ """
+ Creates four courses. Enroll test user in all courses
+ Sets two of them as pre-requisites of another course.
+ Checks course where pre-requisite course is set has appropriate info.
+ """
+ course_location2 = CourseKey.from_string('Org1/Course2/Run2')
+ self._create_course_with_access_groups(course_location2)
+ pre_requisite_course_location = CourseKey.from_string('Org1/Course3/Run3')
+ self._create_course_with_access_groups(pre_requisite_course_location)
+ pre_requisite_course_location2 = CourseKey.from_string('Org1/Course4/Run4')
+ self._create_course_with_access_groups(pre_requisite_course_location2)
+ # create a course with pre_requisite_courses
+ pre_requisite_courses = [
+ unicode(pre_requisite_course_location),
+ unicode(pre_requisite_course_location2),
+ ]
+ course_location = CourseKey.from_string('Org1/Course1/Run1')
+ self._create_course_with_access_groups(course_location, {
+ 'pre_requisite_courses': pre_requisite_courses
+ })
+
+ set_prerequisite_courses(course_location, pre_requisite_courses)
+ # get dashboard
+ courses_list = list(get_course_enrollment_pairs(self.student, None, []))
+ courses_requirements_not_met = get_pre_requisite_courses_not_completed(self.student, courses_list)
+ self.assertEqual(len(courses_requirements_not_met[course_location]['courses']), len(pre_requisite_courses))
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 4c8b08d7ec07..6c72cd37ebfc 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -89,7 +89,9 @@
from util.db import commit_on_success_with_read_committed
from util.json_request import JsonResponse
from util.bad_request_rate_limiter import BadRequestRateLimiter
-
+from util.milestones_helpers import (
+ get_pre_requisite_courses_not_completed,
+)
from microsite_configuration import microsite
from util.password_policy_validators import (
@@ -644,6 +646,9 @@ def dashboard(request):
# Populate the Order History for the side-bar.
order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set)
+ # get list of courses having pre-requisites yet to be completed
+ courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, course_enrollment_pairs)
+
context = {
'enrollment_message': enrollment_message,
'course_enrollment_pairs': course_enrollment_pairs,
@@ -673,7 +678,8 @@ def dashboard(request):
'platform_name': settings.PLATFORM_NAME,
'enrolled_courses_either_paid': enrolled_courses_either_paid,
'provider_states': [],
- 'order_history_list': order_history_list
+ 'order_history_list': order_history_list,
+ 'courses_requirements_not_met': courses_requirements_not_met,
}
if third_party_auth.is_enabled():
diff --git a/common/djangoapps/util/milestones_helpers.py b/common/djangoapps/util/milestones_helpers.py
new file mode 100644
index 000000000000..2080963aa74d
--- /dev/null
+++ b/common/djangoapps/util/milestones_helpers.py
@@ -0,0 +1,149 @@
+"""
+Helper methods for milestones api calls.
+"""
+
+from django.utils.translation import ugettext as _
+from opaque_keys import InvalidKeyError
+from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from opaque_keys.edx.keys import CourseKey
+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 django.conf import settings
+
+
+def add_prerequisite_course(course_key, prerequisite_course_key):
+ """
+ It would create a milestone for given course and set it as
+ requirement milestone for given course and set it a fulfilment
+ milestone for pre-requisite course.
+ """
+ # 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)
+
+ # add fulfillment course milestone
+ add_course_milestone(prerequisite_course_key, 'fulfills', milestone)
+
+
+def remove_prerequisite_course(course_key, milestone):
+ """
+ It would remove pre-requisite course milestone for a course
+ """
+
+ remove_course_milestone(
+ course_key,
+ milestone,
+ )
+
+
+def set_prerequisite_courses(course_key, prerequisite_course_keys):
+ """
+ It would remove any existing requirement milestone for the given course
+ and create new milestones for each pre requisite course in `prerequisite_course_keys`
+ """
+ #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)
+
+ # 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): # pylint: disable=invalid-name
+ """
+ It would make dict of prerequisite courses not completed by user among courses
+ user has enrolled in. It calls the fulfilment api of milestones app and
+ iterates over all fulfillments of user not achieved to make dict of
+ prerequisites yet to be completed.
+ """
+ pre_requisite_courses = {}
+ if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES'):
+ for course, __ in enrolled_courses:
+ required_courses = []
+ # if course has pre-requisites then fetch fulfilment path
+ if course.pre_requisite_courses:
+ fulfilment_paths = get_course_milestones_fulfillment_paths(course.id, {'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:
+ for required_course in value:
+ required_course_key = CourseKey.from_string(required_course['course_id'])
+ required_course_descriptor = modulestore().get_course(required_course_key)
+ required_courses.append({
+ 'key': required_course_key,
+ 'display': ' '.join([
+ required_course_descriptor.display_org_with_default,
+ required_course_descriptor.display_number_with_default
+ ])
+ })
+
+ # if there are required courses add to dict
+ if required_courses:
+ pre_requisite_courses[course.id] = {'courses': required_courses}
+ return pre_requisite_courses
+
+
+def get_prerequisite_courses_display(course_descriptor): # pylint: disable=invalid-name
+ """
+ It would retrieve pre-requisite courses, make display strings
+ and return them as list
+ """
+ pre_requisite_courses = []
+ if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES', False) and course_descriptor.pre_requisite_courses:
+ for course_id in course_descriptor.pre_requisite_courses:
+ course_key = CourseKey.from_string(course_id)
+ required_course_descriptor = modulestore().get_course(course_key)
+ pre_requisite_courses.append(' '.join([
+ required_course_descriptor.display_org_with_default,
+ required_course_descriptor.display_number_with_default
+ ]))
+ return pre_requisite_courses
+
+
+def fulfill_course_milestone(course_key, user):
+ """
+ It would save course milestone collected by user.
+ """
+ course_milestones = get_course_milestones(course_key=course_key, relationship="fulfills")
+ for milestone in course_milestones:
+ add_user_milestone({'id': user.id}, milestone)
+
+
+def milestones_achieved_by_user(user):
+ """
+ It would fetch list of milestones completed by user
+ """
+ return get_user_milestones({'id': user.id})
+
+
+def is_valid_course_key(key):
+ """
+ validates course key. returns True if valid else False.
+ """
+ try:
+ course_key = CourseKey.from_string(key)
+ except InvalidKeyError:
+ try:
+ course_key = SlashSeparatedCourseKey.from_deprecated_string(key)
+ except InvalidKeyError:
+ course_key = key
+
+ return isinstance(course_key, CourseKey)
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 0f9ccdce1fb7..5ff06028df22 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -184,6 +184,11 @@ class CourseFields(object):
help=_("Enter the date you want to advertise as the course start date, if this date is different from the set start date. To advertise the set start date, enter null."),
scope=Scope.settings
)
+ pre_requisite_courses = List(
+ display_name=_("Pre-Requisite Courses"),
+ help=_("Pre-Requisite Course key if this course has a pre-requisite course"),
+ scope=Scope.settings
+ )
grading_policy = Dict(
help="Grading policy definition for this class",
default={
diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py
index 69836fbee048..ac67da704af0 100644
--- a/common/test/acceptance/fixtures/course.py
+++ b/common/test/acceptance/fixtures/course.py
@@ -334,8 +334,8 @@ def _configure_course(self):
if not response.ok:
raise CourseFixtureError(
- "Could not retrieve course details. Status was {0}".format(
- response.status_code))
+ "Could not retrieve course details located at {0}. Status was {1}".format(
+ url, response.status_code))
try:
details = response.json()
diff --git a/common/test/acceptance/pages/lms/dashboard.py b/common/test/acceptance/pages/lms/dashboard.py
index 4d810c98a913..dd7706adaedb 100644
--- a/common/test/acceptance/pages/lms/dashboard.py
+++ b/common/test/acceptance/pages/lms/dashboard.py
@@ -95,3 +95,9 @@ def _check_func():
modal_is_visible = self.q(css='section#change_language.modal').visible
return (language_is_selected and not modal_is_visible)
return EmptyPromise(_check_func, "language changed and modal hidden")
+
+ def pre_requisite_message_displayed(self):
+ """
+ Verify if pre-requisite course messages are being displayed.
+ """
+ return self.q(css='section.prerequisites > .tip').visible
diff --git a/common/test/acceptance/pages/studio/settings.py b/common/test/acceptance/pages/studio/settings.py
index 2af5c0c37838..96d5bd23016d 100644
--- a/common/test/acceptance/pages/studio/settings.py
+++ b/common/test/acceptance/pages/studio/settings.py
@@ -3,6 +3,7 @@
"""
from .course_page import CoursePage
+from .utils import press_the_notification_button
class SettingsPage(CoursePage):
@@ -14,3 +15,22 @@ class SettingsPage(CoursePage):
def is_browser_on_page(self):
return self.q(css='body.view-settings').present
+
+ @property
+ def pre_requisite_course(self):
+ """
+ Returns the pre-requisite course drop down field.
+ """
+ return self.q(css='#pre-requisite-course')
+
+ def save_changes(self):
+ """
+ Clicks save button.
+ """
+ press_the_notification_button(self, "save")
+
+ def refresh_page(self):
+ """
+ Reload the page.
+ """
+ self.browser.refresh()
diff --git a/common/test/acceptance/tests/helpers.py b/common/test/acceptance/tests/helpers.py
index 7b833dde2287..765c58226449 100644
--- a/common/test/acceptance/tests/helpers.py
+++ b/common/test/acceptance/tests/helpers.py
@@ -9,6 +9,7 @@
from path import path
from bok_choy.web_app_test import WebAppTest
from opaque_keys.edx.locator import CourseLocator
+from selenium.webdriver.support.select import Select
def skip_if_browser(browser):
@@ -167,6 +168,31 @@ def enable_css_animations(page):
""")
+def generate_course_key(org, number, run):
+ """
+ Makes a CourseLocator from org, number and run
+ """
+ default_store = os.environ.get('DEFAULT_STORE', 'draft')
+ return CourseLocator(org, number, run, deprecated=(default_store == 'draft'))
+
+
+def select_option_by_value(browser_query, value):
+ """
+ Selects a html select element by matching value attribute
+ """
+ select = Select(browser_query.first.results[0])
+ select.select_by_value(value)
+
+
+def is_option_value_selected(browser_query, value):
+ """
+ return true if given value is selected in html select element, else return false.
+ """
+ select = Select(browser_query.first.results[0])
+ ddl_selected_value = select.first_selected_option.get_attribute('value')
+ return ddl_selected_value == value
+
+
class UniqueCourseTest(WebAppTest):
"""
Test that provides a unique course ID.
diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py
index 09853250bfcf..eb22b71fbc9f 100644
--- a/common/test/acceptance/tests/lms/test_lms.py
+++ b/common/test/acceptance/tests/lms/test_lms.py
@@ -8,7 +8,13 @@
from nose.plugins.attrib import attr
from bok_choy.web_app_test import WebAppTest
-from ..helpers import UniqueCourseTest, load_data_str
+from bok_choy.promise import EmptyPromise
+from ..helpers import (
+ UniqueCourseTest,
+ load_data_str,
+ generate_course_key,
+ select_option_by_value,
+)
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.common.logout import LogoutPage
from ...pages.lms.find_courses import FindCoursesPage
@@ -22,6 +28,7 @@
from ...pages.lms.video.video import VideoPage
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.login_and_register import CombinedLoginAndRegisterPage
+from ...pages.studio.settings import SettingsPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc
@@ -601,6 +608,90 @@ def test_tooltip(self):
self.assertTrue(self.courseware_page.tooltips_displayed())
+class PreRequisiteCourseTest(UniqueCourseTest):
+ """
+ Tests that pre-requisite course messages are displayed
+ """
+
+ def setUp(self):
+ """
+ Initialize pages and install a course fixture.
+ """
+ super(PreRequisiteCourseTest, self).setUp()
+
+ CourseFixture(
+ self.course_info['org'], self.course_info['number'],
+ self.course_info['run'], self.course_info['display_name']
+ ).install()
+
+ self.prc_info = {
+ 'org': 'test_org',
+ 'number': self.unique_id,
+ 'run': 'prc_test_run',
+ 'display_name': 'PR Test Course' + self.unique_id
+ }
+
+ CourseFixture(
+ self.prc_info['org'], self.prc_info['number'],
+ self.prc_info['run'], self.prc_info['display_name']
+ ).install()
+
+ pre_requisite_course_key = generate_course_key(
+ self.prc_info['org'],
+ self.prc_info['number'],
+ self.prc_info['run']
+ )
+ self.pre_requisite_course_id = unicode(pre_requisite_course_key)
+
+ self.dashboard_page = DashboardPage(self.browser)
+ self.settings_page = SettingsPage(
+ self.browser,
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run']
+
+ )
+ # Auto-auth register for the course
+ AutoAuthPage(self.browser, course_id=self.course_id).visit()
+
+ def test_dashboard_message(self):
+ """
+ Scenario: Any course where there is a Pre-Requisite course Student dashboard should have
+ appropriate messaging.
+ Given that I am on the Student dashboard
+ When I view a course with a pre-requisite course set
+ Then At the bottom of course I should see course requirements message.'
+ """
+
+ # visit dashboard page and make sure there is not pre-requisite course message
+ self.dashboard_page.visit()
+ self.assertFalse(self.dashboard_page.pre_requisite_message_displayed())
+
+ # Logout and login as a staff.
+ LogoutPage(self.browser).visit()
+ AutoAuthPage(self.browser, course_id=self.course_id, staff=True).visit()
+
+ # visit course settings page and set pre-requisite course
+ self.settings_page.visit()
+ self._set_pre_requisite_course()
+
+ # Logout and login as a student.
+ LogoutPage(self.browser).visit()
+ AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit()
+
+ # visit dashboard page again now it should have pre-requisite course message
+ self.dashboard_page.visit()
+ EmptyPromise(lambda: self.dashboard_page.available_courses > 0, 'Dashboard page loaded').fulfill()
+ self.assertTrue(self.dashboard_page.pre_requisite_message_displayed())
+
+ def _set_pre_requisite_course(self):
+ """
+ set pre-requisite course
+ """
+ select_option_by_value(self.settings_page.pre_requisite_course, self.pre_requisite_course_id)
+ self.settings_page.save_changes()
+
+
class ProblemExecutionTest(UniqueCourseTest):
"""
Tests of problems.
diff --git a/common/test/acceptance/tests/studio/test_studio_settings_details.py b/common/test/acceptance/tests/studio/test_studio_settings_details.py
new file mode 100644
index 000000000000..4ee2da19eb54
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_settings_details.py
@@ -0,0 +1,84 @@
+"""
+Acceptance tests for Studio's Settings Details pages
+"""
+from acceptance.tests.studio.base_studio_test import StudioCourseTest
+from ...fixtures.course import CourseFixture
+from ..helpers import (
+ generate_course_key,
+ select_option_by_value,
+ is_option_value_selected
+)
+
+from ...pages.studio.settings import SettingsPage
+
+
+class SettingsMilestonesTest(StudioCourseTest):
+ """
+ Tests for milestones feature in Studio's settings tab
+ """
+ def setUp(self):
+ super(SettingsMilestonesTest, self).setUp(is_staff=True)
+ self.settings_detail = SettingsPage(
+ self.browser,
+ self.course_info['org'],
+ self.course_info['number'],
+ self.course_info['run']
+ )
+
+ # Before every test, make sure to visit the page first
+ self.settings_detail.visit()
+ self.assertTrue(self.settings_detail.is_browser_on_page())
+
+ def test_page_has_prerequisite_field(self):
+ """
+ Test to make sure page has pre-requisite course field if milestones app is enabled.
+ """
+
+ self.assertTrue(self.settings_detail.pre_requisite_course.present)
+
+ def test_prerequisite_course_save_successfully(self):
+ """
+ Scenario: Selecting course from Pre-Requisite course drop down save the selected course as pre-requisite
+ course.
+ Given that I am on the Schedule & Details page on studio
+ When I select an item in pre-requisite course drop down and click Save Changes button
+ Then My selected item should be saved as pre-requisite course
+ And My selected item should be selected after refreshing the page.'
+ """
+ course_number = self.unique_id
+ CourseFixture(
+ org='test_org',
+ number=course_number,
+ run='test_run',
+ display_name='Test Course' + course_number
+ ).install()
+
+ pre_requisite_course_key = generate_course_key(
+ org='test_org',
+ number=course_number,
+ run='test_run'
+ )
+ pre_requisite_course_id = unicode(pre_requisite_course_key)
+
+ # refreshing the page after creating a course fixture, in order reload the pre requisite course drop down.
+ self.settings_detail.refresh_page()
+ select_option_by_value(
+ browser_query=self.settings_detail.pre_requisite_course,
+ value=pre_requisite_course_id
+ )
+
+ # trigger the save changes button.
+ self.settings_detail.save_changes()
+
+ self.assertTrue('Your changes have been saved.' in self.settings_detail.browser.page_source)
+ self.settings_detail.refresh_page()
+ self.assertTrue(is_option_value_selected(browser_query=self.settings_detail.pre_requisite_course,
+ value=pre_requisite_course_id))
+
+ # now reset/update the pre requisite course to none
+ select_option_by_value(browser_query=self.settings_detail.pre_requisite_course, value='')
+
+ # trigger the save changes button.
+ self.settings_detail.save_changes()
+ self.assertTrue('Your changes have been saved.' in self.settings_detail.browser.page_source)
+ self.assertTrue(is_option_value_selected(browser_query=self.settings_detail.pre_requisite_course, value=''))
diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py
index 0b02ed37284f..0476579da597 100644
--- a/lms/djangoapps/certificates/models.py
+++ b/lms/djangoapps/certificates/models.py
@@ -1,8 +1,12 @@
from django.contrib.auth.models import User
from django.db import models
+from django.db.models.signals import post_save
+from django.dispatch import receiver
+from django.conf import settings
from datetime import datetime
from model_utils import Choices
from xmodule_django.models import CourseKeyField, NoneToEmptyManager
+from util.milestones_helpers import fulfill_course_milestone
"""
Certificates are created for a student and an offering of a course.
@@ -118,6 +122,17 @@ def certificate_for_student(cls, student, course_id):
return None
+@receiver(post_save, sender=GeneratedCertificate)
+def handle_post_cert_generated(sender, instance, **kwargs): # pylint: disable=no-self-argument, unused-argument
+ """
+ Handles post_save signal of GeneratedCertificate, and mark user collected
+ course milestone entry if user has passed the course
+ or certificate status is 'generating'.
+ """
+ if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES') and instance.status == CertificateStatuses.generating:
+ fulfill_course_milestone(instance.course_id, instance.user)
+
+
def certificate_status_for_student(student, course_id):
'''
This returns a dictionary with a key for status, and other information.
diff --git a/lms/djangoapps/certificates/tests/tests.py b/lms/djangoapps/certificates/tests/tests.py
index 52d73f42e95c..b248734f8a57 100644
--- a/lms/djangoapps/certificates/tests/tests.py
+++ b/lms/djangoapps/certificates/tests/tests.py
@@ -2,6 +2,8 @@
Tests for the certificates models.
"""
+from mock import patch
+from django.conf import settings
from django.test import TestCase
from xmodule.modulestore.tests.factories import CourseFactory
@@ -9,6 +11,8 @@
from student.tests.factories import UserFactory
from certificates.models import CertificateStatuses, GeneratedCertificate, certificate_status_for_student
+from certificates.tests.factories import GeneratedCertificateFactory
+from util.milestones_helpers import set_prerequisite_courses, milestones_achieved_by_user
class CertificatesModelTest(ModuleStoreTestCase):
@@ -23,3 +27,25 @@ def test_certificate_status_for_student(self):
certificate_status = certificate_status_for_student(student, course.id)
self.assertEqual(certificate_status['status'], CertificateStatuses.unavailable)
self.assertEqual(certificate_status['mode'], GeneratedCertificate.MODES.honor)
+
+ @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True})
+ def test_course_milestone_collected(self):
+ student = UserFactory()
+ course = CourseFactory.create(org='edx', number='998', display_name='Test Course')
+ pre_requisite_course = CourseFactory.create(org='edx', number='999', display_name='Pre requisite Course')
+ # set pre-requisite course
+ set_prerequisite_courses(course.id, [unicode(pre_requisite_course.id)])
+ # get milestones collected by user before completing the pre-requisite course
+ completed_milestones = milestones_achieved_by_user(student)
+ self.assertEqual(len(completed_milestones), 0)
+
+ GeneratedCertificateFactory.create(
+ user=student,
+ course_id=pre_requisite_course.id,
+ status=CertificateStatuses.generating,
+ mode='verified'
+ )
+ # get milestones collected by user after user has completed the pre-requisite course
+ completed_milestones = milestones_achieved_by_user(student)
+ self.assertEqual(len(completed_milestones), 1)
+ self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id))
diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py
index 4dae9f316172..463fc7219f63 100644
--- a/lms/djangoapps/courseware/access.py
+++ b/lms/djangoapps/courseware/access.py
@@ -25,6 +25,7 @@
)
from student.models import CourseEnrollment, CourseEnrollmentAllowed
from opaque_keys.edx.keys import CourseKey, UsageKey
+from util.milestones_helpers import get_pre_requisite_courses_not_completed
DEBUG_ACCESS = False
log = logging.getLogger(__name__)
@@ -287,6 +288,13 @@ def can_load():
if descriptor.visible_to_staff_only and not _has_staff_access_to_descriptor(user, descriptor, course_key):
return False
+ # if course has pre-requisite course not passed by user
+ if settings.FEATURES['ENABLE_PREREQUISITE_COURSES'] and \
+ not _has_staff_access_to_descriptor(user, descriptor, course_key) and \
+ descriptor.pre_requisite_courses and \
+ get_pre_requisite_courses_not_completed(user, [(descriptor, None)]):
+ return False
+
# If start dates are off, can always load
if settings.FEATURES['DISABLE_START_DATES'] and not is_masquerading_as_student(user):
debug("Allow: DISABLE_START_DATES")
diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py
index b9bdb1233d99..f7cc6f2901e8 100644
--- a/lms/djangoapps/courseware/tests/test_about.py
+++ b/lms/djangoapps/courseware/tests/test_about.py
@@ -120,6 +120,19 @@ def test_logged_in_marketing(self):
info_url = reverse('info', args=[self.course.id.to_deprecated_string()])
self.assertTrue(target_url.endswith(info_url))
+ @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True})
+ def test_pre_requisite_course(self):
+ pre_requisite_course = CourseFactory.create(org='edX', course='900', display_name='pre requisite course')
+ course = CourseFactory.create(pre_requisite_courses=[unicode(pre_requisite_course.id)])
+ self.setup_user()
+ url = reverse('about_course', args=[unicode(course.id)])
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+ self.assertIn("{} {} "
+ .format(pre_requisite_course.display_org_with_default,
+ pre_requisite_course.display_number_with_default),
+ resp.content.strip('\n'))
+
@override_settings(MODULESTORE=TEST_DATA_MIXED_CLOSED_MODULESTORE)
class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py
index 59a9a5b86b09..f9dee6ff978e 100644
--- a/lms/djangoapps/courseware/tests/test_access.py
+++ b/lms/djangoapps/courseware/tests/test_access.py
@@ -12,6 +12,11 @@
CATALOG_VISIBILITY_CATALOG_AND_ABOUT, CATALOG_VISIBILITY_ABOUT,
CATALOG_VISIBILITY_NONE
)
+from xmodule.modulestore.tests.factories import CourseFactory
+from util.milestones_helpers import (
+ set_prerequisite_courses,
+ fulfill_course_milestone,
+)
# pylint: disable=missing-docstring
# pylint: disable=protected-access
@@ -242,6 +247,35 @@ def test__catalog_visibility(self):
self.assertTrue(access._has_access_course_desc(staff, 'see_in_catalog', course))
self.assertTrue(access._has_access_course_desc(staff, 'see_about_page', course))
+ @patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True})
+ def test_access_on_course_with_pre_requisites(self):
+ """
+ Test course access when a course has pre-requisite course yet to be completed
+ """
+ user = UserFactory.create()
+
+ pre_requisite_course = CourseFactory.create(
+ org='test_org', number='788', run='test_run'
+ )
+
+ pre_requisite_courses = [unicode(pre_requisite_course.id)]
+ course = CourseFactory.create(
+ org='test_org', number='786', run='test_run', pre_requisite_courses=pre_requisite_courses
+ )
+ set_prerequisite_courses(course.id, pre_requisite_courses)
+
+ #user should not be able to load course even if enrolled
+ CourseEnrollmentAllowedFactory(email=user.email, course_id=course.id)
+ self.assertFalse(access._has_access_course_desc(user, 'load', course))
+
+ # Staff can always access course
+ staff = StaffFactory.create(course_key=course.id)
+ self.assertTrue(access._has_access_course_desc(staff, 'load', course))
+
+ # User should be able access after completing required course
+ fulfill_course_milestone(pre_requisite_course.id, user)
+ self.assertTrue(access._has_access_course_desc(user, 'load', course))
+
class UserRoleTestCase(TestCase):
"""
diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py
index 30347abc236c..1001b8ff8649 100644
--- a/lms/djangoapps/courseware/views.py
+++ b/lms/djangoapps/courseware/views.py
@@ -56,6 +56,7 @@
from shoppingcart.models import CourseRegistrationCode
from shoppingcart.utils import is_shopping_cart_enabled
from opaque_keys import InvalidKeyError
+from util.milestones_helpers import get_prerequisite_courses_display
from microsite_configuration import microsite
from opaque_keys.edx.locations import SlashSeparatedCourseKey
@@ -792,6 +793,9 @@ def course_about(request, course_id):
is_shib_course = uses_shib(course)
+ # get prerequisite courses display names
+ pre_requisite_courses = get_prerequisite_courses_display(course)
+
return render_to_response('courseware/course_about.html', {
'course': course,
'staff_access': staff_access,
@@ -813,6 +817,7 @@ def course_about(request, course_id):
'disable_courseware_header': True,
'is_shopping_cart_enabled': _is_shopping_cart_enabled,
'cart_link': reverse('shoppingcart.views.show_cart'),
+ 'pre_requisite_courses': pre_requisite_courses
})
diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py
index af3b992808f8..f91731353a37 100644
--- a/lms/envs/bok_choy.py
+++ b/lms/envs/bok_choy.py
@@ -80,6 +80,9 @@
for log_name, log_level in LOG_OVERRIDES:
logging.getLogger(log_name).setLevel(log_level)
+# Enable pre-requisite course
+FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
+
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 5d3f1fe9a4f6..3a2af0ec3b7b 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -1608,6 +1608,10 @@
INSTALLED_APPS += ('django_cas',)
MIDDLEWARE_CLASSES += ('django_cas.middleware.CASMiddleware',)
+############## Pre-requisite Courses ##############
+# Turn of pre-requisite course by default. This feature depends on milestones app
+FEATURES['ENABLE_PREREQUISITE_COURSES'] = False
+
###################### Registration ##################################
# For each of the fields, give one of the following values:
@@ -1886,7 +1890,8 @@
'openassessment.xblock',
# edxval
- 'edxval'
+ 'edxval',
+ 'milestones'
)
for app_name in OPTIONAL_APPS:
diff --git a/lms/envs/test.py b/lms/envs/test.py
index 7f1e751c4da0..859f45d3e56a 100644
--- a/lms/envs/test.py
+++ b/lms/envs/test.py
@@ -405,3 +405,6 @@
'password': '',
'db': 'xlog',
}
+
+# Add milestones to Installed apps for testing
+INSTALLED_APPS += ('milestones', )
diff --git a/lms/static/sass/multicourse/_course_about.scss b/lms/static/sass/multicourse/_course_about.scss
index 24e04bdb75aa..ad72f54370b9 100644
--- a/lms/static/sass/multicourse/_course_about.scss
+++ b/lms/static/sass/multicourse/_course_about.scss
@@ -564,6 +564,20 @@
font-weight: 700;
}
}
+
+ .prerequisite-course {
+ .pre-requisite {
+ max-width: 39%;
+ @extend %text-truncated;
+ }
+ .tip {
+ float: left;
+ margin: $baseline 0 ($baseline/2);
+ font-size: 0.8em;
+ color: $lighter-base-font-color;
+ font-family: $sans-serif;
+ }
+ }
}
}
}
diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss
index e3820f30c831..59efb12a981d 100644
--- a/lms/static/sass/multicourse/_dashboard.scss
+++ b/lms/static/sass/multicourse/_dashboard.scss
@@ -482,6 +482,17 @@
}
}
+ .prerequisites {
+ @include clearfix;
+
+ .tip {
+ font-family: $sans-serif;
+ font-size: 1em;
+ color: $lighter-base-font-color;
+ margin-top: ($baseline/2);
+ }
+ }
+
// "enrolled as" status
.sts-enrollment {
position: absolute;
diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html
index f2e24cf5ae24..326c310e1602 100644
--- a/lms/templates/courseware/course_about.html
+++ b/lms/templates/courseware/course_about.html
@@ -319,6 +319,14 @@
% endif
+ % if pre_requisite_courses:
+
+
+ ${_("Requirements")}
+ ${pre_requisite_courses[0]}
+ ${_("You must successfully complete {course} before you begin this course").format(course=pre_requisite_courses[0])}.
+
+ % endif
% if get_course_about_section(course, "prerequisites"):
${_("Prerequisites")}
${get_course_about_section(course, "prerequisites")}
% endif
diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html
index f30da273e5bc..a07f56bcacec 100644
--- a/lms/templates/dashboard.html
+++ b/lms/templates/dashboard.html
@@ -190,7 +190,8 @@ ${_("Current Courses")}
<% is_paid_course = (course.id in enrolled_courses_either_paid) %>
<% is_course_blocked = (course.id in block_courses) %>
<% course_verification_status = verification_status_by_course.get(course.id, {}) %>
- <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status" />
+ <% course_requirements = courses_requirements_not_met.get(course.id) %>
+ <%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements" />
% endfor
diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html
index 026d4c0e95b6..e1fc56686424 100644
--- a/lms/templates/dashboard/_dashboard_course_listing.html
+++ b/lms/templates/dashboard/_dashboard_course_listing.html
@@ -1,4 +1,4 @@
-<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status" />
+<%page args="course, enrollment, show_courseware_link, cert_status, show_email_settings, course_mode_info, show_refund_option, is_paid_course, is_course_blocked, verification_status, course_requirements" />
<%! from django.utils.translation import ugettext as _ %>
<%!
@@ -292,6 +292,18 @@
+ % if course_requirements:
+ <% prc_target = reverse('about_course', args=[unicode(course_requirements['courses'][0]['key'])]) %>
+
+
+ ${_("You must successfully complete {link_start}{prc_display}{link_end} before you begin this course.").format(
+ link_start=''.format(prc_target),
+ link_end=' ',
+ prc_display=course_requirements['courses'][0]['display'],
+ )}
+
+
+ % endif
diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt
index d48cc8457b6f..a2db4165a009 100644
--- a/requirements/edx/github.txt
+++ b/requirements/edx/github.txt
@@ -36,3 +36,4 @@ git+https://github.com/mitocw/django-cas.git@60a5b8e5a62e63e0d5d224a87f0b489201a
-e git+https://github.com/edx/edx-oauth2-provider.git@0.4.0#egg=oauth2-provider
-e git+https://github.com/edx/edx-val.git@ba00a5f2e0571e9a3f37d293a98efe4cbca850d5#egg=edx-val
-e git+https://github.com/pmitros/RecommenderXBlock.git@b41ba8778b98da0ea680ffb8bbc59492d669df2d#egg=recommender-xblock
+-e git+https://github.com/edx/edx-milestones.git@6e844a332b655e252d4e684f5cbec26656611b68#egg=edx-milestones