Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions cms/djangoapps/models/settings/course_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
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):
'''
Expand Down Expand Up @@ -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.
"""
Expand Down Expand Up @@ -117,6 +119,10 @@ def filtered_list(cls):
if not XBlockStudioConfigurationFlag.is_enabled():
filtered_list.append('allow_unsupported_xblocks')

# Do not show "Course Visibility For Unauthenticated Students" 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
Expand All @@ -128,7 +134,7 @@ def fetch(cls, descriptor):
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
Expand Down Expand Up @@ -163,7 +169,7 @@ def update_from_json(cls, descriptor, jsondict, user, filter_tabs=True):

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")
Expand Down Expand Up @@ -199,7 +205,7 @@ def validate_and_update_from_json(cls, descriptor, jsondict, user, filter_tabs=T
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")

Expand Down
19 changes: 19 additions & 0 deletions common/lib/xmodule/xmodule/course_module.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@

DEFAULT_MOBILE_AVAILABLE = getattr(settings, 'DEFAULT_MOBILE_AVAILABLE', False)

COURSE_VISIBILITY_PRIVATE = 'private'
COURSE_VISIBILITY_PREVIEW = 'preview'
COURSE_VISIBILITY_PUBLIC = 'public'


class StringOrDate(Date):
def from_json(self, value):
Expand Down Expand Up @@ -814,6 +818,21 @@ class CourseFields(object):
scope=Scope.settings
)

course_visibility = String(
display_name=_("Course Visibility For Unauthenticated Students"),
help=_(
"Defines the access permissions for unauthenticated users. This can be set to one of three values: "
"'private' (default visibility, only allowed for enrolled students), 'preview' (allow access to course "
"outline) and 'public' (allow full-access to course material)."
),
default=COURSE_VISIBILITY_PRIVATE,
scope=Scope.settings,
values=[
{"display_name": _("private"), "value": COURSE_VISIBILITY_PRIVATE},
{"display_name": _("preview"), "value": COURSE_VISIBILITY_PREVIEW},
{"display_name": _("public"), "value": COURSE_VISIBILITY_PUBLIC}]
)

"""
instructor_info dict structure:
{
Expand Down
5 changes: 4 additions & 1 deletion lms/djangoapps/course_api/blocks/transformers/milestones.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ def get_required_content(usage_info, block_structure):

"""
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:
Expand Down
37 changes: 24 additions & 13 deletions lms/djangoapps/courseware/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,13 @@
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
from openedx.core.lib.tests import attr
from openedx.core.lib.url_utils import quote_slashes
from openedx.features.course_experience import COURSE_OUTLINE_PAGE_FLAG, UNIFIED_COURSE_TAB_FLAG
from openedx.features.course_experience import COURSE_OUTLINE_PAGE_FLAG, UNIFIED_COURSE_TAB_FLAG, \
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
from student.models import CourseEnrollment
from student.tests.factories import TEST_PASSWORD, AdminFactory, CourseEnrollmentFactory, UserFactory
Expand Down Expand Up @@ -2265,7 +2265,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)
Expand Down Expand Up @@ -2335,8 +2334,17 @@ def test_activate_block_id(self):
)
self.assertIn("Activate Block ID: test_block_id", response.content)

def test_anonymous_access(self):
course = CourseFactory()
@ddt.data(
[False, 'private', False],
[False, 'preview', False],
[False, 'public', False],
[True, 'private', False],
[True, 'preview', False],
[True, 'public', True],
)
@ddt.unpack
def test_anonymous_unenrolled_access(self, waffle_override, course_visibility, allow_unenrolled):
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')
Expand All @@ -2350,19 +2358,22 @@ def test_anonymous_access(self):
'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):
# Test anonymous access
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 allow_unenrolled else 302

user = UserFactory()
CourseEnrollmentFactory(user=user, course_id=course.id)
self.assertTrue(self.client.login(username=user.username, password='test'))

# Test unenrolled access
with override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=waffle_override):
response = self.client.get(url, follow=False)
assert response.status_code == 200 if allow_unenrolled else 302

# Test enrolled access
CourseEnrollmentFactory(user=user, course_id=course.id)
response = self.client.get(url, follow=False)
assert response.status_code == 200
self.assertIn('data-save-position="true"', response.content)
Expand Down
20 changes: 12 additions & 8 deletions lms/djangoapps/courseware/views/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@
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_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.models import CourseEnrollment
from student.views import is_course_blocked
from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore
Expand Down Expand Up @@ -68,9 +70,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_courseware_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))
Expand All @@ -96,8 +97,8 @@ def get(self, request, course_id, chapter=None, section=None, position=None):
position (unicode): position in module, eg of <sequential> module
"""
self.course_key = CourseKey.from_string(course_id)

if not (request.user.is_authenticated or self.enable_anonymous_courseware_access):
is_enrolled = CourseEnrollment.is_enrolled(request.user, self.course_key)
if not (is_enrolled or self.enable_unenrolled_courseware_access):
return redirect_to_login(request.get_full_path())

self.original_chapter_url_name = chapter
Expand All @@ -116,8 +117,11 @@ def get(self, request, course_id, chapter=None, section=None, position=None):
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_courseware_access,
)
if not (is_enrolled or self.course.course_visibility == 'public'):
return redirect_to_login(request.get_full_path())

self.is_staff = has_access(request.user, 'staff', self.course)
self._setup_masquerade_for_effective_user()
return self.render(request)
Expand Down
1 change: 1 addition & 0 deletions lms/static/sass/features/_course-experience.scss
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@

list-style-type: none;

span.outline-item,
a.outline-item {
display: flex;
justify-content: space-between;
Expand Down
4 changes: 4 additions & 0 deletions openedx/features/course_experience/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
# 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_unenrolled_courseware_access')


def course_home_page_title(course): # pylint: disable=unused-argument
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ <h3 class="section-title">${ section['display_name'] }</h3>
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"
Expand Down Expand Up @@ -152,9 +152,13 @@ <h4 class="subsection-title">
>
% for vertical in subsection.get('children', []):
<li class="vertical outline-item focusable">
% if enable_links:
<a class="outline-item focusable"
href="${ vertical['lms_web_url'] }"
id="${ vertical['id'] }">
% else:
<span class="outline-item">
% endif
<div class="vertical-details">
<div class="vertical-title">
${ vertical['display_name'] }
Expand All @@ -163,7 +167,11 @@ <h4 class="subsection-title">
% if vertical.get('complete'):
<span class="complete-checkmark fa fa-check"></span>
% endif
% if enable_links:
</a>
% else:
</span>
% endif
</li>
% endfor
</ol>
Expand Down
47 changes: 33 additions & 14 deletions openedx/features/course_experience/tests/views/test_course_home.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
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
Expand Down Expand Up @@ -217,32 +217,51 @@ def tearDown(self):

@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, 'private', CourseUserType.ANONYMOUS, 'You must be enrolled in the course to see course content.'],
[False, 'preview', CourseUserType.ANONYMOUS, 'You must be enrolled in the course to see course content.'],
[False, 'public', CourseUserType.ANONYMOUS, 'You must be enrolled in the course to see course content.'],
[True, 'private', CourseUserType.ANONYMOUS, 'You must be enrolled in the course to see course content.'],
[True, 'preview', CourseUserType.ANONYMOUS, 'You must be enrolled in the course to see course content.'],
[True, 'public', CourseUserType.ANONYMOUS, 'You must be enrolled in the course to see course content.'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will likely need a different message for this case but it is something the edX UX can recommend.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add checks that CourseUserType.UNENROLLED see the same content as anonymous learners?

[False, 'private', CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
[False, 'preview', CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
[False, 'public', CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
[True, 'private', CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
[True, 'preview', CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
[True, 'public', CourseUserType.UNENROLLED, 'You must be enrolled in the course to see course content.'],
[False, 'private', CourseUserType.ENROLLED, None],
[False, 'private', CourseUserType.UNENROLLED_STAFF,
'You must be enrolled in the course to see course content.'],
)
@ddt.unpack
def test_home_page(self, user_type, expected_message):
def test_home_page(self, enable_anonymous_access, course_visibility, user_type, expected_message):
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_anonymous_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, 'Course Tools')
self.assertContains(response, 'Today is')

# Verify that the outline, start button, course sock, and welcome message
# Verify that start button, course sock, and welcome message
# are only shown to enrolled users.
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)
expected_welcome = 1 if (is_enrolled or is_unenrolled_staff) else 0
self.assertContains(response, 'Start Course', count=expected_welcome)
self.assertContains(response, 'Learn About Verified Certificate', count=(1 if is_enrolled else 0))
self.assertContains(response, TEST_WELCOME_MESSAGE, count=expected_count)
self.assertContains(response, TEST_WELCOME_MESSAGE, count=expected_welcome)

# Verify the outline is shown to enrolled users, unenrolled_staff and anonymous users if allowed
is_public = course_visibility == 'public' and enable_anonymous_access
is_preview = course_visibility == 'preview' and enable_anonymous_access
expected_chapter = 1 if (is_public or is_preview) else expected_welcome
self.assertContains(response, TEST_CHAPTER_NAME, count=expected_chapter)

# Verify that the expected message is shown to the user
self.assertContains(response, '<div class="user-messages">', count=1 if expected_message else 0)
Expand Down
14 changes: 8 additions & 6 deletions openedx/features/course_experience/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from lms.djangoapps.course_blocks.utils import get_student_module_as_dict
from opaque_keys.edx.keys import CourseKey
from openedx.core.djangoapps.request_cache.middleware import request_cached
from student.models import CourseEnrollment
from xmodule.modulestore.django import modulestore


Expand Down Expand Up @@ -156,13 +157,14 @@ def mark_last_accessed(user, course_key, block):
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 CourseEnrollment.is_enrolled(request.user, course_key):
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


Expand Down
Loading