module_render.py 45.4 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
Calen Pennington committed
31 32
from courseware.masquerade import setup_masquerade
from courseware.model_data import FieldDataCache, DjangoKeyValueStore
33
from courseware.models import SCORE_CHANGED
34 35 36 37
from courseware.entrance_exams import (
    get_entrance_exam_score,
    user_must_complete_entrance_exam
)
38 39
from edxmako.shortcuts import render_to_string
from eventtracking import tracker
40 41
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem, unquote_slashes, quote_slashes
42
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
43
from opaque_keys import InvalidKeyError
44
from opaque_keys.edx.keys import UsageKey, CourseKey
45 46
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
from student.models import anonymous_id_for_user, user_by_anonymous_id
47
from opaque_keys.edx.locations import SlashSeparatedCourseKey
48
from openedx.core.lib.xblock_utils import (
49 50 51 52 53
    replace_course_urls,
    replace_jump_to_id_urls,
    replace_static_urls,
    add_staff_markup,
    wrap_xblock,
54
    request_token as xblock_request_token,
55
)
56 57 58 59 60 61 62 63 64 65 66 67 68
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
69
from xmodule.lti_module import LTIModule
70
from xmodule.modulestore.exceptions import ItemNotFoundError
71
from xmodule.x_module import XModuleDescriptor
72
from xmodule.mixin import wrap_with_license
73 74
from api_manager.models import CourseModuleCompletion

75
from util.json_request import JsonResponse
76
from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
77
from util import milestones_helpers
78
from verify_student.services import ReverificationService
79

80 81
from .field_overrides import OverrideFieldData

82
log = logging.getLogger(__name__)
83

84

85
if settings.XQUEUE_INTERFACE.get('basic_auth') is not None:
86
    REQUESTS_AUTH = HTTPBasicAuth(*settings.XQUEUE_INTERFACE['basic_auth'])
87
else:
88
    REQUESTS_AUTH = None
89

90
XQUEUE_INTERFACE = XQueueInterface(
91
    settings.XQUEUE_INTERFACE['url'],
92
    settings.XQUEUE_INTERFACE['django_auth'],
93
    REQUESTS_AUTH,
94 95
)

96 97 98
# 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!
99

100

101 102 103 104 105 106 107
class LmsModuleRenderError(Exception):
    """
    An exception class for exceptions thrown by module_render that don't fit well elsewhere
    """
    pass


108
def make_track_function(request):
109
    '''
110
    Make a tracking function that logs what happened.
111
    For use in ModuleSystem.
Piotr Mitros committed
112
    '''
113 114
    import track.views

Adam Palay committed
115
    def function(event_type, event):
116
        return track.views.server_track(request, event_type, event, page='x_module')
Adam Palay committed
117
    return function
118

119

120
def toc_for_course(request, course, active_chapter, active_section, field_data_cache):
121 122
    '''
    Create a table of contents from the module store
123

124
    Return format:
125 126
    [ {'display_name': name, 'url_name': url_name,
       'sections': SECTIONS, 'active': bool}, ... ]
127

128
    where SECTIONS is a list
129
    [ {'display_name': name, 'url_name': url_name,
kimth committed
130
       'format': format, 'due': due, 'active' : bool, 'graded': bool}, ...]
131

132
    active is set for the section and chapter corresponding to the passed
133 134
    parameters, which are expected to be url_names of the chapter+section.
    Everything else comes from the xml, or defaults to "".
135

136
    chapters with name 'hidden' are skipped.
137 138 139

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

Calen Pennington committed
141
    field_data_cache must include data from the course module and 2 levels of its descendents
142
    '''
143

144
    with modulestore().bulk_operations(course.id):
145 146 147
        course_module = get_module_for_descriptor(
            request.user, request, course, field_data_cache, course.id, course=course
        )
148 149 150
        if course_module is None:
            return None

151 152 153 154
        toc_chapters = list()
        chapters = course_module.get_display_items()

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

157 158 159 160 161
        # The user may not actually have to complete the entrance exam, if one is required
        if not user_must_complete_entrance_exam(request, request.user, course):
            required_content = [content for content in required_content if not content == course.entrance_exam_id]

        for chapter in chapters:
162 163 164
            # Only show required content, if there is required content
            # chapter.hide_from_toc is read-only (boo)
            local_hide_from_toc = False
165
            if required_content:
166 167 168 169 170
                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:
171 172 173 174 175 176 177 178 179 180 181 182
                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:
                    sections.append({'display_name': section.display_name_with_default,
                                     'url_name': section.url_name,
                                     'format': section.format if section.format is not None else '',
183
                                     'due': section.due,
184 185 186
                                     'active': active,
                                     'graded': section.graded,
                                     })
187 188 189 190 191 192 193
            toc_chapters.append({
                'display_name': chapter.display_name_with_default,
                'url_name': chapter.url_name,
                'sections': sections,
                'active': chapter.url_name == active_chapter
            })
        return toc_chapters
194

195

196
def get_module(user, request, usage_key, field_data_cache,
197
               position=None, log_if_not_found=True, wrap_xmodule_display=True,
198
               grade_bucket_type=None, depth=0,
199
               static_asset_path='', course=None):
200 201
    """
    Get an instance of the xmodule class identified by location,
202 203
    setting the state based on an existing StudentModule, or creating one if none
    exists.
204 205

    Arguments:
206
      - user                  : User for whom we're getting the module
207 208
      - request               : current django HTTPrequest.  Note: request.user isn't used for anything--all auth
                                and such works based on user.
209
      - usage_key             : A UsageKey object identifying the module to load
Calen Pennington committed
210
      - field_data_cache      : a FieldDataCache
211
      - position              : extra information from URL for user-specified
212
                                position within module
213 214 215
      - 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
216 217
      - depth                 : number of levels of descendents to cache when loading this module.
                                None means cache all descendents
218 219 220 221
      - 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.
222

223 224 225 226 227
    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:
228 229
        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
230
                                         position=position,
231
                                         wrap_xmodule_display=wrap_xmodule_display,
232
                                         grade_bucket_type=grade_bucket_type,
233 234
                                         static_asset_path=static_asset_path,
                                         course=course)
235
    except ItemNotFoundError:
236 237
        if log_if_not_found:
            log.debug("Error in get_module: ItemNotFoundError")
238
        return None
239

240 241 242 243
    except:
        # Something has gone terribly wrong, but still not letting it turn into a 500.
        log.exception("Error in get_module")
        return None
244

245

246 247 248 249
def get_xqueue_callback_url_prefix(request):
    """
    Calculates default prefix based on request, but allows override via settings

250 251 252
    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.
253
    """
254
    prefix = '{proto}://{host}'.format(
Adam Palay committed
255 256 257
        proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http'),
        host=request.get_host()
    )
258
    return settings.XQUEUE_INTERFACE.get('callback_url', prefix)
259 260


261
def get_module_for_descriptor(user, request, descriptor, field_data_cache, course_key,
262
                              position=None, wrap_xmodule_display=True, grade_bucket_type=None,
263 264
                              static_asset_path='', disable_staff_debug_info=False,
                              course=None):
265
    """
266 267
    Implements get_module, extracting out the request-specific functionality.

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

270 271 272 273 274
    See get_module() docstring for further details.
    """
    track_function = make_track_function(request)
    xqueue_callback_url_prefix = get_xqueue_callback_url_prefix(request)

275 276
    user_location = getattr(request, 'session', {}).get('country_code')

277 278 279 280
    return get_module_for_descriptor_internal(
        user=user,
        descriptor=descriptor,
        field_data_cache=field_data_cache,
281
        course_id=course_key,
282 283 284 285 286 287 288
        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,
289 290
        request_token=xblock_request_token(request),
        disable_staff_debug_info=disable_staff_debug_info,
291
        course=course
292
    )
293 294


295
def get_module_system_for_user(user, field_data_cache,  # TODO  # pylint: disable=too-many-statements
296 297
                               # Arguments preceding this comment have user binding, those following don't
                               descriptor, course_id, track_function, xqueue_callback_url_prefix,
298
                               request_token, position=None, wrap_xmodule_display=True, grade_bucket_type=None,
299 300
                               static_asset_path='', user_location=None, disable_staff_debug_info=False,
                               course=None):
301
    """
302
    Helper function that returns a module system and student_data bound to a user and a descriptor.
303

304 305 306
    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.
307

308 309 310 311
    The arguments fall into two categories: those that have explicit or implicit user binding, which are user
    and field_data_cache, and those don't and are just present so that ModuleSystem can be instantiated, which
    are all the other arguments.  Ultimately, this isn't too different than how get_module_for_descriptor_internal
    was before refactoring.
312

313 314
    Arguments:
        see arguments for get_module()
315
        request_token (str): A token unique to the request use by xblock initialization
316

317 318 319 320
    Returns:
        (LmsModuleSystem, KvsFieldData):  (module system, student_data) bound to, primarily, the user and descriptor
    """
    student_data = KvsFieldData(DjangoKeyValueStore(field_data_cache))
321

322
    def make_xqueue_callback(dispatch='score_update'):
323 324 325
        """
        Returns fully qualified callback URL for external queueing system
        """
Adam Palay committed
326 327 328
        relative_xqueue_callback_url = reverse(
            'xqueue_callback',
            kwargs=dict(
329
                course_id=course_id.to_deprecated_string(),
Adam Palay committed
330
                userid=str(user.id),
331
                mod_id=descriptor.location.to_deprecated_string(),
Adam Palay committed
332 333 334
                dispatch=dispatch
            ),
        )
335
        return xqueue_callback_url_prefix + relative_xqueue_callback_url
336

337
    # Default queuename is course-specific and is derived from the course that
338 339 340
    #   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
341

342
    xqueue = {
343
        'interface': XQUEUE_INTERFACE,
344 345 346 347
        'construct_callback': make_xqueue_callback,
        'default_queuename': xqueue_default_queuename.replace(' ', '_'),
        'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
    }
348

349 350 351 352
    # 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
353

354
    # Get descriptor metadata fields indicating needs for various settings
355 356
    needs_open_ended_interface = getattr(descriptor, "needs_open_ended_interface", False)
    needs_s3_interface = getattr(descriptor, "needs_s3_interface", False)
357

358
    # Initialize interfaces to None
359 360
    open_ended_grading_interface = None
    s3_interface = None
361

362
    # Create interfaces if needed
363
    if needs_open_ended_interface:
364
        open_ended_grading_interface = settings.OPEN_ENDED_GRADING_INTERFACE
365 366
        open_ended_grading_interface['mock_peer_grading'] = settings.MOCK_PEER_GRADING
        open_ended_grading_interface['mock_staff_grading'] = settings.MOCK_STAFF_GRADING
367 368
    if needs_s3_interface:
        s3_interface = {
Vik Paruchuri committed
369 370 371
            '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')
372
        }
373

374
    def inner_get_module(descriptor):
375
        """
376 377 378
        Delegate to get_module_for_descriptor_internal() with all values except `descriptor` set.

        Because it does an access check, it may return None.
379
        """
380 381
        # 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....
382 383 384 385 386 387 388 389 390 391 392 393 394
        return get_module_for_descriptor_internal(
            user=user,
            descriptor=descriptor,
            field_data_cache=field_data_cache,
            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,
395
            course=course
396
        )
397

398
    def _fulfill_content_milestones(user, course_key, content_key):
399 400 401 402 403 404 405 406 407 408 409 410
        """
        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:
411 412 413 414
                # 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)
415
                if exam_pct >= course.entrance_exam_minimum_score_pct:
416
                    exam_key = UsageKey.from_string(course.entrance_exam_id)
417 418
                    relationship_types = milestones_helpers.get_milestone_relationship_types()
                    content_milestones = milestones_helpers.get_course_content_milestones(
419 420 421 422 423
                        course_key,
                        exam_key,
                        relationship=relationship_types['FULFILLS']
                    )
                    # Add each milestone to the user's set...
424
                    user = {'id': request.user.id}
425
                    for milestone in content_milestones:
426
                        milestones_helpers.add_user_milestone(user, milestone)
427 428 429 430 431

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

434 435
        grade = event.get('value')
        max_grade = event.get('max_value')
436

437 438 439 440 441
        field_data_cache.set_score(
            user_id,
            descriptor.location,
            grade,
            max_grade,
442
        )
443

Don Mitchell committed
444
        # Bin score into range and increment stats
445
        score_bucket = get_score_bucket(grade, max_grade)
446

Adam Palay committed
447
        tags = [
448 449
            u"org:{}".format(course_id.org),
            u"course:{}".format(course_id),
450
            u"score_bucket:{0}".format(score_bucket)
Adam Palay committed
451
        ]
452

453 454 455
        if grade_bucket_type is not None:
            tags.append('type:%s' % grade_bucket_type)

456
        dog_stats_api.increment("lms.courseware.question_answered", tags=tags)
457

458
        # Cycle through the milestone fulfillment scenarios to see if any are now applicable
459
        # thanks to the updated grading information that was just submitted
460 461 462 463 464
        _fulfill_content_milestones(
            user,
            course_id,
            descriptor.location,
        )
465 466 467 468 469 470 471 472 473 474 475 476
        # we can treat a grading event as a indication that a user
        # "completed" an xBlock
        if settings.FEATURES.get('MARK_PROGRESS_ON_GRADING_EVENT', False):
            handle_progress_event(block, event_type, event)

    def handle_progress_event(block, event_type, event):
        """
        tie into the CourseCompletions datamodels that are exposed in the api_manager djangoapp
        """
        user_id = event.get('user_id', user.id)
        if not user_id:
            return
477

478 479 480 481 482 483 484 485 486 487 488
        # 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)
        )

489 490 491 492 493 494
        CourseModuleCompletion.objects.get_or_create(
            user_id=user_id,
            course_id=course_id,
            content_id=unicode(descriptor.location)
        )

495 496 497 498
    def publish(block, event_type, event):
        """A function that allows XModules to publish events."""
        if event_type == 'grade':
            handle_grade_event(block, event_type, event)
499 500 501 502
        elif event_type == 'progress':
            # expose another special case event type which gets sent
            # into the CourseCompletions models
            handle_progress_event(block, event_type, event)
503 504 505
        else:
            track_function(event_type, event)

506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527
    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,
528 529
            module.descriptor,
            asides=XBlockAsidesConfig.possible_asides(),
530 531 532
        )

        (inner_system, inner_student_data) = get_module_system_for_user(
533 534 535 536 537 538 539 540 541 542 543
            user=real_user,
            field_data_cache=field_data_cache_real_user,  # These have implicit user bindings, rest of args considered not to
            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,
544 545
            request_token=request_token,
            course=course
546
        )
547

548 549
        module.descriptor.bind_for_student(
            inner_system,
550
            real_user.id,
551
            [
552
                partial(OverrideFieldData.wrap, real_user, course),
553 554
                partial(LmsFieldData, student_data=inner_student_data),
            ],
555
        )
556

557 558 559
        module.descriptor.scope_ids = (
            module.descriptor.scope_ids._replace(user_id=real_user.id)  # pylint: disable=protected-access
        )
560 561 562 563 564
        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

565 566 567 568
    # 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 = []

569 570 571
    if settings.FEATURES.get("LICENSING", False):
        block_wrappers.append(wrap_with_license)

572 573 574
    # Wrap the output display in a single div to allow for the XModule
    # javascript to be bound correctly
    if wrap_xmodule_display is True:
575
        block_wrappers.append(partial(
576 577
            wrap_xblock,
            'LmsRuntime',
578
            extra_data={'course-id': course_id.to_deprecated_string()},
579 580
            usage_id_serializer=lambda usage_id: quote_slashes(usage_id.to_deprecated_string()),
            request_token=request_token,
581
        ))
582

583 584 585
    # 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
586

587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606
    # 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,
607
        reverse('jump_to_id', kwargs={'course_id': course_id.to_deprecated_string(), 'module_id': ''}),
608 609
    ))

610
    if settings.FEATURES.get('DISPLAY_DEBUG_INFO_TO_STAFF'):
611
        if has_access(user, 'staff', descriptor, course_id):
612
            has_instructor_access = has_access(user, 'instructor', descriptor, course_id)
613
            block_wrappers.append(partial(add_staff_markup, user, has_instructor_access, disable_staff_debug_info))
614

615 616 617 618 619 620
    # 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.
621
    is_pure_xblock = isinstance(descriptor, XBlock) and not isinstance(descriptor, XModuleDescriptor)
622 623
    module_class = getattr(descriptor, 'module_class', None)
    is_lti_module = not is_pure_xblock and issubclass(module_class, LTIModule)
624
    if is_pure_xblock or is_lti_module:
625 626
        anonymous_student_id = anonymous_id_for_user(user, course_id)
    else:
627
        anonymous_student_id = anonymous_id_for_user(user, None)
628

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

631 632
    user_is_staff = has_access(user, u'staff', descriptor.location, course_id)

633
    system = LmsModuleSystem(
Adam Palay committed
634 635
        track_function=track_function,
        render_template=render_to_string,
636
        static_url=settings.STATIC_URL,
Adam Palay committed
637 638
        xqueue=xqueue,
        # TODO (cpennington): Figure out how to share info between systems
639
        filestore=descriptor.runtime.resources_fs,
Adam Palay committed
640 641
        get_module=inner_get_module,
        user=user,
642 643
        debug=settings.DEBUG,
        hostname=settings.SITE_NAME,
Adam Palay committed
644 645 646 647 648 649
        # 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
650
            course_id=course_id,
Calen Pennington committed
651
            static_asset_path=static_asset_path or descriptor.static_asset_path,
Adam Palay committed
652
        ),
653 654
        replace_course_urls=partial(
            static_replace.replace_course_urls,
655
            course_key=course_id
656 657 658 659
        ),
        replace_jump_to_id_urls=partial(
            static_replace.replace_jump_to_id_urls,
            course_id=course_id,
660
            jump_to_id_base_url=reverse('jump_to_id', kwargs={'course_id': course_id.to_deprecated_string(), 'module_id': ''})
661
        ),
Adam Palay committed
662 663
        node_path=settings.NODE_PATH,
        publish=publish,
664
        anonymous_student_id=anonymous_student_id,
Adam Palay committed
665 666 667 668 669
        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)),
670
        get_python_lib_zip=(lambda: get_python_lib_zip(contentstore, course_id)),
Calen Pennington committed
671
        # TODO: When we merge the descriptor and module systems, we can stop reaching into the mixologist (cpennington)
672 673
        mixins=descriptor.runtime.mixologist._mixins,  # pylint: disable=protected-access
        wrappers=block_wrappers,
674
        get_real_user=user_by_anonymous_id,
675
        services={
676
            'i18n': ModuleI18nService(),
677
            'fs': FSService(),
678
            'field-data': field_data,
679
            'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff),
680
            "reverification": ReverificationService()
681
        },
682
        get_user_role=lambda: get_user_role(user, course_id),
683
        descriptor_runtime=descriptor._runtime,  # pylint: disable=protected-access
684
        rebind_noauth_module_to_user=rebind_noauth_module_to_user,
685
        user_location=user_location,
686
        request_token=request_token,
Adam Palay committed
687
    )
Chris Dodge committed
688

689
    # pass position specified in URL to module through ModuleSystem
690 691 692 693 694 695
    if position is not None:
        try:
            position = int(position)
        except (ValueError, TypeError):
            log.exception('Non-integer %r passed as position.', position)
            position = None
696
    system.set('position', position)
697

698
    if settings.FEATURES.get('ENABLE_PSYCHOMETRICS') and user.is_authenticated():
Adam Palay committed
699 700
        system.set(
            'psychometrics_handler',  # set callback for updating PsychometricsData
701
            make_psychometrics_data_update_handler(course_id, user, descriptor.location)
Adam Palay committed
702
        )
703

704
    system.set(u'user_is_staff', user_is_staff)
Calen Pennington committed
705
    system.set(u'user_is_admin', has_access(user, u'staff', 'global'))
706 707
    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'))
708

709
    # make an ErrorDescriptor -- assuming that the descriptor's system is ok
710
    if has_access(user, u'staff', descriptor.location, course_id):
711 712 713
        system.error_descriptor_class = ErrorDescriptor
    else:
        system.error_descriptor_class = NonStaffErrorDescriptor
714

715
    return system, field_data
716 717


718 719
# TODO: Find all the places that this method is called and figure out how to
# get a loaded course passed into it
720
def get_module_for_descriptor_internal(user, descriptor, field_data_cache, course_id,  # pylint: disable=invalid-name
721
                                       track_function, xqueue_callback_url_prefix, request_token,
722
                                       position=None, wrap_xmodule_display=True, grade_bucket_type=None,
723 724
                                       static_asset_path='', user_location=None, disable_staff_debug_info=False,
                                       course=None):
725 726 727 728
    """
    Actually implement get_module, without requiring a request.

    See get_module() docstring for further details.
729 730 731

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

734
    (system, student_data) = get_module_system_for_user(
735 736 737 738 739 740 741 742 743 744 745
        user=user,
        field_data_cache=field_data_cache,  # These have implicit user bindings, the rest of args are considered not to
        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,
746 747
        request_token=request_token,
        disable_staff_debug_info=disable_staff_debug_info,
748
        course=course
749 750
    )

751 752 753 754
    descriptor.bind_for_student(
        system,
        user.id,
        [
755
            partial(OverrideFieldData.wrap, user, course),
756 757 758 759
            partial(LmsFieldData, student_data=student_data),
        ],
    )

760
    descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id)  # pylint: disable=protected-access
761 762 763 764 765 766 767 768 769

    # 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

770
    return descriptor
771

772

773
def load_single_xblock(request, user_id, course_id, usage_key_string, course=None):
774
    """
775
    Load a single XBlock identified by usage_key_string.
776
    """
777 778 779
    usage_key = UsageKey.from_string(usage_key_string)
    course_key = CourseKey.from_string(course_id)
    usage_key = usage_key.map_into_course(course_key)
780
    user = User.objects.get(id=user_id)
Calen Pennington committed
781
    field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
782
        course_key,
783
        user,
784
        modulestore().get_item(usage_key),
785 786
        depth=0,
    )
787
    instance = get_module(user, request, usage_key, field_data_cache, grade_bucket_type='xqueue', course=course)
788
    if instance is None:
789
        msg = "No module {0} for user {1}--access denied?".format(usage_key_string, user)
790 791 792 793 794
        log.debug(msg)
        raise Http404
    return instance


795
@csrf_exempt
796
def xqueue_callback(request, course_id, userid, mod_id, dispatch):
797
    '''
798
    Entry point for graded results from the queueing system.
799
    '''
800 801
    data = request.POST.copy()

802 803
    # Test xqueue package, which we expect to be:
    #   xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}),
804
    #               'xqueue_body'  : 'Message from grader'}
805
    for key in ['xqueue_header', 'xqueue_body']:
806
        if key not in data:
807
            raise Http404
808 809 810

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

813 814 815 816
    course_key = CourseKey.from_string(course_id)

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

818
        instance = load_single_xblock(request, userid, course_id, mod_id, course=course)
819

820 821 822 823 824 825 826 827 828 829 830 831 832 833
        # 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
834

835
        return HttpResponse("")
836

837

838 839 840 841 842
@csrf_exempt
def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix=None):
    """
    Entry point for unauthenticated XBlock handlers.
    """
843 844
    request.user.known = False

845 846 847 848
    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)
849 850


851 852 853
def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
    """
    Generic view for extensions. This is where AJAX calls go.
854 855 856 857

    Arguments:

      - request -- the django request.
858 859
      - location -- the module location. Used to look up the XModule instance
      - course_id -- defines the course context for this request.
860

polesye committed
861
    Return 403 error if the user is not logged in. Raises Http404 if
862 863 864
    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.
865
    """
866
    if not request.user.is_authenticated():
polesye committed
867
        return HttpResponse('Unauthenticated', status=403)
868

869 870 871 872 873 874 875 876 877 878 879 880
    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)
881 882


883 884 885 886 887 888 889 890 891 892
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
893
    except Exception:  # pylint: disable=broad-except
894 895 896 897 898 899
        log.error('Failed to load xblock resource', exc_info=True)
        raise Http404
    mimetype, _ = mimetypes.guess_type(uri)
    return HttpResponse(content, mimetype=mimetype)


900
def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_info=False, course=None):
901
    """
902
    Gets a module instance based on its `usage_id` in a course, for a given request/user
903

904
    Returns (instance, tracking_context)
905
    """
906 907
    user = request.user

908 909 910 911
    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:
912 913
        raise Http404("Invalid location")

914
    try:
915
        descriptor = modulestore().get_item(usage_key)
916
        descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
917 918
    except ItemNotFoundError:
        log.warn(
919 920 921
            "Invalid location for course id {course_id}: {usage_key}".format(
                course_id=usage_key.course_key,
                usage_key=usage_key
922 923 924 925
            )
        )
        raise Http404

926 927 928
    tracking_context = {
        'module': {
            'display_name': descriptor.display_name_with_default,
929
            'usage_key': unicode(descriptor.location),
930 931
        }
    }
932

933 934 935 936
    # 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)
937

Calen Pennington committed
938
    field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
939
        course_id,
940
        user,
941 942
        descriptor
    )
943
    setup_masquerade(request, course_id, has_access(user, 'staff', descriptor, course_id))
944 945 946 947 948 949
    instance = get_module_for_descriptor(
        user,
        request,
        descriptor,
        field_data_cache,
        usage_key.course_key,
950 951
        disable_staff_debug_info=disable_staff_debug_info,
        course=course
952
    )
953 954 955
    if instance is None:
        # Either permissions just changed, or someone is trying to be clever
        # and load something they shouldn't have access to.
956
        log.debug("No module %s for user %s -- access denied?", usage_key, user)
957
        raise Http404
958

959 960 961
    return (instance, tracking_context)


962
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=None):
963 964 965 966 967 968 969 970 971
    """
    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
972
        user (User): The currently logged in user
973 974 975 976 977 978 979 980
    """

    # Check submitted files
    files = request.FILES or {}
    error_msg = _check_files_limits(files)
    if error_msg:
        return JsonResponse(object={'success': error_msg}, status=413)

981 982 983 984 985
    # 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
986

987
    with modulestore().bulk_operations(course_key):
988
        instance, tracking_context = get_module_by_usage_id(request, course_id, usage_id, course=course)
989

990 991 992 993 994 995
        # 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")
996

997 998 999 1000 1001
        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)
1002

1003 1004 1005
        except NoSuchHandlerError:
            log.exception("XBlock %s attempted to access missing handler %r", instance, handler)
            raise Http404
1006

1007 1008 1009 1010
        # 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
1011

1012 1013 1014 1015 1016 1017 1018 1019 1020 1021
        # 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
1022

1023
    return webob_to_django_response(resp)
1024

Calen Pennington committed
1025

1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053
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:
1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066
        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
1067

1068 1069 1070
        hashed_resources = OrderedDict()
        for resource in fragment.resources:
            hashed_resources[hash_resource(resource)] = resource
1071

1072 1073 1074 1075 1076
        return JsonResponse({
            'html': fragment.content,
            'resources': hashed_resources.items(),
            'csrf_token': unicode(csrf(request)['csrf_token']),
        })
1077 1078


Calen Pennington committed
1079
def get_score_bucket(grade, max_grade):
Vik Paruchuri committed
1080 1081 1082 1083
    """
    Function to split arbitrary score ranges into 3 buckets.
    Used with statsd tracking.
    """
Calen Pennington committed
1084 1085 1086 1087 1088
    score_bucket = "incorrect"
    if(grade > 0 and grade < max_grade):
        score_bucket = "partial"
    elif(grade == max_grade):
        score_bucket = "correct"
1089 1090

    return score_bucket
1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105


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:
1106
            msg = 'Submission aborted! Maximum %d files may be submitted at once' % \
1107 1108 1109 1110 1111
                  settings.MAX_FILEUPLOADS_PER_INPUT
            return msg

        # Check file sizes
        for inputfile in inputfiles:
1112 1113
            if inputfile.size > settings.STUDENT_FILEUPLOAD_MAX_SIZE:  # Bytes
                msg = 'Submission aborted! Your file "%s" is too large (max size: %d MB)' % \
1114 1115 1116 1117
                      (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))
                return msg

    return None