Commit b59566a3 by Matt Drayer

Merge pull request #6455 from edx/mattdrayer/entrance-exams

(WIP) Course Entrance Exams
parents 27fee620 3c669e38
......@@ -57,6 +57,8 @@ jscover.log
jscover.log.*
.tddium*
common/test/data/test_unicode/static/
django-pyfs
test_root/uploads/*.txt
### Installation artifacts
*.egg-info
......
......@@ -139,6 +139,100 @@ class CourseDetailsTestCase(CourseTestCase):
self.assertNotContains(response, "Course Introduction Video")
self.assertNotContains(response, "Requirements")
def _seed_milestone_relationship_types(self):
"""
Helper method to prepopulate MRTs so the tests can run
Note the settings check -- exams feature must be enabled for the tests to run correctly
"""
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
from milestones.models import MilestoneRelationshipType
MilestoneRelationshipType.objects.create(name='requires')
MilestoneRelationshipType.objects.create(name='fulfills')
@patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True})
def test_entrance_exam_created_and_deleted_successfully(self):
self._seed_milestone_relationship_types()
settings_details_url = get_url(self.course.id)
data = {
'entrance_exam_enabled': 'true',
'entrance_exam_minimum_score_pct': '60',
'syllabus': 'none',
'short_description': 'empty',
'overview': '',
'effort': '',
'intro_video': ''
}
response = self.client.post(settings_details_url, data=json.dumps(data), content_type='application/json',
HTTP_ACCEPT='application/json')
self.assertEquals(response.status_code, 200)
course = modulestore().get_course(self.course.id)
self.assertTrue(course.entrance_exam_enabled)
self.assertEquals(course.entrance_exam_minimum_score_pct, .60)
# Delete the entrance exam
data['entrance_exam_enabled'] = "false"
response = self.client.post(
settings_details_url,
data=json.dumps(data),
content_type='application/json',
HTTP_ACCEPT='application/json'
)
course = modulestore().get_course(self.course.id)
self.assertEquals(response.status_code, 200)
self.assertFalse(course.entrance_exam_enabled)
self.assertEquals(course.entrance_exam_minimum_score_pct, None)
@patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True})
def test_entrance_exam_store_default_min_score(self):
"""
test that creating an entrance exam should store the default value, if key missing in json request
or entrance_exam_minimum_score_pct is an empty string
"""
self._seed_milestone_relationship_types()
settings_details_url = get_url(self.course.id)
test_data_1 = {
'entrance_exam_enabled': 'true',
'syllabus': 'none',
'short_description': 'empty',
'overview': '',
'effort': '',
'intro_video': ''
}
response = self.client.post(
settings_details_url,
data=json.dumps(test_data_1),
content_type='application/json',
HTTP_ACCEPT='application/json'
)
self.assertEquals(response.status_code, 200)
course = modulestore().get_course(self.course.id)
self.assertTrue(course.entrance_exam_enabled)
# entrance_exam_minimum_score_pct is not present in the request so default value should be saved.
self.assertEquals(course.entrance_exam_minimum_score_pct, .5)
#add entrance_exam_minimum_score_pct with empty value in json request.
test_data_2 = {
'entrance_exam_enabled': 'true',
'entrance_exam_minimum_score_pct': '',
'syllabus': 'none',
'short_description': 'empty',
'overview': '',
'effort': '',
'intro_video': ''
}
response = self.client.post(
settings_details_url,
data=json.dumps(test_data_2),
content_type='application/json',
HTTP_ACCEPT='application/json'
)
self.assertEquals(response.status_code, 200)
course = modulestore().get_course(self.course.id)
self.assertTrue(course.entrance_exam_enabled)
self.assertEquals(course.entrance_exam_minimum_score_pct, .5)
def test_editable_short_description_fetch(self):
settings_details_url = get_url(self.course.id)
......
......@@ -8,6 +8,7 @@ from .assets import *
from .checklist import *
from .component import *
from .course import *
from .entrance_exam import *
from .error import *
from .helpers import *
from .item import *
......
......@@ -60,6 +60,8 @@ from .component import (
ADVANCED_COMPONENT_TYPES,
)
from contentstore.tasks import rerun_course
from contentstore.views.entrance_exam import create_entrance_exam, delete_entrance_exam
from .library import LIBRARIES_ENABLED
from .item import create_xblock_info
from course_creators.views import get_course_creator_status, add_user_with_status_unrequested
......@@ -824,7 +826,8 @@ def settings_handler(request, course_key_string):
'details_url': reverse_course_url('settings_handler', course_key),
'about_page_editable': about_page_editable,
'short_description_editable': short_description_editable,
'upload_asset_url': upload_asset_url
'upload_asset_url': upload_asset_url,
'course_handler_url': reverse_course_url('course_handler', course_key),
}
if prerequisite_course_enabled:
courses, in_process_course_actions = get_courses_accessible_to_user(request)
......@@ -843,13 +846,40 @@ def settings_handler(request, course_key_string):
# encoder serializes dates, old locations, and instances
encoder=CourseSettingsEncoder
)
else: # post or put, doesn't matter.
# For every other possible method type submitted by the caller...
else:
# 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)
# If the entrance exams feature has been enabled, we'll need to check for some
# feature-specific settings and handle them accordingly
# We have to be careful that we're only executing the following logic if we actually
# need to create or delete an entrance exam from the specified course
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
course_entrance_exam_present = course_module.entrance_exam_enabled
entrance_exam_enabled = request.json.get('entrance_exam_enabled', '') == 'true'
ee_min_score_pct = request.json.get('entrance_exam_minimum_score_pct', None)
# If the entrance exam box on the settings screen has been checked,
# and the course does not already have an entrance exam attached...
if entrance_exam_enabled and not course_entrance_exam_present:
# Load the default minimum score threshold from settings, then try to override it
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
if ee_min_score_pct and ee_min_score_pct != '':
entrance_exam_minimum_score_pct = float(ee_min_score_pct)
# Create the entrance exam
create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct)
# If the entrance exam box on the settings screen has been unchecked,
# and the course has an entrance exam attached...
elif not entrance_exam_enabled and course_entrance_exam_present:
delete_entrance_exam(request, course_key)
# Perform the normal update workflow for the CourseDetails model
return JsonResponse(
CourseDetails.update_from_json(course_key, request.json, request.user),
encoder=CourseSettingsEncoder
......
"""
Entrance Exams view module -- handles all requests related to entrance exam management via Studio
Intended to be utilized as an AJAX callback handler, versus a proper view/screen
"""
import json
import logging
from django.contrib.auth.decorators import login_required
from django_future.csrf import ensure_csrf_cookie
from django.http import HttpResponse
from django.test import RequestFactory
from contentstore.views.item import create_item, delete_item
from milestones import api as milestones_api
from models.settings.course_metadata import CourseMetadata
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys import InvalidKeyError
from student.auth import has_course_author_access
from util.milestones_helpers import generate_milestone_namespace, NAMESPACE_CHOICES
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from django.conf import settings
__all__ = ['entrance_exam', ]
log = logging.getLogger(__name__)
@login_required
@ensure_csrf_cookie
def entrance_exam(request, course_key_string):
"""
The restful handler for entrance exams.
It allows retrieval of all the assets (as an HTML page), as well as uploading new assets,
deleting assets, and changing the "locked" state of an asset.
GET
Retrieves the entrance exam module (metadata) for the specified course
POST
Adds an entrance exam module to the specified course.
DELETE
Removes the entrance exam from the course
"""
course_key = CourseKey.from_string(course_key_string)
# Deny access if the user is valid, but they lack the proper object access privileges
if not has_course_author_access(request.user, course_key):
return HttpResponse(status=403)
# Retrieve the entrance exam module for the specified course (returns 404 if none found)
if request.method == 'GET':
return _get_entrance_exam(request, course_key)
# Create a new entrance exam for the specified course (returns 201 if created)
elif request.method == 'POST':
response_format = request.REQUEST.get('format', 'html')
http_accept = request.META.get('http_accept')
if response_format == 'json' or 'application/json' in http_accept:
ee_min_score = request.POST.get('entrance_exam_minimum_score_pct', None)
# if request contains empty value or none then save the default one.
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
if ee_min_score != '' and ee_min_score is not None:
entrance_exam_minimum_score_pct = float(ee_min_score)
return create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct)
return HttpResponse(status=400)
# Remove the entrance exam module for the specified course (returns 204 regardless of existence)
elif request.method == 'DELETE':
return delete_entrance_exam(request, course_key)
# No other HTTP verbs/methods are supported at this time
else:
return HttpResponse(status=405)
def create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct):
"""
api method to create an entrance exam.
First clean out any old entrance exams.
"""
_delete_entrance_exam(request, course_key)
return _create_entrance_exam(
request=request,
course_key=course_key,
entrance_exam_minimum_score_pct=entrance_exam_minimum_score_pct
)
def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=None):
"""
Internal workflow operation to create an entrance exam
"""
# Provide a default value for the minimum score percent if nothing specified
if entrance_exam_minimum_score_pct is None:
entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT)
# Confirm the course exists
course = modulestore().get_course(course_key)
if course is None:
return HttpResponse(status=400)
# Create the entrance exam item (currently it's just a chapter)
payload = {
'category': "chapter",
'display_name': "Entrance Exam",
'parent_locator': unicode(course.location),
'is_entrance_exam': True,
'in_entrance_exam': True,
}
factory = RequestFactory()
internal_request = factory.post('/', json.dumps(payload), content_type="application/json")
internal_request.user = request.user
created_item = json.loads(create_item(internal_request).content)
# Set the entrance exam metadata flags for this course
# Reload the course so we don't overwrite the new child reference
course = modulestore().get_course(course_key)
metadata = {
'entrance_exam_enabled': True,
'entrance_exam_minimum_score_pct': entrance_exam_minimum_score_pct / 100,
'entrance_exam_id': created_item['locator'],
}
CourseMetadata.update_from_dict(metadata, course, request.user)
# Add an entrance exam milestone if one does not already exist
milestone_namespace = generate_milestone_namespace(
NAMESPACE_CHOICES['ENTRANCE_EXAM'],
course_key
)
milestones = milestones_api.get_milestones(milestone_namespace)
if len(milestones):
milestone = milestones[0]
else:
description = 'Autogenerated during {} entrance exam creation.'.format(unicode(course.id))
milestone = milestones_api.add_milestone({
'name': 'Completed Course Entrance Exam',
'namespace': milestone_namespace,
'description': description
})
relationship_types = milestones_api.get_milestone_relationship_types()
milestones_api.add_course_milestone(
unicode(course.id),
relationship_types['REQUIRES'],
milestone
)
milestones_api.add_course_content_milestone(
unicode(course.id),
created_item['locator'],
relationship_types['FULFILLS'],
milestone
)
return HttpResponse(status=201)
def _get_entrance_exam(request, course_key): # pylint: disable=W0613
"""
Internal workflow operation to retrieve an entrance exam
"""
course = modulestore().get_course(course_key)
if course is None:
return HttpResponse(status=400)
if not getattr(course, 'entrance_exam_id'):
return HttpResponse(status=404)
try:
exam_key = UsageKey.from_string(course.entrance_exam_id)
except InvalidKeyError:
return HttpResponse(status=404)
try:
exam_descriptor = modulestore().get_item(exam_key)
return HttpResponse(
_serialize_entrance_exam(exam_descriptor),
status=200, mimetype='application/json')
except ItemNotFoundError:
return HttpResponse(status=404)
def delete_entrance_exam(request, course_key):
"""
api method to delete an entrance exam
"""
return _delete_entrance_exam(request=request, course_key=course_key)
def _delete_entrance_exam(request, course_key):
"""
Internal workflow operation to remove an entrance exam
"""
store = modulestore()
course = store.get_course(course_key)
if course is None:
return HttpResponse(status=400)
course_children = store.get_items(
course_key,
qualifiers={'category': 'chapter'}
)
for course_child in course_children:
if course_child.is_entrance_exam:
delete_item(request, course_child.scope_ids.usage_id)
milestones_api.remove_content_references(unicode(course_child.scope_ids.usage_id))
# Reset the entrance exam flags on the course
# Reload the course so we have the latest state
course = store.get_course(course_key)
if getattr(course, 'entrance_exam_id'):
metadata = {
'entrance_exam_enabled': False,
'entrance_exam_minimum_score_pct': None,
'entrance_exam_id': None,
}
CourseMetadata.update_from_dict(metadata, course, request.user)
return HttpResponse(status=204)
def _serialize_entrance_exam(entrance_exam_module):
"""
Internal helper to convert an entrance exam module/object into JSON
"""
return json.dumps({
'locator': unicode(entrance_exam_module.location)
})
......@@ -13,6 +13,7 @@ from functools import partial
from static_replace import replace_static_urls
from xmodule_modifiers import wrap_xblock, request_token
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest, HttpResponse, Http404
......@@ -87,6 +88,17 @@ def usage_key_with_run(usage_key_string):
return usage_key
def _filter_entrance_exam_grader(graders):
"""
If the entrance exams feature is enabled we need to hide away the grader from
views/controls like the 'Grade as' dropdown that allows a course author to select
the grader type for a given section of a course
"""
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
graders = [grader for grader in graders if grader.get('type') != u'Entrance Exam']
return graders
# pylint: disable=unused-argument
@require_http_methods(("DELETE", "GET", "PUT", "POST", "PATCH"))
@login_required
......@@ -513,6 +525,15 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None,
@login_required
@expect_json
def create_item(request):
"""
Exposes internal helper method without breaking existing bindings/dependencies
"""
return _create_item(request)
@login_required
@expect_json
def _create_item(request):
"""View for create items."""
usage_key = usage_key_with_run(request.json['parent_locator'])
......@@ -549,6 +570,15 @@ def _create_item(request):
if display_name is not None:
metadata['display_name'] = display_name
# Entrance Exams: Chapter module positioning
child_position = None
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
is_entrance_exam = request.json.get('is_entrance_exam', False)
if category == 'chapter' and is_entrance_exam:
metadata['is_entrance_exam'] = is_entrance_exam
metadata['in_entrance_exam'] = True # Inherited metadata, all children will have it
child_position = 0
# TODO need to fix components that are sending definition_data as strings, instead of as dicts
# For now, migrate them into dicts here.
if isinstance(data, basestring):
......@@ -562,6 +592,30 @@ def _create_item(request):
definition_data=data,
metadata=metadata,
runtime=parent.runtime,
position=child_position
)
# Entrance Exams: Grader assignment
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
course = store.get_course(usage_key.course_key)
if hasattr(course, 'entrance_exam_enabled') and course.entrance_exam_enabled:
if category == 'sequential' and request.json.get('parent_locator') == course.entrance_exam_id:
grader = {
"type": "Entrance Exam",
"min_count": 0,
"drop_count": 0,
"short_label": "Entrance",
"weight": 0
}
grading_model = CourseGradingModel.update_grader_from_json(
course.id,
grader,
request.user
)
CourseGradingModel.update_section_grader_type(
created_block,
grading_model['type'],
request.user
)
# VS[compat] cdodge: This is a hack because static_tabs also have references from the course module, so
......@@ -643,6 +697,15 @@ def _duplicate_item(parent_usage_key, duplicate_source_usage_key, user, display_
return dest_module.location
@login_required
@expect_json
def delete_item(request, usage_key):
"""
Exposes internal helper method without breaking existing bindings/dependencies
"""
_delete_item(usage_key, request.user)
def _delete_item(usage_key, user):
"""
Deletes an existing xblock with the given usage_key.
......@@ -781,6 +844,9 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
else:
graders = []
# Filter the graders data as needed
graders = _filter_entrance_exam_grader(graders)
# Compute the child info first so it can be included in aggregate information for the parent
should_visit_children = include_child_info and (course_outline and not is_xblock_unit or not course_outline)
if should_visit_children and xblock.has_children:
......@@ -799,6 +865,11 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
visibility_state = None
published = modulestore().has_published_version(xblock) if not is_library_block else None
#instead of adding a new feature directly into xblock-info, we should add them into override_type.
override_type = {}
if getattr(xblock, "is_entrance_exam", None):
override_type['is_entrance_exam'] = xblock.is_entrance_exam
xblock_info = {
"id": unicode(xblock.location),
"display_name": xblock.display_name_with_default,
......@@ -818,6 +889,7 @@ def create_xblock_info(xblock, data=None, metadata=None, include_ancestor_info=F
"format": xblock.format,
"course_graders": json.dumps([grader.get('type') for grader in graders]),
"has_changes": has_changes,
"override_type": override_type,
}
if data is not None:
xblock_info["data"] = data
......
......@@ -4,6 +4,8 @@ import datetime
import json
from json.encoder import JSONEncoder
from django.conf import settings
from opaque_keys.edx.locations import Location
from xmodule.modulestore.exceptions import ItemNotFoundError
from contentstore.utils import course_image_url
......@@ -19,6 +21,9 @@ ABOUT_ATTRIBUTES = [
'short_description',
'overview',
'effort',
'entrance_exam_enabled',
'entrance_exam_id',
'entrance_exam_minimum_score_pct',
]
......@@ -40,6 +45,12 @@ class CourseDetails(object):
self.course_image_name = ""
self.course_image_asset_path = "" # URL of the course image
self.pre_requisite_courses = [] # pre-requisite courses
self.entrance_exam_enabled = "" # is entrance exam enabled
self.entrance_exam_id = "" # the content location for the entrance exam
self.entrance_exam_minimum_score_pct = settings.FEATURES.get(
'ENTRANCE_EXAM_MIN_SCORE_PCT',
'50'
) # minimum passing score for entrance exam content module/tree
@classmethod
def _fetch_about_attribute(cls, course_key, attribute):
......@@ -168,6 +179,7 @@ class CourseDetails(object):
# NOTE: below auto writes to the db w/o verifying that any of the fields actually changed
# to make faster, could compare against db or could have client send over a list of which fields changed.
for attribute in ABOUT_ATTRIBUTES:
if attribute in jsondict:
cls.update_about_item(course_key, attribute, jsondict[attribute], descriptor, user)
recomposed_video_tag = CourseDetails.recompose_video_tag(jsondict['intro_video'])
......
"""
Django module for Course Metadata class -- manages advanced settings and related parameters
"""
from xblock.fields import Scope
from xmodule.modulestore.django import modulestore
from django.utils.translation import ugettext as _
......@@ -33,7 +36,10 @@ class CourseMetadata(object):
'tags', # from xblock
'visible_to_staff_only',
'group_access',
'pre_requisite_courses'
'pre_requisite_courses',
'entrance_exam_enabled',
'entrance_exam_minimum_score_pct',
'entrance_exam_id',
]
@classmethod
......@@ -61,21 +67,28 @@ class CourseMetadata(object):
persistence and return a CourseMetadata model.
"""
result = {}
metadata = cls.fetch_all(descriptor)
for key, value in metadata.iteritems():
if key in cls.filtered_list():
continue
result[key] = value
return result
@classmethod
def fetch_all(cls, descriptor):
"""
Fetches all key:value pairs from persistence and returns a CourseMetadata model.
"""
result = {}
for field in descriptor.fields.values():
if field.scope != Scope.settings:
continue
if field.name in cls.filtered_list():
continue
result[field.name] = {
'value': field.read_json(descriptor),
'display_name': _(field.display_name),
'help': _(field.help),
'deprecated': field.runtime_options.get('deprecated', False)
}
return result
@classmethod
......
......@@ -68,6 +68,8 @@
"ENABLE_DISCUSSION_SERVICE": true,
"ENABLE_INSTRUCTOR_ANALYTICS": true,
"ENABLE_S3_GRADE_DOWNLOADS": true,
"ENTRANCE_EXAMS": true,
"MILESTONES_APP": true,
"PREVIEW_LMS_BASE": "localhost:8003",
"SUBDOMAIN_BRANDING": false,
"SUBDOMAIN_COURSE_LISTINGS": false,
......
......@@ -60,6 +60,9 @@ FEATURES['MILESTONES_APP'] = True
# Enable pre-requisite course
FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
......
......@@ -113,6 +113,7 @@ FEATURES = {
# Turn off Video Upload Pipeline through Studio, by default
'ENABLE_VIDEO_UPLOAD_PIPELINE': False,
# Is this an edX-owned domain? (edx.org)
# for consistency in user-experience, keep the value of this feature flag
# in sync with the one in lms/envs/common.py
......@@ -134,7 +135,14 @@ FEATURES = {
# Prerequisite courses feature flag
'ENABLE_PREREQUISITE_COURSES': False,
# Toggle course milestones app/feature
'MILESTONES_APP': False,
# Toggle course entrance exams feature
'ENTRANCE_EXAMS': False,
}
ENABLE_JASMINE = False
......@@ -751,7 +759,9 @@ OPTIONAL_APPS = (
# edxval
'edxval',
'milestones'
# milestones
'milestones',
)
......@@ -786,6 +796,9 @@ MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB = 10
# a file that exceeds the above size
MAX_ASSET_UPLOAD_FILE_SIZE_URL = ""
### Default value for entrance exam minimum score
ENTRANCE_EXAM_MIN_SCORE_PCT = 50
################ ADVANCED_COMPONENT_TYPES ###############
ADVANCED_COMPONENT_TYPES = [
......
......@@ -75,6 +75,15 @@ DEBUG_TOOLBAR_CONFIG = {
# Stacktraces slow down page loads drastically (for pages with lots of queries).
DEBUG_TOOLBAR_MONGO_STACKTRACES = False
################################ MILESTONES ################################
FEATURES['MILESTONES_APP'] = True
################################ ENTRANCE EXAMS ################################
FEATURES['ENTRANCE_EXAMS'] = True
###############################################################################
# See if the developer has any local overrides.
try:
......
......@@ -232,7 +232,15 @@ FEATURES['USE_MICROSITES'] = True
# the one in lms/envs/test.py
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
# Enable content libraries code for the tests
FEATURES['ENABLE_CONTENT_LIBRARIES'] = True
FEATURES['ENABLE_EDXNOTES'] = True
# MILESTONES
FEATURES['MILESTONES_APP'] = True
# ENTRANCE EXAMS
FEATURES['ENTRANCE_EXAMS'] = True
ENTRANCE_EXAM_MIN_SCORE_PCT = 50
define(["backbone", "underscore", "gettext"], function(Backbone, _, gettext) {
define(["backbone", "underscore", "gettext", "js/models/validation_helpers"],
function(Backbone, _, gettext, ValidationHelpers) {
var CourseDetails = Backbone.Model.extend({
defaults: {
......@@ -16,7 +17,9 @@ var CourseDetails = Backbone.Model.extend({
effort: null, // an int or null,
course_image_name: '', // the filename
course_image_asset_path: '', // the full URL (/c4x/org/course/num/asset/filename)
pre_requisite_courses: []
pre_requisite_courses: [],
entrance_exam_enabled : '',
entrance_exam_minimum_score_pct: '50'
},
validate: function(newattrs) {
......@@ -44,6 +47,16 @@ var CourseDetails = Backbone.Model.extend({
}
// TODO check if key points to a real video using google's youtube api
}
if(_.has(newattrs, 'entrance_exam_minimum_score_pct')){
var range = {
min: 1,
max: 100
};
if(!ValidationHelpers.validateIntegerRange(newattrs.entrance_exam_minimum_score_pct, range)){
errors.entrance_exam_minimum_score_pct = gettext("Please enter an integer between "
+ range.min +" and "+ range.max +".");
}
}
if (!_.isEmpty(errors)) return errors;
// NOTE don't return empty errors as that will be interpreted as an error state
},
......
/**
* Provide helper methods for modal validation.
*/
define(["jquery"],
function($) {
var validateIntegerRange = function(attributeVal, range) {
//Validating attribute should have an integer value and should be under the given range.
var isIntegerUnderRange = true;
var value = Math.round(attributeVal); // see if this ensures value saved is int
if (!isFinite(value) || /\D+/.test(attributeVal) || value < range.min || value > range.max) {
isIntegerUnderRange = false;
}
return isIntegerUnderRange;
}
return {
'validateIntegerRange': validateIntegerRange
}
});
......@@ -131,7 +131,11 @@ function(Backbone, _, str, ModuleUtils) {
* content groups. Note that this is not a recursive property. Will only be present if
* publishing info was explicitly requested.
*/
'has_content_group_components': null
'has_content_group_components': null,
/**
* Indicate the type of xblock
*/
'override_type': null
},
initialize: function () {
......@@ -168,6 +172,19 @@ function(Backbone, _, str, ModuleUtils) {
return !this.get('published') || this.get('has_changes');
},
canBeDeleted: function(){
//get the type of xblock
if(this.get('override_type') != null) {
var type = this.get('override_type');
//hide/remove the delete trash icon if type is entrance exam.
if (_.has(type, 'is_entrance_exam') && type['is_entrance_exam']) {
return false;
}
}
return true;
},
/**
* Return a list of convenience methods to check affiliation to the category.
* @return {Array}
......
......@@ -20,7 +20,9 @@ define([
effort : null,
course_image_name : '',
course_image_asset_path : '',
pre_requisite_courses : []
pre_requisite_courses : [],
entrance_exam_enabled : '',
entrance_exam_minimum_score_pct: '50'
},
mockSettingsPage = readFixtures('mock/mock-settings-page.underscore');
......@@ -76,5 +78,45 @@ define([
);
AjaxHelpers.respondWithJson(requests, expectedJson);
});
it('should save entrance exam course details information correctly', function () {
var entrance_exam_minimum_score_pct = '60';
var entrance_exam_enabled = 'true';
var entrance_exam_min_score = this.view.$('#entrance-exam-minimum-score-pct');
var entrance_exam_enabled_field = this.view.$('#entrance-exam-enabled');
var requests = AjaxHelpers.requests(this),
expectedJson = $.extend(true, {}, modelData, {
entrance_exam_enabled: entrance_exam_enabled,
entrance_exam_minimum_score_pct: entrance_exam_minimum_score_pct
});
expect(this.view.$('.div-grade-requirements div')).toBeHidden();
// select the entrance-exam-enabled checkbox. grade requirement section should be visible
entrance_exam_enabled_field
.attr('checked', entrance_exam_enabled)
.trigger('change');
expect(this.view.$('.div-grade-requirements div')).toBeVisible();
//input some invalid values.
expect(entrance_exam_min_score.val('101').trigger('input')).toHaveClass("error");
expect(entrance_exam_min_score.val('invalidVal').trigger('input')).toHaveClass("error");
//if input an empty value, model should be populated with the default value.
entrance_exam_min_score.val('').trigger('input');
expect(this.model.get('entrance_exam_minimum_score_pct'))
.toEqual(this.model.defaults.entrance_exam_minimum_score_pct);
// input a valid value for entrance exam minimum score.
entrance_exam_min_score.val(entrance_exam_minimum_score_pct).trigger('input');
this.view.saveView();
AjaxHelpers.expectJsonRequest(
requests, 'POST', urlRoot, expectedJson
);
AjaxHelpers.respondWithJson(requests, expectedJson);
});
});
});
......@@ -64,10 +64,21 @@ 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);
if (this.model.get('entrance_exam_enabled') == 'true') {
this.$('#' + this.fieldToSelectorMap['entrance_exam_enabled']).attr('checked', this.model.get('entrance_exam_enabled'));
this.$('.div-grade-requirements').show();
}
else {
this.$('#' + this.fieldToSelectorMap['entrance_exam_enabled']).removeAttr('checked');
this.$('.div-grade-requirements').hide();
}
this.$('#' + this.fieldToSelectorMap['entrance_exam_minimum_score_pct']).val(this.model.get('entrance_exam_minimum_score_pct'));
return this;
},
fieldToSelectorMap : {
......@@ -80,7 +91,9 @@ var DetailsView = ValidatingView.extend({
'intro_video' : 'course-introduction-video',
'effort' : "course-effort",
'course_image_asset_path': 'course-image-url',
'pre_requisite_courses': 'pre-requisite-course'
'pre_requisite_courses': 'pre-requisite-course',
'entrance_exam_enabled': 'entrance-exam-enabled',
'entrance_exam_minimum_score_pct': 'entrance-exam-minimum-score-pct'
},
updateTime : function(e) {
......@@ -156,6 +169,23 @@ var DetailsView = ValidatingView.extend({
case 'course-effort':
this.setField(event);
break;
case 'entrance-exam-enabled':
if($(event.currentTarget).is(":checked")){
this.$('.div-grade-requirements').show();
}else{
this.$('.div-grade-requirements').hide();
}
this.setField(event);
break;
case 'entrance-exam-minimum-score-pct':
// If the val is an empty string then update model with default value.
if ($(event.currentTarget).val() === '') {
this.model.set('entrance_exam_minimum_score_pct', this.model.defaults.entrance_exam_minimum_score_pct);
}
else {
this.setField(event);
}
break;
case 'course-short-description':
this.setField(event);
break;
......
......@@ -60,7 +60,12 @@ var ValidatingView = BaseView.extend({
// Set model field and return the new value.
this.clearValidationErrors();
var field = this.selectorToField[event.currentTarget.id];
var newVal = $(event.currentTarget).val();
var newVal = ''
if(event.currentTarget.type == 'checkbox'){
newVal = $(event.currentTarget).is(":checked").toString();
}else{
newVal = $(event.currentTarget).val();
}
this.model.set(field, newVal);
this.model.isValid();
return newVal;
......
......@@ -100,7 +100,11 @@
@include transition(color $tmg-f2 ease-in-out 0s);
display: block;
margin-top: ($baseline/4);
color: $gray-l3;
color: $gray-d1;
}
.tip-inline{
display: inline;
margin-left: 5px;
}
.message-error {
......@@ -126,6 +130,30 @@
.list-input {
@extend %cont-no-list;
.show-data{
.heading{
border: 1px solid #E0E0E0;
padding: 5px 15px;
margin-top: 5px;
}
.div-grade-requirements{
border: 1px solid #E0E0E0;
border-top: none;
padding: 10px 15px;
label{font-weight: 600;}
input#entrance-exam-minimum-score-pct{
height: 40px;
font-size: 18px;
}
}
}
#heading-entrance-exam{
font-weight: 600;
}
label[for="entrance-exam-enabled"] {
font-size: 14px;
}
.field {
margin: 0 0 ($baseline*2) 0;
......
......@@ -78,12 +78,14 @@ if (xblockInfo.get('graded')) {
</a>
</li>
<% } %>
<% if (xblockInfo.canBeDeleted()) { %>
<li class="action-item action-delete">
<a href="#" data-tooltip="<%= gettext('Delete') %>" class="delete-button action-button">
<i class="icon fa fa-trash-o"></i>
<i class="icon fa fa-trash-o" aria-hidden="true"></i>
<span class="sr action-button-text"><%= gettext('Delete') %></span>
</a>
</li>
<% } %>
<li class="action-item action-drag">
<span data-tooltip="<%= gettext('Drag to reorder') %>"
class="drag-handle <%= xblockType %>-drag-handle action">
......
......@@ -74,6 +74,19 @@
<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>
<h3 id="heading-entrance-exam">${_("Entrance Exam")}</h3>
<div class="show-data">
<div class="heading">
<input type="checkbox" id="entrance-exam-enabled" />
<label for="entrance-exam-enabled">${_("Require students to pass an exam before beginning the course.")}</label>
</div>
<div class="div-grade-requirements">
<p><span class="tip tip-inline">${_("To create your course entrance exam, go to the ")}<a href='${course_handler_url}'>${_("Course Outline")}</a>${_(". An Entrance Exam section will be created automatically.")}</span></p>
<p><label for="entrance-exam-minimum-score-pct">${_("Minimum Passing Score")}</label></p>
<p><div><input type="text" id="entrance-exam-minimum-score-pct" aria-describedby="min-score-format min-score-tip"><span id="min-score-format" class="tip tip-inline">${_(" %")}</span></div></p>
<p><span class="tip tip-inline" id="min-score-tip">${_("The minimum score a student must receive to pass the entrance exam.")}</span></p>
</div>
</div>
</li>
</ol>
</section>
......
......@@ -322,6 +322,23 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}';
</form>
</li>
% endif
% if settings.FEATURES.get('ENTRANCE_EXAMS'):
<li>
<h3 id="heading-entrance-exam">${_("Entrance Exam")}</h3>
<div class="show-data">
<div class="heading">
<input type="checkbox" id="entrance-exam-enabled" />
<label for="entrance-exam-enabled">${_("Require students to pass an exam before beginning the course.")}</label>
</div>
<div class="div-grade-requirements" hidden="hidden">
<p><span class="tip tip-inline">${_("You can now view and author your course entrance exam from the ")}<a href='${course_handler_url}'>${_("Course Outline")}</a></span></p>
<p><h3>${_("Grade Requirements")}</h3></p>
<p><div><input type="text" id="entrance-exam-minimum-score-pct" aria-describedby="min-score-format"><span id="min-score-format" class="tip tip-inline">${_(" %")}</span></div></p>
<p><span class="tip tip-inline">${_("The score student must meet in order to successfully complete the entrance exam. ")}</span></p>
</div>
</div>
</li>
% endif
</ol>
</section>
% endif
......
......@@ -158,6 +158,12 @@ if settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING'):
url(r'^auto_auth$', 'student.views.auto_auth'),
)
# enable entrance exams
if settings.FEATURES.get('ENTRANCE_EXAMS'):
urlpatterns += (
url(r'^course/{}/entrance_exam/?$'.format(settings.COURSE_KEY_PATTERN), 'contentstore.views.entrance_exam'),
)
if settings.DEBUG:
try:
from .urls_dev import urlpatterns as dev_urlpatterns
......
# pylint: disable=invalid-name
"""
Helper methods for milestones api calls.
Utility library for working with the edx-milestones app
"""
from django.conf import settings
......@@ -20,6 +20,10 @@ from milestones.api import (
)
from milestones.models import MilestoneRelationshipType
NAMESPACE_CHOICES = {
'ENTRANCE_EXAM': 'entrance_exams'
}
def add_prerequisite_course(course_key, prerequisite_course_key):
"""
......@@ -165,3 +169,21 @@ def seed_milestone_relationship_types():
if settings.FEATURES.get('MILESTONES_APP', False):
MilestoneRelationshipType.objects.create(name='requires')
MilestoneRelationshipType.objects.create(name='fulfills')
def generate_milestone_namespace(namespace, course_key=None):
"""
Returns a specifically-formatted namespace string for the specified type
"""
if namespace in NAMESPACE_CHOICES.values():
if namespace == 'entrance_exams':
return '{}.{}'.format(unicode(course_key), NAMESPACE_CHOICES['ENTRANCE_EXAM'])
def serialize_user(user):
"""
Returns a milestones-friendly representation of a user object
"""
return {
'id': user.id,
}
"""
Django module container for classes and operations related to the "Course Module" content type
"""
import logging
from cStringIO import StringIO
from math import exp
......@@ -8,12 +11,13 @@ from datetime import datetime
import dateutil.parser
from lazy import lazy
from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.graders import grader_from_conf
from xmodule.tabs import CourseTabList
import json
from xblock.fields import Scope, List, String, Dict, Boolean, Integer
from xblock.fields import Scope, List, String, Dict, Boolean, Integer, Float
from .fields import Date
from django.utils.timezone import UTC
......@@ -657,6 +661,31 @@ class CourseFields(object):
{"display_name": _("None"), "value": CATALOG_VISIBILITY_NONE}]
)
entrance_exam_enabled = Boolean(
display_name=_("Entrance Exam Enabled"),
help=_(
"Specify whether students must complete an entrance exam before they can view your course content." +
"Note, you must enable Entrance Exams for this course setting to take effect."),
default=False,
scope=Scope.settings,
)
entrance_exam_minimum_score_pct = Float(
display_name=_("Entrance Exam Minimum Score (%)"),
help=_(
"Specify a minimum percentage score for an entrance exam before students can view your course content." +
"Note, you must enable Entrance Exams for this course setting to take effect."),
default=65,
scope=Scope.settings,
)
entrance_exam_id = String(
display_name=_("Entrance Exam ID"),
help=_("Content module identifier (location) of entrance exam."),
default=None,
scope=Scope.settings,
)
class CourseDescriptor(CourseFields, SequenceDescriptor):
module_class = SequenceModule
......
......@@ -177,6 +177,14 @@ class InheritanceMixin(XBlockMixin):
scope=Scope.user_info
)
in_entrance_exam = Boolean(
display_name=_("Tag this module as part of an Entrance Exam section"),
help=_("Enter true or false. If true, answer submissions for problem modules will be "
"considered in the Entrance Exam scoring/gating algorithm."),
scope=Scope.settings,
default=False
)
def compute_inherited_metadata(descriptor):
"""Given a descriptor, traverse all of its descendants and do metadata
......
......@@ -1225,7 +1225,13 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo
# attach to parent if given
if 'detached' not in xblock._class_tags:
parent = self.get_item(parent_usage_key)
# Originally added to support entrance exams (settings.FEATURES.get('ENTRANCE_EXAMS'))
if kwargs.get('position') is None:
parent.children.append(xblock.location)
else:
parent.children.insert(kwargs.get('position'), xblock.location)
self.update_item(parent, user_id)
return xblock
......
......@@ -1510,7 +1510,16 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
raise ItemNotFoundError(parent_usage_key)
parent = new_structure['blocks'][block_id]
# Originally added to support entrance exams (settings.FEATURES.get('ENTRANCE_EXAMS'))
if kwargs.get('position') is None:
parent['fields'].setdefault('children', []).append(BlockKey.from_usage_key(xblock.location))
else:
parent['fields'].setdefault('children', []).insert(
kwargs.get('position'),
BlockKey.from_usage_key(xblock.location)
)
if parent['edit_info']['update_version'] != new_structure['_id']:
# if the parent hadn't been previously changed in this bulk transaction, indicate that it's
# part of the bulk transaction
......
......@@ -676,6 +676,35 @@ class TestMongoModuleStore(TestMongoModuleStoreBase):
finally:
shutil.rmtree(root_dir)
def test_draft_modulestore_create_child_with_position(self):
"""
This test is designed to hit a specific set of use cases having to do with
the child positioning logic found in mongo/base.py:create_child()
"""
# Set up the draft module store
course = self.draft_store.create_course("TestX", "ChildTest", "1234_A1", 1)
first_child = self.draft_store.create_child(
self.dummy_user,
course.location,
"chapter",
block_id=course.location.block_id
)
second_child = self.draft_store.create_child(
self.dummy_user,
course.location,
"chapter",
block_id=course.location.block_id,
position=0
)
# First child should have been moved to second position, and better child takes the lead
course = self.draft_store.get_course(course.id)
self.assertEqual(unicode(course.children[1]), unicode(first_child.location))
self.assertEqual(unicode(course.children[0]), unicode(second_child.location))
# Clean up the data so we don't break other tests which apparently expect a particular state
self.draft_store.delete_course(course.id, self.dummy_user)
class TestMongoModuleStoreWithNoAssetCollection(TestMongoModuleStore):
'''
......
......@@ -1396,6 +1396,41 @@ class TestItemCrud(SplitModuleTest):
for _ in range(4):
self.create_subtree_for_deletion(node_loc, category_queue[1:])
def test_split_modulestore_create_child_with_position(self):
"""
This test is designed to hit a specific set of use cases having to do with
the child positioning logic found in split_mongo/split.py:create_child()
"""
# Set up the split module store
store = modulestore()
user = random.getrandbits(32)
course_key = CourseLocator('test_org', 'test_transaction', 'test_run')
with store.bulk_operations(course_key):
new_course = store.create_course('test_org', 'test_transaction', 'test_run', user, BRANCH_NAME_DRAFT)
new_course_locator = new_course.id
versionless_course_locator = new_course_locator.version_agnostic()
first_child = store.create_child(
self.user_id,
new_course.location,
"chapter"
)
refetch_course = store.get_course(versionless_course_locator)
second_child = store.create_child(
self.user_id,
refetch_course.location,
"chapter",
position=0
)
# First child should have been moved to second position, and better child takes the lead
refetch_course = store.get_course(versionless_course_locator)
children = refetch_course.get_children()
self.assertEqual(unicode(children[1].location), unicode(first_child.location))
self.assertEqual(unicode(children[0].location), unicode(second_child.location))
# Clean up the data so we don't break other tests which apparently expect a particular state
store.delete_course(refetch_course.id, user)
class TestCourseCreation(SplitModuleTest):
"""
......
......@@ -4,7 +4,7 @@ import warnings
from lxml import etree
from xblock.fields import Integer, Scope
from xblock.fields import Integer, Scope, Boolean
from xblock.fragment import Fragment
from pkg_resources import resource_string
......@@ -45,6 +45,16 @@ class SequenceFields(object):
scope=Scope.user_state,
)
# Entrance Exam flag -- see cms/contentstore/views/entrance_exam.py for usage
is_entrance_exam = Boolean(
display_name=_("Is Entrance Exam"),
help=_(
"Tag this course module as an Entrance Exam. " +
"Note, you must enable Entrance Exams for this course setting to take effect."
),
scope=Scope.settings,
)
class SequenceModule(SequenceFields, XModule):
''' Layout module which lays out content in a temporal sequence
......
"""
Course Schedule and Details Settings page.
"""
from bok_choy.promise import EmptyPromise
from .course_page import CoursePage
from .utils import press_the_notification_button
......@@ -23,11 +24,42 @@ class SettingsPage(CoursePage):
"""
return self.q(css='#pre-requisite-course')
def save_changes(self):
@property
def entrance_exam_field(self):
"""
Returns the enable entrance exam checkbox.
"""
return self.q(css='#entrance-exam-enabled').execute()
def require_entrance_exam(self, required=True):
"""
Set the entrance exam requirement via the checkbox.
"""
checkbox = self.entrance_exam_field[0]
selected = checkbox.is_selected()
if required and not selected:
checkbox.click()
self.wait_for_element_visibility(
'#entrance-exam-minimum-score-pct',
'Entrance exam minimum score percent is visible'
)
if not required and selected:
checkbox.click()
self.wait_for_element_invisibility(
'#entrance-exam-minimum-score-pct',
'Entrance exam minimum score percent is visible'
)
def save_changes(self, wait_for_confirmation=True):
"""
Clicks save button.
Clicks save button, waits for confirmation unless otherwise specified
"""
press_the_notification_button(self, "save")
if wait_for_confirmation:
EmptyPromise(
lambda: self.q(css='#alert-confirmation-title').present,
'Waiting for save confirmation...'
).fulfill()
def refresh_page(self):
"""
......
......@@ -221,6 +221,19 @@ def is_option_value_selected(browser_query, value):
return ddl_selected_value == value
def element_has_text(page, css_selector, text):
"""
Return true if the given text is present in the list.
"""
text_present = False
text_list = page.q(css=css_selector).text
if len(text_list) > 0 and (text in text_list):
text_present = True
return text_present
class UniqueCourseTest(WebAppTest):
"""
Test that provides a unique course ID.
......
......@@ -7,13 +7,14 @@ from textwrap import dedent
from unittest import skip
from nose.plugins.attrib import attr
from bok_choy.web_app_test import WebAppTest
from bok_choy.promise import EmptyPromise
from bok_choy.web_app_test import WebAppTest
from ..helpers import (
UniqueCourseTest,
load_data_str,
generate_course_key,
select_option_by_value,
element_has_text
)
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.common.logout import LogoutPage
......@@ -27,6 +28,7 @@ from ...pages.lms.dashboard import DashboardPage
from ...pages.lms.problem import ProblemPage
from ...pages.lms.video.video import VideoPage
from ...pages.lms.courseware import CoursewarePage
from ...pages.studio.settings import SettingsPage
from ...pages.lms.login_and_register import CombinedLoginAndRegisterPage
from ...pages.studio.settings import SettingsPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc
......@@ -771,3 +773,73 @@ class ProblemExecutionTest(UniqueCourseTest):
problem_page.fill_answer("4")
problem_page.click_check()
self.assertFalse(problem_page.is_correct())
class EntranceExamTest(UniqueCourseTest):
"""
Tests that course has an entrance exam.
"""
def setUp(self):
"""
Initialize pages and install a course fixture.
"""
super(EntranceExamTest, self).setUp()
CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
).install()
self.course_info_page = CourseInfoPage(self.browser, self.course_id)
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_entrance_exam_section(self):
"""
Scenario: Any course that is enabled for an entrance exam, should have entrance exam section at course info
page.
Given that I am on the course info page
When I view the course info that has an entrance exam
Then there should be an "Entrance Exam" section.'
"""
# visit course info page and make sure there is not entrance exam section.
self.course_info_page.visit()
self.course_info_page.wait_for_page()
self.assertFalse(element_has_text(
page=self.course_info_page,
css_selector='div ol li a',
text='Entrance Exam'
))
# 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/enabled entrance exam for that course.
self.settings_page.visit()
self.settings_page.wait_for_page()
self.assertTrue(self.settings_page.is_browser_on_page())
self.settings_page.entrance_exam_field[0].click()
self.settings_page.save_changes()
# Logout and login as a student.
LogoutPage(self.browser).visit()
AutoAuthPage(self.browser, course_id=self.course_id, staff=False).visit()
# visit course info page and make sure there is an "Entrance Exam" section.
self.course_info_page.visit()
self.course_info_page.wait_for_page()
self.assertTrue(element_has_text(
page=self.course_info_page,
css_selector='div ol li a',
text='Entrance Exam'
))
"""
Acceptance tests for Studio's Settings Details pages
"""
from unittest import skip
from acceptance.tests.studio.base_studio_test import StudioCourseTest
from ...fixtures.course import CourseFixture
from ...pages.studio.settings import SettingsPage
from ...pages.studio.overview import CourseOutlinePage
from ...tests.studio.base_studio_test import StudioCourseTest
from ..helpers import (
generate_course_key,
select_option_by_value,
is_option_value_selected
is_option_value_selected,
element_has_text,
)
from ...pages.studio.settings import SettingsPage
class SettingsMilestonesTest(StudioCourseTest):
"""
......@@ -82,3 +86,43 @@ class SettingsMilestonesTest(StudioCourseTest):
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=''))
def test_page_has_enable_entrance_exam_field(self):
"""
Test to make sure page has 'enable entrance exam' field.
"""
self.assertTrue(self.settings_detail.entrance_exam_field)
@skip('Passes in devstack, passes individually in Jenkins, fails in suite in Jenkins.')
def test_enable_entrance_exam_for_course(self):
"""
Test that entrance exam should be created after checking the 'enable entrance exam' checkbox.
And also that the entrance exam is destroyed after deselecting the checkbox.
"""
self.settings_detail.require_entrance_exam(required=True)
self.settings_detail.save_changes()
# getting the course outline page.
course_outline_page = CourseOutlinePage(
self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']
)
course_outline_page.visit()
# title with text 'Entrance Exam' should be present on page.
self.assertTrue(element_has_text(
page=course_outline_page,
css_selector='span.section-title',
text='Entrance Exam'
))
# Delete the currently created entrance exam.
self.settings_detail.visit()
self.settings_detail.require_entrance_exam(required=False)
self.settings_detail.save_changes()
course_outline_page.visit()
self.assertFalse(element_has_text(
page=course_outline_page,
css_selector='span.section-title',
text='Entrance Exam'
))
......@@ -1102,8 +1102,8 @@ CREATE TABLE `djcelery_periodictask` (
UNIQUE KEY `name` (`name`),
KEY `djcelery_periodictask_17d2d99d` (`interval_id`),
KEY `djcelery_periodictask_7aa5fda` (`crontab_id`),
CONSTRAINT `crontab_id_refs_id_ebff5e74` FOREIGN KEY (`crontab_id`) REFERENCES `djcelery_crontabschedule` (`id`),
CONSTRAINT `interval_id_refs_id_f2054349` FOREIGN KEY (`interval_id`) REFERENCES `djcelery_intervalschedule` (`id`)
CONSTRAINT `interval_id_refs_id_f2054349` FOREIGN KEY (`interval_id`) REFERENCES `djcelery_intervalschedule` (`id`),
CONSTRAINT `crontab_id_refs_id_ebff5e74` FOREIGN KEY (`crontab_id`) REFERENCES `djcelery_crontabschedule` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
/*!40101 SET character_set_client = @saved_cs_client */;
DROP TABLE IF EXISTS `djcelery_periodictasks`;
......
......@@ -22,33 +22,12 @@ from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.util.duedate import get_extended_due_date
from .models import StudentModule
from .module_render import get_module_for_descriptor
from .module_utils import yield_dynamic_descriptor_descendents
from submissions import api as sub_api # installed from the edx-submissions repository
from opaque_keys import InvalidKeyError
log = logging.getLogger("edx.courseware")
def yield_dynamic_descriptor_descendents(descriptor, module_creator):
"""
This returns all of the descendants of a descriptor. If the descriptor
has dynamic children, the module will be created using module_creator
and the children (as descriptors) of that module will be returned.
"""
def get_dynamic_descriptor_children(descriptor):
if descriptor.has_dynamic_children():
module = module_creator(descriptor)
if module is None:
return []
return module.get_child_descriptors()
else:
return descriptor.get_children()
stack = [descriptor]
while len(stack) > 0:
next_descriptor = stack.pop()
stack.extend(get_dynamic_descriptor_children(next_descriptor))
yield next_descriptor
log = logging.getLogger("edx.courseware")
def answer_distributions(course_key):
......
......@@ -21,9 +21,11 @@ from capa.xqueue_interface import XQueueInterface
from courseware.access import has_access, get_user_role
from courseware.masquerade import setup_masquerade
from courseware.model_data import FieldDataCache, DjangoKeyValueStore
from courseware.models import StudentModule
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
from .module_utils import yield_dynamic_descriptor_descendents
from edxmako.shortcuts import render_to_string
from eventtracking import tracker
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
......@@ -35,6 +37,7 @@ from xblock.exceptions import NoSuchHandlerError
from xblock.django.request import django_to_webob_request, webob_to_django_response
from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError
from opaque_keys.edx.keys import UsageKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore, ModuleI18nService
......@@ -53,7 +56,10 @@ from xmodule.x_module import XModuleDescriptor
from util.json_request import JsonResponse
from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
if settings.FEATURES.get('MILESTONES_APP', False):
from milestones import api as milestones_api
from milestones.exceptions import InvalidMilestoneRelationshipTypeException
from util.milestones_helpers import serialize_user
log = logging.getLogger(__name__)
......@@ -93,6 +99,31 @@ def make_track_function(request):
return function
def _get_required_content(course, user):
"""
Queries milestones subsystem to see if the specified course is gated on one or more milestones,
and if those milestones can be fulfilled via completion of a particular course content module
"""
required_content = []
if settings.FEATURES.get('MILESTONES_APP', False):
# Get all of the outstanding milestones for this course, for this user
try:
milestone_paths = milestones_api.get_course_milestones_fulfillment_paths(
unicode(course.id),
serialize_user(user)
)
except InvalidMilestoneRelationshipTypeException:
return required_content
# For each outstanding milestone, see if this content is one of its fulfillment paths
for path_key in milestone_paths:
milestone_path = milestone_paths[path_key]
if milestone_path.get('content') and len(milestone_path['content']):
for content in milestone_path['content']:
required_content.append(content)
return required_content
def toc_for_course(request, course, active_chapter, active_section, field_data_cache):
'''
Create a table of contents from the module store
......@@ -122,9 +153,20 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c
if course_module is None:
return None
# Check to see if the course is gated on required content (such as an Entrance Exam)
required_content = _get_required_content(course, request.user)
chapters = list()
for chapter in course_module.get_display_items():
if chapter.hide_from_toc:
# Only show required content, if there is required content
# chapter.hide_from_toc is read-only (boo)
local_hide_from_toc = False
if len(required_content):
if unicode(chapter.location) not in required_content:
local_hide_from_toc = True
# Skip the current chapter if a hide flag is tripped
if chapter.hide_from_toc or local_hide_from_toc:
continue
sections = list()
......@@ -141,7 +183,6 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c
'active': active,
'graded': section.graded,
})
chapters.append({'display_name': chapter.display_name_with_default,
'url_name': chapter.url_name,
'sections': sections,
......@@ -341,7 +382,58 @@ def get_module_system_for_user(user, field_data_cache,
request_token=request_token,
)
def handle_grade_event(block, event_type, event):
def _fulfill_content_milestones(course_key, content_key, user_id): # pylint: disable=unused-argument
"""
Internal helper to handle milestone fulfillments for the specified content module
"""
# Fulfillment Use Case: Entrance Exam
# If this module is part of an entrance exam, we'll need to see if the student
# has reached the point at which they can collect the associated milestone
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
course = modulestore().get_course(course_key)
content = modulestore().get_item(content_key)
entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', False)
in_entrance_exam = getattr(content, 'in_entrance_exam', False)
if entrance_exam_enabled and in_entrance_exam:
exam_key = UsageKey.from_string(course.entrance_exam_id)
exam_descriptor = modulestore().get_item(exam_key)
exam_modules = yield_dynamic_descriptor_descendents(
exam_descriptor,
inner_get_module
)
ignore_categories = ['course', 'chapter', 'sequential', 'vertical']
module_pcts = []
exam_pct = 0
for module in exam_modules:
if module.graded and module.category not in ignore_categories:
module_pct = 0
try:
student_module = StudentModule.objects.get(
module_state_key=module.scope_ids.usage_id,
student_id=user_id
)
if student_module.max_grade:
module_pct = student_module.grade / student_module.max_grade
except StudentModule.DoesNotExist:
pass
module_pcts.append(module_pct)
exam_pct = sum(module_pcts) / float(len(module_pcts))
if exam_pct >= course.entrance_exam_minimum_score_pct:
relationship_types = milestones_api.get_milestone_relationship_types()
content_milestones = milestones_api.get_course_content_milestones(
course_key,
exam_key,
relationship=relationship_types['FULFILLS']
)
# Add each milestone to the user's set...
user = {'id': user_id}
for milestone in content_milestones:
milestones_api.add_user_milestone(user, milestone)
def handle_grade_event(block, event_type, event): # pylint: disable=unused-argument
"""
Manages the workflow for recording and updating of student module grade state
"""
user_id = event.get('user_id', user.id)
# Construct the key for the module
......@@ -373,6 +465,16 @@ def get_module_system_for_user(user, field_data_cache,
dog_stats_api.increment("lms.courseware.question_answered", tags=tags)
# If we're using the awesome edx-milestones app, we need to cycle
# through the fulfillment scenarios to see if any are now applicable
# thanks to the updated grading information that was just submitted
if settings.FEATURES.get('MILESTONES_APP', False):
_fulfill_content_milestones(
course_id,
descriptor.location,
user_id
)
def publish(block, event_type, event):
"""A function that allows XModules to publish events."""
if event_type == 'grade':
......
"""
Utility library containing operations used/shared by multiple courseware modules
"""
def yield_dynamic_descriptor_descendents(descriptor, module_creator): # pylint: disable=invalid-name
"""
This returns all of the descendants of a descriptor. If the descriptor
has dynamic children, the module will be created using module_creator
and the children (as descriptors) of that module will be returned.
"""
def get_dynamic_descriptor_children(descriptor):
"""
Internal recursive helper for traversing the child hierarchy
"""
module_children = []
if descriptor.has_dynamic_children():
module = module_creator(descriptor)
if module is not None:
module_children = module.get_child_descriptors()
else:
module_children = descriptor.get_children()
return module_children
stack = [descriptor]
while len(stack) > 0:
next_descriptor = stack.pop()
stack.extend(get_dynamic_descriptor_children(next_descriptor))
yield next_descriptor
"""
This module is essentially a broker to xmodule/tabs.py -- it was originally introduced to
perform some LMS-specific tab display gymnastics for the Entrance Exams feature
"""
from django.conf import settings
from django.utils.translation import ugettext as _
from courseware.access import has_access
from student.models import CourseEnrollment
from xmodule.tabs import CourseTabList
if settings.FEATURES.get('MILESTONES_APP', False):
from milestones.api import get_course_milestones_fulfillment_paths
from util.milestones_helpers import serialize_user
def get_course_tab_list(course, user):
"""
Retrieves the course tab list from xmodule.tabs and manipulates the set as necessary
"""
user_is_enrolled = user.is_authenticated() and CourseEnrollment.is_enrolled(user, course.id)
xmodule_tab_list = CourseTabList.iterate_displayable(
course,
settings,
user.is_authenticated(),
has_access(user, 'staff', course, course.id),
user_is_enrolled
)
# Entrance Exams Feature
# If the course has an entrance exam, we'll need to see if the user has not passed it
# If so, we'll need to hide away all of the tabs except for Courseware and Instructor
entrance_exam_mode = False
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
if getattr(course, 'entrance_exam_enabled', False):
course_milestones_paths = get_course_milestones_fulfillment_paths(
unicode(course.id),
serialize_user(user)
)
for __, value in course_milestones_paths.iteritems():
if len(value.get('content', [])):
for content in value['content']:
if content == course.entrance_exam_id:
entrance_exam_mode = True
break
# Now that we've loaded the tabs for this course, perform the Entrance Exam mode work
# Majority case is no entrance exam defined
course_tab_list = []
for tab in xmodule_tab_list:
if entrance_exam_mode:
# Hide all of the tabs except for 'Courseware' and 'Instructor'
# Rename 'Courseware' tab to 'Entrance Exam'
if tab.type not in ['courseware', 'instructor']:
continue
if tab.type == 'courseware':
tab.name = _("Entrance Exam")
course_tab_list.append(tab)
return course_tab_list
......@@ -30,7 +30,7 @@ from courseware.tests.factories import StudentModuleFactory, UserFactory, Global
from courseware.tests.tests import LoginEnrollmentTestCase
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_MOCK_MODULESTORE, TEST_DATA_MIXED_TOY_MODULESTORE,
TEST_DATA_XML_MODULESTORE
TEST_DATA_XML_MODULESTORE, TEST_DATA_MIXED_CLOSED_MODULESTORE
)
from courseware.tests.test_submitting_problems import TestSubmittingProblems
from lms.djangoapps.lms_xblock.runtime import quote_slashes
......
"""
Test cases for tabs.
Note: Tests covering workflows in the actual tabs.py file begin after line 100
"""
from django.conf import settings
from django.core.urlresolvers import reverse
from django.http import Http404
from django.test.utils import override_settings
......@@ -18,6 +20,11 @@ from xmodule.tabs import CourseTabList
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
if settings.FEATURES.get('MILESTONES_APP', False):
from courseware.tabs import get_course_tab_list
from milestones import api as milestones_api
from milestones.models import MilestoneRelationshipType
@override_settings(MODULESTORE=TEST_DATA_MIXED_TOY_MODULESTORE)
class StaticTabDateTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
......@@ -97,3 +104,69 @@ class StaticTabDateTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertIn(self.xml_data, resp.content)
@override_settings(MODULESTORE=TEST_DATA_MIXED_CLOSED_MODULESTORE)
class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
"""
Validate tab behavior when dealing with Entrance Exams
"""
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
def setUp(self):
"""
Test case scaffolding
"""
self.course = CourseFactory.create()
self.instructor_tab = ItemFactory.create(
category="instructor", parent_location=self.course.location,
data="Instructor Tab", display_name="Instructor"
)
self.extra_tab_2 = ItemFactory.create(
category="static_tab", parent_location=self.course.location,
data="Extra Tab", display_name="Extra Tab 2"
)
self.extra_tab_3 = ItemFactory.create(
category="static_tab", parent_location=self.course.location,
data="Extra Tab", display_name="Extra Tab 3"
)
self.setup_user()
self.enroll(self.course)
self.user.is_staff = True
self.relationship_types = milestones_api.get_milestone_relationship_types()
MilestoneRelationshipType.objects.create(name='requires')
MilestoneRelationshipType.objects.create(name='fulfills')
def test_get_course_tabs_list_entrance_exam_enabled(self):
"""
Unit Test: test_get_course_tabs_list_entrance_exam_enabled
"""
entrance_exam = ItemFactory.create(
category="chapter", parent_location=self.course.location,
data="Exam Data", display_name="Entrance Exam"
)
entrance_exam.is_entrance_exam = True
milestone = {
'name': 'Test Milestone',
'namespace': '{}.entrance_exams'.format(unicode(self.course.id)),
'description': 'Testing Courseware Tabs'
}
self.course.entrance_exam_enabled = True
self.course.entrance_exam_id = unicode(entrance_exam.location)
milestone = milestones_api.add_milestone(milestone)
milestones_api.add_course_milestone(
unicode(self.course.id),
self.relationship_types['REQUIRES'],
milestone
)
milestones_api.add_course_content_milestone(
unicode(self.course.id),
unicode(entrance_exam.location),
self.relationship_types['FULFILLS'],
milestone
)
course_tab_list = get_course_tab_list(self.course, self.user)
self.assertEqual(len(course_tab_list), 2)
self.assertEqual(course_tab_list[0]['tab_id'], 'courseware')
self.assertEqual(course_tab_list[0]['name'], 'Entrance Exam')
self.assertEqual(course_tab_list[1]['tab_id'], 'instructor')
......@@ -68,6 +68,7 @@ import survey.utils
import survey.views
from util.views import ensure_valid_course_key
log = logging.getLogger("edx.courseware")
template_imports = {'urllib': urllib}
......
......@@ -92,6 +92,10 @@ FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
# Unfortunately, we need to use debug mode to serve staticfiles
DEBUG = True
########################### Entrance Exams #################################
FEATURES['MILESTONES_APP'] = True
FEATURES['ENTRANCE_EXAMS'] = True
# Point the URL used to test YouTube availability to our stub YouTube server
YOUTUBE_PORT = 9080
YOUTUBE['API'] = "127.0.0.1:{0}/get_youtube_api/".format(YOUTUBE_PORT)
......
......@@ -1920,7 +1920,9 @@ OPTIONAL_APPS = (
# edxval
'edxval',
'milestones'
# milestones
'milestones',
)
for app_name in OPTIONAL_APPS:
......
......@@ -104,6 +104,15 @@ FEATURES['ADVANCED_SECURITY'] = False
PASSWORD_MIN_LENGTH = None
PASSWORD_COMPLEXITY = {}
########################### Milestones #################################
FEATURES['MILESTONES_APP'] = True
########################### Entrance Exams #################################
FEATURES['ENTRANCE_EXAMS'] = True
#####################################################################
# See if the developer has any local overrides.
try:
......
......@@ -443,3 +443,9 @@ FEATURES['ENABLE_EDXNOTES'] = True
# Add milestones to Installed apps for testing
INSTALLED_APPS += ('milestones', )
# MILESTONES
FEATURES['MILESTONES_APP'] = True
# ENTRANCE EXAMS
FEATURES['ENTRANCE_EXAMS'] = True
......@@ -12,17 +12,12 @@ def url_class(is_active):
return "active"
return ""
%>
<%! from xmodule.tabs import CourseTabList %>
<%! from courseware.access import has_access %>
<%! from courseware.masquerade import get_course_masquerade %>
<%! from courseware.tabs import get_course_tab_list %>
<%! from courseware.views import notification_image_for_tab %>
<%! from django.conf import settings %>
<%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<%! from openedx.core.djangoapps.course_groups.partition_scheme import get_cohorted_user_partition %>
<%! from student.models import CourseEnrollment %>
<%
user_is_enrolled = user.is_authenticated() and CourseEnrollment.is_enrolled(user, course.id)
cohorted_user_partition = get_cohorted_user_partition(course.id)
show_preview_menu = staff_access and active_page in ['courseware', 'info']
is_student_masquerade = masquerade and masquerade.role == 'student'
......@@ -59,7 +54,7 @@ def url_class(is_active):
<nav class="${active_page} wrapper-course-material">
<div class="course-material">
<ol class="course-tabs">
% for tab in CourseTabList.iterate_displayable(course, settings, user.is_authenticated(), has_access(user, 'staff', course, course.id), user_is_enrolled):
% for tab in get_course_tab_list(course, user):
<%
tab_is_active = (tab.tab_id == active_page) or (tab.tab_id == default_tab)
tab_image = notification_image_for_tab(tab, user, course)
......
......@@ -57,10 +57,11 @@ class BokChoyTestSuite(TestSuite):
print(msg)
bokchoy_utils.check_services()
sh("{}/scripts/reset-test-db.sh".format(Env.REPO_ROOT))
if not self.fasttest:
# Process assets and set up database for bok-choy tests
# Reset the database
sh("{}/scripts/reset-test-db.sh".format(Env.REPO_ROOT))
# Collect static assets
sh("paver update_assets --settings=bok_choy")
......
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