You need to sign in or sign up before continuing.
module_render.py 49.4 KB
Newer Older
1 2 3 4 5
"""
Module rendering
"""

import hashlib
6
import json
7
import logging
8
from collections import OrderedDict
9
from functools import partial
10

11 12 13
import dogstats_wrapper as dog_stats_api
import newrelic.agent
from capa.xqueue_interface import XQueueInterface
14
from django.conf import settings
15
from django.contrib.auth.models import User
16
from django.core.cache import cache
17 18
from django.core.context_processors import csrf
from django.core.exceptions import PermissionDenied
19
from django.core.urlresolvers import reverse
20
from django.http import Http404, HttpResponse
21
from django.test.client import RequestFactory
22
from django.views.decorators.csrf import csrf_exempt
23 24 25 26 27 28 29 30 31 32
from edx_proctoring.services import ProctoringService
from eventtracking import tracker
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from requests.auth import HTTPBasicAuth
from xblock.core import XBlock
from xblock.django.request import django_to_webob_request, webob_to_django_response
from xblock.exceptions import NoSuchHandlerError, NoSuchViewError
from xblock.reference.plugins import FSService
33

34
import static_replace
35
from courseware.access import has_access, get_user_role
36 37 38 39 40
from courseware.entrance_exams import (
    get_entrance_exam_score,
    user_must_complete_entrance_exam,
    user_has_passed_entrance_exam
)
41 42 43 44 45 46 47
from courseware.masquerade import (
    MasqueradingKeyValueStore,
    filter_displayed_blocks,
    is_masquerading_as_specific_student,
    setup_masquerade,
)
from courseware.model_data import DjangoKeyValueStore, FieldDataCache, set_score
48
from lms.djangoapps.grades.signals.signals import SCORE_CHANGED
49
from edxmako.shortcuts import render_to_string
50
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
51
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
52
from openedx.core.djangoapps.bookmarks.services import BookmarksService
53
from lms.djangoapps.lms_xblock.runtime import LmsModuleSystem
54
from lms.djangoapps.verify_student.services import VerificationService, ReverificationService
55
from openedx.core.djangoapps.credit.services import CreditService
56
from openedx.core.djangoapps.util.user_utils import SystemUser
57
from openedx.core.lib.xblock_utils import (
58 59 60 61 62
    replace_course_urls,
    replace_jump_to_id_urls,
    replace_static_urls,
    add_staff_markup,
    wrap_xblock,
63
    request_token as xblock_request_token,
64
)
65
from openedx.core.lib.url_utils import unquote_slashes, quote_slashes
66 67
from student.models import anonymous_id_for_user, user_by_anonymous_id
from student.roles import CourseBetaTesterRole
68 69 70 71
from util import milestones_helpers
from util.json_request import JsonResponse
from util.model_utils import slugify
from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip
72
from xblock.runtime import KvsFieldData
73
from xblock_django.user_service import DjangoXBlockUserService
74 75 76
from xmodule.contentstore.django import contentstore
from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
from xmodule.exceptions import NotFoundError, ProcessingError
77
from xmodule.lti_module import LTIModule
78
from xmodule.mixin import wrap_with_license
79
from xmodule.modulestore.django import modulestore
80
from xmodule.modulestore.exceptions import ItemNotFoundError
81
from xmodule.x_module import XModuleDescriptor
82 83
from .field_overrides import OverrideFieldData

84
log = logging.getLogger(__name__)
85

86

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

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

98 99 100
# 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!
101

102

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


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

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

121

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

126
    Return format:
127 128 129 130 131 132
    { 'chapters': [
            {'display_name': name, 'url_name': url_name, 'sections': SECTIONS, 'active': bool},
        ],
        'previous_of_active_section': {..},
        'next_of_active_section': {..}
    }
133

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

138 139 140
    where previous_of_active_section and next_of_active_section have information on the
    next/previous sections of the active section.

141
    active is set for the section and chapter corresponding to the passed
142 143
    parameters, which are expected to be url_names of the chapter+section.
    Everything else comes from the xml, or defaults to "".
144

145
    chapters with name 'hidden' are skipped.
146 147 148

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

150
    field_data_cache must include data from the course module and 2 levels of its descendants
151
    '''
152

153
    with modulestore().bulk_operations(course.id):
154
        course_module = get_module_for_descriptor(
155
            user, request, course, field_data_cache, course.id, course=course
156
        )
157
        if course_module is None:
158
            return None, None, None
159

160 161 162
        toc_chapters = list()
        chapters = course_module.get_display_items()

163 164
        # Check for content which needs to be completed
        # before the rest of the content is made available
165
        required_content = milestones_helpers.get_required_content(course, user)
166

167
        # The user may not actually have to complete the entrance exam, if one is required
168
        if not user_must_complete_entrance_exam(request, user, course):
169 170
            required_content = [content for content in required_content if not content == course.entrance_exam_id]

171 172 173
        previous_of_active_section, next_of_active_section = None, None
        last_processed_section, last_processed_chapter = None, None
        found_active_section = False
174
        for chapter in chapters:
175
            # Only show required content, if there is required content
176
            # chapter.hide_from_toc is read-only (bool)
177
            display_id = slugify(chapter.display_name_with_default_escaped)
178
            local_hide_from_toc = False
179
            if required_content:
180 181 182 183 184
                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:
185 186 187 188
                continue

            sections = list()
            for section in chapter.get_display_items():
189
                # skip the section if it is hidden from the user
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
                if section.hide_from_toc:
                    continue

                is_section_active = (chapter.url_name == active_chapter and section.url_name == active_section)
                if is_section_active:
                    found_active_section = True

                section_context = {
                    'display_name': section.display_name_with_default_escaped,
                    'url_name': section.url_name,
                    'format': section.format if section.format is not None else '',
                    'due': section.due,
                    'active': is_section_active,
                    'graded': section.graded,
                }
                _add_timed_exam_info(user, course, section, section_context)

                # update next and previous of active section, if applicable
                if is_section_active:
                    if last_processed_section:
                        previous_of_active_section = last_processed_section.copy()
                        previous_of_active_section['chapter_url_name'] = last_processed_chapter.url_name
                elif found_active_section and not next_of_active_section:
                    next_of_active_section = section_context.copy()
                    next_of_active_section['chapter_url_name'] = chapter.url_name

                sections.append(section_context)
                last_processed_section = section_context
                last_processed_chapter = chapter
219

220
            toc_chapters.append({
221
                'display_name': chapter.display_name_with_default_escaped,
222
                'display_id': display_id,
223 224 225 226
                'url_name': chapter.url_name,
                'sections': sections,
                'active': chapter.url_name == active_chapter
            })
227 228 229 230 231
        return {
            'chapters': toc_chapters,
            'previous_of_active_section': previous_of_active_section,
            'next_of_active_section': next_of_active_section,
        }
232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285


def _add_timed_exam_info(user, course, section, section_context):
    """
    Add in rendering context if exam is a timed exam (which includes proctored)
    """
    section_is_time_limited = (
        getattr(section, 'is_time_limited', False) and
        settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False)
    )
    if section_is_time_limited:
        # 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
        #
        timed_exam_attempt_context = None
        try:
            timed_exam_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 timed_exam_attempt_context:
            # yes, user has proctoring context about
            # this level of the courseware
            # so add to the accordion data context
            section_context.update({
                'proctoring': timed_exam_attempt_context,
            })
286

287

288
def get_module(user, request, usage_key, field_data_cache,
289
               position=None, log_if_not_found=True, wrap_xmodule_display=True,
290
               grade_bucket_type=None, depth=0,
291
               static_asset_path='', course=None):
292 293
    """
    Get an instance of the xmodule class identified by location,
294 295
    setting the state based on an existing StudentModule, or creating one if none
    exists.
296 297

    Arguments:
298
      - user                  : User for whom we're getting the module
299 300
      - request               : current django HTTPrequest.  Note: request.user isn't used for anything--all auth
                                and such works based on user.
301
      - usage_key             : A UsageKey object identifying the module to load
Calen Pennington committed
302
      - field_data_cache      : a FieldDataCache
303
      - position              : extra information from URL for user-specified
304
                                position within module
305 306 307
      - 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
308 309
      - depth                 : number of levels of descendents to cache when loading this module.
                                None means cache all descendents
310 311 312 313
      - 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.
314

315 316 317 318 319
    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:
320 321
        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
322
                                         position=position,
323
                                         wrap_xmodule_display=wrap_xmodule_display,
324
                                         grade_bucket_type=grade_bucket_type,
325 326
                                         static_asset_path=static_asset_path,
                                         course=course)
327
    except ItemNotFoundError:
328 329
        if log_if_not_found:
            log.debug("Error in get_module: ItemNotFoundError")
330
        return None
331

332 333 334 335
    except:
        # Something has gone terribly wrong, but still not letting it turn into a 500.
        log.exception("Error in get_module")
        return None
336

337

338 339 340 341
def get_xqueue_callback_url_prefix(request):
    """
    Calculates default prefix based on request, but allows override via settings

342 343 344
    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.
345
    """
346
    prefix = '{proto}://{host}'.format(
Adam Palay committed
347 348 349
        proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http'),
        host=request.get_host()
    )
350
    return settings.XQUEUE_INTERFACE.get('callback_url', prefix)
351 352


353
def get_module_for_descriptor(user, request, descriptor, field_data_cache, course_key,
354
                              position=None, wrap_xmodule_display=True, grade_bucket_type=None,
355 356
                              static_asset_path='', disable_staff_debug_info=False,
                              course=None):
357
    """
358 359
    Implements get_module, extracting out the request-specific functionality.

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

362 363 364 365 366
    See get_module() docstring for further details.
    """
    track_function = make_track_function(request)
    xqueue_callback_url_prefix = get_xqueue_callback_url_prefix(request)

367 368
    user_location = getattr(request, 'session', {}).get('country_code')

369 370 371 372 373
    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)

374 375 376
    return get_module_for_descriptor_internal(
        user=user,
        descriptor=descriptor,
377
        student_data=student_data,
378
        course_id=course_key,
379 380 381 382 383 384 385
        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,
386 387
        request_token=xblock_request_token(request),
        disable_staff_debug_info=disable_staff_debug_info,
388
        course=course
389
    )
390 391


392
def get_module_system_for_user(user, student_data,  # TODO  # pylint: disable=too-many-statements
393 394
                               # Arguments preceding this comment have user binding, those following don't
                               descriptor, course_id, track_function, xqueue_callback_url_prefix,
395
                               request_token, position=None, wrap_xmodule_display=True, grade_bucket_type=None,
396 397
                               static_asset_path='', user_location=None, disable_staff_debug_info=False,
                               course=None):
398
    """
399
    Helper function that returns a module system and student_data bound to a user and a descriptor.
400

401 402 403
    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.
404

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

410 411
    Arguments:
        see arguments for get_module()
412
        request_token (str): A token unique to the request use by xblock initialization
413

414 415 416
    Returns:
        (LmsModuleSystem, KvsFieldData):  (module system, student_data) bound to, primarily, the user and descriptor
    """
417

418
    def make_xqueue_callback(dispatch='score_update'):
419 420 421
        """
        Returns fully qualified callback URL for external queueing system
        """
Adam Palay committed
422 423 424
        relative_xqueue_callback_url = reverse(
            'xqueue_callback',
            kwargs=dict(
425
                course_id=course_id.to_deprecated_string(),
Adam Palay committed
426
                userid=str(user.id),
427
                mod_id=descriptor.location.to_deprecated_string(),
Adam Palay committed
428 429 430
                dispatch=dispatch
            ),
        )
431
        return xqueue_callback_url_prefix + relative_xqueue_callback_url
432

433
    # Default queuename is course-specific and is derived from the course that
434 435 436
    #   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
437

438
    xqueue = {
439
        'interface': XQUEUE_INTERFACE,
440 441 442 443
        'construct_callback': make_xqueue_callback,
        'default_queuename': xqueue_default_queuename.replace(' ', '_'),
        'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
    }
444

445
    def inner_get_module(descriptor):
446
        """
447 448 449
        Delegate to get_module_for_descriptor_internal() with all values except `descriptor` set.

        Because it does an access check, it may return None.
450
        """
451 452
        # 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....
453 454 455
        return get_module_for_descriptor_internal(
            user=user,
            descriptor=descriptor,
456
            student_data=student_data,
457 458 459 460 461 462 463 464 465
            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,
466
            course=course
467
        )
468

469
    def _fulfill_content_milestones(user, course_key, content_key):
470 471 472 473 474 475
        """
        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
476
        if milestones_helpers.is_entrance_exams_enabled():
477 478 479 480 481
            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:
482 483 484 485
                # 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)
486
                if exam_pct >= course.entrance_exam_minimum_score_pct:
487
                    exam_key = UsageKey.from_string(course.entrance_exam_id)
488 489
                    relationship_types = milestones_helpers.get_milestone_relationship_types()
                    content_milestones = milestones_helpers.get_course_content_milestones(
490 491 492 493 494
                        course_key,
                        exam_key,
                        relationship=relationship_types['FULFILLS']
                    )
                    # Add each milestone to the user's set...
495
                    user = {'id': request.user.id}
496
                    for milestone in content_milestones:
497
                        milestones_helpers.add_user_milestone(user, milestone)
498 499 500 501 502

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

505 506
        grade = event.get('value')
        max_grade = event.get('max_value')
507

508
        set_score(
509 510 511 512
            user_id,
            descriptor.location,
            grade,
            max_grade,
513
        )
514

Don Mitchell committed
515
        # Bin score into range and increment stats
516
        score_bucket = get_score_bucket(grade, max_grade)
517

Adam Palay committed
518
        tags = [
519 520
            u"org:{}".format(course_id.org),
            u"course:{}".format(course_id),
521
            u"score_bucket:{0}".format(score_bucket)
Adam Palay committed
522
        ]
523

524 525 526
        if grade_bucket_type is not None:
            tags.append('type:%s' % grade_bucket_type)

527
        dog_stats_api.increment("lms.courseware.question_answered", tags=tags)
528

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

537 538 539 540 541 542
        # 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'],
543
            user=user,
544 545 546 547
            course_id=unicode(course_id),
            usage_id=unicode(descriptor.location)
        )

548 549
    def publish(block, event_type, event):
        """A function that allows XModules to publish events."""
550
        if event_type == 'grade' and not is_masquerading_as_specific_student(user, course_id):
551 552
            handle_grade_event(block, event_type, event)
        else:
553 554 555 556 557 558 559 560
            aside_context = {}
            for aside in block.runtime.get_asides(block):
                if hasattr(aside, 'get_event_context'):
                    aside_event_info = aside.get_event_context(event_type, event)
                    if aside_event_info is not None:
                        aside_context[aside.scope_ids.block_type] = aside_event_info
            with tracker.get_tracker().context('asides', {'asides': aside_context}):
                track_function(event_type, event)
561

562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583
    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,
584 585
            module.descriptor,
            asides=XBlockAsidesConfig.possible_asides(),
586
        )
587
        student_data_real_user = KvsFieldData(DjangoKeyValueStore(field_data_cache_real_user))
588 589

        (inner_system, inner_student_data) = get_module_system_for_user(
590
            user=real_user,
591
            student_data=student_data_real_user,  # These have implicit user bindings, rest of args considered not to
592 593 594 595 596 597 598 599 600
            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,
601 602
            request_token=request_token,
            course=course
603
        )
604

605 606
        module.descriptor.bind_for_student(
            inner_system,
607
            real_user.id,
608
            [
609
                partial(OverrideFieldData.wrap, real_user, course),
610 611
                partial(LmsFieldData, student_data=inner_student_data),
            ],
612
        )
613

614
        module.descriptor.scope_ids = (
615
            module.descriptor.scope_ids._replace(user_id=real_user.id)
616
        )
617 618 619 620 621
        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

622 623 624 625
    # 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 = []

626 627 628
    if is_masquerading_as_specific_student(user, course_id):
        block_wrappers.append(filter_displayed_blocks)

629 630 631
    if settings.FEATURES.get("LICENSING", False):
        block_wrappers.append(wrap_with_license)

632 633 634
    # Wrap the output display in a single div to allow for the XModule
    # javascript to be bound correctly
    if wrap_xmodule_display is True:
635
        block_wrappers.append(partial(
636 637
            wrap_xblock,
            'LmsRuntime',
638
            extra_data={'course-id': course_id.to_deprecated_string()},
639 640
            usage_id_serializer=lambda usage_id: quote_slashes(usage_id.to_deprecated_string()),
            request_token=request_token,
641
        ))
642

643 644 645
    # 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
646

647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666
    # 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,
667
        reverse('jump_to_id', kwargs={'course_id': course_id.to_deprecated_string(), 'module_id': ''}),
668 669
    ))

670
    if settings.FEATURES.get('DISPLAY_DEBUG_INFO_TO_STAFF'):
671 672 673 674 675 676 677 678 679 680
        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
681
            instructor_access = bool(has_access(user.real_user, 'instructor', descriptor, course_id))
682 683 684
            user.real_user.masquerade_settings = masquerade_settings
        else:
            staff_access = has_access(user, 'staff', descriptor, course_id)
685
            instructor_access = bool(has_access(user, 'instructor', descriptor, course_id))
686 687
        if staff_access:
            block_wrappers.append(partial(add_staff_markup, user, instructor_access, disable_staff_debug_info))
688

689 690 691 692 693 694
    # 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.
695
    is_pure_xblock = isinstance(descriptor, XBlock) and not isinstance(descriptor, XModuleDescriptor)
696 697
    module_class = getattr(descriptor, 'module_class', None)
    is_lti_module = not is_pure_xblock and issubclass(module_class, LTIModule)
698
    if is_pure_xblock or is_lti_module:
699 700
        anonymous_student_id = anonymous_id_for_user(user, course_id)
    else:
701
        anonymous_student_id = anonymous_id_for_user(user, None)
702

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

705
    user_is_staff = bool(has_access(user, u'staff', descriptor.location, course_id))
706

707
    system = LmsModuleSystem(
Adam Palay committed
708 709
        track_function=track_function,
        render_template=render_to_string,
710
        static_url=settings.STATIC_URL,
Adam Palay committed
711 712
        xqueue=xqueue,
        # TODO (cpennington): Figure out how to share info between systems
713
        filestore=descriptor.runtime.resources_fs,
Adam Palay committed
714 715
        get_module=inner_get_module,
        user=user,
716 717
        debug=settings.DEBUG,
        hostname=settings.SITE_NAME,
Adam Palay committed
718 719 720 721 722 723
        # 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
724
            course_id=course_id,
Calen Pennington committed
725
            static_asset_path=static_asset_path or descriptor.static_asset_path,
Adam Palay committed
726
        ),
727 728
        replace_course_urls=partial(
            static_replace.replace_course_urls,
729
            course_key=course_id
730 731 732 733
        ),
        replace_jump_to_id_urls=partial(
            static_replace.replace_jump_to_id_urls,
            course_id=course_id,
734
            jump_to_id_base_url=reverse('jump_to_id', kwargs={'course_id': course_id.to_deprecated_string(), 'module_id': ''})
735
        ),
Adam Palay committed
736 737
        node_path=settings.NODE_PATH,
        publish=publish,
738
        anonymous_student_id=anonymous_student_id,
Adam Palay committed
739 740 741
        course_id=course_id,
        cache=cache,
        can_execute_unsafe_code=(lambda: can_execute_unsafe_code(course_id)),
742
        get_python_lib_zip=(lambda: get_python_lib_zip(contentstore, course_id)),
Calen Pennington committed
743
        # TODO: When we merge the descriptor and module systems, we can stop reaching into the mixologist (cpennington)
744 745
        mixins=descriptor.runtime.mixologist._mixins,  # pylint: disable=protected-access
        wrappers=block_wrappers,
746
        get_real_user=user_by_anonymous_id,
747
        services={
748
            'fs': FSService(),
749
            'field-data': field_data,
750
            'user': DjangoXBlockUserService(user, user_is_staff=user_is_staff),
751 752
            'verification': VerificationService(),
            'reverification': ReverificationService(),
753
            'proctoring': ProctoringService(),
754
            'milestones': milestones_helpers.get_service(),
755
            'credit': CreditService(),
756
            'bookmarks': BookmarksService(user=user),
757
        },
758
        get_user_role=lambda: get_user_role(user, course_id),
759
        descriptor_runtime=descriptor._runtime,  # pylint: disable=protected-access
760
        rebind_noauth_module_to_user=rebind_noauth_module_to_user,
761
        user_location=user_location,
762
        request_token=request_token,
Adam Palay committed
763
    )
Chris Dodge committed
764

765
    # pass position specified in URL to module through ModuleSystem
766 767 768 769 770 771 772
    if position is not None:
        try:
            position = int(position)
        except (ValueError, TypeError):
            log.exception('Non-integer %r passed as position.', position)
            position = None

773 774
    system.set('position', position)

775
    system.set(u'user_is_staff', user_is_staff)
776
    system.set(u'user_is_admin', bool(has_access(user, u'staff', 'global')))
777
    system.set(u'user_is_beta_tester', CourseBetaTesterRole(course_id).has_user(user))
778
    system.set(u'days_early_for_beta', descriptor.days_early_for_beta)
779

780
    # make an ErrorDescriptor -- assuming that the descriptor's system is ok
781
    if has_access(user, u'staff', descriptor.location, course_id):
782 783 784
        system.error_descriptor_class = ErrorDescriptor
    else:
        system.error_descriptor_class = NonStaffErrorDescriptor
785

786
    return system, field_data
787 788


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

    See get_module() docstring for further details.
800 801 802

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

805
    (system, student_data) = get_module_system_for_user(
806
        user=user,
807
        student_data=student_data,  # These have implicit user bindings, the rest of args are considered not to
808 809 810 811 812 813 814 815 816
        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,
817 818
        request_token=request_token,
        disable_staff_debug_info=disable_staff_debug_info,
819
        course=course
820 821
    )

822 823 824 825
    descriptor.bind_for_student(
        system,
        user.id,
        [
826
            partial(OverrideFieldData.wrap, user, course),
827 828 829 830
            partial(LmsFieldData, student_data=student_data),
        ],
    )

831
    descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id)
832 833 834 835 836

    # 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.
837 838
    user_needs_access_check = getattr(user, 'known', True) and not isinstance(user, SystemUser)
    if user_needs_access_check:
839 840
        if not has_access(user, 'load', descriptor, course_id):
            return None
841
    return descriptor
842

843

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


866
@csrf_exempt
867
def xqueue_callback(request, course_id, userid, mod_id, dispatch):
868
    '''
869
    Entry point for graded results from the queueing system.
870
    '''
871 872
    data = request.POST.copy()

873 874
    # Test xqueue package, which we expect to be:
    #   xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}),
875
    #               'xqueue_body'  : 'Message from grader'}
876
    for key in ['xqueue_header', 'xqueue_body']:
877
        if key not in data:
878
            raise Http404
879 880 881

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

884 885 886 887
    course_key = CourseKey.from_string(course_id)

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

889
        instance = load_single_xblock(request, userid, course_id, mod_id, course=course)
890

891 892 893 894 895 896 897 898 899 900 901 902 903 904
        # 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
905

906
        return HttpResponse("")
907

908

909 910 911 912 913
@csrf_exempt
def handle_xblock_callback_noauth(request, course_id, usage_id, handler, suffix=None):
    """
    Entry point for unauthenticated XBlock handlers.
    """
914 915
    request.user.known = False

916 917 918 919
    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)
920 921


922 923 924
def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
    """
    Generic view for extensions. This is where AJAX calls go.
925 926 927 928

    Arguments:

      - request -- the django request.
929 930
      - location -- the module location. Used to look up the XModule instance
      - course_id -- defines the course context for this request.
931

polesye committed
932
    Return 403 error if the user is not logged in. Raises Http404 if
933 934 935
    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.
936
    """
937
    if not request.user.is_authenticated():
polesye committed
938
        return HttpResponse('Unauthenticated', status=403)
939

940 941 942 943 944 945 946 947 948 949 950 951
    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)
952 953


954
def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_info=False, course=None):
955
    """
956
    Gets a module instance based on its `usage_id` in a course, for a given request/user
957

958
    Returns (instance, tracking_context)
959
    """
960 961
    user = request.user

962 963 964 965
    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:
966 967
        raise Http404("Invalid location")

968
    try:
969
        descriptor = modulestore().get_item(usage_key)
970
        descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
971 972
    except ItemNotFoundError:
        log.warn(
973 974 975
            "Invalid location for course id %s: %s",
            usage_key.course_key,
            usage_key
976 977 978
        )
        raise Http404

979 980
    tracking_context = {
        'module': {
981
            'display_name': descriptor.display_name_with_default_escaped,
982
            'usage_key': unicode(descriptor.location),
983 984
        }
    }
985

986 987 988 989
    # 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)
990

991
    unused_masquerade, user = setup_masquerade(request, course_id, has_access(user, 'staff', descriptor, course_id))
Calen Pennington committed
992
    field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
993
        course_id,
994
        user,
995 996
        descriptor
    )
997 998 999 1000 1001 1002
    instance = get_module_for_descriptor(
        user,
        request,
        descriptor,
        field_data_cache,
        usage_key.course_key,
1003 1004
        disable_staff_debug_info=disable_staff_debug_info,
        course=course
1005
    )
1006 1007 1008
    if instance is None:
        # Either permissions just changed, or someone is trying to be clever
        # and load something they shouldn't have access to.
1009
        log.debug("No module %s for user %s -- access denied?", usage_key, user)
1010
        raise Http404
1011

1012 1013 1014
    return (instance, tracking_context)


1015
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=None):
1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030
    """
    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:
1031
        return JsonResponse({'success': error_msg}, status=413)
1032

1033 1034 1035 1036 1037
    # 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
1038

1039 1040 1041 1042
    # Gather metrics for New Relic so we can slice data in New Relic Insights
    newrelic.agent.add_custom_parameter('course_id', unicode(course_key))
    newrelic.agent.add_custom_parameter('org', unicode(course_key.org))

1043
    with modulestore().bulk_operations(course_key):
1044
        instance, tracking_context = get_module_by_usage_id(request, course_id, usage_id, course=course)
1045

1046 1047 1048 1049
        # 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)
1050
        nr_tx_name += "/{}".format(suffix) if (suffix and handler == "xmodule_handler") else ""
1051
        newrelic.agent.set_transaction_name(nr_tx_name, group="Python/XBlock/Handler")
1052

1053 1054 1055 1056 1057
        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)
1058 1059 1060 1061 1062 1063
                if suffix == 'problem_check' \
                        and course \
                        and getattr(course, 'entrance_exam_enabled', False) \
                        and getattr(instance, 'in_entrance_exam', False):
                    ee_data = {'entrance_exam_passed': user_has_passed_entrance_exam(request, course)}
                    resp = append_data_to_webob_response(resp, ee_data)
1064

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

1069 1070 1071 1072
        # 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
1073

1074 1075 1076 1077
        # 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)
1078
            return JsonResponse({'success': err.args[0]}, status=200)
1079 1080 1081 1082 1083

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

1085
    return webob_to_django_response(resp)
1086

Calen Pennington committed
1087

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
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:
1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128
        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
1129

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

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


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

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


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:
1168
            msg = 'Submission aborted! Maximum %d files may be submitted at once' % \
1169 1170 1171 1172 1173
                  settings.MAX_FILEUPLOADS_PER_INPUT
            return msg

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

    return None
1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198


def append_data_to_webob_response(response, data):
    """
    Appends data to a JSON webob response.

    Arguments:
        response (webob response object):  the webob response object that needs to be modified
        data (dict):  dictionary containing data that needs to be appended to response body

    Returns:
        (webob response object):  webob response with updated body.

    """
    if getattr(response, 'content_type', None) == 'application/json':
        response_data = json.loads(response.body)
        response_data.update(data)
        response.body = json.dumps(response_data)
    return response