Adds CouseModule.course_visibility and XBlock.public_view() for unenrolled users access to courses.
The course_visiblity field can have one of three values: 1. private (default): This keeps the standard access rules. 2. public_outline: Allows unenrolled and anonymous users access to the outline. 3. public: Allows unenrolled and anonymous users access to both outline and course content. When an unenrolled user accesses course content, instead of student_view(), public_view() is used. A default implementation is provided for XBlocks which do not implement this view. The public_view() must not have any functionality which assumes the presence of a valid User and should show a readonly only interface for the XBlock content.
This commit is contained in:
committed by
Usman Khalid
parent
2d338b34aa
commit
c4fc4b5df6
@@ -9,6 +9,8 @@ from xblock.fields import Scope
|
||||
from xblock_django.models import XBlockStudioConfigurationFlag
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
|
||||
|
||||
|
||||
class CourseMetadata(object):
|
||||
'''
|
||||
@@ -63,7 +65,7 @@ class CourseMetadata(object):
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def filtered_list(cls):
|
||||
def filtered_list(cls, course_key=None):
|
||||
"""
|
||||
Filter fields based on feature flag, i.e. enabled, disabled.
|
||||
"""
|
||||
@@ -117,6 +119,10 @@ class CourseMetadata(object):
|
||||
if not XBlockStudioConfigurationFlag.is_enabled():
|
||||
filtered_list.append('allow_unsupported_xblocks')
|
||||
|
||||
# Do not show "Course Visibility For Unenrolled Learners" in Studio Advanced Settings
|
||||
# if the enable_anonymous_access flag is not enabled
|
||||
if not COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key=course_key):
|
||||
filtered_list.append('course_visibility')
|
||||
return filtered_list
|
||||
|
||||
@classmethod
|
||||
@@ -128,7 +134,7 @@ class CourseMetadata(object):
|
||||
result = {}
|
||||
metadata = cls.fetch_all(descriptor)
|
||||
for key, value in metadata.iteritems():
|
||||
if key in cls.filtered_list():
|
||||
if key in cls.filtered_list(descriptor.id):
|
||||
continue
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -163,7 +169,7 @@ class CourseMetadata(object):
|
||||
|
||||
Ensures none of the fields are in the blacklist.
|
||||
"""
|
||||
filtered_list = cls.filtered_list()
|
||||
filtered_list = cls.filtered_list(descriptor.id)
|
||||
# Don't filter on the tab attribute if filter_tabs is False.
|
||||
if not filter_tabs:
|
||||
filtered_list.remove("tabs")
|
||||
@@ -199,7 +205,7 @@ class CourseMetadata(object):
|
||||
errors: list of error objects
|
||||
result: the updated course metadata or None if error
|
||||
"""
|
||||
filtered_list = cls.filtered_list()
|
||||
filtered_list = cls.filtered_list(descriptor.id)
|
||||
if not filter_tabs:
|
||||
filtered_list.remove("tabs")
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ DEFAULT_COURSE_VISIBILITY_IN_CATALOG = getattr(
|
||||
|
||||
DEFAULT_MOBILE_AVAILABLE = getattr(settings, 'DEFAULT_MOBILE_AVAILABLE', False)
|
||||
|
||||
COURSE_VISIBILITY_PRIVATE = 'private'
|
||||
COURSE_VISIBILITY_PUBLIC_OUTLINE = 'public_outline'
|
||||
COURSE_VISIBILITY_PUBLIC = 'public'
|
||||
|
||||
|
||||
class StringOrDate(Date):
|
||||
def from_json(self, value):
|
||||
@@ -814,6 +818,21 @@ class CourseFields(object):
|
||||
scope=Scope.settings
|
||||
)
|
||||
|
||||
course_visibility = String(
|
||||
display_name=_("Course Visibility For Unenrolled Learners"),
|
||||
help=_(
|
||||
"Defines the access permissions for unenrolled learners. This can be set to one of three values: "
|
||||
"'private' (default visibility, only allowed for enrolled students), 'public_outline' "
|
||||
"(allow access to course outline) and 'public' (allow access to both outline and course content)."
|
||||
),
|
||||
default=COURSE_VISIBILITY_PRIVATE,
|
||||
scope=Scope.settings,
|
||||
values=[
|
||||
{"display_name": _("private"), "value": COURSE_VISIBILITY_PRIVATE},
|
||||
{"display_name": _("public_outline"), "value": COURSE_VISIBILITY_PUBLIC_OUTLINE},
|
||||
{"display_name": _("public"), "value": COURSE_VISIBILITY_PUBLIC}]
|
||||
)
|
||||
|
||||
"""
|
||||
instructor_info dict structure:
|
||||
{
|
||||
|
||||
@@ -79,6 +79,13 @@ class HtmlBlock(object):
|
||||
"""
|
||||
return Fragment(self.get_html())
|
||||
|
||||
@XBlock.supports("multi_device")
|
||||
def public_view(self, context):
|
||||
"""
|
||||
Returns a fragment that contains the html for the preview view
|
||||
"""
|
||||
return self.student_view(context)
|
||||
|
||||
def student_view_data(self, context=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Return a JSON representation of the student_view of this XBlock.
|
||||
|
||||
@@ -18,7 +18,7 @@ from mock import patch
|
||||
from openedx.core.djangolib.testing.utils import CacheIsolationMixin, CacheIsolationTestCase, FilteredQueryCountMixin
|
||||
from openedx.core.lib.tempdir import mkdtemp_clean
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
from student.tests.factories import AdminFactory, UserFactory
|
||||
from xmodule.contentstore.django import _CONTENTSTORE
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import SignalHandler, clear_existing_modulestores, modulestore
|
||||
@@ -343,6 +343,8 @@ class ModuleStoreTestUsersMixin():
|
||||
# Set up the test user
|
||||
if is_unenrolled_staff:
|
||||
user = StaffFactory(course_key=course.id, password=self.TEST_PASSWORD)
|
||||
elif user_type is CourseUserType.GLOBAL_STAFF:
|
||||
user = AdminFactory(password=self.TEST_PASSWORD)
|
||||
else:
|
||||
user = UserFactory(password=self.TEST_PASSWORD)
|
||||
self.client.login(username=user.username, password=self.TEST_PASSWORD)
|
||||
|
||||
@@ -22,7 +22,7 @@ from .exceptions import NotFoundError
|
||||
from .fields import Date
|
||||
from .mako_module import MakoModuleDescriptor
|
||||
from .progress import Progress
|
||||
from .x_module import STUDENT_VIEW, XModule
|
||||
from .x_module import STUDENT_VIEW, PUBLIC_VIEW, XModule
|
||||
from .xml_module import XmlDescriptor
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -257,7 +257,18 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
banner_text, special_html = special_html_view
|
||||
if special_html and not masquerading_as_specific_student:
|
||||
return Fragment(special_html)
|
||||
return self._student_view(context, prereq_met, prereq_meta_info, banner_text)
|
||||
return self._student_or_public_view(context, prereq_met, prereq_meta_info, banner_text)
|
||||
|
||||
def public_view(self, context):
|
||||
"""
|
||||
Renders the preview view of the block in the LMS.
|
||||
"""
|
||||
prereq_met = True
|
||||
prereq_meta_info = {}
|
||||
|
||||
if self._required_prereq():
|
||||
prereq_met, prereq_meta_info = self._compute_is_prereq_met(True)
|
||||
return self._student_or_public_view(context or {}, prereq_met, prereq_meta_info, None, PUBLIC_VIEW)
|
||||
|
||||
def _special_exam_student_view(self):
|
||||
"""
|
||||
@@ -309,7 +320,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
# NOTE (CCB): We default to true to maintain the behavior in place prior to allowing anonymous access access.
|
||||
return context.get('user_authenticated', True)
|
||||
|
||||
def _student_view(self, context, prereq_met, prereq_meta_info, banner_text=None):
|
||||
def _student_or_public_view(self, context, prereq_met, prereq_meta_info, banner_text=None, view=STUDENT_VIEW):
|
||||
"""
|
||||
Returns the rendered student view of the content of this
|
||||
sequential. If banner_text is given, it is added to the
|
||||
@@ -319,11 +330,14 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
self._update_position(context, len(display_items))
|
||||
|
||||
if prereq_met and not self._is_gate_fulfilled():
|
||||
banner_text = _('This section is a prerequisite. You must complete this section in order to unlock additional content.')
|
||||
banner_text = _(
|
||||
'This section is a prerequisite. You must complete this section in order to unlock additional content.'
|
||||
)
|
||||
|
||||
fragment = Fragment()
|
||||
items = self._render_student_view_for_items(context, display_items, fragment, view) if prereq_met else []
|
||||
params = {
|
||||
'items': self._render_student_view_for_items(context, display_items, fragment) if prereq_met else [],
|
||||
'items': items,
|
||||
'element_id': self.location.html_id(),
|
||||
'item_id': text_type(self.location),
|
||||
'position': self.position,
|
||||
@@ -332,8 +346,8 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
'next_url': context.get('next_url'),
|
||||
'prev_url': context.get('prev_url'),
|
||||
'banner_text': banner_text,
|
||||
'save_position': self.is_user_authenticated(context),
|
||||
'show_completion': self.is_user_authenticated(context),
|
||||
'save_position': view != PUBLIC_VIEW,
|
||||
'show_completion': view != PUBLIC_VIEW,
|
||||
'gated_content': self._get_gated_content_info(prereq_met, prereq_meta_info)
|
||||
}
|
||||
fragment.add_content(self.system.render_template("seq_module.html", params))
|
||||
@@ -425,7 +439,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
elif self.position is None or self.position > number_of_display_items:
|
||||
self.position = 1
|
||||
|
||||
def _render_student_view_for_items(self, context, display_items, fragment):
|
||||
def _render_student_view_for_items(self, context, display_items, fragment, view=STUDENT_VIEW):
|
||||
"""
|
||||
Updates the given fragment with rendered student views of the given
|
||||
display_items. Returns a list of dict objects with information about
|
||||
@@ -463,7 +477,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
|
||||
context['show_bookmark_button'] = show_bookmark_button
|
||||
context['bookmarked'] = is_bookmarked
|
||||
|
||||
rendered_item = item.render(STUDENT_VIEW, context)
|
||||
rendered_item = item.render(view, context)
|
||||
fragment.add_fragment_resources(rendered_item)
|
||||
|
||||
iteminfo = {
|
||||
|
||||
@@ -32,10 +32,19 @@ class StubUserService(UserService):
|
||||
"""
|
||||
Stub UserService for testing the sequence module.
|
||||
"""
|
||||
|
||||
def __init__(self, is_anonymous=False, **kwargs):
|
||||
self.is_anonymous = is_anonymous
|
||||
super(StubUserService, self).__init__(**kwargs)
|
||||
|
||||
def get_current_user(self):
|
||||
"""
|
||||
Implements abstract method for getting the current user.
|
||||
"""
|
||||
user = XBlockUser()
|
||||
user.opt_attrs['edx-platform.username'] = 'bilbo'
|
||||
if self.is_anonymous:
|
||||
user.opt_attrs['edx-platform.username'] = 'anonymous'
|
||||
user.opt_attrs['edx-platform.is_authenticated'] = False
|
||||
else:
|
||||
user.opt_attrs['edx-platform.username'] = 'bilbo'
|
||||
return user
|
||||
|
||||
@@ -11,6 +11,7 @@ from xblock.fields import ScopeIds
|
||||
from xmodule.html_module import CourseInfoModule, HtmlDescriptor, HtmlModule
|
||||
|
||||
from . import get_test_descriptor_system, get_test_system
|
||||
from ..x_module import PUBLIC_VIEW, STUDENT_VIEW
|
||||
|
||||
|
||||
def instantiate_descriptor(**field_data):
|
||||
@@ -81,6 +82,22 @@ class HtmlModuleCourseApiTestCase(unittest.TestCase):
|
||||
module = HtmlModule(descriptor, module_system, field_data, Mock())
|
||||
self.assertEqual(module.student_view_data(), dict(enabled=True, html=html))
|
||||
|
||||
@ddt.data(
|
||||
STUDENT_VIEW,
|
||||
PUBLIC_VIEW,
|
||||
)
|
||||
def test_student_preview_view(self, view):
|
||||
"""
|
||||
Ensure that student_view and public_view renders correctly.
|
||||
"""
|
||||
html = '<p>This is a test</p>'
|
||||
descriptor = Mock()
|
||||
field_data = DictFieldData({'data': html})
|
||||
module_system = get_test_system()
|
||||
module = HtmlModule(descriptor, module_system, field_data, Mock())
|
||||
rendered = module_system.render(module, view, {}).content
|
||||
self.assertIn(html, rendered)
|
||||
|
||||
|
||||
class HtmlModuleSubstitutionTestCase(unittest.TestCase):
|
||||
descriptor = Mock()
|
||||
|
||||
@@ -5,6 +5,7 @@ Tests for sequence module.
|
||||
import json
|
||||
|
||||
from datetime import timedelta
|
||||
import ddt
|
||||
from django.utils.timezone import now
|
||||
from freezegun import freeze_time
|
||||
from mock import Mock, patch
|
||||
@@ -12,7 +13,7 @@ from xmodule.seq_module import SequenceModule
|
||||
from xmodule.tests import get_test_system
|
||||
from xmodule.tests.helpers import StubUserService
|
||||
from xmodule.tests.xml import factories as xml, XModuleXmlImportTest
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW
|
||||
|
||||
TODAY = now()
|
||||
DUE_DATE = TODAY + timedelta(days=7)
|
||||
@@ -20,6 +21,7 @@ PAST_DUE_BEFORE_END_DATE = TODAY + timedelta(days=14)
|
||||
COURSE_END_DATE = TODAY + timedelta(days=21)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
"""
|
||||
Base class for tests of Sequence Module.
|
||||
@@ -92,7 +94,12 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
module_system.descriptor_runtime = block._runtime # pylint: disable=protected-access
|
||||
block.xmodule_runtime = module_system
|
||||
|
||||
def _get_rendered_student_view(self, sequence, requested_child=None, extra_context=None, self_paced=False):
|
||||
def _get_rendered_view(self,
|
||||
sequence,
|
||||
requested_child=None,
|
||||
extra_context=None,
|
||||
self_paced=False,
|
||||
view=STUDENT_VIEW):
|
||||
"""
|
||||
Returns the rendered student view for the given sequence and the
|
||||
requested_child parameter.
|
||||
@@ -106,7 +113,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
with patch.object(SequenceModule, '_get_course') as mock_course:
|
||||
self.course.self_paced = self_paced
|
||||
mock_course.return_value = self.course
|
||||
return sequence.xmodule_runtime.render(sequence, STUDENT_VIEW, context).content
|
||||
return sequence.xmodule_runtime.render(sequence, view, context).content
|
||||
|
||||
def _assert_view_at_position(self, rendered_html, expected_position):
|
||||
"""
|
||||
@@ -118,10 +125,16 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
seq_module = SequenceModule(runtime=Mock(position=2), descriptor=Mock(), scope_ids=Mock())
|
||||
self.assertEquals(seq_module.position, 2) # matches position set in the runtime
|
||||
|
||||
def test_render_student_view(self):
|
||||
html = self._get_rendered_student_view(
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
{'view': STUDENT_VIEW},
|
||||
{'view': PUBLIC_VIEW},
|
||||
)
|
||||
def test_render_student_view(self, view):
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_3_1,
|
||||
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
|
||||
view=view
|
||||
)
|
||||
self._assert_view_at_position(html, expected_position=1)
|
||||
self.assertIn(unicode(self.sequence_3_1.location), html)
|
||||
@@ -130,28 +143,40 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
self.assertIn("'prev_url': 'PrevSequential'", html)
|
||||
self.assertNotIn("fa fa-check-circle check-circle is-hidden", html)
|
||||
|
||||
def test_student_view_first_child(self):
|
||||
html = self._get_rendered_student_view(self.sequence_3_1, requested_child='first')
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
{'view': STUDENT_VIEW},
|
||||
{'view': PUBLIC_VIEW},
|
||||
)
|
||||
def test_student_view_first_child(self, view):
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_3_1, requested_child='first', view=view
|
||||
)
|
||||
self._assert_view_at_position(html, expected_position=1)
|
||||
|
||||
def test_student_view_last_child(self):
|
||||
html = self._get_rendered_student_view(self.sequence_3_1, requested_child='last')
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
{'view': STUDENT_VIEW},
|
||||
{'view': PUBLIC_VIEW},
|
||||
)
|
||||
def test_student_view_last_child(self, view):
|
||||
html = self._get_rendered_view(self.sequence_3_1, requested_child='last', view=view)
|
||||
self._assert_view_at_position(html, expected_position=3)
|
||||
|
||||
def test_tooltip(self):
|
||||
html = self._get_rendered_student_view(self.sequence_3_1, requested_child=None)
|
||||
html = self._get_rendered_view(self.sequence_3_1, requested_child=None)
|
||||
for child in self.sequence_3_1.children:
|
||||
self.assertIn("'page_title': '{}'".format(child.block_id), html)
|
||||
|
||||
def test_hidden_content_before_due(self):
|
||||
html = self._get_rendered_student_view(self.sequence_4_1)
|
||||
html = self._get_rendered_view(self.sequence_4_1)
|
||||
self.assertIn("seq_module.html", html)
|
||||
self.assertIn("'banner_text': None", html)
|
||||
|
||||
def test_hidden_content_past_due(self):
|
||||
with freeze_time(COURSE_END_DATE):
|
||||
progress_url = 'http://test_progress_link'
|
||||
html = self._get_rendered_student_view(
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_4_1,
|
||||
extra_context=dict(progress_url=progress_url),
|
||||
)
|
||||
@@ -160,7 +185,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
|
||||
def test_masquerade_hidden_content_past_due(self):
|
||||
with freeze_time(COURSE_END_DATE):
|
||||
html = self._get_rendered_student_view(
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_4_1,
|
||||
extra_context=dict(specific_masquerade=True),
|
||||
)
|
||||
@@ -173,14 +198,14 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
|
||||
def test_hidden_content_self_paced_past_due_before_end(self):
|
||||
with freeze_time(PAST_DUE_BEFORE_END_DATE):
|
||||
html = self._get_rendered_student_view(self.sequence_4_1, self_paced=True)
|
||||
html = self._get_rendered_view(self.sequence_4_1, self_paced=True)
|
||||
self.assertIn("seq_module.html", html)
|
||||
self.assertIn("'banner_text': None", html)
|
||||
|
||||
def test_hidden_content_self_paced_past_end(self):
|
||||
with freeze_time(COURSE_END_DATE + timedelta(days=7)):
|
||||
progress_url = 'http://test_progress_link'
|
||||
html = self._get_rendered_student_view(
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_4_1,
|
||||
extra_context=dict(progress_url=progress_url),
|
||||
self_paced=True,
|
||||
@@ -248,7 +273,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
self.sequence_1_2.xmodule_runtime._services['gating'] = gating_mock_1_2 # pylint: disable=protected-access
|
||||
self.sequence_1_2.display_name = 'sequence_1_2'
|
||||
|
||||
html = self._get_rendered_student_view(
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_1_2,
|
||||
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
|
||||
)
|
||||
@@ -261,7 +286,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
gating_mock_1_2.return_value.required_prereq.return_value = True
|
||||
gating_mock_1_2.return_value.compute_is_prereq_met.return_value = [True, {}]
|
||||
|
||||
html = self._get_rendered_student_view(
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_1_2,
|
||||
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
|
||||
)
|
||||
@@ -274,7 +299,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
||||
gating_mock_1_2.return_value.required_prereq.return_value = True
|
||||
gating_mock_1_2.return_value.compute_is_prereq_met.return_value = [True, {}]
|
||||
|
||||
html = self._get_rendered_student_view(
|
||||
html = self._get_rendered_view(
|
||||
self.sequence_1_2,
|
||||
extra_context=dict(next_url='NextSequential', prev_url='PrevSequential'),
|
||||
)
|
||||
|
||||
@@ -11,14 +11,12 @@ import json
|
||||
import ddt
|
||||
from fs.memoryfs import MemoryFS
|
||||
from mock import Mock, patch
|
||||
import six
|
||||
|
||||
from . import get_test_system
|
||||
from .helpers import StubUserService
|
||||
from .xml import XModuleXmlImportTest
|
||||
from .xml import factories as xml
|
||||
from ..x_module import STUDENT_VIEW, AUTHOR_VIEW
|
||||
|
||||
from ..x_module import STUDENT_VIEW, AUTHOR_VIEW, PUBLIC_VIEW
|
||||
|
||||
COMPLETION_DELAY = 9876
|
||||
|
||||
@@ -111,34 +109,41 @@ class VerticalBlockTestCase(BaseVerticalBlockTest):
|
||||
"""
|
||||
shard = 1
|
||||
|
||||
def assert_bookmark_info_in(self, content):
|
||||
def assert_bookmark_info(self, assertion, content):
|
||||
"""
|
||||
Assert content has all the bookmark info.
|
||||
Assert content has/hasn't all the bookmark info.
|
||||
"""
|
||||
self.assertIn('bookmark_id', content)
|
||||
self.assertIn('{},{}'.format(self.username, unicode(self.vertical.location)), content)
|
||||
self.assertIn('bookmarked', content)
|
||||
self.assertIn('show_bookmark_button', content)
|
||||
assertion('bookmark_id', content)
|
||||
assertion('{},{}'.format(self.username, unicode(self.vertical.location)), content)
|
||||
assertion('bookmarked', content)
|
||||
assertion('show_bookmark_button', content)
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
{'context': None},
|
||||
{'context': {}}
|
||||
{'context': None, 'view': STUDENT_VIEW},
|
||||
{'context': {}, 'view': STUDENT_VIEW},
|
||||
{'context': {}, 'view': PUBLIC_VIEW},
|
||||
)
|
||||
def test_render_student_view(self, context):
|
||||
def test_render_student_preview_view(self, context, view):
|
||||
"""
|
||||
Test the rendering of the student view.
|
||||
Test the rendering of the student and public view.
|
||||
"""
|
||||
self.module_system._services['bookmarks'] = Mock()
|
||||
self.module_system._services['user'] = StubUserService()
|
||||
self.module_system._services['completion'] = StubCompletionService(enabled=True, completion_value=0.0)
|
||||
if view == STUDENT_VIEW:
|
||||
self.module_system._services['user'] = StubUserService()
|
||||
self.module_system._services['completion'] = StubCompletionService(enabled=True, completion_value=0.0)
|
||||
elif view == PUBLIC_VIEW:
|
||||
self.module_system._services['user'] = StubUserService(is_anonymous=True)
|
||||
|
||||
html = self.module_system.render(
|
||||
self.vertical, STUDENT_VIEW, self.default_context if context is None else context
|
||||
self.vertical, view, self.default_context if context is None else context
|
||||
).content
|
||||
self.assertIn(self.test_html_1, html)
|
||||
self.assertIn(self.test_html_2, html)
|
||||
self.assert_bookmark_info_in(html)
|
||||
if view == STUDENT_VIEW:
|
||||
self.assert_bookmark_info(self.assertIn, html)
|
||||
else:
|
||||
self.assert_bookmark_info(self.assertNotIn, html)
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data(
|
||||
|
||||
@@ -17,7 +17,7 @@ from xmodule.progress import Progress
|
||||
from xmodule.seq_module import SequenceFields
|
||||
from xmodule.studio_editable import StudioEditableBlock
|
||||
from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.x_module import STUDENT_VIEW, XModuleFields
|
||||
from xmodule.x_module import STUDENT_VIEW, PUBLIC_VIEW, XModuleFields
|
||||
from xmodule.xml_module import XmlParserMixin
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -43,9 +43,9 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
|
||||
|
||||
show_in_read_only_mode = True
|
||||
|
||||
def student_view(self, context):
|
||||
def _student_or_public_view(self, context, view):
|
||||
"""
|
||||
Renders the student view of the block in the LMS.
|
||||
Renders the requested view type of the block in the LMS.
|
||||
"""
|
||||
fragment = Fragment()
|
||||
contents = []
|
||||
@@ -55,12 +55,14 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
|
||||
else:
|
||||
child_context = {}
|
||||
|
||||
if 'bookmarked' not in child_context:
|
||||
bookmarks_service = self.runtime.service(self, 'bookmarks')
|
||||
child_context['bookmarked'] = bookmarks_service.is_bookmarked(usage_key=self.location), # pylint: disable=no-member
|
||||
if 'username' not in child_context:
|
||||
user_service = self.runtime.service(self, 'user')
|
||||
child_context['username'] = user_service.get_current_user().opt_attrs['edx-platform.username']
|
||||
if view == STUDENT_VIEW:
|
||||
if 'bookmarked' not in child_context:
|
||||
bookmarks_service = self.runtime.service(self, 'bookmarks')
|
||||
child_context['bookmarked'] = bookmarks_service.is_bookmarked(
|
||||
usage_key=self.location), # pylint: disable=no-member
|
||||
if 'username' not in child_context:
|
||||
user_service = self.runtime.service(self, 'user')
|
||||
child_context['username'] = user_service.get_current_user().opt_attrs['edx-platform.username']
|
||||
|
||||
child_blocks = self.get_display_items()
|
||||
|
||||
@@ -80,7 +82,7 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
|
||||
child_block_context['wrap_xblock_data'] = {
|
||||
'mark-completed-on-view-after-delay': complete_on_view_delay
|
||||
}
|
||||
rendered_child = child.render(STUDENT_VIEW, child_block_context)
|
||||
rendered_child = child.render(view, child_block_context)
|
||||
fragment.add_fragment_resources(rendered_child)
|
||||
|
||||
contents.append({
|
||||
@@ -88,20 +90,39 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
|
||||
'content': rendered_child.content
|
||||
})
|
||||
|
||||
fragment.add_content(self.system.render_template('vert_module.html', {
|
||||
fragment_context = {
|
||||
'items': contents,
|
||||
'xblock_context': context,
|
||||
'unit_title': self.display_name_with_default if not is_child_of_vertical else None,
|
||||
'show_bookmark_button': child_context.get('show_bookmark_button', not is_child_of_vertical),
|
||||
'bookmarked': child_context['bookmarked'],
|
||||
'bookmark_id': u"{},{}".format(child_context['username'], unicode(self.location)), # pylint: disable=no-member
|
||||
}))
|
||||
}
|
||||
|
||||
if view == STUDENT_VIEW:
|
||||
fragment_context.update({
|
||||
'show_bookmark_button': child_context.get('show_bookmark_button', not is_child_of_vertical),
|
||||
'bookmarked': child_context['bookmarked'],
|
||||
'bookmark_id': u"{},{}".format(
|
||||
child_context['username'], unicode(self.location)), # pylint: disable=no-member
|
||||
})
|
||||
|
||||
fragment.add_content(self.system.render_template('vert_module.html', fragment_context))
|
||||
|
||||
add_webpack_to_fragment(fragment, 'VerticalStudentView')
|
||||
fragment.initialize_js('VerticalStudentView')
|
||||
|
||||
return fragment
|
||||
|
||||
def student_view(self, context):
|
||||
"""
|
||||
Renders the student view of the block in the LMS.
|
||||
"""
|
||||
return self._student_or_public_view(context, STUDENT_VIEW)
|
||||
|
||||
def public_view(self, context):
|
||||
"""
|
||||
Renders the anonymous view of the block in the LMS.
|
||||
"""
|
||||
return self._student_or_public_view(context, PUBLIC_VIEW)
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview view, which supports drag and drop.
|
||||
|
||||
@@ -39,6 +39,8 @@ from opaque_keys.edx.asides import AsideUsageKeyV2, AsideDefinitionKeyV2
|
||||
from xmodule.exceptions import UndefinedContext
|
||||
import dogstats_wrapper as dog_stats_api
|
||||
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
XMODULE_METRIC_NAME = 'edxapp.xmodule'
|
||||
@@ -55,6 +57,10 @@ DEPRECATION_VSCOMPAT_EVENT = 'deprecation.vscompat'
|
||||
# the XBlock also implements author_view.
|
||||
STUDENT_VIEW = 'student_view'
|
||||
|
||||
# This is the view that will be rendered to display the XBlock in the LMS for unenrolled learners.
|
||||
# Implementations of this view should assume that a user and user data are not available.
|
||||
PUBLIC_VIEW = 'public_view'
|
||||
|
||||
# An optional view of the XBlock similar to student_view, but with possible inline
|
||||
# editing capabilities. This view differs from studio_view in that it should be as similar to student_view
|
||||
# as possible. When previewing XBlocks within Studio, Studio will prefer author_view to student_view.
|
||||
@@ -65,8 +71,9 @@ AUTHOR_VIEW = 'author_view'
|
||||
STUDIO_VIEW = 'studio_view'
|
||||
|
||||
# Views that present a "preview" view of an xblock (as opposed to an editing view).
|
||||
PREVIEW_VIEWS = [STUDENT_VIEW, AUTHOR_VIEW]
|
||||
PREVIEW_VIEWS = [STUDENT_VIEW, PUBLIC_VIEW, AUTHOR_VIEW]
|
||||
|
||||
DEFAULT_PUBLIC_VIEW_MESSAGE = u'Please enroll to view this content.'
|
||||
|
||||
# Make '_' a no-op so we can scrape strings. Using lambda instead of
|
||||
# `django.utils.translation.ugettext_noop` because Django cannot be imported in this file
|
||||
@@ -759,6 +766,17 @@ class XModuleMixin(XModuleFields, XBlock):
|
||||
|
||||
return metadata_field_editor_info
|
||||
|
||||
def public_view(self, _context):
|
||||
"""
|
||||
Default message for blocks that don't implement public_view
|
||||
"""
|
||||
alert_html = HTML(
|
||||
u'<div class="page-banner"><div class="alert alert-warning">'
|
||||
u'<span class="icon icon-alert fa fa fa-warning" aria-hidden="true"></span>'
|
||||
u'<div class="message-content">{}</div></div></div>'
|
||||
)
|
||||
return Fragment(alert_html.format(DEFAULT_PUBLIC_VIEW_MESSAGE))
|
||||
|
||||
|
||||
class ProxyAttribute(object):
|
||||
"""
|
||||
@@ -1223,6 +1241,7 @@ class XModuleDescriptor(HTMLSnippet, ResourceTemplates, XModuleMixin):
|
||||
get_score = module_attr('get_score')
|
||||
handle_ajax = module_attr('handle_ajax')
|
||||
student_view = module_attr(STUDENT_VIEW)
|
||||
public_view = module_attr(PUBLIC_VIEW)
|
||||
get_child_descriptors = module_attr('get_child_descriptors')
|
||||
xmodule_handler = module_attr('xmodule_handler')
|
||||
|
||||
|
||||
@@ -144,7 +144,10 @@ class MilestonesAndSpecialExamsTransformer(BlockStructureTransformer):
|
||||
|
||||
"""
|
||||
course_key = block_structure.root_block_usage_key.course_key
|
||||
user_can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(usage_info.user, course_key)
|
||||
user_can_skip_entrance_exam = False
|
||||
if usage_info.user.is_authenticated:
|
||||
user_can_skip_entrance_exam = EntranceExamConfiguration.user_can_skip_entrance_exam(
|
||||
usage_info.user, course_key)
|
||||
required_content = milestones_helpers.get_required_content(course_key, usage_info.user)
|
||||
|
||||
if not required_content:
|
||||
|
||||
@@ -59,8 +59,6 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi
|
||||
from openedx.core.djangoapps.crawlers.models import CrawlersConfig
|
||||
from openedx.core.djangoapps.credit.api import set_credit_requirements
|
||||
from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider
|
||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace
|
||||
|
||||
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag
|
||||
from openedx.core.djangolib.testing.utils import get_mock_request
|
||||
from openedx.core.lib.gating import api as gating_api
|
||||
@@ -71,6 +69,7 @@ from openedx.features.course_duration_limits.models import CourseDurationLimitCo
|
||||
from openedx.features.course_experience.tests.views.helpers import add_course_mode
|
||||
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
|
||||
from openedx.features.course_experience import (
|
||||
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
|
||||
COURSE_OUTLINE_PAGE_FLAG,
|
||||
UNIFIED_COURSE_TAB_FLAG,
|
||||
)
|
||||
@@ -79,13 +78,15 @@ from student.tests.factories import TEST_PASSWORD, AdminFactory, CourseEnrollmen
|
||||
from util.tests.test_date_utils import fake_pgettext, fake_ugettext
|
||||
from util.url import reload_django_url_config
|
||||
from util.views import ensure_valid_course_key
|
||||
from xmodule.course_module import COURSE_VISIBILITY_PRIVATE, COURSE_VISIBILITY_PUBLIC_OUTLINE, COURSE_VISIBILITY_PUBLIC
|
||||
from xmodule.graders import ShowCorrectness
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
TEST_DATA_MIXED_MODULESTORE,
|
||||
ModuleStoreTestCase,
|
||||
SharedModuleStoreTestCase
|
||||
SharedModuleStoreTestCase,
|
||||
CourseUserType,
|
||||
)
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
|
||||
|
||||
@@ -2332,7 +2333,6 @@ class TestIndexView(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests of the courseware.views.index view.
|
||||
"""
|
||||
SEO_WAFFLE_FLAG = CourseWaffleFlag(WaffleFlagNamespace(name='seo'), 'enable_anonymous_courseware_access')
|
||||
|
||||
@XBlock.register_temp_plugin(ViewCheckerBlock, 'view_checker')
|
||||
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
||||
@@ -2402,12 +2402,48 @@ class TestIndexView(ModuleStoreTestCase):
|
||||
)
|
||||
self.assertIn("Activate Block ID: test_block_id", response.content)
|
||||
|
||||
def test_anonymous_access(self):
|
||||
course = CourseFactory()
|
||||
@ddt.data(
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, False],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, False],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, False],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, False],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, False],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, False],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, True],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ENROLLED, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ENROLLED, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED_STAFF, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED_STAFF, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.GLOBAL_STAFF, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.GLOBAL_STAFF, True],
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_courseware_access(self, waffle_override, course_visibility, user_type, expected_course_content):
|
||||
|
||||
course = CourseFactory(course_visibility=course_visibility)
|
||||
with self.store.bulk_operations(course.id):
|
||||
chapter = ItemFactory(parent=course, category='chapter')
|
||||
section = ItemFactory(parent=chapter, category='sequential')
|
||||
ItemFactory.create(parent=section, category='vertical', display_name="Vertical")
|
||||
vertical = ItemFactory.create(parent=section, category='vertical', display_name="Vertical")
|
||||
ItemFactory.create(parent=vertical, category='html', display_name='HTML block')
|
||||
ItemFactory.create(parent=vertical, category='video', display_name='Video')
|
||||
|
||||
self.create_user_for_course(course, user_type)
|
||||
|
||||
url = reverse(
|
||||
'courseware_section',
|
||||
@@ -2417,23 +2453,27 @@ class TestIndexView(ModuleStoreTestCase):
|
||||
'section': section.url_name,
|
||||
}
|
||||
)
|
||||
response = self.client.get(url, follow=False)
|
||||
assert response.status_code == 302
|
||||
|
||||
waffle_flag = CourseWaffleFlag(WaffleFlagNamespace(name='seo'), 'enable_anonymous_courseware_access')
|
||||
with override_waffle_flag(waffle_flag, active=True):
|
||||
with override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=waffle_override):
|
||||
|
||||
response = self.client.get(url, follow=False)
|
||||
assert response.status_code == 200
|
||||
self.assertIn('data-save-position="false"', response.content)
|
||||
self.assertIn('data-show-completion="false"', response.content)
|
||||
assert response.status_code == (200 if expected_course_content else 302)
|
||||
|
||||
user = UserFactory()
|
||||
CourseEnrollmentFactory(user=user, course_id=course.id)
|
||||
self.assertTrue(self.client.login(username=user.username, password='test'))
|
||||
response = self.client.get(url, follow=False)
|
||||
assert response.status_code == 200
|
||||
self.assertIn('data-save-position="true"', response.content)
|
||||
self.assertIn('data-show-completion="true"', response.content)
|
||||
if expected_course_content:
|
||||
if user_type in (CourseUserType.ANONYMOUS, CourseUserType.UNENROLLED):
|
||||
self.assertIn('data-save-position="false"', response.content)
|
||||
self.assertIn('data-show-completion="false"', response.content)
|
||||
self.assertIn('xblock-public_view-sequential', response.content)
|
||||
self.assertIn('xblock-public_view-vertical', response.content)
|
||||
self.assertIn('xblock-public_view-html', response.content)
|
||||
self.assertIn('xblock-public_view-video', response.content)
|
||||
else:
|
||||
self.assertIn('data-save-position="true"', response.content)
|
||||
self.assertIn('data-show-completion="true"', response.content)
|
||||
self.assertIn('xblock-student_view-sequential', response.content)
|
||||
self.assertIn('xblock-student_view-vertical', response.content)
|
||||
self.assertIn('xblock-student_view-html', response.content)
|
||||
self.assertIn('xblock-student_view-video', response.content)
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
|
||||
@@ -24,6 +24,7 @@ from opaque_keys.edx.keys import CourseKey
|
||||
from web_fragments.fragment import Fragment
|
||||
|
||||
from edxmako.shortcuts import render_to_response, render_to_string
|
||||
|
||||
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
|
||||
from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context
|
||||
from lms.djangoapps.gating.api import get_entrance_exam_score_ratio, get_entrance_exam_usage_key
|
||||
@@ -32,21 +33,24 @@ from openedx.core.djangoapps.crawlers.models import CrawlersConfig
|
||||
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
|
||||
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace, WaffleFlagNamespace, CourseWaffleFlag
|
||||
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.features.course_duration_limits.access import register_course_expired_message
|
||||
from openedx.features.course_experience import COURSE_OUTLINE_PAGE_FLAG, default_course_url_name
|
||||
from openedx.features.course_experience import (
|
||||
COURSE_OUTLINE_PAGE_FLAG, default_course_url_name, COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
|
||||
)
|
||||
from openedx.features.course_experience.views.course_sock import CourseSockFragmentView
|
||||
from openedx.features.enterprise_support.api import data_sharing_consent_required
|
||||
from shoppingcart.models import CourseRegistrationCode
|
||||
from student.views import is_course_blocked
|
||||
from util.views import ensure_valid_course_key
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.x_module import STUDENT_VIEW
|
||||
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC
|
||||
from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW
|
||||
from .views import CourseTabView
|
||||
from ..access import has_access
|
||||
from ..access_utils import check_course_open_for_learner
|
||||
from ..courses import get_course_with_access, get_current_child, get_studio_url
|
||||
from ..courses import check_course_access, get_course_with_access, get_current_child, get_studio_url
|
||||
from ..entrance_exams import (
|
||||
course_has_entrance_exam,
|
||||
get_entrance_exam_content,
|
||||
@@ -69,9 +73,8 @@ class CoursewareIndex(View):
|
||||
"""
|
||||
|
||||
@cached_property
|
||||
def enable_anonymous_courseware_access(self):
|
||||
waffle_flag = CourseWaffleFlag(WaffleFlagNamespace(name='seo'), 'enable_anonymous_courseware_access')
|
||||
return waffle_flag.is_enabled(self.course_key)
|
||||
def enable_unenrolled_access(self):
|
||||
return COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(self.course_key)
|
||||
|
||||
@method_decorator(ensure_csrf_cookie)
|
||||
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
|
||||
@@ -98,7 +101,7 @@ class CoursewareIndex(View):
|
||||
"""
|
||||
self.course_key = CourseKey.from_string(course_id)
|
||||
|
||||
if not (request.user.is_authenticated or self.enable_anonymous_courseware_access):
|
||||
if not (request.user.is_authenticated or self.enable_unenrolled_access):
|
||||
return redirect_to_login(request.get_full_path())
|
||||
|
||||
self.original_chapter_url_name = chapter
|
||||
@@ -114,11 +117,34 @@ class CoursewareIndex(View):
|
||||
set_custom_metrics_for_course_key(self.course_key)
|
||||
self._clean_position()
|
||||
with modulestore().bulk_operations(self.course_key):
|
||||
|
||||
self.view = STUDENT_VIEW
|
||||
|
||||
# Do the enrollment check if enable_unenrolled_access is not enabled.
|
||||
self.course = get_course_with_access(
|
||||
request.user, 'load', self.course_key,
|
||||
depth=CONTENT_DEPTH,
|
||||
check_if_enrolled=not self.enable_anonymous_courseware_access,
|
||||
check_if_enrolled=not self.enable_unenrolled_access,
|
||||
)
|
||||
|
||||
if self.enable_unenrolled_access:
|
||||
# Check if the user is considered enrolled (i.e. is an enrolled learner or staff).
|
||||
try:
|
||||
check_course_access(
|
||||
self.course, request.user, 'load', check_if_enrolled=True,
|
||||
)
|
||||
except CourseAccessRedirect as exception:
|
||||
# If the user is not considered enrolled:
|
||||
if self.course.course_visibility == COURSE_VISIBILITY_PUBLIC:
|
||||
# If course visibility is public show the XBlock public_view.
|
||||
self.view = PUBLIC_VIEW
|
||||
else:
|
||||
# Otherwise deny them access.
|
||||
raise exception
|
||||
else:
|
||||
# If the user is considered enrolled show the default XBlock student_view.
|
||||
pass
|
||||
|
||||
self.is_staff = has_access(request.user, 'staff', self.course)
|
||||
self._setup_masquerade_for_effective_user()
|
||||
register_course_expired_message(request, self.course)
|
||||
@@ -438,7 +464,9 @@ class CoursewareIndex(View):
|
||||
table_of_contents['previous_of_active_section'],
|
||||
table_of_contents['next_of_active_section'],
|
||||
)
|
||||
courseware_context['fragment'] = self.section.render(STUDENT_VIEW, section_context)
|
||||
|
||||
courseware_context['fragment'] = self.section.render(self.view, section_context)
|
||||
|
||||
if self.section.position and self.section.has_children:
|
||||
self._add_sequence_title_to_context(courseware_context)
|
||||
|
||||
|
||||
@@ -139,7 +139,8 @@ html.video-fullscreen {
|
||||
word-break: break-word;
|
||||
margin: 0 auto;
|
||||
|
||||
&.xblock-student_view-vertical {
|
||||
&.xblock-student_view-vertical,
|
||||
&.xblock-public_view-vertical {
|
||||
max-width: $text-width-readability-max;
|
||||
}
|
||||
}
|
||||
@@ -581,7 +582,8 @@ html.video-fullscreen {
|
||||
padding: 0 0 15px;
|
||||
}
|
||||
|
||||
.vert > .xblock-student_view.is-hidden {
|
||||
.vert > .xblock-student_view.is-hidden,
|
||||
.vert > .xblock-public_view.is-hidden {
|
||||
display: none;
|
||||
border-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
@@ -594,7 +596,8 @@ html.video-fullscreen {
|
||||
}
|
||||
}
|
||||
|
||||
section.xblock-student_view-wrapper div.vert-mod > div {
|
||||
section.xblock-student_view-wrapper div.vert-mod > div,
|
||||
section.xblock-public_view-wrapper div.vert-mod > div {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@ LATEST_UPDATE_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'latest_update')
|
||||
# Waffle flag to enable the use of Bootstrap for course experience pages
|
||||
USE_BOOTSTRAP_FLAG = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'use_bootstrap', flag_undefined_default=True)
|
||||
|
||||
# Waffle flag to enable anonymous access to a course
|
||||
SEO_WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='seo')
|
||||
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG = CourseWaffleFlag(SEO_WAFFLE_FLAG_NAMESPACE, 'enable_anonymous_courseware_access')
|
||||
|
||||
|
||||
def course_home_page_title(course): # pylint: disable=unused-argument
|
||||
"""
|
||||
|
||||
@@ -51,7 +51,7 @@ course_sections = blocks.get('children')
|
||||
completed_prereqs = gated_content[subsection['id']]['completed_prereqs'] if gated_subsection else False
|
||||
subsection_is_auto_opened = subsection.get('resume_block') is True
|
||||
%>
|
||||
<li class="subsection accordion ${ 'current' if subsection['resume_block'] else '' }">
|
||||
<li class="subsection accordion ${ 'current' if subsection.get('resume_block') else '' }">
|
||||
% if gated_subsection and not completed_prereqs:
|
||||
<a href="${ subsection['lms_web_url'] }">
|
||||
<button class="subsection-text prerequisite-button"
|
||||
@@ -153,7 +153,11 @@ course_sections = blocks.get('children')
|
||||
% for vertical in subsection.get('children', []):
|
||||
<li class="vertical outline-item focusable">
|
||||
<a class="outline-item focusable"
|
||||
href="${ vertical['lms_web_url'] }"
|
||||
% if enable_links:
|
||||
href="${ vertical['lms_web_url'] }"
|
||||
% else:
|
||||
aria-disabled="true"
|
||||
% endif
|
||||
id="${ vertical['id'] }">
|
||||
<div class="vertical-details">
|
||||
<div class="vertical-title">
|
||||
|
||||
@@ -38,11 +38,13 @@ from openedx.features.course_duration_limits.models import CourseDurationLimitCo
|
||||
from openedx.features.course_experience import (
|
||||
SHOW_REVIEWS_TOOL_FLAG,
|
||||
SHOW_UPGRADE_MSG_ON_COURSE_HOME,
|
||||
UNIFIED_COURSE_TAB_FLAG
|
||||
UNIFIED_COURSE_TAB_FLAG,
|
||||
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
|
||||
)
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
from util.date_utils import strftime_localized
|
||||
from xmodule.course_module import COURSE_VISIBILITY_PRIVATE, COURSE_VISIBILITY_PUBLIC_OUTLINE, COURSE_VISIBILITY_PUBLIC
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
|
||||
@@ -239,37 +241,76 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
|
||||
|
||||
@override_waffle_flag(SHOW_REVIEWS_TOOL_FLAG, active=True)
|
||||
@ddt.data(
|
||||
[CourseUserType.ANONYMOUS, 'To see course content'],
|
||||
[CourseUserType.ENROLLED, None],
|
||||
[CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
|
||||
[CourseUserType.UNENROLLED_STAFF, 'You must be enrolled in the course to see course content.'],
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, True, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, True, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, True, False],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, True, False],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, True, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, True, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, True, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, True, False],
|
||||
[False, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, True, False],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, True, False],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, True, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, True, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, False, True],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, False, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ENROLLED, False, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ENROLLED, False, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True, True],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED_STAFF, True, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED_STAFF, True, True],
|
||||
|
||||
[False, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True, True],
|
||||
[True, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.GLOBAL_STAFF, True, True],
|
||||
[True, COURSE_VISIBILITY_PUBLIC, CourseUserType.GLOBAL_STAFF, True, True],
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_home_page(self, user_type, expected_message):
|
||||
def test_home_page(
|
||||
self, enable_unenrolled_access, course_visibility, user_type,
|
||||
expected_enroll_message, expected_course_outline,
|
||||
):
|
||||
self.create_user_for_course(self.course, user_type)
|
||||
|
||||
# Render the course home page
|
||||
url = course_home_url(self.course)
|
||||
response = self.client.get(url)
|
||||
with mock.patch('xmodule.course_module.CourseDescriptor.course_visibility', course_visibility):
|
||||
# Test access with anonymous flag and course visibility
|
||||
with override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, enable_unenrolled_access):
|
||||
url = course_home_url(self.course)
|
||||
response = self.client.get(url)
|
||||
|
||||
# Verify that the course tools and dates are always shown
|
||||
self.assertContains(response, TEST_COURSE_TOOLS)
|
||||
self.assertContains(response, TEST_COURSE_TODAY)
|
||||
|
||||
# Verify that the outline, start button, course sock, and welcome message
|
||||
# are only shown to enrolled users.
|
||||
is_anonymous = user_type is CourseUserType.ANONYMOUS
|
||||
is_enrolled = user_type is CourseUserType.ENROLLED
|
||||
is_unenrolled_staff = user_type is CourseUserType.UNENROLLED_STAFF
|
||||
expected_count = 1 if (is_enrolled or is_unenrolled_staff) else 0
|
||||
self.assertContains(response, TEST_CHAPTER_NAME, count=expected_count)
|
||||
self.assertContains(response, 'Start Course', count=expected_count)
|
||||
is_enrolled_or_staff = is_enrolled or user_type in (
|
||||
CourseUserType.UNENROLLED_STAFF, CourseUserType.GLOBAL_STAFF
|
||||
)
|
||||
|
||||
self.assertContains(response, 'Learn About Verified Certificate', count=(1 if is_enrolled else 0))
|
||||
self.assertContains(response, TEST_WELCOME_MESSAGE, count=expected_count)
|
||||
|
||||
# Verify that start button, course sock, and welcome message
|
||||
# are only shown to enrolled users or staff.
|
||||
self.assertContains(response, 'Start Course', count=(1 if is_enrolled_or_staff else 0))
|
||||
self.assertContains(response, TEST_WELCOME_MESSAGE, count=(1 if is_enrolled_or_staff else 0))
|
||||
|
||||
# Verify the outline is shown to enrolled users, unenrolled_staff and anonymous users if allowed
|
||||
self.assertContains(response, TEST_CHAPTER_NAME, count=(1 if expected_course_outline else 0))
|
||||
|
||||
# Verify that the expected message is shown to the user
|
||||
self.assertContains(response, '<div class="user-messages"', count=1 if expected_message else 0)
|
||||
if expected_message:
|
||||
self.assertContains(response, expected_message)
|
||||
self.assertContains(
|
||||
response, 'To see course content', count=(1 if is_anonymous else 0)
|
||||
)
|
||||
self.assertContains(response, '<div class="user-messages"', count=(1 if expected_enroll_message else 0))
|
||||
if expected_enroll_message:
|
||||
self.assertContains(response, 'You must be enrolled in the course to see course content.')
|
||||
|
||||
@override_waffle_flag(UNIFIED_COURSE_TAB_FLAG, active=False)
|
||||
@override_waffle_flag(SHOW_REVIEWS_TOOL_FLAG, active=True)
|
||||
|
||||
@@ -11,11 +11,13 @@ from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
@request_cached()
|
||||
def get_course_outline_block_tree(request, course_id):
|
||||
def get_course_outline_block_tree(request, course_id, user=None):
|
||||
"""
|
||||
Returns the root block of the course outline, with children as blocks.
|
||||
"""
|
||||
|
||||
assert user is None or user.is_authenticated
|
||||
|
||||
def populate_children(block, all_blocks):
|
||||
"""
|
||||
Replace each child id with the full block for the child.
|
||||
@@ -156,13 +158,13 @@ def get_course_outline_block_tree(request, course_id):
|
||||
course_outline_root_block = all_blocks['blocks'].get(all_blocks['root'], None)
|
||||
if course_outline_root_block:
|
||||
populate_children(course_outline_root_block, all_blocks['blocks'])
|
||||
set_last_accessed_default(course_outline_root_block)
|
||||
|
||||
mark_blocks_completed(
|
||||
block=course_outline_root_block,
|
||||
user=request.user,
|
||||
course_key=course_key
|
||||
)
|
||||
if user:
|
||||
set_last_accessed_default(course_outline_root_block)
|
||||
mark_blocks_completed(
|
||||
block=course_outline_root_block,
|
||||
user=request.user,
|
||||
course_key=course_key
|
||||
)
|
||||
return course_outline_root_block
|
||||
|
||||
|
||||
|
||||
@@ -28,8 +28,11 @@ from openedx.core.djangoapps.util.maintenance_banner import add_maintenance_bann
|
||||
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
|
||||
from student.models import CourseEnrollment
|
||||
from util.views import ensure_valid_course_key
|
||||
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC_OUTLINE, COURSE_VISIBILITY_PUBLIC
|
||||
|
||||
from .. import LATEST_UPDATE_FLAG, SHOW_UPGRADE_MSG_ON_COURSE_HOME, USE_BOOTSTRAP_FLAG
|
||||
from .. import (
|
||||
LATEST_UPDATE_FLAG, SHOW_UPGRADE_MSG_ON_COURSE_HOME, USE_BOOTSTRAP_FLAG, COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
|
||||
)
|
||||
from ..utils import get_course_outline_block_tree, get_resume_block
|
||||
from .course_dates import CourseDatesFragmentView
|
||||
from .course_home_messages import CourseHomeMessageFragmentView
|
||||
@@ -83,7 +86,7 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
otherwise the URL of the course root.
|
||||
|
||||
"""
|
||||
course_outline_root_block = get_course_outline_block_tree(request, course_id)
|
||||
course_outline_root_block = get_course_outline_block_tree(request, course_id, request.user)
|
||||
resume_block = get_resume_block(course_outline_root_block) if course_outline_root_block else None
|
||||
has_visited_course = bool(resume_block)
|
||||
if resume_block:
|
||||
@@ -117,11 +120,26 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
enrollment = CourseEnrollment.get_enrollment(request.user, course_key)
|
||||
user_access = {
|
||||
'is_anonymous': request.user.is_anonymous,
|
||||
'is_enrolled': enrollment is not None,
|
||||
'is_enrolled': enrollment and enrollment.is_active,
|
||||
'is_staff': has_access(request.user, 'staff', course_key),
|
||||
}
|
||||
|
||||
allow_anonymous = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course_key)
|
||||
allow_public = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC
|
||||
allow_public_outline = allow_anonymous and course.course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE
|
||||
|
||||
# Set all the fragments
|
||||
outline_fragment = None
|
||||
update_message_fragment = None
|
||||
course_sock_fragment = None
|
||||
has_visited_course = None
|
||||
resume_course_url = None
|
||||
handouts_html = None
|
||||
|
||||
if user_access['is_enrolled'] or user_access['is_staff']:
|
||||
outline_fragment = CourseOutlineFragmentView().render_to_fragment(request, course_id=course_id, **kwargs)
|
||||
outline_fragment = CourseOutlineFragmentView().render_to_fragment(
|
||||
request, course_id=course_id, **kwargs
|
||||
)
|
||||
if LATEST_UPDATE_FLAG.is_enabled(course_key):
|
||||
update_message_fragment = LatestUpdateFragmentView().render_to_fragment(
|
||||
request, course_id=course_id, **kwargs
|
||||
@@ -132,22 +150,18 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
)
|
||||
course_sock_fragment = CourseSockFragmentView().render_to_fragment(request, course=course, **kwargs)
|
||||
has_visited_course, resume_course_url = self._get_resume_course_info(request, course_id)
|
||||
handouts_html = self._get_course_handouts(request, course)
|
||||
elif allow_public_outline or allow_public:
|
||||
outline_fragment = CourseOutlineFragmentView().render_to_fragment(
|
||||
request, course_id=course_id, user_is_enrolled=False, **kwargs
|
||||
)
|
||||
course_sock_fragment = CourseSockFragmentView().render_to_fragment(request, course=course, **kwargs)
|
||||
else:
|
||||
# Redirect the user to the dashboard if they are not enrolled and
|
||||
# this is a course that does not support direct enrollment.
|
||||
if not can_self_enroll_in_course(course_key):
|
||||
raise CourseAccessRedirect(reverse('dashboard'))
|
||||
|
||||
# Set all the fragments
|
||||
outline_fragment = None
|
||||
update_message_fragment = None
|
||||
course_sock_fragment = None
|
||||
has_visited_course = None
|
||||
resume_course_url = None
|
||||
|
||||
# Get the handouts
|
||||
handouts_html = self._get_course_handouts(request, course)
|
||||
|
||||
# Get the course tools enabled for this user and course
|
||||
course_tools = CourseToolsPluginManager.get_enabled_course_tools(request, course_key)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from openedx.core.djangoapps.plugin_api.views import EdxFragmentView
|
||||
from student.models import CourseEnrollment
|
||||
|
||||
from util.milestones_helpers import get_course_content_milestones
|
||||
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from ..utils import get_course_outline_block_tree, get_resume_block
|
||||
|
||||
@@ -30,15 +31,19 @@ class CourseOutlineFragmentView(EdxFragmentView):
|
||||
Course outline fragment to be shown in the unified course view.
|
||||
"""
|
||||
|
||||
def render_to_fragment(self, request, course_id=None, **kwargs): # pylint: disable=arguments-differ
|
||||
def render_to_fragment(self, request, course_id, user_is_enrolled=True, **kwargs): # pylint: disable=arguments-differ
|
||||
"""
|
||||
Renders the course outline as a fragment.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course_overview = get_course_overview_with_access(request.user, 'load', course_key, check_if_enrolled=True)
|
||||
course_overview = get_course_overview_with_access(
|
||||
request.user, 'load', course_key, check_if_enrolled=user_is_enrolled
|
||||
)
|
||||
course = modulestore().get_course(course_key)
|
||||
|
||||
course_block_tree = get_course_outline_block_tree(request, course_id)
|
||||
course_block_tree = get_course_outline_block_tree(
|
||||
request, course_id, request.user if user_is_enrolled else None
|
||||
)
|
||||
if not course_block_tree:
|
||||
return None
|
||||
|
||||
@@ -46,10 +51,12 @@ class CourseOutlineFragmentView(EdxFragmentView):
|
||||
'csrf': csrf(request)['csrf_token'],
|
||||
'course': course_overview,
|
||||
'due_date_display_format': course.due_date_display_format,
|
||||
'blocks': course_block_tree
|
||||
'blocks': course_block_tree,
|
||||
'enable_links': user_is_enrolled or course.course_visibility == COURSE_VISIBILITY_PUBLIC,
|
||||
}
|
||||
|
||||
resume_block = get_resume_block(course_block_tree)
|
||||
resume_block = get_resume_block(course_block_tree) if user_is_enrolled else None
|
||||
|
||||
if not resume_block:
|
||||
self.mark_first_unit_to_resume(course_block_tree)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user