module_render.py 21 KB
Newer Older
1
import json
2
import logging
3
import pyparsing
Ned Batchelder committed
4
import re
5
import sys
6
import static_replace
7

8
from functools import partial
9

10
from django.conf import settings
11
from django.contrib.auth.models import User
12
from django.core.cache import cache
13
from django.core.exceptions import PermissionDenied
14
from django.core.urlresolvers import reverse
15
from django.http import Http404
16
from django.http import HttpResponse, HttpResponseBadRequest
kimth committed
17
from django.views.decorators.csrf import csrf_exempt
18

19 20
from requests.auth import HTTPBasicAuth

21
from capa.xqueue_interface import XQueueInterface
22
from courseware.masquerade import setup_masquerade
23
from courseware.access import has_access
24
from mitxmako.shortcuts import render_to_string
25
from .models import StudentModule
26
from psychometrics.psychoanalyze import make_psychometrics_data_update_handler
27
from student.models import unique_id_for_user
28
from xmodule.errortracker import exc_info_to_str
29
from xmodule.exceptions import NotFoundError, ProcessingError
30 31
from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore
32
from xmodule.x_module import ModuleSystem
33
from xmodule.error_module import ErrorDescriptor, NonStaffErrorDescriptor
34
from xblock.runtime import DbModel
35
from xmodule_modifiers import replace_course_urls, replace_static_urls, add_histogram, wrap_xmodule
36
from .model_data import LmsKeyValueStore, LmsUsage, ModelDataCache
37

38
from xmodule.modulestore.exceptions import ItemNotFoundError
39
from statsd import statsd
40

41
log = logging.getLogger(__name__)
42

43

44
if settings.XQUEUE_INTERFACE.get('basic_auth') is not None:
45
    requests_auth = HTTPBasicAuth(*settings.XQUEUE_INTERFACE['basic_auth'])
46 47 48
else:
    requests_auth = None

49 50
xqueue_interface = XQueueInterface(
    settings.XQUEUE_INTERFACE['url'],
51
    settings.XQUEUE_INTERFACE['django_auth'],
52
    requests_auth,
53 54 55
)


56
def make_track_function(request):
57
    '''
58
    Make a tracking function that logs what happened.
59
    For use in ModuleSystem.
Piotr Mitros committed
60
    '''
61 62
    import track.views

63 64 65
    def f(event_type, event):
        return track.views.server_track(request, event_type, event, page='x_module')
    return f
66

67

68
def toc_for_course(user, request, course, active_chapter, active_section, model_data_cache):
69 70
    '''
    Create a table of contents from the module store
71

72
    Return format:
73 74
    [ {'display_name': name, 'url_name': url_name,
       'sections': SECTIONS, 'active': bool}, ... ]
75

76
    where SECTIONS is a list
77
    [ {'display_name': name, 'url_name': url_name,
kimth committed
78
       'format': format, 'due': due, 'active' : bool, 'graded': bool}, ...]
79

80
    active is set for the section and chapter corresponding to the passed
81 82
    parameters, which are expected to be url_names of the chapter+section.
    Everything else comes from the xml, or defaults to "".
83

84
    chapters with name 'hidden' are skipped.
85 86 87

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

    model_data_cache must include data from the course module and 2 levels of its descendents
90
    '''
91

92
    course_module = get_module_for_descriptor(user, request, course, model_data_cache, course.id)
Victor Shnayder committed
93
    if course_module is None:
94
        return None
95

96
    chapters = list()
Victor Shnayder committed
97
    for chapter in course_module.get_display_items():
98
        if chapter.lms.hide_from_toc:
99
            continue
100

101 102
        sections = list()
        for section in chapter.get_display_items():
Matthew Mongeau committed
103

104 105
            active = (chapter.url_name == active_chapter and
                      section.url_name == active_section)
106

107
            if not section.lms.hide_from_toc:
108
                sections.append({'display_name': section.display_name_with_default,
109
                                 'url_name': section.url_name,
110
                                 'format': section.lms.format if section.lms.format is not None else '',
111
                                 'due': section.lms.due,
112
                                 'active': active,
113
                                 'graded': section.lms.graded,
114
                                 })
115

116
        chapters.append({'display_name': chapter.display_name_with_default,
117
                         'url_name': chapter.url_name,
118
                         'sections': sections,
119
                         'active': chapter.url_name == active_chapter})
120
    return chapters
121

122

123
def get_module(user, request, location, model_data_cache, course_id,
124
               position=None, not_found_ok = False, wrap_xmodule_display=True,
125
               grade_bucket_type=None, depth=0):
126 127
    """
    Get an instance of the xmodule class identified by location,
128 129
    setting the state based on an existing StudentModule, or creating one if none
    exists.
130 131

    Arguments:
132
      - user                  : User for whom we're getting the module
133 134
      - request               : current django HTTPrequest.  Note: request.user isn't used for anything--all auth
                                and such works based on user.
135
      - location              : A Location-like object identifying the module to load
136
      - model_data_cache      : a ModelDataCache
137
      - course_id             : the course_id in the context of which to load module
138
      - position              : extra information from URL for user-specified
139
                                position within module
140 141
      - depth                 : number of levels of descendents to cache when loading this module.
                                None means cache all descendents
142

143 144 145 146 147
    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:
148 149
        location = Location(location)
        descriptor = modulestore().get_instance(course_id, location, depth=depth)
150
        return get_module_for_descriptor(user, request, descriptor, model_data_cache, course_id,
Calen Pennington committed
151
                                         position=position,
152 153
                                         wrap_xmodule_display=wrap_xmodule_display,
                                         grade_bucket_type=grade_bucket_type)
154 155 156 157
    except ItemNotFoundError:
        if not not_found_ok:
            log.exception("Error in get_module")
        return None
158 159 160 161
    except:
        # Something has gone terribly wrong, but still not letting it turn into a 500.
        log.exception("Error in get_module")
        return None
162

163

164
def get_module_for_descriptor(user, request, descriptor, model_data_cache, course_id,
165
                position=None, wrap_xmodule_display=True, grade_bucket_type=None):
166 167 168
    """
    Actually implement get_module.  See docstring there for details.
    """
169

170 171 172 173
    # allow course staff to masquerade as student
    if has_access(user, descriptor, 'staff', course_id):
        setup_masquerade(request, True)

174 175 176 177
    # Short circuit--if the user shouldn't have access, bail without doing any work
    if not has_access(user, descriptor, 'load', course_id):
        return None

178
    # Setup system context for module instance
179
    ajax_url = reverse('modx_dispatch',
180
                       kwargs=dict(course_id=course_id,
181
                                   location=descriptor.location.url(),
182 183
                                   dispatch=''),
                       )
184 185
    # Intended use is as {ajax_url}/{dispatch_command}, so get rid of the trailing slash.
    ajax_url = ajax_url.rstrip('/')
186

187
    def make_xqueue_callback(dispatch='score_update'):
188 189 190 191 192
        # Fully qualified callback URL for external queueing system
        xqueue_callback_url = '{proto}://{host}'.format(
            host=request.get_host(),
            proto=request.META.get('HTTP_X_FORWARDED_PROTO', 'https' if request.is_secure() else 'http')
        )
193
        xqueue_callback_url = settings.XQUEUE_INTERFACE.get('callback_url',xqueue_callback_url)	# allow override
194 195 196 197 198 199 200 201

        xqueue_callback_url += reverse('xqueue_callback',
                                      kwargs=dict(course_id=course_id,
                                                  userid=str(user.id),
                                                  id=descriptor.location.url(),
                                                  dispatch=dispatch),
                                      )
        return xqueue_callback_url
202

203
    # Default queuename is course-specific and is derived from the course that
204 205 206
    #   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
207

208
    xqueue = {'interface': xqueue_interface,
209
              'construct_callback': make_xqueue_callback,
kimth committed
210
              'default_queuename': xqueue_default_queuename.replace(' ', '_'),
211
              'waittime': settings.XQUEUE_WAITTIME_BETWEEN_REQUESTS
kimth committed
212
             }
213

214 215 216 217
    #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
218 219

    #Get descriptor metadata fields indicating needs for various settings
220 221
    needs_open_ended_interface = getattr(descriptor, "needs_open_ended_interface", False)
    needs_s3_interface = getattr(descriptor, "needs_s3_interface", False)
222 223

    #Initialize interfaces to None
224 225
    open_ended_grading_interface = None
    s3_interface = None
226 227 228

    #Create interfaces if needed
    if needs_open_ended_interface:
229
        open_ended_grading_interface = settings.OPEN_ENDED_GRADING_INTERFACE
230 231
        open_ended_grading_interface['mock_peer_grading'] = settings.MOCK_PEER_GRADING
        open_ended_grading_interface['mock_staff_grading'] = settings.MOCK_STAFF_GRADING
232 233
    if needs_s3_interface:
        s3_interface = {
Vik Paruchuri committed
234 235 236
            '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')
237
        }
238

239
    def inner_get_module(descriptor):
240 241 242
        """
        Delegate to get_module.  It does an access check, so may return None
        """
243
        return get_module_for_descriptor(user, request, descriptor,
Calen Pennington committed
244
                                         model_data_cache, course_id, position)
245

246
    def xblock_model_data(descriptor):
247
        return DbModel(
248
            LmsKeyValueStore(descriptor._model_data, model_data_cache),
249 250
            descriptor.module_class,
            user.id,
Calen Pennington committed
251
            LmsUsage(descriptor.location, descriptor.location)
252 253
        )

254 255 256 257
    def publish(event):
        if event.get('event_name') != 'grade':
            return

258 259 260 261 262 263
        student_module, created = StudentModule.objects.get_or_create(
            course_id=course_id,
            student=user,
            module_type=descriptor.location.category,
            module_state_key=descriptor.location.url(),
            defaults={'state': '{}'},
264 265 266 267 268
        )
        student_module.grade = event.get('value')
        student_module.max_grade = event.get('max_value')
        student_module.save()

269 270 271 272
        #Bin score into range and increment stats
        score_bucket = get_score_bucket(student_module.grade, student_module.max_grade)
        org, course_num, run = course_id.split("/")

273 274 275 276
        tags = ["org:{0}".format(org),
                "course:{0}".format(course_num),
                "run:{0}".format(run),
                "score_bucket:{0}".format(score_bucket)]
277

278 279 280 281
        if grade_bucket_type is not None:
            tags.append('type:%s' % grade_bucket_type)

        statsd.increment("lms.courseware.question_answered", tags=tags)
282

283 284 285 286 287 288 289 290
    def can_execute_unsafe_code():
        # To decide if we can run unsafe code, we check the course id against
        # a list of regexes configured on the server.
        for regex in settings.COURSES_WITH_UNSAFE_CODE:
            if re.match(regex, course_id):
                return True
        return False

291 292 293
    # 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
294 295 296
    system = ModuleSystem(track_function=make_track_function(request),
                          render_template=render_to_string,
                          ajax_url=ajax_url,
297
                          xqueue=xqueue,
298 299
                          # TODO (cpennington): Figure out how to share info between systems
                          filestore=descriptor.system.resources_fs,
300
                          get_module=inner_get_module,
301 302 303 304
                          user=user,
                          # 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
305
                          replace_urls=partial(
306
                              static_replace.replace_static_urls,
Calen Pennington committed
307
                              data_directory=getattr(descriptor, 'data_dir', None),
308 309
                              course_namespace=descriptor.location._replace(category=None, name=None),
                          ),
310
                          node_path=settings.NODE_PATH,
311
                          xblock_model_data=xblock_model_data,
312
                          publish=publish,
313 314
                          anonymous_student_id=unique_id_for_user(user),
                          course_id=course_id,
315 316
                          open_ended_grading_interface=open_ended_grading_interface,
                          s3_interface=s3_interface,
317
                          cache=cache,
318
                          can_execute_unsafe_code=can_execute_unsafe_code,
319 320
                          )
    # pass position specified in URL to module through ModuleSystem
321
    system.set('position', position)
322
    system.set('DEBUG', settings.DEBUG)
323
    if settings.MITX_FEATURES.get('ENABLE_PSYCHOMETRICS'):
324
        system.set('psychometrics_handler',		# set callback for updating PsychometricsData
325
                   make_psychometrics_data_update_handler(course_id, user, descriptor.location.url()))
326

327
    try:
328
        module = descriptor.xmodule(system)
329 330 331 332
    except:
        log.exception("Error creating module from descriptor {0}".format(descriptor))

        # make an ErrorDescriptor -- assuming that the descriptor's system is ok
Calen Pennington committed
333
        if has_access(user, descriptor.location, 'staff', course_id):
334
            err_descriptor_class = ErrorDescriptor
335
        else:
336 337 338 339 340 341 342
            err_descriptor_class = NonStaffErrorDescriptor

        err_descriptor = err_descriptor_class.from_xml(
            str(descriptor), descriptor.system,
            org=descriptor.location.org, course=descriptor.location.course,
            error_msg=exc_info_to_str(sys.exc_info())
        )
343 344

        # Make an error module
345
        return err_descriptor.xmodule(system)
346

347
    system.set('user_is_staff', has_access(user, descriptor.location, 'staff', course_id))
348 349 350 351 352
    _get_html = module.get_html

    if wrap_xmodule_display == True:
        _get_html = wrap_xmodule(module.get_html, module, 'xmodule_display.html')

353
    module.get_html = replace_static_urls(
354
        _get_html,
355
        getattr(descriptor, 'data_dir', None),
Calen Pennington committed
356
        course_namespace=module.location._replace(category=None, name=None))
357

358 359
    # Allow URLs of the form '/course/' refer to the root of multicourse directory
    #   hierarchy of this course
360
    module.get_html = replace_course_urls(module.get_html, course_id)
361

362
    if settings.MITX_FEATURES.get('DISPLAY_HISTOGRAMS_TO_STAFF'):
363
        if has_access(user, module, 'staff', course_id):
364
            module.get_html = add_histogram(module.get_html, module, user)
365

366 367
    return module

368

369
@csrf_exempt
370
def xqueue_callback(request, course_id, userid, id, dispatch):
371
    '''
372
    Entry point for graded results from the queueing system.
373
    '''
374 375
    # Test xqueue package, which we expect to be:
    #   xpackage = {'xqueue_header': json.dumps({'lms_key':'secretkey',...}),
376
    #               'xqueue_body'  : 'Message from grader'}
377
    get = request.POST.copy()
378 379
    for key in ['xqueue_header', 'xqueue_body']:
        if not get.has_key(key):
380
            raise Http404
381 382
    header = json.loads(get['xqueue_header'])
    if not isinstance(header, dict) or not header.has_key('lms_key'):
383
        raise Http404
384 385

    # Retrieve target StudentModule
386
    user = User.objects.get(id=userid)
387

388
    model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id,
389
        user, modulestore().get_instance(course_id, id), depth=0, select_for_update=True)
390
    instance = get_module(user, request, id, model_data_cache, course_id, grade_bucket_type='xqueue')
391
    if instance is None:
392
        log.debug("No module {0} for user {1}--access denied?".format(id, user))
393 394
        raise Http404

395 396
    # Transfer 'queuekey' from xqueue response header to 'get'. This is required to
    #   use the interface defined by 'handle_ajax'
397
    get.update({'queuekey': header['lms_key']})
398

399 400 401
    # We go through the "AJAX" path
    #   So far, the only dispatch from xqueue will be 'score_update'
    try:
402 403
        # Can ignore the return value--not used for xqueue_callback
        instance.handle_ajax(dispatch, get)
404 405 406 407 408 409
    except:
        log.exception("error processing ajax call")
        raise

    return HttpResponse("")

410

411
def modx_dispatch(request, dispatch, location, course_id):
412 413 414 415 416 417 418 419
    ''' Generic view for extensions. This is where AJAX calls go.

    Arguments:

      - request -- the django request.
      - dispatch -- the command string to pass through to the module's handle_ajax call
           (e.g. 'problem_reset').  If this string contains '?', only pass
           through the part before the first '?'.
420 421
      - location -- the module location. Used to look up the XModule instance
      - course_id -- defines the course context for this request.
422 423 424 425 426

    Raises PermissionDenied if the user is not logged in. Raises Http404 if
    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.
427
    '''
428
    # ''' (fix emacs broken parsing)
429

430 431 432 433
    # Check parameters and fail fast if there's a problem
    if not Location.is_valid(location):
        raise Http404("Invalid location")

434 435 436
    if not request.user.is_authenticated():
        raise PermissionDenied

437
    # Check for submitted files and basic file size checks
438
    p = request.POST.copy()
439
    if request.FILES:
kimth committed
440 441
        for fileinput_id in request.FILES.keys():
            inputfiles = request.FILES.getlist(fileinput_id)
442 443 444 445 446 447

            if len(inputfiles) > settings.MAX_FILEUPLOADS_PER_INPUT:
                too_many_files_msg = 'Submission aborted! Maximum %d files may be submitted at once' %\
                    settings.MAX_FILEUPLOADS_PER_INPUT
                return HttpResponse(json.dumps({'success': too_many_files_msg}))

kimth committed
448
            for inputfile in inputfiles:
Calen Pennington committed
449
                if inputfile.size > settings.STUDENT_FILEUPLOAD_MAX_SIZE:   # Bytes
kimth committed
450
                    file_too_big_msg = 'Submission aborted! Your file "%s" is too large (max size: %d MB)' %\
Calen Pennington committed
451
                                        (inputfile.name, settings.STUDENT_FILEUPLOAD_MAX_SIZE / (1000 ** 2))
kimth committed
452 453
                    return HttpResponse(json.dumps({'success': file_too_big_msg}))
            p[fileinput_id] = inputfiles
454

455 456 457 458 459 460 461 462 463 464 465
    try:
        descriptor = modulestore().get_instance(course_id, location)
    except ItemNotFoundError:
        log.warn(
            "Invalid location for course id {course_id}: {location}".format(
                course_id=course_id,
                location=location
            )
        )
        raise Http404

466
    model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course_id,
467
        request.user, descriptor)
468

469
    instance = get_module(request.user, request, location, model_data_cache, course_id, grade_bucket_type='ajax')
470 471 472
    if instance is None:
        # Either permissions just changed, or someone is trying to be clever
        # and load something they shouldn't have access to.
473
        log.debug("No module {0} for user {1}--access denied?".format(location, request.user))
474
        raise Http404
475

476
    # Let the module handle the AJAX
477
    try:
478
        ajax_return = instance.handle_ajax(dispatch, p)
479 480

    # If we can't find the module, respond with a 404
481 482 483
    except NotFoundError:
        log.exception("Module indicating to user that request doesn't exist")
        raise Http404
484

485
    # For XModule-specific errors, we respond with 400
486
    except ProcessingError:
487 488
        log.warning("Module encountered an error while prcessing AJAX call",
                    exc_info=True)
489
        return HttpResponseBadRequest()
490 491

    # If any other error occurred, re-raise it to trigger a 500 response
492 493 494
    except:
        log.exception("error processing ajax call")
        raise
495

496 497
    # Return whatever the module wanted to return to the client/caller
    return HttpResponse(ajax_return)
498

Calen Pennington committed
499

500

Calen Pennington committed
501
def get_score_bucket(grade, max_grade):
Vik Paruchuri committed
502 503 504 505
    """
    Function to split arbitrary score ranges into 3 buckets.
    Used with statsd tracking.
    """
Calen Pennington committed
506 507 508 509 510
    score_bucket = "incorrect"
    if(grade > 0 and grade < max_grade):
        score_bucket = "partial"
    elif(grade == max_grade):
        score_bucket = "correct"
511 512

    return score_bucket