Support anonymous users in the Blockstore-based XBlock runtime
Implementation details: * Anonymous users are assigned a unique ID (like `anon42c08f9996194e2a9339`) which gets stored in the django session. `block.scope_ids.user_id` and `block.runtime.anonymous_student_id` will both return this value. * User state for anonymous users is stored in the django cache and automatically expires as the cache gets pruned. Because user state is stored, anonymous users can use interactive blocks like capa problems. * There is no mechanism for upgrading to a registered account and keeping user state since the user state store for anonymous users (EphemeralKeyValueStore) is completely different than the one for registered users (DjangoKeyValueStore/"CSM"), and has no "list all keys" functionality. * "User State Summary" field values are shared among [recently active] anonymous users but are not shared with registered users. * Anonymous users can only access the `public_view` of XBlocks, not the regular `student_view`.
This commit is contained in:
@@ -2093,7 +2093,10 @@ PROCTORING_SETTINGS = {}
|
||||
|
||||
################## BLOCKSTORE RELATED SETTINGS #########################
|
||||
BLOCKSTORE_PUBLIC_URL_ROOT = 'http://localhost:18250'
|
||||
BLOCKSTORE_API_URL = 'http://localhost:18250/api/v1'
|
||||
BLOCKSTORE_API_URL = 'http://localhost:18250/api/v1/'
|
||||
# Which of django's caches to use for storing anonymous user state for XBlocks
|
||||
# in the blockstore-based XBlock runtime
|
||||
XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE = 'default'
|
||||
|
||||
###################### LEARNER PORTAL ################################
|
||||
LEARNER_PORTAL_URL_ROOT = 'https://learner-portal-localhost:18000'
|
||||
|
||||
@@ -119,6 +119,20 @@ class ProblemBlock(
|
||||
shim_xmodule_js(fragment, 'Problem')
|
||||
return fragment
|
||||
|
||||
def public_view(self, context):
|
||||
"""
|
||||
Return the view seen by users who aren't logged in or who aren't
|
||||
enrolled in the course.
|
||||
"""
|
||||
if getattr(self.runtime, 'suppports_state_for_anonymous_users', False):
|
||||
# The new XBlock runtime can generally support capa problems for users who aren't logged in, so show the
|
||||
# normal student_view. To prevent anonymous users from viewing specific problems, adjust course policies
|
||||
# and/or content groups.
|
||||
return self.student_view(context)
|
||||
else:
|
||||
# Show a message that this content requires users to login/enroll.
|
||||
return super(ProblemBlock, self).public_view(context)
|
||||
|
||||
def author_view(self, context):
|
||||
"""
|
||||
Renders the Studio preview view.
|
||||
|
||||
@@ -52,6 +52,8 @@ class UnitBlock(XBlock):
|
||||
result.add_content('</div>')
|
||||
return result
|
||||
|
||||
public_view = student_view
|
||||
|
||||
def index_dictionary(self):
|
||||
"""
|
||||
Return dictionary prepared with module content and type for indexing, so
|
||||
|
||||
@@ -252,6 +252,9 @@ class VideoBlock(
|
||||
"""
|
||||
Returns a fragment that contains the html for the public view
|
||||
"""
|
||||
if getattr(self.runtime, 'suppports_state_for_anonymous_users', False):
|
||||
# The new runtime can support anonymous users as fully as regular users:
|
||||
return self.student_view(context)
|
||||
return Fragment(self.get_html(view=PUBLIC_VIEW))
|
||||
|
||||
def get_html(self, view=STUDENT_VIEW):
|
||||
@@ -610,12 +613,8 @@ class VideoBlock(
|
||||
field_data = cls.parse_video_xml(node)
|
||||
for key, val in field_data.items():
|
||||
setattr(video_block, key, cls.fields[key].from_json(val))
|
||||
# Update VAL with info extracted from `xml_object`
|
||||
video_block.edx_video_id = video_block.import_video_info_into_val(
|
||||
node,
|
||||
runtime.resources_fs,
|
||||
keys.usage_id.context_key,
|
||||
)
|
||||
# Don't use VAL in the new runtime:
|
||||
video_block.edx_video_id = None
|
||||
return video_block
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -3834,7 +3834,10 @@ MAILCHIMP_NEW_USER_LIST_ID = ""
|
||||
|
||||
########################## BLOCKSTORE #####################################
|
||||
BLOCKSTORE_PUBLIC_URL_ROOT = 'http://localhost:18250'
|
||||
BLOCKSTORE_API_URL = 'http://localhost:18250/api/v1'
|
||||
BLOCKSTORE_API_URL = 'http://localhost:18250/api/v1/'
|
||||
# Which of django's caches to use for storing anonymous user state for XBlocks
|
||||
# in the blockstore-based XBlock runtime
|
||||
XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE = 'default'
|
||||
|
||||
########################## LEARNER PORTAL ##############################
|
||||
LEARNER_PORTAL_URL_ROOT = 'https://learner-portal-localhost:18000'
|
||||
|
||||
@@ -239,6 +239,7 @@ CACHES = {
|
||||
RUN_BLOCKSTORE_TESTS = os.environ.get('EDXAPP_RUN_BLOCKSTORE_TESTS', 'no').lower() in ('true', 'yes', '1')
|
||||
BLOCKSTORE_API_URL = os.environ.get('EDXAPP_BLOCKSTORE_API_URL', "http://edx.devstack.blockstore-test:18251/api/v1/")
|
||||
BLOCKSTORE_API_AUTH_TOKEN = os.environ.get('EDXAPP_BLOCKSTORE_API_AUTH_TOKEN', 'edxapp-test-key')
|
||||
XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE = 'blockstore' # This must be set to a working cache for the tests to pass
|
||||
|
||||
# Dummy secret key for dev
|
||||
SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
||||
|
||||
@@ -4,14 +4,12 @@ Test the Blockstore-based XBlock runtime and content libraries together.
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
from django.test import TestCase
|
||||
from django.test import TestCase, override_settings
|
||||
from organizations.models import Organization
|
||||
from rest_framework.test import APIClient
|
||||
from xblock.core import XBlock, Scope
|
||||
from xblock import fields
|
||||
from xblock.core import XBlock
|
||||
|
||||
from lms.djangoapps.courseware.model_data import get_score
|
||||
from openedx.core.djangoapps.content_libraries import api as library_api
|
||||
@@ -20,6 +18,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import (
|
||||
URL_BLOCK_RENDER_VIEW,
|
||||
URL_BLOCK_GET_HANDLER_URL,
|
||||
)
|
||||
from openedx.core.djangoapps.content_libraries.tests.user_state_block import UserStateTestBlock
|
||||
from openedx.core.djangoapps.xblock import api as xblock_api
|
||||
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
||||
from openedx.core.lib import blockstore_api
|
||||
@@ -27,20 +26,6 @@ from student.tests.factories import UserFactory
|
||||
from xmodule.unit_block import UnitBlock
|
||||
|
||||
|
||||
class UserStateTestBlock(XBlock):
|
||||
"""
|
||||
Block for testing variously scoped XBlock fields.
|
||||
"""
|
||||
BLOCK_TYPE = "user-state-test"
|
||||
|
||||
display_name = fields.String(scope=Scope.content, name='User State Test Block')
|
||||
# User-specific fields:
|
||||
user_str = fields.String(scope=Scope.user_state, default='default value') # This usage, one user
|
||||
uss_str = fields.String(scope=Scope.user_state_summary, default='default value') # This usage, all users
|
||||
pref_str = fields.String(scope=Scope.preferences, default='default value') # Block type, one user
|
||||
user_info_str = fields.String(scope=Scope.user_info, default='default value') # All blocks, one user
|
||||
|
||||
|
||||
class ContentLibraryContentTestMixin(object):
|
||||
"""
|
||||
Mixin for content library tests that creates two students and a library.
|
||||
@@ -69,6 +54,8 @@ class ContentLibraryContentTestMixin(object):
|
||||
|
||||
|
||||
@requires_blockstore
|
||||
# EphemeralKeyValueStore requires a working cache, and the default test cache doesn't work:
|
||||
@override_settings(XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE='blockstore')
|
||||
class ContentLibraryRuntimeTest(ContentLibraryContentTestMixin, TestCase):
|
||||
"""
|
||||
Basic tests of the Blockstore-based XBlock runtime using XBlocks in a
|
||||
@@ -168,13 +155,101 @@ class ContentLibraryXBlockUserStateTest(ContentLibraryContentTestMixin, TestCase
|
||||
self.assertEqual(block1_bob.pref_str, 'default value')
|
||||
self.assertEqual(block1_bob.user_info_str, 'default value')
|
||||
|
||||
@XBlock.register_temp_plugin(UserStateTestBlock, UserStateTestBlock.BLOCK_TYPE)
|
||||
def test_state_for_anonymous_users(self):
|
||||
"""
|
||||
Test that anonymous users can interact with XBlocks and get/set their
|
||||
state via handlers.
|
||||
"""
|
||||
# Create two XBlocks, block1 and block2
|
||||
block1_metadata = library_api.create_library_block(self.library.key, UserStateTestBlock.BLOCK_TYPE, "b3-1")
|
||||
block1_usage_key = block1_metadata.usage_key
|
||||
block2_metadata = library_api.create_library_block(self.library.key, UserStateTestBlock.BLOCK_TYPE, "b3-2")
|
||||
block2_usage_key = block2_metadata.usage_key
|
||||
library_api.publish_changes(self.library.key)
|
||||
# Create two clients (anonymous user's browsers)
|
||||
client1 = APIClient()
|
||||
client2 = APIClient()
|
||||
|
||||
def call_handler(client, block_key, handler_name, method, data=None):
|
||||
""" Call an XBlock handler """
|
||||
url_result = client.get(URL_BLOCK_GET_HANDLER_URL.format(block_key=block_key, handler_name=handler_name))
|
||||
url = url_result.data["handler_url"]
|
||||
data_json = json.dumps(data) if data else None
|
||||
response = getattr(client, method)(url, data_json, content_type="application/json")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.json()
|
||||
|
||||
# Now client1 sets all the fields via a handler:
|
||||
call_handler(client1, block1_usage_key, "set_user_state", "post", {
|
||||
"user_str": "1 was here",
|
||||
"uss_str": "1 was here (USS)",
|
||||
"pref_str": "1 was here (prefs)",
|
||||
"user_info_str": "1 was here (user info)",
|
||||
})
|
||||
|
||||
# Now load it back and expect the same data:
|
||||
data = call_handler(client1, block1_usage_key, "get_user_state", "get")
|
||||
self.assertEqual(data["user_str"], "1 was here")
|
||||
self.assertEqual(data["uss_str"], "1 was here (USS)")
|
||||
self.assertEqual(data["pref_str"], "1 was here (prefs)")
|
||||
self.assertEqual(data["user_info_str"], "1 was here (user info)")
|
||||
|
||||
# Now load a different XBlock and expect only pref_str and user_info_str to be set:
|
||||
data = call_handler(client1, block2_usage_key, "get_user_state", "get")
|
||||
self.assertEqual(data["user_str"], "default value")
|
||||
self.assertEqual(data["uss_str"], "default value")
|
||||
self.assertEqual(data["pref_str"], "1 was here (prefs)")
|
||||
self.assertEqual(data["user_info_str"], "1 was here (user info)")
|
||||
|
||||
# Now a different anonymous user loading the first block should see only the uss_str set:
|
||||
data = call_handler(client2, block1_usage_key, "get_user_state", "get")
|
||||
self.assertEqual(data["user_str"], "default value")
|
||||
self.assertEqual(data["uss_str"], "1 was here (USS)")
|
||||
self.assertEqual(data["pref_str"], "default value")
|
||||
self.assertEqual(data["user_info_str"], "default value")
|
||||
|
||||
# The "user state summary" should not be shared between registered and anonymous users:
|
||||
client_registered = APIClient()
|
||||
client_registered.login(username=self.student_a.username, password='edx')
|
||||
data = call_handler(client_registered, block1_usage_key, "get_user_state", "get")
|
||||
self.assertEqual(data["user_str"], "default value")
|
||||
self.assertEqual(data["uss_str"], "default value")
|
||||
self.assertEqual(data["pref_str"], "default value")
|
||||
self.assertEqual(data["user_info_str"], "default value")
|
||||
|
||||
def test_views_for_anonymous_users(self):
|
||||
"""
|
||||
Test that anonymous users can view XBlock's 'public_view' but not other
|
||||
views
|
||||
"""
|
||||
# Create an XBlock
|
||||
block_metadata = library_api.create_library_block(self.library.key, "html", "html1")
|
||||
block_usage_key = block_metadata.usage_key
|
||||
library_api.set_library_block_olx(block_usage_key, "<html>Hello world</html>")
|
||||
library_api.publish_changes(self.library.key)
|
||||
|
||||
anon_client = APIClient()
|
||||
# View the public_view:
|
||||
public_view_result = anon_client.get(
|
||||
URL_BLOCK_RENDER_VIEW.format(block_key=block_usage_key, view_name='public_view'),
|
||||
)
|
||||
self.assertEqual(public_view_result.status_code, 200)
|
||||
self.assertIn("Hello world", public_view_result.data["content"])
|
||||
|
||||
# Try to view the student_view:
|
||||
public_view_result = anon_client.get(
|
||||
URL_BLOCK_RENDER_VIEW.format(block_key=block_usage_key, view_name='student_view'),
|
||||
)
|
||||
self.assertEqual(public_view_result.status_code, 403)
|
||||
|
||||
@XBlock.register_temp_plugin(UserStateTestBlock, UserStateTestBlock.BLOCK_TYPE)
|
||||
def test_independent_instances(self):
|
||||
"""
|
||||
Test that independent instances of the same block don't share field data
|
||||
until .save() and re-loading, even when they're using the same runtime.
|
||||
"""
|
||||
block_metadata = library_api.create_library_block(self.library.key, UserStateTestBlock.BLOCK_TYPE, "b3")
|
||||
block_metadata = library_api.create_library_block(self.library.key, UserStateTestBlock.BLOCK_TYPE, "b4")
|
||||
block_usage_key = block_metadata.usage_key
|
||||
library_api.publish_changes(self.library.key)
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Block for testing variously scoped XBlock fields.
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
import json
|
||||
|
||||
from webob import Response
|
||||
from xblock.core import XBlock, Scope
|
||||
from xblock import fields
|
||||
|
||||
|
||||
class UserStateTestBlock(XBlock):
|
||||
"""
|
||||
Block for testing variously scoped XBlock fields.
|
||||
"""
|
||||
BLOCK_TYPE = "user-state-test"
|
||||
has_score = False
|
||||
|
||||
display_name = fields.String(scope=Scope.content, name='User State Test Block')
|
||||
# User-specific fields:
|
||||
user_str = fields.String(scope=Scope.user_state, default='default value') # This usage, one user
|
||||
uss_str = fields.String(scope=Scope.user_state_summary, default='default value') # This usage, all users
|
||||
pref_str = fields.String(scope=Scope.preferences, default='default value') # Block type, one user
|
||||
user_info_str = fields.String(scope=Scope.user_info, default='default value') # All blocks, one user
|
||||
|
||||
@XBlock.json_handler
|
||||
def set_user_state(self, data, suffix): # pylint: disable=unused-argument
|
||||
"""
|
||||
Set the user-scoped fields
|
||||
"""
|
||||
self.user_str = data["user_str"]
|
||||
self.uss_str = data["uss_str"]
|
||||
self.pref_str = data["pref_str"]
|
||||
self.user_info_str = data["user_info_str"]
|
||||
return {}
|
||||
|
||||
@XBlock.handler
|
||||
def get_user_state(self, request, suffix=None): # pylint: disable=unused-argument
|
||||
"""
|
||||
Get the various user-scoped fields of this XBlock.
|
||||
"""
|
||||
return Response(
|
||||
json.dumps({
|
||||
"user_str": self.user_str,
|
||||
"uss_str": self.uss_str,
|
||||
"pref_str": self.pref_str,
|
||||
"user_info_str": self.user_info_str,
|
||||
}),
|
||||
content_type='application/json',
|
||||
charset='UTF-8',
|
||||
)
|
||||
@@ -24,7 +24,7 @@ from openedx.core.djangoapps.xblock.learning_context.manager import get_learning
|
||||
from openedx.core.djangoapps.xblock.runtime.blockstore_runtime import BlockstoreXBlockRuntime, xml_for_definition
|
||||
from openedx.core.djangoapps.xblock.runtime.runtime import XBlockRuntimeSystem
|
||||
from openedx.core.djangolib.blockstore_cache import BundleCache
|
||||
from .utils import get_secure_token_for_xblock_handler
|
||||
from .utils import get_secure_token_for_xblock_handler, get_xblock_id_for_anonymous_user
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -160,11 +160,9 @@ def render_block_view(block, view_name, user): # pylint: disable=unused-argumen
|
||||
"""
|
||||
Get the HTML, JS, and CSS needed to render the given XBlock view.
|
||||
|
||||
The difference between this method and calling
|
||||
The only difference between this method and calling
|
||||
load_block().render(view_name)
|
||||
is that this method will automatically save any changes to field data that
|
||||
resulted from rendering the view. If you don't want that, call .render()
|
||||
directly.
|
||||
is that this method can fall back from 'author_view' to 'student_view'
|
||||
|
||||
Returns a Fragment.
|
||||
"""
|
||||
@@ -179,14 +177,10 @@ def render_block_view(block, view_name, user): # pylint: disable=unused-argumen
|
||||
else:
|
||||
raise
|
||||
|
||||
# TODO: save any changed user state fields
|
||||
# TODO: if the view is anything other than student_view and we're not in the LMS, save any changed
|
||||
# content/settings/children fields.
|
||||
|
||||
return fragment
|
||||
|
||||
|
||||
def get_handler_url(usage_key, handler_name, user_id):
|
||||
def get_handler_url(usage_key, handler_name, user):
|
||||
"""
|
||||
A method for getting the URL to any XBlock handler. The URL must be usable
|
||||
without any authentication (no cookie, no OAuth/JWT), and may expire. (So
|
||||
@@ -202,26 +196,30 @@ def get_handler_url(usage_key, handler_name, user_id):
|
||||
Params:
|
||||
usage_key - Usage Key (Opaque Key object or string)
|
||||
handler_name - Name of the handler or a dummy name like 'any_handler'
|
||||
user_id - User ID or XBlockRuntimeSystem.ANONYMOUS_USER
|
||||
user - Django User (registered or anonymous)
|
||||
|
||||
This view does not check/care if the XBlock actually exists.
|
||||
"""
|
||||
usage_key_str = six.text_type(usage_key)
|
||||
site_root_url = get_xblock_app_config().get_site_root_url()
|
||||
if user_id is None:
|
||||
if not user:
|
||||
raise TypeError("Cannot get handler URLs without specifying a specific user ID.")
|
||||
elif user_id == XBlockRuntimeSystem.ANONYMOUS_USER:
|
||||
raise NotImplementedError("handler links for anonymous users are not yet implemented") # TODO: implement
|
||||
elif user.is_authenticated:
|
||||
user_id = user.id
|
||||
elif user.is_anonymous:
|
||||
user_id = get_xblock_id_for_anonymous_user(user)
|
||||
else:
|
||||
# Normal case: generate a token-secured URL for this handler, specific
|
||||
# to this user and this XBlock.
|
||||
secure_token = get_secure_token_for_xblock_handler(user_id, usage_key_str)
|
||||
path = reverse('xblock_api:xblock_handler', kwargs={
|
||||
'usage_key_str': usage_key_str,
|
||||
'user_id': user_id,
|
||||
'secure_token': secure_token,
|
||||
'handler_name': handler_name,
|
||||
})
|
||||
raise ValueError("Invalid user value")
|
||||
# Now generate a token-secured URL for this handler, specific to this user
|
||||
# and this XBlock:
|
||||
secure_token = get_secure_token_for_xblock_handler(user_id, usage_key_str)
|
||||
# Now generate the URL to that handler:
|
||||
path = reverse('xblock_api:xblock_handler', kwargs={
|
||||
'usage_key_str': usage_key_str,
|
||||
'user_id': user_id,
|
||||
'secure_token': secure_token,
|
||||
'handler_name': handler_name,
|
||||
})
|
||||
# We must return an absolute URL. We can't just use
|
||||
# rest_framework.reverse.reverse to get the absolute URL because this method
|
||||
# can be called by the XBlock from python as well and in that case we don't
|
||||
|
||||
@@ -20,7 +20,7 @@ urlpatterns = [
|
||||
url(r'^handler_url/(?P<handler_name>[\w\-]+)/$', views.get_handler_url),
|
||||
# call one of this block's handlers
|
||||
url(
|
||||
r'^handler/(?P<user_id>\d+)-(?P<secure_token>\w+)/(?P<handler_name>[\w\-]+)/(?P<suffix>.+)?$',
|
||||
r'^handler/(?P<user_id>\w+)-(?P<secure_token>\w+)/(?P<handler_name>[\w\-]+)/(?P<suffix>.+)?$',
|
||||
views.xblock_handler,
|
||||
name='xblock_handler',
|
||||
),
|
||||
|
||||
@@ -42,7 +42,7 @@ def block_metadata(request, usage_key_str):
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
@view_auth_classes(is_authenticated=False)
|
||||
@permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context
|
||||
def render_block_view(request, usage_key_str, view_name):
|
||||
"""
|
||||
@@ -57,7 +57,7 @@ def render_block_view(request, usage_key_str, view_name):
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
@view_auth_classes(is_authenticated=False)
|
||||
def get_handler_url(request, usage_key_str, handler_name):
|
||||
"""
|
||||
Get an absolute URL which can be used (without any authentication) to call
|
||||
@@ -66,7 +66,7 @@ def get_handler_url(request, usage_key_str, handler_name):
|
||||
The URL will expire but is guaranteed to be valid for a minimum of 2 days.
|
||||
"""
|
||||
usage_key = UsageKey.from_string(usage_key_str)
|
||||
handler_url = _get_handler_url(usage_key, handler_name, request.user.id)
|
||||
handler_url = _get_handler_url(usage_key, handler_name, request.user)
|
||||
return Response({"handler_url": handler_url})
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name,
|
||||
auth token included in the URL (see below). As a result it can be exempt
|
||||
from CSRF, session auth, and JWT/OAuth.
|
||||
"""
|
||||
user_id = int(user_id) # User ID comes from the URL, not session auth
|
||||
usage_key = UsageKey.from_string(usage_key_str)
|
||||
|
||||
# To support sandboxed XBlocks, custom frontends, and other use cases, we
|
||||
@@ -94,13 +93,31 @@ def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name,
|
||||
if not validate_secure_token_for_xblock_handler(user_id, usage_key_str, secure_token):
|
||||
raise PermissionDenied("Invalid/expired auth token.")
|
||||
if request.user.is_authenticated:
|
||||
# The user authenticated twice, e.g. with session auth and the token
|
||||
# So just make sure the session auth matches the token
|
||||
if request.user.id != user_id:
|
||||
# The user authenticated twice, e.g. with session auth and the token.
|
||||
# This can happen if not running the XBlock in a sandboxed iframe.
|
||||
# Just make sure the session auth matches the token:
|
||||
if request.user.id != int(user_id):
|
||||
raise AuthenticationFailed("Authentication conflict.")
|
||||
user = request.user
|
||||
elif user_id.isdigit():
|
||||
# This is a normal (integer) user ID for a registered user.
|
||||
# This is the "normal" way this view gets used, with a sandboxed iframe.
|
||||
user = User.objects.get(pk=int(user_id))
|
||||
elif user_id.startswith("anon"):
|
||||
# This is a non-registered (anonymous) user:
|
||||
assert request.user.is_anonymous
|
||||
assert not hasattr(request.user, 'xblock_id_for_anonymous_user')
|
||||
user = request.user # An AnonymousUser
|
||||
# Since this particular view usually gets called from a sandboxed iframe
|
||||
# we won't have access to the LMS session data for this user (the iframe
|
||||
# has a new, empty session). So we need to save the identifier for this
|
||||
# anonymous user (from the URL) on the user object, so that the runtime
|
||||
# can get it (instead of generating a new one and saving it into this
|
||||
# new empty session)
|
||||
# See djangoapps.xblock.utils.get_xblock_id_for_anonymous_user()
|
||||
user.xblock_id_for_anonymous_user = user_id
|
||||
else:
|
||||
user = User.objects.get(pk=user_id)
|
||||
raise AuthenticationFailed("Invalid user ID format.")
|
||||
|
||||
request_webob = DjangoWebobRequest(request) # Convert from django request to the webob format that XBlocks expect
|
||||
block = load_block(usage_key, user)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
An :class:`~xblock.runtime.KeyValueStore` that stores data in the django cache
|
||||
|
||||
This is used for low-priority ephemeral student state data:
|
||||
* Anonymous users browsing and previewing content
|
||||
* Studio authors testing out XBlocks
|
||||
|
||||
We could also store this data in django sessions, but its a bit tricky to access
|
||||
session data during any requests which don't have any cookies or other normal
|
||||
authentication mechanisms (like XBlock handler calls from within XBlock <iframe>
|
||||
sandboxes). And keeping this storage completely separate from django session
|
||||
data and registered user XBlock state reduces the potential for security
|
||||
problems. We expect the data in this store to be low-value and free of
|
||||
personally identifiable information (PII) so if some security bug results in one
|
||||
user accessing a different user's entries in this particular store, it's not a
|
||||
big deal.
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import caches
|
||||
from xblock.runtime import KeyValueStore
|
||||
|
||||
|
||||
FIELD_DATA_TIMEOUT = None # keep in cache indefinitely, until cache needs pruning
|
||||
|
||||
|
||||
class NotFound(object):
|
||||
"""
|
||||
This class is a unique value that can be stored in a cache to indicate "not found"
|
||||
"""
|
||||
# Store the class itself, not an instance of it.
|
||||
|
||||
|
||||
class EphemeralKeyValueStore(KeyValueStore):
|
||||
"""
|
||||
An XBlock field data key-value store that is backed by the django cache
|
||||
"""
|
||||
def _wrap_key(self, key):
|
||||
"""
|
||||
Expand the given XBlock key tuple to a format we can use as a key.
|
||||
"""
|
||||
return u"ephemeral-xblock:{}".format(repr(tuple(key)))
|
||||
|
||||
@property
|
||||
def _cache(self):
|
||||
return caches[settings.XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE]
|
||||
|
||||
def get(self, key):
|
||||
value = self._cache.get(self._wrap_key(key), default=NotFound)
|
||||
if value is NotFound:
|
||||
raise KeyError # Normal, this is how we indicate a value is not found
|
||||
return value
|
||||
|
||||
def set(self, key, value):
|
||||
self._cache.set(self._wrap_key(key), value, timeout=FIELD_DATA_TIMEOUT)
|
||||
|
||||
def delete(self, key):
|
||||
self._cache.delete(self._wrap_key(key))
|
||||
|
||||
def has(self, key):
|
||||
return self._cache.get(self._wrap_key(key), default=NotFound) is not NotFound
|
||||
@@ -4,8 +4,12 @@ the new XBlock runtime.
|
||||
"""
|
||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
from xblock.core import XBlock, XBlockMixin
|
||||
from xblock.exceptions import JsonHandlerError
|
||||
from web_fragments.fragment import Fragment
|
||||
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
|
||||
|
||||
@XBlock.wants('completion')
|
||||
@@ -40,3 +44,28 @@ class LmsBlockMixin(XBlockMixin):
|
||||
raise JsonHandlerError(400, u"Block not configured for completion on view.")
|
||||
self.runtime.publish(self, "completion", data)
|
||||
return {'result': 'ok'}
|
||||
|
||||
def public_view(self, _context):
|
||||
"""
|
||||
Default message for blocks that don't implement public_view
|
||||
|
||||
public_view is shown when users aren't logged in and/or are not enrolled
|
||||
in a particular course.
|
||||
"""
|
||||
alert_html = HTML(
|
||||
'<div class="page-banner"><div class="alert alert-warning">'
|
||||
'<span class="icon icon-alert fa fa fa-warning" aria-hidden="true"></span>'
|
||||
'<div class="message-content">{}</div></div></div>'
|
||||
)
|
||||
|
||||
# Determine if the user is seeing public_view because they're not logged in or because they're not enrolled.
|
||||
# (Note: 'self.runtime.user' is not part of the XBlock API and some runtimes don't provide it, but this mixin is
|
||||
# part of the runtime so it's OK to access it that way.)
|
||||
if self.runtime.user is None or self.runtime.user.is_anonymous:
|
||||
display_text = _('This content is only accessible to registered learners. Sign in or register to view it.')
|
||||
else:
|
||||
# This is a registered user but they're still seeing public_view
|
||||
# so they must be excluded because of enrollment status.
|
||||
display_text = _('This content is only accessible to enrolled learners. ')
|
||||
|
||||
return Fragment(alert_html.format(display_text))
|
||||
|
||||
@@ -9,6 +9,7 @@ from completion.models import BlockCompletion
|
||||
from completion.services import CompletionService
|
||||
import crum
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.lru_cache import lru_cache
|
||||
from eventtracking import tracker
|
||||
from six.moves.urllib.parse import urljoin # pylint: disable=import-error
|
||||
@@ -24,7 +25,9 @@ from lms.djangoapps.courseware.model_data import DjangoKeyValueStore, FieldDataC
|
||||
from lms.djangoapps.grades.api import signals as grades_signals
|
||||
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
|
||||
from openedx.core.djangoapps.xblock.runtime.blockstore_field_data import BlockstoreFieldData
|
||||
from openedx.core.djangoapps.xblock.runtime.ephemeral_field_data import EphemeralKeyValueStore
|
||||
from openedx.core.djangoapps.xblock.runtime.mixin import LmsBlockMixin
|
||||
from openedx.core.djangoapps.xblock.utils import get_xblock_id_for_anonymous_user
|
||||
from openedx.core.lib.xblock_utils import wrap_fragment, xblock_local_resource_url
|
||||
from static_replace import process_static_urls
|
||||
from xmodule.errortracker import make_error_tracker
|
||||
@@ -62,6 +65,11 @@ class XBlockRuntime(RuntimeShim, Runtime):
|
||||
# ** Do not add any XModule compatibility code to this class **
|
||||
# Add it to RuntimeShim instead, to help keep legacy code isolated.
|
||||
|
||||
# Feature flags:
|
||||
|
||||
# This runtime can save state for users who aren't logged in:
|
||||
suppports_state_for_anonymous_users = True
|
||||
|
||||
def __init__(self, system, user):
|
||||
super(XBlockRuntime, self).__init__(
|
||||
id_reader=system.id_reader,
|
||||
@@ -78,7 +86,13 @@ class XBlockRuntime(RuntimeShim, Runtime):
|
||||
)
|
||||
self.system = system
|
||||
self.user = user
|
||||
self.user_id = user.id if self.user else None # Must be set as a separate attribute since base class sets it
|
||||
# self.user_id must be set as a separate attribute since base class sets it:
|
||||
if self.user is None:
|
||||
self.user_id = None
|
||||
elif self.user.is_anonymous:
|
||||
self.user_id = get_xblock_id_for_anonymous_user(user)
|
||||
else:
|
||||
self.user_id = self.user.id
|
||||
self.block_field_datas = {} # dict of FieldData stores for our loaded XBlocks. Key is the block's scope_ids.
|
||||
self.django_field_data_caches = {} # dict of FieldDataCache objects for XBlock with database-based user state
|
||||
|
||||
@@ -86,11 +100,9 @@ class XBlockRuntime(RuntimeShim, Runtime):
|
||||
"""
|
||||
Get the URL to a specific handler.
|
||||
"""
|
||||
url = self.system.handler_url(
|
||||
usage_key=block.scope_ids.usage_id,
|
||||
handler_name=handler_name,
|
||||
user_id=XBlockRuntimeSystem.ANONYMOUS_USER if thirdparty else self.user_id,
|
||||
)
|
||||
if thirdparty:
|
||||
raise NotImplementedError("thirdparty handlers are not supported by this runtime.")
|
||||
url = self.system.handler_url(usage_key=block.scope_ids.usage_id, handler_name=handler_name, user=self.user)
|
||||
if suffix:
|
||||
if not url.endswith('/'):
|
||||
url += '/'
|
||||
@@ -231,17 +243,15 @@ class XBlockRuntime(RuntimeShim, Runtime):
|
||||
# No user is specified, so we want to throw an error if anything attempts to read/write user-specific fields
|
||||
student_data_store = None
|
||||
elif self.user.is_anonymous:
|
||||
# The user is anonymous. Future work will support saving their state
|
||||
# in a cache or the django session but for now just use a highly
|
||||
# ephemeral dict.
|
||||
student_data_store = KvsFieldData(kvs=DictKeyValueStore())
|
||||
# This is an anonymous (non-registered) user:
|
||||
assert self.user_id.startswith("anon")
|
||||
kvs = EphemeralKeyValueStore()
|
||||
student_data_store = KvsFieldData(kvs)
|
||||
elif self.system.student_data_mode == XBlockRuntimeSystem.STUDENT_DATA_EPHEMERAL:
|
||||
# We're in an environment like Studio where we want to let the
|
||||
# author test blocks out but not permanently save their state.
|
||||
# This in-memory dict will typically only persist for one
|
||||
# request-response cycle, so we need to soon replace it with a store
|
||||
# that puts the state into a cache or the django session.
|
||||
student_data_store = KvsFieldData(kvs=DictKeyValueStore())
|
||||
kvs = EphemeralKeyValueStore()
|
||||
student_data_store = KvsFieldData(kvs)
|
||||
else:
|
||||
# Use database-backed field data (i.e. store user_state in StudentModule)
|
||||
context_key = block.scope_ids.usage_id.context_key
|
||||
@@ -270,7 +280,11 @@ class XBlockRuntime(RuntimeShim, Runtime):
|
||||
"""
|
||||
Render a specific view of an XBlock.
|
||||
"""
|
||||
# We only need to override this method because some XBlocks in the
|
||||
# Users who aren't logged in are not allowed to view any views other
|
||||
# than public_view. They may call any handlers though.
|
||||
if (self.user is None or self.user.is_anonymous) and view_name != 'public_view':
|
||||
raise PermissionDenied
|
||||
# We also need to override this method because some XBlocks in the
|
||||
# edx-platform codebase use methods like add_webpack_to_fragment()
|
||||
# which create relative URLs (/static/studio/bundles/webpack-foo.js).
|
||||
# We want all resource URLs to be absolute, such as is done when
|
||||
@@ -353,8 +367,6 @@ class XBlockRuntimeSystem(object):
|
||||
class can be used with many different XBlocks, whereas each XBlock gets its
|
||||
own instance of XBlockRuntime.
|
||||
"""
|
||||
ANONYMOUS_USER = 'anon' # Special value passed to handler_url() methods
|
||||
|
||||
STUDENT_DATA_EPHEMERAL = 'ephemeral'
|
||||
STUDENT_DATA_PERSISTED = 'persisted'
|
||||
|
||||
@@ -371,10 +383,8 @@ class XBlockRuntimeSystem(object):
|
||||
handler_url(
|
||||
usage_key: UsageKey,
|
||||
handler_name: str,
|
||||
user_id: Union[int, ANONYMOUS_USER],
|
||||
user_id: Union[int, str],
|
||||
)
|
||||
If user_id is ANONYMOUS_USER, the handler should execute without
|
||||
any user-scoped fields.
|
||||
student_data_mode: Specifies whether student data should be kept
|
||||
in a temporary in-memory store (e.g. Studio) or persisted
|
||||
forever in the database.
|
||||
|
||||
@@ -62,10 +62,14 @@ class RuntimeShim(object):
|
||||
"""
|
||||
Get an anonymized identifier for this user.
|
||||
"""
|
||||
# TODO: Change this to a runtime service or method so that we can have
|
||||
# To do? Change this to a runtime service or method so that we can have
|
||||
# access to the context_key without relying on self._active_block.
|
||||
if self.user_id is None:
|
||||
raise NotImplementedError("TODO: anonymous ID for anonymous users.")
|
||||
if self.user.is_anonymous:
|
||||
# This is an anonymous user, and the self.user_id value is already
|
||||
# an anonymous string. It's not anonymized per course, but we don't
|
||||
# really care since this user's XBlock data is ephemeral and is only
|
||||
# kept around for a day or two anyways.
|
||||
return self.user_id
|
||||
#### TEMPORARY IMPLEMENTATION:
|
||||
# TODO: Update student.models.AnonymousUserId to have a 'context_key'
|
||||
# column instead of 'course_key' (no DB migration needed). Then change
|
||||
@@ -279,13 +283,8 @@ class RuntimeShim(object):
|
||||
" is_staff = user.opt_attrs.get('edx-platform.user_is_staff')",
|
||||
DeprecationWarning, stacklevel=2,
|
||||
)
|
||||
if self.user_id:
|
||||
from django.contrib.auth import get_user_model
|
||||
try:
|
||||
user = get_user_model().objects.get(id=self.user_id)
|
||||
except get_user_model().DoesNotExist:
|
||||
return False
|
||||
return user.is_staff
|
||||
if self.user and self.user.is_authenticated:
|
||||
return self.user.is_staff
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
@@ -368,7 +367,7 @@ class RuntimeShim(object):
|
||||
# Many CSS styles for former XModules use
|
||||
# .xmodule_display.xmodule_VideoBlock
|
||||
# as their selector, so add those classes:
|
||||
if view == 'student_view':
|
||||
if view in ('student_view', 'public_view'):
|
||||
css_classes.append('xmodule_display')
|
||||
elif view == 'studio_view':
|
||||
css_classes.append('xmodule_edit')
|
||||
|
||||
@@ -6,7 +6,9 @@ import hashlib
|
||||
import hmac
|
||||
import math
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import crum
|
||||
from django.conf import settings
|
||||
from six import text_type
|
||||
|
||||
@@ -67,3 +69,32 @@ def validate_secure_token_for_xblock_handler(user_id, block_key_str, token):
|
||||
# All computations happen above this line so this function always takes a
|
||||
# constant time to produce its answer (security best practice).
|
||||
return bool(result1 or result2)
|
||||
|
||||
|
||||
def get_xblock_id_for_anonymous_user(user):
|
||||
"""
|
||||
Get a unique string that identifies the current anonymous (not logged in)
|
||||
user. (This is different than the "anonymous user ID", which is an
|
||||
anonymized identifier for a logged in user.)
|
||||
|
||||
Note that this ID is a string, not an int. It is guaranteed to be in a
|
||||
unique namespace that won't collide with "normal" user IDs, even when
|
||||
they are converted to a string.
|
||||
"""
|
||||
if not user or not user.is_anonymous:
|
||||
raise TypeError("get_xblock_id_for_anonymous_user() is only for anonymous (not logged in) users.")
|
||||
if hasattr(user, 'xblock_id_for_anonymous_user'):
|
||||
# If code elsewhere (like the xblock_handler API endpoint) has stored
|
||||
# the key on the AnonymousUser object, just return that - it supersedes
|
||||
# everything else:
|
||||
return user.xblock_id_for_anonymous_user
|
||||
# We use the session to track (and create if needed) a unique ID for this anonymous user:
|
||||
current_request = crum.get_current_request()
|
||||
if current_request and current_request.session:
|
||||
# Make sure we have a key for this user:
|
||||
if "xblock_id_for_anonymous_user" not in current_request.session:
|
||||
new_id = "anon{}".format(uuid4().hex[:20])
|
||||
current_request.session["xblock_id_for_anonymous_user"] = new_id
|
||||
return current_request.session["xblock_id_for_anonymous_user"]
|
||||
else:
|
||||
raise RuntimeError("Cannot get a user ID for an anonymous user outside of an HTTP request context.")
|
||||
|
||||
Reference in New Issue
Block a user