module_render.py 48.9 KB
Newer Older
1 2 3 4 5
"""
Module rendering
"""

import hashlib
6
import json
7
import logging
8
import mimetypes
Calen Pennington committed
9 10 11

import static_replace

12
from collections import OrderedDict
13
from functools import partial
Calen Pennington committed
14
from requests.auth import HTTPBasicAuth
15
import dogstats_wrapper as dog_stats_api
16

17
from django.conf import settings
18
from django.contrib.auth.models import User
19
from django.core.cache import cache
20 21
from django.core.context_processors import csrf
from django.core.exceptions import PermissionDenied
22
from django.core.urlresolvers import reverse
23
from django.http import Http404, HttpResponse
24
from django.test.client import RequestFactory
25
from django.views.decorators.csrf import csrf_exempt
26

27 28
import newrelic.agent

29
from capa.xqueue_interface import XQueueInterface
30
from courseware.access import has_access, get_user_role
31 32 33 34 35 36 37
from courseware.masquerade import (
    MasqueradingKeyValueStore,
    filter_displayed_blocks,
    is_masquerading_as_specific_student,
    setup_masquerade,
)
from courseware.model_data import DjangoKeyValueStore, FieldDataCache, set_score
38
from courseware.models import SCORE_CHANGED
39 40 41 42
from courseware.entrance_exams import (
    get_entrance_exam_score,
    user_must_complete_entrance_exam
)
43 44
from edxmako.shortcuts import render_to_string
from eventtracking import tracker
45 46
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes
47
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
48
from opaque_keys import InvalidKeyError
49
from opaque_keys.edx.keys import UsageKey, CourseKey
50
from opaque_keys.edx.locations import SlashSeparatedCourseKey
51
from openedx.core.lib.xblock_utils import (
52 53 54 55 56
    replace_course_urls,
    replace_jump_to_id_urls,
    replace_static_urls,
    add_staff_markup,
    wrap_xblock,
57
    request_token as xblock_request_token,
58
)
59 60 61 62 63 64 65 66 67 68 69 70 71
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
from student.models import anonymous_id_for_user, user_by_anonymous_id
from student.roles import CourseBetaTesterRole
from xblock.core import XBlock
from xblock.django.request import django_to_webob_request, webob_to_django_response
from xblock_django.user_service import DjangoXBlockUserService
from xblock.exceptions import NoSuchHandlerError, NoSuchViewError
from xblock.reference.plugins import FSService
from xblock.runtime import KvsFieldData
from xmodule.contentstore.django import contentstore
from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.modulestore.django import modulestore, ModuleI18nService
72
from xmodule.lti_module import LTIModule
73
from xmodule.modulestore.exceptions import ItemNotFoundError
74
from xmodule.x_module import XModuleDescriptor
75
from xmodule.mixin import wrap_with_license
76
from util.json_request import JsonResponse
77
from util.model_utils import slugify
78
from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
79
from util import milestones_helpers
80
from verify_student.services import ReverificationService
81

82 83 84
from edx_proctoring.services import ProctoringService
from openedx.core.djangoapps.credit.services import CreditService

85 86
from .field_overrides import OverrideFieldData

87
log = logging.getLogger(__name__)
88

89

90
if settings.XQUEUE_INTERFACE.get('basic_auth') is not None:
91
    REQUESTS_AUTH = HTTPBasicAuth(*settings.XQUEUE_INTERFACE['basic_auth'])
92
else:
93
    REQUESTS_AUTH = None
94

95
XQUEUE_INTERFACE = XQueueInterface(
96
    settings.XQUEUE_INTERFACE['url'],
97
    settings.XQUEUE_INTERFACE['django_auth'],
98
    REQUESTS_AUTH,
99 100
)

101 102 103
# TODO: course_id and course_key are used interchangeably in this file, which is wrong.
# Some brave person should make the variable names consistently someday, but the code's
# coupled enough that it's kind of tricky--you've been warned!
104

105

106 107 108 109 110 111 112
class LmsModuleRenderError(Exception):
    """
    An exception class for exceptions thrown by module_render that don't fit well elsewhere
    """
    pass


113
def make_track_function(request):
114
    '''
115
    Make a tracking function that logs what happened.
116
    For use in ModuleSystem.
Piotr Mitros committed
117
    '''
118 119
    import track.views

Adam Palay committed
120
    def function(event_type, event):
121
        return track.views.server_track(request, event_type, event, page='x_module')
Adam Palay committed
122
    return function
123

124

125
def toc_for_course(user, request, course, active_chapter, active_section, field_data_cache):
126 127
    '''
    Create a table of contents from the module store
128

129
    Return format:
130 131
    [ {'display_name': name, 'url_name': url_name,
       'sections': SECTIONS, 'active': bool}, ... ]
132

133
    where SECTIONS is a list
134
    [ {'display_name': name, 'url_name': url_name,
kimth committed
135
       'format': format, 'due': due, 'active' : bool, 'graded': bool}, ...]
136

137
    active is set for the section and chapter corresponding to the passed
138 139
    parameters, which are expected to be url_names of the chapter+section.
    Everything else comes from the xml, or defaults to "".
140

141
    chapters with name 'hidden' are skipped.
142 143 144

    NOTE: assumes that if we got this far, user has access to course.  Returns
    None if this is not the case.
145

Calen Pennington committed
146
    field_data_cache must include data from the course module and 2 levels of its descendents
147
    '''
148

149
    with modulestore().bulk_operations(course.id):
150
        course_module = get_module_for_descriptor(
151
            user, request, course, field_data_cache, course.id, course=course
152
        )
153 154 155
        if course_module is None:
            return None

156 157 158 159
        toc_chapters = list()
        chapters = course_module.get_display_items()

        # See if the course is gated by one or more content milestones
160
        required_content = milestones_helpers.get_required_content(course, user)
161

162
        # The user may not actually have to complete the entrance exam, if one is required
163
        if not user_must_complete_entrance_exam(request, user, course):
164 165 166
            required_content = [content for content in required_content if not content == course.entrance_exam_id]

        for chapter in chapters:
167 168
            # Only show required content, if there is required content
            # chapter.hide_from_toc is read-only (boo)
169
            display_id = slugify(chapter.display_name_with_default)
170
            local_hide_from_toc = False
171
            if required_content:
172 173 174 175 176
                if unicode(chapter.location) not in required_content:
                    local_hide_from_toc = True

            # Skip the current chapter if a hide flag is tripped
            if chapter.hide_from_toc or local_hide_from_toc:
177 178 179 180 181 182 183 184 185
                continue

            sections = list()
            for section in chapter.get_display_items():

                active = (chapter.url_name == active_chapter and
                          section.url_name == active_section)

                if not section.hide_from_toc:
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248
                    section_context = {
                        'display_name': section.display_name_with_default,
                        'url_name': section.url_name,
                        'format': section.format if section.format is not None else '',
                        'due': section.due,
                        'active': active,
                        'graded': section.graded,
                    }

                    #
                    # Add in rendering context for proctored exams
                    # if applicable
                    #
                    is_proctored_enabled = (
                        getattr(section, 'is_proctored_enabled', False) and
                        settings.FEATURES.get('ENABLE_PROCTORED_EXAMS', False)
                    )
                    if is_proctored_enabled:
                        # We need to import this here otherwise Lettuce test
                        # harness fails. When running in 'harvest' mode, the
                        # test service appears to get into trouble with
                        # circular references (not sure which as edx_proctoring.api
                        # doesn't import anything from edx-platform). Odd thing
                        # is that running: manage.py lms runserver --settings=acceptance
                        # works just fine, it's really a combination of Lettuce and the
                        # 'harvest' management command
                        #
                        # One idea is that there is some coupling between
                        # lettuce and the 'terrain' Djangoapps projects in /common
                        # This would need more investigation
                        from edx_proctoring.api import get_attempt_status_summary

                        #
                        # call into edx_proctoring subsystem
                        # to get relevant proctoring information regarding this
                        # level of the courseware
                        #
                        # This will return None, if (user, course_id, content_id)
                        # is not applicable
                        #
                        proctoring_attempt_context = None
                        try:
                            proctoring_attempt_context = get_attempt_status_summary(
                                user.id,
                                unicode(course.id),
                                unicode(section.location)
                            )
                        except Exception, ex:  # pylint: disable=broad-except
                            # safety net in case something blows up in edx_proctoring
                            # as this is just informational descriptions, it is better
                            # to log and continue (which is safe) than to have it be an
                            # unhandled exception
                            log.exception(ex)

                        if proctoring_attempt_context:
                            # yes, user has proctoring context about
                            # this level of the courseware
                            # so add to the accordion data context
                            section_context.update({
                                'proctoring': proctoring_attempt_context,
                            })

                    sections.append(section_context)
249 250
            toc_chapters.append({
                'display_name': chapter.display_name_with_default,
251
                'display_id': display_id,
252 253 254 255 256
                'url_name': chapter.url_name,
                'sections': sections,
                'active': chapter.url_name == active_chapter
            })
        return toc_chapters
257

258

259
def get_module(user, request, usage_key, field_data_cache,
260
               position=None, log_if_not_found=True, wrap_xmodule_display=True,
261
               grade_bucket_type=None, depth=0,
262
               static_asset_path='', course=None):
263 264
    """
    Get an instance of the xmodule class identified by location,
265 266
    setting the state based on an existing StudentModule, or creating one if none
    exists.
267 268

    Arguments:
269
      - user                  : User for whom we're getting the module
270 271
      - request               : current django HTTPrequest.  Note: request.user isn't used for anything--all auth
                                and such works based on user.
272
      - usage_key             : A UsageKey object identifying the module to load
Calen Pennington committed
273
      - field_data_cache      : a FieldDataCache
274
      - position              : extra information from URL for user-specified
275
                                position within module
276 277 278
      - log_if_not_found      : If this is True, we log a debug message if we cannot find the requested xmodule.
      - wrap_xmodule_display  : If this is True, wrap the output display in a single div to allow for the
                                XModule javascript to be bound correctly
279 280
      - depth                 : number of levels of descendents to cache when loading this module.
                                None means cache all descendents
281 282 283 284
      - static_asset_path     : static asset path to use (overrides descriptor's value); needed
                                by get_course_info_section, because info section modules
                                do not have a course as the parent module, and thus do not
                                inherit this lms key value.
285

286 287 288 289 290
    Returns: xmodule instance, or None if the user does not have access to the
    module.  If there's an error, will try to return an instance of ErrorModule
    if possible.  If not possible, return None.
    """
    try:
291 292
        descriptor = modulestore().get_item(usage_key, depth=depth)
        return get_module_for_descriptor(user, request, descriptor, field_data_cache, usage_key.course_key,
Calen Pennington committed
293
                                         position=position,
294
                                         wrap_xmodule_display=wrap_xmodule_display,
295
                                         grade_bucket_type=grade_bucket_type,
296 297
                                         static_asset_path=static_asset_path,
                                         course=course)
298
    except ItemNotFoundError:
299 300
        if log_if_not_found:
            log.debug("Error in get_module: ItemNotFoundError")
301
        return None
302

303 304 305 306
    except:
        # Something has gone terribly wrong, but still not letting it turn into a 500.
        log.exception("Error in get_module")
        return None
307

308

309 310 311 312
def get_xqueue_callback_url_prefix(request):
    """
    Calculates default prefix based on request, but allows override via settings

313 314 315
    This is separated from get_module_for_descriptor so that it can be called
    by the LMS before submitting background tasks to run.  The xqueue callbacks
    should go back to the LMS, not to the worker.
316
    """
317
    prefix = '{proto}://{host}'.format(
Adam Palay committed
318 319 320
        proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http'),
        host=request.get_host()
    )
321
    return settings.XQUEUE_INTERFACE.get('callback_url', prefix)
322 323


324
def get_module_for_descriptor(user, request, descriptor, field_data_cache, course_key,
325
                              position=None, wrap_xmodule_display=True, grade_bucket_type=None,
326 327
                              static_asset_path='', disable_staff_debug_info=False,
                              course=None):
328
    """
329 330
    Implements get_module, extracting out the request-specific functionality.

331 332
    disable_staff_debug_info : If this is True, exclude staff debug information in the rendering of the module.

333 334 335 336 337
    See get_module() docstring for further details.
    """
    track_function = make_track_function(request)
    xqueue_callback_url_prefix = get_xqueue_callback_url_prefix(request)

338 339
    user_location = getattr(request, 'session', {}).get('country_code')

340 341 342 343 344
    student_kvs = DjangoKeyValueStore(field_data_cache)
    if is_masquerading_as_specific_student(user, course_key):
        student_kvs = MasqueradingKeyValueStore(student_kvs, request.session)
    student_data = KvsFieldData(student_kvs)

345 346 347
    return get_module_for_descriptor_internal(
        user=user,
        descriptor=descriptor,
348
        student_data=student_data,
349
        course_id=course_key,
350 351 352 353 354 355 356
        track_function=track_function,
        xqueue_callback_url_prefix=xqueue_callback_url_prefix,
        position=position,
        wrap_xmodule_display=wrap_xmodule_display,
        grade_bucket_type=grade_bucket_type,
        static_asset_path=static_asset_path,
        user_location=user_location,
357 358
        request_token=xblock_request_token(request),
        disable_staff_debug_info=disable_staff_debug_info,
359
        course=course
360
    )
361 362


363
def get_module_system_for_user(user, student_data,  # TODO  # pylint: disable=too-many-statements
364 365
                               # Arguments preceding this comment have user binding, those following don't
                               descriptor, course_id, track_function, xqueue_callback_url_prefix,
366
                               request_token, position=None, wrap_xmodule_display=True, grade_bucket_type=None,
367 368
                               static_asset_path='', user_location=None, disable_staff_debug_info=False,
                               course=None):
369
    """
370
    Helper function that returns a module system and student_data bound to a user and a descriptor.
371

372 373 374
    The purpose of this function is to factor out everywhere a user is implicitly bound when creating a module,
    to allow an existing module to be re-bound to a user.  Most of the user bindings happen when creating the
    closures that feed the instantiation of ModuleSystem.
375

376
    The arguments fall into two categories: those that have explicit or implicit user binding, which are user
377
    and student_data, and those don't and are just present so that ModuleSystem can be instantiated, which
378 379
    are all the other arguments.  Ultimately, this isn't too different than how get_module_for_descriptor_internal
    was before refactoring.
380

381 382
    Arguments:
        see arguments for get_module()
383
        request_token (str): A token unique to the request use by xblock initialization
384

385 386 387
    Returns:
        (LmsModuleSystem, KvsFieldData):  (module system, student_data) bound to, primarily, the user and descriptor
    """
388

389
    def make_xqueue_callback(dispatch='score_update'):
390 391 392
        """
        Returns fully qualified callback URL for external queueing system
        """
Adam Palay committed
393 394 395
        relative_xqueue_callback_url = reverse(
            'xqueue_callback',
            kwargs=dict(
396
                course_id=course_id.to_deprecated_string(),
Adam Palay committed
397
                userid=str(user.id),
398
                mod_id=descriptor.location.to_deprecated_string(),
Adam Palay committed
399 400 401
                dispatch=dispatch
            ),
        )
402
        return xqueue_callback_url_prefix + relative_xqueue_callback_url
403

404
    # Default queuename is course-specific and is derived from the course that
405 406 407
    #   contains the current module.
    # TODO: Queuename should be derived from 'course_settings.json' of each course
    xqueue_default_queuename = descriptor.location.org + '-' + descriptor.location.course
408

409
    xqueue = {
410
        'interface': XQUEUE_INTERFACE,
411 412 413 414
        'construct_callback': make_xqueue_callback,
        'default_queuename': xqueue_default_queuename.replace(' ', '_'),
        'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
    }
415

416 417 418 419
    # This is a hacky way to pass settings to the combined open ended xmodule
    # It needs an S3 interface to upload images to S3
    # It needs the open ended grading interface in order to get peer grading to be done
    # this first checks to see if the descriptor is the correct one, and only sends settings if it is
420

421
    # Get descriptor metadata fields indicating needs for various settings
422 423
    needs_open_ended_interface = getattr(descriptor, "needs_open_ended_interface", False)
    needs_s3_interface = getattr(descriptor, "needs_s3_interface", False)
424

425
    # Initialize interfaces to None
426 427
    open_ended_grading_interface = None
    s3_interface = None
428

429
    # Create interfaces if needed
430
    if needs_open_ended_interface:
431
        open_ended_grading_interface = settings.OPEN_ENDED_GRADING_INTERFACE
432 433
        open_ended_grading_interface['mock_peer_grading'] = settings.MOCK_PEER_GRADING
        open_ended_grading_interface['mock_staff_grading'] = settings.MOCK_STAFF_GRADING
434 435
    if needs_s3_interface:
        s3_interface = {
Vik Paruchuri committed
436 437 438
            'access_key': getattr(settings, 'AWS_ACCESS_KEY_ID', ''),
            'secret_access_key': getattr(settings, 'AWS_SECRET_ACCESS_KEY', ''),
            'storage_bucket_name': getattr(settings, 'AWS_STORAGE_BUCKET_NAME', 'openended')
439
        }
440

441
    def inner_get_module(descriptor):
442
        """
443 444 445
        Delegate to get_module_for_descriptor_internal() with all values except `descriptor` set.

        Because it does an access check, it may return None.
446
        """
447 448
        # TODO: fix this so that make_xqueue_callback uses the descriptor passed into
        # inner_get_module, not the parent's callback.  Add it as an argument....
449 450 451
        return get_module_for_descriptor_internal(
            user=user,
            descriptor=descriptor,
452
            student_data=student_data,
453 454 455 456 457 458 459 460 461
            course_id=course_id,
            track_function=track_function,
            xqueue_callback_url_prefix=xqueue_callback_url_prefix,
            position=position,
            wrap_xmodule_display=wrap_xmodule_display,
            grade_bucket_type=grade_bucket_type,
            static_asset_path=static_asset_path,
            user_location=user_location,
            request_token=request_token,
462
            course=course
463
        )
464

465
    def _fulfill_content_milestones(user, course_key, content_key):
466 467 468 469 470 471 472 473 474 475 476 477
        """
        Internal helper to handle milestone fulfillments for the specified content module
        """
        # Fulfillment Use Case: Entrance Exam
        # If this module is part of an entrance exam, we'll need to see if the student
        # has reached the point at which they can collect the associated milestone
        if settings.FEATURES.get('ENTRANCE_EXAMS', False):
            course = modulestore().get_course(course_key)
            content = modulestore().get_item(content_key)
            entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', False)
            in_entrance_exam = getattr(content, 'in_entrance_exam', False)
            if entrance_exam_enabled and in_entrance_exam:
478 479 480 481
                # We don't have access to the true request object in this context, but we can use a mock
                request = RequestFactory().request()
                request.user = user
                exam_pct = get_entrance_exam_score(request, course)
482
                if exam_pct >= course.entrance_exam_minimum_score_pct:
483
                    exam_key = UsageKey.from_string(course.entrance_exam_id)
484 485
                    relationship_types = milestones_helpers.get_milestone_relationship_types()
                    content_milestones = milestones_helpers.get_course_content_milestones(
486 487 488 489 490
                        course_key,
                        exam_key,
                        relationship=relationship_types['FULFILLS']
                    )
                    # Add each milestone to the user's set...
491
                    user = {'id': request.user.id}
492
                    for milestone in content_milestones:
493
                        milestones_helpers.add_user_milestone(user, milestone)
494 495 496 497 498

    def handle_grade_event(block, event_type, event):  # pylint: disable=unused-argument
        """
        Manages the workflow for recording and updating of student module grade state
        """
499
        user_id = event.get('user_id', user.id)
500

501 502
        grade = event.get('value')
        max_grade = event.get('max_value')
503

504
        set_score(
505 506 507 508
            user_id,
            descriptor.location,
            grade,
            max_grade,
509
        )
510

Don Mitchell committed
511
        # Bin score into range and increment stats
512
        score_bucket = get_score_bucket(grade, max_grade)
513

Adam Palay committed
514
        tags = [
515 516
            u"org:{}".format(course_id.org),
            u"course:{}".format(course_id),
517
            u"score_bucket:{0}".format(score_bucket)
Adam Palay committed
518
        ]
519

520 521 522
        if grade_bucket_type is not None:
            tags.append('type:%s' % grade_bucket_type)

523
        dog_stats_api.increment("lms.courseware.question_answered", tags=tags)
524

525
        # Cycle through the milestone fulfillment scenarios to see if any are now applicable
526
        # thanks to the updated grading information that was just submitted
527 528 529 530 531
        _fulfill_content_milestones(
            user,
            course_id,
            descriptor.location,
        )
532

533 534 535 536 537 538 539 540 541 542 543
        # Send a signal out to any listeners who are waiting for score change
        # events.
        SCORE_CHANGED.send(
            sender=None,
            points_possible=event['max_value'],
            points_earned=event['value'],
            user_id=user_id,
            course_id=unicode(course_id),
            usage_id=unicode(descriptor.location)
        )

544 545
    def publish(block, event_type, event):
        """A function that allows XModules to publish events."""
546
        if event_type == 'grade' and not is_masquerading_as_specific_student(user, course_id):
547 548 549 550
            handle_grade_event(block, event_type, event)
        else:
            track_function(event_type, event)

551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572
    def rebind_noauth_module_to_user(module, real_user):
        """
        A function that allows a module to get re-bound to a real user if it was previously bound to an AnonymousUser.

        Will only work within a module bound to an AnonymousUser, e.g. one that's instantiated by the noauth_handler.

        Arguments:
            module (any xblock type):  the module to rebind
            real_user (django.contrib.auth.models.User):  the user to bind to

        Returns:
            nothing (but the side effect is that module is re-bound to real_user)
        """
        if user.is_authenticated():
            err_msg = ("rebind_noauth_module_to_user can only be called from a module bound to "
                       "an anonymous user")
            log.error(err_msg)
            raise LmsModuleRenderError(err_msg)

        field_data_cache_real_user = FieldDataCache.cache_for_descriptor_descendents(
            course_id,
            real_user,
573 574
            module.descriptor,
            asides=XBlockAsidesConfig.possible_asides(),
575
        )
576
        student_data_real_user = KvsFieldData(DjangoKeyValueStore(field_data_cache_real_user))
577 578

        (inner_system, inner_student_data) = get_module_system_for_user(
579
            user=real_user,
580
            student_data=student_data_real_user,  # These have implicit user bindings, rest of args considered not to
581 582 583 584 585 586 587 588 589
            descriptor=module.descriptor,
            course_id=course_id,
            track_function=track_function,
            xqueue_callback_url_prefix=xqueue_callback_url_prefix,
            position=position,
            wrap_xmodule_display=wrap_xmodule_display,
            grade_bucket_type=grade_bucket_type,
            static_asset_path=static_asset_path,
            user_location=user_location,
590 591
            request_token=request_token,
            course=course
592
        )
593

594 595
        module.descriptor.bind_for_student(
            inner_system,
596
            real_user.id,
597
            [
598
                partial(OverrideFieldData.wrap, real_user, course),
599 600
                partial(LmsFieldData, student_data=inner_student_data),
            ],
601
        )
602

603 604 605
        module.descriptor.scope_ids = (
            module.descriptor.scope_ids._replace(user_id=real_user.id)  # pylint: disable=protected-access
        )
606 607 608 609 610
        module.scope_ids = module.descriptor.scope_ids  # this is needed b/c NamedTuples are immutable
        # now bind the module to the new ModuleSystem instance and vice-versa
        module.runtime = inner_system
        inner_system.xmodule_instance = module

611 612 613 614
    # Build a list of wrapping functions that will be applied in order
    # to the Fragment content coming out of the xblocks that are about to be rendered.
    block_wrappers = []

615 616 617
    if is_masquerading_as_specific_student(user, course_id):
        block_wrappers.append(filter_displayed_blocks)

618 619 620
    if settings.FEATURES.get("LICENSING", False):
        block_wrappers.append(wrap_with_license)

621 622 623
    # Wrap the output display in a single div to allow for the XModule
    # javascript to be bound correctly
    if wrap_xmodule_display is True:
624
        block_wrappers.append(partial(
625 626
            wrap_xblock,
            'LmsRuntime',
627
            extra_data={'course-id': course_id.to_deprecated_string()},
628 629
            usage_id_serializer=lambda usage_id: quote_slashes(usage_id.to_deprecated_string()),
            request_token=request_token,
630
        ))
631

632 633 634
    # TODO (cpennington): When modules are shared between courses, the static
    # prefix is going to have to be specific to the module, not the directory
    # that the xml was loaded from
Chris Dodge committed
635

636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655
    # Rewrite urls beginning in /static to point to course-specific content
    block_wrappers.append(partial(
        replace_static_urls,
        getattr(descriptor, 'data_dir', None),
        course_id=course_id,
        static_asset_path=static_asset_path or descriptor.static_asset_path
    ))

    # Allow URLs of the form '/course/' refer to the root of multicourse directory
    #   hierarchy of this course
    block_wrappers.append(partial(replace_course_urls, course_id))

    # this will rewrite intra-courseware links (/jump_to_id/<id>). This format
    # is an improvement over the /course/... format for studio authored courses,
    # because it is agnostic to course-hierarchy.
    # NOTE: module_id is empty string here. The 'module_id' will get assigned in the replacement
    # function, we just need to specify something to get the reverse() to work.
    block_wrappers.append(partial(
        replace_jump_to_id_urls,
        course_id,
656
        reverse('jump_to_id', kwargs={'course_id': course_id.to_deprecated_string(), 'module_id': ''}),
657 658
    ))

659
    if settings.FEATURES.get('DISPLAY_DEBUG_INFO_TO_STAFF'):
660 661 662 663 664 665 666 667 668 669
        if is_masquerading_as_specific_student(user, course_id):
            # When masquerading as a specific student, we want to show the debug button
            # unconditionally to enable resetting the state of the student we are masquerading as.
            # We already know the user has staff access when masquerading is active.
            staff_access = True
            # To figure out whether the user has instructor access, we temporarily remove the
            # masquerade_settings from the real_user.  With the masquerading settings in place,
            # the result would always be "False".
            masquerade_settings = user.real_user.masquerade_settings
            del user.real_user.masquerade_settings
670
            instructor_access = bool(has_access(user.real_user, 'instructor', descriptor, course_id))
671 672 673
            user.real_user.masquerade_settings = masquerade_settings
        else:
            staff_access = has_access(user, 'staff', descriptor, course_id)
674
            instructor_access = bool(has_access(user, 'instructor', descriptor, course_id))
675 676
        if staff_access:
            block_wrappers.append(partial(add_staff_markup, user, instructor_access, disable_staff_debug_info))
677

678 679 680 681 682 683
    # These modules store data using the anonymous_student_id as a key.
    # To prevent loss of data, we will continue to provide old modules with
    # the per-student anonymized id (as we have in the past),
    # while giving selected modules a per-course anonymized id.
    # As we have the time to manually test more modules, we can add to the list
    # of modules that get the per-course anonymized id.
684
    is_pure_xblock = isinstance(descriptor, XBlock) and not isinstance(descriptor, XModuleDescriptor)
685 686
    module_class = getattr(descriptor, 'module_class', None)
    is_lti_module = not is_pure_xblock and issubclass(module_class, LTIModule)
687
    if is_pure_xblock or is_lti_module:
688 689
        anonymous_student_id = anonymous_id_for_user(user, course_id)
    else:
690
        anonymous_student_id = anonymous_id_for_user(user, None)
691

692 693
    field_data = LmsFieldData(descriptor._field_data, student_data)  # pylint: disable=protected-access

694
    user_is_staff = bool(has_access(user, u'staff', descriptor.location, course_id))
695

696
    system = LmsModuleSystem(
Adam Palay committed
697 698
        track_function=track_function,
        render_template=render_to_string,
699
        static_url=settings.STATIC_URL,
Adam Palay committed
700 701
        xqueue=xqueue,
        # TODO (cpennington): Figure out how to share info between systems
702
        filestore=descriptor.runtime.resources_fs,
Adam Palay committed
703 704
        get_module=inner_get_module,
        user=user,
705 706
        debug=settings.DEBUG,
        hostname=settings.SITE_NAME,
Adam Palay committed
707 708 709 710 711 712
        # TODO (cpennington): This should be removed when all html from
        # a module is coming through get_html and is therefore covered
        # by the replace_static_urls code below
        replace_urls=partial(
            static_replace.replace_static_urls,
            data_directory=getattr(descriptor, 'data_dir', None),
Chris Dodge committed
713
            course_id=course_id,
Calen Pennington committed
714
            static_asset_path=static_asset_path or descriptor.static_asset_path,
Adam Palay committed
715
        ),
716 717
        replace_course_urls=partial(
            static_replace.replace_course_urls,
718
            course_key=course_id
719 720 721 722
        ),
        replace_jump_to_id_urls=partial(
            static_replace.replace_jump_to_id_urls,
            course_id=course_id,
723
            jump_to_id_base_url=reverse('jump_to_id', kwargs={'course_id': course_id.to_deprecated_string(), 'module_id': ''})
724
        ),
Adam Palay committed
725 726
        node_path=settings.NODE_PATH,
        publish=publish,
727
        anonymous_student_id=anonymous_student_id,
Adam Palay committed
728 729 730 731 732
        course_id=course_id,
        open_ended_grading_interface=open_ended_grading_interface,
        s3_interface=s3_interface,
        cache=cache,
        can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
733
        get_python_lib_zip=(lambda: get_python_lib_zip(contentstore, course_id)),
Calen Pennington committed
734
        # TODO: When we merge the descriptor and module systems, we can stop reaching into the mixologist (cpennington)
735 736
        mixins=descriptor.runtime.mixologist._mixins,  # pylint: disable=protected-access
        wrappers=block_wrappers,
737
        get_real_user=user_by_anonymous_id,
738
        services={
739
            'i18n': ModuleI18nService(),
740
            'fs': FSService(),
741
            'field-data': field_data,
742
            'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff),
743 744 745
            "reverification": ReverificationService(),
            'proctoring': ProctoringService(),
            'credit': CreditService(),
746
        },
747
        get_user_role=lambda: get_user_role(user, course_id),
748
        descriptor_runtime=descriptor._runtime,  # pylint: disable=protected-access
749
        rebind_noauth_module_to_user=rebind_noauth_module_to_user,
750
        user_location=user_location,
751
        request_token=request_token,
Adam Palay committed
752
    )
Chris Dodge committed
753

754
    # pass position specified in URL to module through ModuleSystem
755 756 757 758 759 760 761
    if position is not None:
        try:
            position = int(position)
        except (ValueError, TypeError):
            log.exception('Non-integer %r passed as position.', position)
            position = None

762
    system.set('position', position)
763
    if settings.FEATURES.get('ENABLE_PSYCHOMETRICS') and user.is_authenticated():
Adam Palay committed
764 765
        system.set(
            'psychometrics_handler',  # set callback for updating PsychometricsData
766
            make_psychometrics_data_update_handler(course_id, user, descriptor.location)
Adam Palay committed
767
        )
768

769
    system.set(u'user_is_staff', user_is_staff)
770
    system.set(u'user_is_admin', bool(has_access(user, u'staff', 'global')))
771 772
    system.set(u'user_is_beta_tester', CourseBetaTesterRole(course_id).has_user(user))
    system.set(u'days_early_for_beta', getattr(descriptor, 'days_early_for_beta'))
773

774
    # make an ErrorDescriptor -- assuming that the descriptor's system is ok
775
    if has_access(user, u'staff', descriptor.location, course_id):
776 777 778
        system.error_descriptor_class = ErrorDescriptor
    else:
        system.error_descriptor_class = NonStaffErrorDescriptor
779

780
    return system, field_data
781 782


783 784
# TODO: Find all the places that this method is called and figure out how to
# get a loaded course passed into it
785
def get_module_for_descriptor_internal(user, descriptor, student_data, course_id,  # pylint: disable=invalid-name
786
                                       track_function, xqueue_callback_url_prefix, request_token,
787
                                       position=None, wrap_xmodule_display=True, grade_bucket_type=None,
788 789
                                       static_asset_path='', user_location=None, disable_staff_debug_info=False,
                                       course=None):
790 791 792 793
    """
    Actually implement get_module, without requiring a request.

    See get_module() docstring for further details.
794 795 796

    Arguments:
        request_token (str): A unique token for this request, used to isolate xblock rendering
797 798
    """

799
    (system, student_data) = get_module_system_for_user(
800
        user=user,
801
        student_data=student_data,  # These have implicit user bindings, the rest of args are considered not to
802 803 804 805 806 807 808 809 810
        descriptor=descriptor,
        course_id=course_id,
        track_function=track_function,
        xqueue_callback_url_prefix=xqueue_callback_url_prefix,
        position=position,
        wrap_xmodule_display=wrap_xmodule_display,
        grade_bucket_type=grade_bucket_type,
        static_asset_path=static_asset_path,
        user_location=user_location,
811 812
        request_token=request_token,
        disable_staff_debug_info=disable_staff_debug_info,
813
        course=course
814 815
    )

816 817 818 819
    descriptor.bind_for_student(
        system,
        user.id,
        [
820
            partial(OverrideFieldData.wrap, user, course),
821 822 823 824
            partial(LmsFieldData, student_data=student_data),
        ],
    )

825
    descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id)  # pylint: disable=protected-access
826 827 828 829 830 831 832 833 834

    # Do not check access when it's a noauth request.
    # Not that the access check needs to happen after the descriptor is bound
    # for the student, since there may be field override data for the student
    # that affects xblock visibility.
    if getattr(user, 'known', True):
        if not has_access(user, 'load', descriptor, course_id):
            return None

835
    return descriptor
836

837

838
def load_single_xblock(request, user_id, course_id, usage_key_string, course=None):
839
    """
840
    Load a single XBlock identified by usage_key_string.
841
    """
842 843 844
    usage_key = UsageKey.from_string(usage_key_string)
    course_key = CourseKey.from_string(course_id)
    usage_key = usage_key.map_into_course(course_key)
845
    user = User.objects.get(id=user_id)
Calen Pennington committed
846
    field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
847
        course_key,
848
        user,
849
        modulestore().get_item(usage_key),
850 851
        depth=0,
    )
852
    instance = get_module(user, request, usage_key, field_data_cache, grade_bucket_type='xqueue', course=course)
853
    if instance is None:
854
        msg = "No module {0} for user {1}--access denied?".format(usage_key_string, user)
855 856 857 858 859
        log.debug(msg)
        raise Http404
    return instance


860
@csrf_exempt
861
def xqueue_callback(request, course_id, userid, mod_id, dispatch):
862
    '''
863
    Entry point for graded results from the queueing system.
864
    '''
865 866
    data = request.POST.copy()

867 868
    # Test xqueue package, which we expect to be:
    #   xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}),
869
    #               'xqueue_body'  : 'Message from grader'}
870
    for key in ['xqueue_header', 'xqueue_body']:
871
        if key not in data:
872
            raise Http404
873 874 875

    header = json.loads(data['xqueue_header'])
    if not isinstance(header, dict) or 'lms_key' not in header:
876
        raise Http404
877

878 879 880 881
    course_key = CourseKey.from_string(course_id)

    with modulestore().bulk_operations(course_key):
        course = modulestore().get_course(course_key, depth=0)
882

883
        instance = load_single_xblock(request, userid, course_id, mod_id, course=course)
884

885 886 887 888 889 890 891 892 893 894 895 896 897 898
        # Transfer 'queuekey' from xqueue response header to the data.
        # This is required to use the interface defined by 'handle_ajax'
        data.update({'queuekey': header['lms_key']})

        # We go through the "AJAX" path
        # So far, the only dispatch from xqueue will be 'score_update'
        try:
            # Can ignore the return value--not used for xqueue_callback
            instance.handle_ajax(dispatch, data)
            # Save any state that has changed to the underlying KeyValueStore
            instance.save()
        except:
            log.exception("error processing ajax call")
            raise
899

900
        return HttpResponse("")
901

902

903 904 905 906 907
@csrf_exempt
def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix=None):
    """
    Entry point for unauthenticated XBlock handlers.
    """
908 909
    request.user.known = False

910 911 912 913
    course_key = CourseKey.from_string(course_id)
    with modulestore().bulk_operations(course_key):
        course = modulestore().get_course(course_key, depth=0)
        return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)
914 915


916 917 918
def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
    """
    Generic view for extensions. This is where AJAX calls go.
919 920 921 922

    Arguments:

      - request -- the django request.
923 924
      - location -- the module location. Used to look up the XModule instance
      - course_id -- defines the course context for this request.
925

polesye committed
926
    Return 403 error if the user is not logged in. Raises Http404 if
927 928 929
    the location and course_id do not identify a valid module, the module is
    not accessible by the user, or the module raises NotFoundError. If the
    module raises any other error, it will escape this function.
930
    """
931
    if not request.user.is_authenticated():
polesye committed
932
        return HttpResponse('Unauthenticated', status=403)
933

934 935 936 937 938 939 940 941 942 943 944 945
    try:
        course_key = CourseKey.from_string(course_id)
    except InvalidKeyError:
        raise Http404("Invalid location")

    with modulestore().bulk_operations(course_key):
        try:
            course = modulestore().get_course(course_key)
        except ItemNotFoundError:
            raise Http404("invalid location")

        return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)
946 947


948 949 950 951 952 953 954 955 956 957
def xblock_resource(request, block_type, uri):  # pylint: disable=unused-argument
    """
    Return a package resource for the specified XBlock.
    """
    try:
        xblock_class = XBlock.load_class(block_type, select=settings.XBLOCK_SELECT_FUNCTION)
        content = xblock_class.open_local_resource(uri)
    except IOError:
        log.info('Failed to load xblock resource', exc_info=True)
        raise Http404
958
    except Exception:  # pylint: disable=broad-except
959 960 961 962 963 964
        log.error('Failed to load xblock resource', exc_info=True)
        raise Http404
    mimetype, _ = mimetypes.guess_type(uri)
    return HttpResponse(content, mimetype=mimetype)


965
def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_info=False, course=None):
966
    """
967
    Gets a module instance based on its `usage_id` in a course, for a given request/user
968

969
    Returns (instance, tracking_context)
970
    """
971 972
    user = request.user

973 974 975 976
    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:
977 978
        raise Http404("Invalid location")

979
    try:
980
        descriptor = modulestore().get_item(usage_key)
981
        descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
982 983
    except ItemNotFoundError:
        log.warn(
984 985 986
            "Invalid location for course id %s: %s",
            usage_key.course_key,
            usage_key
987 988 989
        )
        raise Http404

990 991 992
    tracking_context = {
        'module': {
            'display_name': descriptor.display_name_with_default,
993
            'usage_key': unicode(descriptor.location),
994 995
        }
    }
996

997 998 999 1000
    # 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)
        tracking_context['module']['original_usage_version'] = unicode(descriptor_orig_version)
1001

1002
    unused_masquerade, user = setup_masquerade(request, course_id, has_access(user, 'staff', descriptor, course_id))
Calen Pennington committed
1003
    field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
1004
        course_id,
1005
        user,
1006 1007
        descriptor
    )
1008 1009 1010 1011 1012 1013
    instance = get_module_for_descriptor(
        user,
        request,
        descriptor,
        field_data_cache,
        usage_key.course_key,
1014 1015
        disable_staff_debug_info=disable_staff_debug_info,
        course=course
1016
    )
1017 1018 1019
    if instance is None:
        # Either permissions just changed, or someone is trying to be clever
        # and load something they shouldn't have access to.
1020
        log.debug("No module %s for user %s -- access denied?", usage_key, user)
1021
        raise Http404
1022

1023 1024 1025
    return (instance, tracking_context)


1026
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=None):
1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043
    """
    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)

1044 1045 1046 1047 1048
    # Make a CourseKey from the course_id, raising a 404 upon parse error.
    try:
        course_key = CourseKey.from_string(course_id)
    except InvalidKeyError:
        raise Http404
1049

1050
    with modulestore().bulk_operations(course_key):
1051
        instance, tracking_context = get_module_by_usage_id(request, course_id, usage_id, course=course)
1052

1053 1054 1055 1056 1057 1058
        # Name the transaction so that we can view XBlock handlers separately in
        # New Relic. The suffix is necessary for XModule handlers because the
        # "handler" in those cases is always just "xmodule_handler".
        nr_tx_name = "{}.{}".format(instance.__class__.__name__, handler)
        nr_tx_name += "/{}".format(suffix) if suffix else ""
        newrelic.agent.set_transaction_name(nr_tx_name, group="Python/XBlock/Handler")
1059

1060 1061 1062 1063 1064
        tracking_context_name = 'module_callback_handler'
        req = django_to_webob_request(request)
        try:
            with tracker.get_tracker().context(tracking_context_name, tracking_context):
                resp = instance.handle(handler, req, suffix)
1065

1066 1067 1068
        except NoSuchHandlerError:
            log.exception("XBlock %s attempted to access missing handler %r", instance, handler)
            raise Http404
1069

1070 1071 1072 1073
        # If we can't find the module, respond with a 404
        except NotFoundError:
            log.exception("Module indicating to user that request doesn't exist")
            raise Http404
1074

1075 1076 1077 1078 1079 1080 1081 1082 1083 1084
        # For XModule-specific errors, we log the error and respond with an error message
        except ProcessingError as err:
            log.warning("Module encountered an error while processing AJAX call",
                        exc_info=True)
            return JsonResponse(object={'success': err.args[0]}, status=200)

        # If any other error occurred, re-raise it to trigger a 500 response
        except Exception:
            log.exception("error executing xblock handler")
            raise
1085

1086
    return webob_to_django_response(resp)
1087

Calen Pennington committed
1088

1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116
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

    try:
1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129
        course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
    except InvalidKeyError:
        raise Http404("Invalid location")

    with modulestore().bulk_operations(course_key):
        course = modulestore().get_course(course_key)
        instance, _ = get_module_by_usage_id(request, course_id, usage_id, course=course)

        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
1130

1131 1132 1133
        hashed_resources = OrderedDict()
        for resource in fragment.resources:
            hashed_resources[hash_resource(resource)] = resource
1134

1135 1136 1137 1138 1139
        return JsonResponse({
            'html': fragment.content,
            'resources': hashed_resources.items(),
            'csrf_token': unicode(csrf(request)['csrf_token']),
        })
1140 1141


Calen Pennington committed
1142
def get_score_bucket(grade, max_grade):
Vik Paruchuri committed
1143 1144 1145 1146
    """
    Function to split arbitrary score ranges into 3 buckets.
    Used with statsd tracking.
    """
Calen Pennington committed
1147
    score_bucket = "incorrect"
1148
    if grade > 0 and grade < max_grade:
Calen Pennington committed
1149
        score_bucket = "partial"
1150
    elif grade == max_grade:
Calen Pennington committed
1151
        score_bucket = "correct"
1152 1153

    return score_bucket
1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168


def _check_files_limits(files):
    """
    Check if the files in a request are under the limits defined by
    `settings.MAX_FILEUPLOADS_PER_INPUT` and
    `settings.STUDENT_FILEUPLOAD_MAX_SIZE`.

    Returns None if files are correct or an error messages otherwise.
    """
    for fileinput_id in files.keys():
        inputfiles = files.getlist(fileinput_id)

        # Check number of files submitted
        if len(inputfiles) > settings.MAX_FILEUPLOADS_PER_INPUT:
1169
            msg = 'Submission aborted! Maximum %d files may be submitted at once' % \
1170 1171 1172 1173 1174
                  settings.MAX_FILEUPLOADS_PER_INPUT
            return msg

        # Check file sizes
        for inputfile in inputfiles:
1175 1176
            if inputfile.size > settings.STUDENT_FILEUPLOAD_MAX_SIZE:  # Bytes
                msg = 'Submission aborted! Your file "%s" is too large (max size: %d MB)' % \
1177 1178 1179 1180
                      (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))
                return msg

    return None