From 2ba4957951ed92bf21dacd5caf05c2bf676ad4fe Mon Sep 17 00:00:00 2001
From: Xavier Antoviaque <xavier@antoviaque.org>
Date: Tue, 18 Mar 2014 08:49:16 -0400
Subject: [PATCH] xblock-external-ui: Add XBlock API call to render XBlock
 views

xblock-external-ui: Include CSRF token in the API answer
xblock-external-ui: Include full path when building local_url
xblock-external-ui: Fix TestHandleXBlockCallback & bok_choy, add tests
xblock-external-ui: Only return `instance` in `_invoke_xblock_handler()`
xblock-external-ui: Group resources by hash tag to avoid duplicate loads
xblock-external-ui: PEP8
xblock-external-ui: Fail early if the XBlock view is called anonymously

    We used to serve anonymous requests, but most XBlocks assume that the
    user is logged in, which can generate a lot of errors when the user is
    accessed or when an XBlock ajax callback is queried. Fail early to only
    get one error per page load, and prevent displaying the XBlock
    altogether when the LMS doesn't find an active user session.

xblock-external-ui: Add request params in view render context
xblock-external-ui: HTTP error status when file is too large for handler
xblock-external-ui: Fix unicode encodings in XBlock rendering
xblock-external-ui: Feature flag for API call ENABLE_XBLOCK_VIEW_ENDPOINT
---
 lms/djangoapps/courseware/module_render.py    | 107 ++++++++++++++----
 .../courseware/tests/test_module_render.py    |  36 +++++-
 lms/djangoapps/lms_xblock/runtime.py          |   4 +-
 lms/envs/bok_choy.env.json                    |   2 +-
 lms/envs/common.py                            |   7 ++
 lms/urls.py                                   |   5 +
 6 files changed, 137 insertions(+), 24 deletions(-)

diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py
index 6fe7f2b276c..433086e2cba 100644
--- a/lms/djangoapps/courseware/module_render.py
+++ b/lms/djangoapps/courseware/module_render.py
@@ -1,3 +1,8 @@
+"""
+Module rendering
+"""
+
+import hashlib
 import json
 import logging
 import mimetypes
@@ -5,6 +10,7 @@ import mimetypes
 import static_replace
 import xblock.reference.plugins
 
+from collections import OrderedDict
 from functools import partial
 from requests.auth import HTTPBasicAuth
 import dogstats_wrapper as dog_stats_api
@@ -13,6 +19,8 @@ from opaque_keys import InvalidKeyError
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.core.cache import cache
+from django.core.context_processors import csrf
+from django.core.exceptions import PermissionDenied
 from django.core.urlresolvers import reverse
 from django.http import Http404, HttpResponse
 from django.views.decorators.csrf import csrf_exempt
@@ -32,7 +40,7 @@ from student.models import anonymous_id_for_user, user_by_anonymous_id
 from xblock.core import XBlock
 from xblock.fields import Scope
 from xblock.runtime import KvsFieldData, KeyValueStore
-from xblock.exceptions import NoSuchHandlerError
+from xblock.exceptions import NoSuchHandlerError, NoSuchViewError
 from xblock.django.request import django_to_webob_request, webob_to_django_response
 from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
 from xmodule.exceptions import NotFoundError, ProcessingError
@@ -781,7 +789,7 @@ def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix=
     """
     request.user.known = False
 
-    return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user)
+    return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix)
 
 
 def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
@@ -802,7 +810,7 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
     if not request.user.is_authenticated():
         return HttpResponse('Unauthenticated', status=403)
 
-    return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, request.user)
+    return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix)
 
 
 def xblock_resource(request, block_type, uri):  # pylint: disable=unused-argument
@@ -822,31 +830,20 @@ def xblock_resource(request, block_type, uri):  # pylint: disable=unused-argumen
     return HttpResponse(content, mimetype=mimetype)
 
 
-def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
+def _get_module_by_usage_id(request, course_id, usage_id):
     """
-    Invoke an XBlock handler, either authenticated or not.
-
-    Arguments:
-        request (HttpRequest): the current request
-        course_id (str): A string of the form org/course/run
-        usage_id (str): A string of the form i4x://org/course/category/name@revision
-        handler (str): The name of the handler to invoke
-        suffix (str): The suffix to pass to the handler when invoked
-        user (User): The currently logged in user
+    Gets a module instance based on its `usage_id` in a course, for a given request/user
 
+    Returns (instance, tracking_context)
     """
+    user = request.user
+
     try:
         course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
         usage_key = course_id.make_usage_key_from_deprecated_string(unquote_slashes(usage_id))
     except InvalidKeyError:
         raise Http404("Invalid location")
 
-    # Check submitted files
-    files = request.FILES or {}
-    error_msg = _check_files_limits(files)
-    if error_msg:
-        return HttpResponse(json.dumps({'success': error_msg}))
-
     try:
         descriptor = modulestore().get_item(usage_key)
         descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
@@ -859,13 +856,13 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
         )
         raise Http404
 
-    tracking_context_name = 'module_callback_handler'
     tracking_context = {
         'module': {
             'display_name': descriptor.display_name_with_default,
             'usage_key': unicode(descriptor.location),
         }
     }
+
     # For blocks that are inherited from a content library, we add some additional metadata:
     if descriptor_orig_usage_key is not None:
         tracking_context['module']['original_usage_key'] = unicode(descriptor_orig_usage_key)
@@ -884,6 +881,30 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
         log.debug("No module %s for user %s -- access denied?", usage_key, user)
         raise Http404
 
+    return (instance, tracking_context)
+
+
+def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix):
+    """
+    Invoke an XBlock handler, either authenticated or not.
+
+    Arguments:
+        request (HttpRequest): the current request
+        course_id (str): A string of the form org/course/run
+        usage_id (str): A string of the form i4x://org/course/category/name@revision
+        handler (str): The name of the handler to invoke
+        suffix (str): The suffix to pass to the handler when invoked
+    """
+
+    # Check submitted files
+    files = request.FILES or {}
+    error_msg = _check_files_limits(files)
+    if error_msg:
+        return JsonResponse(object={'success': error_msg}, status=413)
+
+    instance, tracking_context = _get_module_by_usage_id(request, course_id, usage_id)
+
+    tracking_context_name = 'module_callback_handler'
     req = django_to_webob_request(request)
     try:
         with tracker.get_tracker().context(tracking_context_name, tracking_context):
@@ -912,6 +933,52 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, user):
     return webob_to_django_response(resp)
 
 
+def hash_resource(resource):
+    """
+    Hash a :class:`xblock.fragment.FragmentResource
+    """
+    md5 = hashlib.md5()
+    for data in resource:
+        md5.update(repr(data))
+    return md5.hexdigest()
+
+
+def xblock_view(request, course_id, usage_id, view_name):
+    """
+    Returns the rendered view of a given XBlock, with related resources
+
+    Returns a json object containing two keys:
+        html: The rendered html of the view
+        resources: A list of tuples where the first element is the resource hash, and
+            the second is the resource description
+    """
+    if not settings.FEATURES.get('ENABLE_XBLOCK_VIEW_ENDPOINT', False):
+        log.warn("Attempt to use deactivated XBlock view endpoint -"
+                 " see FEATURES['ENABLE_XBLOCK_VIEW_ENDPOINT']")
+        raise Http404
+
+    if not request.user.is_authenticated():
+        raise PermissionDenied
+
+    instance, tracking_context = _get_module_by_usage_id(request, course_id, usage_id)
+
+    try:
+        fragment = instance.render(view_name, context=request.GET)
+    except NoSuchViewError:
+        log.exception("Attempt to render missing view on %s: %s", instance, view_name)
+        raise Http404
+
+    hashed_resources = OrderedDict()
+    for resource in fragment.resources:
+        hashed_resources[hash_resource(resource)] = resource
+
+    return JsonResponse({
+        'html': fragment.content,
+        'resources': hashed_resources.items(),
+        'csrf_token': str(csrf(request)['csrf_token']),
+    })
+
+
 def get_score_bucket(grade, max_grade):
     """
     Function to split arbitrary score ranges into 3 buckets.
diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py
index 2a12a8c3eaf..278a28a108d 100644
--- a/lms/djangoapps/courseware/tests/test_module_render.py
+++ b/lms/djangoapps/courseware/tests/test_module_render.py
@@ -16,6 +16,7 @@ from django.contrib.auth.models import AnonymousUser
 from mock import MagicMock, patch, Mock
 from opaque_keys.edx.keys import UsageKey, CourseKey
 from opaque_keys.edx.locations import SlashSeparatedCourseKey
+from courseware.module_render import hash_resource
 from xblock.field_data import FieldData
 from xblock.runtime import Runtime
 from xblock.fields import ScopeIds
@@ -246,6 +247,14 @@ class ModuleRenderTestCase(ModuleStoreTestCase, LoginEnrollmentTestCase):
         render.get_module_for_descriptor(self.mock_user, request, descriptor, field_data_cache, self.toy_course.id)
         render.get_module_for_descriptor(self.mock_user, request, descriptor, field_data_cache, self.toy_course.id)
 
+    def test_hash_resource(self):
+        """
+        Ensure that the resource hasher works and does not fail on unicode,
+        decoded or otherwise.
+        """
+        resources = ['ASCII text', u'❄ I am a special snowflake.', "❄ So am I, but I didn't tell you."]
+        self.assertEqual(hash_resource(resources), 'a76e27c8e80ca3efd7ce743093aa59e0')
+
 
 class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase):
     """
@@ -316,7 +325,7 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase):
             json.dumps({
                 'success': 'Submission aborted! Maximum %d files may be submitted at once' %
                            settings.MAX_FILEUPLOADS_PER_INPUT
-            })
+            }, indent=2)
         )
 
     def test_too_large_file(self):
@@ -336,7 +345,7 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase):
             json.dumps({
                 'success': 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %
                            (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))
-            })
+            }, indent=2)
         )
 
     def test_xmodule_dispatch(self):
@@ -399,6 +408,29 @@ class TestHandleXBlockCallback(ModuleStoreTestCase, LoginEnrollmentTestCase):
                 'bad_dispatch',
             )
 
+    @patch.dict('django.conf.settings.FEATURES', {'ENABLE_XBLOCK_VIEW_ENDPOINT': True})
+    def test_xblock_view_handler(self):
+        args = [
+            'edX/toy/2012_Fall',
+            quote_slashes('i4x://edX/toy/videosequence/Toy_Videos'),
+            'student_view'
+        ]
+        xblock_view_url = reverse(
+            'xblock_view',
+            args=args
+        )
+
+        request = self.request_factory.get(xblock_view_url)
+        request.user = self.mock_user
+        response = render.xblock_view(request, *args)
+        self.assertEquals(200, response.status_code)
+
+        expected = ['csrf_token', 'html', 'resources']
+        content = json.loads(response.content)
+        for section in expected:
+            self.assertIn(section, content)
+        self.assertIn('<div class="xblock xblock-student_view xmodule_display', content['html'])
+
 
 @ddt.ddt
 class TestTOC(ModuleStoreTestCase):
diff --git a/lms/djangoapps/lms_xblock/runtime.py b/lms/djangoapps/lms_xblock/runtime.py
index 7b5c3fe7b95..af0c32f14c5 100644
--- a/lms/djangoapps/lms_xblock/runtime.py
+++ b/lms/djangoapps/lms_xblock/runtime.py
@@ -5,6 +5,7 @@ Module implementing `xblock.runtime.Runtime` functionality for the LMS
 import re
 import xblock.reference.plugins
 
+from django.conf import settings
 from django.core.urlresolvers import reverse
 from django.conf import settings
 from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
@@ -118,10 +119,11 @@ class LmsHandlerUrls(object):
         """
         local_resource_url for Studio
         """
-        return reverse('xblock_resource_url', kwargs={
+        path = reverse('xblock_resource_url', kwargs={
             'block_type': block.scope_ids.block_type,
             'uri': uri,
         })
+        return '//{}{}'.format(settings.SITE_NAME, path)
 
 
 class LmsPartitionService(PartitionService):
diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json
index 7f60988d85c..89223675c75 100644
--- a/lms/envs/bok_choy.env.json
+++ b/lms/envs/bok_choy.env.json
@@ -104,7 +104,7 @@
     "SEGMENT_IO_LMS": true,
     "SERVER_EMAIL": "devops@example.com",
     "SESSION_COOKIE_DOMAIN": null,
-    "SITE_NAME": "localhost",
+    "SITE_NAME": "localhost:8003",
     "STATIC_ROOT_BASE": "** OVERRIDDEN **",
     "STATIC_URL_BASE": "/static/",
     "SYSLOG_SERVER": "",
diff --git a/lms/envs/common.py b/lms/envs/common.py
index 64971438ffc..9347fcc670e 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -133,6 +133,13 @@ FEATURES = {
     # Toggles OAuth2 authentication provider
     'ENABLE_OAUTH2_PROVIDER': False,
 
+    # Allows to enable an API endpoint to serve XBlock view, used for example by external applications.
+    # See jquey-xblock: https://github.com/edx-solutions/jquery-xblock
+    'ENABLE_XBLOCK_VIEW_ENDPOINT': False,
+
+    # Allows to configure the LMS to provide CORS headers to serve requests from other domains
+    'ENABLE_CORS_HEADERS': False,
+
     # Can be turned off if course lists need to be hidden. Effects views and templates.
     'COURSES_ARE_BROWSABLE': True,
 
diff --git a/lms/urls.py b/lms/urls.py
index a3b604d756d..37f197aed89 100644
--- a/lms/urls.py
+++ b/lms/urls.py
@@ -237,6 +237,11 @@ if settings.COURSEWARE_ENABLED:
         url(r'^courses/{course_key}/xblock/{usage_key}/handler/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$'.format(course_key=settings.COURSE_ID_PATTERN, usage_key=settings.USAGE_ID_PATTERN),
             'courseware.module_render.handle_xblock_callback',
             name='xblock_handler'),
+        url(r'^courses/{course_key}/xblock/{usage_key}/view/(?P<view_name>[^/]*)$'.format(
+            course_key=settings.COURSE_ID_PATTERN,
+            usage_key=settings.USAGE_ID_PATTERN),
+            'courseware.module_render.xblock_view',
+            name='xblock_view'),
         url(r'^courses/{course_key}/xblock/{usage_key}/handler_noauth/(?P<handler>[^/]*)(?:/(?P<suffix>.*))?$'.format(course_key=settings.COURSE_ID_PATTERN, usage_key=settings.USAGE_ID_PATTERN),
             'courseware.module_render.handle_xblock_callback_noauth',
             name='xblock_handler_noauth'),
-- 
GitLab