xblock_utils.py 15.4 KB
Newer Older
1 2 3 4 5
"""
Functions that can are used to modify XBlock fragments for use in the LMS and Studio
"""

import datetime
6
import json
7
import logging
8
import static_replace
9
import uuid
10
import markupsafe
11
from lxml import html, etree
12
from contracts import contract
13

14
from django.conf import settings
15
from django.utils.timezone import UTC
16
from django.utils.html import escape
17
from django.contrib.auth.models import User
David Baumgold committed
18
from edxmako.shortcuts import render_to_string
19
from xblock.core import XBlock
20
from xblock.exceptions import InvalidScopeError
21 22
from xblock.fragment import Fragment

23
from xmodule.seq_module import SequenceModule
24
from xmodule.vertical_block import VerticalBlock
25
from xmodule.x_module import shim_xmodule_js, XModuleDescriptor, XModule, PREVIEW_VIEWS, STUDIO_VIEW
26
from xmodule.modulestore import ModuleStoreEnum
27
from xmodule.modulestore.django import modulestore
28

29 30 31 32 33 34 35 36 37 38 39
log = logging.getLogger(__name__)


def wrap_fragment(fragment, new_content):
    """
    Returns a new Fragment that has `new_content` and all
    as its content, and all of the resources from fragment
    """
    wrapper_frag = Fragment(content=new_content)
    wrapper_frag.add_frag_resources(fragment)
    return wrapper_frag
40

Calen Pennington committed
41

42 43 44 45 46 47
def request_token(request):
    """
    Return a unique token for the supplied request.
    This token will be the same for all calls to `request_token`
    made on the same request object.
    """
48
    # pylint: disable=protected-access
49 50 51 52 53 54
    if not hasattr(request, '_xblock_token'):
        request._xblock_token = uuid.uuid1().get_hex()

    return request._xblock_token


55 56 57 58 59 60 61 62 63 64 65
def wrap_xblock(
        runtime_class,
        block,
        view,
        frag,
        context,                        # pylint: disable=unused-argument
        usage_id_serializer,
        request_token,                  # pylint: disable=redefined-outer-name
        display_name_only=False,
        extra_data=None
):
66
    """
67
    Wraps the results of rendering an XBlock view in a standard <section> with identifying
68 69
    data so that the appropriate javascript module can be loaded onto it.

70
    :param runtime_class: The name of the javascript runtime class to use to load this block
71 72 73 74
    :param block: An XBlock (that may be an XModule or XModuleDescriptor)
    :param view: The name of the view that rendered the fragment being wrapped
    :param frag: The :class:`Fragment` to be wrapped
    :param context: The context passed to the view being rendered
75 76
    :param usage_id_serializer: A function to serialize the block's usage_id for use by the
        front-end Javascript Runtime.
77 78
    :param request_token: An identifier that is unique per-request, so that only xblocks
        rendered as part of this request will have their javascript initialized.
79 80
    :param display_name_only: If true, don't render the fragment content at all.
        Instead, just render the `display_name` of `block`
81
    :param extra_data: A dictionary with extra data values to be set on the wrapper
82
    """
83 84
    if extra_data is None:
        extra_data = {}
85

86
    # If any mixins have been applied, then use the unmixed class
87
    class_name = getattr(block, 'unmixed_class', block.__class__).__name__
Calen Pennington committed
88

89
    data = {}
90
    data.update(extra_data)
91 92 93

    css_classes = [
        'xblock',
94 95 96 97 98
        'xblock-{}'.format(markupsafe.escape(view)),
        'xblock-{}-{}'.format(
            markupsafe.escape(view),
            markupsafe.escape(block.scope_ids.block_type),
        )
99
    ]
100 101

    if isinstance(block, (XModule, XModuleDescriptor)):
102
        if view in PREVIEW_VIEWS:
103 104
            # The block is acting as an XModule
            css_classes.append('xmodule_display')
105
        elif view == STUDIO_VIEW:
106 107 108
            # The block is acting as an XModuleDescriptor
            css_classes.append('xmodule_edit')

109 110 111
        if getattr(block, 'HIDDEN', False):
            css_classes.append('is-hidden')

112
        css_classes.append('xmodule_' + markupsafe.escape(class_name))
113
        data['type'] = block.js_module_name
114
        shim_xmodule_js(block, frag)
115 116 117

    if frag.js_init_fn:
        data['init'] = frag.js_init_fn
118
        data['runtime-class'] = runtime_class
119
        data['runtime-version'] = frag.js_init_version
120 121 122 123

    data['block-type'] = block.scope_ids.block_type
    data['usage-id'] = usage_id_serializer(block.scope_ids.usage_id)
    data['request-token'] = request_token
124

125 126 127
    if block.name:
        data['name'] = block.name

128
    template_context = {
129 130
        'content': block.display_name if display_name_only else frag.content,
        'classes': css_classes,
131
        'display_name': block.display_name_with_default,
132
        'data_attributes': u' '.join(u'data-{}="{}"'.format(markupsafe.escape(key), markupsafe.escape(value))
133
                                     for key, value in data.iteritems()),
134
    }
135

136
    if hasattr(frag, 'json_init_args') and frag.json_init_args is not None:
137 138
        # Replace / with \/ so that "</script>" in the data won't break things.
        template_context['js_init_parameters'] = json.dumps(frag.json_init_args).replace("/", r"\/")
139 140 141
    else:
        template_context['js_init_parameters'] = ""

142
    return wrap_fragment(frag, render_to_string('xblock_wrapper.html', template_context))
143 144


145
def replace_jump_to_id_urls(course_id, jump_to_id_base_url, block, view, frag, context):  # pylint: disable=unused-argument
146 147 148 149 150
    """
    This will replace a link between courseware in the format
    /jump_to/<id> with a URL for a page that will correctly redirect
    This is similar to replace_course_urls, but much more flexible and
    durable for Studio authored courses. See more comments in static_replace.replace_jump_to_urls
151 152 153 154 155 156 157

    course_id: The course_id in which this rewrite happens
    jump_to_id_base_url:
        A app-tier (e.g. LMS) absolute path to the base of the handler that will perform the
        redirect. e.g. /courses/<org>/<course>/<run>/jump_to_id. NOTE the <id> will be appended to
        the end of this URL at re-write time

158 159
    output: a new :class:`~xblock.fragment.Fragment` that modifies `frag` with
        content that has been update with /jump_to links replaced
160
    """
161
    return wrap_fragment(frag, static_replace.replace_jump_to_id_urls(frag.content, course_id, jump_to_id_base_url))
162 163


164
def replace_course_urls(course_id, block, view, frag, context):  # pylint: disable=unused-argument
165 166 167 168 169
    """
    Updates the supplied module with a new get_html function that wraps
    the old get_html function and substitutes urls of the form /course/...
    with urls that are /courses/<course_id>/...
    """
170
    return wrap_fragment(frag, static_replace.replace_course_urls(frag.content, course_id))
171

Calen Pennington committed
172

173
def replace_static_urls(data_dir, block, view, frag, context, course_id=None, static_asset_path=''):  # pylint: disable=unused-argument
174 175 176 177 178
    """
    Updates the supplied module with a new get_html function that wraps
    the old get_html function and substitutes urls of the form /static/...
    with urls that are /static/<prefix>/...
    """
179 180 181 182 183 184
    return wrap_fragment(frag, static_replace.replace_static_urls(
        frag.content,
        data_dir,
        course_id,
        static_asset_path=static_asset_path
    ))
185 186 187


def grade_histogram(module_id):
188 189 190 191 192 193
    '''
    Print out a histogram of grades on a given problem in staff member debug info.

    Warning: If a student has just looked at an xmodule and not attempted
    it, their grade is None. Since there will always be at least one such student
    this function almost always returns [].
194 195 196 197
    '''
    from django.db import connection
    cursor = connection.cursor()

198 199 200 201 202 203
    query = """\
        SELECT courseware_studentmodule.grade,
        COUNT(courseware_studentmodule.student_id)
        FROM courseware_studentmodule
        WHERE courseware_studentmodule.module_id=%s
        GROUP BY courseware_studentmodule.grade"""
204
    # Passing module_id this way prevents sql-injection.
205
    cursor.execute(query, [module_id.to_deprecated_string()])
206 207

    grades = list(cursor.fetchall())
208
    grades.sort(key=lambda x: x[0])  # Add ORDER BY to sql query?
209
    if len(grades) >= 1 and grades[0][0] is None:
210 211 212 213
        return []
    return grades


214
@contract(user=User, has_instructor_access=bool, block=XBlock, view=basestring, frag=Fragment, context="dict|None")
215
def add_staff_markup(user, has_instructor_access, disable_staff_debug_info, block, view, frag, context):  # pylint: disable=unused-argument
216 217 218
    """
    Updates the supplied module with a new get_html function that wraps
    the output of the old get_html function with additional information
219 220 221
    for admin users only, including a histogram of student answers, the
    definition of the xmodule, and a link to view the module in Studio
    if it is a Studio edited, mongo stored course.
222

223
    Does nothing if module is a SequenceModule.
224
    """
225
    # TODO: make this more general, eg use an XModule attribute instead
226
    if isinstance(block, VerticalBlock) and (not context or not context.get('child_of_vertical', False)):
227
        # check that the course is a mongo backed Studio course before doing work
228
        is_mongo_course = modulestore().get_modulestore_type(block.location.course_key) != ModuleStoreEnum.Type.xml
229 230 231
        is_studio_course = block.course_edit_method == "Studio"

        if is_studio_course and is_mongo_course:
232
            # build edit link to unit in CMS. Can't use reverse here as lms doesn't load cms's urls.py
233
            edit_link = "//" + settings.CMS_BASE + '/container/' + unicode(block.location)
234

235
            # return edit link in rendered HTML for display
236 237 238 239 240 241 242
            return wrap_fragment(
                frag,
                render_to_string(
                    "edit_unit_link.html",
                    {'frag_content': frag.content, 'edit_link': edit_link}
                )
            )
243 244 245
        else:
            return frag

246
    if isinstance(block, SequenceModule) or getattr(block, 'HIDDEN', False):
247 248
        return frag

249
    block_id = block.location
250
    if block.has_score and settings.FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'):
251 252 253 254 255 256
        histogram = grade_histogram(block_id)
        render_histogram = len(histogram) > 0
    else:
        histogram = None
        render_histogram = False

257
    if settings.FEATURES.get('ENABLE_LMS_MIGRATION') and hasattr(block.runtime, 'filestore'):
258
        [filepath, filename] = getattr(block, 'xml_attributes', {}).get('filename', ['', None])
259
        osfs = block.runtime.filestore
260 261 262 263
        if filename is not None and osfs.exists(filename):
            # if original, unmangled filename exists then use it (github
            # doesn't like symlinks)
            filepath = filename
264
        data_dir = block.static_asset_path or osfs.root_path.rsplit('/')[-1]
265 266 267 268 269 270 271 272 273 274
        giturl = block.giturl or 'https://github.com/MITx'
        edit_link = "%s/%s/tree/master/%s" % (giturl, data_dir, filepath)
    else:
        edit_link = False
        # Need to define all the variables that are about to be used
        giturl = ""
        data_dir = ""

    source_file = block.source_file  # source used to generate the problem XML, eg latex or word

275 276 277
    # Useful to indicate to staff if problem has been released or not.
    # TODO (ichuang): use _has_access_descriptor.can_load in lms.courseware.access,
    # instead of now>mstart comparison here.
278 279
    now = datetime.datetime.now(UTC())
    is_released = "unknown"
280
    mstart = block.start
281 282 283 284

    if mstart is not None:
        is_released = "<font color='red'>Yes!</font>" if (now > mstart) else "<font color='green'>Not yet</font>"

285 286 287 288 289 290 291 292
    field_contents = []
    for name, field in block.fields.items():
        try:
            field_contents.append((name, field.read_from(block)))
        except InvalidScopeError:
            log.warning("Unable to read field in Staff Debug information", exc_info=True)
            field_contents.append((name, "WARNING: Unable to read field"))

293 294 295 296 297 298 299 300 301 302 303 304
    staff_context = {
        'fields': field_contents,
        'xml_attributes': getattr(block, 'xml_attributes', {}),
        'location': block.location,
        'xqa_key': block.xqa_key,
        'source_file': source_file,
        'source_url': '%s/%s/tree/master/%s' % (giturl, data_dir, source_file),
        'category': str(block.__class__.__name__),
        # Template uses element_id in js function names, so can't allow dashes
        'element_id': block.location.html_id().replace('-', '_'),
        'edit_link': edit_link,
        'user': user,
305
        'xqa_server': settings.FEATURES.get('XQA_SERVER', "http://your_xqa_server.com"),
306 307 308 309 310
        'histogram': json.dumps(histogram),
        'render_histogram': render_histogram,
        'block_content': frag.content,
        'is_released': is_released,
        'has_instructor_access': has_instructor_access,
311
        'disable_staff_debug_info': disable_staff_debug_info,
312
    }
313
    return wrap_fragment(frag, render_to_string("staff_problem_info.html", staff_context))
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349


def get_course_update_items(course_updates, provided_index=0):
    """
    Returns list of course_updates data dictionaries either from new format if available or
    from old. This function don't modify old data to new data (in db), instead returns data
    in common old dictionary format.
    New Format: {"items" : [{"id": computed_id, "date": date, "content": html-string}],
                 "data": "<ol>[<li><h2>date</h2>content</li>]</ol>"}
    Old Format: {"data": "<ol>[<li><h2>date</h2>content</li>]</ol>"}
    """
    def _course_info_content(html_parsed):
        """
        Constructs the HTML for the course info update, not including the header.
        """
        if len(html_parsed) == 1:
            # could enforce that update[0].tag == 'h2'
            content = html_parsed[0].tail
        else:
            content = html_parsed[0].tail if html_parsed[0].tail is not None else ""
            content += "\n".join([html.tostring(ele) for ele in html_parsed[1:]])
        return content

    if course_updates and getattr(course_updates, "items", None):
        if provided_index and 0 < provided_index <= len(course_updates.items):
            return course_updates.items[provided_index - 1]
        else:
            # return list in reversed order (old format: [4,3,2,1]) for compatibility
            return list(reversed(course_updates.items))

    course_update_items = []
    if course_updates:
        # old method to get course updates
        # purely to handle free formed updates not done via editor. Actually kills them, but at least doesn't break.
        try:
            course_html_parsed = html.fromstring(course_updates.data)
350
        except (etree.XMLSyntaxError, etree.ParserError):   # pylint: disable=no-member
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373
            log.error("Cannot parse: " + course_updates.data)
            escaped = escape(course_updates.data)
            course_html_parsed = html.fromstring("<ol><li>" + escaped + "</li></ol>")

        # confirm that root is <ol>, iterate over <li>, pull out <h2> subs and then rest of val
        if course_html_parsed.tag == 'ol':
            # 0 is the newest
            for index, update in enumerate(course_html_parsed):
                if len(update) > 0:
                    content = _course_info_content(update)
                    # make the id on the client be 1..len w/ 1 being the oldest and len being the newest
                    computed_id = len(course_html_parsed) - index
                    payload = {
                        "id": computed_id,
                        "date": update.findtext("h2"),
                        "content": content
                    }
                    if provided_index == 0:
                        course_update_items.append(payload)
                    elif provided_index == computed_id:
                        return payload

    return course_update_items