Merge pull request #22427 from open-craft/anonymous-user-state
Support anonymous users with new (Blockstore-based) XBlock Runtime
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