Opening courseware to anonymous users

Anonymous users may now view units in the courseware. This access is limited to units that are not considered problems/graded (e.g. homework, exams).
This commit is contained in:
Clinton Blackburn
2017-10-06 15:31:10 -04:00
committed by Clinton Blackburn
parent 9a9ef198b2
commit 69eeca61d8
15 changed files with 280 additions and 102 deletions

View File

@@ -14,7 +14,7 @@ from django.core.cache import cache
from django.template.context_processors import csrf
from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponse
from django.http import Http404, HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from edx_proctoring.services import ProctoringService
from opaque_keys import InvalidKeyError
@@ -925,29 +925,32 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
Generic view for extensions. This is where AJAX calls go.
Arguments:
request (Request): Django request.
course_id (str): Course containing the block
usage_id (str)
handler (str)
suffix (str)
- request -- the django request.
- location -- the module location. Used to look up the XModule instance
- course_id -- defines the course context for this request.
Return 403 error if the user is not logged in. Raises Http404 if
the location and course_id do not identify a valid module, the module is
not accessible by the user, or the module raises NotFoundError. If the
module raises any other error, it will escape this function.
Raises:
Http404: If the course is not found in the modulestore.
"""
if not request.user.is_authenticated():
return HttpResponse('Unauthenticated', status=403)
# NOTE (CCB): Allow anonymous GET calls (e.g. for transcripts). Modifying this view is simpler than updating
# the XBlocks to use `handle_xblock_callback_noauth`...which is practically identical to this view.
if request.method != 'GET' and not request.user.is_authenticated():
return HttpResponseForbidden()
request.user.known = request.user.is_authenticated()
try:
course_key = CourseKey.from_string(course_id)
except InvalidKeyError:
raise Http404("Invalid location")
raise Http404('{} is not a valid course key'.format(course_id))
with modulestore().bulk_operations(course_key):
try:
course = modulestore().get_course(course_key)
except ItemNotFoundError:
raise Http404("invalid location")
raise Http404('{} does not exist in the modulestore'.format(course_id))
return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)

View File

@@ -57,7 +57,7 @@ class TestDiscussionXBlock(XModuleRenderingTestBase):
self.block.xmodule_runtime = mock.Mock()
if self.PATCH_DJANGO_USER:
self.django_user_canary = object()
self.django_user_canary = UserFactory()
self.django_user_mock = self.add_patcher(
mock.patch.object(DiscussionXBlock, "django_user", new_callable=mock.PropertyMock)
)
@@ -259,7 +259,7 @@ class TestXBlockInCourse(SharedModuleStoreTestCase):
Set up a user, course, and discussion XBlock for use by tests.
"""
super(TestXBlockInCourse, cls).setUpClass()
cls.user = UserFactory.create()
cls.user = UserFactory()
cls.course = ToyCourseFactory.create()
cls.course_key = cls.course.id
cls.course_usage_key = cls.store.make_course_usage_key(cls.course_key)
@@ -380,8 +380,8 @@ class TestXBlockQueryLoad(SharedModuleStoreTestCase):
"""
Tests that the permissions queries are cached when rendering numerous discussion XBlocks.
"""
user = UserFactory.create()
course = ToyCourseFactory.create()
user = UserFactory()
course = ToyCourseFactory()
course_key = course.id
course_usage_key = self.store.make_course_usage_key(course_key)
discussions = []

View File

@@ -290,7 +290,6 @@ class ModuleRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase):
)
response = self.client.post(dispatch_url, {'position': 2})
self.assertEquals(403, response.status_code)
self.assertEquals('Unauthenticated', response.content)
def test_missing_position_handler(self):
"""

View File

@@ -11,10 +11,23 @@ from urllib import quote, urlencode
from uuid import uuid4
import ddt
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponseBadRequest
from django.test import TestCase
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from freezegun import freeze_time
from milestones.tests.utils import MilestonesTestCaseMixin
from mock import MagicMock, PropertyMock, create_autospec, patch
from nose.plugins.attrib import attr
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import Location
from pytz import UTC
from xblock.core import XBlock
from xblock.fields import Scope, String
from xblock.fragment import Fragment
import courseware.views.views as views
import shoppingcart
@@ -32,19 +45,9 @@ from courseware.tests.factories import GlobalStaffFactory, StudentModuleFactory
from courseware.testutils import RenderXBlockTestMixin
from courseware.url_helpers import get_redirect_url
from courseware.user_state_client import DjangoXBlockUserStateClient
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponseBadRequest
from django.test import TestCase
from django.test.client import Client, RequestFactory
from django.test.utils import override_settings
from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
from lms.djangoapps.grades.config.waffle import waffle as grades_waffle
from lms.djangoapps.grades.config.waffle import ASSUME_ZERO_GRADE_IF_ABSENT
from milestones.tests.utils import MilestonesTestCaseMixin
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import Location
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory
from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, ProgramFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
@@ -52,19 +55,17 @@ 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.self_paced.models import SelfPacedConfiguration
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.features.course_experience import COURSE_OUTLINE_PAGE_FLAG
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
from student.models import CourseEnrollment
from student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory, TEST_PASSWORD
from student.tests.factories import TEST_PASSWORD, AdminFactory, CourseEnrollmentFactory, UserFactory
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 xblock.core import XBlock
from xblock.fields import Scope, String
from xblock.fragment import Fragment
from xmodule.graders import ShowCorrectness
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
@@ -2193,6 +2194,7 @@ 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)
@@ -2262,6 +2264,28 @@ class TestIndexView(ModuleStoreTestCase):
)
self.assertIn("Activate Block ID: test_block_id", response.content)
def test_anonymous_access(self):
course = CourseFactory()
with self.store.bulk_operations(course.id):
chapter = ItemFactory(parent=course, category='chapter')
section = ItemFactory(parent=chapter, category='sequential')
url = reverse(
'courseware_section',
kwargs={
'course_id': str(course.id),
'chapter': chapter.url_name,
'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):
response = self.client.get(url, follow=False)
assert response.status_code == 200
@ddt.ddt
class TestIndexViewWithVerticalPositions(ModuleStoreTestCase):

View File

@@ -8,12 +8,14 @@ import logging
import urllib
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.template.context_processors import csrf
from django.contrib.auth.views import redirect_to_login
from django.core.urlresolvers import reverse
from django.http import Http404
from django.template.context_processors import csrf
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from django.views.decorators.cache import cache_control
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import View
@@ -29,19 +31,20 @@ from openedx.core.djangoapps.crawlers.models import CrawlersConfig
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.monitoring_utils import set_custom_metrics_for_course_key
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace, WaffleFlagNamespace, CourseWaffleFlag
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.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 student.models import CourseEnrollment
from util.views import ensure_valid_course_key
from xmodule.modulestore.django import modulestore
from xmodule.x_module import STUDENT_VIEW
from .views import CourseTabView
from ..access import has_access
from ..access_utils import in_preview_mode, check_course_open_for_learner
from ..access_utils import check_course_open_for_learner
from ..courses import get_course_with_access, get_current_child, get_studio_url
from ..entrance_exams import (
course_has_entrance_exam,
@@ -52,9 +55,6 @@ from ..entrance_exams import (
from ..masquerade import setup_masquerade
from ..model_data import FieldDataCache
from ..module_render import get_module_for_descriptor, toc_for_course
from .views import (
CourseTabView,
)
log = logging.getLogger("edx.courseware.views.index")
@@ -66,7 +66,12 @@ class CoursewareIndex(View):
"""
View class for the Courseware page.
"""
@method_decorator(login_required)
@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)
@method_decorator(ensure_csrf_cookie)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True))
@method_decorator(ensure_valid_course_key)
@@ -91,7 +96,10 @@ class CoursewareIndex(View):
position (unicode): position in module, eg of <sequential> module
"""
self.course_key = CourseKey.from_string(course_id)
self.request = request
if not (request.user.is_authenticated() or self.enable_anonymous_courseware_access):
return redirect_to_login(request.get_full_path())
self.original_chapter_url_name = chapter
self.original_section_url_name = section
self.chapter_url_name = chapter
@@ -108,11 +116,11 @@ class CoursewareIndex(View):
self.course = get_course_with_access(
request.user, 'load', self.course_key,
depth=CONTENT_DEPTH,
check_if_enrolled=True,
check_if_enrolled=not self.enable_anonymous_courseware_access,
)
self.is_staff = has_access(request.user, 'staff', self.course)
self._setup_masquerade_for_effective_user()
return self._get(request)
return self.render(request)
except Exception as exception: # pylint: disable=broad-except
return CourseTabView.handle_exceptions(request, self.course, exception)
@@ -131,7 +139,7 @@ class CoursewareIndex(View):
# Set the user in the request to the effective user.
self.request.user = self.effective_user
def _get(self, request):
def render(self, request):
"""
Render the index page.
"""
@@ -148,6 +156,28 @@ class CoursewareIndex(View):
self._save_positions()
self._prefetch_and_bind_section()
if not request.user.is_authenticated():
qs = urllib.urlencode({
'course_id': self.course_key,
'enrollment_action': 'enroll',
'email_opt_in': False,
})
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))
def _redirect_if_not_requested_section(self):
@@ -186,15 +216,20 @@ class CoursewareIndex(View):
"""
Redirect to dashboard if the course is blocked due to non-payment.
"""
self.real_user = User.objects.prefetch_related("groups").get(id=self.real_user.id)
redeemed_registration_codes = CourseRegistrationCode.objects.filter(
course_id=self.course_key,
registrationcoderedemption__redeemed_by=self.real_user
)
redeemed_registration_codes = []
if self.request.user.is_authenticated():
self.real_user = User.objects.prefetch_related("groups").get(id=self.real_user.id)
redeemed_registration_codes = CourseRegistrationCode.objects.filter(
course_id=self.course_key,
registrationcoderedemption__redeemed_by=self.real_user
)
if is_course_blocked(self.request, redeemed_registration_codes, self.course_key):
# registration codes may be generated via Bulk Purchase Scenario
# we have to check only for the invoice generated registration codes
# that their invoice is valid or not
# TODO Update message to account for the fact that the user is not authenticated.
log.warning(
u'User %s cannot access the course %s because payment has not yet been received',
self.real_user,
@@ -218,9 +253,11 @@ class CoursewareIndex(View):
"""
Returns the preferred language for the actual user making the request.
"""
language_preference = get_user_preference(self.real_user, LANGUAGE_KEY)
if not language_preference:
language_preference = settings.LANGUAGE_CODE
language_preference = settings.LANGUAGE_CODE
if self.request.user.is_authenticated():
language_preference = get_user_preference(self.real_user, LANGUAGE_KEY)
return language_preference
def _is_masquerading_as_student(self):
@@ -445,10 +482,15 @@ class CoursewareIndex(View):
requested_child=requested_child,
)
# NOTE (CCB): Pull the position from the URL for un-authenticated users. Otherwise, pull the saved
# state from the data store.
position = None if self.request.user.is_authenticated() else self.position
section_context = {
'activate_block_id': self.request.GET.get('activate_block_id'),
'requested_child': self.request.GET.get("child"),
'progress_url': reverse('progress', kwargs={'course_id': unicode(self.course_key)}),
'user_authenticated': self.request.user.is_authenticated(),
'position': position,
}
if previous_of_active_section:
section_context['prev_url'] = _compute_section_url(previous_of_active_section, 'last')

View File

@@ -1,5 +1,5 @@
$(document).ajaxError(function(event, jXHR) {
if (jXHR.status === 403 && jXHR.responseText === 'Unauthenticated') {
if (jXHR.status === 403) {
var message = gettext(
'You have been logged out of your edX account. ' +
'Click Okay to log in again now. ' +

View File

@@ -94,6 +94,16 @@ from openedx.features.course_experience import course_home_page_title, COURSE_OU
var $$course_id = "${course.id | n, js_escaped_string}";
</script>
% if not request.user.is_authenticated():
<script type="text/javascript">
// Disable discussions
$('.xblock-student_view-discussion button.discussion-show').attr('disabled', true);
// Insert message informing user discussions are only available to logged in users.
$('.discussion-module')
</script>
% endif
${HTML(fragment.foot_html())}
</%block>

View File

@@ -13,6 +13,15 @@ from openedx.core.djangolib.js_utils import js_escaped_string
data-user-create-comment="${json_dumps(can_create_comment)}"
data-user-create-subcomment="${json_dumps(can_create_subcomment)}"
data-read-only="${'false' if can_create_thread else 'true'}">
% if not user.is_authenticated():
<div class="page-banner">
<div class="alert alert-warning" role="alert">
<span class="icon icon-alert fa fa fa-warning" aria-hidden="true"></span>
<div class="message-content">${login_msg}</div>
</div>
</div>
<br>
% endif
<div class="discussion-module-header">
<h3 class="hd hd-3 discussion-module-title">${_(display_name)}</h3>
<div class="inline-discussion-topic"><span class="inline-discussion-topic-title">${_("Topic:")}</span> ${discussion_category}
@@ -21,9 +30,12 @@ from openedx.core.djangolib.js_utils import js_escaped_string
%endif
</div>
</div>
<button class="discussion-show btn" data-discussion-id="${discussion_id}">
<button class="discussion-show btn"
data-discussion-id="${discussion_id}"
${"disabled=disabled" if not user.is_authenticated() else ""}>
<span class="button-text">${_("Show Discussion")}</span>
</button>
</div>
<script type="text/javascript">
var $$course_id = "${course_id | n, js_escaped_string}";

View File

@@ -36,7 +36,8 @@
data-element="${idx+1}"
data-page-title="${item['page_title']}"
data-path="${item['path']}"
id="tab_${idx}">
id="tab_${idx}"
${"disabled=disabled" if disable_navigation else ""}>
<span class="icon fa seq_${item['type']}" aria-hidden="true"></span>
<span class="fa fa-fw fa-bookmark bookmark-icon ${"is-hidden" if not item['bookmarked'] else "bookmarked"}" aria-hidden="true"></span>
<div class="sequence-tooltip sr"><span class="sr">${item['type']}&nbsp;</span>${item['page_title']}<span class="sr bookmark-icon-sr">&nbsp;${_("Bookmarked") if item['bookmarked'] else ""}</span></div>