Merge pull request #6455 from edx/mattdrayer/entrance-exams
(WIP) Course Entrance Exams
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
224
cms/djangoapps/contentstore/views/entrance_exam.py
Normal file
224
cms/djangoapps/contentstore/views/entrance_exam.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
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
|
||||
@@ -511,6 +523,15 @@ def _save_xblock(user, xblock, data=None, children_strings=None, metadata=None,
|
||||
return JsonResponse(result, encoder=EdxJSONEncoder)
|
||||
|
||||
|
||||
@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):
|
||||
@@ -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,8 +592,32 @@ 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
|
||||
# if we add one then we need to also add it to the policy information (i.e. metadata)
|
||||
# we should remove this once we can break this reference from the course to static tabs
|
||||
@@ -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
|
||||
|
||||
252
cms/djangoapps/contentstore/views/tests/test_entrance_exam.py
Normal file
252
cms/djangoapps/contentstore/views/tests/test_entrance_exam.py
Normal file
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Test module for Entrance Exams AJAX callback handler workflows
|
||||
"""
|
||||
import json
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase
|
||||
from contentstore.utils import reverse_url
|
||||
from contentstore.views.entrance_exam import create_entrance_exam
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
if settings.FEATURES.get('MILESTONES_APP', False):
|
||||
from milestones import api as milestones_api
|
||||
from milestones.models import MilestoneRelationshipType
|
||||
from util.milestones_helpers import serialize_user
|
||||
|
||||
|
||||
class EntranceExamHandlerTests(CourseTestCase):
|
||||
"""
|
||||
Base test class for create, save, and delete
|
||||
"""
|
||||
if settings.FEATURES.get('ENTRANCE_EXAMS', False):
|
||||
def setUp(self):
|
||||
"""
|
||||
Shared scaffolding for individual test runs
|
||||
"""
|
||||
super(EntranceExamHandlerTests, self).setUp()
|
||||
self.course_key = self.course.id
|
||||
self.usage_key = self.course.location
|
||||
self.course_url = '/course/{}'.format(unicode(self.course.id))
|
||||
self.exam_url = '/course/{}/entrance_exam/'.format(unicode(self.course.id))
|
||||
MilestoneRelationshipType.objects.create(name='requires', active=True)
|
||||
MilestoneRelationshipType.objects.create(name='fulfills', active=True)
|
||||
self.milestone_relationship_types = milestones_api.get_milestone_relationship_types()
|
||||
|
||||
def test_contentstore_views_entrance_exam_post(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_post
|
||||
"""
|
||||
resp = self.client.post(self.exam_url, {}, http_accept='application/json')
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Reload the test course now that the exam module has been added
|
||||
self.course = modulestore().get_course(self.course.id)
|
||||
metadata = CourseMetadata.fetch_all(self.course)
|
||||
self.assertTrue(metadata['entrance_exam_enabled'])
|
||||
self.assertIsNotNone(metadata['entrance_exam_minimum_score_pct'])
|
||||
self.assertIsNotNone(metadata['entrance_exam_id']['value'])
|
||||
self.assertTrue(len(milestones_api.get_course_milestones(unicode(self.course.id))))
|
||||
content_milestones = milestones_api.get_course_content_milestones(
|
||||
unicode(self.course.id),
|
||||
metadata['entrance_exam_id']['value'],
|
||||
self.milestone_relationship_types['FULFILLS']
|
||||
)
|
||||
self.assertTrue(len(content_milestones))
|
||||
|
||||
def test_contentstore_views_entrance_exam_post_new_sequential_confirm_grader(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_post
|
||||
"""
|
||||
resp = self.client.post(self.exam_url, {}, http_accept='application/json')
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
# Reload the test course now that the exam module has been added
|
||||
self.course = modulestore().get_course(self.course.id)
|
||||
|
||||
# Add a new child sequential to the exam module
|
||||
# Confirm that the grader type is 'Entrance Exam'
|
||||
chapter_locator_string = json.loads(resp.content).get('locator')
|
||||
# chapter_locator = UsageKey.from_string(chapter_locator_string)
|
||||
seq_data = {
|
||||
'category': "sequential",
|
||||
'display_name': "Entrance Exam Subsection",
|
||||
'parent_locator': chapter_locator_string,
|
||||
}
|
||||
resp = self.client.ajax_post(reverse_url('xblock_handler'), seq_data)
|
||||
seq_locator_string = json.loads(resp.content).get('locator')
|
||||
seq_locator = UsageKey.from_string(seq_locator_string)
|
||||
section_grader_type = CourseGradingModel.get_section_grader_type(seq_locator)
|
||||
self.assertEqual('Entrance Exam', section_grader_type['graderType'])
|
||||
|
||||
def test_contentstore_views_entrance_exam_get(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_get
|
||||
"""
|
||||
resp = self.client.post(
|
||||
self.exam_url,
|
||||
{'entrance_exam_minimum_score_pct': settings.ENTRANCE_EXAM_MIN_SCORE_PCT},
|
||||
http_accept='application/json'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_contentstore_views_entrance_exam_delete(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_delete
|
||||
"""
|
||||
resp = self.client.post(self.exam_url, {}, http_accept='application/json')
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
resp = self.client.delete(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 204)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
user = User.objects.create(
|
||||
username='test_user',
|
||||
email='test_user@edx.org',
|
||||
is_active=True,
|
||||
)
|
||||
user.set_password('test')
|
||||
user.save()
|
||||
milestones = milestones_api.get_course_milestones(unicode(self.course_key))
|
||||
self.assertEqual(len(milestones), 1)
|
||||
milestone_key = '{}.{}'.format(milestones[0]['namespace'], milestones[0]['name'])
|
||||
paths = milestones_api.get_course_milestones_fulfillment_paths(
|
||||
unicode(self.course_key),
|
||||
serialize_user(user)
|
||||
)
|
||||
|
||||
# What we have now is a course milestone requirement and no valid fulfillment
|
||||
# paths for the specified user. The LMS is going to have to ignore this situation,
|
||||
# because we can't confidently prevent it from occuring at some point in the future.
|
||||
# milestone_key_1 =
|
||||
self.assertEqual(len(paths[milestone_key]), 0)
|
||||
|
||||
# Re-adding an entrance exam to the course should fix the missing link
|
||||
# It wipes out any old entrance exam artifacts and inserts a new exam course chapter/module
|
||||
resp = self.client.post(self.exam_url, {}, http_accept='application/json')
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_contentstore_views_entrance_exam_delete_bogus_course(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_delete_bogus_course
|
||||
"""
|
||||
resp = self.client.delete('/course/bad/course/key/entrance_exam')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_contentstore_views_entrance_exam_get_bogus_course(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_get_bogus_course
|
||||
"""
|
||||
resp = self.client.get('/course/bad/course/key/entrance_exam')
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_contentstore_views_entrance_exam_get_bogus_exam(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_get_bogus_exam
|
||||
"""
|
||||
resp = self.client.post(
|
||||
self.exam_url,
|
||||
{'entrance_exam_minimum_score_pct': '50'},
|
||||
http_accept='application/json'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.course = modulestore().get_course(self.course.id)
|
||||
|
||||
# Should raise an ItemNotFoundError and return a 404
|
||||
updated_metadata = {'entrance_exam_id': 'i4x://org.4/course_4/chapter/ed7c4c6a4d68409998e2c8554c4629d1'}
|
||||
CourseMetadata.update_from_dict(
|
||||
updated_metadata,
|
||||
self.course,
|
||||
self.user,
|
||||
)
|
||||
self.course = modulestore().get_course(self.course.id)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
# Should raise an InvalidKeyError and return a 404
|
||||
updated_metadata = {'entrance_exam_id': '123afsdfsad90f87'}
|
||||
CourseMetadata.update_from_dict(
|
||||
updated_metadata,
|
||||
self.course,
|
||||
self.user,
|
||||
)
|
||||
self.course = modulestore().get_course(self.course.id)
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_contentstore_views_entrance_exam_post_bogus_course(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_post_bogus_course
|
||||
"""
|
||||
resp = self.client.post(
|
||||
'/course/bad/course/key/entrance_exam',
|
||||
{},
|
||||
http_accept='application/json'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_contentstore_views_entrance_exam_post_invalid_http_accept(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_post_invalid_http_accept
|
||||
"""
|
||||
resp = self.client.post(
|
||||
'/course/bad/course/key/entrance_exam',
|
||||
{},
|
||||
http_accept='text/html'
|
||||
)
|
||||
self.assertEqual(resp.status_code, 400)
|
||||
|
||||
def test_contentstore_views_entrance_exam_get_invalid_user(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_get_invalid_user
|
||||
"""
|
||||
user = User.objects.create(
|
||||
username='test_user',
|
||||
email='test_user@edx.org',
|
||||
is_active=True,
|
||||
)
|
||||
user.set_password('test')
|
||||
user.save()
|
||||
self.client = AjaxEnabledTestClient()
|
||||
self.client.login(username='test_user', password='test')
|
||||
resp = self.client.get(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
|
||||
def test_contentstore_views_entrance_exam_unsupported_method(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_unsupported_method
|
||||
"""
|
||||
resp = self.client.put(self.exam_url)
|
||||
self.assertEqual(resp.status_code, 405)
|
||||
|
||||
def test_entrance_exam_view_direct_missing_score_setting(self):
|
||||
"""
|
||||
Unit Test: test_entrance_exam_view_direct_missing_score_setting
|
||||
"""
|
||||
user = UserFactory()
|
||||
user.is_staff = True
|
||||
request = RequestFactory()
|
||||
request.user = user
|
||||
|
||||
resp = create_entrance_exam(request, self.course.id, None)
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
@@ -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,7 +179,8 @@ 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:
|
||||
cls.update_about_item(course_key, attribute, jsondict[attribute], descriptor, user)
|
||||
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'])
|
||||
cls.update_about_item(course_key, 'video', recomposed_video_tag, descriptor, user)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
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
|
||||
|
||||
BIN
cms/static/images/spinner-on-grey.gif
Normal file
BIN
cms/static/images/spinner-on-grey.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -1,4 +1,5 @@
|
||||
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
|
||||
},
|
||||
|
||||
20
cms/static/js/models/validation_helpers.js
Normal file
20
cms/static/js/models/validation_helpers.js
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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,8 +100,12 @@
|
||||
@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 {
|
||||
@extend %t-copy-sub1;
|
||||
@@ -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>
|
||||
<% } %>
|
||||
<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>
|
||||
<span class="sr action-button-text"><%= gettext('Delete') %></span>
|
||||
</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" 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"""
|
||||
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)
|
||||
parent.children.append(xblock.location)
|
||||
|
||||
# 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]
|
||||
parent['fields'].setdefault('children', []).append(BlockKey.from_usage_key(xblock.location))
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
Clicks save button.
|
||||
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, 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'
|
||||
))
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"""
|
||||
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'
|
||||
))
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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,35 +22,14 @@ 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
|
||||
|
||||
|
||||
def answer_distributions(course_key):
|
||||
"""
|
||||
Given a course_key, return answer distributions in the form of a dictionary
|
||||
|
||||
@@ -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':
|
||||
|
||||
30
lms/djangoapps/courseware/module_utils.py
Normal file
30
lms/djangoapps/courseware/module_utils.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
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
|
||||
59
lms/djangoapps/courseware/tabs.py
Normal file
59
lms/djangoapps/courseware/tabs.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
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
|
||||
276
lms/djangoapps/courseware/tests/test_entrance_exam.py
Normal file
276
lms/djangoapps/courseware/tests/test_entrance_exam.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Tests use cases related to LMS Entrance Exam behavior, such as gated content access (TOC)
|
||||
"""
|
||||
from django.test.client import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from courseware.model_data import FieldDataCache
|
||||
from courseware.module_render import get_module, toc_for_course
|
||||
from courseware.tests.factories import UserFactory
|
||||
from milestones import api as milestones_api
|
||||
from milestones.models import MilestoneRelationshipType
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, TEST_DATA_MOCK_MODULESTORE
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from util.milestones_helpers import generate_milestone_namespace, NAMESPACE_CHOICES
|
||||
|
||||
|
||||
@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE)
|
||||
class EntranceExamTestCases(ModuleStoreTestCase):
|
||||
"""
|
||||
Check that content is properly gated. Create a test course from scratch to mess with.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Test case scaffolding
|
||||
"""
|
||||
super(EntranceExamTestCases, self).setUp()
|
||||
self.course = CourseFactory.create(
|
||||
metadata={
|
||||
'entrance_exam_enabled': True,
|
||||
}
|
||||
)
|
||||
chapter = ItemFactory.create(
|
||||
parent=self.course,
|
||||
display_name='Overview'
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent=chapter,
|
||||
display_name='Welcome'
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent=self.course,
|
||||
category='chapter',
|
||||
display_name="Week 1"
|
||||
)
|
||||
ItemFactory.create(
|
||||
parent=chapter,
|
||||
category='sequential',
|
||||
display_name="Lesson 1"
|
||||
)
|
||||
ItemFactory.create(
|
||||
category="instructor",
|
||||
parent=self.course,
|
||||
data="Instructor Tab",
|
||||
display_name="Instructor"
|
||||
)
|
||||
self.entrance_exam = ItemFactory.create(
|
||||
parent=self.course,
|
||||
category="chapter",
|
||||
display_name="Entrance Exam Section - Chapter 1"
|
||||
)
|
||||
self.exam_1 = ItemFactory.create(
|
||||
parent=self.entrance_exam,
|
||||
category='sequential',
|
||||
display_name="Exam Sequential - Subsection 1",
|
||||
graded=True,
|
||||
metadata={'in_entrance_exam': True}
|
||||
)
|
||||
subsection = ItemFactory.create(
|
||||
parent=self.exam_1,
|
||||
category='vertical',
|
||||
display_name='Exam Vertical - Unit 1'
|
||||
)
|
||||
self.problem_1 = ItemFactory.create(
|
||||
parent=subsection,
|
||||
category="problem",
|
||||
display_name="Exam Problem - Problem 1"
|
||||
)
|
||||
self.problem_2 = ItemFactory.create(
|
||||
parent=subsection,
|
||||
category="problem",
|
||||
display_name="Exam Problem - Problem 2"
|
||||
)
|
||||
self.problem_3 = ItemFactory.create(
|
||||
parent=subsection,
|
||||
category="problem",
|
||||
display_name="Exam Problem - Problem 3"
|
||||
)
|
||||
milestone_namespace = generate_milestone_namespace(
|
||||
NAMESPACE_CHOICES['ENTRANCE_EXAM'],
|
||||
self.course.id
|
||||
)
|
||||
self.milestone = {
|
||||
'name': 'Test Milestone',
|
||||
'namespace': milestone_namespace,
|
||||
'description': 'Testing Courseware Entrance Exam Chapter',
|
||||
}
|
||||
MilestoneRelationshipType.objects.create(name='requires', active=True)
|
||||
MilestoneRelationshipType.objects.create(name='fulfills', active=True)
|
||||
self.milestone_relationship_types = milestones_api.get_milestone_relationship_types()
|
||||
self.milestone = milestones_api.add_milestone(self.milestone)
|
||||
milestones_api.add_course_milestone(
|
||||
unicode(self.course.id),
|
||||
self.milestone_relationship_types['REQUIRES'],
|
||||
self.milestone
|
||||
)
|
||||
milestones_api.add_course_content_milestone(
|
||||
unicode(self.course.id),
|
||||
unicode(self.entrance_exam.location),
|
||||
self.milestone_relationship_types['FULFILLS'],
|
||||
self.milestone
|
||||
)
|
||||
user = UserFactory()
|
||||
self.request = RequestFactory()
|
||||
self.request.user = user
|
||||
self.request.COOKIES = {}
|
||||
self.request.META = {}
|
||||
self.request.is_secure = lambda: True
|
||||
self.request.get_host = lambda: "edx.org"
|
||||
self.request.method = 'GET'
|
||||
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
self.course.id,
|
||||
user,
|
||||
self.entrance_exam
|
||||
)
|
||||
self.entrance_exam.is_entrance_exam = True
|
||||
self.entrance_exam.in_entrance_exam = True
|
||||
self.course.entrance_exam_enabled = True
|
||||
self.course.entrance_exam_minimum_score_pct = 0.50
|
||||
self.course.entrance_exam_id = unicode(self.entrance_exam.scope_ids.usage_id)
|
||||
modulestore().update_item(self.course, user.id) # pylint: disable=no-member
|
||||
|
||||
def test_entrance_exam_gating(self):
|
||||
"""
|
||||
Unit Test: test_entrance_exam_gating
|
||||
"""
|
||||
# This user helps to cover a discovered bug in the milestone fulfillment logic
|
||||
chaos_user = UserFactory()
|
||||
expected_locked_toc = (
|
||||
[
|
||||
{
|
||||
'active': True,
|
||||
'sections': [
|
||||
{
|
||||
'url_name': u'Exam_Sequential_-_Subsection_1',
|
||||
'display_name': u'Exam Sequential - Subsection 1',
|
||||
'graded': True,
|
||||
'format': '',
|
||||
'due': None,
|
||||
'active': True
|
||||
}
|
||||
],
|
||||
'url_name': u'Entrance_Exam_Section_-_Chapter_1',
|
||||
'display_name': u'Entrance Exam Section - Chapter 1'
|
||||
}
|
||||
]
|
||||
)
|
||||
locked_toc = toc_for_course(
|
||||
self.request,
|
||||
self.course,
|
||||
self.entrance_exam.url_name,
|
||||
self.exam_1.url_name,
|
||||
self.field_data_cache
|
||||
)
|
||||
for toc_section in expected_locked_toc:
|
||||
self.assertIn(toc_section, locked_toc)
|
||||
|
||||
# Set up the chaos user
|
||||
# pylint: disable=maybe-no-member,no-member
|
||||
grade_dict = {'value': 1, 'max_value': 1, 'user_id': chaos_user.id}
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
self.course.id,
|
||||
chaos_user,
|
||||
self.course,
|
||||
depth=2
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
module = get_module(
|
||||
chaos_user,
|
||||
self.request,
|
||||
self.problem_1.scope_ids.usage_id,
|
||||
field_data_cache,
|
||||
)._xmodule
|
||||
module.system.publish(self.problem_1, 'grade', grade_dict)
|
||||
|
||||
# pylint: disable=maybe-no-member,no-member
|
||||
grade_dict = {'value': 1, 'max_value': 1, 'user_id': self.request.user.id}
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
self.course.id,
|
||||
self.request.user,
|
||||
self.course,
|
||||
depth=2
|
||||
)
|
||||
# pylint: disable=protected-access
|
||||
module = get_module(
|
||||
self.request.user,
|
||||
self.request,
|
||||
self.problem_1.scope_ids.usage_id,
|
||||
field_data_cache,
|
||||
)._xmodule
|
||||
module.system.publish(self.problem_1, 'grade', grade_dict)
|
||||
|
||||
module = get_module(
|
||||
self.request.user,
|
||||
self.request,
|
||||
self.problem_2.scope_ids.usage_id,
|
||||
field_data_cache,
|
||||
)._xmodule # pylint: disable=protected-access
|
||||
module.system.publish(self.problem_2, 'grade', grade_dict)
|
||||
|
||||
expected_unlocked_toc = (
|
||||
[
|
||||
{
|
||||
'active': False,
|
||||
'sections': [
|
||||
{
|
||||
'url_name': u'Welcome',
|
||||
'display_name': u'Welcome',
|
||||
'graded': False,
|
||||
'format': '',
|
||||
'due': None,
|
||||
'active': False
|
||||
},
|
||||
{
|
||||
'url_name': u'Lesson_1',
|
||||
'display_name': u'Lesson 1',
|
||||
'graded': False,
|
||||
'format': '',
|
||||
'due': None,
|
||||
'active': False
|
||||
}
|
||||
],
|
||||
'url_name': u'Overview',
|
||||
'display_name': u'Overview'
|
||||
},
|
||||
{
|
||||
'active': False,
|
||||
'sections': [],
|
||||
'url_name': u'Week_1',
|
||||
'display_name': u'Week 1'
|
||||
},
|
||||
{
|
||||
'active': False,
|
||||
'sections': [],
|
||||
'url_name': u'Instructor',
|
||||
'display_name': u'Instructor'
|
||||
},
|
||||
{
|
||||
'active': True,
|
||||
'sections': [
|
||||
{
|
||||
'url_name': u'Exam_Sequential_-_Subsection_1',
|
||||
'display_name': u'Exam Sequential - Subsection 1',
|
||||
'graded': True,
|
||||
'format': '',
|
||||
'due': None,
|
||||
'active': True
|
||||
}
|
||||
],
|
||||
'url_name': u'Entrance_Exam_Section_-_Chapter_1',
|
||||
'display_name': u'Entrance Exam Section - Chapter 1'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
unlocked_toc = toc_for_course(
|
||||
self.request,
|
||||
self.course,
|
||||
self.entrance_exam.url_name,
|
||||
self.exam_1.url_name,
|
||||
self.field_data_cache
|
||||
)
|
||||
|
||||
for toc_section in expected_unlocked_toc:
|
||||
self.assertIn(toc_section, unlocked_toc)
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user