Commit ca3d84a5 by Zia Fazal Committed by Matt Drayer

New pre-requisite course feature via milestones app

parent adc64122
......@@ -25,6 +25,8 @@ from contentstore.views.component import ADVANCED_COMPONENT_POLICY_KEY
import ddt
from xmodule.modulestore import ModuleStoreEnum
from util.milestones_helpers import seed_milestone_relationship_types
def get_url(course_id, handler_name='settings_handler'):
return reverse_course_url(handler_name, course_id)
......@@ -171,6 +173,9 @@ class CourseDetailsViewTest(CourseTestCase):
"""
Tests for modifying content on the first course settings page (course dates, overview, etc.).
"""
def setUp(self):
super(CourseDetailsViewTest, self).setUp()
def alter_field(self, url, details, field, val):
"""
Change the one field to the given value and then invoke the update post to see if it worked.
......@@ -243,6 +248,55 @@ class CourseDetailsViewTest(CourseTestCase):
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, 'MILESTONES_APP': True})
def test_pre_requisite_course_list_present(self):
seed_milestone_relationship_types()
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, 'MILESTONES_APP': True})
def test_pre_requisite_course_update_and_fetch(self):
seed_milestone_relationship_types()
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, 'MILESTONES_APP': True})
def test_invalid_pre_requisite_course(self):
seed_milestone_relationship_types()
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):
......
......@@ -74,6 +74,11 @@ from microsite_configuration import microsite
from xmodule.course_module import CourseFields
from xmodule.split_test_module import get_split_user_partitions
from util.milestones_helpers import (
set_prerequisite_courses,
is_valid_course_key
)
MINIMUM_GROUP_ID = 100
# Note: the following content group configuration strings are not
......@@ -368,37 +373,10 @@ def _accessible_libraries_list(user):
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)
courses, in_process_course_actions = get_courses_accessible_to_user(request)
libraries = _accessible_libraries_list(request.user) if LIBRARIES_ENABLED else []
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
}
def format_in_process_course_view(uca):
"""
Return a dict of the data which the view requires for each unsucceeded course
......@@ -433,14 +411,7 @@ def course_listing(request):
'can_edit': has_studio_write_access(request.user, library.location.library_key),
}
# 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', {
......@@ -508,6 +479,53 @@ 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):
"""
removes any in-process courses in courses list. in-process actually refers to courses
that are in the process of being generated for re-run
"""
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
......@@ -783,6 +801,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':
......@@ -797,8 +816,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),
......@@ -807,15 +825,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({'possible_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
......
......@@ -39,6 +39,7 @@ class CourseDetails(object):
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 @@ class CourseDetails(object):
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 @@ class CourseDetails(object):
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)
......
......@@ -33,6 +33,7 @@ class CourseMetadata(object):
'tags', # from xblock
'visible_to_staff_only',
'group_access',
'pre_requisite_courses'
]
@classmethod
......
......@@ -54,6 +54,12 @@ for log_name, log_level in LOG_OVERRIDES:
# Use the auto_auth workflow for creating users and logging them in
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
# Enable milestones app
FEATURES['MILESTONES_APP'] = True
# Enable pre-requisite course
FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
......
......@@ -128,6 +128,12 @@ FEATURES = {
# DEFAULT_STORE_FOR_NEW_COURSE to be 'split' to have future courses
# and libraries created with split.
'ENABLE_CONTENT_LIBRARIES': False,
# Milestones application flag
'MILESTONES_APP': False,
# Prerequisite courses feature flag
'ENABLE_PREREQUISITE_COURSES': False,
}
ENABLE_JASMINE = False
......@@ -744,7 +750,8 @@ OPTIONAL_APPS = (
'openassessment.xblock',
# edxval
'edxval'
'edxval',
'milestones'
)
......
......@@ -155,6 +155,9 @@ CACHES = {
# 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')
......
......@@ -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) {
......
......@@ -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);
});
});
});
......@@ -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':
......
......@@ -62,6 +62,19 @@
<span class="tip tip-stacked timezone">(UTC)</span>
</div>
</li>
<li>
<li class="field field-select" id="field-pre-requisite-course">
<label for="pre-requisite-course" class="">Prerequisite Course</label>
<select class="input" id="pre-requisite-course">
<option value="">None</option>
<option value="test/CSS101/2012_T1">[Test] Communicating for Impact</option>
<option value="Test/3423/2014_T2">CohortAverageTesting</option>
<option value="edX/Open_DemoX/edx_demo_course">edX Demonstration Course</option>
</select>
<span class="tip tip-inline">Course that students must complete before beginning this course</span>
<button type="submit" class="sr" name="submit" value="submit">set pre-requisite course</button>
</li>
</li>
</ol>
</section>
</form>
......@@ -307,6 +307,21 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
<input type="text" class="short time" id="course-effort" placeholder="HH:MM" />
<span class="tip tip-inline">${_("Time spent on all course work")}</span>
</li>
% if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES'):
<li class="field field-select" id="field-pre-requisite-course">
<form action="#" class="pre-requisite-course-form" method="post">
<label for="pre-requisite-course">${_("Prerequisite Course")}</label>
<select class="input" id="pre-requisite-course">
<option value="">${_("None")}</option>
% for course_info in sorted(possible_pre_requisite_courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
<option value="${course_info['course_key']}">${course_info['display_name']}</option>
% endfor
</select>
<span class="tip tip-inline">${_("Course that students must complete before beginning this course")}</span>
<button type="submit" class="sr" name="submit" value="submit">${_("set pre-requisite course")}</button>
</form>
</li>
% endif
</ol>
</section>
% endif
......
......@@ -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,12 @@ from xmodule.error_module import ErrorDescriptor
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,
seed_milestone_relationship_types
)
import unittest
from django.conf import settings
......@@ -35,14 +42,16 @@ class TestCourseListing(ModuleStoreTestCase):
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 +128,38 @@ class TestCourseListing(ModuleStoreTestCase):
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, 'MILESTONES_APP': 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.
"""
seed_milestone_relationship_types()
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
course_enrollment_pairs = list(get_course_enrollment_pairs(self.student, None, []))
courses_having_prerequisites = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if course.pre_requisite_courses)
courses_requirements_not_met = get_pre_requisite_courses_not_completed(
self.student,
courses_having_prerequisites
)
self.assertEqual(len(courses_requirements_not_met[course_location]['courses']), len(pre_requisite_courses))
......@@ -89,7 +89,9 @@ import dogstats_wrapper as dog_stats_api
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 (
......@@ -540,8 +542,11 @@ def dashboard(request):
staff_access = True
errored_courses = modulestore().get_errored_courses()
show_courseware_links_for = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if has_access(request.user, 'load', course))
show_courseware_links_for = frozenset(
course.id for course, _enrollment in course_enrollment_pairs
if has_access(request.user, 'load', course)
and has_access(request.user, 'view_courseware_with_prerequisites', course)
)
# Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict
......@@ -652,6 +657,11 @@ 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_having_prerequisites = frozenset(course.id for course, _enrollment in course_enrollment_pairs
if course.pre_requisite_courses)
courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
context = {
'enrollment_message': enrollment_message,
'course_enrollment_pairs': course_enrollment_pairs,
......@@ -681,7 +691,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():
......
# pylint: disable=invalid-name
"""
Helper methods for milestones api calls.
"""
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 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
def add_prerequisite_course(course_key, prerequisite_course_key):
"""
It would create a milestone, then it would set newly created
milestones as requirement for course referred by `course_key`
and it would set newly created milestone as fulfilment
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)
# 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 course
referred by `course_key`.
"""
if settings.FEATURES.get('MILESTONES_APP', False):
remove_course_milestone(
course_key,
milestone,
)
def set_prerequisite_courses(course_key, prerequisite_course_keys):
"""
It would remove any existing requirement milestones for the given `course_key`
and create new milestones for each pre-requisite course in `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)
# 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):
"""
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 fulfilment milestones not achieved to make dict of
prerequisite courses yet to be completed.
"""
pre_requisite_courses = {}
if settings.FEATURES.get('ENABLE_PREREQUISITE_COURSES'):
for course_key in enrolled_courses:
required_courses = []
fulfilment_paths = 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:
for required_course in value:
required_course_key = CourseKey.from_string(required_course)
required_course_descriptor = modulestore().get_course(required_course_key)
required_courses.append({
'key': required_course_key,
'display': get_course_display_name(required_course_descriptor)
})
# if there are required courses add to dict
if required_courses:
pre_requisite_courses[course_key] = {'courses': required_courses}
return pre_requisite_courses
def get_prerequisite_courses_display(course_descriptor):
"""
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(get_course_display_name(required_course_descriptor))
return pre_requisite_courses
def get_course_display_name(descriptor):
"""
It would return display name from given course descriptor
"""
return ' '.join([
descriptor.display_org_with_default,
descriptor.display_number_with_default
])
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)
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)
def is_valid_course_key(key):
"""
validates course key. returns True if valid else False.
"""
try:
course_key = CourseKey.from_string(key)
except InvalidKeyError:
course_key = key
return isinstance(course_key, CourseKey)
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')
......@@ -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={
......
......@@ -11,8 +11,8 @@ data: |
</section>
<section class="prerequisites">
<h2>Prerequisites</h2>
<p>Add information about course prerequisites here.</p>
<h2>Requirements</h2>
<p>Add information about the skills and knowledge students need to take this course.</p>
</section>
<section class="course-staff">
......
......@@ -95,3 +95,9 @@ class DashboardPage(PageObject):
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
......@@ -3,6 +3,7 @@ Course Schedule and Details Settings page.
"""
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()
......@@ -196,6 +196,31 @@ def get_options(select_browser_query):
return Select(select_browser_query.first.results[0]).options
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.
......
......@@ -8,7 +8,13 @@ from unittest import skip
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.problem import ProblemPage
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
......@@ -604,6 +611,90 @@ class TooltipTest(UniqueCourseTest):
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.
......
"""
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, is_staff=True):
super(SettingsMilestonesTest, self).setUp(is_staff=is_staff)
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=''))
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -138,8 +138,8 @@ Get the HTML for the course about page.
<p>This is paragraph 2 of the long course description. Add more paragraphs as needed. Make sure to enclose them in paragraph tags.</p>
</section>\n\n
<section class=\"prerequisites\">\n
<h2>Prerequisites</h2>\n
<p>Add information about course prerequisites here.</p>\n </section>\n\n
<h2>Requirements</h2>\n
<p>Add information about the skills and knowledge students need to take this course.</p>\n </section>\n\n
<section class=\"course-staff\">\n
<h2>Course Staff</h2>\n
<article class=\"teacher\">\n
......
......@@ -19,6 +19,12 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
from courseware.tests.helpers import LoginEnrollmentTestCase
from util.milestones_helpers import (
seed_milestone_relationship_types,
set_prerequisite_courses,
)
FEATURES_WITH_STARTDATE = settings.FEATURES.copy()
FEATURES_WITH_STARTDATE['DISABLE_START_DATES'] = False
......@@ -110,6 +116,52 @@ class AnonymousIndexPageTest(ModuleStoreTestCase):
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
class PreRequisiteCourseCatalog(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test to simulate and verify fix for disappearing courses in
course catalog when using pre-requisite courses
"""
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def setUp(self):
seed_milestone_relationship_types()
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_course_with_prereq(self):
"""
Simulate having a course which has closed enrollments that has
a pre-req course
"""
pre_requisite_course = CourseFactory.create(
org='edX',
course='900',
display_name='pre requisite course',
)
pre_requisite_courses = [unicode(pre_requisite_course.id)]
# for this failure to occur, the enrollment window needs to be in the past
course = CourseFactory.create(
org='edX',
course='1000',
display_name='course that has pre requisite',
# closed enrollment
enrollment_start=datetime.datetime(2013, 1, 1),
enrollment_end=datetime.datetime(2014, 1, 1),
start=datetime.datetime(2013, 1, 1),
end=datetime.datetime(2030, 1, 1),
pre_requisite_courses=pre_requisite_courses,
)
set_prerequisite_courses(course.id, pre_requisite_courses)
resp = self.client.get('/')
self.assertEqual(resp.status_code, 200)
# make sure both courses are visible in the catalog
self.assertIn('pre requisite course', resp.content)
self.assertIn('course that has pre requisite', resp.content)
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
"""
Test for Index page course cards sorting
......
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 @@ class GeneratedCertificate(models.Model):
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.
......
......@@ -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,13 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
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,
seed_milestone_relationship_types,
)
class CertificatesModelTest(ModuleStoreTestCase):
......@@ -23,3 +32,26 @@ class CertificatesModelTest(ModuleStoreTestCase):
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, 'MILESTONES_APP': True})
def test_course_milestone_collected(self):
seed_milestone_relationship_types()
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, unicode(pre_requisite_course.id))
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, unicode(pre_requisite_course.id))
self.assertEqual(len(completed_milestones), 1)
self.assertEqual(completed_milestones[0]['namespace'], unicode(pre_requisite_course.id))
......@@ -28,6 +28,7 @@ from student.roles import (
)
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__)
......@@ -267,8 +268,24 @@ def _has_access_course_desc(user, action, course):
_has_staff_access_to_descriptor(user, course, course.id)
)
def can_view_courseware_with_prerequisites(): # pylint: disable=invalid-name
"""
Checks if prerequisite courses feature is enabled and course has prerequisites
and user is neither staff nor anonymous then it returns False if user has not
passed prerequisite courses otherwise return True.
"""
if settings.FEATURES['ENABLE_PREREQUISITE_COURSES'] \
and not _has_staff_access_to_descriptor(user, course, course.id) \
and course.pre_requisite_courses \
and not user.is_anonymous() \
and get_pre_requisite_courses_not_completed(user, [course.id]):
return False
else:
return True
checkers = {
'load': can_load,
'view_courseware_with_prerequisites': can_view_courseware_with_prerequisites,
'load_forum': can_load_forum,
'load_mobile': can_load_mobile,
'load_mobile_no_enrollment_check': can_load_mobile_no_enroll_check,
......
......@@ -20,6 +20,10 @@ from shoppingcart.models import Order, PaidCourseRegistration
from xmodule.course_module import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from util.milestones_helpers import (
set_prerequisite_courses,
seed_milestone_relationship_types,
)
from .helpers import LoginEnrollmentTestCase
......@@ -33,7 +37,6 @@ class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
"""
Tests about xblock.
"""
def setUp(self):
self.course = CourseFactory.create()
self.about = ItemFactory.create(
......@@ -120,6 +123,60 @@ class AboutTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
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, 'MILESTONES_APP': True})
def test_pre_requisite_course(self):
seed_milestone_relationship_types()
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("<span class=\"important-dates-item-text pre-requisite\">{} {}</span>"
.format(pre_requisite_course.display_org_with_default,
pre_requisite_course.display_number_with_default),
resp.content.strip('\n'))
@patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_about_page_unfulfilled_prereqs(self):
seed_milestone_relationship_types()
pre_requisite_course = CourseFactory.create(
org='edX',
course='900',
display_name='pre requisite course',
)
pre_requisite_courses = [unicode(pre_requisite_course.id)]
# for this failure to occur, the enrollment window needs to be in the past
course = CourseFactory.create(
org='edX',
course='1000',
# closed enrollment
enrollment_start=datetime.datetime(2013, 1, 1),
enrollment_end=datetime.datetime(2014, 1, 1),
start=datetime.datetime(2013, 1, 1),
end=datetime.datetime(2030, 1, 1),
pre_requisite_courses=pre_requisite_courses,
)
set_prerequisite_courses(course.id, pre_requisite_courses)
self.setup_user()
self.enroll(self.course, True)
self.enroll(pre_requisite_course, True)
url = reverse('about_course', args=[unicode(course.id)])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn("<span class=\"important-dates-item-text pre-requisite\">{} {}</span>"
.format(pre_requisite_course.display_org_with_default,
pre_requisite_course.display_number_with_default),
resp.content.strip('\n'))
url = reverse('about_course', args=[unicode(pre_requisite_course.id)])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
@override_settings(MODULESTORE=TEST_DATA_MIXED_CLOSED_MODULESTORE)
class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
......
......@@ -2,27 +2,35 @@ import datetime
import pytz
from django.test import TestCase
from django.core.urlresolvers import reverse
from mock import Mock, patch
from opaque_keys.edx.locations import SlashSeparatedCourseKey
import courseware.access as access
from courseware.masquerade import CourseMasquerade
from courseware.tests.factories import UserFactory, StaffFactory, InstructorFactory
from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory
from courseware.tests.helpers import LoginEnrollmentTestCase
from student.tests.factories import AnonymousUserFactory, CourseEnrollmentAllowedFactory, CourseEnrollmentFactory
from xmodule.course_module import (
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,
seed_milestone_relationship_types,
)
# pylint: disable=missing-docstring
# pylint: disable=protected-access
class AccessTestCase(TestCase):
class AccessTestCase(LoginEnrollmentTestCase):
"""
Tests for the various access controls on the student dashboard
"""
def setUp(self):
course_key = SlashSeparatedCourseKey('edX', 'toy', '2012_Fall')
self.course = course_key.make_usage_key('course', course_key.run)
......@@ -243,6 +251,78 @@ class AccessTestCase(TestCase):
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, 'MILESTONES_APP': True})
def test_access_on_course_with_pre_requisites(self):
"""
Test course access when a course has pre-requisite course yet to be completed
"""
seed_milestone_relationship_types()
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
CourseEnrollmentFactory(user=user, course_id=course.id)
self.assertFalse(access._has_access_course_desc(user, 'view_courseware_with_prerequisites', course))
# Staff can always access course
staff = StaffFactory.create(course_key=course.id)
self.assertTrue(access._has_access_course_desc(staff, 'view_courseware_with_prerequisites', 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, 'view_courseware_with_prerequisites', course))
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True, 'MILESTONES_APP': True})
def test_courseware_page_unfulfilled_prereqs(self):
"""
Test courseware access when a course has pre-requisite course yet to be completed
"""
seed_milestone_relationship_types()
pre_requisite_course = CourseFactory.create(
org='edX',
course='900',
run='test_run',
)
pre_requisite_courses = [unicode(pre_requisite_course.id)]
course = CourseFactory.create(
org='edX',
course='1000',
run='test_run',
pre_requisite_courses=pre_requisite_courses,
)
set_prerequisite_courses(course.id, pre_requisite_courses)
test_password = 't3stp4ss.!'
user = UserFactory.create()
user.set_password(test_password)
user.save()
self.login(user.email, test_password)
CourseEnrollmentFactory(user=user, course_id=course.id)
url = reverse('courseware', args=[unicode(course.id)])
response = self.client.get(url)
self.assertRedirects(
response,
reverse(
'dashboard'
)
)
self.assertEqual(response.status_code, 302)
fulfill_course_milestone(pre_requisite_course.id, user)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class UserRoleTestCase(TestCase):
"""
......
......@@ -56,6 +56,7 @@ import shoppingcart
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
......@@ -349,6 +350,17 @@ def _index_bulk_op(request, course_key, chapter, section, position):
log.debug(u'User %s tried to view course %s but is not enrolled', user, course.location.to_deprecated_string())
return redirect(reverse('about_course', args=[course_key.to_deprecated_string()]))
# see if all pre-requisites (as per the milestones app feature) have been fulfilled
# Note that if the pre-requisite feature flag has been turned off (default) then this check will
# always pass
if not has_access(user, 'view_courseware_with_prerequisites', course):
# prerequisites have not been fulfilled therefore redirect to the Dashboard
log.info(
u'User %d tried to view course %s '
u'without fulfilling prerequisites',
user.id, unicode(course.id))
return redirect(reverse('dashboard'))
# check to see if there is a required survey that must be taken before
# the user can access the course.
if survey.utils.must_answer_survey(course, user):
......@@ -757,8 +769,13 @@ def course_about(request, course_id):
else:
course_target = reverse('about_course', args=[course.id.to_deprecated_string()])
show_courseware_link = (has_access(request.user, 'load', course) or
settings.FEATURES.get('ENABLE_LMS_MIGRATION'))
show_courseware_link = (
(
has_access(request.user, 'load', course)
and has_access(request.user, 'view_courseware_with_prerequisites', course)
)
or settings.FEATURES.get('ENABLE_LMS_MIGRATION')
)
# Note: this is a flow for payment for course registration, not the Verified Certificate flow.
registration_price = 0
......@@ -790,6 +807,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,
......@@ -811,6 +831,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
})
......
......@@ -83,6 +83,12 @@ LOG_OVERRIDES = [
for log_name, log_level in LOG_OVERRIDES:
logging.getLogger(log_name).setLevel(log_level)
# Enable milestones app
FEATURES['MILESTONES_APP'] = True
# Enable pre-requisite course
FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
......
......@@ -314,6 +314,12 @@ FEATURES = {
# let students save and manage their annotations
'ENABLE_EDXNOTES': False,
# Milestones application flag
'MILESTONES_APP': False,
# Prerequisite courses feature flag
'ENABLE_PREREQUISITE_COURSES': False,
}
# Ignore static asset files on import which match this pattern
......@@ -1643,6 +1649,7 @@ if FEATURES.get('AUTH_USE_CAS'):
INSTALLED_APPS += ('django_cas',)
MIDDLEWARE_CLASSES += ('django_cas.middleware.CASMiddleware',)
###################### Registration ##################################
# For each of the fields, give one of the following values:
......@@ -1912,7 +1919,8 @@ OPTIONAL_APPS = (
'openassessment.xblock',
# edxval
'edxval'
'edxval',
'milestones'
)
for app_name in OPTIONAL_APPS:
......
......@@ -437,5 +437,9 @@ MONGODB_LOG = {
'db': 'xlog',
}
# Enable EdxNotes for tests.
FEATURES['ENABLE_EDXNOTES'] = True
# Add milestones to Installed apps for testing
INSTALLED_APPS += ('milestones', )
......@@ -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;
}
}
}
}
}
......@@ -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;
......
......@@ -319,8 +319,17 @@
</li>
% endif
% if pre_requisite_courses:
<li class="prerequisite-course important-dates-item">
<i class="icon fa fa-list-ul"></i>
<p class="important-dates-item-title">${_("Prerequisites")}</p>
## Multiple pre-requisite courses are not supported on frontend that's why we are pulling first element
<span class="important-dates-item-text pre-requisite">${pre_requisite_courses[0]}</span>
<p class="tip">${_("You must successfully complete {course} before you begin this course").format(course=pre_requisite_courses[0])}.</p>
</li>
% endif
% if get_course_about_section(course, "prerequisites"):
<li class="important-dates-item"><i class="icon fa fa-book"></i><p class="important-dates-item-title">${_("Prerequisites")}</p><span class="important-dates-item-text prerequisites">${get_course_about_section(course, "prerequisites")}</span></li>
<li class="important-dates-item"><i class="icon fa fa-book"></i><p class="important-dates-item-title">${_("Requirements")}</p><span class="important-dates-item-text prerequisites">${get_course_about_section(course, "prerequisites")}</span></li>
% endif
</ol>
</section>
......
......@@ -190,7 +190,8 @@
<% 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
</ul>
......
<%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 _
......@@ -324,6 +324,19 @@ from student.helpers import (
</section>
% if course_requirements:
## Multiple pre-requisite courses are not supported on frontend that's why we are pulling first element
<% prc_target = reverse('about_course', args=[unicode(course_requirements['courses'][0]['key'])]) %>
<section class="prerequisites">
<p class="tip">
${_("You must successfully complete {link_start}{prc_display}{link_end} before you begin this course.").format(
link_start='<a href="{}">'.format(prc_target),
link_end='</a>',
prc_display=course_requirements['courses'][0]['display'],
)}
</p>
</section>
% endif
</article>
</article>
</li>
......
......@@ -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@4dfe78a2aae9559ccc979746d13a9b67f0ec311e#egg=edx-milestones
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