Files
edx-platform/lms/djangoapps/courseware/tests/test_module_render.py
David Ormsbee b3614f3a00 Merge pull request #20954 from open-craft/symbolist/html-block
HtmlModule to HtmlBlock [SE-1089]
2019-08-01 11:36:23 -04:00

2643 lines
101 KiB
Python

# -*- coding: utf-8 -*-
"""
Test for lms courseware app, module render unit
"""
from __future__ import absolute_import
import itertools
import json
from datetime import datetime
from functools import partial
import ddt
import pytz
import six
from bson import ObjectId
from completion import waffle as completion_waffle
from completion.models import BlockCompletion
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.http import Http404, HttpResponse
from django.middleware.csrf import get_token
from django.test.client import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
from edx_proctoring.api import create_exam, create_exam_attempt, update_attempt_status
from edx_proctoring.runtime import set_runtime_service
from edx_proctoring.tests.test_services import MockCertificateService, MockCreditService, MockGradesService
from freezegun import freeze_time
from milestones.tests.utils import MilestonesTestCaseMixin
from mock import MagicMock, Mock, patch
from opaque_keys.edx.asides import AsideUsageKeyV2
from opaque_keys.edx.keys import CourseKey, UsageKey
from pyquery import PyQuery
from six import text_type
from six.moves import range
from web_fragments.fragment import Fragment
from xblock.completable import CompletableXBlockMixin
from xblock.core import XBlock, XBlockAside
from xblock.field_data import FieldData
from xblock.fields import ScopeIds
from xblock.runtime import DictKeyValueStore, KvsFieldData, Runtime
from xblock.test.tools import TestRuntime
from capa.tests.response_xml_factory import OptionResponseXMLFactory
from course_modes.models import CourseMode
from courseware import module_render as render
from courseware.access_response import AccessResponse
from courseware.courses import get_course_info_section, get_course_with_access
from courseware.masquerade import CourseMasquerade
from courseware.model_data import FieldDataCache
from courseware.models import StudentModule
from courseware.module_render import get_module_for_descriptor, hash_resource
from courseware.tests.factories import GlobalStaffFactory, RequestFactoryNoCsrf, StudentModuleFactory, UserFactory
from courseware.tests.test_submitting_problems import TestSubmittingProblems
from courseware.tests.tests import LoginEnrollmentTestCase
from lms.djangoapps.courseware.field_overrides import OverrideFieldData
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from openedx.core.djangoapps.credit.api import set_credit_requirement_status, set_credit_requirements
from openedx.core.djangoapps.credit.models import CreditCourse
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.lib.courses import course_image_url
from openedx.core.lib.gating import api as gating_api
from openedx.core.lib.url_utils import quote_slashes
from student.models import CourseEnrollment, anonymous_id_for_user
from verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory
from xblock_django.models import XBlockConfiguration
from xmodule.capa_module import ProblemBlock
from xmodule.html_module import AboutBlock, CourseInfoBlock, HtmlBlock, StaticTabBlock
from xmodule.lti_module import LTIDescriptor
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_MIXED_MODULESTORE,
ModuleStoreTestCase,
SharedModuleStoreTestCase
)
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, ToyCourseFactory, check_mongo_calls
from xmodule.modulestore.tests.test_asides import AsideTestType
from xmodule.video_module import VideoBlock
from xmodule.x_module import STUDENT_VIEW, CombinedSystem, XModule, XModuleDescriptor
TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT
@XBlock.needs("field-data")
@XBlock.needs("i18n")
@XBlock.needs("fs")
@XBlock.needs("user")
@XBlock.needs("bookmarks")
class PureXBlock(XBlock):
"""
Pure XBlock to use in tests.
"""
pass
class EmptyXModule(XModule): # pylint: disable=abstract-method
"""
Empty XModule for testing with no dependencies.
"""
pass
class EmptyXModuleDescriptor(XModuleDescriptor): # pylint: disable=abstract-method
"""
Empty XModule for testing with no dependencies.
"""
module_class = EmptyXModule
class GradedStatelessXBlock(XBlock):
"""
This XBlock exists to test grade storage for blocks that don't store
student state in a scoped field.
"""
@XBlock.json_handler
def set_score(self, json_data, suffix): # pylint: disable=unused-argument
"""
Set the score for this testing XBlock.
"""
self.runtime.publish(
self,
'grade',
{
'value': json_data['grade'],
'max_value': 1
}
)
class StubCompletableXBlock(CompletableXBlockMixin):
"""
This XBlock exists to test completion storage.
"""
@XBlock.json_handler
def complete(self, json_data, suffix): # pylint: disable=unused-argument
"""
Mark the block's completion value using the completion API.
"""
return self.runtime.publish(
self,
'completion',
{'completion': json_data['completion']},
)
@XBlock.json_handler
def progress(self, json_data, suffix): # pylint: disable=unused-argument
"""
Mark the block as complete using the deprecated progress interface.
New code should use the completion event instead.
"""
return self.runtime.publish(self, 'progress', {})
class XBlockWithoutCompletionAPI(XBlock):
"""
This XBlock exists to test completion storage for xblocks
that don't support completion API but do emit progress signal.
"""
@XBlock.json_handler
def progress(self, json_data, suffix): # pylint: disable=unused-argument
"""
Mark the block as complete using the deprecated progress interface.
New code should use the completion event instead.
"""
return self.runtime.publish(self, 'progress', {})
@ddt.ddt
class ModuleRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Tests of courseware.module_render
"""
@classmethod
def setUpClass(cls):
super(ModuleRenderTestCase, cls).setUpClass()
cls.course_key = ToyCourseFactory.create().id
cls.toy_course = modulestore().get_course(cls.course_key)
# TODO: this test relies on the specific setup of the toy course.
# It should be rewritten to build the course it needs and then test that.
def setUp(self):
"""
Set up the course and user context
"""
super(ModuleRenderTestCase, self).setUp()
OverrideFieldData.provider_classes = None
self.mock_user = UserFactory()
self.mock_user.id = 1
self.request_factory = RequestFactoryNoCsrf()
# Construct a mock module for the modulestore to return
self.mock_module = MagicMock()
self.mock_module.id = 1
self.dispatch = 'score_update'
# Construct a 'standard' xqueue_callback url
self.callback_url = reverse(
'xqueue_callback',
kwargs=dict(
course_id=text_type(self.course_key),
userid=str(self.mock_user.id),
mod_id=self.mock_module.id,
dispatch=self.dispatch
)
)
def tearDown(self):
OverrideFieldData.provider_classes = None
super(ModuleRenderTestCase, self).tearDown()
def test_get_module(self):
self.assertEqual(
None,
render.get_module('dummyuser', None, 'invalid location', None)
)
def test_module_render_with_jump_to_id(self):
"""
This test validates that the /jump_to_id/<id> shorthand for intracourse linking works assertIn
expected. Note there's a HTML element in the 'toy' course with the url_name 'toyjumpto' which
defines this linkage
"""
mock_request = MagicMock()
mock_request.user = self.mock_user
course = get_course_with_access(self.mock_user, 'load', self.course_key)
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course_key, self.mock_user, course, depth=2)
module = render.get_module(
self.mock_user,
mock_request,
self.course_key.make_usage_key('html', 'toyjumpto'),
field_data_cache,
)
# get the rendered HTML output which should have the rewritten link
html = module.render(STUDENT_VIEW).content
# See if the url got rewritten to the target link
# note if the URL mapping changes then this assertion will break
self.assertIn('/courses/' + text_type(self.course_key) + '/jump_to_id/vertical_test', html)
def test_xqueue_callback_success(self):
"""
Test for happy-path xqueue_callback
"""
fake_key = 'fake key'
xqueue_header = json.dumps({'lms_key': fake_key})
data = {
'xqueue_header': xqueue_header,
'xqueue_body': 'hello world',
}
# Patch getmodule to return our mock module
with patch('courseware.module_render.load_single_xblock', return_value=self.mock_module):
# call xqueue_callback with our mocked information
request = self.request_factory.post(self.callback_url, data)
render.xqueue_callback(
request,
text_type(self.course_key),
self.mock_user.id,
self.mock_module.id,
self.dispatch
)
# Verify that handle ajax is called with the correct data
request.POST._mutable = True
request.POST['queuekey'] = fake_key
self.mock_module.handle_ajax.assert_called_once_with(self.dispatch, request.POST)
def test_xqueue_callback_missing_header_info(self):
data = {
'xqueue_header': '{}',
'xqueue_body': 'hello world',
}
with patch('courseware.module_render.load_single_xblock', return_value=self.mock_module):
# Test with missing xqueue data
with self.assertRaises(Http404):
request = self.request_factory.post(self.callback_url, {})
render.xqueue_callback(
request,
text_type(self.course_key),
self.mock_user.id,
self.mock_module.id,
self.dispatch
)
# Test with missing xqueue_header
with self.assertRaises(Http404):
request = self.request_factory.post(self.callback_url, data)
render.xqueue_callback(
request,
text_type(self.course_key),
self.mock_user.id,
self.mock_module.id,
self.dispatch
)
def _get_dispatch_url(self):
"""Helper to get dispatch URL for testing xblock callback."""
return reverse(
'xblock_handler',
args=[
text_type(self.course_key),
quote_slashes(text_type(self.course_key.make_usage_key('videosequence', 'Toy_Videos'))),
'xmodule_handler',
'goto_position'
]
)
def test_anonymous_get_xblock_callback(self):
"""Test that anonymous GET is allowed."""
dispatch_url = self._get_dispatch_url()
response = self.client.get(dispatch_url)
self.assertEquals(200, response.status_code)
def test_anonymous_post_xblock_callback(self):
"""Test that anonymous POST is not allowed."""
dispatch_url = self._get_dispatch_url()
response = self.client.post(dispatch_url, {'position': 2})
# https://openedx.atlassian.net/browse/LEARNER-7131
self.assertEquals('Unauthenticated', response.content)
self.assertEquals(403, response.status_code)
def test_session_authentication(self):
""" Test that the xblock endpoint supports session authentication."""
self.client.login(username=self.mock_user.username, password="test")
dispatch_url = self._get_dispatch_url()
response = self.client.post(dispatch_url)
self.assertEqual(200, response.status_code)
def test_oauth_authentication(self):
""" Test that the xblock endpoint supports OAuth authentication."""
dispatch_url = self._get_dispatch_url()
access_token = AccessTokenFactory(user=self.mock_user, client=ClientFactory()).token
headers = {'HTTP_AUTHORIZATION': 'Bearer ' + access_token}
response = self.client.post(dispatch_url, {}, **headers)
self.assertEqual(200, response.status_code)
def test_jwt_authentication(self):
""" Test that the xblock endpoint supports JWT authentication."""
dispatch_url = self._get_dispatch_url()
token = create_jwt_for_user(self.mock_user)
headers = {'HTTP_AUTHORIZATION': 'JWT ' + token}
response = self.client.post(dispatch_url, {}, **headers)
self.assertEqual(200, response.status_code)
def test_missing_position_handler(self):
"""
Test that sending POST request without or invalid position argument don't raise server error
"""
self.client.login(username=self.mock_user.username, password="test")
dispatch_url = self._get_dispatch_url()
response = self.client.post(dispatch_url)
self.assertEqual(200, response.status_code)
self.assertEqual(json.loads(response.content), {'success': True})
response = self.client.post(dispatch_url, {'position': ''})
self.assertEqual(200, response.status_code)
self.assertEqual(json.loads(response.content), {'success': True})
response = self.client.post(dispatch_url, {'position': '-1'})
self.assertEqual(200, response.status_code)
self.assertEqual(json.loads(response.content), {'success': True})
response = self.client.post(dispatch_url, {'position': "string"})
self.assertEqual(200, response.status_code)
self.assertEqual(json.loads(response.content), {'success': True})
response = self.client.post(dispatch_url, {'position': u"Φυσικά"})
self.assertEqual(200, response.status_code)
self.assertEqual(json.loads(response.content), {'success': True})
response = self.client.post(dispatch_url, {'position': None})
self.assertEqual(200, response.status_code)
self.assertEqual(json.loads(response.content), {'success': True})
@ddt.data('pure', 'vertical')
@XBlock.register_temp_plugin(PureXBlock, identifier='pure')
def test_rebinding_same_user(self, block_type):
request = self.request_factory.get('')
request.user = self.mock_user
course = CourseFactory()
descriptor = ItemFactory(category=block_type, parent=course)
field_data_cache = FieldDataCache([self.toy_course, descriptor], self.toy_course.id, self.mock_user)
# This is verifying that caching doesn't cause an error during get_module_for_descriptor, which
# is why it calls the method twice identically.
render.get_module_for_descriptor(
self.mock_user,
request,
descriptor,
field_data_cache,
self.toy_course.id,
course=self.toy_course
)
render.get_module_for_descriptor(
self.mock_user,
request,
descriptor,
field_data_cache,
self.toy_course.id,
course=self.toy_course
)
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
'lms.djangoapps.courseware.student_field_overrides.IndividualStudentOverrideProvider',
))
@patch('xmodule.modulestore.xml.ImportSystem.applicable_aside_types', lambda self, block: ['test_aside'])
@patch('xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types',
lambda self, block: ['test_aside'])
@XBlockAside.register_temp_plugin(AsideTestType, 'test_aside')
@ddt.data('regular', 'test_aside')
def test_rebind_different_users(self, block_category):
"""
This tests the rebinding a descriptor to a student does not result
in overly nested _field_data.
"""
def create_aside(item, block_type):
"""
Helper function to create aside
"""
key_store = DictKeyValueStore()
field_data = KvsFieldData(key_store)
runtime = TestRuntime(services={'field-data': field_data})
def_id = runtime.id_generator.create_definition(block_type)
usage_id = AsideUsageKeyV2(runtime.id_generator.create_usage(def_id), "aside")
aside = AsideTestType(scope_ids=ScopeIds('user', block_type, def_id, usage_id), runtime=runtime)
aside.content = '%s_new_value11' % block_type
aside.data_field = '%s_new_value12' % block_type
aside.has_score = False
modulestore().update_item(item, self.mock_user.id, asides=[aside])
return item
request = self.request_factory.get('')
request.user = self.mock_user
course = CourseFactory.create()
descriptor = ItemFactory(category="html", parent=course)
if block_category == 'test_aside':
descriptor = create_aside(descriptor, "test_aside")
field_data_cache = FieldDataCache(
[course, descriptor], course.id, self.mock_user
)
# grab what _field_data was originally set to
original_field_data = descriptor._field_data # pylint: disable=protected-access
render.get_module_for_descriptor(
self.mock_user, request, descriptor, field_data_cache, course.id, course=course
)
# check that _unwrapped_field_data is the same as the original
# _field_data, but now _field_data as been reset.
# pylint: disable=protected-access
self.assertIs(descriptor._unwrapped_field_data, original_field_data)
self.assertIsNot(descriptor._unwrapped_field_data, descriptor._field_data)
# now bind this module to a few other students
for user in [UserFactory(), UserFactory(), self.mock_user]:
render.get_module_for_descriptor(
user,
request,
descriptor,
field_data_cache,
course.id,
course=course
)
# _field_data should now be wrapped by LmsFieldData
# pylint: disable=protected-access
self.assertIsInstance(descriptor._field_data, LmsFieldData)
# the LmsFieldData should now wrap OverrideFieldData
self.assertIsInstance(
# pylint: disable=protected-access
descriptor._field_data._authored_data._source,
OverrideFieldData
)
# the OverrideFieldData should point to the original unwrapped field_data
self.assertIs(
# pylint: disable=protected-access
descriptor._field_data._authored_data._source.fallback,
descriptor._unwrapped_field_data
)
def test_hash_resource(self):
"""
Ensure that the resource hasher works and does not fail on unicode,
decoded or otherwise.
"""
resources = ['ASCII text', u'❄ I am a special snowflake.', "❄ So am I, but I didn't tell you."]
self.assertEqual(hash_resource(resources), 'a76e27c8e80ca3efd7ce743093aa59e0')
@ddt.ddt
class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test the handle_xblock_callback function
"""
@classmethod
def setUpClass(cls):
super(TestHandleXBlockCallback, cls).setUpClass()
cls.course_key = ToyCourseFactory.create().id
cls.toy_course = modulestore().get_course(cls.course_key)
def setUp(self):
super(TestHandleXBlockCallback, self).setUp()
self.location = self.course_key.make_usage_key('chapter', 'Overview')
self.mock_user = UserFactory.create()
self.request_factory = RequestFactoryNoCsrf()
# Construct a mock module for the modulestore to return
self.mock_module = MagicMock()
self.mock_module.id = 1
self.dispatch = 'score_update'
# Construct a 'standard' xqueue_callback url
self.callback_url = reverse(
'xqueue_callback', kwargs={
'course_id': text_type(self.course_key),
'userid': str(self.mock_user.id),
'mod_id': self.mock_module.id,
'dispatch': self.dispatch
}
)
def _mock_file(self, name='file', size=10):
"""Create a mock file object for testing uploads"""
mock_file = MagicMock(
size=size,
read=lambda: 'x' * size
)
# We can't use `name` as a kwarg to Mock to set the name attribute
# because mock uses `name` to name the mock itself
mock_file.name = name
return mock_file
def make_xblock_callback_response(self, request_data, course, block, handler):
"""
Prepares an xblock callback request and returns response to it.
"""
request = self.request_factory.post(
'/',
data=json.dumps(request_data),
content_type='application/json',
)
request.user = self.mock_user
response = render.handle_xblock_callback(
request,
text_type(course.id),
quote_slashes(text_type(block.scope_ids.usage_id)),
handler,
'',
)
return response
def test_invalid_csrf_token(self):
"""
Verify that invalid CSRF token is rejected.
"""
request = RequestFactory().post('dummy_url', data={'position': 1})
csrf_token = get_token(request)
request._post = {'csrfmiddlewaretoken': '{}-dummy'.format(csrf_token)} # pylint: disable=protected-access
request.user = self.mock_user
response = render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'xmodule_handler',
'goto_position',
)
self.assertEqual(403, response.status_code)
def test_valid_csrf_token(self):
"""
Verify that valid CSRF token is accepted.
"""
request = RequestFactory().post('dummy_url', data={'position': 1})
csrf_token = get_token(request)
request._post = {'csrfmiddlewaretoken': csrf_token} # pylint: disable=protected-access
request.user = self.mock_user
response = render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'xmodule_handler',
'goto_position',
)
self.assertEqual(200, response.status_code)
def test_invalid_location(self):
request = self.request_factory.post('dummy_url', data={'position': 1})
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
'invalid Location',
'dummy_handler'
'dummy_dispatch'
)
def test_too_many_files(self):
request = self.request_factory.post(
'dummy_url',
data={'file_id': (self._mock_file(), ) * (settings.MAX_FILEUPLOADS_PER_INPUT + 1)}
)
request.user = self.mock_user
self.assertEquals(
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'dummy_handler'
).content,
json.dumps({
'success': u'Submission aborted! Maximum %d files may be submitted at once' %
settings.MAX_FILEUPLOADS_PER_INPUT
}, indent=2)
)
def test_too_large_file(self):
inputfile = self._mock_file(size=1 + settings.STUDENT_FILEUPLOAD_MAX_SIZE)
request = self.request_factory.post(
'dummy_url',
data={'file_id': inputfile}
)
request.user = self.mock_user
self.assertEquals(
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'dummy_handler'
).content,
json.dumps({
'success': u'Submission aborted! Your file "%s" is too large (max size: %d MB)' %
(inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))
}, indent=2)
)
def test_xmodule_dispatch(self):
request = self.request_factory.post('dummy_url', data={'position': 1})
request.user = self.mock_user
response = render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'xmodule_handler',
'goto_position',
)
self.assertIsInstance(response, HttpResponse)
def test_bad_course_id(self):
request = self.request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
'bad_course_id',
quote_slashes(text_type(self.location)),
'xmodule_handler',
'goto_position',
)
def test_bad_location(self):
request = self.request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.course_key.make_usage_key('chapter', 'bad_location'))),
'xmodule_handler',
'goto_position',
)
def test_bad_xmodule_dispatch(self):
request = self.request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'xmodule_handler',
'bad_dispatch',
)
def test_missing_handler(self):
request = self.request_factory.post('dummy_url')
request.user = self.mock_user
with self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(self.course_key),
quote_slashes(text_type(self.location)),
'bad_handler',
'bad_dispatch',
)
@XBlock.register_temp_plugin(GradedStatelessXBlock, identifier='stateless_scorer')
def test_score_without_student_state(self):
course = CourseFactory.create()
block = ItemFactory.create(category='stateless_scorer', parent=course)
request = self.request_factory.post(
'dummy_url',
data=json.dumps({"grade": 0.75}),
content_type='application/json'
)
request.user = self.mock_user
response = render.handle_xblock_callback(
request,
text_type(course.id),
quote_slashes(text_type(block.scope_ids.usage_id)),
'set_score',
'',
)
self.assertEquals(response.status_code, 200)
student_module = StudentModule.objects.get(
student=self.mock_user,
module_state_key=block.scope_ids.usage_id,
)
self.assertEquals(student_module.grade, 0.75)
self.assertEquals(student_module.max_grade, 1)
@ddt.data(
('complete', {'completion': 0.625}),
('progress', {}),
)
@ddt.unpack
@XBlock.register_temp_plugin(StubCompletableXBlock, identifier='comp')
def test_completion_events_with_completion_disabled(self, signal, data):
with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, False):
course = CourseFactory.create()
block = ItemFactory.create(category='comp', parent=course)
request = self.request_factory.post(
'/',
data=json.dumps(data),
content_type='application/json',
)
request.user = self.mock_user
with patch('completion.models.BlockCompletionManager.submit_completion') as mock_complete:
render.handle_xblock_callback(
request,
text_type(course.id),
quote_slashes(text_type(block.scope_ids.usage_id)),
signal,
'',
)
mock_complete.assert_not_called()
self.assertFalse(BlockCompletion.objects.filter(block_key=block.scope_ids.usage_id).exists())
@XBlock.register_temp_plugin(StubCompletableXBlock, identifier='comp')
def test_completion_signal_for_completable_xblock(self):
with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, True):
course = CourseFactory.create()
block = ItemFactory.create(category='comp', parent=course)
response = self.make_xblock_callback_response(
{'completion': 0.625}, course, block, 'complete'
)
self.assertEqual(response.status_code, 200)
completion = BlockCompletion.objects.get(block_key=block.scope_ids.usage_id)
self.assertEqual(completion.completion, 0.625)
@XBlock.register_temp_plugin(StubCompletableXBlock, identifier='comp')
@ddt.data((True, True), (False, False),)
@ddt.unpack
def test_aside(self, is_xblock_aside, is_get_aside_called):
"""
test get_aside_from_xblock called
"""
course = CourseFactory.create()
block = ItemFactory.create(category='comp', parent=course)
request = self.request_factory.post(
'/',
data=json.dumps({'completion': 0.625}),
content_type='application/json',
)
request.user = self.mock_user
def get_usage_key():
"""return usage key"""
return (
quote_slashes(text_type(AsideUsageKeyV2(block.scope_ids.usage_id, "aside")))
if is_xblock_aside
else text_type(block.scope_ids.usage_id)
)
with patch(
'courseware.module_render.is_xblock_aside',
return_value=is_xblock_aside
), patch(
'courseware.module_render.get_aside_from_xblock'
) as mocked_get_aside_from_xblock, patch(
"courseware.module_render.webob_to_django_response"
) as mocked_webob_to_django_response:
render.handle_xblock_callback(
request,
text_type(course.id),
get_usage_key(),
'complete',
'',
)
assert mocked_webob_to_django_response.called is True
assert mocked_get_aside_from_xblock.called is is_get_aside_called
def test_aside_invalid_usage_id(self):
"""
test aside work when invalid usage id
"""
course = CourseFactory.create()
request = self.request_factory.post(
'/',
data=json.dumps({'completion': 0.625}),
content_type='application/json',
)
request.user = self.mock_user
with patch(
'courseware.module_render.is_xblock_aside',
return_value=True
), self.assertRaises(Http404):
render.handle_xblock_callback(
request,
text_type(course.id),
"foo@bar",
'complete',
'',
)
@XBlock.register_temp_plugin(StubCompletableXBlock, identifier='comp')
def test_progress_signal_ignored_for_completable_xblock(self):
with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, True):
course = CourseFactory.create()
block = ItemFactory.create(category='comp', parent=course)
response = self.make_xblock_callback_response(
{}, course, block, 'progress'
)
self.assertEqual(response.status_code, 200)
self.assertFalse(BlockCompletion.objects.filter(block_key=block.scope_ids.usage_id).exists())
@XBlock.register_temp_plugin(XBlockWithoutCompletionAPI, identifier='no_comp')
def test_progress_signal_processed_for_xblock_without_completion_api(self):
with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, True):
course = CourseFactory.create()
block = ItemFactory.create(category='no_comp', parent=course)
response = self.make_xblock_callback_response(
{}, course, block, 'progress'
)
self.assertEqual(response.status_code, 200)
completion = BlockCompletion.objects.get(block_key=block.scope_ids.usage_id)
self.assertEqual(completion.completion, 1.0)
@XBlock.register_temp_plugin(StubCompletableXBlock, identifier='comp')
def test_skip_handlers_for_masquerading_staff(self):
with completion_waffle.waffle().override(completion_waffle.ENABLE_COMPLETION_TRACKING, True):
course = CourseFactory.create()
block = ItemFactory.create(category='comp', parent=course)
request = self.request_factory.post(
'/',
data=json.dumps({'completion': 0.8}),
content_type='application/json',
)
request.user = self.mock_user
request.session = {}
request.user.real_user = GlobalStaffFactory.create()
request.user.real_user.masquerade_settings = CourseMasquerade(course.id, user_name="jem")
with patch('courseware.module_render.is_masquerading_as_specific_student') as mock_masq:
mock_masq.return_value = True
response = render.handle_xblock_callback(
request,
text_type(course.id),
quote_slashes(text_type(block.scope_ids.usage_id)),
'complete',
'',
)
mock_masq.assert_called()
self.assertEqual(response.status_code, 200)
with self.assertRaises(BlockCompletion.DoesNotExist):
BlockCompletion.objects.get(block_key=block.scope_ids.usage_id)
@XBlock.register_temp_plugin(GradedStatelessXBlock, identifier='stateless_scorer')
@patch('lms.djangoapps.courseware.module_render.grades_signals.SCORE_PUBLISHED.send')
def test_anonymous_user_not_be_graded(self, mock_score_signal):
course = CourseFactory.create()
descriptor_kwargs = {
'category': 'problem',
}
request = self.request_factory.get('/')
request.user = AnonymousUser()
descriptor = ItemFactory.create(**descriptor_kwargs)
render.handle_xblock_callback(
request,
text_type(course.id),
quote_slashes(text_type(descriptor.location)),
'xmodule_handler',
'problem_check',
)
self.assertFalse(mock_score_signal.called)
@ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_XBLOCK_VIEW_ENDPOINT': True})
class TestXBlockView(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test the handle_xblock_callback function
"""
@classmethod
def setUpClass(cls):
super(TestXBlockView, cls).setUpClass()
cls.course_key = ToyCourseFactory.create().id
cls.toy_course = modulestore().get_course(cls.course_key)
def setUp(self):
super(TestXBlockView, self).setUp()
self.location = text_type(self.course_key.make_usage_key('html', 'toyhtml'))
self.request_factory = RequestFactory()
self.view_args = [text_type(self.course_key), quote_slashes(self.location), 'student_view']
self.xblock_view_url = reverse('xblock_view', args=self.view_args)
def test_xblock_view_handler(self):
request = self.request_factory.get(self.xblock_view_url)
request.user = UserFactory.create()
response = render.xblock_view(request, *self.view_args)
self.assertEquals(200, response.status_code)
expected = ['csrf_token', 'html', 'resources']
content = json.loads(response.content)
for section in expected:
self.assertIn(section, content)
doc = PyQuery(content['html'])
self.assertEquals(len(doc('div.xblock-student_view-html')), 1)
@ddt.data(True, False)
def test_hide_staff_markup(self, hide):
"""
When xblock_view gets 'hide_staff_markup' in its context, the staff markup
should not be included. See 'add_staff_markup' in xblock_utils/__init__.py
"""
request = self.request_factory.get(self.xblock_view_url)
request.user = GlobalStaffFactory.create()
request.session = {}
if hide:
request.GET = {'hide_staff_markup': 'true'}
response = render.xblock_view(request, *self.view_args)
self.assertEquals(200, response.status_code)
html = json.loads(response.content)['html']
self.assertEqual('Staff Debug Info' in html, not hide)
def test_xblock_view_handler_not_authenticated(self):
request = self.request_factory.get(self.xblock_view_url)
request.user = AnonymousUser()
response = render.xblock_view(request, *self.view_args)
self.assertEquals(401, response.status_code)
@ddt.ddt
class TestTOC(ModuleStoreTestCase):
"""Check the Table of Contents for a course"""
def setup_request_and_course(self, num_finds, num_sends):
"""
Sets up the toy course in the modulestore and the request object.
"""
self.course_key = ToyCourseFactory.create().id # pylint: disable=attribute-defined-outside-init
self.chapter = 'Overview'
chapter_url = '%s/%s/%s' % ('/courses', self.course_key, self.chapter)
factory = RequestFactoryNoCsrf()
self.request = factory.get(chapter_url)
self.request.user = UserFactory()
self.modulestore = self.store._get_modulestore_for_courselike(self.course_key) # pylint: disable=protected-access, attribute-defined-outside-init
with self.modulestore.bulk_operations(self.course_key):
with check_mongo_calls(num_finds, num_sends):
self.toy_course = self.store.get_course(self.course_key, depth=2) # pylint: disable=attribute-defined-outside-init
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course_key, self.request.user, self.toy_course, depth=2
)
# Mongo makes 3 queries to load the course to depth 2:
# - 1 for the course
# - 1 for its children
# - 1 for its grandchildren
# Split makes 2 queries to load the course to depth 2:
# - 1 for the structure
# - 1 for 5 definitions
# Split makes 1 query to render the toc:
# - 1 for the active version at the start of the bulk operation
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 2, 0, 1))
@ddt.unpack
def test_toc_toy_from_chapter(self, default_ms, setup_finds, setup_sends, toc_finds):
with self.store.default_store(default_ms):
self.setup_request_and_course(setup_finds, setup_sends)
expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
'format': u'Lecture Sequence', 'due': None, 'active': False},
{'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True,
'format': '', 'due': None, 'active': False},
{'url_name': 'video_123456789012', 'display_name': 'Test Video', 'graded': True,
'format': '', 'due': None, 'active': False},
{'url_name': 'video_4f66f493ac8f', 'display_name': 'Video', 'graded': True,
'format': '', 'due': None, 'active': False}],
'url_name': 'Overview', 'display_name': u'Overview', 'display_id': u'overview'},
{'active': False, 'sections':
[{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True,
'format': '', 'due': None, 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic', 'display_id': 'secretmagic'}])
course = self.store.get_course(self.toy_course.id, depth=2)
with check_mongo_calls(toc_finds):
actual = render.toc_for_course(
self.request.user, self.request, course, self.chapter, None, self.field_data_cache
)
for toc_section in expected:
self.assertIn(toc_section, actual['chapters'])
self.assertIsNone(actual['previous_of_active_section'])
self.assertIsNone(actual['next_of_active_section'])
# Mongo makes 3 queries to load the course to depth 2:
# - 1 for the course
# - 1 for its children
# - 1 for its grandchildren
# Split makes 2 queries to load the course to depth 2:
# - 1 for the structure
# - 1 for 5 definitions
# Split makes 1 query to render the toc:
# - 1 for the active version at the start of the bulk operation
@ddt.data((ModuleStoreEnum.Type.mongo, 3, 0, 0), (ModuleStoreEnum.Type.split, 2, 0, 1))
@ddt.unpack
def test_toc_toy_from_section(self, default_ms, setup_finds, setup_sends, toc_finds):
with self.store.default_store(default_ms):
self.setup_request_and_course(setup_finds, setup_sends)
section = 'Welcome'
expected = ([{'active': True, 'sections':
[{'url_name': 'Toy_Videos', 'display_name': u'Toy Videos', 'graded': True,
'format': u'Lecture Sequence', 'due': None, 'active': False},
{'url_name': 'Welcome', 'display_name': u'Welcome', 'graded': True,
'format': '', 'due': None, 'active': True},
{'url_name': 'video_123456789012', 'display_name': 'Test Video', 'graded': True,
'format': '', 'due': None, 'active': False},
{'url_name': 'video_4f66f493ac8f', 'display_name': 'Video', 'graded': True,
'format': '', 'due': None, 'active': False}],
'url_name': 'Overview', 'display_name': u'Overview', 'display_id': u'overview'},
{'active': False, 'sections':
[{'url_name': 'toyvideo', 'display_name': 'toyvideo', 'graded': True,
'format': '', 'due': None, 'active': False}],
'url_name': 'secret:magic', 'display_name': 'secret:magic', 'display_id': 'secretmagic'}])
with check_mongo_calls(toc_finds):
actual = render.toc_for_course(
self.request.user, self.request, self.toy_course, self.chapter, section, self.field_data_cache
)
for toc_section in expected:
self.assertIn(toc_section, actual['chapters'])
self.assertEquals(actual['previous_of_active_section']['url_name'], 'Toy_Videos')
self.assertEquals(actual['next_of_active_section']['url_name'], 'video_123456789012')
@ddt.ddt
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
class TestProctoringRendering(SharedModuleStoreTestCase):
"""Check the Table of Contents for a course"""
@classmethod
def setUpClass(cls):
super(TestProctoringRendering, cls).setUpClass()
cls.course_key = ToyCourseFactory.create().id
def setUp(self):
"""
Set up the initial mongo datastores
"""
super(TestProctoringRendering, self).setUp()
self.chapter = 'Overview'
chapter_url = '%s/%s/%s' % ('/courses', self.course_key, self.chapter)
factory = RequestFactoryNoCsrf()
self.request = factory.get(chapter_url)
self.request.user = UserFactory.create()
self.user = UserFactory.create()
SoftwareSecurePhotoVerificationFactory.create(user=self.request.user)
self.modulestore = self.store._get_modulestore_for_courselike(self.course_key) # pylint: disable=protected-access
with self.modulestore.bulk_operations(self.course_key):
self.toy_course = self.store.get_course(self.course_key, depth=2)
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course_key, self.request.user, self.toy_course, depth=2
)
@ddt.data(
(CourseMode.DEFAULT_MODE_SLUG, False, None, None),
(
CourseMode.DEFAULT_MODE_SLUG,
True,
'eligible',
{
'status': 'eligible',
'short_description': 'Ungraded Practice Exam',
'suggested_icon': '',
'in_completed_state': False
}
),
(
CourseMode.DEFAULT_MODE_SLUG,
True,
'submitted',
{
'status': 'submitted',
'short_description': 'Practice Exam Completed',
'suggested_icon': 'fa-check',
'in_completed_state': True
}
),
(
CourseMode.DEFAULT_MODE_SLUG,
True,
'error',
{
'status': 'error',
'short_description': 'Practice Exam Failed',
'suggested_icon': 'fa-exclamation-triangle',
'in_completed_state': True
}
),
(
CourseMode.VERIFIED,
False,
None,
{
'status': 'eligible',
'short_description': 'Proctored Option Available',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
(
CourseMode.VERIFIED,
False,
'declined',
{
'status': 'declined',
'short_description': 'Taking As Open Exam',
'suggested_icon': 'fa-pencil-square-o',
'in_completed_state': False
}
),
(
CourseMode.VERIFIED,
False,
'submitted',
{
'status': 'submitted',
'short_description': 'Pending Session Review',
'suggested_icon': 'fa-spinner fa-spin',
'in_completed_state': True
}
),
(
CourseMode.VERIFIED,
False,
'verified',
{
'status': 'verified',
'short_description': 'Passed Proctoring',
'suggested_icon': 'fa-check',
'in_completed_state': True
}
),
(
CourseMode.VERIFIED,
False,
'rejected',
{
'status': 'rejected',
'short_description': 'Failed Proctoring',
'suggested_icon': 'fa-exclamation-triangle',
'in_completed_state': True
}
),
(
CourseMode.VERIFIED,
False,
'error',
{
'status': 'error',
'short_description': 'Failed Proctoring',
'suggested_icon': 'fa-exclamation-triangle',
'in_completed_state': True
}
),
)
@ddt.unpack
def test_proctored_exam_toc(self, enrollment_mode, is_practice_exam,
attempt_status, expected):
"""
Generate TOC for a course with a single chapter/sequence which contains proctored exam
"""
self._setup_test_data(enrollment_mode, is_practice_exam, attempt_status)
actual = render.toc_for_course(
self.request.user,
self.request,
self.toy_course,
self.chapter,
'Toy_Videos',
self.field_data_cache
)
section_actual = self._find_section(actual['chapters'], 'Overview', 'Toy_Videos')
if expected:
self.assertIn(expected, [section_actual['proctoring']])
else:
# we expect there not to be a 'proctoring' key in the dict
self.assertNotIn('proctoring', section_actual)
self.assertIsNone(actual['previous_of_active_section'])
self.assertEquals(actual['next_of_active_section']['url_name'], u"Welcome")
@ddt.data(
(
CourseMode.DEFAULT_MODE_SLUG,
True,
None,
'Try a proctored exam',
True
),
(
CourseMode.DEFAULT_MODE_SLUG,
True,
'submitted',
'You have submitted this practice proctored exam',
False
),
(
CourseMode.DEFAULT_MODE_SLUG,
True,
'error',
'There was a problem with your practice proctoring session',
True
),
(
CourseMode.VERIFIED,
False,
None,
'This exam is proctored',
False
),
(
CourseMode.VERIFIED,
False,
'submitted',
'You have submitted this proctored exam for review',
True
),
(
CourseMode.VERIFIED,
False,
'verified',
'Your proctoring session was reviewed successfully',
False
),
(
CourseMode.VERIFIED,
False,
'rejected',
'Your proctoring session was reviewed, but did not pass all requirements',
True
),
(
CourseMode.VERIFIED,
False,
'error',
'A technical error has occurred with your proctored exam',
False
),
)
@ddt.unpack
def test_render_proctored_exam(self, enrollment_mode, is_practice_exam,
attempt_status, expected, with_credit_context):
"""
Verifies gated content from the student view rendering of a sequence
this is labeled as a proctored exam
"""
usage_key = self._setup_test_data(enrollment_mode, is_practice_exam, attempt_status)
# initialize some credit requirements, if so then specify
if with_credit_context:
credit_course = CreditCourse(course_key=self.course_key, enabled=True)
credit_course.save()
set_credit_requirements(
self.course_key,
[
{
'namespace': 'reverification',
'name': 'reverification-1',
'display_name': 'ICRV1',
'criteria': {},
},
{
'namespace': 'proctored-exam',
'name': 'Exam1',
'display_name': 'A Proctored Exam',
'criteria': {}
}
]
)
set_credit_requirement_status(
self.request.user,
self.course_key,
'reverification',
'ICRV1'
)
module = render.get_module(
self.request.user,
self.request,
usage_key,
self.field_data_cache,
wrap_xmodule_display=True,
)
content = module.render(STUDENT_VIEW).content
self.assertIn(expected, content)
def _setup_test_data(self, enrollment_mode, is_practice_exam, attempt_status):
"""
Helper method to consolidate some courseware/proctoring/credit
test harness data
"""
usage_key = self.course_key.make_usage_key('videosequence', 'Toy_Videos')
sequence = self.modulestore.get_item(usage_key)
sequence.is_time_limited = True
sequence.is_proctored_exam = True
sequence.is_practice_exam = is_practice_exam
self.modulestore.update_item(sequence, self.user.id)
self.toy_course = self.modulestore.get_course(self.course_key)
# refresh cache after update
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course_key, self.request.user, self.toy_course, depth=2
)
set_runtime_service(
'credit',
MockCreditService(enrollment_mode=enrollment_mode)
)
CourseEnrollment.enroll(self.request.user, self.course_key, mode=enrollment_mode)
set_runtime_service(
'grades',
MockGradesService()
)
set_runtime_service(
'certificates',
MockCertificateService()
)
exam_id = create_exam(
course_id=text_type(self.course_key),
content_id=text_type(sequence.location),
exam_name='foo',
time_limit_mins=10,
is_proctored=True,
is_practice_exam=is_practice_exam
)
if attempt_status:
create_exam_attempt(exam_id, self.request.user.id, taking_as_proctored=True)
update_attempt_status(exam_id, self.request.user.id, attempt_status)
return usage_key
def _find_url_name(self, toc, url_name):
"""
Helper to return the dict TOC section associated with a Chapter of url_name
"""
for entry in toc:
if entry['url_name'] == url_name:
return entry
return None
def _find_section(self, toc, chapter_url_name, section_url_name):
"""
Helper to return the dict TOC section associated with a section of url_name
"""
chapter = self._find_url_name(toc, chapter_url_name)
if chapter:
return self._find_url_name(chapter['sections'], section_url_name)
return None
class TestGatedSubsectionRendering(SharedModuleStoreTestCase, MilestonesTestCaseMixin):
"""
Test the toc for a course is rendered correctly when there is gated content
"""
@classmethod
def setUpClass(cls):
super(TestGatedSubsectionRendering, cls).setUpClass()
cls.course = CourseFactory.create()
cls.course.enable_subsection_gating = True
cls.course.save()
cls.store.update_item(cls.course, 0)
def setUp(self):
"""
Set up the initial test data
"""
super(TestGatedSubsectionRendering, self).setUp()
self.chapter = ItemFactory.create(
parent=self.course,
category="chapter",
display_name="Chapter"
)
self.open_seq = ItemFactory.create(
parent=self.chapter,
category='sequential',
display_name="Open Sequential"
)
self.gated_seq = ItemFactory.create(
parent=self.chapter,
category='sequential',
display_name="Gated Sequential"
)
self.request = RequestFactoryNoCsrf().get('%s/%s/%s' % ('/courses', self.course.id, self.chapter.display_name))
self.request.user = UserFactory()
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id, self.request.user, self.course, depth=2
)
gating_api.add_prerequisite(self.course.id, self.open_seq.location)
gating_api.set_required_content(self.course.id, self.gated_seq.location, self.open_seq.location, 100)
def _find_url_name(self, toc, url_name):
"""
Helper to return the TOC section associated with url_name
"""
for entry in toc:
if entry['url_name'] == url_name:
return entry
return None
def _find_sequential(self, toc, chapter_url_name, sequential_url_name):
"""
Helper to return the sequential associated with sequential_url_name
"""
chapter = self._find_url_name(toc, chapter_url_name)
if chapter:
return self._find_url_name(chapter['sections'], sequential_url_name)
return None
def test_toc_with_gated_sequential(self):
"""
Test generation of TOC for a course with a gated subsection
"""
actual = render.toc_for_course(
self.request.user,
self.request,
self.course,
self.chapter.display_name,
self.open_seq.display_name,
self.field_data_cache
)
self.assertIsNotNone(self._find_sequential(actual['chapters'], 'Chapter', 'Open_Sequential'))
self.assertIsNotNone(self._find_sequential(actual['chapters'], 'Chapter', 'Gated_Sequential'))
self.assertIsNone(self._find_sequential(actual['chapters'], 'Non-existent_Chapter', 'Non-existent_Sequential'))
self.assertIsNone(actual['previous_of_active_section'])
self.assertIsNone(actual['next_of_active_section'])
@ddt.ddt
class TestHtmlModifiers(ModuleStoreTestCase):
"""
Tests to verify that standard modifications to the output of XModule/XBlock
student_view are taking place
"""
def setUp(self):
super(TestHtmlModifiers, self).setUp()
self.course = CourseFactory.create()
self.request = RequestFactoryNoCsrf().get('/')
self.request.user = self.user
self.request.session = {}
self.content_string = '<p>This is the content<p>'
self.rewrite_link = '<a href="/static/foo/content">Test rewrite</a>'
self.rewrite_bad_link = '<img src="/static//file.jpg" />'
self.course_link = '<a href="/course/bar/content">Test course rewrite</a>'
self.descriptor = ItemFactory.create(
category='html',
data=self.content_string + self.rewrite_link + self.rewrite_bad_link + self.course_link
)
self.location = self.descriptor.location
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id,
self.user,
self.descriptor
)
def test_xmodule_display_wrapper_enabled(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
wrap_xmodule_display=True,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertEquals(len(PyQuery(result_fragment.content)('div.xblock.xblock-student_view.xmodule_HtmlBlock')), 1)
def test_xmodule_display_wrapper_disabled(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
wrap_xmodule_display=False,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertNotIn('div class="xblock xblock-student_view xmodule_display xmodule_HtmlBlock"',
result_fragment.content)
def test_static_link_rewrite(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertIn(
'/c4x/{org}/{course}/asset/foo_content'.format(
org=self.course.location.org,
course=self.course.location.course,
),
result_fragment.content
)
def test_static_badlink_rewrite(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertIn(
'/c4x/{org}/{course}/asset/file.jpg'.format(
org=self.course.location.org,
course=self.course.location.course,
),
result_fragment.content
)
def test_static_asset_path_use(self):
'''
when a course is loaded with do_import_static=False (see xml_importer.py), then
static_asset_path is set as an lms kv in course. That should make static paths
not be mangled (ie not changed to c4x://).
'''
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
static_asset_path="toy_course_dir",
)
result_fragment = module.render(STUDENT_VIEW)
self.assertIn('href="/static/toy_course_dir', result_fragment.content)
def test_course_image(self):
url = course_image_url(self.course)
self.assertTrue(url.startswith('/c4x/'))
self.course.static_asset_path = "toy_course_dir"
url = course_image_url(self.course)
self.assertTrue(url.startswith('/static/toy_course_dir/'))
self.course.static_asset_path = ""
@override_settings(DEFAULT_COURSE_ABOUT_IMAGE_URL='test.png')
@override_settings(STATIC_URL='static/')
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_course_image_for_split_course(self, store):
"""
for split courses if course_image is empty then course_image_url will be
the default image url defined in settings
"""
self.course = CourseFactory.create(default_store=store)
self.course.course_image = ''
url = course_image_url(self.course)
self.assertEqual('static/test.png', url)
def test_get_course_info_section(self):
self.course.static_asset_path = "toy_course_dir"
get_course_info_section(self.request, self.request.user, self.course, "handouts")
# NOTE: check handouts output...right now test course seems to have no such content
# at least this makes sure get_course_info_section returns without exception
def test_course_link_rewrite(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertIn(
'/courses/{course_id}/bar/content'.format(
course_id=text_type(self.course.id)
),
result_fragment.content
)
class XBlockWithJsonInitData(XBlock):
"""
Pure XBlock to use in tests, with JSON init data.
"""
the_json_data = None
def student_view(self, context=None): # pylint: disable=unused-argument
"""
A simple view that returns just enough to test.
"""
frag = Fragment(u"Hello there!")
frag.add_javascript(u'alert("Hi!");')
frag.initialize_js('ThumbsBlock', self.the_json_data)
return frag
@ddt.ddt
class JsonInitDataTest(ModuleStoreTestCase):
"""Tests for JSON data injected into the JS init function."""
@ddt.data(
({'a': 17}, '''{"a": 17}'''),
({'xss': '</script>alert("XSS")'}, r'''{"xss": "\u003c/script\u003ealert(\"XSS\")"}'''),
)
@ddt.unpack
@XBlock.register_temp_plugin(XBlockWithJsonInitData, identifier='withjson')
def test_json_init_data(self, json_data, json_output):
XBlockWithJsonInitData.the_json_data = json_data
mock_user = UserFactory()
mock_request = MagicMock()
mock_request.user = mock_user
course = CourseFactory()
descriptor = ItemFactory(category='withjson', parent=course)
field_data_cache = FieldDataCache([course, descriptor], course.id, mock_user)
module = render.get_module_for_descriptor(
mock_user,
mock_request,
descriptor,
field_data_cache,
course.id,
course=course
)
html = module.render(STUDENT_VIEW).content
self.assertIn(json_output, html)
# No matter what data goes in, there should only be one close-script tag.
self.assertEqual(html.count("</script>"), 1)
class ViewInStudioTest(ModuleStoreTestCase):
"""Tests for the 'View in Studio' link visiblity."""
def setUp(self):
""" Set up the user and request that will be used. """
super(ViewInStudioTest, self).setUp()
self.staff_user = GlobalStaffFactory.create()
self.request = RequestFactoryNoCsrf().get('/')
self.request.user = self.staff_user
self.request.session = {}
self.module = None
self.default_context = {'bookmarked': False, 'username': self.user.username}
def _get_module(self, course_id, descriptor, location):
"""
Get the module from the course from which to pattern match (or not) the 'View in Studio' buttons
"""
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_id,
self.staff_user,
descriptor
)
return render.get_module(
self.staff_user,
self.request,
location,
field_data_cache,
)
def setup_mongo_course(self, course_edit_method='Studio'):
""" Create a mongo backed course. """
course = CourseFactory.create(
course_edit_method=course_edit_method
)
descriptor = ItemFactory.create(
category='vertical',
parent_location=course.location,
)
child_descriptor = ItemFactory.create(
category='vertical',
parent_location=descriptor.location
)
self.module = self._get_module(course.id, descriptor, descriptor.location)
# pylint: disable=attribute-defined-outside-init
self.child_module = self._get_module(course.id, child_descriptor, child_descriptor.location)
class MongoViewInStudioTest(ViewInStudioTest):
"""Test the 'View in Studio' link visibility in a mongo backed course."""
def test_view_in_studio_link_studio_course(self):
"""Regular Studio courses should see 'View in Studio' links."""
self.setup_mongo_course()
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_only_in_top_level_vertical(self):
"""Regular Studio courses should not see 'View in Studio' for child verticals of verticals."""
self.setup_mongo_course()
# Render the parent vertical, then check that there is only a single "View Unit in Studio" link.
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
# The single "View Unit in Studio" link should appear before the first xmodule vertical definition.
parts = result_fragment.content.split('data-block-type="vertical"')
self.assertEqual(3, len(parts), "Did not find two vertical blocks")
self.assertIn('View Unit in Studio', parts[0])
self.assertNotIn('View Unit in Studio', parts[1])
self.assertNotIn('View Unit in Studio', parts[2])
def test_view_in_studio_link_xml_authored(self):
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
self.setup_mongo_course(course_edit_method='XML')
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content)
class MixedViewInStudioTest(ViewInStudioTest):
"""Test the 'View in Studio' link visibility in a mixed mongo backed course."""
MODULESTORE = TEST_DATA_MIXED_MODULESTORE
def test_view_in_studio_link_mongo_backed(self):
"""Mixed mongo courses that are mongo backed should see 'View in Studio' links."""
self.setup_mongo_course()
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertIn('View Unit in Studio', result_fragment.content)
def test_view_in_studio_link_xml_authored(self):
"""Courses that change 'course_edit_method' setting can hide 'View in Studio' links."""
self.setup_mongo_course(course_edit_method='XML')
result_fragment = self.module.render(STUDENT_VIEW, context=self.default_context)
self.assertNotIn('View Unit in Studio', result_fragment.content)
@XBlock.tag("detached")
class DetachedXBlock(XBlock):
"""
XBlock marked with the 'detached' flag.
"""
def student_view(self, context=None): # pylint: disable=unused-argument
"""
A simple view that returns just enough to test.
"""
frag = Fragment(u"Hello there!")
return frag
@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_DEBUG_INFO_TO_STAFF': True, 'DISPLAY_HISTOGRAMS_TO_STAFF': True})
@patch('courseware.module_render.has_access', Mock(return_value=True, autospec=True))
class TestStaffDebugInfo(SharedModuleStoreTestCase):
"""Tests to verify that Staff Debug Info panel and histograms are displayed to staff."""
@classmethod
def setUpClass(cls):
super(TestStaffDebugInfo, cls).setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super(TestStaffDebugInfo, self).setUp()
self.user = UserFactory.create()
self.request = RequestFactoryNoCsrf().get('/')
self.request.user = self.user
self.request.session = {}
problem_xml = OptionResponseXMLFactory().build_xml(
question_text='The correct answer is Correct',
num_inputs=2,
weight=2,
options=['Correct', 'Incorrect'],
correct_option='Correct'
)
self.descriptor = ItemFactory.create(
category='problem',
data=problem_xml,
display_name='Option Response Problem'
)
self.location = self.descriptor.location
self.field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id,
self.user,
self.descriptor
)
@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_DEBUG_INFO_TO_STAFF': False})
def test_staff_debug_info_disabled(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertNotIn('Staff Debug', result_fragment.content)
def test_staff_debug_info_enabled(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertIn('Staff Debug', result_fragment.content)
@XBlock.register_temp_plugin(DetachedXBlock, identifier='detached-block')
def test_staff_debug_info_disabled_for_detached_blocks(self):
"""Staff markup should not be present on detached blocks."""
descriptor = ItemFactory.create(
category='detached-block',
display_name='Detached Block'
)
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id,
self.user,
descriptor
)
module = render.get_module(
self.user,
self.request,
descriptor.location,
field_data_cache,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertNotIn('Staff Debug', result_fragment.content)
@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_HISTOGRAMS_TO_STAFF': False})
def test_histogram_disabled(self):
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
)
result_fragment = module.render(STUDENT_VIEW)
self.assertNotIn('histrogram', result_fragment.content)
def test_histogram_enabled_for_unscored_xmodules(self):
"""Histograms should not display for xmodules which are not scored."""
html_descriptor = ItemFactory.create(
category='html',
data='Here are some course details.'
)
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id,
self.user,
self.descriptor
)
with patch('openedx.core.lib.xblock_utils.grade_histogram') as mock_grade_histogram:
mock_grade_histogram.return_value = []
module = render.get_module(
self.user,
self.request,
html_descriptor.location,
field_data_cache,
)
module.render(STUDENT_VIEW)
self.assertFalse(mock_grade_histogram.called)
def test_histogram_enabled_for_scored_xmodules(self):
"""Histograms should display for xmodules which are scored."""
StudentModuleFactory.create(
course_id=self.course.id,
module_state_key=self.location,
student=UserFactory(),
grade=1,
max_grade=1,
state="{}",
)
with patch('openedx.core.lib.xblock_utils.grade_histogram') as mock_grade_histogram:
mock_grade_histogram.return_value = []
module = render.get_module(
self.user,
self.request,
self.location,
self.field_data_cache,
)
module.render(STUDENT_VIEW)
self.assertTrue(mock_grade_histogram.called)
PER_COURSE_ANONYMIZED_DESCRIPTORS = (LTIDescriptor, )
PER_STUDENT_ANONYMIZED_XBLOCKS = [
AboutBlock,
CourseInfoBlock,
HtmlBlock,
ProblemBlock,
StaticTabBlock,
VideoBlock,
]
# The "set" here is to work around the bug that load_classes returns duplicates for multiply-declared classes.
PER_STUDENT_ANONYMIZED_DESCRIPTORS = sorted(set([
class_ for (name, class_) in XModuleDescriptor.load_classes()
if not issubclass(class_, PER_COURSE_ANONYMIZED_DESCRIPTORS)
] + PER_STUDENT_ANONYMIZED_XBLOCKS), key=str)
@ddt.ddt
class TestAnonymousStudentId(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Test that anonymous_student_id is set correctly across a variety of XBlock types
"""
@classmethod
def setUpClass(cls):
super(TestAnonymousStudentId, cls).setUpClass()
cls.course_key = ToyCourseFactory.create().id
cls.course = modulestore().get_course(cls.course_key)
def setUp(self):
super(TestAnonymousStudentId, self).setUp()
self.user = UserFactory()
@patch('courseware.module_render.has_access', Mock(return_value=True, autospec=True))
def _get_anonymous_id(self, course_id, xblock_class):
location = course_id.make_usage_key('dummy_category', 'dummy_name')
descriptor = Mock(
spec=xblock_class,
_field_data=Mock(spec=FieldData, name='field_data'),
location=location,
static_asset_path=None,
_runtime=Mock(
spec=Runtime,
resources_fs=None,
mixologist=Mock(_mixins=(), name='mixologist'),
name='runtime',
),
scope_ids=Mock(spec=ScopeIds),
name='descriptor',
_field_data_cache={},
_dirty_fields={},
fields={},
days_early_for_beta=None,
)
descriptor.runtime = CombinedSystem(descriptor._runtime, None) # pylint: disable=protected-access
# Use the xblock_class's bind_for_student method
descriptor.bind_for_student = partial(xblock_class.bind_for_student, descriptor)
if hasattr(xblock_class, 'module_class'):
descriptor.module_class = xblock_class.module_class
return render.get_module_for_descriptor_internal(
user=self.user,
descriptor=descriptor,
student_data=Mock(spec=FieldData, name='student_data'),
course_id=course_id,
track_function=Mock(name='track_function'), # Track Function
xqueue_callback_url_prefix=Mock(name='xqueue_callback_url_prefix'), # XQueue Callback Url Prefix
request_token='request_token',
course=self.course,
).xmodule_runtime.anonymous_student_id
@ddt.data(*PER_STUDENT_ANONYMIZED_DESCRIPTORS)
def test_per_student_anonymized_id(self, descriptor_class):
for course_id in ('MITx/6.00x/2012_Fall', 'MITx/6.00x/2013_Spring'):
self.assertEquals(
# This value is set by observation, so that later changes to the student
# id computation don't break old data
'5afe5d9bb03796557ee2614f5c9611fb',
self._get_anonymous_id(CourseKey.from_string(course_id), descriptor_class)
)
@ddt.data(*PER_COURSE_ANONYMIZED_DESCRIPTORS)
def test_per_course_anonymized_id(self, descriptor_class):
self.assertEquals(
# This value is set by observation, so that later changes to the student
# id computation don't break old data
'e3b0b940318df9c14be59acb08e78af5',
self._get_anonymous_id(CourseKey.from_string('MITx/6.00x/2012_Fall'), descriptor_class)
)
self.assertEquals(
# This value is set by observation, so that later changes to the student
# id computation don't break old data
'f82b5416c9f54b5ce33989511bb5ef2e',
self._get_anonymous_id(CourseKey.from_string('MITx/6.00x/2013_Spring'), descriptor_class)
)
@patch('track.views.tracker', autospec=True)
class TestModuleTrackingContext(SharedModuleStoreTestCase):
"""
Ensure correct tracking information is included in events emitted during XBlock callback handling.
"""
@classmethod
def setUpClass(cls):
super(TestModuleTrackingContext, cls).setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
super(TestModuleTrackingContext, self).setUp()
self.user = UserFactory.create()
self.request = RequestFactoryNoCsrf().get('/')
self.request.user = self.user
self.request.session = {}
self.course = CourseFactory.create()
self.problem_xml = OptionResponseXMLFactory().build_xml(
question_text='The correct answer is Correct',
num_inputs=2,
weight=2,
options=['Correct', 'Incorrect'],
correct_option='Correct'
)
def test_context_contains_display_name(self, mock_tracker):
problem_display_name = u'Option Response Problem'
module_info = self.handle_callback_and_get_module_info(mock_tracker, problem_display_name)
self.assertEquals(problem_display_name, module_info['display_name'])
@XBlockAside.register_temp_plugin(AsideTestType, 'test_aside')
@patch('xmodule.modulestore.mongo.base.CachingDescriptorSystem.applicable_aside_types',
lambda self, block: ['test_aside'])
@patch('lms.djangoapps.lms_xblock.runtime.LmsModuleSystem.applicable_aside_types',
lambda self, block: ['test_aside'])
def test_context_contains_aside_info(self, mock_tracker):
"""
Check that related xblock asides populate information in the 'problem_check' event in case
the 'get_event_context' method is exist
"""
problem_display_name = u'Test Problem'
def get_event_context(self, event_type, event): # pylint: disable=unused-argument
"""
This method return data that should be associated with the "check_problem" event
"""
return {'content': 'test1', 'data_field': 'test2'}
AsideTestType.get_event_context = get_event_context
context_info = self.handle_callback_and_get_context_info(mock_tracker, problem_display_name)
self.assertIn('asides', context_info)
self.assertIn('test_aside', context_info['asides'])
self.assertIn('content', context_info['asides']['test_aside'])
self.assertEquals(context_info['asides']['test_aside']['content'], 'test1')
self.assertIn('data_field', context_info['asides']['test_aside'])
self.assertEquals(context_info['asides']['test_aside']['data_field'], 'test2')
def handle_callback_and_get_context_info(self, mock_tracker, problem_display_name=None):
"""
Creates a fake module, invokes the callback and extracts the 'context'
metadata from the emitted problem_check event.
"""
descriptor_kwargs = {
'category': 'problem',
'data': self.problem_xml
}
if problem_display_name:
descriptor_kwargs['display_name'] = problem_display_name
descriptor = ItemFactory.create(**descriptor_kwargs)
render.handle_xblock_callback(
self.request,
text_type(self.course.id),
quote_slashes(text_type(descriptor.location)),
'xmodule_handler',
'problem_check',
)
self.assertEquals(len(mock_tracker.send.mock_calls), 1)
mock_call = mock_tracker.send.mock_calls[0]
event = mock_call[1][0]
self.assertEquals(event['event_type'], 'problem_check')
return event['context']
def handle_callback_and_get_module_info(self, mock_tracker, problem_display_name=None):
"""
Creates a fake module, invokes the callback and extracts the 'module'
metadata from the emitted problem_check event.
"""
event = self.handle_callback_and_get_context_info(mock_tracker, problem_display_name)
return event['module']
def test_missing_display_name(self, mock_tracker):
actual_display_name = self.handle_callback_and_get_module_info(mock_tracker)['display_name']
self.assertTrue(actual_display_name.startswith('problem'))
def test_library_source_information(self, mock_tracker):
"""
Check that XBlocks that are inherited from a library include the
information about their library block source in events.
We patch the modulestore to avoid having to create a library.
"""
original_usage_key = UsageKey.from_string(u'block-v1:A+B+C+type@problem+block@abcd1234')
original_usage_version = ObjectId()
mock_get_original_usage = lambda _, key: (original_usage_key, original_usage_version)
with patch('xmodule.modulestore.mixed.MixedModuleStore.get_block_original_usage', mock_get_original_usage):
module_info = self.handle_callback_and_get_module_info(mock_tracker)
self.assertIn('original_usage_key', module_info)
self.assertEqual(module_info['original_usage_key'], text_type(original_usage_key))
self.assertIn('original_usage_version', module_info)
self.assertEqual(module_info['original_usage_version'], text_type(original_usage_version))
class TestXmoduleRuntimeEvent(TestSubmittingProblems):
"""
Inherit from TestSubmittingProblems to get functionality that set up a course and problems structure
"""
def setUp(self):
super(TestXmoduleRuntimeEvent, self).setUp()
self.homework = self.add_graded_section_to_course('homework')
self.problem = self.add_dropdown_to_section(self.homework.location, 'p1', 1)
self.grade_dict = {'value': 0.18, 'max_value': 32}
self.delete_dict = {'value': None, 'max_value': None}
def get_module_for_user(self, user):
"""Helper function to get useful module at self.location in self.course_id for user"""
mock_request = MagicMock()
mock_request.user = user
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id, user, self.course, depth=2)
return render.get_module(
user,
mock_request,
self.problem.location,
field_data_cache,
)
def set_module_grade_using_publish(self, grade_dict):
"""Publish the user's grade, takes grade_dict as input"""
module = self.get_module_for_user(self.student_user)
module.system.publish(module, 'grade', grade_dict)
return module
def test_xmodule_runtime_publish(self):
"""Tests the publish mechanism"""
self.set_module_grade_using_publish(self.grade_dict)
student_module = StudentModule.objects.get(student=self.student_user, module_state_key=self.problem.location)
self.assertEqual(student_module.grade, self.grade_dict['value'])
self.assertEqual(student_module.max_grade, self.grade_dict['max_value'])
def test_xmodule_runtime_publish_delete(self):
"""Test deleting the grade using the publish mechanism"""
module = self.set_module_grade_using_publish(self.grade_dict)
module.system.publish(module, 'grade', self.delete_dict)
student_module = StudentModule.objects.get(student=self.student_user, module_state_key=self.problem.location)
self.assertIsNone(student_module.grade)
self.assertIsNone(student_module.max_grade)
@patch('lms.djangoapps.grades.signals.handlers.PROBLEM_RAW_SCORE_CHANGED.send')
def test_score_change_signal(self, send_mock):
"""Test that a Django signal is generated when a score changes"""
with freeze_time(datetime.now().replace(tzinfo=pytz.UTC)):
self.set_module_grade_using_publish(self.grade_dict)
expected_signal_kwargs = {
'sender': None,
'raw_possible': self.grade_dict['max_value'],
'raw_earned': self.grade_dict['value'],
'weight': None,
'user_id': self.student_user.id,
'course_id': text_type(self.course.id),
'usage_id': text_type(self.problem.location),
'only_if_higher': None,
'modified': datetime.now().replace(tzinfo=pytz.UTC),
'score_db_table': 'csm',
'score_deleted': None,
'grader_response': None
}
send_mock.assert_called_with(**expected_signal_kwargs)
class TestRebindModule(TestSubmittingProblems):
"""
Tests to verify the functionality of rebinding a module.
Inherit from TestSubmittingProblems to get functionality that set up a course structure
"""
def setUp(self):
super(TestRebindModule, self).setUp()
self.homework = self.add_graded_section_to_course('homework')
self.lti = ItemFactory.create(category='lti', parent=self.homework)
self.problem = ItemFactory.create(category='problem', parent=self.homework)
self.user = UserFactory.create()
self.anon_user = AnonymousUser()
def get_module_for_user(self, user, item=None):
"""Helper function to get useful module at self.location in self.course_id for user"""
mock_request = MagicMock()
mock_request.user = user
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
self.course.id, user, self.course, depth=2)
if item is None:
item = self.lti
return render.get_module(
user,
mock_request,
item.location,
field_data_cache,
)
def test_rebind_module_to_new_users(self):
module = self.get_module_for_user(self.user, self.problem)
# Bind the module to another student, which will remove "correct_map"
# from the module's _field_data_cache and _dirty_fields.
user2 = UserFactory.create()
module.bind_for_student(module.system, user2.id)
# XBlock's save method assumes that if a field is in _dirty_fields,
# then it's also in _field_data_cache. If this assumption
# doesn't hold, then we get an error trying to bind this module
# to a third student, since we've removed "correct_map" from
# _field_data cache, but not _dirty_fields, when we bound
# this module to the second student. (TNL-2640)
user3 = UserFactory.create()
module.bind_for_student(module.system, user3.id)
def test_rebind_noauth_module_to_user_not_anonymous(self):
"""
Tests that an exception is thrown when rebind_noauth_module_to_user is run from a
module bound to a real user
"""
module = self.get_module_for_user(self.user)
user2 = UserFactory()
user2.id = 2
with self.assertRaisesRegexp(
render.LmsModuleRenderError,
"rebind_noauth_module_to_user can only be called from a module bound to an anonymous user"
):
self.assertTrue(module.system.rebind_noauth_module_to_user(module, user2))
def test_rebind_noauth_module_to_user_anonymous(self):
"""
Tests that get_user_module_for_noauth succeeds when rebind_noauth_module_to_user is run from a
module bound to AnonymousUser
"""
module = self.get_module_for_user(self.anon_user)
user2 = UserFactory()
user2.id = 2
module.system.rebind_noauth_module_to_user(module._xmodule, user2) # pylint: disable=protected-access
self.assertTrue(module)
self.assertEqual(module.system.anonymous_student_id, anonymous_id_for_user(user2, self.course.id))
self.assertEqual(module.scope_ids.user_id, user2.id)
self.assertEqual(module._xmodule.scope_ids.user_id, user2.id) # pylint: disable=protected-access
@ddt.ddt
class TestEventPublishing(ModuleStoreTestCase, LoginEnrollmentTestCase):
"""
Tests of event publishing for both XModules and XBlocks.
"""
def setUp(self):
"""
Set up the course and user context
"""
super(TestEventPublishing, self).setUp()
self.mock_user = UserFactory()
self.mock_user.id = 1
self.request_factory = RequestFactoryNoCsrf()
@ddt.data('xblock', 'xmodule')
@XBlock.register_temp_plugin(PureXBlock, identifier='xblock')
@XBlock.register_temp_plugin(EmptyXModuleDescriptor, identifier='xmodule')
@patch.object(render, 'make_track_function')
def test_event_publishing(self, block_type, mock_track_function):
request = self.request_factory.get('')
request.user = self.mock_user
course = CourseFactory()
descriptor = ItemFactory(category=block_type, parent=course)
field_data_cache = FieldDataCache([course, descriptor], course.id, self.mock_user)
block = render.get_module(self.mock_user, request, descriptor.location, field_data_cache)
event_type = 'event_type'
event = {'event': 'data'}
block.runtime.publish(block, event_type, event)
mock_track_function.assert_called_once_with(request)
mock_track_function.return_value.assert_called_once_with(event_type, event)
@ddt.ddt
class LMSXBlockServiceBindingTest(SharedModuleStoreTestCase):
"""
Tests that the LMS Module System (XBlock Runtime) provides an expected set of services.
"""
@classmethod
def setUpClass(cls):
super(LMSXBlockServiceBindingTest, cls).setUpClass()
cls.course = CourseFactory.create()
def setUp(self):
"""
Set up the user and other fields that will be used to instantiate the runtime.
"""
super(LMSXBlockServiceBindingTest, self).setUp()
self.user = UserFactory()
self.student_data = Mock()
self.track_function = Mock()
self.xqueue_callback_url_prefix = Mock()
self.request_token = Mock()
@XBlock.register_temp_plugin(PureXBlock, identifier='pure')
@ddt.data("user", "i18n", "fs", "field-data", "bookmarks")
def test_expected_services_exist(self, expected_service):
"""
Tests that the 'user', 'i18n', and 'fs' services are provided by the LMS runtime.
"""
descriptor = ItemFactory(category="pure", parent=self.course)
runtime, _ = render.get_module_system_for_user(
self.user,
self.student_data,
descriptor,
self.course.id,
self.track_function,
self.xqueue_callback_url_prefix,
self.request_token,
course=self.course
)
service = runtime.service(descriptor, expected_service)
self.assertIsNotNone(service)
def test_beta_tester_fields_added(self):
"""
Tests that the beta tester fields are set on LMS runtime.
"""
descriptor = ItemFactory(category="pure", parent=self.course)
descriptor.days_early_for_beta = 5
runtime, _ = render.get_module_system_for_user(
self.user,
self.student_data,
descriptor,
self.course.id,
self.track_function,
self.xqueue_callback_url_prefix,
self.request_token,
course=self.course
)
# pylint: disable=no-member
self.assertFalse(runtime.user_is_beta_tester)
self.assertEqual(runtime.days_early_for_beta, 5)
class PureXBlockWithChildren(PureXBlock):
"""
Pure XBlock with children to use in tests.
"""
has_children = True
class EmptyXModuleWithChildren(EmptyXModule): # pylint: disable=abstract-method
"""
Empty XModule for testing with no dependencies.
"""
has_children = True
class EmptyXModuleDescriptorWithChildren(EmptyXModuleDescriptor): # pylint: disable=abstract-method
"""
Empty XModule for testing with no dependencies.
"""
module_class = EmptyXModuleWithChildren
has_children = True
BLOCK_TYPES = ['xblock', 'xmodule']
USER_NUMBERS = list(range(2))
@ddt.ddt
class TestFilteredChildren(SharedModuleStoreTestCase):
"""
Tests that verify access to XBlock/XModule children work correctly
even when those children are filtered by the runtime when loaded.
"""
@classmethod
def setUpClass(cls):
super(TestFilteredChildren, cls).setUpClass()
cls.course = CourseFactory.create()
# pylint: disable=attribute-defined-outside-init
def setUp(self):
super(TestFilteredChildren, self).setUp()
self.users = {number: UserFactory() for number in USER_NUMBERS}
self._old_has_access = render.has_access
patcher = patch('courseware.module_render.has_access', self._has_access)
patcher.start()
self.addCleanup(patcher.stop)
@ddt.data(*BLOCK_TYPES)
@XBlock.register_temp_plugin(PureXBlockWithChildren, identifier='xblock')
@XBlock.register_temp_plugin(EmptyXModuleDescriptorWithChildren, identifier='xmodule')
def test_unbound(self, block_type):
block = self._load_block(block_type)
self.assertUnboundChildren(block)
@ddt.data(*itertools.product(BLOCK_TYPES, USER_NUMBERS))
@ddt.unpack
@XBlock.register_temp_plugin(PureXBlockWithChildren, identifier='xblock')
@XBlock.register_temp_plugin(EmptyXModuleDescriptorWithChildren, identifier='xmodule')
def test_unbound_then_bound_as_descriptor(self, block_type, user_number):
user = self.users[user_number]
block = self._load_block(block_type)
self.assertUnboundChildren(block)
self._bind_block(block, user)
self.assertBoundChildren(block, user)
@ddt.data(*itertools.product(BLOCK_TYPES, USER_NUMBERS))
@ddt.unpack
@XBlock.register_temp_plugin(PureXBlockWithChildren, identifier='xblock')
@XBlock.register_temp_plugin(EmptyXModuleDescriptorWithChildren, identifier='xmodule')
def test_unbound_then_bound_as_xmodule(self, block_type, user_number):
user = self.users[user_number]
block = self._load_block(block_type)
self.assertUnboundChildren(block)
self._bind_block(block, user)
# Validate direct XModule access as well
if isinstance(block, XModuleDescriptor):
self.assertBoundChildren(block._xmodule, user) # pylint: disable=protected-access
else:
self.assertBoundChildren(block, user)
@ddt.data(*itertools.product(BLOCK_TYPES, USER_NUMBERS))
@ddt.unpack
@XBlock.register_temp_plugin(PureXBlockWithChildren, identifier='xblock')
@XBlock.register_temp_plugin(EmptyXModuleDescriptorWithChildren, identifier='xmodule')
def test_bound_only_as_descriptor(self, block_type, user_number):
user = self.users[user_number]
block = self._load_block(block_type)
self._bind_block(block, user)
self.assertBoundChildren(block, user)
@ddt.data(*itertools.product(BLOCK_TYPES, USER_NUMBERS))
@ddt.unpack
@XBlock.register_temp_plugin(PureXBlockWithChildren, identifier='xblock')
@XBlock.register_temp_plugin(EmptyXModuleDescriptorWithChildren, identifier='xmodule')
def test_bound_only_as_xmodule(self, block_type, user_number):
user = self.users[user_number]
block = self._load_block(block_type)
self._bind_block(block, user)
# Validate direct XModule access as well
if isinstance(block, XModuleDescriptor):
self.assertBoundChildren(block._xmodule, user) # pylint: disable=protected-access
else:
self.assertBoundChildren(block, user)
def _load_block(self, block_type):
"""
Instantiate an XBlock of `block_type` with the appropriate set of children.
"""
self.parent = ItemFactory(category=block_type, parent=self.course)
# Create a child of each block type for each user
self.children_for_user = {
user: [
ItemFactory(category=child_type, parent=self.parent).scope_ids.usage_id
for child_type in BLOCK_TYPES
]
for user in six.itervalues(self.users)
}
self.all_children = sum(list(self.children_for_user.values()), [])
return modulestore().get_item(self.parent.scope_ids.usage_id)
def _bind_block(self, block, user):
"""
Bind `block` to the supplied `user`.
"""
course_id = self.course.id
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
course_id,
user,
block,
)
return get_module_for_descriptor(
user,
Mock(name='request', user=user),
block,
field_data_cache,
course_id,
course=self.course
)
def _has_access(self, user, action, obj, course_key=None):
"""
Mock implementation of `has_access` used to control which blocks
have access to which children during tests.
"""
if action != 'load':
return self._old_has_access(user, action, obj, course_key)
if isinstance(obj, XBlock):
key = obj.scope_ids.usage_id
elif isinstance(obj, UsageKey):
key = obj
if key == self.parent.scope_ids.usage_id:
return AccessResponse(True)
return AccessResponse(key in self.children_for_user[user])
def assertBoundChildren(self, block, user):
"""
Ensure the bound children are indeed children.
"""
self.assertChildren(block, self.children_for_user[user])
def assertUnboundChildren(self, block):
"""
Ensure unbound children are indeed children.
"""
self.assertChildren(block, self.all_children)
def assertChildren(self, block, child_usage_ids):
"""
Used to assert that sets of children are equivalent.
"""
self.assertEquals(set(child_usage_ids), set(child.scope_ids.usage_id for child in block.get_children()))
@ddt.ddt
class TestDisabledXBlockTypes(ModuleStoreTestCase):
"""
Tests that verify disabled XBlock types are not loaded.
"""
def setUp(self):
super(TestDisabledXBlockTypes, self).setUp()
XBlockConfiguration(name='video', enabled=False).save()
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_get_item(self, default_ms):
with self.store.default_store(default_ms):
course = CourseFactory()
self._verify_descriptor('video', course, 'RawDescriptorWithMixins')
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_dynamic_updates(self, default_ms):
"""Tests that the list of disabled xblocks can dynamically update."""
with self.store.default_store(default_ms):
course = CourseFactory()
item_usage_id = self._verify_descriptor('problem', course, 'ProblemBlockWithMixins')
XBlockConfiguration(name='problem', enabled=False).save()
# First verify that the cached value is used until there is a new request cache.
self._verify_descriptor('problem', course, 'ProblemBlockWithMixins', item_usage_id)
# Now simulate a new request cache.
self.store.request_cache.data.clear()
self._verify_descriptor('problem', course, 'RawDescriptorWithMixins', item_usage_id)
def _verify_descriptor(self, category, course, descriptor, item_id=None):
"""
Helper method that gets an item with the specified category from the
modulestore and verifies that it has the expected descriptor name.
Returns the item's usage_id.
"""
if not item_id:
item = ItemFactory(category=category, parent=course)
item_id = item.scope_ids.usage_id
item = self.store.get_item(item_id)
self.assertEqual(item.__class__.__name__, descriptor)
return item_id