Merge pull request #19385 from open-craft/pooja/implement-public-cohort

Implement public cohort for anonymous and unenrolled users
This commit is contained in:
David Ormsbee
2019-02-08 13:28:02 -05:00
committed by GitHub
15 changed files with 170 additions and 35 deletions

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
"""
Tests for the wrapping layer that provides the XBlock API using XModule/Descriptor
functionality
@@ -27,7 +28,7 @@ from xblock.core import XBlock
from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator
from xmodule.x_module import ModuleSystem, XModule, XModuleDescriptor, DescriptorSystem, STUDENT_VIEW, STUDIO_VIEW
from xmodule.x_module import ModuleSystem, XModule, XModuleDescriptor, DescriptorSystem, STUDENT_VIEW, STUDIO_VIEW, PUBLIC_VIEW
from xmodule.annotatable_module import AnnotatableDescriptor
from xmodule.capa_module import CapaDescriptor
from xmodule.course_module import CourseDescriptor
@@ -63,8 +64,8 @@ LEAF_XMODULES = {
CONTAINER_XMODULES = {
ConditionalDescriptor: [{}],
CourseDescriptor: [{}],
RandomizeDescriptor: [{}],
SequenceDescriptor: [{}],
RandomizeDescriptor: [{'display_name': 'Test String Display'}],
SequenceDescriptor: [{'display_name': u'Test Unicode हिंदी Display'}],
VerticalBlock: [{}],
WrapperBlock: [{}],
}
@@ -433,3 +434,34 @@ class TestXmlExport(XBlockWrapperTestMixin, TestCase):
self.assertEquals(list(xmodule_api_fs.walk()), list(xblock_api_fs.walk()))
self.assertEquals(etree.tostring(xmodule_node), etree.tostring(xblock_node))
class TestPublicView(XBlockWrapperTestMixin, TestCase):
"""
This tests that default public_view shows the correct message.
"""
shard = 1
def skip_if_invalid(self, descriptor_cls):
pure_xblock_class = issubclass(descriptor_cls, XBlock) and not issubclass(descriptor_cls, XModuleDescriptor)
if pure_xblock_class:
public_view = descriptor_cls.public_view
else:
public_view = descriptor_cls.module_class.public_view
if public_view != XModule.public_view:
raise SkipTest(descriptor_cls.__name__ + " implements public_view")
def check_property(self, descriptor):
"""
Assert that public_view contains correct message.
"""
if descriptor.display_name:
self.assertIn(
descriptor.display_name,
descriptor.render(PUBLIC_VIEW).content
)
else:
self.assertIn(
"This content is only accessible",
descriptor.render(PUBLIC_VIEW).content
)

View File

@@ -72,7 +72,10 @@ STUDIO_VIEW = 'studio_view'
# Views that present a "preview" view of an xblock (as opposed to an editing view).
PREVIEW_VIEWS = [STUDENT_VIEW, PUBLIC_VIEW, AUTHOR_VIEW]
DEFAULT_PUBLIC_VIEW_MESSAGE = u'Please enroll to view this content.'
DEFAULT_PUBLIC_VIEW_MESSAGE = (
u'This content is only accessible to enrolled learners. '
u'Sign in or register, and enroll in this course to view it.'
)
# 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
@@ -766,7 +769,18 @@ class XModuleMixin(XModuleFields, XBlock):
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))
if self.display_name:
display_text = _(
u'{display_name} is only accessible to enrolled learners. '
'Sign in or register, and enroll in this course to view it.'
).format(
display_name=self.display_name
)
else:
display_text = _(DEFAULT_PUBLIC_VIEW_MESSAGE)
return Fragment(alert_html.format(display_text))
class ProxyAttribute(object):

View File

@@ -98,7 +98,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
# Save back any changes
if any(block_keys[changed] for changed in ('invalid', 'overlimit', 'added')):
state_dict['selected'] = list(selected)
StudentModule.objects.update_or_create(
StudentModule.save_state( # pylint: disable=no-value-for-parameter
student=usage_info.user,
course_id=usage_info.course_key,
module_state_key=block_key,

View File

@@ -18,6 +18,9 @@ def get_student_module_as_dict(user, course_key, block_key):
Returns:
StudentModule as a (possibly empty) dict.
"""
if not user.is_authenticated():
return {}
try:
student_module = StudentModule.objects.get(
student=user,

View File

@@ -38,6 +38,7 @@ from opaque_keys.edx.keys import UsageKey
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.api.view_utils import LazySequence
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
from path import Path as path
from six import text_type
from static_replace import replace_static_urls
@@ -643,3 +644,13 @@ def get_course_chapter_ids(course_key):
log.exception('Failed to retrieve course from modulestore.')
return []
return [unicode(chapter_key) for chapter_key in chapter_keys if chapter_key.block_type == 'chapter']
def allow_public_access(course, visibilities):
"""
This checks if the unenrolled access waffle flag for the course is set
and the course visibility matches any of the input visibilities.
"""
unenrolled_access_flag = COURSE_ENABLE_UNENROLLED_ACCESS_FLAG.is_enabled(course.id)
allow_access = unenrolled_access_flag and course.course_visibility in visibilities
return allow_access

View File

@@ -161,6 +161,18 @@ class StudentModule(models.Model):
module_states = module_states.filter(student_id=student_id)
return module_states
@classmethod
def save_state(cls, student, course_id, module_state_key, defaults):
if not student.is_authenticated():
return
else:
cls.objects.update_or_create(
student=student,
course_id=course_id,
module_state_key=module_state_key,
defaults=defaults,
)
class BaseStudentModuleHistory(models.Model):
"""Abstract class containing most fields used by any class

View File

@@ -3,6 +3,7 @@ Test the about xblock
"""
import datetime
import ddt
import mock
import pytz
from ccx_keys.locator import CCXLocator
from django.conf import settings
@@ -16,14 +17,22 @@ from waffle.testutils import override_switch
from course_modes.models import CourseMode
from lms.djangoapps.ccx.tests.factories import CcxFactory
from openedx.core.lib.tests import attr
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from openedx.features.course_experience.waffle import WAFFLE_NAMESPACE as COURSE_EXPERIENCE_WAFFLE_NAMESPACE
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG
from shoppingcart.models import Order, PaidCourseRegistration
from student.models import CourseEnrollment
from student.tests.factories import AdminFactory, CourseEnrollmentAllowedFactory, UserFactory
from track.tests import EventTrackingTestCase
from util.milestones_helpers import get_prerequisite_courses_display, set_prerequisite_courses
from xmodule.course_module import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE
from xmodule.course_module import (
CATALOG_VISIBILITY_ABOUT,
CATALOG_VISIBILITY_NONE,
COURSE_VISIBILITY_PRIVATE,
COURSE_VISIBILITY_PUBLIC_OUTLINE,
COURSE_VISIBILITY_PUBLIC
)
from xmodule.modulestore.tests.django_utils import (
TEST_DATA_MIXED_MODULESTORE,
TEST_DATA_SPLIT_MODULESTORE,
@@ -41,6 +50,7 @@ REG_STR = "<form id=\"class_enroll_form\" method=\"post\" data-remote=\"true\" a
SHIB_ERROR_STR = "The currently logged-in user account does not have permission to enroll in this course."
@ddt.ddt
@attr(shard=1)
class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTrackingTestCase, MilestonesTestCaseMixin):
"""
@@ -222,6 +232,27 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
@ddt.data(
[COURSE_VISIBILITY_PRIVATE],
[COURSE_VISIBILITY_PUBLIC_OUTLINE],
[COURSE_VISIBILITY_PUBLIC],
)
@ddt.unpack
def test_about_page_public_view(self, course_visibility):
"""
Assert that anonymous or unenrolled users see View Course option
when unenrolled access flag is set
"""
with mock.patch('xmodule.course_module.CourseDescriptor.course_visibility', course_visibility):
with override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=True):
url = reverse('about_course', args=[text_type(self.course.id)])
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
if course_visibility == COURSE_VISIBILITY_PUBLIC or course_visibility == COURSE_VISIBILITY_PUBLIC_OUTLINE:
self.assertIn("View Course", resp.content)
else:
self.assertIn("Enroll in", resp.content)
@attr(shard=1)
class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase):

View File

@@ -2472,6 +2472,10 @@ class TestIndexView(ModuleStoreTestCase):
self.assertIn('xblock-public_view-vertical', response.content)
self.assertIn('xblock-public_view-html', response.content)
self.assertIn('xblock-public_view-video', response.content)
if user_type == CourseUserType.ANONYMOUS and course_visibility == COURSE_VISIBILITY_PRIVATE:
self.assertIn('To see course content', response.content)
if user_type == CourseUserType.UNENROLLED and course_visibility == COURSE_VISIBILITY_PRIVATE:
self.assertIn('You must be enrolled', response.content)
else:
self.assertIn('data-save-position="true"', response.content)
self.assertIn('data-show-completion="true"', response.content)

View File

@@ -25,6 +25,7 @@ from web_fragments.fragment import Fragment
from edxmako.shortcuts import render_to_response, render_to_string
from lms.djangoapps.courseware.courses import allow_public_access
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
@@ -195,20 +196,23 @@ class CoursewareIndex(View):
'email_opt_in': False,
})
PageLevelMessages.register_warning_message(
request,
Text(_(u"You are not signed in. To see additional course content, {sign_in_link} or "
u"{register_link}, and enroll in this course.")).format(
sign_in_link=HTML(u'<a href="{url}">{sign_in_label}</a>').format(
sign_in_label=_('sign in'),
url='{}?{}'.format(reverse('signin_user'), qs),
),
register_link=HTML(u'<a href="/{url}">{register_label}</a>').format(
register_label=_('register'),
url='{}?{}'.format(reverse('register_user'), qs),
),
allow_anonymous = allow_public_access(self.course, [COURSE_VISIBILITY_PUBLIC])
if not allow_anonymous:
PageLevelMessages.register_warning_message(
request,
Text(_("You are not signed in. To see additional course content, {sign_in_link} or "
"{register_link}, and enroll in this course.")).format(
sign_in_link=HTML('<a href="{url}">{sign_in_label}</a>').format(
sign_in_label=_('sign in'),
url='{}?{}'.format(reverse('signin_user'), qs),
),
register_link=HTML('<a href="/{url}">{register_label}</a>').format(
register_label=_('register'),
url='{}?{}'.format(reverse('register_user'), qs),
),
)
)
)
return render_to_response('courseware/courseware.html', self._create_courseware_context(request))

View File

@@ -65,6 +65,7 @@ from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
from lms.djangoapps.certificates import api as certs_api
from lms.djangoapps.certificates.models import CertificateStatuses
from lms.djangoapps.commerce.utils import EcommerceService
from lms.djangoapps.courseware.courses import allow_public_access
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect
from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
@@ -87,7 +88,11 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangolib.markup import HTML, Text
from openedx.features.course_duration_limits.access import generate_course_expired_fragment
from openedx.features.course_experience import UNIFIED_COURSE_TAB_FLAG, course_home_url_name
from openedx.features.course_experience import (
UNIFIED_COURSE_TAB_FLAG,
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG,
course_home_url_name,
)
from openedx.features.course_experience.course_tools import CourseToolsPluginManager
from openedx.features.course_experience.views.course_dates import CourseDatesFragmentView
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
@@ -101,6 +106,7 @@ from util.cache import cache, cache_if_anonymous
from util.db import outer_atomic
from util.milestones_helpers import get_prerequisite_courses_display
from util.views import _record_feedback_in_zendesk, ensure_valid_course_key, ensure_valid_usage_key
from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE
from web_fragments.fragment import Fragment
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
@@ -459,7 +465,7 @@ class StaticCourseTabView(EdxFragmentView):
raise Http404
# Show warnings if the user has limited access
CourseTabView.register_user_access_warning_messages(request, course_key)
CourseTabView.register_user_access_warning_messages(request, course)
return super(StaticCourseTabView, self).get(request, course=course, tab=tab, **kwargs)
@@ -504,7 +510,7 @@ class CourseTabView(EdxFragmentView):
# Show warnings if the user has limited access
# Must come after masquerading on creation of page context
self.register_user_access_warning_messages(request, course_key)
self.register_user_access_warning_messages(request, course)
set_custom_metrics_for_course_key(course_key)
return super(CourseTabView, self).get(request, course=course, page_context=page_context, **kwargs)
@@ -522,11 +528,13 @@ class CourseTabView(EdxFragmentView):
return url_to_enroll
@staticmethod
def register_user_access_warning_messages(request, course_key):
def register_user_access_warning_messages(request, course):
"""
Register messages to be shown to the user if they have limited access.
"""
if request.user.is_anonymous:
allow_anonymous = allow_public_access(course, [COURSE_VISIBILITY_PUBLIC])
if request.user.is_anonymous and not allow_anonymous:
PageLevelMessages.register_warning_message(
request,
Text(_(u"To see course content, {sign_in_link} or {register_link}.")).format(
@@ -541,9 +549,9 @@ class CourseTabView(EdxFragmentView):
)
)
else:
if not CourseEnrollment.is_enrolled(request.user, course_key):
if not CourseEnrollment.is_enrolled(request.user, course.id) and not allow_anonymous:
# Only show enroll button if course is open for enrollment.
if course_open_for_self_enrollment(course_key):
if course_open_for_self_enrollment(course.id):
enroll_message = _(u'You must be enrolled in the course to see course content. \
{enroll_link_start}Enroll now{enroll_link_end}.')
PageLevelMessages.register_warning_message(
@@ -840,6 +848,8 @@ def course_about(request, course_id):
sidebar_html_enabled = course_experience_waffle().is_enabled(ENABLE_COURSE_ABOUT_SIDEBAR_HTML)
allow_anonymous = allow_public_access(course, [COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE])
# This local import is due to the circularity of lms and openedx references.
# This may be resolved by using stevedore to allow web fragments to be used
# as plugins, and to avoid the direct import.
@@ -878,6 +888,7 @@ def course_about(request, course_id):
'course_image_urls': overview.image_urls,
'reviews_fragment_view': reviews_fragment_view,
'sidebar_html_enabled': sidebar_html_enabled,
'allow_anonymous': allow_anonymous,
}
return render_to_response('courseware/course_about.html', context)

View File

@@ -168,6 +168,12 @@ from six import string_types
.format(course_name=course.display_number_with_default, price=course_price)}
</a>
<div id="register_error"></div>
%elif allow_anonymous:
%if show_courseware_link:
<a href="${course_target}">
<strong>${_("View Course")}</strong>
</a>
%endif
%else:
<%
if ecommerce_checkout:

View File

@@ -391,6 +391,10 @@ class TestCohorts(ModuleStoreTestCase):
Anonymous user is not assigned to any cohort group.
"""
course = modulestore().get_course(self.toy_course_key)
# verify cohorts is None when course is not cohorted
self.assertIsNone(cohorts.get_cohort(AnonymousUser(), course.id))
config_course_cohorts(
course,
is_cohorted=True,

View File

@@ -313,12 +313,13 @@ class TestCourseHomePageAccess(CourseHomePageTestCase):
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, '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.')
if not enable_unenrolled_access or course_visibility != COURSE_VISIBILITY_PUBLIC:
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)

View File

@@ -163,6 +163,8 @@ class CourseHomeFragmentView(EdxFragmentView):
request, course_id=course_id, user_is_enrolled=False, **kwargs
)
course_sock_fragment = CourseSockFragmentView().render_to_fragment(request, course=course, **kwargs)
if allow_public:
handouts_html = self._get_course_handouts(request, course)
else:
# Redirect the user to the dashboard if they are not enrolled and
# this is a course that does not support direct enrollment.

View File

@@ -130,8 +130,8 @@ def _register_course_home_messages(request, course, user_access, course_start_da
Text(_(
'{open_enroll_link}Enroll now{close_enroll_link} to access the full course.'
)).format(
open_enroll_link='',
close_enroll_link=''
open_enroll_link=HTML('<button class="enroll-btn btn-link">'),
close_enroll_link=HTML('</button>')
),
title=Text(_('Welcome to {course_display_name}')).format(
course_display_name=course.display_name