SOL-1292
This commit is contained in:
@@ -164,35 +164,7 @@ def _create_entrance_exam(request, course_key, entrance_exam_minimum_score_pct=N
|
||||
category='sequential',
|
||||
display_name=_('Entrance Exam - Subsection')
|
||||
)
|
||||
|
||||
# Add an entrance exam milestone if one does not already exist
|
||||
namespace_choices = milestones_helpers.get_namespace_choices()
|
||||
milestone_namespace = milestones_helpers.generate_milestone_namespace(
|
||||
namespace_choices.get('ENTRANCE_EXAM'),
|
||||
course_key
|
||||
)
|
||||
milestones = milestones_helpers.get_milestones(milestone_namespace)
|
||||
if len(milestones):
|
||||
milestone = milestones[0]
|
||||
else:
|
||||
description = 'Autogenerated during {} entrance exam creation.'.format(unicode(course.id))
|
||||
milestone = milestones_helpers.add_milestone({
|
||||
'name': _('Completed Course Entrance Exam'),
|
||||
'namespace': milestone_namespace,
|
||||
'description': description
|
||||
})
|
||||
relationship_types = milestones_helpers.get_milestone_relationship_types()
|
||||
milestones_helpers.add_course_milestone(
|
||||
unicode(course.id),
|
||||
relationship_types['REQUIRES'],
|
||||
milestone
|
||||
)
|
||||
milestones_helpers.add_course_content_milestone(
|
||||
unicode(course.id),
|
||||
unicode(created_block.location),
|
||||
relationship_types['FULFILLS'],
|
||||
milestone
|
||||
)
|
||||
add_entrance_exam_milestone(course.id, created_block)
|
||||
|
||||
return HttpResponse(status=201)
|
||||
|
||||
@@ -250,14 +222,7 @@ def _delete_entrance_exam(request, 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_helpers.remove_content_references(unicode(course_child.scope_ids.usage_id))
|
||||
remove_entrance_exam_milestone_reference(request, course_key)
|
||||
|
||||
# Reset the entrance exam flags on the course
|
||||
# Reload the course so we have the latest state
|
||||
@@ -283,3 +248,50 @@ def _serialize_entrance_exam(entrance_exam_module):
|
||||
return json.dumps({
|
||||
'locator': unicode(entrance_exam_module.location)
|
||||
})
|
||||
|
||||
|
||||
def add_entrance_exam_milestone(course_id, x_block):
|
||||
# Add an entrance exam milestone if one does not already exist for given xBlock
|
||||
# As this is a standalone method for entrance exam, We should check that given xBlock should be an entrance exam.
|
||||
if x_block.is_entrance_exam:
|
||||
namespace_choices = milestones_helpers.get_namespace_choices()
|
||||
milestone_namespace = milestones_helpers.generate_milestone_namespace(
|
||||
namespace_choices.get('ENTRANCE_EXAM'),
|
||||
course_id
|
||||
)
|
||||
milestones = milestones_helpers.get_milestones(milestone_namespace)
|
||||
if len(milestones):
|
||||
milestone = milestones[0]
|
||||
else:
|
||||
description = 'Autogenerated during {} entrance exam creation.'.format(unicode(course_id))
|
||||
milestone = milestones_helpers.add_milestone({
|
||||
'name': _('Completed Course Entrance Exam'),
|
||||
'namespace': milestone_namespace,
|
||||
'description': description
|
||||
})
|
||||
relationship_types = milestones_helpers.get_milestone_relationship_types()
|
||||
milestones_helpers.add_course_milestone(
|
||||
unicode(course_id),
|
||||
relationship_types['REQUIRES'],
|
||||
milestone
|
||||
)
|
||||
milestones_helpers.add_course_content_milestone(
|
||||
unicode(course_id),
|
||||
unicode(x_block.location),
|
||||
relationship_types['FULFILLS'],
|
||||
milestone
|
||||
)
|
||||
|
||||
|
||||
def remove_entrance_exam_milestone_reference(request, course_key):
|
||||
"""
|
||||
Remove content reference for entrance exam.
|
||||
"""
|
||||
course_children = modulestore().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_helpers.remove_content_references(unicode(course_child.scope_ids.usage_id))
|
||||
|
||||
@@ -37,6 +37,11 @@ from student.auth import has_course_author_access
|
||||
from openedx.core.lib.extract_tar import safetar_extractall
|
||||
from util.json_request import JsonResponse
|
||||
from util.views import ensure_valid_course_key
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from contentstore.views.entrance_exam import (
|
||||
add_entrance_exam_milestone,
|
||||
remove_entrance_exam_milestone_reference
|
||||
)
|
||||
|
||||
from contentstore.utils import reverse_course_url, reverse_usage_url, reverse_library_url
|
||||
|
||||
@@ -110,6 +115,17 @@ def _import_handler(request, courselike_key, root_name, successful_url, context_
|
||||
session_status = request.session.setdefault("import_status", {})
|
||||
courselike_string = unicode(courselike_key) + filename
|
||||
_save_request_status(request, courselike_string, 0)
|
||||
|
||||
# If the course has an entrance exam then remove it and its corresponding milestone.
|
||||
# current course state before import.
|
||||
if root_name == COURSE_ROOT:
|
||||
if courselike_module.entrance_exam_enabled:
|
||||
remove_entrance_exam_milestone_reference(request, courselike_key)
|
||||
log.info(
|
||||
"entrance exam milestone content reference for course %s has been removed",
|
||||
courselike_module.id
|
||||
)
|
||||
|
||||
if not filename.endswith('.tar.gz'):
|
||||
_save_request_status(request, courselike_string, -1)
|
||||
return JsonResponse(
|
||||
@@ -300,6 +316,22 @@ def _import_handler(request, courselike_key, root_name, successful_url, context_
|
||||
if session_status[courselike_string] != 4:
|
||||
_save_request_status(request, courselike_string, -abs(session_status[courselike_string]))
|
||||
|
||||
# status == 4 represents that course has been imported successfully.
|
||||
if session_status[courselike_string] == 4 and root_name == COURSE_ROOT:
|
||||
# Reload the course so we have the latest state
|
||||
course = modulestore().get_course(courselike_key)
|
||||
if course.entrance_exam_enabled:
|
||||
entrance_exam_chapter = modulestore().get_items(
|
||||
course.id,
|
||||
qualifiers={'category': 'chapter'},
|
||||
settings={'is_entrance_exam': True}
|
||||
)[0]
|
||||
|
||||
metadata = {'entrance_exam_id': unicode(entrance_exam_chapter.location)}
|
||||
CourseMetadata.update_from_dict(metadata, course, request.user)
|
||||
add_entrance_exam_milestone(course.id, entrance_exam_chapter)
|
||||
log.info("Course %s Entrance exam imported", course.id)
|
||||
|
||||
return JsonResponse({'Status': 'OK'})
|
||||
elif request.method == 'GET': # assume html
|
||||
status_url = reverse_course_url(
|
||||
|
||||
@@ -10,7 +10,8 @@ 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, update_entrance_exam, delete_entrance_exam
|
||||
from contentstore.views.entrance_exam import create_entrance_exam, update_entrance_exam, delete_entrance_exam,\
|
||||
add_entrance_exam_milestone, remove_entrance_exam_milestone_reference
|
||||
from contentstore.views.helpers import GRADER_TYPES
|
||||
from models.settings.course_grading import CourseGradingModel
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
@@ -18,6 +19,7 @@ from opaque_keys.edx.keys import UsageKey
|
||||
from student.tests.factories import UserFactory
|
||||
from util import milestones_helpers
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from contentstore.views.helpers import create_xblock
|
||||
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True})
|
||||
@@ -37,6 +39,57 @@ class EntranceExamHandlerTests(CourseTestCase):
|
||||
milestones_helpers.seed_milestone_relationship_types()
|
||||
self.milestone_relationship_types = milestones_helpers.get_milestone_relationship_types()
|
||||
|
||||
def test_entrance_exam_milestone_addition(self):
|
||||
"""
|
||||
Unit Test: test addition of entrance exam milestone content
|
||||
"""
|
||||
parent_locator = unicode(self.course.location)
|
||||
created_block = create_xblock(
|
||||
parent_locator=parent_locator,
|
||||
user=self.user,
|
||||
category='chapter',
|
||||
display_name=('Entrance Exam'),
|
||||
is_entrance_exam=True
|
||||
)
|
||||
add_entrance_exam_milestone(self.course.id, created_block)
|
||||
content_milestones = milestones_helpers.get_course_content_milestones(
|
||||
unicode(self.course.id),
|
||||
unicode(created_block.location),
|
||||
self.milestone_relationship_types['FULFILLS']
|
||||
)
|
||||
self.assertTrue(len(content_milestones))
|
||||
self.assertEqual(len(milestones_helpers.get_course_milestones(self.course.id)), 1)
|
||||
|
||||
def test_entrance_exam_milestone_removal(self):
|
||||
"""
|
||||
Unit Test: test removal of entrance exam milestone content
|
||||
"""
|
||||
parent_locator = unicode(self.course.location)
|
||||
created_block = create_xblock(
|
||||
parent_locator=parent_locator,
|
||||
user=self.user,
|
||||
category='chapter',
|
||||
display_name=('Entrance Exam'),
|
||||
is_entrance_exam=True
|
||||
)
|
||||
add_entrance_exam_milestone(self.course.id, created_block)
|
||||
content_milestones = milestones_helpers.get_course_content_milestones(
|
||||
unicode(self.course.id),
|
||||
unicode(created_block.location),
|
||||
self.milestone_relationship_types['FULFILLS']
|
||||
)
|
||||
self.assertEqual(len(content_milestones), 1)
|
||||
user = UserFactory()
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
remove_entrance_exam_milestone_reference(request, self.course.id)
|
||||
content_milestones = milestones_helpers.get_course_content_milestones(
|
||||
unicode(self.course.id),
|
||||
unicode(created_block.location),
|
||||
self.milestone_relationship_types['FULFILLS']
|
||||
)
|
||||
self.assertEqual(len(content_milestones), 0)
|
||||
|
||||
def test_contentstore_views_entrance_exam_post(self):
|
||||
"""
|
||||
Unit Test: test_contentstore_views_entrance_exam_post
|
||||
|
||||
@@ -26,6 +26,10 @@ from contentstore.tests.utils import CourseTestCase
|
||||
from openedx.core.lib.extract_tar import safetar_extractall
|
||||
from student import auth
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole
|
||||
from util.milestones_helpers import seed_milestone_relationship_types
|
||||
from models.settings.course_metadata import CourseMetadata
|
||||
from util import milestones_helpers
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE)
|
||||
TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex
|
||||
@@ -34,6 +38,92 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class ImportEntranceExamTestCase(CourseTestCase):
|
||||
"""
|
||||
Unit tests for importing a course with entrance exam
|
||||
"""
|
||||
def setUp(self):
|
||||
super(ImportEntranceExamTestCase, self).setUp()
|
||||
self.url = reverse_course_url('import_handler', self.course.id)
|
||||
self.content_dir = path(tempfile.mkdtemp())
|
||||
self.addCleanup(shutil.rmtree, self.content_dir)
|
||||
|
||||
# Create tar test file -----------------------------------------------
|
||||
# OK course with entrance exam section:
|
||||
seed_milestone_relationship_types()
|
||||
entrance_exam_dir = tempfile.mkdtemp(dir=self.content_dir)
|
||||
# test course being deeper down than top of tar file
|
||||
embedded_exam_dir = os.path.join(entrance_exam_dir, "grandparent", "parent")
|
||||
os.makedirs(os.path.join(embedded_exam_dir, "course"))
|
||||
os.makedirs(os.path.join(embedded_exam_dir, "chapter"))
|
||||
with open(os.path.join(embedded_exam_dir, "course.xml"), "w+") as f:
|
||||
f.write('<course url_name="2013_Spring" org="EDx" course="0.00x"/>')
|
||||
|
||||
with open(os.path.join(embedded_exam_dir, "course", "2013_Spring.xml"), "w+") as f:
|
||||
f.write(
|
||||
'<course '
|
||||
'entrance_exam_enabled="true" entrance_exam_id="xyz" entrance_exam_minimum_score_pct="0.7">'
|
||||
'<chapter url_name="2015_chapter_entrance_exam"/></course>'
|
||||
)
|
||||
|
||||
with open(os.path.join(embedded_exam_dir, "chapter", "2015_chapter_entrance_exam.xml"), "w+") as f:
|
||||
f.write('<chapter display_name="Entrance Exam" in_entrance_exam="true" is_entrance_exam="true"></chapter>')
|
||||
|
||||
self.entrance_exam_tar = os.path.join(self.content_dir, "entrance_exam.tar.gz")
|
||||
with tarfile.open(self.entrance_exam_tar, "w:gz") as gtar:
|
||||
gtar.add(entrance_exam_dir)
|
||||
|
||||
def test_import_existing_entrance_exam_course(self):
|
||||
"""
|
||||
Check that course is imported successfully as an entrance exam.
|
||||
"""
|
||||
course = self.store.get_course(self.course.id)
|
||||
self.assertIsNotNone(course)
|
||||
self.assertEquals(course.entrance_exam_enabled, False)
|
||||
|
||||
with open(self.entrance_exam_tar) as gtar:
|
||||
args = {"name": self.entrance_exam_tar, "course-data": [gtar]}
|
||||
resp = self.client.post(self.url, args)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
course = self.store.get_course(self.course.id)
|
||||
self.assertIsNotNone(course)
|
||||
self.assertEquals(course.entrance_exam_enabled, True)
|
||||
self.assertEquals(course.entrance_exam_minimum_score_pct, 0.7)
|
||||
|
||||
def test_import_delete_pre_exiting_entrance_exam(self):
|
||||
"""
|
||||
Check that pre existed entrance exam content should be overwrite with the imported course.
|
||||
"""
|
||||
exam_url = '/course/{}/entrance_exam/'.format(unicode(self.course.id))
|
||||
resp = self.client.post(exam_url, {'entrance_exam_minimum_score_pct': 0.5}, http_accept='application/json')
|
||||
self.assertEqual(resp.status_code, 201)
|
||||
|
||||
# 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.assertEqual(metadata['entrance_exam_minimum_score_pct']['value'], 0.5)
|
||||
self.assertTrue(len(milestones_helpers.get_course_milestones(unicode(self.course.id))))
|
||||
content_milestones = milestones_helpers.get_course_content_milestones(
|
||||
unicode(self.course.id),
|
||||
metadata['entrance_exam_id']['value'],
|
||||
milestones_helpers.get_milestone_relationship_types()['FULFILLS']
|
||||
)
|
||||
self.assertTrue(len(content_milestones))
|
||||
|
||||
# Now import entrance exam course
|
||||
with open(self.entrance_exam_tar) as gtar:
|
||||
args = {"name": self.entrance_exam_tar, "course-data": [gtar]}
|
||||
resp = self.client.post(self.url, args)
|
||||
self.assertEquals(resp.status_code, 200)
|
||||
course = self.store.get_course(self.course.id)
|
||||
self.assertIsNotNone(course)
|
||||
self.assertEquals(course.entrance_exam_enabled, True)
|
||||
self.assertEquals(course.entrance_exam_minimum_score_pct, 0.7)
|
||||
|
||||
|
||||
@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE)
|
||||
class ImportTestCase(CourseTestCase):
|
||||
"""
|
||||
|
||||
@@ -1459,7 +1459,7 @@ class TestXBlockInfo(ItemTest):
|
||||
self.validate_course_xblock_info(json_response, course_outline=True)
|
||||
|
||||
@ddt.data(
|
||||
(ModuleStoreEnum.Type.split, 5, 5),
|
||||
(ModuleStoreEnum.Type.split, 4, 4),
|
||||
(ModuleStoreEnum.Type.mongo, 5, 7),
|
||||
)
|
||||
@ddt.unpack
|
||||
|
||||
@@ -108,9 +108,9 @@ class CountMongoCallsCourseTraversal(TestCase):
|
||||
# The line below shows the way this traversal *should* be done
|
||||
# (if you'll eventually access all the fields and load all the definitions anyway).
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, None, False, True, 4),
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, None, True, True, 143),
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, None, True, True, 41),
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, 0, False, True, 143),
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, 0, True, True, 143),
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, 0, True, True, 41),
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, None, False, False, 4),
|
||||
(MIXED_SPLIT_MODULESTORE_BUILDER, None, True, False, 4),
|
||||
# TODO: The call count below seems like a bug - should be 4?
|
||||
|
||||
@@ -52,7 +52,7 @@ class SequenceFields(object):
|
||||
"Note, you must enable Entrance Exams for this course setting to take effect."
|
||||
),
|
||||
default=False,
|
||||
scope=Scope.content,
|
||||
scope=Scope.settings,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ from ...pages.studio.import_export import ExportLibraryPage, ExportCoursePage, I
|
||||
from ...pages.studio.library import LibraryEditPage
|
||||
from ...pages.studio.container import ContainerPage
|
||||
from ...pages.studio.overview import CourseOutlinePage
|
||||
from ...pages.lms.courseware import CoursewarePage
|
||||
from ...pages.lms.staff_view import StaffPage
|
||||
|
||||
|
||||
class ExportTestMixin(object):
|
||||
@@ -269,6 +271,51 @@ class ImportTestMixin(object):
|
||||
self.import_page.wait_for_tasks(fail_on='Updating')
|
||||
|
||||
|
||||
class TestEntranceExamCourseImport(ImportTestMixin, StudioCourseTest):
|
||||
"""
|
||||
Tests the Course import page
|
||||
"""
|
||||
tarball_name = 'entrance_exam_course.2015.tar.gz'
|
||||
bad_tarball_name = 'bad_course.tar.gz'
|
||||
import_page_class = ImportCoursePage
|
||||
landing_page_class = CourseOutlinePage
|
||||
|
||||
def page_args(self):
|
||||
return [self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']]
|
||||
|
||||
def test_course_updated_with_entrance_exam(self):
|
||||
"""
|
||||
Given that I visit an empty course before import
|
||||
I should not see a section named 'Section' or 'Entrance Exam'
|
||||
When I visit the import page
|
||||
And I upload a course that has an entrance exam section named 'Entrance Exam'
|
||||
And I visit the course outline page again
|
||||
The section named 'Entrance Exam' should now be available.
|
||||
And when I switch the view mode to student view and Visit CourseWare
|
||||
Then I see one section in the sidebar that is 'Entrance Exam'
|
||||
"""
|
||||
self.landing_page.visit()
|
||||
# Should not exist yet.
|
||||
self.assertRaises(IndexError, self.landing_page.section, "Section")
|
||||
self.assertRaises(IndexError, self.landing_page.section, "Entrance Exam")
|
||||
self.import_page.visit()
|
||||
self.import_page.upload_tarball(self.tarball_name)
|
||||
self.import_page.wait_for_upload()
|
||||
self.landing_page.visit()
|
||||
# There should be two sections. 'Entrance Exam' and 'Section' on the landing page.
|
||||
self.landing_page.section("Entrance Exam")
|
||||
self.landing_page.section("Section")
|
||||
|
||||
self.landing_page.view_live()
|
||||
courseware = CoursewarePage(self.browser, self.course_id)
|
||||
courseware.wait_for_page()
|
||||
StaffPage(self.browser, self.course_id).set_staff_view_mode('Student')
|
||||
self.assertEqual(courseware.num_sections, 1)
|
||||
self.assertIn(
|
||||
"To access course materials, you must score", courseware.entrance_exam_message_selector.text[0]
|
||||
)
|
||||
|
||||
|
||||
class TestCourseImport(ImportTestMixin, StudioCourseTest):
|
||||
"""
|
||||
Tests the Course import page
|
||||
|
||||
BIN
common/test/data/imports/entrance_exam_course.2015.tar.gz
Normal file
BIN
common/test/data/imports/entrance_exam_course.2015.tar.gz
Normal file
Binary file not shown.
Reference in New Issue
Block a user