Merge pull request #22427 from open-craft/anonymous-user-state

Support anonymous users with new (Blockstore-based) XBlock Runtime
This commit is contained in:
David Ormsbee
2019-12-20 10:28:41 -05:00
committed by GitHub
16 changed files with 385 additions and 90 deletions

View File

@@ -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'

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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)

View File

@@ -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',
)

View File

@@ -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

View File

@@ -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',
),

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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.

View File

@@ -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')

View File

@@ -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.")